mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
fix(interactions): derive chat recipients and validate explicit users (#14742)
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Agents use saved questions to get human input and continue the same task. > - The standard question example recently told models to copy a user ID. > - A model can omit an identity prefix and create a question its intended recipient cannot answer. > - Agent Chat already knows the conversation owner, so the server can supply that identity. > - This pull request removes the blanket instruction and validates explicit recipients before saving. > - Ordinary questions stay simple, and explicit addressing remains available for decisions that need a particular person. ## Linked Issues or Issue Description Refs #14707, #14188. Related: #14238 handles legacy email recipients; this change prevents invalid recipients in new cards and retains exact ID matching. **What happened?** A model copied a Cloud user ID without its prefix into `addresseeUserId`. Creation succeeded. The intended user's answer then failed the exact recipient check. **Expected behavior** Ordinary chat questions use the saved conversation owner. A task may optionally name a specific recipient. The API rejects an unknown or unauthorized recipient before it creates a card. **Steps to reproduce** Create a chat question for a user whose ID is `paperclip-id:example`. Supply `example` as the addressee. Before this change, creation accepts the invalid recipient and the owner cannot answer. With this change, creation returns 422. Omitting the field saves the full owner ID and allows that owner to answer. ## What Changed - Remove `addresseeUserId` from standard question examples and remove the blanket requester-ID instruction. - Derive the recipient of ordinary chat questions from the persisted conversation owner. Reject conflicting explicit user IDs. - Keep explicit task recipients optional. Validate supplied user IDs with the existing board mutation policy, including company, viewer, and Cloud restrictions. - Preserve explicit agent routing, connector intents, confirmations, exact recipient checks, idempotent retries, and no-login local-board authority in local-trusted mode. - Update the blocker grader to accept an omitted recipient and verify the actual requester answered. - Add database and HTTP tests for prefixed identities, denied recipients, concurrent retries, saved answers, and response delivery. ## Verification - Database interaction service suite: 90 tests passed, including implicit local-board creation/answering and authenticated/Cloud denial. - Interaction HTTP route suite: 84 tests passed. - Affected interaction/native/connector/documentation suites: 231 tests passed across six files after valid-user fixtures were updated. - Resolver and interaction unit suites: 29 tests passed. - Product E2E unit/calibration suite: 793 tests passed; Product E2E typecheck and blocker catalog discovery passed. - Generated API-reference and capability contract checks passed. - `pnpm -r typecheck` and `pnpm build` passed. - Full local `pnpm test:run` did not finish green: its initial general-server pass had 14,416 passing assertions, one unrelated native-resume assertion failure on macOS, and three teardowns from an intermediate fixture cleanup fixed above. Separate broad local groups also encountered timeout/live-port failures under host load. Local UI (7,026), CLI (502), shared (817), and skills-catalog (20) tests passed; the complete final-head CI matrix is the broad verification gate. - After two CI cold-start readiness timeouts, a separate test-only commit gives the first exposure lifecycle fixture the existing normal 30-second readiness budget. Its real HTTP, ordering, and cleanup assertions remain intact; the targeted case and final Linux CI shard passed. Production deadlines are unchanged. - A separate OpenCode fixture failed twice on GitHub-hosted Ubuntu because its cached Node executable was group-writable; the same case passed on AWS runners. The fixture now qualifies its own Linux copy with mode `0500` and the actual copy digest. Host files and production security checks are unchanged. The focused macOS case passed; the new Linux-copy branch also passed on the final AWS-hosted Linux runner (1,125 passing Runner tests, 3 skipped). The final run was not on a GitHub-hosted runner. - Final-head [CI run 36762078176](https://github.com/paperclipai/paperclip/actions/runs/36762078176) passed for `116b968b24fa0a8c5724a7bf96e73a8dda5f0425`: 54 successful checks and two conditional Storybook skips, with no pending or failed checks. The 27 general/serialized test jobs reported 28,635 passing tests. Typecheck, build, Runner, browser E2E, and Canary gates passed. Greptile reviewed that exact head at 5/5; both review threads are resolved, with no open follow-ups. - No live provider replay is claimed by this PR. ## Risks - New explicitly addressed cards reject users who cannot mutate the issue, including viewers, inactive members, and invalid IDs. Callers that supplied invalid recipients must correct their request. - Existing addressed cards are not rewritten. Existing authorization checks remain strict. - Chat inference applies only to questions without an agent addressee. Connector intents and governed confirmations retain their own recipient paths. - No schema change or migration is required. ## Model Used OpenAI Codex, GPT-6 (exact serving variant and context window are not exposed in this environment). Used reasoning, tool use, code editing, and test execution. ## 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
b54b2dc35c
commit
cbd278dc03
20 files changed
+350
-41
No files matched your search
@@ -739,8 +739,12 @@ outcomes and resolver attribution are immutable.
|
||||
|
||||
An explicit named agent or user addressee and a company-configured cap may narrow
|
||||
the effective audience. Only the exact named addressee may resolve an addressed
|
||||
interaction; a human does not override a user addressee. A cap never widens the
|
||||
requested audience. Tool-action confirmations and
|
||||
interaction; a human does not override a user addressee. Explicit user recipients
|
||||
must exist and be authorized to respond in the issue's company before creation.
|
||||
Ordinary Agent Chat questions derive their user recipient from the persisted
|
||||
conversation owner. A conflicting explicit user recipient is rejected; IDs are
|
||||
never guessed or repaired. Ordinary task questions keep optional addressing.
|
||||
A cap never widens the requested audience. Tool-action confirmations and
|
||||
other hard-governed action cards remain `human_only` (or move to the formal approval
|
||||
system) regardless of a requested open audience.
|
||||
|
||||
|
||||
@@ -289,6 +289,14 @@ There is no separate messaging or chat system. Tasks are the communication chann
|
||||
|
||||
Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. Chat has a searchable secondary sidebar with agent avatars; adding an agent starts or reopens their single conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with ordinary company visibility. New execution tasks are ordinary project tasks, not children of the conversation. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`.
|
||||
|
||||
### Question recipients
|
||||
|
||||
Ordinary Agent Chat questions use the server-owned conversation recipient.
|
||||
Task questions may optionally name a particular user or agent. Explicit user
|
||||
recipients must be valid and authorized in the company before a question is
|
||||
saved. See `SPEC-implementation.md` §9.8.1 for the resolver contract.
|
||||
|
||||
|
||||
### Implications
|
||||
|
||||
- An agent's "inbox" is: tasks assigned to them + comments on tasks they're involved in
|
||||
|
||||
@@ -7527,13 +7527,22 @@ it.each([true, false])("preserves prepared OpenCode cleanup errors (primary fail
|
||||
|
||||
it("preserves prepared input through runnerd and the real OpenCode proxy boundary", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "runnerd-prepared-opencode-"));
|
||||
// GitHub-hosted Linux toolcache Node can be group-writable, unlike the AWS
|
||||
// fleet. Qualify an owned copy with strict permissions, never chmod the host
|
||||
// runtime or weaken the launch boundary. Keep macOS's native runtime path
|
||||
// because its signing and dylib lookup can depend on that location.
|
||||
const providerNode = process.platform === "linux" ? join(root, "node") : process.execPath;
|
||||
if (process.platform === "linux") {
|
||||
await cp(process.execPath, providerNode);
|
||||
await chmod(providerNode, 0o500);
|
||||
}
|
||||
// The qualified launch boundary unlinks its executable after exec. Use a
|
||||
// native wrapper, like the real OpenCode binary; a shebang script would need
|
||||
// to reopen the now-unlinked path in its interpreter.
|
||||
const executable = join(root, "fake-opencode");
|
||||
const fixture = resolve("test/fixtures/fake-opencode-server.mjs");
|
||||
execFileSync("cc", ["-x", "c", "-o", executable, "-"], {
|
||||
input: `#include <unistd.h>\n#include <stdlib.h>\nint main(int argc, char **argv) { char **args = calloc(argc + 2, sizeof(char *)); args[0] = ${JSON.stringify(process.execPath)}; args[1] = ${JSON.stringify(fixture)}; for (int i = 1; i < argc; i++) args[i + 1] = argv[i]; execv(args[0], args); return 127; }`,
|
||||
input: `#include <unistd.h>\n#include <stdlib.h>\nint main(int argc, char **argv) { char **args = calloc(argc + 2, sizeof(char *)); args[0] = ${JSON.stringify(providerNode)}; args[1] = ${JSON.stringify(fixture)}; for (int i = 1; i < argc; i++) args[i + 1] = argv[i]; execv(args[0], args); return 127; }`,
|
||||
});
|
||||
// CI may use umask 0002; qualified executables cannot be group-writable.
|
||||
await chmod(executable, 0o755);
|
||||
@@ -7558,8 +7567,8 @@ it("preserves prepared input through runnerd and the real OpenCode proxy boundar
|
||||
opencodeCommandSha256: digest(executable),
|
||||
opencodeProxyPath: proxy,
|
||||
opencodeProxySha256: digest(proxy),
|
||||
providerNodeCommand: process.execPath,
|
||||
providerNodeCommandSha256: digest(process.execPath),
|
||||
providerNodeCommand: providerNode,
|
||||
providerNodeCommandSha256: digest(providerNode),
|
||||
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
||||
});
|
||||
const task = createCodexTaskEnvelope({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, agents, companies, createDb, documents, documentRevisions, heartbeatRuns,
|
||||
import { activityLog, agents, authUsers, companies, companyMemberships, createDb, documents, documentRevisions, heartbeatRuns,
|
||||
issueComments, issueDocuments, issues, issueThreadInteractions } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { resolveConfirmationFromComment } from "../services/confirmation-comment-resolution.js";
|
||||
@@ -26,6 +26,10 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
async function seed(checkbox = false, conversation = false) {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Replies", issuePrefix: companyId.slice(0, 8) });
|
||||
if (conversation) {
|
||||
await db.insert(authUsers).values({ id: "operator", name: "Operator", email: "operator@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "operator", status: "active", membershipRole: "member" });
|
||||
}
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Planner", adapterType: "paperclip_runner" });
|
||||
const [issue] = await db.insert(issues).values({ id: issueId, companyId, title: "Proposal", status: "in_progress", assigneeAgentId: agentId,
|
||||
...(conversation ? { conversationAgentId: agentId, conversationUserId: "operator", conversationState: "active", conversationSessionGeneration: 1 } : {}) }).returning();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
goals,
|
||||
@@ -54,6 +55,10 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
issuePrefix: "AGG",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(authUsers).values({
|
||||
id: "responsible-user", name: "Responsible user", email: "responsible-user@example.test",
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
|
||||
@@ -34,7 +34,8 @@ describe("published hiring and human-input examples", () => {
|
||||
const section = skill.split('<a id="asking-for-human-input"></a>')[1]!;
|
||||
const body = JSON.parse(section.match(/```json\n([\s\S]*?)\n```/)![1]);
|
||||
expect(createIssueThreadInteractionSchema.safeParse(substituteIds(body))).toMatchObject({ success: true });
|
||||
expect(body).toMatchObject({ addresseeUserId: "{requesting-user-id}", resolverPolicy: "human_only", continuationPolicy: "wake_assignee" });
|
||||
expect(body).toMatchObject({ resolverPolicy: "human_only", continuationPolicy: "wake_assignee" });
|
||||
expect(body).not.toHaveProperty("addresseeUserId");
|
||||
expect(body.payload.questionSet.questions[0]).toMatchObject({ answerMode: "text" });
|
||||
expect(body.payload.questions[0].id).toBe(body.payload.questionSet.questions[0].id);
|
||||
});
|
||||
|
||||
@@ -824,6 +824,30 @@ describe.sequential("issue thread interaction routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["paperclip-id:chat-owner", "chat-owner", "paperclip-id:another-user"])("keeps chat response delivery limited to the exact owner: %s", async (userId) => {
|
||||
const issue = createIssue({ conversationAgentId: ASSIGNEE_AGENT_ID, conversationUserId: "paperclip-id:chat-owner" });
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockInteractionService.getForIssue.mockResolvedValue({
|
||||
id: "interaction-2", kind: "ask_user_questions", status: "pending",
|
||||
requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only",
|
||||
addresseeUserId: "paperclip-id:chat-owner", continuationPolicy: "wake_assignee",
|
||||
payload: { version: 1, questions: [] },
|
||||
});
|
||||
const app = await createApp({ type: "board", userId, source: "cloud_tenant", companyIds: ["company-1"] });
|
||||
const response = await request(app).post(`/api/issues/${ISSUE_ID}/interactions/interaction-2/respond`)
|
||||
.send({ answers: [{ questionId: "scope", optionIds: ["phase-1"] }] });
|
||||
if (userId === "paperclip-id:chat-owner") {
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockInteractionService.answerQuestions).toHaveBeenCalledWith(expect.anything(), "interaction-2", expect.anything(), expect.objectContaining({ userId }));
|
||||
expect(mockQuestionResponseDeliveries.deliver).toHaveBeenCalledWith("interaction-2");
|
||||
} else {
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockInteractionService.answerQuestions).not.toHaveBeenCalled();
|
||||
expect(mockQuestionResponseDeliveries.deliver).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes wake-on-accept question answers through the same causal delivery service", async () => {
|
||||
mockInteractionService.answerQuestions.mockResolvedValueOnce({
|
||||
id: "interaction-2",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
companyMemberships,
|
||||
instanceUserRoles,
|
||||
issueQuestionResponseDeliveries,
|
||||
companies,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
@@ -48,6 +52,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueComments);
|
||||
@@ -64,7 +69,10 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
await db.delete(goals);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(instanceUserRoles);
|
||||
await db.delete(companies);
|
||||
await db.delete(authUsers);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -205,6 +213,167 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
};
|
||||
}
|
||||
|
||||
async function seedQuestionUser(companyId: string, userId: string, role = "member", status = "active") {
|
||||
await db.insert(authUsers).values({ id: userId, name: "Question recipient", email: `${randomUUID()}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: role, status });
|
||||
}
|
||||
|
||||
async function seedChatQuestion() {
|
||||
const fixture = await seedSourceQuestionFixture({});
|
||||
const userId = "paperclip-id:question-owner";
|
||||
await seedQuestionUser(fixture.companyId, userId);
|
||||
await db.update(issues).set({
|
||||
conversationAgentId: fixture.agentId, conversationUserId: userId, conversationState: "active",
|
||||
assigneeAgentId: fixture.agentId,
|
||||
}).where(eq(issues.id, fixture.issueId));
|
||||
return { ...fixture, userId };
|
||||
}
|
||||
|
||||
it("derives the exact Cloud chat owner, denies other responders, and saves one answer delivery", async () => {
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-cloud-token");
|
||||
const fixture = await seedChatQuestion();
|
||||
const scope = { id: fixture.issueId, companyId: fixture.companyId };
|
||||
const created = await interactionsSvc.create(scope, {
|
||||
...questionCreateInput(fixture.runId), resolverPolicy: "human_only",
|
||||
}, { agentId: fixture.agentId });
|
||||
expect(created.addresseeUserId).toBe(fixture.userId);
|
||||
const answer = { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] };
|
||||
for (const userId of ["question-owner", "paperclip-id:other-user"]) {
|
||||
await expect(interactionsSvc.answerQuestions(scope, created.id, answer, { userId }))
|
||||
.rejects.toMatchObject({ status: 403, details: { code: "interaction_addressee_mismatch" } });
|
||||
}
|
||||
await expect(interactionsSvc.answerQuestions(scope, created.id, answer, { agentId: fixture.agentId, runId: fixture.runId }))
|
||||
.rejects.toMatchObject({ status: 403 });
|
||||
expect(await db.select().from(issueQuestionResponseDeliveries)).toHaveLength(0);
|
||||
const answered = await interactionsSvc.answerQuestions(scope, created.id, answer, { userId: fixture.userId });
|
||||
expect(answered).toMatchObject({ status: "answered", resolvedByUserId: fixture.userId, continuationPolicy: "wake_assignee" });
|
||||
await expect(interactionsSvc.answerQuestions(scope, created.id, answer, { userId: fixture.userId }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
expect(await db.select().from(issueQuestionResponseDeliveries)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reuses concurrent omitted and explicit chat-owner requests and legacy omitted recipients", async () => {
|
||||
const fixture = await seedChatQuestion();
|
||||
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),
|
||||
]);
|
||||
expect(explicit.id).toBe(omitted.id);
|
||||
expect(explicit.addresseeUserId).toBe(fixture.userId);
|
||||
expect(await db.select().from(issueThreadInteractions)).toHaveLength(1);
|
||||
await db.update(issueThreadInteractions).set({ addresseeUserId: null }).where(eq(issueThreadInteractions.id, omitted.id));
|
||||
expect((await interactionsSvc.create(scope, input, actor)).id).toBe(omitted.id);
|
||||
});
|
||||
|
||||
it.each(["question-owner", "paperclip-id:another-member"])("rejects contradictory chat recipient %s before saving", async (addresseeUserId) => {
|
||||
const fixture = await seedChatQuestion();
|
||||
await seedQuestionUser(fixture.companyId, "paperclip-id:another-member");
|
||||
await expect(interactionsSvc.create({ id: fixture.issueId, companyId: fixture.companyId }, {
|
||||
...questionCreateInput(fixture.runId), addresseeUserId,
|
||||
}, { agentId: fixture.agentId })).rejects.toMatchObject({
|
||||
status: 422, details: { code: "interaction_chat_addressee_mismatch" },
|
||||
});
|
||||
expect(await db.select().from(issueThreadInteractions)).toHaveLength(0);
|
||||
expect(await db.select().from(issueQuestionResponseDeliveries)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not infer chat recipients for confirmations or replace their explicit owner", async () => {
|
||||
const fixture = await seedChatQuestion();
|
||||
const scope = { id: fixture.issueId, companyId: fixture.companyId };
|
||||
const input = { kind: "request_confirmation" as const, payload: { version: 1 as const, prompt: "Proceed?" } };
|
||||
const actor = { agentId: fixture.agentId };
|
||||
expect(await interactionsSvc.create(scope, input, actor)).toMatchObject({ addresseeUserId: null });
|
||||
const addresseeUserId = "paperclip-id:confirmation-reviewer";
|
||||
await seedQuestionUser(fixture.companyId, addresseeUserId);
|
||||
expect(await interactionsSvc.create(scope, { ...input, addresseeUserId }, actor)).toMatchObject({ addresseeUserId });
|
||||
});
|
||||
|
||||
it("keeps ordinary task addressing optional and enforces valid explicit recipients", async () => {
|
||||
const fixture = await seedSourceQuestionFixture({});
|
||||
const scope = { id: fixture.issueId, companyId: fixture.companyId };
|
||||
const input = { ...questionCreateInput(fixture.runId), resolverPolicy: "human_only" as const };
|
||||
const actor = { agentId: fixture.agentId };
|
||||
const open = await interactionsSvc.create(scope, input, actor);
|
||||
expect(open.addresseeUserId).toBeNull();
|
||||
const userId = "paperclip-id:task-recipient";
|
||||
await seedQuestionUser(fixture.companyId, userId);
|
||||
const addressed = await interactionsSvc.create(scope, { ...input, addresseeUserId: userId }, actor);
|
||||
expect(addressed.addresseeUserId).toBe(userId);
|
||||
const answer = { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] };
|
||||
await expect(interactionsSvc.answerQuestions(scope, addressed.id, answer, { userId: "other-user" })).rejects.toMatchObject({ status: 403 });
|
||||
expect(await interactionsSvc.answerQuestions(scope, addressed.id, answer, { userId })).toMatchObject({ status: "answered", resolvedByUserId: userId });
|
||||
});
|
||||
|
||||
it.each(["unknown", "prefix-dropped", "cross-company", "inactive", "viewer"])("rejects %s explicit task recipients before saving", async (kind) => {
|
||||
const fixture = await seedSourceQuestionFixture({});
|
||||
const userId = "paperclip-id:task-recipient";
|
||||
if (kind !== "unknown") {
|
||||
const companyId = kind === "cross-company" ? (await seedConfirmationIssue()).companyId : fixture.companyId;
|
||||
await seedQuestionUser(companyId, userId, kind === "viewer" ? "viewer" : "member", kind === "inactive" ? "inactive" : "active");
|
||||
}
|
||||
await expect(interactionsSvc.create({ id: fixture.issueId, companyId: fixture.companyId }, {
|
||||
...questionCreateInput(fixture.runId), addresseeUserId: kind === "prefix-dropped" ? "task-recipient" : userId,
|
||||
}, { agentId: fixture.agentId })).rejects.toMatchObject({
|
||||
status: 422, details: { code: "interaction_addressee_user_unavailable" },
|
||||
});
|
||||
expect(await db.select().from(issueThreadInteractions)).toHaveLength(0);
|
||||
expect(await db.select().from(issueQuestionResponseDeliveries)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves valid instance-admin recipients", async () => {
|
||||
const userId = "instance-admin";
|
||||
const fixture = await seedSourceQuestionFixture({});
|
||||
await db.insert(authUsers).values({ id: userId, name: "Admin", email: `${randomUUID()}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(instanceUserRoles).values({ userId, role: "instance_admin" });
|
||||
expect(await interactionsSvc.create({ id: fixture.issueId, companyId: fixture.companyId }, {
|
||||
...questionCreateInput(fixture.runId), addresseeUserId: userId,
|
||||
}, { agentId: fixture.agentId })).toMatchObject({ addresseeUserId: userId });
|
||||
});
|
||||
|
||||
it("lets the implicit local board answer chat questions without an auth row", async () => {
|
||||
vi.stubEnv("PAPERCLIP_DEPLOYMENT_MODE", "local_trusted");
|
||||
const fixture = await seedSourceQuestionFixture({});
|
||||
await db.update(issues).set({
|
||||
conversationAgentId: fixture.agentId, conversationUserId: "local-board", conversationState: "active",
|
||||
assigneeAgentId: fixture.agentId,
|
||||
}).where(eq(issues.id, fixture.issueId));
|
||||
const scope = { id: fixture.issueId, companyId: fixture.companyId };
|
||||
const question = await interactionsSvc.create(scope, questionCreateInput(fixture.runId), { agentId: fixture.agentId });
|
||||
expect(question.addresseeUserId).toBe("local-board");
|
||||
expect(await db.select().from(authUsers)).toHaveLength(0);
|
||||
expect(await db.select().from(companyMemberships)).toHaveLength(0);
|
||||
expect(await interactionsSvc.answerQuestions(scope, question.id, {
|
||||
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
|
||||
}, { userId: "local-board" })).toMatchObject({ status: "answered", resolvedByUserId: "local-board" });
|
||||
});
|
||||
|
||||
it.each(["authenticated", "cloud"])("does not infer local-board authority in %s mode", async (mode) => {
|
||||
vi.stubEnv("PAPERCLIP_DEPLOYMENT_MODE", mode === "cloud" ? "local_trusted" : "authenticated");
|
||||
if (mode === "cloud") vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-cloud-token");
|
||||
const fixture = await seedSourceQuestionFixture({});
|
||||
await expect(interactionsSvc.create({ id: fixture.issueId, companyId: fixture.companyId }, {
|
||||
...questionCreateInput(fixture.runId), addresseeUserId: "local-board",
|
||||
}, { agentId: fixture.agentId })).rejects.toMatchObject({
|
||||
status: 422, details: { code: "interaction_addressee_user_unavailable" },
|
||||
});
|
||||
expect(await db.select().from(issueThreadInteractions)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not accept a stale Cloud instance-admin row as recipient authority", async () => {
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-cloud-token");
|
||||
const fixture = await seedSourceQuestionFixture({});
|
||||
const userId = "paperclip-id:stale-admin";
|
||||
await db.insert(authUsers).values({ id: userId, name: "Stale admin", email: `${randomUUID()}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(instanceUserRoles).values({ userId, role: "instance_admin" });
|
||||
await expect(interactionsSvc.create({ id: fixture.issueId, companyId: fixture.companyId }, {
|
||||
...questionCreateInput(fixture.runId), addresseeUserId: userId,
|
||||
}, { agentId: fixture.agentId })).rejects.toMatchObject({ status: 422 });
|
||||
expect(await db.select().from(issueThreadInteractions)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a source-run question when a newer human comment was not delivered", async () => {
|
||||
const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [] } });
|
||||
const commentId = randomUUID();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
DEPLOYMENT_MODES,
|
||||
findPaperclipConfigKeyWarnings,
|
||||
paperclipConfigSchema,
|
||||
type PaperclipConfig,
|
||||
type DeploymentMode,
|
||||
} from "@paperclipai/shared";
|
||||
import { ZodError } from "zod";
|
||||
import { resolvePaperclipConfigPath } from "./paths.js";
|
||||
@@ -45,3 +47,11 @@ export function readConfigFile(): PaperclipConfig | null {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the same deployment mode without loading config.ts's dotenv/startup side effects. */
|
||||
export function resolveDeploymentMode(fileConfig = readConfigFile()): DeploymentMode {
|
||||
const fromEnv = process.env.PAPERCLIP_DEPLOYMENT_MODE;
|
||||
return fromEnv && DEPLOYMENT_MODES.includes(fromEnv as DeploymentMode)
|
||||
? fromEnv as DeploymentMode
|
||||
: fileConfig?.server.deploymentMode ?? "local_trusted";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readConfigFile } from "./config-file.js";
|
||||
import { readConfigFile, resolveDeploymentMode } from "./config-file.js";
|
||||
import { parseChatWebhookPublicBaseUrl } from "./chat-webhook-public-url.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
AUTH_BASE_URL_MODES,
|
||||
BIND_MODES,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
DEPLOYMENT_MODES,
|
||||
SECRET_PROVIDERS,
|
||||
STORAGE_PROVIDERS,
|
||||
type BindMode,
|
||||
@@ -167,12 +166,7 @@ export function loadConfig(): Config {
|
||||
process.env.PAPERCLIP_TELEMETRY_BACKEND_TOKEN?.trim() ||
|
||||
undefined;
|
||||
|
||||
const deploymentModeFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_MODE;
|
||||
const deploymentModeFromEnv =
|
||||
deploymentModeFromEnvRaw && DEPLOYMENT_MODES.includes(deploymentModeFromEnvRaw as DeploymentMode)
|
||||
? (deploymentModeFromEnvRaw as DeploymentMode)
|
||||
: null;
|
||||
const deploymentMode: DeploymentMode = deploymentModeFromEnv ?? fileConfig?.server.deploymentMode ?? "local_trusted";
|
||||
const deploymentMode = resolveDeploymentMode(fileConfig);
|
||||
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
|
||||
const secretsStrictMode =
|
||||
strictModeFromEnv !== undefined
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
companySecretProposals,
|
||||
companies,
|
||||
documents,
|
||||
@@ -93,6 +94,9 @@ import {
|
||||
import { z } from "zod";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { authorizationService } from "./authorization.js";
|
||||
import { isCloudManagedInstance } from "./cloud-instance.js";
|
||||
import { resolveDeploymentMode } from "../config-file.js";
|
||||
import {
|
||||
logActivity,
|
||||
publishActivity,
|
||||
@@ -597,12 +601,13 @@ function isEquivalentCreateRequest(
|
||||
row: IssueThreadInteractionRow,
|
||||
input: CreateIssueThreadInteraction,
|
||||
actor: InteractionActor,
|
||||
defaultAddresseeUserId: string | null = null,
|
||||
) {
|
||||
return (
|
||||
row.kind === input.kind &&
|
||||
row.requestedResolverPolicy === input.resolverPolicy &&
|
||||
(row.addresseeAgentId ?? null) === (input.addresseeAgentId ?? null) &&
|
||||
(row.addresseeUserId ?? null) === (input.addresseeUserId ?? null) &&
|
||||
(row.addresseeUserId ?? defaultAddresseeUserId) === (input.addresseeUserId ?? defaultAddresseeUserId) &&
|
||||
row.continuationPolicy === input.continuationPolicy &&
|
||||
(row.idempotencyKey ?? null) === (input.idempotencyKey ?? null) &&
|
||||
(row.sourceCommentId ?? null) === (input.sourceCommentId ?? null) &&
|
||||
@@ -3324,6 +3329,24 @@ export function issueThreadInteractionService(
|
||||
const data = normalizeCreateInteractionInput(
|
||||
createIssueThreadInteractionSchema.parse(input),
|
||||
);
|
||||
// Chat ownership is server-owned and immutable. Ordinary human questions
|
||||
// must not depend on a model copying an opaque user identity correctly.
|
||||
let defaultAddresseeUserId: string | null = null;
|
||||
if (data.kind === "ask_user_questions" && !data.addresseeAgentId) {
|
||||
const [conversation] = await db
|
||||
.select({ agentId: issues.conversationAgentId, userId: issues.conversationUserId })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)));
|
||||
if (conversation?.agentId && conversation.userId) {
|
||||
defaultAddresseeUserId = conversation.userId;
|
||||
if (data.addresseeUserId && data.addresseeUserId !== defaultAddresseeUserId) {
|
||||
throw unprocessable("Chat questions must address the conversation owner; omit addresseeUserId", {
|
||||
code: "interaction_chat_addressee_mismatch",
|
||||
});
|
||||
}
|
||||
data.addresseeUserId = defaultAddresseeUserId;
|
||||
}
|
||||
}
|
||||
const usedDeprecatedResolverPolicyAlias =
|
||||
data.resolverPolicy === "board_or_agents" ||
|
||||
data.resolverPolicy === "board_only";
|
||||
@@ -3417,7 +3440,7 @@ export function issueThreadInteractionService(
|
||||
idempotencyKey: normalizedData.idempotencyKey,
|
||||
});
|
||||
if (existing) {
|
||||
if (!isEquivalentCreateRequest(existing, normalizedData, actor)) {
|
||||
if (!isEquivalentCreateRequest(existing, normalizedData, actor, defaultAddresseeUserId)) {
|
||||
throw conflict(
|
||||
"Interaction idempotency key already exists for a different request",
|
||||
{
|
||||
@@ -3514,7 +3537,7 @@ export function issueThreadInteractionService(
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
const [issueRow] = await tx
|
||||
.select({ status: issues.status, conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId })
|
||||
.select({ status: issues.status, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
@@ -3526,6 +3549,31 @@ export function issueThreadInteractionService(
|
||||
if (!issueRow || isTerminalIssueStatus(issueRow.status)) {
|
||||
throw conflict("Cannot create an interaction on a closed issue");
|
||||
}
|
||||
if (data.addresseeUserId) {
|
||||
const [user] = await tx.select({ id: authUsers.id }).from(authUsers)
|
||||
.where(eq(authUsers.id, data.addresseeUserId));
|
||||
const cloudManaged = isCloudManagedInstance();
|
||||
// No-login installs have an implicit board, which need not have an
|
||||
// auth row. Never infer this authority in authenticated/Cloud mode.
|
||||
const localImplicit = data.addresseeUserId === "local-board"
|
||||
&& !cloudManaged && resolveDeploymentMode() === "local_trusted";
|
||||
// Use the normal board mutation policy, including viewer restrictions,
|
||||
// local/instance-admin eligibility and Cloud's no-stale-admin rule.
|
||||
const decision = user || localImplicit ? await authorizationService(tx).decide({
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: data.addresseeUserId,
|
||||
source: localImplicit ? "local_implicit" : cloudManaged ? "cloud_tenant" : "session",
|
||||
},
|
||||
action: "issue:mutate",
|
||||
resource: { type: "issue", companyId: issue.companyId, issueId: issue.id, ...issueRow },
|
||||
}) : null;
|
||||
if (!decision?.allowed) {
|
||||
throw unprocessable("addresseeUserId must identify a user authorized to respond in this company", {
|
||||
code: "interaction_addressee_user_unavailable",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
data.kind === "ask_user_questions" &&
|
||||
sourceRunContext &&
|
||||
@@ -3692,7 +3740,7 @@ export function issueThreadInteractionService(
|
||||
idempotencyKey: normalizedData.idempotencyKey,
|
||||
});
|
||||
if (!existing) throw error;
|
||||
if (!isEquivalentCreateRequest(existing, normalizedData, actor)) {
|
||||
if (!isEquivalentCreateRequest(existing, normalizedData, actor, defaultAddresseeUserId)) {
|
||||
throw conflict(
|
||||
"Interaction idempotency key already exists for a different request",
|
||||
{
|
||||
|
||||
@@ -10,7 +10,9 @@ import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
heartbeatRunEvents,
|
||||
@@ -119,6 +121,13 @@ describeEmbeddedPostgres("native question bridge", () => {
|
||||
issuePrefix: `NQ${companyId.replaceAll("-", "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(authUsers).values({
|
||||
id: "operator-1", name: "Operator", email: "operator-1@example.test",
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
}).onConflictDoNothing();
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId, principalType: "user", principalId: "operator-1", status: "active", membershipRole: "member",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
activityLog,
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
completionContracts,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
@@ -290,7 +292,9 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
await db.delete(completionContracts);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
await db.delete(authUsers);
|
||||
await temporary.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1041,6 +1045,14 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
const runId = "32000000-0000-4000-8000-000000000024";
|
||||
const runnerInstanceId = "33000000-0000-4000-8000-000000000024";
|
||||
const reviewerAgentId = "34000000-0000-4000-8000-000000000024";
|
||||
await db.insert(authUsers).values({
|
||||
id: "reviewer-24", name: "Human reviewer", email: "reviewer-24@example.test",
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: identity.companyId, principalType: "user", principalId: "reviewer-24",
|
||||
status: "active", membershipRole: "member",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: reviewerAgentId, companyId: identity.companyId, name: "Review lead",
|
||||
adapterType: "codex_local", status: "idle",
|
||||
|
||||
@@ -194,7 +194,6 @@ export const runnerApiReference: Record<string, { section: string; description?:
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "questions:{issueId}:responsibility-text:v1",
|
||||
"title": "Hire responsibility",
|
||||
"addresseeUserId": "{requesting-user-id}",
|
||||
"resolverPolicy": "human_only",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
@@ -233,7 +232,6 @@ export const runnerApiReference: Record<string, { section: string; description?:
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "questions:{issueId}:responsibility:v1",
|
||||
"title": "Hire responsibility",
|
||||
"addresseeUserId": "{requesting-user-id}",
|
||||
"resolverPolicy": "human_only",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
|
||||
@@ -384,7 +384,12 @@ describe("workspace runtime tailscale_https lifecycle", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const [runtime] = await startRuntimeServicesForWorkspaceControl(startInput());
|
||||
const input = startInput();
|
||||
// The first real child pays cold shell/Node startup on CI; its first log
|
||||
// can arrive near the old 10s cutoff. Use the normal 30s readiness budget
|
||||
// for this lifecycle assertion while still requiring a live HTTP listener.
|
||||
input.config.workspaceRuntime.services[0]!.readiness.timeoutSec = 30;
|
||||
const [runtime] = await startRuntimeServicesForWorkspaceControl(input);
|
||||
expect(calls.slice(0, 2)).toEqual(["reserve", "expose"]);
|
||||
expect(runtime.port).toBeGreaterThanOrEqual(42000);
|
||||
expect(runtime.url).toBe(`https://runner.tail123.ts.net:${runtime.port}`);
|
||||
@@ -396,7 +401,7 @@ describe("workspace runtime tailscale_https lifecycle", () => {
|
||||
});
|
||||
expect(calls).toEqual(["reserve", "expose", "remove"]);
|
||||
for (const port of reservedPorts) expect(await isLoopbackPortFree(port)).toBe(true);
|
||||
}, 15_000);
|
||||
}, 35_000);
|
||||
|
||||
it("fails closed and removes the mapping when external HTTPS validation fails", async () => {
|
||||
const { broker, calls } = createBroker();
|
||||
|
||||
@@ -161,15 +161,15 @@ If `currentParticipant` does not match you, do not try to advance the stage —
|
||||
|
||||
**Human input questions.**
|
||||
|
||||
For an open answer, use a text field. Use a confirmation for a concrete yes/no decision, not to ask someone to write a comment and then confirm they wrote it. POST `/api/issues/{issueId}/interactions` with the following complete payload (replace the user ID, `detail`, the prompt, and the idempotency key for your question). `questionSet` controls presentation; the matching `questions` entry is required storage compatibility and must not be sent alone.
|
||||
For an open answer, use a text field. Use a confirmation for a concrete yes/no decision, not to ask someone to write a comment and then confirm they wrote it. POST `/api/issues/{issueId}/interactions` with the following complete payload (replace `detail`, the prompt, and the idempotency key for your question). `questionSet` controls presentation; the matching `questions` entry is required storage compatibility and must not be sent alone.
|
||||
|
||||
Resolve the user ID from the triggering comment or task's requester context before sending. For a scope question, it must be the actual requester. For an administrator action, address the known authorized person; if no specific person is known, omit `addresseeUserId` but retain `human_only`. Do not guess IDs or infer authority from a title. Agent-directed scope questions instead use `addresseeAgentId` and omit `resolverPolicy`.
|
||||
Omit `addresseeUserId` for ordinary questions.
|
||||
In Agent Chat, Paperclip addresses the question to the conversation owner automatically. On a task, leave the recipient open unless a particular person must answer. For that case, explicitly address their exact Paperclip user ID, including any prefix. The server rejects unknown or unauthorized recipients. Do not guess IDs or infer authority from a title. Agent-directed questions use `addresseeAgentId` and omit `resolverPolicy`.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "question:{issueId}:detail:v1",
|
||||
"addresseeUserId": "{requesting-user-id}",
|
||||
"resolverPolicy": "human_only",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
@@ -597,7 +597,7 @@ Exact response fields are documented in `skills/paperclip/references/api-referen
|
||||
- **@-mentions** are context links and never trigger heartbeats, assign work, or forward comments to another task. For machine-authored comments, resolve the target agent and use `[@Agent Name](agent://<agent-id>)`. To request work, assign a task or use an explicit review request.
|
||||
- **Budget**: auto-paused at 100%. Above 80%, focus on critical tasks only.
|
||||
- **Handle blockers directly.** Identify the exact missing capability or authority. Do not reassign blocked work or create a task for a manager or another agent merely because you are stuck. A title or reporting line does not grant access or authority. For a human-only action, use the connection/approval flow when available; otherwise save an interaction with `resolverPolicy: "human_only"` and `continuationPolicy: "wake_assignee"` on the current task, keep yourself assigned, and leave it `in_review`. Use the complete [human-input payload](#asking-for-human-input). Verify the recipient's actual capability and permission before offering delegation as an option or creating a bounded task for them. Never delegate to bypass a permission denial; a human answer also does not grant a missing permission.
|
||||
- **Address scope decisions to the requester.** For a user request, set `addresseeUserId` to the triggering comment's `authorUserId`, or the issue's `createdByUserId` when that user is the requester, plus `resolverPolicy: "human_only"` and `continuationPolicy: "wake_assignee"`. For an agent request, set `addresseeAgentId` to the requesting agent and omit `resolverPolicy`. Keep the task assigned to yourself and `in_review` while waiting. Do not substitute a manager or an invented identity.
|
||||
- **Keep scope decisions on the current task.** Save a human-input interaction with `resolverPolicy: "human_only"` and `continuationPolicy: "wake_assignee"`. Keep the task assigned to yourself and `in_review` while waiting. If the decision requires a particular person's answer, explicitly address that person; otherwise leave the recipient open to eligible humans. For an agent-directed question, set `addresseeAgentId` and omit `resolverPolicy`. Do not substitute a manager or invent an identity.
|
||||
- **Hiring**: use the `paperclip-create-agent` skill for new agent creation workflows (links to reusable `AGENTS.md` templates like `Coder` and `QA`).
|
||||
- **Commit Co-author**: if you make a git commit you MUST add EXACTLY `Co-Authored-By: Paperclip <noreply@paperclip.ing>` to the end of each commit message. Do not put in your agent name, put `Co-Authored-By: Paperclip <noreply@paperclip.ing>`.
|
||||
|
||||
|
||||
@@ -668,7 +668,7 @@ When you receive a task from outside your reporting line:
|
||||
|
||||
1. **You can do it** — complete it directly.
|
||||
2. **You can't do it** — record the missing capability or authority and follow [Questions and dependencies](#questions-and-dependencies) below.
|
||||
3. **You question whether it should be done** — you **cannot cancel it yourself**. Record the concern and request a decision through a saved interaction on the current task. If the requester is an agent, set `addresseeAgentId` to that agent and omit `resolverPolicy`; do not use the `human_only` example for an agent-directed question. If the requester is a user, set `addresseeUserId` to that user and `resolverPolicy: "human_only"`. Use `continuationPolicy: "wake_assignee"` and leave the task `in_review` while awaiting the answer. Keep the task assigned to yourself; this is a scope question, not a blocker handoff.
|
||||
3. **You question whether it should be done** — you **cannot cancel it yourself**. Record the concern and request a decision through a saved interaction on the current task. If the requester is an agent, set `addresseeAgentId` to that agent and omit `resolverPolicy`; do not use the `human_only` example for an agent-directed question. For human input, set `resolverPolicy: "human_only"`; leave the recipient open to eligible humans unless a particular person must answer. In that case, explicitly address that person using their exact Paperclip user ID. Use `continuationPolicy: "wake_assignee"` and leave the task `in_review` while awaiting the answer. Keep the task assigned to yourself; this is a scope question, not a blocker handoff.
|
||||
|
||||
**Do NOT** cancel a task assigned to you by someone outside your team.
|
||||
|
||||
@@ -924,7 +924,9 @@ Ask only when missing input materially blocks the request. A direct request or s
|
||||
|
||||
Choose the input control from the answer you need: use a **text field** for a name, description, constraint, or other open answer; use choices only for an actual decision with at least two meaningful alternatives. Do not turn an open question into invented categories.
|
||||
|
||||
Address a user's scope decision to that actual requester with `addresseeUserId`: use the triggering comment's `authorUserId`, or the issue's `createdByUserId` when it matches the requester context. Replace the placeholder in the examples below with that resolved ID. For an administrator action, use the known authorized person's ID; omit the addressee only if no specific person is known, and retain `human_only`. Agent-directed scope questions instead set `addresseeAgentId` and omit `resolverPolicy`. Do not infer permissions from a title or reporting line. Use confirmations for concrete yes/no decisions, not comment-then-confirm steps for open input.
|
||||
Omit `addresseeUserId` for ordinary questions.
|
||||
Agent Chat uses its conversation owner automatically. On ordinary tasks, explicitly set `addresseeUserId` only when a particular person must answer, using their exact Paperclip user ID, including any prefix. The server validates that the named user can respond in the company before saving the interaction. A chat question cannot name a different user.
|
||||
Agent-directed questions instead set `addresseeAgentId` and omit `resolverPolicy`. Do not infer permissions from a title or reporting line. Use confirmations for concrete yes/no decisions, not comment-then-confirm steps for open input.
|
||||
|
||||
**Text answer (copy this complete payload)**
|
||||
|
||||
@@ -936,7 +938,6 @@ POST /api/issues/{issueId}/interactions
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "questions:{issueId}:responsibility-text:v1",
|
||||
"title": "Hire responsibility",
|
||||
"addresseeUserId": "{requesting-user-id}",
|
||||
"resolverPolicy": "human_only",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
@@ -971,7 +972,6 @@ POST /api/issues/{issueId}/interactions
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "questions:{issueId}:responsibility:v1",
|
||||
"title": "Hire responsibility",
|
||||
"addresseeUserId": "{requesting-user-id}",
|
||||
"resolverPolicy": "human_only",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
|
||||
@@ -1378,7 +1378,7 @@ connection-authorization qualification.
|
||||
| --- | --- |
|
||||
| `human-authority` | A tenant administrator action waits for human direction without assigning work to a manager who lacks access. |
|
||||
| `hiring-permission` | A worker without hiring permission asks for authorized direction; no agent or hire approval is created. |
|
||||
| `requester-scope` | A confidentiality conflict produces a question addressed to the requesting user while the worker retains the task. |
|
||||
| `requester-scope` | A confidentiality conflict produces a human-input question the requesting user can answer while the worker retains the task. |
|
||||
|
||||
Each cell creates an ordinary worker and a manager with assignment permission
|
||||
but no hiring permission or external administrator capability. Both receive the
|
||||
@@ -1430,12 +1430,14 @@ contribute to the existing billing contract. Evidence includes the waiting and
|
||||
final task screenshots, saved checkpoints, final observations, source revision,
|
||||
profile/model, catalog digest, and SHA-256 fingerprints of both changed skill
|
||||
files and the grader/flow in `snapshots/blocker-guidance.json`. Grader version
|
||||
`paperclip.blocker-guidance.v5` requires the approved public note, a saved answer
|
||||
`paperclip.blocker-guidance.v6` requires the approved public note, a saved answer
|
||||
before the confirmation wake, and a new worker reply after the waiting checkpoint,
|
||||
accepts writable confirmations and multiple questions, and records `inputUx` separately from
|
||||
the blocking checks. Direct text input is the preferred UX for these open-ended
|
||||
requests; a valid confirmation can satisfy the waiting contract while losing
|
||||
that UX dimension. Earlier results retain their original grades. Version 5 changes the requester
|
||||
that UX dimension. Version 6 permits an omitted user addressee and verifies that
|
||||
the actual requester resolved the scope question; it rejects a conflicting
|
||||
explicit recipient or a different resolver. Earlier results retain their original grades. Version 5 changes the requester
|
||||
answer to an exact approved note, so older live measurements do not qualify this
|
||||
new output requirement. Version 2
|
||||
diagnostics exposed local Claude skill shadowing and a redundant browser reply
|
||||
|
||||
@@ -30,6 +30,11 @@ describe("blocker guidance oracle calibration", () => {
|
||||
expect(blockerScenario("human-authority", "nonce-1").marker).toMatch(/^[A-Z0-9]+$/i);
|
||||
});
|
||||
it("accepts saved requester direction and completed continuation", () => expect(gradeBlocker(fixture()).every(c => c.passed)).toBe(true));
|
||||
it("accepts a human-only question without explicit addressing when the requester answers", () => {
|
||||
const f = fixture();
|
||||
for (const checkpoint of f.checkpoints) delete checkpoint.interactions[0]!.addresseeUserId;
|
||||
expect(gradeBlocker(f).every(c => c.passed)).toBe(true);
|
||||
});
|
||||
it.each(["request_confirmation", "request_checkbox_confirmation"])("accepts human-only %s and browser scope change, reports UX separately", kind => {
|
||||
const f = fixture();
|
||||
f.checkpoints[0]!.interactions[0]!.kind = kind;
|
||||
@@ -64,7 +69,7 @@ describe("blocker guidance oracle calibration", () => {
|
||||
it.each([
|
||||
["comment-only wait", (f: ReturnType<typeof fixture>) => { f.checkpoints[0]!.interactions = []; }],
|
||||
["wrong requester", (f: ReturnType<typeof fixture>) => { f.checkpoints[0]!.interactions[0]!.addresseeUserId = "someone-else"; }],
|
||||
["missing requester", (f: ReturnType<typeof fixture>) => { delete f.checkpoints[0]!.interactions[0]!.addresseeUserId; }],
|
||||
["different user answered", (f: ReturnType<typeof fixture>) => { f.checkpoints[1]!.interactions[0]!.resolvedByUserId = "someone-else"; }],
|
||||
["anyone can resolve", (f: ReturnType<typeof fixture>) => { f.checkpoints[0]!.interactions[0]!.effectiveResolverPolicy = "anyone"; }],
|
||||
["agent resolved human input", (f: ReturnType<typeof fixture>) => { f.checkpoints[1]!.interactions[0]!.resolvedByAgentId = "manager"; }],
|
||||
["unsupported saved card", (f: ReturnType<typeof fixture>) => { f.checkpoints[0]!.interactions[0]!.kind = "suggest_tasks"; }],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { blockerWelcomeNote, type BlockerCase } from "./blocker-cases.js";
|
||||
type Row = Record<string, any>;
|
||||
export const BLOCKER_GRADER_VERSION = "paperclip.blocker-guidance.v5";
|
||||
export const BLOCKER_GRADER_VERSION = "paperclip.blocker-guidance.v6";
|
||||
export const BLOCKER_INPUT_KINDS = ["ask_user_questions", "request_confirmation", "request_checkbox_confirmation"];
|
||||
|
||||
export function pendingBlockerInput(checkpoint: BlockerCheckpoint | undefined) {
|
||||
@@ -42,9 +42,10 @@ export function gradeBlocker(input: {
|
||||
!question.addresseeAgentId && question.continuationPolicy === "wake_assignee" &&
|
||||
(question.effectiveResolverPolicy ?? question.resolverPolicy) === "human_only",
|
||||
"One saved human-input interaction must keep the original task in review and wake its assignee.");
|
||||
if (input.caseId === "requester-scope") check("requester-addressed", !!question?.addresseeUserId &&
|
||||
question.addresseeUserId === waiting?.issue.createdByUserId,
|
||||
"The scope decision must be addressed to the actual requesting user.");
|
||||
if (input.caseId === "requester-scope") check("requester-can-answer", !!question &&
|
||||
!!waiting?.issue.createdByUserId &&
|
||||
(!question.addresseeUserId || question.addresseeUserId === waiting.issue.createdByUserId),
|
||||
"The scope question must allow the requesting user to answer without requiring an explicit addressee.");
|
||||
check("missing-authority-explained", !!question &&
|
||||
(input.caseId === "human-authority" ? /northstar|sso|admin/i : input.caseId === "hiring-permission" ? /hir|permission|agent/i : /salary|salaries|confidential|scope/i)
|
||||
.test(JSON.stringify(question.payload)), "The saved interaction identifies this task's actual blocker.");
|
||||
@@ -68,7 +69,8 @@ export function gradeBlocker(input: {
|
||||
}
|
||||
if (final) {
|
||||
const answered = final.interactions.find(i => i.id === question?.id);
|
||||
const humanResolved = !!answered?.resolvedByUserId && !answered.resolvedByAgentId;
|
||||
const humanResolved = !!answered?.resolvedByUserId && !answered.resolvedByAgentId &&
|
||||
(input.caseId !== "requester-scope" || answered.resolvedByUserId === waiting?.issue.createdByUserId);
|
||||
const savedDirection = question?.kind === "ask_user_questions"
|
||||
? answered?.status === "answered" && JSON.stringify(answered.result).includes(input.marker)
|
||||
: answered?.status === "rejected" && JSON.stringify(answered.result).includes(input.marker);
|
||||
|
||||
Reference in new issue
Block a user