mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
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:
1 parent
467125fafb
commit
0829d94af2
16 files changed
+588
-25
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user