fix: select actionable Pi permission cards after setup approval

This commit is contained in:
Dotta committed 2026-10-04 21:14:20 -05:00
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.
+14 -5
View File
@@ -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"));
+5 -1
View File
@@ -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) {
+7 -2
View File
@@ -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);
+16 -3
View File
@@ -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));