mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
Bind Pi steering to the submitted browser comment
Preserve exact queue authority through editor Markdown serialization and bound installer scratch capacity for Pi assembly. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
03dd6ef933
commit
2a6107c049
8 files changed
+62
-13
No files matched your search
@@ -19,11 +19,11 @@ qualification. The existing draft stack must be reviewed in dependency order.
|
||||
| Gate | Acceptance evidence | Current result |
|
||||
| --- | --- | --- |
|
||||
| Runtime identity and admission | Exact runtime/profile/model; verified effective thinking; startup below the 60-second admission limit; unsupported configuration fails before a prompt | Deterministic checks and ARM installed startup pass. Recheck final source on all supported hosts. |
|
||||
| Live lifecycle | Pending native question survives controller restart in the original process; one creation and resolution; stale answer after provider death rejected; Stop retires owned processes; three warm turns keep process identity | Installed local restart and three-turn warm continuity pass at `f5f57e380`. Provider-death and Stop remain required. |
|
||||
| Live lifecycle | Pending native question survives controller restart in the original process; one creation and resolution; stale answer after provider death rejected; Stop retires owned processes; three warm turns keep process identity | Installed local restart and three-turn warm continuity pass at `f5f57e380`. Pending-permission Stop and stale response rejection pass at `03dd6ef93`. Provider-death evidence remains required. |
|
||||
| Product workflows | All 26 explicit Pi Product E2E cells (13 local, 13 Daytona); screenshots, public state, independent artifacts, terminal and cleanup evidence | Historical partial passes exist. Full final-source campaign remains required. |
|
||||
| Runner protocol | Pi roster passes through the native packaged runner and authenticated mock control plane; lifecycle/denial/control cases remain distinct from Product tests | Historical get-task-context pass. Final-source roster remains required. |
|
||||
| Runner protocol | Pi roster passes through the native packaged runner and authenticated mock control plane; lifecycle/denial/control cases remain distinct from Product tests | Installed get-task-context passes at `f5f57e380`; context-before-action times out. Five cells remain unexecuted. |
|
||||
| Installed distribution | Public CLI/server tars on ARM Mac, Intel Mac and Linux; normal Pi setup; exact Linux companion and immutable Daytona image imported without binary override | Fresh ARM public installation and normal packaged no-key probe pass. The Linux fleet job is queued. Final-source Intel/Linux/Daytona receipts remain required. |
|
||||
| Governance and spend | Company isolation, human-only permission, duplicate/stale answers, Stop and budget hard stop; pricing estimates never become claimed bills | 58 focused governance/cost/session invariants pass at `f5f57e380`. Live Stop remains required. The qualification key cap is not proof of Paperclip budget enforcement. |
|
||||
| Governance and spend | Company isolation, human-only permission, duplicate/stale answers, Stop and budget hard stop; pricing estimates never become claimed bills | 58 focused governance/cost/session invariants pass at `f5f57e380`. Live pending-permission Stop passes at `03dd6ef93`. The qualification key cap is not proof of Paperclip budget enforcement. |
|
||||
| Integration and rollout | Review each prerequisite; final-head typecheck, tests, build and CI pass; no unresolved review; exact artifacts; rollback recorded | Draft #14956 is stacked on #14924. Prerequisite PRs #14921–#14924 remain open. No merge or release. |
|
||||
|
||||
## Bounded execution
|
||||
@@ -78,6 +78,20 @@ prices are estimates. Unpriced usage must stay unpriced in the ledger and UI.
|
||||
credentials, prompts, binary override or borrowed workspace package.
|
||||
- Draft #14956 at `2b3d7ff0a` has a fresh Greptile 5/5, the Linux finding is
|
||||
resolved, and its CI checks pass. Subsequent changes require a fresh review.
|
||||
- At `03dd6ef93`, fresh pending-permission Stop passes: the original callback is
|
||||
cancelled, a stale decline is rejected, the owned processes retire and the
|
||||
continuous watcher records no file effect. The subsequent steering attempt
|
||||
retains its failed grade. Its browser successfully posts one queued comment,
|
||||
but the oracle compares plain input with the editor's Markdown-escaped body
|
||||
and never clicks Steer. The correction binds to the exact browser POST body;
|
||||
67 calibrations include escaped content and rejection of an altered queue.
|
||||
- The `03dd6ef93` full build passes. The broad unit run reports 14,984 passes
|
||||
and one HTTP socket failure; all 12 tests in that unchanged suite pass in
|
||||
isolation. Fresh review is 5/5. CI's public installer reaches Pi setup but
|
||||
exhausts its 256 MiB scratch mount. Pi assembly now gets at most 2048 MiB,
|
||||
retaining the same unprivileged, read-only sandbox and 3 GiB memory limit.
|
||||
All seven sandbox checks pass. CI's separate legacy signoff browser failure
|
||||
received one diagnostic shard rerun; it is not counted as passing yet.
|
||||
- Earlier evidence in `doc/architecture/runner-pi-capabilities.md` is historical
|
||||
and must not be counted as qualification of a new source revision.
|
||||
|
||||
|
||||
@@ -38,6 +38,18 @@ test('only the scripts-disabled dependency download gets network access', () =>
|
||||
assert.ok(values(args, '--env').includes('npm_config_ignore_scripts=true'));
|
||||
});
|
||||
|
||||
test('Pi assembly gets bounded scratch capacity while preserving sandbox restrictions', () => {
|
||||
const args = grokConsumerDockerArgs({ ...paths, download: true, temporarySizeMiB: 2048, command: ['node', '/consumer/node_modules/paperclipai/dist/index.js', 'runtime', 'setup', 'pi'] });
|
||||
assert.deepEqual(values(args, '--tmpfs'), ['/tmp:rw,nosuid,nodev,size=2048m,mode=1777']);
|
||||
assert.deepEqual(values(args, '--memory'), ['3g']);
|
||||
assert.ok(args.includes('--read-only'));
|
||||
assert.deepEqual(values(args, '--cap-drop'), ['ALL']);
|
||||
assert.deepEqual(values(args, '--security-opt'), ['no-new-privileges']);
|
||||
for (const temporarySizeMiB of [0, 255, 2049, Infinity, '2048']) {
|
||||
assert.throws(() => grokConsumerDockerArgs({ ...paths, temporarySizeMiB, command: ['node'] }), /bounded/);
|
||||
}
|
||||
});
|
||||
|
||||
test('the separately provisioned executable is exposed read-only to the offline probe', () => {
|
||||
const prerequisite = '/private/staging/native/grok';
|
||||
const args = grokConsumerDockerArgs({ ...paths, prerequisite, command: ['node', '/packages/probe.mjs', 'present'] });
|
||||
|
||||
@@ -7,17 +7,20 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [
|
||||
'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts',
|
||||
];
|
||||
|
||||
export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite }) {
|
||||
export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMiB = 256 }) {
|
||||
if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) {
|
||||
throw new Error('Public-install verification requires an unprivileged host user');
|
||||
}
|
||||
if (!Number.isSafeInteger(temporarySizeMiB) || temporarySizeMiB < 256 || temporarySizeMiB > 2048) {
|
||||
throw new Error('Public-install temporary storage must be bounded between 256 and 2048 MiB');
|
||||
}
|
||||
return [
|
||||
'run', '--rm', '--platform', 'linux/amd64',
|
||||
'--user', `${uid}:${gid}`, '--read-only',
|
||||
'--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
||||
'--pids-limit', '256', '--memory', '3g',
|
||||
'--network', download ? 'bridge' : 'none',
|
||||
'--tmpfs', '/tmp:rw,nosuid,nodev,size=256m,mode=1777',
|
||||
'--tmpfs', `/tmp:rw,nosuid,nodev,size=${temporarySizeMiB}m,mode=1777`,
|
||||
'--env', 'HOME=/tmp', '--env', 'npm_config_cache=/cache',
|
||||
'--env', 'npm_config_nodedir=/usr/local',
|
||||
'--env', 'npm_config_audit=false', '--env', 'npm_config_fund=false',
|
||||
|
||||
@@ -121,7 +121,10 @@ try {
|
||||
// isolated; the actual admission probe runs without a network or credentials.
|
||||
const piProbe = join(assets, 'pi-public-install-probe.mjs');
|
||||
cpSync(join(repo, 'scripts/pi-public-install-probe.mjs'), piProbe); chmodSync(piProbe, 0o644);
|
||||
const setup = isolated(['node', '/consumer/node_modules/paperclipai/dist/index.js', 'runtime', 'setup', 'pi'], { download: true }).toString();
|
||||
// Pi assembles a bundled runtime in scratch space before atomic publication.
|
||||
// Keep the existing sandbox and memory bound; only this download needs more
|
||||
// temporary capacity than the smaller offline lifecycle probes.
|
||||
const setup = isolated(['node', '/consumer/node_modules/paperclipai/dist/index.js', 'runtime', 'setup', 'pi'], { download: true, temporarySizeMiB: 2048 }).toString();
|
||||
const receipt = JSON.parse(setup.trim());
|
||||
assert.equal(receipt.status, 'installed_verified');
|
||||
assert.equal(receipt.target, 'linux-x64');
|
||||
|
||||
@@ -437,7 +437,8 @@ Only after cancellation does it attempt a stale **decline**, which must return
|
||||
cancelled run and no automatic continuation.
|
||||
|
||||
Steering submits a random marker only in a browser comment after the permission
|
||||
is pending, then clicks that comment's production Steer button. It records the
|
||||
is pending, binds the queued comment to the exact body submitted by the
|
||||
production Markdown editor, then clicks that comment's production Steer button. It records the
|
||||
exact public POST's queue/revision/run binding and requires the saved run
|
||||
acknowledgment plus the Product facade's same-turn acknowledgment item. The raw
|
||||
Rust `acpx-control-*` transport echo is suppressed by the facade;
|
||||
|
||||
@@ -1090,8 +1090,9 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
|
||||
description: "Pending native-write Stop and browser-originated same-turn steering, with exact control receipts and independent retirement/no-effect evidence.",
|
||||
groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "pi"),
|
||||
environments: runnerEnvironments, tasks: piControlTasks, expectedMatrixSize: 4,
|
||||
definitionMetadata: { version: 2, qualification: "pending", scheduling: "explicit-only", profileVersion: QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion,
|
||||
definitionMetadata: { version: 3, qualification: "pending", scheduling: "explicit-only", profileVersion: QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion,
|
||||
nativeArguments: "streamed-until-exact-target",
|
||||
steeringComment: "exact-browser-submitted-markdown",
|
||||
pending: "paperclip.e2e.pi-control-pending.v1", stop: "paperclip.e2e.pi-stop-settlement.v1", steering: "paperclip.e2e.pi-steering-settlement.v1",
|
||||
permissionPolicy: "approve-reads", lifecycle: "per_turn", normalCompletionProvesStop: false, nativeFollowUp: "not-covered", providerDeath: "not-covered",
|
||||
remoteObservationCoverage: "continuous-through-owned-process-retirement", filesystemAfterRemoteRetirementObserved: false },
|
||||
|
||||
@@ -11,7 +11,7 @@ import { readPiSteeringSettlement } from "./pi-controls-evidence.js";
|
||||
const harness = vi.hoisted(() => ({ target: "", prompt: "", message: "", mutation: false, incomplete: false }));
|
||||
vi.mock("./user-actions.js", () => ({
|
||||
createTaskThroughUi: async (input: { prompt: string }) => { harness.prompt = input.prompt; },
|
||||
submitTaskReply: async (_page: unknown, body: string) => { harness.message = body; return Date.now(); },
|
||||
submitTaskReply: async (page: { submitReply(body: string): void }, body: string) => { page.submitReply(body); return Date.now(); },
|
||||
}));
|
||||
vi.mock("./copilot-local-fixtures.js", async importOriginal => {
|
||||
const actual = await importOriginal<typeof import("./copilot-local-fixtures.js")>();
|
||||
@@ -26,7 +26,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") {
|
||||
async function exercise(taskId: string, remote: boolean, failure?: "mutation" | "incomplete" | "missing-ack" | "missing-comment" | "foreign-comment" | "foreign-queued-body") {
|
||||
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-"));
|
||||
@@ -39,7 +39,7 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" |
|
||||
const target = remote ? "pi-control-fixture.txt" : harness.target;
|
||||
f.scope.target = target; f.tool.target = target; f.issue.title = task.buildTitle("fixture");
|
||||
};
|
||||
const queue = () => ({ queueId: "queue", targetRunId: "run", revision: "revision", protocol: "paperclip_runner_v1", steeringDisposition: "available", entries: harness.message ? [{ comment: { id: "comment", body: harness.message } }] : [] });
|
||||
const queue = () => ({ queueId: "queue", targetRunId: "run", revision: "revision", protocol: "paperclip_runner_v1", steeringDisposition: "available", entries: harness.message ? [{ comment: { id: "comment", body: harness.message + (failure === "foreign-queued-body" ? " altered" : "") } }] : [] });
|
||||
const api = {
|
||||
post: async (path: string, body: any) => {
|
||||
if (path.endsWith("/projects")) return { name: "Pi controls fixture" };
|
||||
@@ -102,6 +102,13 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" |
|
||||
},
|
||||
});
|
||||
const page = { goto: async () => {}, reload: async () => {}, getByText: () => locator(),
|
||||
submitReply: (body: string) => {
|
||||
// Match the production editor's retained Markdown escaping. Matching the
|
||||
// plain input would never observe this otherwise valid queued comment.
|
||||
harness.message = body.replaceAll("_", "\\\\_").replaceAll("[]", "\\\\[]");
|
||||
expect(harness.message).not.toBe(body);
|
||||
postBrowser("/api/issues/issue/comments", { body: harness.message });
|
||||
},
|
||||
getByTestId: (id: string) => locator(id === "task-chat-runtime-request" ? "card" : id.startsWith("task-chat-queued-steer-") ? "steer" : id === "task-chat-history-loading" ? "loading" : "other"),
|
||||
waitForRequest: (predicate: (request: any) => boolean) => new Promise(resolve => waiters.push({ predicate, resolve })) };
|
||||
// The real shared remote oracle is exercised. Poisoned local copyback cannot
|
||||
@@ -127,6 +134,7 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" |
|
||||
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); }
|
||||
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();
|
||||
else {
|
||||
const result = await call; expect(result.checks.every(c => c.passed)).toBe(true); expect(saved.has("api-state.json")).toBe(true);
|
||||
@@ -165,4 +173,5 @@ for (const task of ["pending-permission-stop", "same-turn-steering"]) {
|
||||
it.each(["mutation", "incomplete"] as const)("fails local cleanup on %s despite an absent final target", failure => exercise("pending-permission-stop", false, failure));
|
||||
it.each(["mutation", "incomplete"] as const)("fails remote lifetime proof on %s despite an absent target", failure => exercise("same-turn-steering", true, failure));
|
||||
it("never denies the native write before the steering acknowledgement exists", () => exercise("same-turn-steering", false, "missing-ack"));
|
||||
it("rejects a queued body that differs from the exact browser submission", () => exercise("same-turn-steering", false, "foreign-queued-body"));
|
||||
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));
|
||||
@@ -172,10 +172,16 @@ export async function runPiControlsFlow(input: {
|
||||
await input.evidence("pi-control-pending.json", pending);
|
||||
const marker = `PI-STEER-${randomBytes(16).toString("hex")}`;
|
||||
const message = `After the actual native denial, complete this negative test. Do not write or invoke any other native tool. ${piNativeFinish(marker)}`;
|
||||
const commentRoutes = new Set([issue.id, issue.identifier].filter(Boolean).map(id => `/api/issues/${id}/comments`));
|
||||
const submitted = page.waitForRequest(request => commentRoutes.has(new URL(request.url()).pathname) && request.method() === "POST");
|
||||
await submitTaskReply(page, message);
|
||||
// The production rich-text editor serializes plain input as Markdown.
|
||||
// Bind the queued comment to the exact body actually sent by the browser.
|
||||
const submittedBody = (await submitted).postDataJSON()?.body;
|
||||
check("browser-steering-content", typeof submittedBody === "string" && submittedBody.startsWith("After the actual native denial, complete this negative test. Do not write or invoke any other native tool. ") && submittedBody.includes(marker), "Browser submitted the hidden instruction while permission remains pending");
|
||||
const queue = await pollUntil({ label: "Pi browser comment queued for steering", deadlineAt: input.deadlineAt, intervalMs: 200,
|
||||
load: () => api.get<Row>(`/api/issues/${issue.id}/queued-comments`), accept: q => q.steeringDisposition === "available" && q.entries?.some((entry: Row) => entry.comment.body === message) });
|
||||
const entries = queue.entries.filter((entry: Row) => entry.comment.body === message);
|
||||
load: () => api.get<Row>(`/api/issues/${issue.id}/queued-comments`), accept: q => q.steeringDisposition === "available" && q.entries?.some((entry: Row) => entry.comment.body === submittedBody) });
|
||||
const entries = queue.entries.filter((entry: Row) => entry.comment.body === submittedBody);
|
||||
check("one-browser-steering-message", entries.length === 1 && queue.entries.length === 1 && queue.targetRunId === pending.scope.runId, "One browser-originated comment targets the pending run");
|
||||
const commentId = entries[0].comment.id, queueId = queue.queueId;
|
||||
assertSamePiPending(pending, observePiControlPending({ ...await load(), scope: scope() }));
|
||||
|
||||
Reference in new issue
Block a user