fix(connections): address personal auth prompts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-21 01:55:23 +00:00
1 parent 621943a441
commit 7db053bef2
22 files changed
+39947 -18

No files matched your search

+4 -2
View File
@@ -696,8 +696,10 @@ old creator-excluding behavior as canonical `not_creator`, legacy `board_only` r
become `human_only`, and both are marked `legacy_inherited_restriction`. Resolved
outcomes and resolver attribution are immutable.
An explicit named addressee and a company-configured cap may narrow the effective
audience. A cap never widens the requested audience. Tool-action confirmations and
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
other hard-governed action cards remain `human_only` (or move to the formal approval
system) regardless of a requested open audience.
@@ -0,0 +1,2 @@
ALTER TABLE "issue_thread_interactions" ADD COLUMN "addressee_user_id" text;--> statement-breakpoint
CREATE INDEX "issue_thread_interactions_addressee_user_idx" ON "issue_thread_interactions" USING btree ("addressee_user_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -1583,6 +1583,13 @@
"when": 1787264558023,
"tag": "0227_third_devos",
"breakpoints": true
},
{
"idx": 228,
"version": "7",
"when": 1787274518405,
"tag": "0228_last_anthem",
"breakpoints": true
}
]
}
@@ -45,6 +45,7 @@ export const issueThreadInteractions = pgTable(
summary: text("summary"),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id),
addresseeAgentId: uuid("addressee_agent_id").references(() => agents.id, { onDelete: "set null" }),
addresseeUserId: text("addressee_user_id"),
createdByUserId: text("created_by_user_id"),
resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id),
resolvedByRunId: uuid("resolved_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
@@ -72,5 +73,6 @@ export const issueThreadInteractions = pgTable(
.where(sql`${table.idempotencyKey} IS NOT NULL`),
sourceCommentIdx: index("issue_thread_interactions_source_comment_idx").on(table.sourceCommentId),
addresseeAgentIdx: index("issue_thread_interactions_addressee_agent_idx").on(table.addresseeAgentId),
addresseeUserIdx: index("issue_thread_interactions_addressee_user_idx").on(table.addresseeUserId),
}),
);
+3 -1
View File
@@ -207,7 +207,9 @@ export interface AttentionResolverAudience {
resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance;
/** Agent the card is addressed to, when it names one. */
addresseeAgentId: string | null;
/** Display name of {@link addresseeAgentId}, resolved server-side. */
/** User the card is addressed to, when it names one. */
addresseeUserId?: string | null;
/** Display name of the agent addressee, resolved server-side. */
addresseeName: string | null;
/** Agent that created the card, excluded when the policy is `not_creator`. */
createdByAgentId: string | null;
+1
View File
@@ -1347,6 +1347,7 @@ export interface IssueThreadInteractionBase extends IssueThreadInteractionActorF
sourceCommentId?: string | null;
sourceRunId?: string | null;
addresseeAgentId?: string | null;
addresseeUserId?: string | null;
title?: string | null;
summary?: string | null;
status: IssueThreadInteractionStatus;
+1
View File
@@ -253,6 +253,7 @@ export type ConnectionTokenSubject = { type: "app" } | { type: "user"; userId: s
export const CONNECTION_RECOVERABLE_ERROR_CODES = [
"user_authorization_required",
"organization_authorization_required",
"grant_audience_denied",
"grant_revoked",
"needs_reauthorization",
+1
View File
@@ -1247,6 +1247,7 @@ export const requestItemVerdictsResultSchema = z.object({
const createIssueThreadInteractionCommon = {
resolverPolicy: issueThreadInteractionResolverPolicySchema.optional(),
addresseeAgentId: z.string().uuid().nullable().optional(),
addresseeUserId: z.string().trim().min(1).nullable().optional(),
};
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
@@ -860,9 +860,23 @@ describeEmbeddedPostgres("attention service", () => {
addresseeAgentId: reviewerId,
payload: { version: 1, questions: [] },
});
await db.insert(issueThreadInteractions).values({
id: randomUUID(),
companyId,
issueId,
kind: "ask_user_questions",
status: "pending",
title: "User-addressed question",
createdByAgentId: workerId,
addresseeUserId: "board-user",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
payload: { version: 1, questions: [] },
});
await agentService(db).pause(reviewerId);
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
const otherUserFeed = await attentionService(db).list(companyId, { userId: "other-user" });
const audienceByTitle = new Map(feed.items
.filter((item) => item.sourceKind === "issue_thread_interaction")
.map((item) => [item.subject.title, item.resolverAudience]));
@@ -888,6 +902,11 @@ describeEmbeddedPostgres("attention service", () => {
addresseeAgentId: reviewerId,
addresseeName: "Reviewer",
});
expect(audienceByTitle.get("User-addressed question")).toMatchObject({
addresseeUserId: "board-user",
effectiveResolverPolicy: "human_only",
});
expect(otherUserFeed.items.some((item) => item.subject.title === "User-addressed question")).toBe(false);
// Non-interaction rows carry no resolver policy at all.
expect(feed.items.find((item) => item.sourceKind !== "issue_thread_interaction")?.resolverAudience)
.toBeNull();
+14
View File
@@ -1523,6 +1523,19 @@ rl.on("line", (line) => {
.rejects.toMatchObject({ status: 403, reasonCode: "grant_audience_denied" });
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
await db.insert(connectionGrantMembers).values({
companyId: company.id,
grantId: organizationGrant.id,
subjectType: "user",
subjectId: "alice",
});
expect(await executeIdentity()).toBe("organization");
await db.delete(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id));
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, organizationGrant.id));
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
.rejects.toMatchObject({ status: 409, reasonCode: "organization_authorization_required" });
await db.delete(connectionGrantMembers).where(eq(connectionGrantMembers.grantId, organizationGrant.id));
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id));
await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, run.id));
@@ -1860,6 +1873,7 @@ rl.on("line", (line) => {
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
addresseeUserId: "carol",
});
expect(interaction!.payload).toMatchObject({
prompt: `Connect your ${connection.name} account to continue`,
+1
View File
@@ -93,6 +93,7 @@ export function errorHandler(
const workspaceRepairPreconditionFailure = details?.code === "workspace_repair_precondition_failed";
const structuredConnectionError = new Set([
"user_authorization_required",
"organization_authorization_required",
"grant_audience_denied",
"grant_revoked",
"needs_reauthorization",
+2
View File
@@ -4328,6 +4328,7 @@ export function issueRoutes(
effectiveResolverPolicy: string;
resolverPolicyProvenance?: string | null;
addresseeAgentId?: string | null;
addresseeUserId?: string | null;
kind: string;
status: string;
payload?: unknown;
@@ -11264,6 +11265,7 @@ export function issueRoutes(
interactionStatus: interaction.status,
continuationPolicy: interaction.continuationPolicy,
addresseeAgentId: interaction.addresseeAgentId ?? null,
addresseeUserId: interaction.addresseeUserId ?? null,
requestedResolverPolicy: interaction.requestedResolverPolicy,
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
+6 -2
View File
@@ -733,6 +733,7 @@ function interactionVerbs(kind: string, payload: Record<string, unknown>) {
export function interactionResolverAudience(
row: {
addresseeAgentId: string | null;
addresseeUserId?: string | null;
createdByAgentId: string | null;
requestedResolverPolicy: string;
effectiveResolverPolicy: string;
@@ -752,6 +753,7 @@ export function interactionResolverAudience(
(row.effectiveResolverPolicySource ?? "requested") as IssueThreadInteractionEffectiveResolverPolicySource,
resolverPolicyProvenance: provenance,
addresseeAgentId: row.addresseeAgentId,
addresseeUserId: row.addresseeUserId ?? null,
addresseeName: row.addresseeAgentId ? agentName(row.addresseeAgentId) : null,
createdByAgentId: row.createdByAgentId,
createdByAgentName: row.createdByAgentId ? agentName(row.createdByAgentId) : null,
@@ -1172,6 +1174,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
summary: issueThreadInteractions.summary,
payload: issueThreadInteractions.payload,
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
addresseeUserId: issueThreadInteractions.addresseeUserId,
createdByAgentId: issueThreadInteractions.createdByAgentId,
requestedResolverPolicy: issueThreadInteractions.requestedResolverPolicy,
effectiveResolverPolicy: issueThreadInteractions.effectiveResolverPolicy,
@@ -1207,8 +1210,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
: [];
const companyAgentMap = new Map(companyAgentRows.map((agent) => [agent.id, agent]));
const boardInteractionRows = interactionRows.filter((row) =>
row.addresseeAgentId === null ||
!evaluateAgentInvokability(companyAgentMap.get(row.addresseeAgentId), companyAgentRows).invokable
(row.addresseeAgentId === null ||
!evaluateAgentInvokability(companyAgentMap.get(row.addresseeAgentId), companyAgentRows).invokable)
&& (row.addresseeUserId === null || row.addresseeUserId === options.userId)
);
const visibleInteractionRows = collapsePendingConfirmationsToNewest(boardInteractionRows);
const [interactionIssueMap, interactionImageMap, interactionPlanDocumentMap] = await Promise.all([
@@ -12,6 +12,7 @@ function interaction(overrides: Record<string, unknown> = {}) {
createdByUserId: null,
sourceRunId: "run-1",
addresseeAgentId: null,
addresseeUserId: null,
effectiveResolverPolicy: "anyone",
...overrides,
};
@@ -50,6 +51,18 @@ describe("issue-thread interaction resolver audience", () => {
expect(decision).toMatchObject({ allowed: false, code: "interaction_addressee_mismatch" });
});
it("allows only the addressed user to resolve a user-addressed interaction", () => {
expect(evaluateIssueThreadInteractionResolverAudience({
actor: { type: "user", userId: "alice" },
interaction: interaction({ addresseeUserId: "alice", effectiveResolverPolicy: "human_only" }),
})).toMatchObject({ allowed: true, reason: "allow_addressee" });
expect(evaluateIssueThreadInteractionResolverAudience({
actor: { type: "user", userId: "bob" },
interaction: interaction({ addresseeUserId: "alice", effectiveResolverPolicy: "human_only" }),
})).toMatchObject({ allowed: false, code: "interaction_addressee_mismatch" });
});
it("requires run attribution for agents", () => {
const decision = evaluateIssueThreadInteractionResolverAudience({
actor: { type: "agent", agentId: "agent-1", runId: null },
@@ -46,6 +46,7 @@ export type IssueThreadInteractionResolverAudienceInput = {
createdByUserId?: string | null;
sourceRunId?: string | null;
addresseeAgentId?: string | null;
addresseeUserId?: string | null;
effectiveResolverPolicy: IssueThreadInteractionResolverPolicy | string;
resolverPolicyProvenance?: string | null;
};
@@ -180,6 +181,18 @@ export function evaluateIssueThreadInteractionResolverAudience(
}
if (input.actor.type === "user") {
if (
input.interaction.addresseeUserId
&& input.interaction.addresseeUserId !== input.actor.userId
) {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_addressee_mismatch",
message: "Only the addressed user may resolve this issue-thread interaction",
};
}
if (
creatorExcluded
&& input.interaction.createdByUserId === input.actor.userId
@@ -204,7 +217,11 @@ export function evaluateIssueThreadInteractionResolverAudience(
return {
allowed: true,
effectiveResolverPolicy,
reason: input.interaction.addresseeAgentId ? "allow_human_override" : "allow_human",
reason: input.interaction.addresseeUserId
? "allow_addressee"
: input.interaction.addresseeAgentId
? "allow_human_override"
: "allow_human",
};
}
@@ -419,6 +419,7 @@ function isEquivalentCreateRequest(
row.kind === input.kind
&& row.requestedResolverPolicy === input.resolverPolicy
&& (row.addresseeAgentId ?? null) === (input.addresseeAgentId ?? null)
&& (row.addresseeUserId ?? null) === (input.addresseeUserId ?? null)
&& row.continuationPolicy === input.continuationPolicy
&& (row.idempotencyKey ?? null) === (input.idempotencyKey ?? null)
&& (row.sourceCommentId ?? null) === (input.sourceCommentId ?? null)
@@ -476,6 +477,7 @@ function hydrateInteraction(
...row,
idempotencyKey: row.idempotencyKey ?? null,
addresseeAgentId: row.addresseeAgentId ?? null,
addresseeUserId: row.addresseeUserId ?? null,
status: row.status as IssueThreadInteraction["status"],
continuationPolicy: row.continuationPolicy as IssueThreadInteraction["continuationPolicy"],
resolverPolicy: requestedResolverPolicy,
@@ -2277,6 +2279,10 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
});
const normalizedData = { ...data, resolverPolicy: policy.requestedResolverPolicy };
if (normalizedData.addresseeAgentId && normalizedData.addresseeUserId) {
throw unprocessable("An issue-thread interaction cannot address both an agent and a user");
}
if (normalizedData.addresseeAgentId) {
if (normalizedData.addresseeAgentId === actor.agentId) {
throw unprocessable("Agents cannot address issue-thread interactions to themselves");
@@ -2407,6 +2413,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
summary: data.summary ?? null,
createdByAgentId: actor.agentId ?? null,
addresseeAgentId: data.addresseeAgentId ?? null,
addresseeUserId: data.addresseeUserId ?? null,
createdByUserId: actor.userId ?? null,
payload: data.payload,
})
+6
View File
@@ -7216,6 +7216,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
const [interaction] = existingInteraction
? await db.update(issueThreadInteractions).set({
status: "pending",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
addresseeUserId: input.subjectUserId,
payload,
result: null,
resolvedAt: null,
@@ -7231,6 +7236,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
addresseeUserId: input.subjectUserId,
idempotencyKey,
sourceRunId: binding.actorType === "agent" ? input.actor.sessionId ?? null : null,
title: "Connect your account",
+3 -1
View File
@@ -2610,6 +2610,7 @@ export function createToolGatewayService(
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
addresseeUserId: userId,
payload,
result: null,
resolvedAt: null,
@@ -2632,6 +2633,7 @@ export function createToolGatewayService(
title: `Connect your ${connection.name}`,
summary: "Personal authorization is required before this run can continue.",
createdByAgentId: session.agentId,
addresseeUserId: userId,
payload,
});
}
@@ -2667,7 +2669,7 @@ export function createToolGatewayService(
eq(connectionGrants.status, "active"),
)).limit(1);
if (!grant) {
throw new ToolGatewayHttpError(409, "Organization authorization is required", "user_authorization_required", {
throw new ToolGatewayHttpError(409, "Organization authorization is required", "organization_authorization_required", {
connectionId: connection.id,
});
}
@@ -3612,10 +3612,11 @@ export function IssueThreadInteractionCard({
: null;
// P4: audit-visible distinction between agent and human resolution.
const resolvedByAgent = Boolean(interaction.resolvedByAgentId);
// P3: interactions directed at a specific agent addressee.
const addresseeLabel = interaction.addresseeAgentId
// Interactions can be directed at a specific agent or board user.
const addresseeLabel = interaction.addresseeAgentId || interaction.addresseeUserId
? resolveActorLabel({
agentId: interaction.addresseeAgentId,
userId: interaction.addresseeUserId,
agentMap,
currentUserId,
userLabelMap,
+14
View File
@@ -163,6 +163,20 @@ describe("describeInteractionAudience", () => {
expect(audience.label).toBe("Human only");
});
it("names one addressed user instead of the whole board", () => {
const audience = describeInteractionAudience({
interaction: confirmation({
addresseeUserId: "user-alice",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
}),
addresseeLabel: "Alice",
});
expect(audience.summary).toBe("Only Alice can respond.");
expect(audience.shortSummary).toBe("Only Alice can respond");
expect(audience.label).toBe("Addressed");
});
it("explains a governed-action clamp", () => {
const audience = describeInteractionAudience({
interaction: confirmation({
+17 -9
View File
@@ -125,6 +125,7 @@ export interface InteractionAudienceFacts {
effectiveResolverPolicySource: IssueThreadInteractionEffectiveResolverPolicySource;
resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance;
hasAddressee: boolean;
isUserAddressee?: boolean;
}
/**
@@ -143,7 +144,7 @@ export function describeInteractionAudience({
interaction: IssueThreadInteraction;
/** Display label of the creating actor, when known. */
creatorLabel?: string | null;
/** Display label of the named addressee agent, when the card has one. */
/** Display label of the named addressee, when the card has one. */
addresseeLabel?: string | null;
}): InteractionAudienceDescription {
return describeResolverAudience({
@@ -152,7 +153,8 @@ export function describeInteractionAudience({
requestedResolverPolicy: interaction.requestedResolverPolicy,
effectiveResolverPolicySource: interaction.effectiveResolverPolicySource,
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
hasAddressee: Boolean(interaction.addresseeAgentId),
hasAddressee: Boolean(interaction.addresseeAgentId || interaction.addresseeUserId),
isUserAddressee: Boolean(interaction.addresseeUserId),
},
creatorLabel,
addresseeLabel,
@@ -176,10 +178,13 @@ export function describeResolverAudience({
const policy = facts.effectiveResolverPolicy;
const requestedPolicy = facts.requestedResolverPolicy;
const hasAddressee = facts.hasAddressee;
const addressee = addresseeLabel?.trim() || "the addressed agent";
const isUserAddressee = facts.isUserAddressee === true;
const addressee = addresseeLabel?.trim() || (isUserAddressee ? "the addressed user" : "the addressed agent");
const creator = creatorLabel?.trim() || "the agent that created it";
const summary = policy === "human_only"
const summary = isUserAddressee
? `Only ${addressee} can respond.`
: policy === "human_only"
? "Only a person on the board can respond — agents cannot resolve this card."
: hasAddressee
? `Only ${addressee} or a person on the board can respond.`
@@ -189,7 +194,9 @@ export function describeResolverAudience({
// Same fact, fewer words: a collapsed row has to answer "is this mine to
// decide?" in one glance, next to the buttons that act on the answer.
const shortSummary = policy === "human_only"
const shortSummary = isUserAddressee
? `Only ${addressee} can respond`
: policy === "human_only"
? "Only the board can respond"
: hasAddressee
? `Only ${addressee} or the board can respond`
@@ -228,9 +235,9 @@ export function describeResolverAudience({
policy,
requestedPolicy,
// A named addressee owns the response, so the label must not read "Anyone"
// while the sentence next to it names one agent. `human_only` still wins,
// because an addressed agent cannot resolve a human-only card.
label: policy !== "human_only" && hasAddressee
// while the sentence next to it names one actor. `human_only` wins for an
// agent addressee, while a user addressee is the narrower human audience.
label: (policy !== "human_only" || isUserAddressee) && hasAddressee
? "Addressed"
: RESOLVER_POLICY_LABELS[policy],
summary,
@@ -259,7 +266,8 @@ export function describeAttentionResolverAudience(
requestedResolverPolicy: audience.requestedResolverPolicy,
effectiveResolverPolicySource: audience.effectiveResolverPolicySource,
resolverPolicyProvenance: audience.resolverPolicyProvenance,
hasAddressee: Boolean(audience.addresseeAgentId),
hasAddressee: Boolean(audience.addresseeAgentId || audience.addresseeUserId),
isUserAddressee: Boolean(audience.addresseeUserId),
},
creatorLabel: audience.createdByAgentName,
addresseeLabel: audience.addresseeName,