diff --git a/packages/eve/src/execution/subagent-auth-proxy.integration.test.ts b/packages/eve/src/execution/subagent-auth-proxy.integration.test.ts index f806e5101..e13b530b6 100644 --- a/packages/eve/src/execution/subagent-auth-proxy.integration.test.ts +++ b/packages/eve/src/execution/subagent-auth-proxy.integration.test.ts @@ -147,6 +147,7 @@ describe("subagent authorization proxy", () => { candidateId: "candidate-1", outcome: "pending", requestId: "approval-1", + responderPrincipalId: "slack:T1:U1", sequence: 0, stepIndex: 1, turnId: "child-turn", diff --git a/packages/eve/src/harness/authorization.test.ts b/packages/eve/src/harness/authorization.test.ts new file mode 100644 index 000000000..d6e456f81 --- /dev/null +++ b/packages/eve/src/harness/authorization.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { + getPendingAuthorization, + setPendingAuthorization, + type AuthorizationChallenge, +} from "#harness/authorization.js"; + +function challenge(name: string, candidateId: string): AuthorizationChallenge { + return { + candidateId, + challenge: { url: `https://example.com/${name}` }, + hookUrl: `https://eve.example/${name}`, + name, + }; +} + +describe("pending authorization state", () => { + it("merges concurrent candidate challenges by authorization name", () => { + const first = setPendingAuthorization(undefined, { + challenges: [challenge("candidate-1:github", "candidate-1")], + }); + const second = setPendingAuthorization(first, { + challenges: [challenge("candidate-2:github", "candidate-2")], + }); + + expect(getPendingAuthorization(second)?.challenges).toEqual([ + expect.objectContaining({ candidateId: "candidate-1", name: "candidate-1:github" }), + expect.objectContaining({ candidateId: "candidate-2", name: "candidate-2:github" }), + ]); + }); + + it("replaces a repeated challenge without duplicating it", () => { + const first = setPendingAuthorization(undefined, { + challenges: [challenge("candidate-1:github", "candidate-1")], + }); + const second = setPendingAuthorization(first, { + challenges: [ + { + ...challenge("candidate-1:github", "candidate-1"), + hookUrl: "https://eve.example/refreshed", + }, + ], + }); + + expect(getPendingAuthorization(second)?.challenges).toEqual([ + expect.objectContaining({ hookUrl: "https://eve.example/refreshed" }), + ]); + }); +}); diff --git a/packages/eve/src/harness/authorization.ts b/packages/eve/src/harness/authorization.ts index f5d4ccd74..b7d9316c6 100644 --- a/packages/eve/src/harness/authorization.ts +++ b/packages/eve/src/harness/authorization.ts @@ -242,7 +242,13 @@ export function setPendingAuthorization( sessionState: Record | undefined, value: PendingAuthorizationState, ): Record { - return { ...sessionState, [PENDING_AUTHORIZATION_KEY]: value }; + const existing = getPendingAuthorization(sessionState)?.challenges ?? []; + const byName = new Map(existing.map((challenge) => [challenge.name, challenge])); + for (const challenge of value.challenges) byName.set(challenge.name, challenge); + return { + ...sessionState, + [PENDING_AUTHORIZATION_KEY]: { challenges: [...byName.values()] }, + }; } export function clearPendingAuthorization( diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index 673484492..3694874a6 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -580,6 +580,10 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { candidateId: authorized.candidateId ?? `stale:${authorized.requestId}`, outcome: authorized.kind === "authorization-required" ? "pending" : authorized.kind, requestId: authorized.requestId, + responderPrincipalId: + getApprovalAuditState(session.state).activeCandidates.find( + (candidate) => candidate.candidateId === authorized.candidateId, + )?.responder.principalId ?? "unknown", safeReason: "safeReason" in authorized ? authorized.safeReason : undefined, sequence: emissionState.sequence, stepIndex: emissionState.stepIndex, diff --git a/packages/eve/src/protocol/message.ts b/packages/eve/src/protocol/message.ts index 4762eab1a..5dc9bb5c0 100644 --- a/packages/eve/src/protocol/message.ts +++ b/packages/eve/src/protocol/message.ts @@ -221,6 +221,7 @@ export interface ApprovalCandidateStreamEvent { candidateId: string; outcome: ApprovalCandidateOutcome; requestId: string; + responderPrincipalId: string; safeReason?: string; sequence: number; stepIndex: number; diff --git a/packages/eve/src/public/channels/slack/defaults.test.ts b/packages/eve/src/public/channels/slack/defaults.test.ts index 8c9b62402..6399db72d 100644 --- a/packages/eve/src/public/channels/slack/defaults.test.ts +++ b/packages/eve/src/public/channels/slack/defaults.test.ts @@ -66,6 +66,7 @@ describe("defaultEvents approval lifecycle", () => { candidateId: "candidate-1", outcome: "pending", requestId: "approval-1", + responderPrincipalId: "slack:T1:U777", sequence: 1, stepIndex: 0, turnId: "turn-1", diff --git a/packages/eve/src/public/channels/slack/defaults.ts b/packages/eve/src/public/channels/slack/defaults.ts index 6ad851bf0..156bb7a0f 100644 --- a/packages/eve/src/public/channels/slack/defaults.ts +++ b/packages/eve/src/public/channels/slack/defaults.ts @@ -163,17 +163,28 @@ function buildInputRequestPosts( */ export const defaultEvents: SlackChannelInternalEvents = { async "approval.candidate"(event, channel, ctx) { - const userId = slackUserIdFromAuthContext(ctx.session.auth.current); - if (userId === undefined) return; - if (event.outcome === "pending") { - await channel.thread.postEphemeral(userId, "Checking whether you can approve this action…"); + const currentUserId = slackUserIdFromAuthContext(ctx.session.auth.current); + if (event.outcome === "pending" && currentUserId !== undefined) { + channel.state.pendingApprovalCandidateUsers = { + ...channel.state.pendingApprovalCandidateUsers, + [event.candidateId]: currentUserId, + }; + await channel.thread.postEphemeral( + currentUserId, + "Checking whether you can approve this action…", + ); return; } + const userId = channel.state.pendingApprovalCandidateUsers?.[event.candidateId]; + if (userId === undefined) return; if (event.outcome === "rejected" || event.outcome === "failed") { await channel.thread.postEphemeral( userId, event.safeReason ?? "We couldn’t verify your approval. Please try again.", ); + const next = { ...channel.state.pendingApprovalCandidateUsers }; + delete next[event.candidateId]; + channel.state.pendingApprovalCandidateUsers = next; } }, diff --git a/packages/eve/src/public/channels/slack/slackChannel.ts b/packages/eve/src/public/channels/slack/slackChannel.ts index 873140c4c..aa6a8e932 100644 --- a/packages/eve/src/public/channels/slack/slackChannel.ts +++ b/packages/eve/src/public/channels/slack/slackChannel.ts @@ -213,6 +213,7 @@ export interface SlackChannelState { */ pendingAuthMessageTs?: Record; pendingApprovalCards?: Record; + pendingApprovalCandidateUsers?: Record; } /** @@ -698,6 +699,7 @@ export function slackChannel(config: SlackChannelConfig = {}): SlackChannel { lastReasoningTypingStatus: null, pendingAuthMessageTs: {}, pendingApprovalCards: {}, + pendingApprovalCandidateUsers: {}, }, fetchFile: slackFetchFile, metadata(state): SlackInstrumentationMetadata {