From 0829d94af2a2381d39dfa5eac4b80dac8539dff0 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:45:18 -0500 Subject: [PATCH] fix(auth): derive low-trust human direction from existing execution records (#14775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Low-trust review contains work that may include hostile input. > - Its default intake boundary currently blocks direct human chat and tasks outside that boundary. > - Human direction should authorize the assigned work while preserving containment. > - Existing conversations and execution requests already identify direct human instructions. > - This pull request derives exact-task authority from those records and the current assignee. > - The agent can perform that work without gaining access to unrelated tasks or privileged tools. ## Linked Issues or Issue Description **What happened?** A low-trust agent with a project boundary rejects its owner's direct Agent Chat before provider execution. Human-assigned tasks outside that project fail the same check. **Expected behavior** An authorized human can talk to the agent or assign it a task. The exact task runs with its existing sandbox, credential, and tool restrictions. **Steps to reproduce** 1. Enable Agent Chat and isolated workspaces. Configure a sandbox agent with low-trust review scoped to an intake project. 2. Send the agent a direct board chat message, or assign it a projectless task. 3. Observe `low_trust_boundary_mismatch` before execution. Related: #14766 adds private task directories for repo-free low-trust execution. It is now merged into master and included in the branch base, so CI and staging verify the combined behavior. ## What Changed - Derive owner-chat access from existing conversation identity. - Derive exact-task access from the existing human requester and server-owned request origin, including coalesced requests. Plugin and external sender attribution do not authorize work. - Follow existing `retryOfRunId` database links for automatic continuations, checking company, agent, and task throughout; cancelled ancestors cannot grant authority. - Require a live run and current assignment. Preserve sandbox, credential, privileged-tool, responsible-user, and quarantined-output checks. - Retain board backlog assignments in existing request records without starting execution. Reassignment cancels old human requests in the common service transaction, including plugin writes; late settlement cannot revive them. - Add real database and HTTP coverage for request provenance, retry ancestry, cancelled runs, concurrent reassignment, spoofing, and containment. Document the rule. - Preserve legacy board assignment requests through their existing source, reason, and human requester. - Use the existing wrapped-error helper for concurrent chat-question idempotency; a deterministic race test reproduces the CI failure before the fix and passes after it. - No new schema, migrations, or user-identity fields. Existing requester columns hold attribution. ## Verification - Passed the focused database, policy-retention, HTTP, and reassignment tests locally. The HTTP test creates a task through the real board route and checks the resulting persisted wakeup before exercising agent reads, comments, mutations, and review handoff. - Database tests hold a reassignment transaction open to verify coherent authorization before and after commit, with a two-connection pool. They cover retries, coalesced requests, cancelled ancestry, invalid cross-company/agent/task links, cycles, and forged attribution. - Full local `pnpm -r typecheck` and `pnpm build` passed on the final commit (`d107c26df`). [Latest-head CI](https://github.com/paperclipai/paperclip/actions/runs/36815589542) passed: 54 successful checks, two expected skips, including all eight browser-test shards. Greptile is 5/5 on this exact commit with no unresolved threads. Local tests were targeted; the full test suite ran through CI’s test matrix. - The revised HTTP suite passed all 13 tests; database authorization tests passed all 11, including legacy compatibility and late watchdog settlement; the backlog route contract passed all 3 tests. Another 102 tests covering durable chat admission, wake queues, and Cursor execution passed. - All 90 interaction-service tests passed with both create calls deliberately held until their optimistic reads complete, forcing duplicate-key recovery. That forced race failed before switching to the shared wrapped-error helper. - Previous staging proof covered owner chat and projectless task persistence. The simplified revision has not been redeployed; that earlier proof is not claimed for the new implementation. ## Risks - This is an authorization change: only the live run's exact task qualifies, and normal responsible-user restrictions still apply. - Existing request and retry records are authoritative. Merely naming a responsible/originating user or an external connector sender does not qualify. - Reassignment invalidates existing human request records transactionally. A cancelled run or request cannot regain authority when the task is assigned back. - Ordinary task exceptions require server-owned origin or the legacy board assignment source/reason/actor combination. Existing owner chats use conversation identity. ## Model Used OpenAI GPT-6 in Codex, with reasoning, repository tools, code execution, and browser testing. The runtime does not expose a more specific model ID or context-window size. ## 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 - [x] All Paperclip CI gates are green - [x] 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 --- doc/LOW-TRUST-PRESETS.md | 32 ++- doc/SPEC-implementation.md | 9 + .../src/__tests__/human-directed-work.test.ts | 268 ++++++++++++++++++ ...e-assigned-backlog-contract-routes.test.ts | 7 + .../issue-thread-interactions-service.test.ts | 23 +- .../low-trust-red-team-routes.test.ts | 110 +++++++ .../active-run-watchdog/adapters/postgres.ts | 4 +- server/src/routes/issues.ts | 8 + server/src/services/authorization.ts | 11 +- server/src/services/heartbeat.ts | 11 +- server/src/services/human-directed-work.ts | 94 ++++++ .../src/services/issue-thread-interactions.ts | 17 +- server/src/services/issues.ts | 9 + .../services/low-trust-runtime-containment.ts | 2 + server/src/services/run-trust-preset.ts | 6 +- server/src/services/trust-preset-resolver.ts | 2 + 16 files changed, 588 insertions(+), 25 deletions(-) create mode 100644 server/src/__tests__/human-directed-work.test.ts create mode 100644 server/src/services/human-directed-work.ts diff --git a/doc/LOW-TRUST-PRESETS.md b/doc/LOW-TRUST-PRESETS.md index 9581e7cfa37..30007192164 100644 --- a/doc/LOW-TRUST-PRESETS.md +++ b/doc/LOW-TRUST-PRESETS.md @@ -41,6 +41,35 @@ Low-trust agents cannot read or mutate agent configuration, instruction bundles, or company skill configuration through direct grants. Configuration changes from low-trust work must go through higher-trust review and promotion paths instead. +## Human-directed work + +An authenticated board user can talk to a low-trust agent in their own Agent +Chat or assign it a task outside its default intake boundary. Existing conversation +identity authorizes owner chat. Ordinary tasks use the human requester already +recorded on the run's wakeup requests, including coalesced requests. Each request +retains its server-owned origin; plugin-attributed users do not count as board +instructions. Legacy board assignment requests remain valid through their existing +assignment source, reason, and human requester, without trusting old plugin human +attribution. A board backlog assignment is retained as a completed assignment +request with no run, so it authorizes the later system launch without starting +work prematurely. No separate permission table or client-supplied human identity is needed. Responsible-user +attribution, external connector sender attribution, and agent claims do not qualify. + +At dispatch and API authorization, Paperclip checks the live run and current +assignment in one database snapshot. The exception permits reading, commenting +on, and updating only that run's exact task. It does not extend to another task, +a child, a whole project, configuration, instructions, secrets, or runtime +management. Normal responsible-user checks still apply. The exception is never +stored in the inherited trust boundary. + +Automatic retries and continuations follow the existing `retryOfRunId` database +links, checking the same company, agent, and task at every step. Cancelled runs +cannot authorize a retry. The common assignment transaction cancels prior human +requests, including plugin and service reassignments. Assigning the task back +cannot revive those requests, and late run settlement cannot undo cancellation. Sandbox and +isolated workspace requirements still apply, as do malformed-policy and +company-boundary checks. + ## Child→Parent Reporting Under Containment The direct-parent report comment (`doc/execution-semantics.md` §6, "Child→Parent @@ -59,7 +88,8 @@ runtime boundary: - the selected execution environment must use the `sandbox` driver - the effective execution workspace mode must be `isolated_workspace` -- the issue being run must be inside the resolved low-trust boundary +- the issue being run must be inside the resolved low-trust boundary or be the + exact human-directed task described above - secret references must use binding ids explicitly allowed by the boundary - inline sensitive environment values such as API keys and tokens are rejected - workspace runtime-service mutations are denied unless the boundary explicitly diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 782f4c08b6d..580aa360aaf 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -626,6 +626,15 @@ budget gates, and pause gates remain independently enforced. Comment access is structurally downstream of issue read access (`issue:comment` is a subset of `issue:read`). +Authenticated board direction permits a low-trust agent to execute its own +human conversation or the exact task explicitly assigned or addressed by a human. +This server-owned exception is bound to the current assignee and the run's task; +it uses existing conversation identity and authenticated execution-request records, +including same-task retry ancestry. It does not expand inherited boundaries or +grant privileged tools. Backlog assignments retain the existing human requester +without starting a run. Reassignment transactionally cancels prior human requests, +including service/plugin writes; cancelled runs cannot authorize later retries. See `doc/LOW-TRUST-PRESETS.md` for containment details. + Cross-issue writes are contained per heartbeat run. An agent-authored comment may wake the target assignee, including an explicit `resume: true` comment on a `done` or `cancelled` issue, but the wake remains agent-class and is subject to diff --git a/server/src/__tests__/human-directed-work.test.ts b/server/src/__tests__/human-directed-work.test.ts new file mode 100644 index 00000000000..b76e28677c1 --- /dev/null +++ b/server/src/__tests__/human-directed-work.test.ts @@ -0,0 +1,268 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, createDb, heartbeatRuns, agentWakeupRequests, issues, projects } from "@paperclipai/db"; +import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { createPostgresWatchdogAdapter } from "../modules/active-run-watchdog/adapters/postgres.js"; +import { issueService } from "../services/issues.js"; +import { authorizationService } from "../services/authorization.js"; +import { resolveAndRetainRunTrustPreset } from "../services/run-trust-preset.js"; +import { assertLowTrustWorkspaceIsolation } from "../services/low-trust-runtime-containment.js"; +import { retainBacklogHumanAssignment, withHumanDirectedWork } from "../services/human-directed-work.js"; +import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js"; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("human-directed low-trust work", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-human-work-"); + db = createDb(database.connectionString, { maxConnections: 2 }); + }, 30_000); + afterAll(async () => { await database?.cleanup(); }, 60_000); + + async function seed() { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: companyId, issuePrefix: companyId.slice(0, 8) }); + const [project] = await db.insert(projects).values({ companyId, name: "Contained intake" }).returning(); + const [agent] = await db.insert(agents).values({ + companyId, name: "Contained agent", role: "engineer", adapterType: "paperclip_runner", + permissions: { authorizationPolicy: { trustPreset: LOW_TRUST_REVIEW_PRESET, + trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, companyId, projectIds: [project.id] } } }, + }).returning(); + const [other] = await db.insert(agents).values({ companyId, name: "Other", role: "engineer" }).returning(); + return { companyId, agent, other }; + } + async function task(f: Awaited>) { + return issueService(db).create(f.companyId, { + title: `Direct task ${randomUUID()}`, assigneeAgentId: f.agent.id, + createdByUserId: "owner", responsibleUserId: "owner", + }); + } + async function execution(f: Awaited>, issue: Awaited>, + options: { human?: boolean; context?: Record; retryOfRunId?: string; status?: string } = {}) { + const [wake] = await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agent.id, + source: options.human ? "assignment" : "automation", status: "claimed", + requestedByActorType: options.human ? "user" : "system", requestedByActorId: options.human ? "owner" : null, + payload: { issueId: issue.id, _paperclipWakeContext: { source: "issue.update" } } }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agent.id, + status: options.status ?? "running", responsibleUserId: "owner", wakeupRequestId: wake.id, + retryOfRunId: options.retryOfRunId, contextSnapshot: { issueId: issue.id, ...options.context } }).returning(); + await db.update(agentWakeupRequests).set({ runId: run.id }).where(eq(agentWakeupRequests.id, wake.id)); + return { run, wake, actor: { type: "agent" as const, companyId: f.companyId, + agentId: f.agent.id, source: "agent_key" as const, runId: run.id } }; + } + async function launch(f: Awaited>, issue: Awaited>, + options: Parameters[2] = {}) { + const current = await execution(f, issue, options); + return { ...current, ...await resolveAndRetainRunTrustPreset(db, { + companyId: f.companyId, agentId: f.agent.id, runId: current.run.id, agent: f.agent, issue, + }) }; + } + async function resolve(f: Awaited>, issue: Awaited>, runId: string) { + return withHumanDirectedWork(db, resolveCoreTrustPreset({ companyId: f.companyId, agent: f.agent, issue }), + { companyId: f.companyId, agentId: f.agent.id, runId }); + } + const resource = (issue: Awaited>) => ({ type: "issue" as const, + companyId: issue.companyId, issueId: issue.id, projectId: issue.projectId, + parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, status: issue.status }); + + it("allows the exact human-assigned task at dispatch and tool access; retains containment on retries", async () => { + const f = await seed(); const issue = await task(f); + const first = await launch(f, issue, { human: true }); + expect(first.trustPreset).toMatchObject({ kind: "low_trust_review", humanDirectedIssueId: issue.id }); + expect(JSON.stringify(first.executionPolicy)).not.toContain("humanDirectedIssueId"); + const retried = await resolveAndRetainRunTrustPreset(db, { companyId: f.companyId, + agentId: f.agent.id, runId: first.run.id, agent: f.agent, issue }); + expect(retried.trustPreset).toMatchObject(first.trustPreset); + await expect(assertLowTrustWorkspaceIsolation({ db, resolution: first.trustPreset, issue, + isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "isolated_workspace", + selectedEnvironmentDriver: "sandbox" })).resolves.toBeUndefined(); + for (const action of ["issue:read", "issue:comment", "issue:mutate"] as const) { + expect(await authorizationService(db).decide({ actor: first.actor, action, resource: resource(issue) })).toMatchObject({ allowed: true }); + } + const unrelated = await task(f); + expect(await authorizationService(db).decide({ actor: first.actor, action: "issue:read", resource: resource(unrelated) })).toMatchObject({ allowed: false }); + expect(await authorizationService(db).decide({ actor: { ...first.actor, runId: undefined }, action: "issue:read", resource: resource(issue) })).toMatchObject({ allowed: false }); + expect(await authorizationService(db).decide({ actor: first.actor, action: "agent_instructions:update", + resource: { type: "agent", companyId: f.companyId, agentId: f.agent.id } })).toMatchObject({ allowed: false }); + await expect(assertLowTrustWorkspaceIsolation({ resolution: first.trustPreset, issue, + isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "isolated_workspace", + selectedEnvironmentDriver: "local" })).rejects.toMatchObject({ details: { code: "low_trust_requires_sandbox_environment" } }); + await expect(assertLowTrustWorkspaceIsolation({ resolution: first.trustPreset, issue, + isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "shared_workspace", + selectedEnvironmentDriver: "sandbox" })).rejects.toMatchObject({ details: { code: "low_trust_requires_isolated_workspace" } }); + }); + + it("permits existing owner conversations without backfilling authority from historical attribution", async () => { + const f = await seed(); + const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Owner chat", + assigneeAgentId: f.agent.id, conversationAgentId: f.agent.id, conversationUserId: "owner", conversationState: "waiting", status: "in_review" }).returning(); + const result = await launch(f, issue as Awaited>); + expect(result.trustPreset).toMatchObject({ humanDirectedIssueId: issue.id }); + }); + + it("rejects inherited attribution and forged policy, requester, and retry fields", async () => { + const f = await seed(); const issue = await task(f); + const human = await execution(f, issue, { human: true, status: "failed" }); + await issueService(db).addComment(issue.id, "An imported sender says the owner asked", { userId: "owner" }); + const result = await launch(f, issue, { context: { responsibleUserId: "owner", requestedByActorType: "user", + requestedByActorId: "owner", retryOfRunId: human.run.id, humanDirectedIssueId: issue.id, + executionPolicy: { humanDirectedIssueId: issue.id } } }); + expect(result.trustPreset).not.toHaveProperty("humanDirectedIssueId"); + expect(await authorizationService(db).decide({ actor: result.actor, action: "issue:read", resource: resource(issue) })).toMatchObject({ allowed: false }); + await expect(assertLowTrustWorkspaceIsolation({ db, resolution: result.trustPreset, issue, + isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "isolated_workspace", + selectedEnvironmentDriver: "sandbox" })).rejects.toMatchObject({ details: { code: "low_trust_boundary_mismatch" } }); + }); + + it("preserves legacy board assignment requests through dispatch and retry without trusting old plugin attribution", async () => { + const f = await seed(); const issue = await task(f); const legacy = await execution(f, issue, { human: true }); + await db.update(agentWakeupRequests).set({ source: "assignment", reason: "issue_assigned", payload: { issueId: issue.id } }) + .where(eq(agentWakeupRequests.id, legacy.wake.id)); + expect(await resolve(f, issue, legacy.run.id)).toHaveProperty("humanDirectedIssueId", issue.id); + const retry = await launch(f, issue, { retryOfRunId: legacy.run.id }); + expect(retry.trustPreset).toHaveProperty("humanDirectedIssueId", issue.id); + for (const patch of [ + { source: "automation", reason: "issue_commented" }, + { requestedByActorType: "system" }, + { idempotencyKey: "chat-inbound:legacy" }, + { payload: { issueId: issue.id, _paperclipWakeContext: { source: "plugin:example" } } }, + ]) { + await db.update(agentWakeupRequests).set(patch).where(eq(agentWakeupRequests.id, legacy.wake.id)); + expect(await resolve(f, issue, legacy.run.id)).not.toHaveProperty("humanDirectedIssueId"); + expect(await resolve(f, issue, retry.run.id)).not.toHaveProperty("humanDirectedIssueId"); + await db.update(agentWakeupRequests).set({ source: "assignment", reason: "issue_assigned", requestedByActorType: "user", + idempotencyKey: null, payload: { issueId: issue.id } }).where(eq(agentWakeupRequests.id, legacy.wake.id)); + } + }); + + it("recognizes a coalesced human request for the same run and exact task", async () => { + const f = await seed(); const issue = await task(f); const current = await execution(f, issue); + expect(await resolve(f, issue, current.run.id)).not.toHaveProperty("humanDirectedIssueId"); + const [coalesced] = await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agent.id, + runId: current.run.id, source: "assignment", status: "coalesced", requestedByActorType: "user", + requestedByActorId: "owner", payload: { issueId: issue.id, _paperclipWakeContext: { source: "issue.update" } } }).returning(); + expect(await resolve(f, issue, current.run.id)).toHaveProperty("humanDirectedIssueId", issue.id); + for (const patch of [ + { status: "cancelled" }, { status: "skipped" }, { requestedByActorId: " " }, + { requestedByActorType: "agent" }, { idempotencyKey: "chat-inbound:external-sender" }, + { payload: { issueId: randomUUID() } }, + { payload: { issueId: issue.id, _paperclipWakeContext: { source: "plugin:untrusted" } } }, + { payload: { issueId: issue.id } }, { companyId: randomUUID() }, { agentId: f.other.id }, + ]) { + // Existing foreign keys require a real company for the cross-company case. + const badCompany = patch.companyId; + if (badCompany) await db.insert(companies).values({ id: badCompany, name: badCompany }); + await db.update(agentWakeupRequests).set(patch).where(eq(agentWakeupRequests.id, coalesced.id)); + expect(await resolve(f, issue, current.run.id)).not.toHaveProperty("humanDirectedIssueId"); + await db.update(agentWakeupRequests).set({ status: "coalesced", requestedByActorType: "user", + requestedByActorId: "owner", idempotencyKey: null, payload: { issueId: issue.id, _paperclipWakeContext: { source: "issue.update" } }, + companyId: f.companyId, agentId: f.agent.id }).where(eq(agentWakeupRequests.id, coalesced.id)); + } + }); + + it("preserves human authority through durable retry and continuation ancestry", async () => { + const f = await seed(); const issue = await task(f); + const root = await execution(f, issue, { human: true, status: "failed" }); + const middle = await execution(f, issue, { retryOfRunId: root.run.id, status: "succeeded" }); + const last = await launch(f, issue, { retryOfRunId: middle.run.id }); + expect(last.trustPreset).toHaveProperty("humanDirectedIssueId", issue.id); + expect(await authorizationService(db).decide({ actor: last.actor, action: "issue:comment", resource: resource(issue) })).toMatchObject({ allowed: true }); + await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: "issue_reassigned" }).where(eq(heartbeatRuns.id, middle.run.id)); + expect(await resolve(f, issue, last.run.id)).not.toHaveProperty("humanDirectedIssueId"); + }); + + it("never borrows retry authority from another task, agent, or company and terminates cycles", async () => { + const f = await seed(); const issue = await task(f); const otherIssue = await task(f); + const otherCompany = await seed(); + for (const scope of [ + { fixture: f, issue: otherIssue }, + { fixture: { ...f, agent: { ...f.agent, id: f.other.id } }, issue }, + { fixture: otherCompany, issue: await task(otherCompany) }, + ]) { + const parent = await execution(scope.fixture, scope.issue, { human: true, status: "failed" }); + const child = await launch(f, issue, { retryOfRunId: parent.run.id }); + expect(child.trustPreset).not.toHaveProperty("humanDirectedIssueId"); + } + const a = await execution(f, issue); const b = await execution(f, issue, { retryOfRunId: a.run.id }); + await db.update(heartbeatRuns).set({ retryOfRunId: b.run.id }).where(eq(heartbeatRuns.id, a.run.id)); + expect(await resolve(f, issue, a.run.id)).not.toHaveProperty("humanDirectedIssueId"); + }); + + it("revokes direction in service/plugin assignment transactions even while the old run remains running", async () => { + const f = await seed(); const issue = await task(f); const first = await execution(f, issue, { human: true }); + let release!: () => void; let ready!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + const changed = new Promise((resolve) => { ready = resolve; }); + const reassignment = db.transaction(async (tx) => { + await issueService(db).update(issue.id, { assigneeAgentId: f.other.id }, tx); + ready(); await held; + }); + try { + await Promise.race([changed, reassignment]); + // With the reassignment deliberately held open, reads see the old committed + // assignment and live run together. After commit the wake is revoked even + // without route-level cancellation of the run. + expect(await resolve(f, issue, first.run.id)).toHaveProperty("humanDirectedIssueId", issue.id); + } finally { release(); await reassignment; } + expect(await resolve(f, issue, first.run.id)).not.toHaveProperty("humanDirectedIssueId"); + await issueService(db).update(issue.id, { assigneeAgentId: f.agent.id }); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, first.run.id)))[0].status).toBe("running"); + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, first.wake.id)))[0].status).toBe("cancelled"); + expect(await resolve(f, issue, first.run.id)).not.toHaveProperty("humanDirectedIssueId"); + const oldRetry = await launch(f, issue, { retryOfRunId: first.run.id }); + expect(oldRetry.trustPreset).not.toHaveProperty("humanDirectedIssueId"); + const automatic = await launch(f, issue); + expect(automatic.trustPreset).not.toHaveProperty("humanDirectedIssueId"); + const human = await launch(f, issue, { human: true }); + expect(human.trustPreset).toHaveProperty("humanDirectedIssueId", issue.id); + }); + + it("a late watchdog success cannot restore a request revoked by reassignment", async () => { + const f = await seed(); const issue = await task(f); const first = await execution(f, issue, { human: true }); + await issueService(db).update(issue.id, { assigneeAgentId: f.other.id }); + const completed = (await issueService(db).update(issue.id, { assigneeAgentId: f.agent.id, status: "done" }))!; + const watchdog = createPostgresWatchdogAdapter(db); + expect(await watchdog.foldSourceResolvedRun(f.companyId, { + run: { ...first.run, sourceIssueId: issue.id }, + sourceIssue: { ...completed, isRecoveryOriginKind: false }, + evidence: { kind: "activity", id: randomUUID(), createdAt: new Date(), action: "issue.updated" }, + existingEvaluation: null, silenceStartedAt: null, silenceAgeMs: null, + cleanup: { attempted: false, outcome: "no_process_metadata", adapterType: "paperclip_runner" }, now: new Date(), + })).toMatchObject({ kind: "folded" }); + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, first.wake.id)))[0].status).toBe("cancelled"); + const retry = await launch(f, issue, { retryOfRunId: first.run.id }); + expect(retry.trustPreset).not.toHaveProperty("humanDirectedIssueId"); + }); + + it("retains backlog direction for a later system run and rejects stale or nonhuman assignment callbacks", async () => { + const f = await seed(); const issue = await task(f); + await retainBacklogHumanAssignment(db, issue, { actorType: "agent", actorId: "owner" }); + const automatic = await execution(f, issue); + expect(await resolve(f, issue, automatic.run.id)).not.toHaveProperty("humanDirectedIssueId"); + await retainBacklogHumanAssignment(db, issue, { actorType: "user", actorId: "owner" }); + const promoted = (await issueService(db).update(issue.id, { status: "todo" }))!; + expect(await resolve(f, promoted, automatic.run.id)).toHaveProperty("humanDirectedIssueId", issue.id); + await issueService(db).update(issue.id, { assigneeAgentId: f.other.id }); + await issueService(db).update(issue.id, { assigneeAgentId: f.agent.id, status: "backlog" }); + // A delayed callback holding the original issue snapshot cannot regrant. + await retainBacklogHumanAssignment(db, issue, { actorType: "user", actorId: "owner" }); + expect(await resolve(f, issue, automatic.run.id)).not.toHaveProperty("humanDirectedIssueId"); + }); + + it("does not authorize a cancelled chat or an uncommitted human request", async () => { + const f = await seed(); const issue = await task(f); const current = await execution(f, issue); + await expect(db.transaction(async (tx) => { + await tx.update(agentWakeupRequests).set({ requestedByActorType: "user", requestedByActorId: "owner" }).where(eq(agentWakeupRequests.id, current.wake.id)); + expect(await resolve(f, issue, current.run.id)).not.toHaveProperty("humanDirectedIssueId"); + throw new Error("rollback sentinel"); + })).rejects.toThrow("rollback sentinel"); + expect(await resolve(f, issue, current.run.id)).not.toHaveProperty("humanDirectedIssueId"); + await db.update(issues).set({ conversationAgentId: f.agent.id, conversationUserId: "owner", conversationState: "active" }).where(eq(issues.id, issue.id)); + expect(await resolve(f, issue, current.run.id)).toHaveProperty("humanDirectedIssueId", issue.id); + await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, current.run.id)); + expect(await resolve(f, issue, current.run.id)).not.toHaveProperty("humanDirectedIssueId"); + }); +}); diff --git a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts index c98b523720f..3df28bdcc14 100644 --- a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts +++ b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts @@ -4,6 +4,9 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const assigneeAgentId = "22222222-2222-4222-8222-222222222222"; +const mockRetainBacklogAssignment = vi.hoisted(() => vi.fn(async () => undefined)); +vi.mock("../services/human-directed-work.js", () => ({ retainBacklogHumanAssignment: mockRetainBacklogAssignment })); + const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined)); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockIssueService = vi.hoisted(() => ({ @@ -331,6 +334,10 @@ describe("assigned backlog creation contract", () => { assigneeAgentId, status: "backlog", })); + expect(mockRetainBacklogAssignment).toHaveBeenCalledWith( + expect.anything(), expect.objectContaining({ assigneeAgentId, status: "backlog" }), + expect.objectContaining({ actorType: "user", actorId: "local-board" }), + ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 707612762e9..8af1e9b98f2 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -257,10 +257,25 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const scope = { id: fixture.issueId, companyId: fixture.companyId }; const input = { ...questionCreateInput(fixture.runId), idempotencyKey: "chat-question" }; const actor = { agentId: fixture.agentId }; - const [omitted, explicit] = await Promise.all([ - interactionsSvc.create(scope, input, actor), - interactionsSvc.create(scope, { ...input, addresseeUserId: fixture.userId }, actor), - ]); + // Both callers must finish their optimistic lookup before either transaction + // inserts, forcing the unique-conflict recovery path instead of relying on timing. + const transaction = db.transaction.bind(db); + let arrivals = 0; + let release!: () => void; + const ready = new Promise(resolve => { release = resolve; }); + const gate = vi.spyOn(db, "transaction").mockImplementation(async (callback, config) => { + if (++arrivals === 2) release(); + await ready; + return transaction(callback, config); + }); + let omitted!: Awaited>; + let explicit!: Awaited>; + try { + [omitted, explicit] = await Promise.all([ + interactionsSvc.create(scope, input, actor), + interactionsSvc.create(scope, { ...input, addresseeUserId: fixture.userId }, actor), + ]); + } finally { gate.mockRestore(); } expect(explicit.id).toBe(omitted.id); expect(explicit.addresseeUserId).toBe(fixture.userId); expect(await db.select().from(issueThreadInteractions)).toHaveLength(1); diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 5fb90ce4803..b565f4a6eb3 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -7,6 +7,7 @@ import { and, eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, + authUsers, agentWakeupRequests, agentRuntimeState, agents, @@ -49,6 +50,7 @@ import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.j import { errorHandler } from "../middleware/index.js"; import { agentRoutes } from "../routes/agents.js"; import { issueRoutes } from "../routes/issues.js"; +import { issueService } from "../services/issues.js"; import { heartbeatService } from "../services/heartbeat.js"; import { LOW_TRUST_QUARANTINED_BODY } from "../services/source-trust.js"; @@ -914,6 +916,7 @@ describeEmbeddedPostgres( await db.delete(agentRuntimeState); await db.delete(principalPermissionGrants); await db.delete(companyMemberships); + await db.delete(authUsers); await db.delete(agents); await db.delete(projects); await deleteCompanySkillsAfterLateHeartbeatWritesDrain(db); @@ -923,6 +926,113 @@ describeEmbeddedPostgres( await tempDb?.cleanup(); }); + it("allows the human-requested run through HTTP while rejecting attribution-only authority", async () => { + const fixture = await seedLowTrustFixture(db); + await db.insert(authUsers).values({ id: "board-user", name: "Owner", email: "owner@example.test", createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: fixture.company.id, principalType: "user", principalId: "board-user", membershipRole: "owner", status: "active" }); + // The fixture already occupies the agent's sole execution slot. Let the + // real board route enqueue work, then bind its queued run for HTTP checks. + await db.update(agents).set({ runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } }) + .where(eq(agents.id, fixture.agents.lowTrust.id)); + const board = createApp(db, boardActor(fixture)); + const created = await request(board).post(`/api/companies/${fixture.company.id}/issues`) + .send({ title: "Human assigned outside intake", status: "todo", assigneeAgentId: fixture.agents.lowTrust.id }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + const queued = () => db.select().from(agentWakeupRequests).where(and( + eq(agentWakeupRequests.companyId, fixture.company.id), + eq(agentWakeupRequests.agentId, fixture.agents.lowTrust.id), + )).then(rows => rows.find(row => row.payload?.issueId === created.body.id && row.runId)); + await waitFor(async () => Boolean(await queued())); + const wake = (await queued())!; + expect(wake).toMatchObject({ requestedByActorType: "user", requestedByActorId: "board-user" }); + const [directRun] = await db.update(heartbeatRuns).set({ status: "running" }) + .where(eq(heartbeatRuns.id, wake.runId!)).returning(); + await db.update(issues).set({ status: "in_progress", checkoutRunId: directRun.id, executionRunId: directRun.id }) + .where(eq(issues.id, created.body.id)); + const directedAgent = createApp(db, { ...agentActor(fixture), runId: directRun.id }); + const read = await request(directedAgent).get(`/api/issues/${created.body.id}`); + expect(read.status, JSON.stringify(read.body)).toBe(200); + const reply = await request(directedAgent).post(`/api/issues/${created.body.id}/comments`) + .send({ body: "I can work on this human-directed task" }); + expect(reply.status, JSON.stringify(reply.body)).toBe(201); + expect(reply.body.sourceTrust).toMatchObject({ preset: LOW_TRUST_REVIEW_PRESET, disposition: "quarantined" }); + const updated = await request(directedAgent).patch(`/api/issues/${created.body.id}`) + .send({ description: "Completed the requested analysis" }); + expect(updated.status, JSON.stringify(updated.body)).toBe(200); + expect(updated.body.description).toBe("Completed the requested analysis"); + // The human requester does not authorize reads outside this run's task. + const foreignRead = await request(directedAgent).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}`); + expect([403, 404]).toContain(foreignRead.status); + expectNoCanary(foreignRead.body, fixture.canaries.issueSibling); + + await db.update(agentWakeupRequests).set({ requestedByActorType: "agent", requestedByActorId: fixture.agents.collaborator.id }) + .where(eq(agentWakeupRequests.id, wake.id)); + const forgedRead = await request(directedAgent).get(`/api/issues/${created.body.id}`); + expect([403, 404]).toContain(forgedRead.status); + + await db.update(agentWakeupRequests).set({ requestedByActorType: "user", requestedByActorId: "board-user" }) + .where(eq(agentWakeupRequests.id, wake.id)); + const handoff = await request(directedAgent).patch(`/api/issues/${created.body.id}`) + .send({ status: "in_review", assigneeAgentId: null, assigneeUserId: "board-user", comment: "Ready for human review" }); + expect(handoff.status, JSON.stringify(handoff.body)).toBe(200); + const [stopped] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, directRun.id)); + expect(stopped).toMatchObject({ status: "cancelled", errorCode: "issue_reassigned" }); + const returned = await request(board).patch(`/api/issues/${created.body.id}`) + .send({ status: "backlog", assigneeAgentId: fixture.agents.lowTrust.id, assigneeUserId: null }); + expect(returned.status, JSON.stringify(returned.body)).toBe(200); + // Reassignment back does not revive the cancelled run's human request. + const staleRead = await request(directedAgent).get(`/api/issues/${created.body.id}`); + expect([403, 404]).toContain(staleRead.status); + + }); + + it("retains a board backlog assignment and rejects plugin-attributed wakes with forged board payloads", async () => { + const fixture = await seedLowTrustFixture(db); + await db.insert(authUsers).values({ id: "board-user", name: "Owner", email: "owner@example.test", createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: fixture.company.id, principalType: "user", principalId: "board-user", membershipRole: "owner", status: "active" }); + await db.update(agents).set({ runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } }) + .where(eq(agents.id, fixture.agents.lowTrust.id)); + const board = createApp(db, boardActor(fixture)); + const created = await request(board).post(`/api/companies/${fixture.company.id}/issues`) + .send({ title: "Human backlog assignment", status: "backlog", assigneeAgentId: fixture.agents.lowTrust.id }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + const receipts = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, fixture.company.id)); + expect(receipts.filter(w => w.payload?.issueId === created.body.id)).toEqual([expect.objectContaining({ + reason: "issue_human_assignment", status: "completed", runId: null, requestedByActorId: "board-user", + })]); + await issueService(db).update(created.body.id, { status: "todo" }); + const systemRun = await heartbeatService(db).wakeup(fixture.agents.lowTrust.id, { + source: "automation", reason: "issue_ready", requestedByActorType: "system", + payload: { issueId: created.body.id }, contextSnapshot: { issueId: created.body.id, source: "automatic_promotion" }, + }); + expect(systemRun).toBeTruthy(); + await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, systemRun!.id)); + const worker = createApp(db, { ...agentActor(fixture), runId: systemRun!.id }); + expect((await request(worker).get(`/api/issues/${created.body.id}`)).status).toBe(200); + // These are the same service calls used by plugin issues.update; no HTTP + // cancellation is involved, and the run is deliberately left running. + await issueService(db).update(created.body.id, { assigneeAgentId: fixture.agents.collaborator.id }); + await issueService(db).update(created.body.id, { assigneeAgentId: fixture.agents.lowTrust.id }); + expect([403, 404]).toContain((await request(worker).get(`/api/issues/${created.body.id}`)).status); + const pluginRun = await heartbeatService(db).wakeup(fixture.agents.lowTrust.id, { + source: "automation", reason: "issue_commented", requestedByActorType: "user", requestedByActorId: "board-user", + payload: { issueId: created.body.id, _paperclipWakeContext: { source: "issue.comment" } }, + contextSnapshot: { issueId: created.body.id, source: "plugin:example" }, + }); + expect(pluginRun).toBeTruthy(); + const pluginWake = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.runId, pluginRun!.id))) + .find(w => w.requestedByActorType === "user")!; + expect(pluginWake.payload?._paperclipWakeContext).toMatchObject({ source: "plugin:example" }); + await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, pluginRun!.id)); + const pluginWorker = createApp(db, { ...agentActor(fixture), runId: pluginRun!.id }); + expect([403, 404]).toContain((await request(pluginWorker).get(`/api/issues/${created.body.id}`)).status); + // Authenticated reassignment while still in backlog also retains direction. + const assigned = await request(board).patch(`/api/issues/${created.body.id}`) + .send({ status: "backlog", assigneeAgentId: fixture.agents.lowTrust.id }); + expect(assigned.status, JSON.stringify(assigned.body)).toBe(200); + expect((await request(pluginWorker).get(`/api/issues/${created.body.id}`)).status).toBe(200); + }); + it("allows bounded same-issue reads and writes while quarantining low-trust output", async () => { const fixture = await seedLowTrustFixture(db); const app = createApp(db, agentActor(fixture)); diff --git a/server/src/modules/active-run-watchdog/adapters/postgres.ts b/server/src/modules/active-run-watchdog/adapters/postgres.ts index 126bd70a395..ac46b6c0954 100644 --- a/server/src/modules/active-run-watchdog/adapters/postgres.ts +++ b/server/src/modules/active-run-watchdog/adapters/postgres.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, gt, gte, inArray, notInArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, gte, inArray, ne, notInArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -337,6 +337,8 @@ export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & Watch .where(and( eq(agentWakeupRequests.id, input.run.wakeupRequestId), eq(agentWakeupRequests.companyId, companyId), + // A late success fold cannot restore cancelled assignment authority. + ne(agentWakeupRequests.status, "cancelled"), )); } diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 2b00cc1b0d4..35c467d91e3 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -252,6 +252,7 @@ import { normalizeUploadAttachmentContentType, SVG_CONTENT_TYPE, } from "../attachment-types.js"; +import { retainBacklogHumanAssignment } from "../services/human-directed-work.js"; import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; import { shouldWakeAssigneeForIssueComment } from "../services/issue-comment-wakeup.js"; import { createSecretProposalsService } from "../services/secret-proposals.js"; @@ -11816,6 +11817,7 @@ export function issueRoutes( }); return; } + await retainBacklogHumanAssignment(db, issue, actor); await issueReferencesSvc.syncIssue(issue.id); await externalObjectsSvc.syncIssueSafely(issue.id); const referenceSummary = @@ -12215,6 +12217,7 @@ export function issueRoutes( }); } + await retainBacklogHumanAssignment(db, issue, actor); if (!serializationContext || !currentSerializedChild) { void queueIssueAssignmentWakeup({ heartbeat, @@ -12474,6 +12477,7 @@ export function issueRoutes( }); } + await retainBacklogHumanAssignment(db, issue, actor); if (!serializedBlockedChildIds.has(issue.id)) { void queueIssueAssignmentWakeup({ heartbeat, @@ -13818,6 +13822,10 @@ export function issueRoutes( } } + if (req.body.assigneeAgentId !== undefined) { + await retainBacklogHumanAssignment(db, issue, actor); + } + if (titleOrDescriptionChanged) { await issueReferencesSvc.syncIssue(issue.id); await externalObjectsSvc.syncIssueSafely(issue.id); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index ed1719e0280..c3362a20023 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -1,5 +1,6 @@ import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; +import { withHumanDirectedWork } from "./human-directed-work.js"; import { agents, authUsers, @@ -873,13 +874,17 @@ export function authorizationService(db: Db | DbTransaction) { }): Promise { const { issue, project } = await loadResourceContext(input.resource); const run = await loadRunPolicy(input.actor.runId, input.companyId, input.actorAgent.id); - return resolveCoreTrustPreset({ + const resolution = resolveCoreTrustPreset({ companyId: input.companyId, agent: input.actorAgent, project, issue, run, }); + if (!input.actor.runId || resolution.kind !== "low_trust_review") return resolution; + return withHumanDirectedWork(db, resolution, { + companyId: input.companyId, agentId: input.actorAgent.id, runId: input.actor.runId, + }); } async function issueIdIsDescendantOf(issueId: string, rootIssueId: string, companyId: string) { @@ -1036,6 +1041,10 @@ export function authorizationService(db: Db | DbTransaction) { if (input.resource.type !== "issue") { return lowTrustDeny("Low-trust issue access is missing an issue resource."); } + if (input.resolution.humanDirectedIssueId && input.resource.companyId === boundary.companyId && + input.resource.issueId === input.resolution.humanDirectedIssueId) { + return lowTrustAllow("Allowed on the exact task directed by an authenticated human."); + } if (input.action === "issue:comment" && input.directParentReportTarget) { if ( input.resource.issueId && diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6cce1f365ea..57f0c5fa5b3 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -13126,7 +13126,9 @@ export function heartbeatService( await db .update(agentWakeupRequests) .set({ status, ...patch, updatedAt: new Date() }) - .where(eq(agentWakeupRequests.id, wakeupRequestId)); + // Reassignment revokes requests transactionally. Late execution settlement + // must not turn a revoked request back into authority for a later retry. + .where(and(eq(agentWakeupRequests.id, wakeupRequestId), ne(agentWakeupRequests.status, "cancelled"))); } async function addContinuationExhaustedCommentOnce(input: { @@ -17191,6 +17193,7 @@ export function heartbeatService( eq(agentWakeupRequests.companyId, current.companyId), eq(agentWakeupRequests.agentId, current.agentId), eq(agentWakeupRequests.runId, current.id), + ne(agentWakeupRequests.status, "cancelled"), ), ); await tx @@ -17929,7 +17932,7 @@ export function heartbeatService( await tx .update(agentWakeupRequests) .set({ status: "queued", claimedAt: null, updatedAt: now }) - .where(eq(agentWakeupRequests.id, released.wakeupRequestId)); + .where(and(eq(agentWakeupRequests.id, released.wakeupRequestId), ne(agentWakeupRequests.status, "cancelled"))); } const context = parseObject(released.contextSnapshot); @@ -26700,6 +26703,10 @@ export function heartbeatService( triggerDetail, payload, }); + // Keep each request's own server-derived origin, including coalesced wakes. + // A run's merged context cannot establish which caller authored one receipt. + // Overwrite caller-supplied nested context rather than trusting it. + payload = { ...payload, [DEFERRED_WAKE_CONTEXT_KEY]: { ...enrichedContextSnapshot } }; let issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload; if (executionReconciliationWake && !issueId) return null; diff --git a/server/src/services/human-directed-work.ts b/server/src/services/human-directed-work.ts new file mode 100644 index 00000000000..477aaaad208 --- /dev/null +++ b/server/src/services/human-directed-work.ts @@ -0,0 +1,94 @@ +import { and, eq, sql } from "drizzle-orm"; +import { agentWakeupRequests, issues } from "@paperclipai/db"; +import type { Db } from "@paperclipai/db"; +import type { TrustPresetResolution } from "./trust-preset-resolver.js"; + +/** + * Derive exact-task authority from existing server-owned execution records. + * Responsible-user attribution alone is insufficient: it is also inherited by + * agent-created work. Never read authority or retry ancestry from tool payloads. + */ +export async function withHumanDirectedWork( + db: Pick, + resolution: TrustPresetResolution, + input: { companyId: string; agentId: string; runId: string }, +): Promise { + if (resolution.kind !== "low_trust_review" || + resolution.boundary.companyId !== input.companyId) return resolution; + + // One snapshot binds the live run, current assignee, and authenticated wake. + // Reassignment cancels the old request records in the assignment transaction. + // Neither those records nor cancelled executions can lend authority to retries. + // UNION deduplicates rows so a malformed retry cycle terminates without a cap. + const [issue] = await db.execute<{ id: string }>(sql` + WITH RECURSIVE current_task AS ( + SELECT i.id, i.conversation_agent_id, i.conversation_user_id, r.id AS run_id + FROM heartbeat_runs r + JOIN issues i ON i.id::text = coalesce(r.context_snapshot->>'issueId', r.context_snapshot->>'taskId') + AND i.company_id = r.company_id AND i.assignee_agent_id = r.agent_id + WHERE r.id = ${input.runId}::uuid AND r.company_id = ${input.companyId}::uuid + AND r.agent_id = ${input.agentId}::uuid AND r.status = 'running' + ), task_runs AS ( + SELECT r.id, r.retry_of_run_id + FROM heartbeat_runs r JOIN current_task t ON t.run_id = r.id + UNION + SELECT parent.id, parent.retry_of_run_id + FROM heartbeat_runs parent JOIN task_runs child ON parent.id = child.retry_of_run_id + JOIN current_task t ON t.id::text = coalesce(parent.context_snapshot->>'issueId', parent.context_snapshot->>'taskId') + WHERE parent.company_id = ${input.companyId}::uuid AND parent.agent_id = ${input.agentId}::uuid + AND parent.status <> 'cancelled' + ) + SELECT t.id FROM current_task t + WHERE (t.conversation_agent_id = ${input.agentId}::uuid AND t.conversation_user_id IS NOT NULL) + OR EXISTS ( + SELECT 1 FROM agent_wakeup_requests w + WHERE (w.run_id IN (SELECT id FROM task_runs) OR + (w.run_id IS NULL AND w.reason = 'issue_human_assignment' AND w.status = 'completed')) + AND w.company_id = ${input.companyId}::uuid AND w.agent_id = ${input.agentId}::uuid + AND w.requested_by_actor_type = 'user' AND nullif(trim(w.requested_by_actor_id), '') IS NOT NULL + AND w.status NOT IN ('skipped', 'cancelled') + -- The source is copied from server-owned context at admission, never + -- from caller payloads. Plugins can attribute a user but use plugin: sources. + AND (w.payload->'_paperclipWakeContext'->>'source' LIKE 'issue.%' + -- Before origin snapshots were retained, board assignment requests + -- already had this closed source/reason/actor combination. Plugin + -- assignment wakes are system-attributed; plugin human comments are + -- automation wakes. External connector receipts are excluded below. + OR (NOT (w.payload ? '_paperclipWakeContext') + AND w.source = 'assignment' AND w.reason = 'issue_assigned')) + AND coalesce(w.payload->>'issueId', w.payload->>'taskId', + w.payload->'_paperclipWakeContext'->>'issueId', w.payload->'_paperclipWakeContext'->>'taskId') = t.id::text + -- Connector sender attribution can also use a user id. Its durable + -- inbound receipts are not authenticated board instructions. + AND (w.idempotency_key IS NULL OR w.idempotency_key NOT LIKE 'chat-inbound:%') + ) + `); + return issue ? { ...resolution, humanDirectedIssueId: issue.id } : resolution; +} + +/** Preserve a board assignment made before a task is runnable, without starting it. + * Uses the existing requester columns, not another user identity or grant table. + * Call only with the authenticated HTTP actor and the committed write result. + */ +export async function retainBacklogHumanAssignment( + db: Db, + issue: { id: string; companyId: string; assigneeAgentId: string | null; status: string; statusVersion: number }, + actor: { actorType: string; actorId: string }, +) { + if (actor.actorType !== "user" || !actor.actorId.trim() || issue.status !== "backlog" || !issue.assigneeAgentId) return; + await db.transaction(async (tx) => { + // An assignment that changed (even A -> B -> A) before this callback cannot + // revive stale direction. Reassignment and this receipt share the issue lock. + const [current] = await tx.select({ id: issues.id }).from(issues).where(and( + eq(issues.id, issue.id), eq(issues.companyId, issue.companyId), + eq(issues.assigneeAgentId, issue.assigneeAgentId!), eq(issues.statusVersion, issue.statusVersion), + )).for("update"); + if (!current) return; + await tx.insert(agentWakeupRequests).values({ + companyId: issue.companyId, agentId: issue.assigneeAgentId!, source: "assignment", + reason: "issue_human_assignment", status: "completed", finishedAt: new Date(), + requestedByActorType: "user", requestedByActorId: actor.actorId, + payload: { issueId: issue.id, _paperclipWakeContext: { source: "issue.backlog_assignment" } }, + }); + }); +} diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 4e5a9e71541..7ff415d5250 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -2,6 +2,7 @@ import { currentContinuationOrigins, deliveredContinuationCommentIds, } from "./execution-continuation.js"; +import { isUniqueViolation } from "../db-errors.js"; import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js"; import { connectionIntentDeliveries } from "@paperclipai/db"; import { isDeepStrictEqual } from "node:util"; @@ -583,20 +584,6 @@ function isUserCommentSupersedableKind( ).includes(kind); } -function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean { - if (typeof error !== "object" || error === null) return false; - const err = error as { - code?: string; - constraint?: string; - constraint_name?: string; - }; - const constraint = err.constraint ?? err.constraint_name; - return ( - err.code === "23505" && - constraint === ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT - ); -} - function isEquivalentCreateRequest( row: IssueThreadInteractionRow, input: CreateIssueThreadInteraction, @@ -3730,7 +3717,7 @@ export function issueThreadInteractionService( } catch (error) { if ( !normalizedData.idempotencyKey || - !isIssueThreadInteractionIdempotencyConflict(error) + !isUniqueViolation(error, ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT) ) { throw error; } diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 18585836a40..0e6c8611939 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -10950,6 +10950,15 @@ export function issueService(db: Db) { if ((issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== receiptExisting.assigneeAgentId) || (issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== receiptExisting.assigneeUserId)) { patch.statusVersion = sql`${issues.statusVersion} + 1` as unknown as number; + // Invalidate human direction at the common assignment boundary, including + // plugin/service writes that do not go through HTTP run cancellation. + // Keep the requester attribution for audit; cancellation revokes its use. + await tx.update(agentWakeupRequests).set({ status: "cancelled", finishedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(agentWakeupRequests.companyId, receiptExisting.companyId), + eq(agentWakeupRequests.requestedByActorType, "user"), + sql`coalesce(${agentWakeupRequests.payload}->>'issueId', ${agentWakeupRequests.payload}->>'taskId', + ${agentWakeupRequests.payload}->'_paperclipWakeContext'->>'issueId', + ${agentWakeupRequests.payload}->'_paperclipWakeContext'->>'taskId') = ${id}`)); } // Reasserting Blocked or changing its blockers is a fresh decision even // when the status string stays the same. Invalidate recovery's prior diff --git a/server/src/services/low-trust-runtime-containment.ts b/server/src/services/low-trust-runtime-containment.ts index 22ce04d6d9d..613123f5620 100644 --- a/server/src/services/low-trust-runtime-containment.ts +++ b/server/src/services/low-trust-runtime-containment.ts @@ -69,6 +69,8 @@ export async function assertLowTrustWorkspaceIsolation(input: { } if ( !input.issue || + !(input.resolution.humanDirectedIssueId && input.issue.companyId === input.resolution.boundary.companyId && + input.issue.id === input.resolution.humanDirectedIssueId) && !(await workspaceIssueWithinLowTrustBoundary({ db: input.db, boundary: input.resolution.boundary, diff --git a/server/src/services/run-trust-preset.ts b/server/src/services/run-trust-preset.ts index 20cf0da92b8..b02094114ca 100644 --- a/server/src/services/run-trust-preset.ts +++ b/server/src/services/run-trust-preset.ts @@ -1,6 +1,7 @@ import { and, eq, sql } from "drizzle-orm"; import { heartbeatRuns, type Db } from "@paperclipai/db"; import { conflict } from "../errors.js"; +import { withHumanDirectedWork } from "./human-directed-work.js"; import { resolveCoreTrustPreset, type ResolveCoreTrustPresetInput, @@ -61,6 +62,9 @@ export async function resolveAndRetainRunTrustPreset( updatedAt: new Date(), }) .where(scope); - return { trustPreset, executionPolicy }; + return { trustPreset: await withHumanDirectedWork(tx, trustPreset, { + companyId: input.companyId, agentId: input.agentId, + runId: input.runId, + }), executionPolicy }; }); } diff --git a/server/src/services/trust-preset-resolver.ts b/server/src/services/trust-preset-resolver.ts index 97529840bcd..7b0ab3b7f6b 100644 --- a/server/src/services/trust-preset-resolver.ts +++ b/server/src/services/trust-preset-resolver.ts @@ -53,6 +53,8 @@ export type TrustPresetResolution = | { kind: "low_trust_review"; preset: typeof LOW_TRUST_REVIEW_PRESET; + /** Server-derived authority for this run's exact task; never a policy input. */ + humanDirectedIssueId?: string; boundary: LowTrustBoundary & { companyId: string }; sourcePresets: Partial>; }