fix(auth): derive low-trust human direction from existing execution records (#14775)

## 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-01 09:45:18 -05:00
1 parent 467125fafb
commit 0829d94af2
16 files changed
+588 -25

No files matched your search

+31 -1
View File
@@ -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
+9
View File
@@ -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
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
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<ReturnType<typeof seed>>) {
return issueService(db).create(f.companyId, {
title: `Direct task ${randomUUID()}`, assigneeAgentId: f.agent.id,
createdByUserId: "owner", responsibleUserId: "owner",
});
}
async function execution(f: Awaited<ReturnType<typeof seed>>, issue: Awaited<ReturnType<typeof task>>,
options: { human?: boolean; context?: Record<string, unknown>; 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<ReturnType<typeof seed>>, issue: Awaited<ReturnType<typeof task>>,
options: Parameters<typeof execution>[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<ReturnType<typeof seed>>, issue: Awaited<ReturnType<typeof task>>, 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<ReturnType<typeof task>>) => ({ 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<ReturnType<typeof task>>);
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<void>((resolve) => { release = resolve; });
const changed = new Promise<void>((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");
});
});
@@ -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({
@@ -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<void>(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<ReturnType<typeof interactionsSvc.create>>;
let explicit!: Awaited<ReturnType<typeof interactionsSvc.create>>;
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);
@@ -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));
@@ -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"),
));
}
+8
View File
@@ -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);
+10 -1
View File
@@ -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<TrustPresetResolution> {
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 &&
+9 -2
View File
@@ -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;
@@ -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<Db, "execute">,
resolution: TrustPresetResolution,
input: { companyId: string; agentId: string; runId: string },
): Promise<TrustPresetResolution> {
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" } },
});
});
}
@@ -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;
}
+9
View File
@@ -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
@@ -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,
+5 -1
View File
@@ -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 };
});
}
@@ -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<Record<TrustPresetPolicySource, TrustPreset>>;
}