mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix: select actionable Pi permission cards after setup approval
This commit is contained in:
1 parent
e22a8dfb72
commit
14c48ff408
5 files changed
+54
-12
No files matched your search
@@ -70,13 +70,24 @@ read through the public operator API after observer arming. It retains the
|
||||
completed read, resolution and unchanged file hash. All tested write permissions,
|
||||
Stop/steering/denial assertions and retirement/no-effect proofs remain required.
|
||||
Controls definitions advance to 7 and native definitions to 5. Prior attempt
|
||||
fingerprints and grades remain unchanged. All 110 targeted native Linux tests
|
||||
fingerprints and grades remain unchanged. All 112 targeted native Linux tests
|
||||
pass, including positive Stop/steering settlement and negative setup approvals.
|
||||
The browser selectors require exactly one visible card with a pending decline
|
||||
button, allowing the resolved setup-read receipt to remain in the transcript.
|
||||
Two actionable cards still fail before any control or denial is sent; the
|
||||
public native request and exact browser POST checks remain required.
|
||||
The local pure oracle tests pass; the local ownership flows still fail their
|
||||
unchanged file-watch completeness gate. Local Product typecheck is blocked by
|
||||
stale dependency declarations in the existing linked build. Fresh CI must verify
|
||||
the committed head. Live proof on the new definitions is still required.
|
||||
|
||||
Head `e22a8dfb7` completes 50 successful checks and two skips, while the browser
|
||||
aggregate/shard and Greptile checks fail. Greptile identifies the resolved setup
|
||||
card count corrected above. The separate agent-run retry feedback test passes
|
||||
unchanged in a retained credential-free native Linux browser trace against the
|
||||
installed frozen server. Its CI failure remains preserved; this diagnostic does
|
||||
not establish its cause or make the failed CI check pass.
|
||||
|
||||
Private receipts retain credential and provisional spend checks. Paid dispatch
|
||||
requires fresh checks under the approved bounded execution phase. No paid
|
||||
provider attempt occurs during this resumed preparation.
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("@playwright/test", () => ({ expect: (actual: any, message?: string) =>
|
||||
toHaveCount: async (value: number) => { if (typeof actual.count === "function") expect(actual.count()).toBe(value); },
|
||||
}) }));
|
||||
|
||||
async function exercise(taskId: string, remote: boolean, failure?: "mutation" | "incomplete" | "missing-ack" | "missing-comment" | "foreign-comment" | "foreign-queued-body" | "steer-rejected") {
|
||||
async function exercise(taskId: string, remote: boolean, failure?: "mutation" | "incomplete" | "missing-ack" | "missing-comment" | "foreign-comment" | "foreign-queued-body" | "steer-rejected" | "duplicate-permission") {
|
||||
harness.target = ""; harness.prompt = ""; harness.message = ""; harness.mutation = failure === "mutation"; harness.incomplete = failure === "incomplete";
|
||||
const task = piControlTasks.find(t => t.id === taskId)!, stopCase = taskId === "pending-permission-stop";
|
||||
const workspacePath = await mkdtemp(join(tmpdir(), "pi-controls-fixture-"));
|
||||
@@ -85,9 +85,15 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" |
|
||||
const waiter = waiters.find(w => w.predicate(request)); if (!waiter) throw new Error("Browser request was not awaited");
|
||||
waiter.resolve(request); waiters.splice(waiters.indexOf(waiter), 1);
|
||||
}
|
||||
const locator = (kind = "other"): any => ({
|
||||
filter: () => locator(kind), last: () => locator(kind), getByText: () => locator(),
|
||||
count: () => kind === "loading" ? 0 : kind === "card" ? 1 : kind === "deny" ? (f.run.status === "running" ? 1 : 0) : 1,
|
||||
const locator = (kind = "other", actionable = false): any => ({
|
||||
filter: (options: { has?: { kind: string } }) => {
|
||||
if (kind === "card" && options.has) expect(options.has.kind).toBe("deny");
|
||||
return locator(kind, actionable || Boolean(options.has));
|
||||
}, last: () => locator(kind, actionable), getByText: () => locator(),
|
||||
kind,
|
||||
// Model the production transcript: the resolved setup card stays visible
|
||||
// beside the pending write, and has no actionable decision buttons.
|
||||
count: () => kind === "loading" ? 0 : kind === "card" ? (actionable ? (failure === "duplicate-permission" ? 2 : 1) : (remote ? 2 : 1)) : kind === "deny" ? (f.run.status === "running" ? 1 : 0) : 1,
|
||||
getByRole: (_role: string, options: { name: string }) => locator(options.name === "Deny" ? "deny" : "other"),
|
||||
getByTestId: () => locator(),
|
||||
click: async () => {
|
||||
@@ -106,6 +112,7 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" |
|
||||
},
|
||||
});
|
||||
const page = { goto: async () => {}, reload: async () => {}, getByText: () => locator(),
|
||||
getByRole: (_role: string, options: { name: string }) => locator(options.name === "Deny" ? "deny" : "other"),
|
||||
submitReply: (body: string) => {
|
||||
// Match the production editor's retained Markdown escaping. Matching the
|
||||
// plain input would never observe this otherwise valid queued comment.
|
||||
@@ -137,7 +144,8 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" |
|
||||
execution: { task, suite: { id: "pi-controls" }, environment: { id: remote ? "daytona" : "local" }, profile: { qualificationCandidate: "pi" } }, workspacePath, nonce: "fixture", deadlineAt: Date.now() + 1000,
|
||||
observe: () => {}, capture: async () => {}, evidence: async (name: string, value: unknown) => saved.set(name, structuredClone(value)), remoteBootstrap,
|
||||
registerCleanupAssertion: (fn: () => Promise<any>) => localCleanup.push(fn), registerBeforeEnvironmentTeardownAssertion: (fn: () => Promise<any>) => remoteCleanup.push(fn) } as any);
|
||||
if (failure === "missing-ack") { await expect(call).rejects.toThrow("acknowledgement"); expect(browserDeclines).toBe(0); }
|
||||
if (failure === "duplicate-permission") { await expect(call).rejects.toThrow(); expect(stops).toBe(0); expect(browserDeclines).toBe(0); expect(browserSteers).toBe(0); }
|
||||
else if (failure === "missing-ack") { await expect(call).rejects.toThrow("acknowledgement"); expect(browserDeclines).toBe(0); }
|
||||
else if (failure === "steer-rejected") { await expect(call).rejects.toThrow("steering rejected: HTTP 409"); expect(browserDeclines).toBe(0); }
|
||||
else if (failure === "foreign-queued-body") { await expect(call).rejects.toThrow("browser comment queued"); expect(browserSteers).toBe(0); expect(browserDeclines).toBe(0); }
|
||||
else if (remote && (failure === "mutation" || failure === "incomplete")) await expect(call).rejects.toThrow();
|
||||
@@ -181,3 +189,4 @@ it("never denies the native write before the steering acknowledgement exists", (
|
||||
it("rejects a queued body that differs from the exact browser submission", () => exercise("same-turn-steering", false, "foreign-queued-body"));
|
||||
it("stops before denial when the real steering API rejects the request", () => exercise("same-turn-steering", false, "steer-rejected"));
|
||||
it.each(["missing-comment", "foreign-comment"] as const)("retains and rejects %s during cleanup without fabricating persisted output", failure => exercise("same-turn-steering", false, failure));
|
||||
it("refuses control when two actionable permission cards remain beside resolved setup history", () => exercise("pending-permission-stop", true, "duplicate-permission"));
|
||||
@@ -160,7 +160,11 @@ export async function runPiControlsFlow(input: {
|
||||
load: async () => { const state = await load(); if (["failed", "timed_out", "cancelled", "succeeded"].includes(state.run.status)) throw new Error("Stopped waiting for Pi unanswered native write: no active pending permission"); return state; },
|
||||
accept: state => { observePiControlPending({ ...state, scope: scope() }); return true; } });
|
||||
await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`);
|
||||
const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true });
|
||||
// A resolved bootstrap read remains visible in the transcript. Only the
|
||||
// pending permission has decision buttons; the public event oracle and
|
||||
// browser POST below still bind the decision to the exact write request.
|
||||
const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true })
|
||||
.filter({ has: page.getByRole("button", { name: "Deny", exact: true }) });
|
||||
await expect(card).toHaveCount(1); await expect(card.getByRole("button", { name: "Deny", exact: true })).toBeEnabled();
|
||||
await input.capture("pending-permission", "Pi write waits for a human decision", "pending-permission.png"); await sample("pending");
|
||||
if (stopCase) {
|
||||
|
||||
@@ -215,11 +215,16 @@ export async function runPiNativeFlow(input: {
|
||||
await page.reload();
|
||||
const before = await events(identity.runId);
|
||||
check("human-permission-reconnect", piPermissionRequests(before, identity.runId).some(value => value.request.requestId === identity.requestId) && !before.some(row => row.payload?.prpEvent?.payload?.requestId === identity.requestId && ["runtime_request.resolved", "runtime_request.expired", "runtime_request.cancelled"].includes(row.eventType)), "Reload retained the exact unanswered native permission");
|
||||
const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true }); await expect(card).toHaveCount(1);
|
||||
const declineLabel = native.request.choices.find((choice: Row) => choice.key === "decline").label;
|
||||
// Resolved setup-read receipts remain visible but have no decision
|
||||
// buttons. Require one actionable card and verify its exact POST below.
|
||||
const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true })
|
||||
.filter({ has: page.getByRole("button", { name: declineLabel, exact: true }) });
|
||||
await expect(card).toHaveCount(1);
|
||||
await input.capture("pi-human-denial", "Pi native permission awaiting browser denial", "pi-human-denial.png");
|
||||
const route = `/api/heartbeat-runs/${identity.runId}/runtime-requests/${encodeURIComponent(identity.requestId)}/resolve`;
|
||||
const posted = page.waitForRequest(request => new URL(request.url()).pathname === route && request.method() === "POST");
|
||||
await card.getByRole("button", { name: native.request.choices.find((choice: Row) => choice.key === "decline").label, exact: true }).click();
|
||||
await card.getByRole("button", { name: declineLabel, exact: true }).click();
|
||||
const response = (await posted).postDataJSON();
|
||||
check("human-exact-browser-decline", response.turnId === identity.turnId && response.requestKind === "permission_approval" && response.resolution?.action === "decline", "Actual browser POST declines this run, turn and request");
|
||||
const final = await settle(1), runEvents = await events(identity.runId);
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock("@playwright/test", () => ({ expect: (actual: any, message?: string) =>
|
||||
toBe: (value: unknown) => expect(actual, message).toBe(value), toBeVisible: async () => {}, toHaveCount: async (value: number) => expect(actual.count).toBe(value),
|
||||
}) }));
|
||||
const wrap = (eventType: string, seq: number, payload: unknown) => ({ runId: "run", protocolSchemaVersion: 1, eventType, seq, payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn", eventType, payload } } });
|
||||
async function exercise(mutation: boolean, remote = false, adapter = "acpx-runtime", incomplete = false) {
|
||||
async function exercise(mutation: boolean, remote = false, adapter = "acpx-runtime", incomplete = false, duplicatePermission = false) {
|
||||
proof.mutation = mutation; proof.incomplete = incomplete; proof.live = false; proof.target = "pi-human-denied.txt"; proof.prompt = "";
|
||||
const workspacePath = await mkdtemp(join(tmpdir(), "pi-human-fixture-"));
|
||||
let declined = false, browserPosts = 0; const saved = new Map<string, any>(); const cleanup: Array<() => Promise<any>> = [];
|
||||
@@ -45,15 +45,27 @@ async function exercise(mutation: boolean, remote = false, adapter = "acpx-runti
|
||||
};
|
||||
const browserRequest = { url: () => "http://fixture/api/heartbeat-runs/run/runtime-requests/request/resolve", method: () => "POST", postDataJSON: () => ({ turnId: "turn", requestKind: "permission_approval", resolution: { action: "decline" } }) };
|
||||
let resolvePost: ((value: typeof browserRequest) => void) | undefined;
|
||||
const card = (actionable = false): any => ({
|
||||
filter: (options: { has?: { name: string } }) => {
|
||||
if (options.has) expect(options.has.name).toBe("Decline");
|
||||
return card(actionable || Boolean(options.has));
|
||||
},
|
||||
// The resolved bootstrap receipt is visible but cannot be declined again.
|
||||
count: actionable ? (duplicatePermission ? 2 : 1) : (remote ? 2 : 1),
|
||||
getByRole: (_role: string, options: { name: string }) => ({ click: async () => { expect(options.name).toBe("Decline"); browserPosts++; declined = true; resolvePost!(browserRequest); } }),
|
||||
});
|
||||
const page = { goto: async () => {}, reload: async () => {}, waitForRequest: (predicate: (v: typeof browserRequest) => boolean) => new Promise(resolve => { expect(predicate(browserRequest)).toBe(true); resolvePost = resolve; }),
|
||||
getByTestId: (id: string) => id === "issue-detail-header" ? { getByRole: () => ({}) } : { filter: () => ({ count: 1, getByRole: (_role: string, options: { name: string }) => ({ click: async () => { expect(options.name).toBe("Decline"); browserPosts++; declined = true; resolvePost!(browserRequest); } }) }) } };
|
||||
getByRole: (_role: string, options: { name: string }) => ({ name: options.name }),
|
||||
getByTestId: (id: string) => id === "issue-detail-header" ? { getByRole: () => ({}) } : card() };
|
||||
const nativeRoot = { pid: 456, ppid: 1, startTicks: "123", bootId: "remote-boot" };
|
||||
const snapshot = () => ({ observedAtMs: declined ? 2 : 1, complete: true, workspace: {}, targets: { "pi-human-denied.txt": { absent: true, sha256: null, parent: { dev: "1", ino: "2" }, mutationCount: mutation ? 1 : 0, complete: true } }, watcher: { complete: true, targetMutationCount: mutation ? 1 : 0, workspaceMutationCount: 0 }, processes: { captured: true, root: nativeRoot, journal: [nativeRoot], live: declined ? [] : [456] } });
|
||||
const fixture = { binding: { runId: "run" }, remoteCwd: "/remote", outsideTarget: null, snapshot: async () => snapshot(), finish: async () => snapshot(), close: async () => {} };
|
||||
const remoteBootstrap = remote ? { prompt: () => "Read bootstrap only", bindAndRelease: async (input: any) => { expect(input.targets).toEqual(["pi-human-denied.txt"]); expect(await input.actionPrompt(fixture)).toContain("native write exactly once"); return fixture; } } : undefined;
|
||||
try {
|
||||
if (remote) await writeFile(join(workspacePath, "pi-human-denied.txt"), "POISON HOST COPYBACK");
|
||||
const result = await runPiNativeFlow({ page, api, fixtures: { company: { id: "company", issuePrefix: "PI" }, agent: { id: "agent", name: "Pi" }, environment: { id: "environment" } }, execution: { task, environment: { id: remote ? "daytona" : "local" }, profile: { qualificationCandidate: "pi" } }, workspacePath, nonce: "fixture", deadlineAt: Date.now() + 2000, restart: async () => {}, observe: () => {}, capture: async () => {}, evidence: async (name: string, value: unknown) => { saved.set(name, value); }, remoteBootstrap, registerCleanupAssertion: (fn: () => Promise<any>) => cleanup.push(fn) } as any);
|
||||
const call = runPiNativeFlow({ page, api, fixtures: { company: { id: "company", issuePrefix: "PI" }, agent: { id: "agent", name: "Pi" }, environment: { id: "environment" } }, execution: { task, environment: { id: remote ? "daytona" : "local" }, profile: { qualificationCandidate: "pi" } }, workspacePath, nonce: "fixture", deadlineAt: Date.now() + 2000, restart: async () => {}, observe: () => {}, capture: async () => {}, evidence: async (name: string, value: unknown) => { saved.set(name, value); }, remoteBootstrap, registerCleanupAssertion: (fn: () => Promise<any>) => cleanup.push(fn) } as any);
|
||||
if (duplicatePermission) { await expect(call).rejects.toThrow(); expect(browserPosts).toBe(0); return; }
|
||||
const result = await call;
|
||||
if (!remote) { expect(proof.target).toMatch(/^pc-denied-[a-zA-Z0-9]+\/pi-human-denied\.txt$/); expect(proof.prompt).toContain(`relative path ${proof.target} and content forbidden`); }
|
||||
expect(browserPosts).toBe(1); expect(result.checks.every(check => check.passed)).toBe(true); expect(saved.has("api-state.json")).toBe(true);
|
||||
expect(cleanup).toHaveLength(1);
|
||||
@@ -70,3 +82,4 @@ it("proves browser denial against remote watcher and retirement without trusting
|
||||
it.each([false, true])("drives sidecar permission denial with remote=%s", async remote => exercise(false, remote, "acpx-runtime-sidecar"));
|
||||
|
||||
it("rejects incomplete local observation even with zero target mutations", async () => exercise(false, false, "acpx-runtime", true));
|
||||
it("refuses browser denial when two actionable cards remain beside resolved setup history", async () => exercise(false, true, "acpx-runtime", false, true));
|
||||
Reference in new issue
Block a user