mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents use legacy skills or native runner tools to work on tasks. > - Those agents can encounter friction that does not belong in the task thread. > - A complaint should preserve the raw reaction. A suggestion should describe an improvement. > - This pull request adds attributed local storage and both submission paths. > - Agents can submit feedback once and continue their primary work. ## Linked Issues or Issue Description **Subsystem affected** Server, database, shared contracts, runtime skills, and native runner tools. **Problem or motivation** Agents have no default internal channel for incidental complaints and suggestions. Sending this feedback through task comments adds noise and can alter task workflows. **Proposed solution** Store free-form feedback in the current instance database. Derive agent, run, company, and task attribution from active authority. Provide default legacy skills and provider-neutral native actions. Keep the instructions close to Warp's MIT-licensed originals. **Alternatives considered** Task comments and external Slack delivery add unwanted side effects. Mandatory suggestion fields and short editorial limits would discard useful feedback. This release has no listing API, UI, read tool, automatic triage, or external forwarding. **Roadmap alignment** This is a maintainer-requested addition to the existing runtime skills and runner tool paths. It does not duplicate a listed roadmap milestone. Searches for complaint tooling, suggestion-box, and agent commentary found no overlapping public PR or issue. ## What Changed - Add the company-scoped `agent_commentary` table, shared validation, and idempotent migration `0310`. - Add one transactional service and the agent-only POST route. Validate active authority before writes or replay. Redact known credentials. Commit a content-free audit with each new record. - Add `submit_complaint` and `submit_suggestion` to standard, ask, and planning modes. Keep review, revocation, and completion restrictions. Store replay identity on the commentary row. - Mount `complain` and `suggestion-box` by default for legacy agents. Bundle a dependency-free Node.js stdin helper in the operational skill and allow its POST through the sandbox bridge. - Preserve Warp's complaint voice and suggestion guidance, with attribution and local transport adaptations. Keep source attribution and MIT notices in each skill's LICENSE, outside runtime instructions. - Document custom-runtime HTTP use and database inspection. Add real-database tests and a repeatable live Codex smoke for local and Daytona execution. - Pin the lagging-source migration fixture before the identity-repair migration so later migrations preserve its regression coverage. ## Verification - Personally ran real Codex submissions in all four environments on 2026-10-06. Local runs passed at 20:35 UTC. Daytona native passed at 20:31 UTC; Daytona legacy passed at 20:33 UTC. Each stored exactly two rows with company, agent, run, and task attribution, wrote the continuation marker, exited zero, created no task comments, and left task status unchanged. Each recorded two content-free activity entries. - Daytona used production provider hooks, real remote execution and file transfer, the legacy queue callback bridge, and native private WebSocket ingress. The current Linux runner was built from `abf47b595`, staged, and verified against controller contracts. Both sandboxes were confirmed deleted. This is a focused feedback transport smoke; it does not claim full Runner E2E catalog or browser qualification. - The immutable base image and Linux binary digest are recorded in [the verification documentation](https://github.com/paperclipai/paperclip/blob/codex/agent-commentary/doc/agent-commentary.md#verification). The smoke script can save content-free JSON evidence. No credentials or feedback bodies are in these reports. | Environment | Runner | Complaint row | Suggestion row | | --- | --- | --- | --- | | local | legacy Codex | `59413a00-1de2-4bb1-bcc6-9c4b54c64aa6` | `3db2364d-3e15-4f47-846f-875d3902999d` | | local | native Codex | `5da22b5f-41df-4de5-8ba0-d9345ab01267` | `2d5abe17-dd41-403c-a5ee-4729f2d58921` | | daytona | legacy Codex | `27c9d0aa-8477-409f-9da0-e8ffa48dee50` | `209681c9-d1e9-4ce1-999e-48fa07692389` | | daytona | native Codex | `6eb001bb-4bcf-43f7-8717-f662f53dc7c3` | `77c383d8-a997-49e5-a33e-25c70e15c0b2` | - Run the local check with `node cli/node_modules/tsx/dist/cli.mjs server/scripts/verify-agent-commentary-live.ts`. The documentation gives the Daytona invocation. Both use disposable instance databases and normal Codex provider usage. - Repository `pnpm -r typecheck` and `pnpm build` passed after the test extension. The build includes runner generation, contracts, and replay checks. The smoke scripts also passed a separate TypeScript check. The lagging-source migration regression passed. All equivalent current-head Vitest CI shards passed. The local monolithic `pnpm test:run` invocation was stopped after CI supplied that coverage; it did not complete locally. - Focused tests cover company isolation, spoofing, revoked credentials, stale ownership, post-finish rejection, concurrent replay, conflicting keys, atomic rollback, and deletion through existing services. Boundary tests cover empty text, Unicode, text beyond 8,000 characters, and the 524,288-character ceiling without truncation. Mounting tests cover Codex, Claude, and sandbox staging. Helper tests cover standalone Node execution, stdin, invalid UTF-8, redirects, HTTP failure, and its deadline. Privacy and bridge tests cover successful and rejected requests. - Instructions were compared with Warp's originals. MIT notices and source credits live only in LICENSE files. Native tools preserve truthful disclosure when asked, without routine announcements. - [Full CI](https://github.com/paperclipai/paperclip/actions/runs/37508559190) and Greptile 5/5 passed on the earlier feature commit `5209c3501`. The later head found the migration-fixture assumption fixed in this update. On `8a4965164`, all 55 check contexts passed after one browser shard rerun. Its initial reviewer signoff failure also passed an isolated local browser run (1 test). Greptile scored that head 5/5 and identified one smoke cleanup gap. `6ecbafb0b` fixes failed-acquisition cleanup with four passing tests and a passing smoke-script typecheck. Fresh CI is pending for this final test-only fix. No commentary production code changed during verification. ## Risks - Feedback is internally attributed. It is not anonymous. Existing redaction removes known credentials, but agents must still omit sensitive content. Normal provider transcripts can include their submitted arguments. - Default skill availability changes for existing legacy agents. Runtime policy filtering still applies. The helper uses the existing Node.js runtime with no extra dependencies; custom runtimes can call the HTTP endpoint. - Feedback is removed with its run, agent, or company. Task deletion clears only the issue pointer. Normal database backups include the table. - The migration is additive and has no backfill. Writes serialize on the active run for replay consistency. No server suggestion quota is imposed. ## Model Used OpenAI `gpt-6-astra` through Codex, with `xhigh` reasoning effort and a reported 258,400-token context window. Capabilities used: repository inspection, code execution, and live runtime verification. No subagents were used. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
262 lines
18 KiB
TypeScript
262 lines
18 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { activityLog, agentApiKeys, agentCommentary, agents, authUsers, companyMemberships, heartbeatRuns, issues, nativeRunResults } from "@paperclipai/db";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
|
import { submitAgentCommentary } from "../services/agent-commentary.js";
|
|
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
|
import { isSecretSensitiveHttpRequest } from "../middleware/http-log-policy.js";
|
|
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
|
import { ensureNativeCompletionContract } from "../services/native-runtime/completion-contracts.js";
|
|
import { agentCommentaryRoutes } from "../routes/agent-commentary.js";
|
|
import { errorHandler } from "../middleware/error-handler.js";
|
|
import { agentService } from "../services/agents.js";
|
|
import { companyService } from "../services/companies.js";
|
|
|
|
describe("internal agent commentary through both transports", () => {
|
|
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
|
|
let home: string;
|
|
const oldHome = process.env.PAPERCLIP_HOME;
|
|
const oldSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
|
beforeAll(async () => {
|
|
home = await mkdtemp(join(tmpdir(), "paperclip-commentary-"));
|
|
process.env.PAPERCLIP_HOME = home;
|
|
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
|
|
server = await startRunnerApiTestServer();
|
|
}, 90_000);
|
|
afterAll(async () => {
|
|
await server?.close();
|
|
if (oldHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = oldHome;
|
|
if (oldSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = oldSecret;
|
|
if (home) await rm(home, { recursive: true, force: true });
|
|
});
|
|
type Fixture = Awaited<ReturnType<typeof server.fixture>>;
|
|
const input = { body: "The tool returned success before the write finished. Please wait for persistence.", idempotencyKey: "one" };
|
|
const rows = (f: Fixture) => server.db.select().from(agentCommentary).where(eq(agentCommentary.runId, f.runId));
|
|
async function legacy() {
|
|
const f = await server.fixture();
|
|
await server.db.update(heartbeatRuns).set({ runtimeMode: "legacy", nativeIssueId: null }).where(eq(heartbeatRuns.id, f.runId));
|
|
await server.db.update(agents).set({ adapterType: "codex_local" }).where(eq(agents.id, f.agentId));
|
|
return { ...f, token: createLocalAgentJwt(f.agentId, f.companyId, "codex_local", f.runId)! };
|
|
}
|
|
async function post(f: Fixture & { token: string }, value: unknown, companyId = f.companyId) {
|
|
return fetch(`${server.apiUrl}/api/companies/${companyId}/agent-commentary`, {
|
|
method: "POST", headers: { authorization: `Bearer ${f.token}`, "X-Paperclip-Run-Id": f.runId, "Content-Type": "application/json" }, body: JSON.stringify(value),
|
|
});
|
|
}
|
|
|
|
it("can reapply the generated migration without losing data", async () => {
|
|
const f = await legacy();
|
|
await post(f, { ...input, kind: "complaint" });
|
|
const migration = await readFile(new URL("../../../packages/db/src/migrations/0310_agent_commentary.sql", import.meta.url), "utf8");
|
|
for (const statement of migration.split("--> statement-breakpoint")) {
|
|
if (statement.trim()) await server.db.execute(sql.raw(statement));
|
|
}
|
|
expect(await rows(f)).toHaveLength(1);
|
|
});
|
|
|
|
it.each(["standard", "ask", "planning"] as const)("exposes native feedback in %s and persists only feedback plus a content-free audit", async (mode) => {
|
|
const f = await server.fixture({ mode });
|
|
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, workMode: mode });
|
|
expect(authority.definitions().map(t => t.name)).toEqual(expect.arrayContaining(["submit_complaint", "submit_suggestion"]));
|
|
const before = await f.snapshot();
|
|
for (const tool of ["submit_complaint", "submit_suggestion"]) {
|
|
const result = await authority.execute({ tool, callId: tool, arguments: { ...input, idempotencyKey: tool } });
|
|
expect(result).toMatchObject({ kind: tool === "submit_complaint" ? "complaint" : "suggestion", replayed: false });
|
|
expect(JSON.stringify(result)).not.toContain(input.body);
|
|
}
|
|
expect(await rows(f)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, body: input.body, kind: "complaint" }),
|
|
expect.objectContaining({ kind: "suggestion" }),
|
|
]));
|
|
const after = await f.snapshot();
|
|
expect(after.issues).toEqual(before.issues);
|
|
expect(after.comments).toEqual(before.comments);
|
|
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(run.resultJson).toBeNull();
|
|
const audit = await server.db.select().from(activityLog).where(and(eq(activityLog.runId, f.runId), eq(activityLog.action, "agent.commentary_submitted")));
|
|
expect(audit).toHaveLength(2);
|
|
expect(audit.every(a => Object.keys(a.details ?? {}).join() === "kind")).toBe(true);
|
|
});
|
|
|
|
it("deduplicates concurrent replay, rejects changed content, and imposes no suggestion quota", async () => {
|
|
const f = await server.fixture();
|
|
const submit = (args = input) => f.authority.execute({ tool: "submit_suggestion", callId: randomUUID(), arguments: args });
|
|
const results = await Promise.all([submit(), submit(), submit()]) as Array<{ id: string; replayed: boolean }>;
|
|
expect(new Set(results.map(r => r.id)).size).toBe(1);
|
|
expect(results.filter(r => !r.replayed)).toHaveLength(1);
|
|
await expect(submit({ ...input, body: "different" })).rejects.toThrow(/different content/);
|
|
for (let i = 0; i < 4; i++) await submit({ ...input, idempotencyKey: `new-${i}` });
|
|
expect(await rows(f)).toHaveLength(5);
|
|
expect(await server.db.select().from(activityLog).where(and(eq(activityLog.runId, f.runId), eq(activityLog.action, "agent.commentary_submitted")))).toHaveLength(5);
|
|
});
|
|
|
|
it("accepts document-sized text without truncation and redacts credential syntax", async () => {
|
|
const f = await legacy();
|
|
const body = "🙂".repeat(262_144);
|
|
expect((await post(f, { ...input, kind: "suggestion", body })).status).toBe(201);
|
|
expect((await rows(f))[0].body).toBe(body);
|
|
expect((await post(f, { ...input, kind: "suggestion", body: body + "!" })).status).toBe(400);
|
|
const secretBody = "It printed Authorization: Bearer sk-test-very-sensitive-credential before failing.";
|
|
expect((await post(f, { ...input, kind: "complaint", body: secretBody, idempotencyKey: "secret" })).status).toBe(201);
|
|
expect((await rows(f)).find(r => r.idempotencyKey === "secret")?.body).not.toContain("sk-test-very-sensitive-credential");
|
|
});
|
|
|
|
it("rejects spoofed input, cross-company HTTP access, board access, and missing authentication", async () => {
|
|
const f = await legacy();
|
|
for (const spoof of [{ agentId: f.agentId }, { issueId: f.issueId }, { runId: f.runId }, { companyId: f.companyId }, { body: " \n" }]) {
|
|
expect((await post(f, { ...input, kind: "complaint", ...spoof })).status).toBe(400);
|
|
}
|
|
expect((await post(f, { ...input, kind: "complaint" }, f.foreignCompanyId)).status).toBe(403);
|
|
expect((await fetch(`${server.apiUrl}/api/companies/${f.companyId}/agent-commentary`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...input, kind: "complaint" }) })).status).toBe(401);
|
|
const boardApp = express();
|
|
boardApp.use(express.json(), (req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "operator" }; next(); });
|
|
boardApp.use("/api", agentCommentaryRoutes(server.db));
|
|
boardApp.use(errorHandler);
|
|
expect((await request(boardApp).post(`/api/companies/${f.companyId}/agent-commentary`).send({ ...input, kind: "complaint" })).status).toBe(403);
|
|
await expect(submitAgentCommentary(server.db, { ...f, companyId: f.foreignCompanyId }, { ...input, kind: "complaint" })).rejects.toThrow(/authenticated/);
|
|
expect(await rows(f)).toHaveLength(0);
|
|
});
|
|
|
|
it.each(["cancelled", "succeeded", "failed"])("rejects submissions and replay after the run is %s", async (status) => {
|
|
const f = await legacy();
|
|
expect((await post(f, { ...input, kind: "complaint" })).status).toBe(201);
|
|
await server.db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, f.runId));
|
|
expect((await post(f, { ...input, kind: "complaint" })).status).toBe(403);
|
|
expect(await rows(f)).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects native spoofing and paused agents without changing task state", async () => {
|
|
const f = await server.fixture();
|
|
await expect(f.authority.execute({ tool: "submit_complaint", callId: "spoof", arguments: { ...input, kind: "suggestion" } })).rejects.toThrow(/accepts only/);
|
|
await server.db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
|
|
await expect(f.authority.execute({ tool: "submit_complaint", callId: "paused", arguments: input })).rejects.toThrow(/not_authorized/);
|
|
expect(await rows(f)).toHaveLength(0);
|
|
});
|
|
|
|
it.each(["jwt", "api_key"])("rejects new feedback and replay with %s after Stop revokes a still-running legacy run", async (credential) => {
|
|
const f = await legacy();
|
|
if (credential === "api_key") {
|
|
f.token = `pcp_${randomUUID()}`;
|
|
const responsibleUserId = randomUUID();
|
|
await server.db.insert(authUsers).values({ id: responsibleUserId, name: "Feedback key owner", email: `${responsibleUserId}@fixture.invalid`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
|
|
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: responsibleUserId, status: "active", membershipRole: "member" });
|
|
await server.db.insert(agentApiKeys).values({ companyId: f.companyId, agentId: f.agentId, responsibleUserId, name: "feedback", keyHash: createHash("sha256").update(f.token).digest("hex") });
|
|
}
|
|
expect((await post(f, { ...input, kind: "complaint" })).status).toBe(201);
|
|
await server.db.update(heartbeatRuns).set({ resultJson: { executionCancellation: { state: "requested" } } }).where(eq(heartbeatRuns.id, f.runId));
|
|
for (const idempotencyKey of [input.idempotencyKey, "after-stop"]) {
|
|
const body = { ...input, kind: "complaint", idempotencyKey };
|
|
expect((await post(f, body)).status).toBe(403);
|
|
// The persistence boundary must also reject a request whose middleware
|
|
// authenticated before Stop committed the revocation.
|
|
await expect(submitAgentCommentary(server.db, f, body)).rejects.toThrow(/no longer authorized/);
|
|
}
|
|
expect(await rows(f)).toHaveLength(1);
|
|
expect(await server.db.select().from(activityLog).where(and(eq(activityLog.runId, f.runId), eq(activityLog.action, "agent.commentary_submitted")))).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects revoked legacy credentials, replaced run ownership, and specialized review tools", async () => {
|
|
const f = await legacy();
|
|
const token = `pcp_${randomUUID()}`;
|
|
await server.db.insert(agentApiKeys).values({ companyId: f.companyId, agentId: f.agentId, name: "revoked", keyHash: createHash("sha256").update(token).digest("hex"), revokedAt: new Date() });
|
|
expect((await post({ ...f, token }, { ...input, kind: "complaint" })).status).toBe(401);
|
|
const native = await server.fixture();
|
|
const replacementId = randomUUID();
|
|
await server.db.insert(heartbeatRuns).values({ id: replacementId, companyId: native.companyId, agentId: native.agentId, status: "running", invocationSource: "on_demand" });
|
|
await server.db.update(issues).set({ executionRunId: replacementId }).where(eq(issues.id, native.issueId));
|
|
await expect(native.authority.execute({ tool: "submit_suggestion", callId: "stale", arguments: input })).rejects.toThrow();
|
|
const reviewer = new PaperclipRunnerToolAuthority(server.db, { ...native, nativeReview: { nativeReviewInteractionId: randomUUID(), nativeReviewDecisionId: randomUUID() } });
|
|
expect(reviewer.definitions().map(t => t.name)).not.toContain("submit_complaint");
|
|
expect(reviewer.definitions().map(t => t.name)).not.toContain("submit_suggestion");
|
|
await expect(reviewer.execute({ tool: "submit_complaint", callId: "review", arguments: input })).rejects.toThrow();
|
|
expect(await rows(f)).toHaveLength(0);
|
|
expect(await rows(native)).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects native feedback after an accepted finish even while the provider is still running", async () => {
|
|
const f = await server.fixture();
|
|
const { row: contract } = await ensureNativeCompletionContract({ db: server.db, companyId: f.companyId, issue: { id: f.issueId, title: "Test", description: null }, actorId: "test" });
|
|
await server.db.update(heartbeatRuns).set({ completionContractId: contract.id }).where(eq(heartbeatRuns.id, f.runId));
|
|
await server.db.insert(nativeRunResults).values({
|
|
companyId: f.companyId, issueId: f.issueId, runId: f.runId, completionContractId: contract.id,
|
|
serverFingerprint: randomUUID(), schemaStatus: "accepted", resultJson: {}, canonicalSha256: "test",
|
|
});
|
|
await expect(f.authority.execute({ tool: "submit_complaint", callId: "late", arguments: input })).rejects.toThrow(/before finishing/);
|
|
expect(await rows(f)).toHaveLength(0);
|
|
});
|
|
|
|
it("retains feedback after task deletion and removes it with the run", async () => {
|
|
const f = await legacy();
|
|
await post(f, { ...input, kind: "complaint" });
|
|
await server.db.delete(issues).where(eq(issues.id, f.issueId));
|
|
expect((await rows(f))[0].issueId).toBeNull();
|
|
await server.db.delete(activityLog).where(eq(activityLog.runId, f.runId));
|
|
await server.db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(await rows(f)).toHaveLength(0);
|
|
});
|
|
|
|
it.each(["agent", "company"])("uses existing %s deletion handling", async (owner) => {
|
|
const f = await legacy();
|
|
// A taskless run also verifies optional issue attribution, without unrelated
|
|
// fixture approval records that deliberately prevent deleting their author.
|
|
const agentId = randomUUID(), runId = randomUUID();
|
|
await server.db.insert(agents).values({ id: agentId, companyId: f.companyId, name: "Feedback author", adapterType: "codex_local", status: "active" });
|
|
await server.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId, status: "running", invocationSource: "on_demand" });
|
|
await submitAgentCommentary(server.db, { companyId: f.companyId, agentId, runId }, { ...input, kind: "complaint" });
|
|
expect((await rows({ ...f, runId }))[0].issueId).toBeNull();
|
|
if (owner === "agent") await agentService(server.db).remove(agentId);
|
|
else await companyService(server.db).remove(f.companyId);
|
|
expect(await rows({ ...f, runId })).toHaveLength(0);
|
|
});
|
|
|
|
it("rolls back feedback when its audit insert fails and sanitizes the database error", async () => {
|
|
const f = await legacy();
|
|
await server.db.execute(sql.raw(`CREATE FUNCTION fail_commentary_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'agent.commentary_submitted' THEN RAISE EXCEPTION 'private database canary'; END IF; RETURN NEW; END $$`));
|
|
await server.db.execute(sql.raw(`CREATE TRIGGER fail_commentary_audit BEFORE INSERT ON activity_log FOR EACH ROW EXECUTE FUNCTION fail_commentary_audit()`));
|
|
try {
|
|
const response = await post(f, { ...input, kind: "complaint" });
|
|
expect(response.status).toBe(503);
|
|
expect(await response.text()).not.toMatch(/private database canary|The tool returned/);
|
|
expect(await rows(f)).toHaveLength(0);
|
|
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(run.status).toBe("running");
|
|
} finally {
|
|
await server.db.execute(sql.raw("DROP TRIGGER fail_commentary_audit ON activity_log"));
|
|
await server.db.execute(sql.raw("DROP FUNCTION fail_commentary_audit()"));
|
|
}
|
|
});
|
|
|
|
it("runs the legacy stdin helper and handles failure silently without exposing its body", async () => {
|
|
const f = await legacy();
|
|
const helper = fileURLToPath(new URL("../../../skills/paperclip/scripts/submit-agent-commentary.mjs", import.meta.url));
|
|
const body = "Quotes, `backticks`, $(printf should-not-run), and emoji 😭 stay text.\n";
|
|
async function run(apiUrl: string) {
|
|
return new Promise<{ code: number | null; out: string; err: string }>((resolve, reject) => {
|
|
const child = spawn(process.execPath, [helper, "complaint", "helper-one"], { env: { ...process.env, PAPERCLIP_API_URL: apiUrl, PAPERCLIP_API_KEY: f.token, PAPERCLIP_COMPANY_ID: f.companyId, PAPERCLIP_RUN_ID: f.runId }, stdio: "pipe" });
|
|
let out = "", err = "";
|
|
child.stdout.on("data", c => out += c); child.stderr.on("data", c => err += c);
|
|
child.on("error", reject); child.on("close", code => resolve({ code, out, err }));
|
|
child.stdin.end(body);
|
|
});
|
|
}
|
|
expect(await run(server.apiUrl)).toEqual({ code: 0, out: "Feedback stored.\n", err: "" });
|
|
expect(await run(server.apiUrl + "/api/")).toEqual({ code: 0, out: "Feedback stored.\n", err: "" });
|
|
expect(await rows(f)).toHaveLength(1);
|
|
expect((await rows(f))[0].body).toBe(body);
|
|
const failed = await run("http://127.0.0.1:1");
|
|
expect(failed).toMatchObject({ code: 0, out: "" });
|
|
expect(failed.err).toContain("continue the primary task");
|
|
expect(failed.err).not.toContain(f.token);
|
|
expect(failed.err).not.toContain(body);
|
|
expect(isSecretSensitiveHttpRequest("POST", `/api/companies/${f.companyId}/agent-commentary`)).toBe(true);
|
|
});
|
|
});
|