diff --git a/packages/eve/src/context/build-dynamic-tools.ts b/packages/eve/src/context/build-dynamic-tools.ts index ac8a4e2aa..7e27006e0 100644 --- a/packages/eve/src/context/build-dynamic-tools.ts +++ b/packages/eve/src/context/build-dynamic-tools.ts @@ -1,4 +1,5 @@ import type { HarnessToolDefinition } from "#harness/execute-tool.js"; +import type { HarnessToolMap } from "#harness/types.js"; import type { ContextKey } from "#context/key.js"; import { SessionDynamicToolMetadataKey, @@ -121,6 +122,20 @@ function buildReplayedApproval( * `LiveStepToolsKey`). Session/turn tools are replayed from durable * metadata via the bundler's registered step functions. */ +export function buildResponseAuthorizationTools(input: { + readonly authoredTools: HarnessToolMap; + readonly context?: { get(key: ContextKey): T | undefined }; +}): HarnessToolMap { + const tools = new Map(); + for (const tool of input.context === undefined ? [] : buildDynamicTools(input.context)) { + if (!tools.has(tool.name)) tools.set(tool.name, tool); + } + for (const [name, tool] of input.authoredTools) { + if (!tools.has(name)) tools.set(name, tool); + } + return tools; +} + export function buildDynamicTools(ctx: { get(key: ContextKey): T | undefined; }): readonly HarnessToolDefinition[] { diff --git a/packages/eve/src/context/dynamic-tool-lifecycle.test.ts b/packages/eve/src/context/dynamic-tool-lifecycle.test.ts index e96cec46a..77bf86915 100644 --- a/packages/eve/src/context/dynamic-tool-lifecycle.test.ts +++ b/packages/eve/src/context/dynamic-tool-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { asSchema } from "ai"; +import { asSchema, jsonSchema } from "ai"; import { describe, expect, it, vi } from "vitest"; import type { DynamicToolEntry } from "#shared/dynamic-tool-definition.js"; @@ -19,10 +19,12 @@ const { dispatchDynamicToolEvent, refreshDynamicSessionToolsForRuntimeRevision, } = await import("#context/dynamic-tool-lifecycle.js"); -const { buildDynamicTools } = await import("#context/build-dynamic-tools.js"); +const { buildDynamicTools, buildResponseAuthorizationTools } = + await import("#context/build-dynamic-tools.js"); import { ContextContainer } from "#context/container.js"; import { + LiveStepToolsKey, SessionIdKey, SessionDynamicToolMetadataKey, SessionDynamicToolRuntimeRevisionKey, @@ -1184,6 +1186,41 @@ describe("framework dynamic tools (no bundler transform)", () => { expect(approvalFn).toHaveBeenCalledExactlyOnceWith(approvalCtx); }); + it("uses the first dynamic definition for response authorization", () => { + const ctx = createCtx(); + ctx.set(LiveStepToolsKey, [ + { + approval: { + authorizeResponse: async () => "allowed" as const, + policy: () => "user-approval", + }, + description: "step", + execute: () => null, + inputSchema: jsonSchema({ type: "object" }), + name: "guarded", + }, + ]); + ctx.set(SessionDynamicToolMetadataKey, [ + { + approvalResponseStepFnName: "session-authorizer", + approvalStepFnName: "session-policy", + description: "session", + entryKey: "session:guarded", + executeStepFnName: "session-execute", + inputSchema: { type: "object" }, + name: "guarded", + resolverSlug: "session", + }, + ]); + + const tools = buildResponseAuthorizationTools({ + authoredTools: new Map(), + context: ctx, + }); + + expect(tools.get("guarded")?.description).toBe("step"); + }); + it("replays response authorization from session-scoped dynamic tools", async () => { const ctx = createCtx(); const authorizeResponse = vi.fn(async () => "allowed" as const); diff --git a/packages/eve/src/execution/tool-auth.integration.test.ts b/packages/eve/src/execution/tool-auth.integration.test.ts index 89fc1813e..ad4bcb5db 100644 --- a/packages/eve/src/execution/tool-auth.integration.test.ts +++ b/packages/eve/src/execution/tool-auth.integration.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { buildApprovalResponseAuth, createToolExecuteWithAuth } from "#execution/tool-auth.js"; +import { + createApprovalCandidate, + getActiveApprovalCandidate, +} from "#harness/approval-candidates.js"; import { evictScopedToken, resolveScopedToken } from "#runtime/connections/scoped-authorization.js"; import { loadContext } from "#context/container.js"; import { AuthKey, SessionIdKey } from "#context/keys.js"; @@ -72,15 +76,89 @@ function authoredTool(input: { } describe("approval response authorization", () => { - it("resolves user tokens for the explicitly bound responder instead of ambient auth", async () => { + it("resolves user tokens for the durable responder instead of ambient auth", async () => { + let resolvedPrincipal: ConnectionPrincipal | undefined; + const provider: AuthorizationDefinition = { + principalType: "user", + async getToken({ principal }): Promise { + resolvedPrincipal = principal; + return { token: "durable-responder-token" }; + }, + }; + const created = createApprovalCandidate({ + candidateIdPrefix: "candidate-1", + createdAt: 100, + expiresAt: 700, + requestId: "request-1", + responder: { + attributes: { environment: "production", role: ["approver"] }, + authenticator: "test-idp", + issuer: "test-idp", + principalId: "durable-U1", + principalType: "user", + subject: "durable-subject-U1", + }, + state: undefined, + }); + const candidate = getActiveApprovalCandidate(created.state, "candidate-1"); + if (candidate === undefined) throw new Error("Expected durable candidate."); + const runtime = createTestRuntime({ tools: [] }); + + const token = await runtime.runAsSession(undefined, async () => { + loadContext().set(AuthKey, { + attributes: {}, + authenticator: "test-idp", + issuer: "test-idp", + principalId: "ambient-U2", + principalType: "user", + }); + const auth = buildApprovalResponseAuth({ + responder: candidate.responder, + scope: "candidate-1", + }); + return await auth.getToken(provider); + }); + + expect(token.token).toBe("durable-responder-token"); + expect(candidate.responder).toMatchObject({ + attributes: { environment: "production", role: ["approver"] }, + subject: "durable-subject-U1", + }); + expect(resolvedPrincipal).toMatchObject({ + attributes: { environment: "production", role: ["approver"] }, + id: "durable-U1", + issuer: "test-idp", + type: "user", + }); + }); + + it("preserves Vercel development subject projection through candidate state", async () => { let resolvedPrincipal: ConnectionPrincipal | undefined; const provider: AuthorizationDefinition = { principalType: "user", + vercelConnect: { connector: "oauth/github" }, async getToken({ principal }): Promise { resolvedPrincipal = principal; - return { token: "bound-responder-token" }; + return { token: "development-user-token" }; }, }; + const created = createApprovalCandidate({ + candidateIdPrefix: "candidate-vercel", + createdAt: 100, + expiresAt: 700, + requestId: "request-vercel", + responder: { + attributes: { environment: "development", user_id: "vercel-user-1" }, + authenticator: "oidc", + issuer: "https://oidc.vercel.com/team", + principalId: "channel-user-1", + principalType: "user", + subject: "vercel-user-1", + }, + state: undefined, + }); + const candidate = getActiveApprovalCandidate(created.state, "candidate-vercel"); + if (candidate === undefined) throw new Error("Expected durable candidate."); const runtime = createTestRuntime({ tools: [] }); await runtime.runAsSession(undefined, async () => { @@ -91,24 +169,17 @@ describe("approval response authorization", () => { principalType: "user", }); return await buildApprovalResponseAuth({ - responder: { - attributes: { role: ["approver"] }, - authenticator: "test-idp", - issuer: "test-idp", - principalId: "bound-U1", - principalType: "user", - subject: "bound-subject-U1", - }, - scope: "candidate-1", + responder: candidate.responder, + scope: "candidate-vercel", }).getToken(provider); }); expect(resolvedPrincipal).toMatchObject({ - attributes: { role: ["approver"] }, - id: "bound-U1", - issuer: "test-idp", + attributes: { environment: "development", user_id: "vercel-user-1" }, + id: "vercel-user-1", type: "user", }); + expect(resolvedPrincipal).not.toHaveProperty("issuer"); }); }); diff --git a/packages/eve/src/harness/approval-candidates.test.ts b/packages/eve/src/harness/approval-candidates.test.ts new file mode 100644 index 000000000..2cadc7347 --- /dev/null +++ b/packages/eve/src/harness/approval-candidates.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { + cancelApprovalRequest, + createApprovalCandidate, + expireApprovalCandidates, + finishApprovalCandidate, + getApprovalAuditState, + markApprovalCandidateAuthorizationRequired, + settleAllowedCandidate, +} from "#harness/approval-candidates.js"; +import type { SessionStateMap } from "#harness/types.js"; + +function responder(principalId: string): SessionAuthContext { + return { + attributes: { workspace: "T1" }, + authenticator: "slack-webhook", + issuer: "slack:T1", + principalId, + principalType: "user", + }; +} + +function create(input: { + readonly candidateId: string; + readonly principalId: string; + readonly requestId?: string; + readonly state?: SessionStateMap; +}) { + return createApprovalCandidate({ + candidateIdPrefix: input.candidateId, + createdAt: 100, + expiresAt: 700, + requestId: input.requestId ?? "request-1", + responder: responder(input.principalId), + runtimeRevision: "deploy-1", + state: input.state, + }); +} + +describe("approval candidate state", () => { + it("persists the complete responder while a candidate is active", () => { + const transition = create({ candidateId: "candidate-1", principalId: "U1" }); + + expect(transition.result).toMatchObject({ kind: "created" }); + expect(getApprovalAuditState(transition.state).activeCandidates).toEqual([ + { + candidateId: "candidate-1", + createdAt: 100, + expiresAt: 700, + requestId: "request-1", + responder: { + attributes: { workspace: "T1" }, + authenticator: "slack-webhook", + issuer: "slack:T1", + principalId: "U1", + principalType: "user", + }, + runtimeRevision: "deploy-1", + status: "pending", + }, + ]); + }); + + it("silently deduplicates one responder's active candidate", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const duplicate = create({ + candidateId: "candidate-1", + principalId: "U1", + state: first.state, + }); + + expect(duplicate.result).toMatchObject({ + kind: "duplicate", + candidate: { candidateId: "candidate-1" }, + }); + expect(duplicate.state).toBe(first.state); + }); + + it("defensively deduplicates the same responder under another id", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const duplicate = create({ + candidateId: "candidate-2", + principalId: "U1", + state: first.state, + }); + + expect(duplicate.result).toMatchObject({ + kind: "duplicate", + candidate: { candidateId: "candidate-1" }, + }); + }); + + it("allows different responders to validate concurrently", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const second = create({ + candidateId: "candidate-2", + principalId: "U2", + state: first.state, + }); + + expect(second.result).toMatchObject({ kind: "created" }); + expect(getApprovalAuditState(second.state).activeCandidates).toHaveLength(2); + }); + + it("tracks authorization-required state and provider expiry", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const state = markApprovalCandidateAuthorizationRequired({ + authorizationChallenges: [], + candidateId: "candidate-1", + expiresAt: 500, + provider: "GitHub", + state: first.state, + }); + + expect(getApprovalAuditState(state).activeCandidates[0]).toMatchObject({ + expiresAt: 500, + provider: "GitHub", + status: "authorization-required", + }); + }); + + it("projects the responder to narrow identity in terminal history", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const state = finishApprovalCandidate({ + candidateId: "candidate-1", + completedAt: 200, + state: first.state, + status: "rejected", + }); + + expect(getApprovalAuditState(state).candidateHistory[0]?.responder).toEqual({ + authenticator: "slack-webhook", + issuer: "slack:T1", + principalId: "U1", + principalType: "user", + }); + }); + + it("persists safe rejection feedback and permits a later retry", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const rejected = finishApprovalCandidate({ + candidateId: "candidate-1", + completedAt: 200, + safeReason: "GitHub write permission is required.", + state: first.state, + status: "rejected", + }); + const retry = create({ + candidateId: "candidate-1", + principalId: "U1", + state: rejected, + }); + + expect(getApprovalAuditState(retry.state).candidateHistory).toEqual([ + expect.objectContaining({ + candidateId: "candidate-1", + safeReason: "GitHub write permission is required.", + status: "rejected", + }), + ]); + expect(retry.result).toMatchObject({ + candidate: { candidateId: "candidate-1.1" }, + kind: "created", + }); + }); + + it("expires stale candidates intrinsically before creating another candidate", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const next = createApprovalCandidate({ + candidateIdPrefix: "candidate-2", + createdAt: 800, + expiresAt: 1_400, + requestId: "request-1", + responder: responder("U2"), + state: first.state, + }); + + expect(getApprovalAuditState(next.state)).toMatchObject({ + activeCandidates: [expect.objectContaining({ candidateId: "candidate-2" })], + candidateHistory: [ + expect.objectContaining({ candidateId: "candidate-1", status: "timed-out" }), + ], + }); + }); + + it("expires only candidates whose deadline has passed", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const second = createApprovalCandidate({ + candidateIdPrefix: "candidate-2", + createdAt: 100, + expiresAt: 900, + requestId: "request-1", + responder: responder("U2"), + state: first.state, + }); + const state = expireApprovalCandidates({ now: 800, state: second.state }); + const audit = getApprovalAuditState(state); + + expect(audit.activeCandidates.map((candidate) => candidate.candidateId)).toEqual([ + "candidate-2", + ]); + expect(audit.candidateHistory).toEqual([ + expect.objectContaining({ candidateId: "candidate-1", status: "timed-out" }), + ]); + }); + + it("atomically settles the first allowed candidate and stales competitors", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const second = create({ + candidateId: "candidate-2", + principalId: "U2", + state: first.state, + }); + const winner = settleAllowedCandidate({ + candidateId: "candidate-2", + settledAt: 300, + state: second.state, + }); + const late = settleAllowedCandidate({ + candidateId: "candidate-1", + settledAt: 400, + state: winner.state, + }); + const audit = getApprovalAuditState(late.state); + + expect(winner.result).toMatchObject({ + kind: "settled", + settlement: { candidateId: "candidate-2", outcome: "allowed" }, + }); + expect(late.result).toMatchObject({ kind: "stale", settlement: { outcome: "allowed" } }); + expect(audit.activeCandidates).toEqual([]); + expect(audit.candidateHistory).toEqual( + expect.arrayContaining([ + expect.objectContaining({ candidateId: "candidate-1", status: "stale" }), + expect.objectContaining({ candidateId: "candidate-2", status: "allowed" }), + ]), + ); + }); + + it("lets Cancel win atomically and stales every Allow candidate", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const cancelled = cancelApprovalRequest({ + actor: responder("U2"), + requestId: "request-1", + settledAt: 250, + state: first.state, + }); + const late = settleAllowedCandidate({ + candidateId: "candidate-1", + settledAt: 300, + state: cancelled.state, + }); + + expect(cancelled.result).toMatchObject({ + kind: "settled", + settlement: { actor: { principalId: "U2" }, outcome: "cancelled" }, + }); + expect(late.result).toMatchObject({ + kind: "stale", + settlement: { outcome: "cancelled" }, + }); + }); + + it("does not let a candidate start after terminal settlement", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const settled = settleAllowedCandidate({ + candidateId: "candidate-1", + settledAt: 300, + state: first.state, + }); + const late = create({ + candidateId: "candidate-2", + principalId: "U2", + state: settled.state, + }); + + expect(late.result).toMatchObject({ kind: "stale", settlement: { outcome: "allowed" } }); + }); + + it("keeps unrelated requests active when another request settles", () => { + const first = create({ candidateId: "candidate-1", principalId: "U1" }); + const unrelated = create({ + candidateId: "candidate-2", + principalId: "U2", + requestId: "request-2", + state: first.state, + }); + const settled = settleAllowedCandidate({ + candidateId: "candidate-1", + settledAt: 300, + state: unrelated.state, + }); + + expect(getApprovalAuditState(settled.state).activeCandidates).toEqual([ + expect.objectContaining({ candidateId: "candidate-2", requestId: "request-2" }), + ]); + }); +}); diff --git a/packages/eve/src/harness/approval-candidates.ts b/packages/eve/src/harness/approval-candidates.ts new file mode 100644 index 000000000..ab1502a6b --- /dev/null +++ b/packages/eve/src/harness/approval-candidates.ts @@ -0,0 +1,417 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import type { AuthorizationChallenge } from "#harness/authorization.js"; +import type { SessionStateMap } from "#harness/types.js"; + +const APPROVAL_STATE_KEY = "eve.runtime.hitl.approvalState"; + +export type ApprovalCandidateStatus = + | "pending" + | "authorization-required" + | "allowed" + | "rejected" + | "failed" + | "timed-out" + | "stale"; + +export interface ApprovalCandidateAuditRecord { + readonly candidateId: string; + readonly requestId: string; + readonly responder: ApprovalResponderIdentity; + readonly status: ApprovalCandidateStatus; + readonly createdAt: number; + readonly completedAt?: number; + readonly expiresAt?: number; + readonly authorizationChallenges?: readonly AuthorizationChallenge[]; + readonly provider?: string; + readonly runtimeRevision?: string; + readonly safeReason?: string; +} + +export interface ApprovalResponderIdentity { + readonly authenticator: string; + readonly issuer?: string; + readonly principalId: string; + readonly principalType: string; +} + +export interface ApprovalSettlementAuditRecord { + readonly actor: ApprovalResponderIdentity; + readonly outcome: "allowed" | "cancelled"; + readonly requestId: string; + readonly settledAt: number; + readonly candidateId?: string; +} + +export interface ActiveApprovalCandidate { + readonly candidateId: string; + readonly requestId: string; + readonly responder: SessionAuthContext; + readonly status: "pending" | "authorization-required"; + readonly createdAt: number; + readonly expiresAt: number; + readonly authorizationChallenges?: readonly AuthorizationChallenge[]; + readonly provider?: string; + readonly runtimeRevision?: string; +} + +interface DurableApprovalState { + readonly activeCandidates: Readonly>; + readonly nextCandidateSequence: number; + readonly candidateHistory: readonly ApprovalCandidateAuditRecord[]; + readonly settlements: Readonly>; +} + +export type CreateApprovalCandidateResult = + | { readonly kind: "created"; readonly candidate: ActiveApprovalCandidate } + | { readonly kind: "duplicate"; readonly candidate: ActiveApprovalCandidate } + | { readonly kind: "stale"; readonly settlement: ApprovalSettlementAuditRecord }; + +export type SettleApprovalResult = + | { readonly kind: "settled"; readonly settlement: ApprovalSettlementAuditRecord } + | { readonly kind: "stale"; readonly settlement: ApprovalSettlementAuditRecord }; + +export interface ApprovalStateTransition { + readonly result: TResult; + readonly state: SessionStateMap | undefined; +} + +/** Creates or deduplicates one responder's Allow candidate for a pending request. */ +export function createApprovalCandidate(input: { + readonly candidateIdPrefix: string; + readonly createdAt: number; + readonly expiresAt: number; + readonly requestId: string; + readonly responder: SessionAuthContext; + readonly runtimeRevision?: string; + readonly state: SessionStateMap | undefined; +}): ApprovalStateTransition { + const expiredState = expireApprovalCandidates({ now: input.createdAt, state: input.state }); + const approvalState = readApprovalState(expiredState); + const settlement = approvalState.settlements[input.requestId]; + if (settlement !== undefined) { + return { result: { kind: "stale", settlement }, state: expiredState }; + } + + const responder = input.responder; + const duplicate = Object.values(approvalState.activeCandidates).find( + (candidate) => + candidate.requestId === input.requestId && sameResponder(candidate.responder, responder), + ); + if (duplicate !== undefined) { + return { result: { kind: "duplicate", candidate: duplicate }, state: expiredState }; + } + + const prefixWasUsed = + approvalState.activeCandidates[input.candidateIdPrefix] !== undefined || + approvalState.candidateHistory.some( + (candidate) => candidate.candidateId === input.candidateIdPrefix, + ); + const candidateId = prefixWasUsed + ? `${input.candidateIdPrefix}.${approvalState.nextCandidateSequence.toString(36)}` + : input.candidateIdPrefix; + if ( + approvalState.activeCandidates[candidateId] !== undefined || + approvalState.candidateHistory.some((candidate) => candidate.candidateId === candidateId) + ) { + throw new Error(`Approval candidate id collision: "${candidateId}".`); + } + + const candidate: ActiveApprovalCandidate = { + candidateId, + createdAt: input.createdAt, + expiresAt: input.expiresAt, + requestId: input.requestId, + responder, + runtimeRevision: input.runtimeRevision, + status: "pending", + }; + const next: DurableApprovalState = { + ...approvalState, + activeCandidates: { ...approvalState.activeCandidates, [candidate.candidateId]: candidate }, + nextCandidateSequence: approvalState.nextCandidateSequence + 1, + }; + return { + result: { candidate, kind: "created" }, + state: writeApprovalState(expiredState, next), + }; +} + +/** Marks a candidate as waiting on a private authorization challenge. */ +export function markApprovalCandidateAuthorizationRequired(input: { + readonly authorizationChallenges: readonly AuthorizationChallenge[]; + readonly candidateId: string; + readonly expiresAt?: number; + readonly provider?: string; + readonly state: SessionStateMap | undefined; +}): SessionStateMap | undefined { + const approvalState = readApprovalState(input.state); + const candidate = approvalState.activeCandidates[input.candidateId]; + if (candidate === undefined) return input.state; + const nextCandidate: ActiveApprovalCandidate = { + ...candidate, + authorizationChallenges: input.authorizationChallenges, + expiresAt: input.expiresAt ?? candidate.expiresAt, + provider: input.provider, + status: "authorization-required", + }; + return writeApprovalState(input.state, { + ...approvalState, + activeCandidates: { ...approvalState.activeCandidates, [input.candidateId]: nextCandidate }, + }); +} + +/** Finishes one candidate without settling the shared request. */ +export function finishApprovalCandidate(input: { + readonly candidateId: string; + readonly completedAt: number; + readonly safeReason?: string; + readonly state: SessionStateMap | undefined; + readonly status: Exclude; +}): SessionStateMap | undefined { + const approvalState = readApprovalState(input.state); + const candidate = approvalState.activeCandidates[input.candidateId]; + if (candidate === undefined) return input.state; + const activeCandidates = { ...approvalState.activeCandidates }; + delete activeCandidates[input.candidateId]; + return writeApprovalState(input.state, { + ...approvalState, + activeCandidates, + candidateHistory: [ + ...approvalState.candidateHistory, + toCandidateAuditRecord({ + candidate, + completedAt: input.completedAt, + safeReason: input.safeReason, + status: input.status, + }), + ], + }); +} + +/** Expires active candidates whose deterministic deadline has passed. */ +export function expireApprovalCandidates(input: { + readonly now: number; + readonly state: SessionStateMap | undefined; +}): SessionStateMap | undefined { + let state = input.state; + const candidates = Object.values(readApprovalState(state).activeCandidates); + for (const candidate of candidates) { + if (candidate.expiresAt > input.now) continue; + state = finishApprovalCandidate({ + candidateId: candidate.candidateId, + completedAt: input.now, + state, + status: "timed-out", + }); + } + return state; +} + +/** Atomically settles an allowed candidate; every losing candidate becomes stale. */ +export function settleAllowedCandidate(input: { + readonly candidateId: string; + readonly settledAt: number; + readonly state: SessionStateMap | undefined; +}): ApprovalStateTransition { + const expiredState = expireApprovalCandidates({ now: input.settledAt, state: input.state }); + const approvalState = readApprovalState(expiredState); + const candidate = approvalState.activeCandidates[input.candidateId]; + if (candidate === undefined) { + const historical = approvalState.candidateHistory.find( + (entry) => entry.candidateId === input.candidateId, + ); + const settlement = historical && approvalState.settlements[historical.requestId]; + if (settlement !== undefined) { + return { result: { kind: "stale", settlement }, state: expiredState }; + } + throw new Error(`Unknown approval candidate "${input.candidateId}".`); + } + return settleRequest({ + actor: projectResponder(candidate.responder), + candidateId: candidate.candidateId, + outcome: "allowed", + requestId: candidate.requestId, + settledAt: input.settledAt, + state: expiredState, + }); +} + +/** Atomically settles a direct authenticated approval response. */ +export function settleDirectApprovalResponse(input: { + readonly actor: SessionAuthContext; + readonly outcome: "allowed" | "cancelled"; + readonly requestId: string; + readonly settledAt: number; + readonly state: SessionStateMap | undefined; +}): ApprovalStateTransition { + const state = expireApprovalCandidates({ now: input.settledAt, state: input.state }); + return settleRequest({ + actor: projectResponder(input.actor), + outcome: input.outcome, + requestId: input.requestId, + settledAt: input.settledAt, + state, + }); +} + +/** Atomically cancels a pending request using ordinary authenticated flow control. */ +export function cancelApprovalRequest( + input: Omit[0], "outcome">, +): ApprovalStateTransition { + return settleDirectApprovalResponse({ ...input, outcome: "cancelled" }); +} + +/** Returns one active candidate by id. */ +export function getActiveApprovalCandidate( + state: SessionStateMap | undefined, + candidateId: string, +): ActiveApprovalCandidate | undefined { + return readApprovalState(state).activeCandidates[candidateId]; +} + +/** Returns a copy of the durable candidate/audit state for inspection and replay. */ +export function getApprovalAuditState(state: SessionStateMap | undefined): { + readonly activeCandidates: readonly ActiveApprovalCandidate[]; + readonly candidateHistory: readonly ApprovalCandidateAuditRecord[]; + readonly settlements: readonly ApprovalSettlementAuditRecord[]; +} { + const approvalState = readApprovalState(state); + return { + activeCandidates: Object.values(approvalState.activeCandidates), + candidateHistory: approvalState.candidateHistory, + settlements: Object.values(approvalState.settlements), + }; +} + +function settleRequest(input: { + readonly actor: ApprovalResponderIdentity; + readonly candidateId?: string; + readonly outcome: ApprovalSettlementAuditRecord["outcome"]; + readonly requestId: string; + readonly settledAt: number; + readonly state: SessionStateMap | undefined; +}): ApprovalStateTransition { + const approvalState = readApprovalState(input.state); + const existing = approvalState.settlements[input.requestId]; + if (existing !== undefined) { + return { result: { kind: "stale", settlement: existing }, state: input.state }; + } + + const settlement: ApprovalSettlementAuditRecord = { + actor: input.actor, + candidateId: input.candidateId, + outcome: input.outcome, + requestId: input.requestId, + settledAt: input.settledAt, + }; + const activeCandidates: Record = {}; + const candidateHistory = [...approvalState.candidateHistory]; + for (const candidate of Object.values(approvalState.activeCandidates)) { + if (candidate.requestId !== input.requestId) { + activeCandidates[candidate.candidateId] = candidate; + continue; + } + candidateHistory.push( + toCandidateAuditRecord({ + candidate, + completedAt: input.settledAt, + status: candidate.candidateId === input.candidateId ? "allowed" : "stale", + }), + ); + } + const next: DurableApprovalState = { + activeCandidates, + candidateHistory, + nextCandidateSequence: approvalState.nextCandidateSequence, + settlements: { ...approvalState.settlements, [input.requestId]: settlement }, + }; + return { + result: { kind: "settled", settlement }, + state: writeApprovalState(input.state, next), + }; +} + +function toCandidateAuditRecord(input: { + readonly candidate: ActiveApprovalCandidate; + readonly completedAt: number; + readonly safeReason?: string; + readonly status: Exclude; +}): ApprovalCandidateAuditRecord { + const { + authorizationChallenges: _authorizationChallenges, + responder, + ...candidate + } = input.candidate; + return { + ...candidate, + completedAt: input.completedAt, + responder: projectResponder(responder), + safeReason: input.safeReason, + status: input.status, + }; +} + +function projectResponder(responder: SessionAuthContext): ApprovalResponderIdentity { + return { + authenticator: responder.authenticator, + issuer: responder.issuer, + principalId: responder.principalId, + principalType: responder.principalType, + }; +} + +function sameResponder( + a: Pick, + b: Pick, +): boolean { + return ( + a.authenticator === b.authenticator && + a.issuer === b.issuer && + a.principalId === b.principalId && + a.principalType === b.principalType + ); +} + +function readApprovalState(state: SessionStateMap | undefined): DurableApprovalState { + const value = state?.[APPROVAL_STATE_KEY]; + if (typeof value !== "object" || value === null) { + return { + activeCandidates: {}, + candidateHistory: [], + nextCandidateSequence: 0, + settlements: {}, + }; + } + const candidate = value as Partial; + return { + activeCandidates: + typeof candidate.activeCandidates === "object" && candidate.activeCandidates !== null + ? candidate.activeCandidates + : {}, + candidateHistory: Array.isArray(candidate.candidateHistory) ? candidate.candidateHistory : [], + nextCandidateSequence: + typeof candidate.nextCandidateSequence === "number" && + Number.isSafeInteger(candidate.nextCandidateSequence) && + candidate.nextCandidateSequence >= 0 + ? candidate.nextCandidateSequence + : deriveNextCandidateSequence(candidate), + settlements: + typeof candidate.settlements === "object" && candidate.settlements !== null + ? candidate.settlements + : {}, + }; +} + +function deriveNextCandidateSequence(state: Partial): number { + return ( + Object.keys(state.activeCandidates ?? {}).length + + (Array.isArray(state.candidateHistory) ? state.candidateHistory.length : 0) + ); +} + +function writeApprovalState( + state: SessionStateMap | undefined, + approvalState: DurableApprovalState, +): SessionStateMap { + return { ...state, [APPROVAL_STATE_KEY]: approvalState }; +} diff --git a/packages/eve/src/harness/approval-delivery-coordinator.ts b/packages/eve/src/harness/approval-delivery-coordinator.ts new file mode 100644 index 000000000..841512836 --- /dev/null +++ b/packages/eve/src/harness/approval-delivery-coordinator.ts @@ -0,0 +1,392 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import { buildCallbackContext } from "#context/build-callback-context.js"; +import { contextStorage } from "#context/container.js"; +import { AuthKey, SessionKey } from "#context/keys.js"; +import { + buildApprovalResponseAuth, + handleApprovalResponseAuthorizationError, +} from "#execution/tool-auth.js"; +import { + cancelApprovalRequest, + createApprovalCandidate, + expireApprovalCandidates, + finishApprovalCandidate, + getActiveApprovalCandidate, + getApprovalAuditState, + markApprovalCandidateAuthorizationRequired, + settleAllowedCandidate, + settleDirectApprovalResponse, + type ActiveApprovalCandidate, +} from "#harness/approval-candidates.js"; +import { + getAuthorizationResult, + getPendingAuthorization, + isAuthorizationSignal, + type AuthorizationChallenge, +} from "#harness/authorization.js"; +import { isApprovalRequest } from "#harness/input-request-class.js"; +import { + getPendingInputBatch, + getResponseAuthRequiredRequestIds, +} from "#harness/input-requests.js"; +import type { HarnessSession, HarnessToolMap, StepInput } from "#harness/types.js"; +import type { InputRequest } from "#runtime/input/types.js"; + +const APPROVAL_AUTHORIZER_TIMEOUT_MS = 10_000; +const APPROVAL_CANDIDATE_TTL_MS = 10 * 60_000; + +export interface ApprovalDeliveryResult { + readonly challenges: readonly AuthorizationChallenge[]; + readonly kind: "continue" | "continue-coordination" | "authorization-required"; + readonly session: HarnessSession; + readonly stepInput?: StepInput; +} + +/** + * Advances approval state by one durable phase. + * + * | State | Input | Transition | + * | --- | --- | --- | + * | pending request | Approve | create responder-bound candidate | + * | pending request | Cancel | settle cancelled and stale candidates | + * | pending candidate | coordinator pass | run current authorizer | + * | pending candidate | allowed | settle approved and stale competitors | + * | pending candidate | rejected/error/expiry | append terminal history | + * | pending candidate | authorization required | persist private challenge | + * | authorization required | matching callback | re-run current authorizer | + * | settled request | any later response | no state change | + * + * Delivery ingestion returns before authorizer work so Cancel and candidate + * creation commit before long-running policy execution. Candidate results also + * commit before lifecycle events are projected by the next stack layer. + */ +export async function coordinateApprovalDelivery(input: { + readonly now?: number; + readonly runtimeRevision?: string; + readonly session: HarnessSession; + readonly stepInput?: StepInput; + readonly tools: HarnessToolMap; +}): Promise { + const now = input.now ?? Date.now(); + let session: HarnessSession = { + ...input.session, + state: expireApprovalCandidates({ now, state: input.session.state }), + }; + const batch = getPendingInputBatch(session.state); + if (batch === undefined) return deliveryResult(session, input.stepInput); + + const authorizationRequiredRequestIds = getResponseAuthRequiredRequestIds(session.state); + const requests = new Map(batch.requests.map((request) => [request.requestId, request])); + const challenges: AuthorizationChallenge[] = []; + const consumed = new Set(); + let didCommit = false; + const candidatesAtStart = getApprovalAuditState(session.state).activeCandidates; + + for (const response of input.stepInput?.inputResponses ?? []) { + const request = requests.get(response.requestId); + if (request === undefined || !isApprovalRequest(request)) continue; + + const requiresAuthorization = authorizationRequiredRequestIds.has(response.requestId); + if (!requiresAuthorization) { + const context = contextStorage.getStore(); + const responder = context?.get(AuthKey) ?? context?.get(SessionKey)?.auth.current ?? null; + if ( + responder !== null && + (response.optionId === "approve" || response.optionId === "cancel") + ) { + consumed.add(response.requestId); + const settled = settleDirectApprovalResponse({ + actor: responder, + outcome: response.optionId === "approve" ? "allowed" : "cancelled", + requestId: response.requestId, + settledAt: now, + state: session.state, + }); + session = { ...session, state: settled.state }; + didCommit ||= settled.result.kind === "settled"; + } + continue; + } + consumed.add(response.requestId); + + if (response.optionId === "cancel") { + const responder = buildCallbackContext().session.auth.current; + if (responder === null) continue; + const settled = cancelApprovalRequest({ + actor: responder, + requestId: response.requestId, + settledAt: now, + state: session.state, + }); + session = { ...session, state: settled.state }; + didCommit ||= settled.result.kind === "settled"; + continue; + } + + if (response.optionId !== "approve") continue; + const responder = buildCallbackContext().session.auth.current; + if (responder === null) continue; + + const created = createApprovalCandidate({ + candidateIdPrefix: approvalCandidateIdPrefix(request.requestId, responder), + createdAt: now, + expiresAt: now + APPROVAL_CANDIDATE_TTL_MS, + requestId: request.requestId, + responder, + runtimeRevision: input.runtimeRevision, + state: session.state, + }); + session = { ...session, state: created.state }; + didCommit ||= created.result.kind === "created"; + } + + const stepInput = removeConsumedResponses(input.stepInput, consumed); + if (consumed.size > 0) { + return deliveryResult(session, stepInput, didCommit ? "continue-coordination" : "continue"); + } + + // Candidates are persisted in an earlier pass. Run pending candidates and + // resume only authorization-required candidates whose callback arrived. + const parkedChallengeNames = new Set( + getPendingAuthorization(session.state)?.challenges.map((challenge) => challenge.name) ?? [], + ); + for (const candidate of candidatesAtStart) { + if (candidate.status === "authorization-required") { + const candidateChallenges = candidate.authorizationChallenges ?? []; + const hasCallback = candidateChallenges.some( + (challenge) => getAuthorizationResult(challenge.name) !== undefined, + ); + if (!hasCallback) { + challenges.push( + ...candidateChallenges.filter((challenge) => !parkedChallengeNames.has(challenge.name)), + ); + continue; + } + } + + const request = requests.get(candidate.requestId); + if ( + request === undefined || + getActiveApprovalCandidate(session.state, candidate.candidateId) === undefined + ) { + continue; + } + const processed = await authorizeCandidate({ + candidateId: candidate.candidateId, + now, + request, + responder: candidate.responder, + session, + tools: input.tools, + }); + session = processed.session; + didCommit ||= processed.didCommit; + challenges.push(...processed.challenges); + } + + return didCommit + ? deliveryResult(session, stepInput, "continue-coordination") + : deliveryResult( + session, + stepInput, + challenges.length > 0 ? "authorization-required" : "continue", + challenges, + ); +} + +async function authorizeCandidate(input: { + readonly candidateId: string; + readonly now: number; + readonly request: InputRequest; + readonly responder: ActiveApprovalCandidate["responder"]; + readonly session: HarnessSession; + readonly tools: HarnessToolMap; +}): Promise<{ + readonly challenges: readonly AuthorizationChallenge[]; + readonly didCommit: boolean; + readonly session: HarnessSession; +}> { + // Expiry is checked again immediately before callback/policy execution. + let session = { + ...input.session, + state: expireApprovalCandidates({ now: input.now, state: input.session.state }), + }; + if (getActiveApprovalCandidate(session.state, input.candidateId) === undefined) { + return { challenges: [], didCommit: false, session }; + } + + const approval = input.tools.get(input.request.action.toolName)?.approval; + const authorizer = + approval !== undefined && typeof approval !== "function" + ? approval.authorizeResponse + : undefined; + if (authorizer === undefined) { + return failCandidate({ + ...input, + safeReason: "Approval authorization is temporarily unavailable. Please try again.", + session, + }); + } + + try { + const context = buildCallbackContext(); + const outcome = await withAuthorizerTimeout( + authorizer({ + auth: buildApprovalResponseAuth({ + responder: input.responder, + scope: input.candidateId, + }), + request: { + callId: input.request.action.callId, + requestId: input.request.requestId, + toolInput: input.request.action.input, + toolName: input.request.action.toolName, + }, + responder: input.responder, + session: { + id: context.session.id, + initiator: context.session.auth.initiator, + parent: context.session.parent, + turn: context.session.turn, + }, + }), + ); + if (outcome !== "allowed") { + session = { + ...session, + state: finishApprovalCandidate({ + candidateId: input.candidateId, + completedAt: input.now, + safeReason: outcome.safeReason, + state: session.state, + status: "rejected", + }), + }; + return { challenges: [], didCommit: true, session }; + } + + const settled = settleAllowedCandidate({ + candidateId: input.candidateId, + settledAt: input.now, + state: session.state, + }); + return { + challenges: [], + didCommit: settled.result.kind === "settled", + session: { ...session, state: settled.state }, + }; + } catch (error) { + const authorization = await handleApprovalResponseAuthorizationError(error).catch( + () => undefined, + ); + if (isAuthorizationSignal(authorization)) { + const providerExpiresAt = authorization.challenges + .map((entry) => Date.parse(entry.challenge.expiresAt ?? "")) + .filter(Number.isFinite) + .sort((a, b) => a - b)[0]; + session = { + ...session, + state: markApprovalCandidateAuthorizationRequired({ + authorizationChallenges: authorization.challenges.map((challenge) => ({ + ...challenge, + candidateId: input.candidateId, + })), + candidateId: input.candidateId, + expiresAt: providerExpiresAt, + provider: authorization.challenges[0]?.challenge.displayName, + state: session.state, + }), + }; + return { + challenges: authorization.challenges.map((challenge) => ({ + ...challenge, + candidateId: input.candidateId, + })), + didCommit: true, + session, + }; + } + return failCandidate({ ...input, session }); + } +} + +function failCandidate(input: { + readonly candidateId: string; + readonly now: number; + readonly request: InputRequest; + readonly safeReason?: string; + readonly session: HarnessSession; +}): { + readonly challenges: readonly AuthorizationChallenge[]; + readonly didCommit: true; + readonly session: HarnessSession; +} { + const safeReason = input.safeReason ?? "We couldn’t verify your approval. Please try again."; + return { + challenges: [], + didCommit: true, + session: { + ...input.session, + state: finishApprovalCandidate({ + candidateId: input.candidateId, + completedAt: input.now, + safeReason, + state: input.session.state, + status: "failed", + }), + }, + }; +} + +function removeConsumedResponses( + stepInput: StepInput | undefined, + consumed: ReadonlySet, +): StepInput | undefined { + if (stepInput?.inputResponses === undefined) return stepInput; + return { + ...stepInput, + inputResponses: stepInput.inputResponses.filter( + (response) => !consumed.has(response.requestId), + ), + }; +} + +function deliveryResult( + session: HarnessSession, + stepInput: StepInput | undefined, + kind: ApprovalDeliveryResult["kind"] = "continue", + challenges: readonly AuthorizationChallenge[] = [], +): ApprovalDeliveryResult { + return { challenges, kind, session, stepInput }; +} + +function approvalCandidateIdPrefix(requestId: string, responder: SessionAuthContext): string { + const principal = [ + responder.authenticator, + responder.issuer ?? "", + responder.principalType, + responder.principalId, + ].join(":"); + return `${encodeCandidateIdPart(requestId)}.${encodeCandidateIdPart(principal)}`; +} + +function encodeCandidateIdPart(value: string): string { + return Array.from(value, (character) => character.codePointAt(0)!.toString(36)).join("-"); +} + +async function withAuthorizerTimeout(promise: Promise | T): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve(promise), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error("Approval response authorizer timed out.")), + APPROVAL_AUTHORIZER_TIMEOUT_MS, + ); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} diff --git a/packages/eve/src/harness/input-extraction.test.ts b/packages/eve/src/harness/input-extraction.test.ts index 2714aab76..2dde16370 100644 --- a/packages/eve/src/harness/input-extraction.test.ts +++ b/packages/eve/src/harness/input-extraction.test.ts @@ -35,7 +35,7 @@ describe("extractQuestionInputRequests", () => { }, display: "select", kind: "question", - options: [{ id: "yes", label: "Yes" }], + options: [{ id: "yes", label: "Approve" }], prompt: "Continue?", requestId: "call-1", }, diff --git a/packages/eve/src/harness/input-extraction.ts b/packages/eve/src/harness/input-extraction.ts index 2bdc48a6a..bf70abcaa 100644 --- a/packages/eve/src/harness/input-extraction.ts +++ b/packages/eve/src/harness/input-extraction.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { ASK_QUESTION_TOOL_NAME } from "#runtime/framework-tools/ask-question.js"; import type { InputRequest } from "#runtime/input/types.js"; -import { createRuntimeToolCallActionFromToolCall } from "#harness/input-requests.js"; +import { createRuntimeToolCallActionFromToolCall } from "#harness/tool-call-action.js"; // Persisted history parts lose AI SDK typing on the storage round trip. The // schemas are the single source for the runtime narrowing and the static diff --git a/packages/eve/src/harness/input-requests.test.ts b/packages/eve/src/harness/input-requests.test.ts index d4a36c44f..22e21ce4f 100644 --- a/packages/eve/src/harness/input-requests.test.ts +++ b/packages/eve/src/harness/input-requests.test.ts @@ -4,12 +4,19 @@ import { describe, expect, it } from "vitest"; import { ContextContainer, contextStorage } from "#context/container.js"; import { SessionKey } from "#context/keys.js"; import { once } from "#public/tools/approval/approval-helpers.js"; +import type { ApprovalResponseAuthorizer } from "#public/definitions/approval.js"; import type { InputRequest } from "#runtime/input/types.js"; import type { HarnessToolDefinition } from "#harness/execute-tool.js"; +import { + createApprovalCandidate, + getApprovalAuditState, + markApprovalCandidateAuthorizationRequired, +} from "#harness/approval-candidates.js"; +import { setPendingAuthorization } from "#harness/authorization.js"; +import { coordinateApprovalDelivery } from "#harness/approval-delivery-coordinator.js"; import { clearPendingSessionLimitPrompt, consumeDeferredStepInput, - createRuntimeToolCallActionFromToolCall, getApprovedTools, hasDeferredStepInput, hasStepInput, @@ -17,6 +24,7 @@ import { setPendingInputBatch, } from "#harness/input-requests.js"; import { createSessionLimitContinuationRequest } from "#harness/session-limit-continuation.js"; +import { createRuntimeToolCallActionFromToolCall } from "#harness/tool-call-action.js"; import { buildToolApproval, buildToolSet } from "#harness/tools.js"; import type { HarnessSession, HarnessToolMap } from "#harness/types.js"; @@ -880,6 +888,474 @@ describe("resolvePendingInput", () => { }); }); +describe("coordinateApprovalDelivery", () => { + async function completeApprovalDelivery(input: Parameters[0]) { + let result = await coordinateApprovalDelivery(input); + for (let pass = 0; result.kind === "continue-coordination" && pass < 3; pass += 1) { + result = await coordinateApprovalDelivery({ + ...input, + session: result.session, + stepInput: result.stepInput, + }); + } + return result; + } + + function approvalRequest( + input: { + readonly requestId?: string; + readonly toolName?: string; + } = {}, + ): InputRequest { + const requestId = input.requestId ?? "approval-1"; + const toolName = input.toolName ?? "create_issue"; + return { + action: { + callId: requestId.replace("approval", "call"), + input: { owner: "vercel", repo: "eve" }, + kind: "tool-call", + toolName, + }, + kind: "tool-approval", + options: [ + { id: "approve", label: "Approve" }, + { id: "cancel", label: "Cancel" }, + ], + prompt: `Approve tool call: ${toolName}`, + requestId, + }; + } + + function pendingSession(requiresAuthorization = true): HarnessSession { + return setPendingInputBatch({ + requests: [approvalRequest()], + responseAuthRequiredRequestIds: requiresAuthorization ? ["approval-1"] : [], + responseMessages: [], + session: createHarnessSession(), + }); + } + + function approvalTool( + name: string, + authorizeResponse: ApprovalResponseAuthorizer, + ): HarnessToolDefinition { + return { + approval: { authorizeResponse, policy: () => "user-approval" }, + description: name, + inputSchema: jsonSchema({ type: "object" }), + name, + }; + } + + function approvalContext(run: () => T): T { + const ctx = new ContextContainer(); + ctx.set(SessionKey, { + auth: { + current: { + attributes: {}, + authenticator: "slack-webhook", + issuer: "slack:T1", + principalId: "U1", + principalType: "user", + }, + initiator: null, + }, + sessionId: "sess-test", + turn: { id: "turn-test", sequence: 1 }, + }); + return contextStorage.run(ctx, run); + } + + it("fails closed when a required authorizer is missing", async () => { + const result = await approvalContext(() => + completeApprovalDelivery({ + now: 100, + session: pendingSession(), + stepInput: { + inputResponses: [{ optionId: "approve", requestId: "approval-1" }], + }, + tools: new Map(), + }), + ); + + expect(result.stepInput).toEqual({ inputResponses: [] }); + expect(getApprovalAuditState(result.session.state).candidateHistory).toEqual([ + expect.objectContaining({ + safeReason: "Approval authorization is temporarily unavailable. Please try again.", + status: "failed", + }), + ]); + expect(resolvePendingInput({ session: result.session }).outcome).toBe("unresolved"); + }); + + it("retains the request after an authored safe rejection", async () => { + const tools: HarnessToolMap = new Map([ + [ + "create_issue", + approvalTool("create_issue", ({ responder }) => ({ + safeReason: `${responder.principalId} lacks repository write access.`, + status: "rejected", + })), + ], + ]); + const result = await approvalContext(() => + completeApprovalDelivery({ + now: 100, + session: pendingSession(), + stepInput: { + inputResponses: [{ optionId: "approve", requestId: "approval-1" }], + }, + tools, + }), + ); + + expect(result.stepInput).toEqual({ inputResponses: [] }); + expect(getApprovalAuditState(result.session.state).candidateHistory).toEqual([ + expect.objectContaining({ + safeReason: "U1 lacks repository write access.", + status: "rejected", + }), + ]); + expect(resolvePendingInput({ session: result.session }).outcome).toBe("unresolved"); + }); + + it("durably settles an allowed candidate before ordinary approval resolution", async () => { + const tools: HarnessToolMap = new Map([ + ["create_issue", approvalTool("create_issue", () => "allowed")], + ]); + const result = await approvalContext(() => + completeApprovalDelivery({ + now: 100, + session: pendingSession(), + stepInput: { + inputResponses: [{ optionId: "approve", requestId: "approval-1" }], + }, + tools, + }), + ); + + expect(result.kind).toBe("continue"); + expect(result.session.state?.["eve.runtime.hitl.approvalState"]).toMatchObject({ + settlements: { "approval-1": { outcome: "allowed" } }, + }); + expect( + resolvePendingInput({ session: result.session, stepInput: result.stepInput }).outcome, + ).toBe("resolved"); + }); + + it("processes every approval response in a delivery", async () => { + const second = { + action: { + callId: "call-2", + input: { owner: "vercel", repo: "eve" }, + kind: "tool-call" as const, + toolName: "delete_issue", + }, + kind: "tool-approval" as const, + options: [ + { id: "approve", label: "Approve" }, + { id: "cancel", label: "Cancel" }, + ], + prompt: "Approve tool call: delete_issue", + requestId: "approval-2", + }; + const firstBatch = pendingSession().state?.["eve.runtime.pendingInputBatch"] as { + requests: readonly InputRequest[]; + }; + const session = setPendingInputBatch({ + requests: [...firstBatch.requests, second], + responseAuthRequiredRequestIds: ["approval-1", "approval-2"], + responseMessages: [], + session: createHarnessSession(), + }); + const tools: HarnessToolMap = new Map([ + [ + "create_issue", + { + ...approvalTool("create_issue", () => "allowed"), + }, + ], + [ + "delete_issue", + { + approval: { + authorizeResponse: () => ({ safeReason: "Delete denied.", status: "rejected" }), + policy: () => "user-approval", + }, + description: "Delete issue", + inputSchema: jsonSchema({ type: "object" }), + name: "delete_issue", + }, + ], + ]); + const result = await approvalContext(() => + completeApprovalDelivery({ + now: 100, + session, + stepInput: { + inputResponses: [ + { optionId: "approve", requestId: "approval-1" }, + { optionId: "approve", requestId: "approval-2" }, + ], + }, + tools, + }), + ); + + const audit = getApprovalAuditState(result.session.state); + expect(audit.settlements).toEqual([ + expect.objectContaining({ outcome: "allowed", requestId: "approval-1" }), + ]); + expect(audit.candidateHistory).toEqual( + expect.arrayContaining([ + expect.objectContaining({ requestId: "approval-2", status: "rejected" }), + ]), + ); + expect(result.stepInput?.inputResponses).toEqual([]); + expect( + resolvePendingInput({ session: result.session, stepInput: result.stepInput }).outcome, + ).toBe("unresolved"); + }); + + it("processes Cancel before running an existing candidate authorizer", async () => { + let authorizerCalls = 0; + const pending = pendingSession(); + const created = createApprovalCandidate({ + candidateIdPrefix: "candidate-existing", + createdAt: 50, + expiresAt: 1_000, + requestId: "approval-1", + responder: { + attributes: {}, + authenticator: "slack-webhook", + issuer: "slack:T1", + principalId: "U2", + principalType: "user", + }, + state: pending.state, + }); + const result = await approvalContext(() => + coordinateApprovalDelivery({ + now: 100, + session: { ...pending, state: created.state }, + stepInput: { inputResponses: [{ optionId: "cancel", requestId: "approval-1" }] }, + tools: new Map([ + [ + "create_issue", + { + approval: { + authorizeResponse: () => { + authorizerCalls += 1; + return "allowed"; + }, + policy: () => "user-approval", + }, + description: "Create issue", + inputSchema: jsonSchema({ type: "object" }), + name: "create_issue", + }, + ], + ]), + }), + ); + + expect(authorizerCalls).toBe(0); + expect(getApprovalAuditState(result.session.state).settlements).toEqual([ + expect.objectContaining({ outcome: "cancelled", requestId: "approval-1" }), + ]); + }); + + it("does not re-emit an already parked candidate challenge", async () => { + const pending = pendingSession(); + const created = createApprovalCandidate({ + candidateIdPrefix: "candidate-existing", + createdAt: 50, + expiresAt: 1_000, + requestId: "approval-1", + responder: { + attributes: {}, + authenticator: "slack-webhook", + principalId: "U1", + principalType: "user", + }, + state: pending.state, + }); + const challenge = { + candidateId: "candidate-existing", + challenge: { url: "https://example.com/oauth" }, + hookUrl: "https://agent.example/auth/candidate-existing:github", + name: "candidate-existing:github", + }; + const candidateState = markApprovalCandidateAuthorizationRequired({ + authorizationChallenges: [challenge], + candidateId: "candidate-existing", + state: created.state, + }); + const state = setPendingAuthorization(candidateState, { challenges: [challenge] }); + + const result = await approvalContext(() => + coordinateApprovalDelivery({ + now: 100, + session: { ...pending, state }, + tools: new Map(), + }), + ); + + expect(result.challenges).toEqual([]); + expect(result.kind).toBe("continue"); + }); + + it("durably settles an authenticated ordinary approval without a candidate", async () => { + const session = setPendingInputBatch({ + requests: [ + { + action: { + callId: "call-1", + input: {}, + kind: "tool-call", + toolName: "create_issue", + }, + kind: "tool-approval", + options: [ + { id: "approve", label: "Approve" }, + { id: "cancel", label: "Cancel" }, + ], + prompt: "Approve tool call: create_issue", + requestId: "approval-1", + }, + ], + responseMessages: [], + session: createHarnessSession(), + }); + const coordinated = await approvalContext(() => + completeApprovalDelivery({ + now: 100, + session, + stepInput: { inputResponses: [{ optionId: "approve", requestId: "approval-1" }] }, + tools: new Map(), + }), + ); + const audit = getApprovalAuditState(coordinated.session.state); + + expect(audit.activeCandidates).toEqual([]); + expect(audit.settlements).toEqual([ + expect.objectContaining({ + actor: expect.objectContaining({ principalId: "U1" }), + outcome: "allowed", + requestId: "approval-1", + }), + ]); + expect( + resolvePendingInput({ + session: coordinated.session, + stepInput: coordinated.stepInput, + }).outcome, + ).toBe("resolved"); + }); + + it("preserves anonymous ordinary approval behavior without creating a settlement", async () => { + const session = setPendingInputBatch({ + requests: [ + { + action: { callId: "call-1", input: {}, kind: "tool-call", toolName: "create_issue" }, + kind: "tool-approval", + options: [ + { id: "approve", label: "Approve" }, + { id: "cancel", label: "Cancel" }, + ], + prompt: "Approve tool call: create_issue", + requestId: "approval-1", + }, + ], + responseMessages: [], + session: createHarnessSession(), + }); + const coordinated = await coordinateApprovalDelivery({ + now: 100, + session, + stepInput: { inputResponses: [{ optionId: "approve", requestId: "approval-1" }] }, + tools: new Map(), + }); + + expect(getApprovalAuditState(coordinated.session.state).settlements).toEqual([]); + expect( + resolvePendingInput({ session: coordinated.session, stepInput: coordinated.stepInput }) + .outcome, + ).toBe("resolved"); + }); + + it("assigns a fresh attempt id when the same responder retries", async () => { + const tools: HarnessToolMap = new Map([ + [ + "create_issue", + { + approval: { + authorizeResponse: () => ({ safeReason: "Retry allowed.", status: "rejected" }), + policy: () => "user-approval", + }, + description: "Create issue", + inputSchema: jsonSchema({ type: "object" }), + name: "create_issue", + }, + ], + ]); + const first = await approvalContext(() => + completeApprovalDelivery({ + now: 100, + session: pendingSession(), + stepInput: { inputResponses: [{ optionId: "approve", requestId: "approval-1" }] }, + tools, + }), + ); + const retry = await approvalContext(() => + completeApprovalDelivery({ + now: 200, + session: first.session, + stepInput: { inputResponses: [{ optionId: "approve", requestId: "approval-1" }] }, + tools, + }), + ); + const history = getApprovalAuditState(retry.session.state).candidateHistory; + + expect(history).toHaveLength(2); + expect(new Set(history.map((candidate) => candidate.candidateId)).size).toBe(2); + expect(history[1]?.candidateId).not.toBe(history[0]?.candidateId); + }); + + it("cancels without running the response authorizer", async () => { + const tools: HarnessToolMap = new Map([ + [ + "create_issue", + { + approval: { + authorizeResponse: () => { + throw new Error("must not run"); + }, + policy: () => "user-approval", + }, + description: "Create issue", + inputSchema: jsonSchema({ type: "object" }), + name: "create_issue", + }, + ], + ]); + const result = await approvalContext(() => + completeApprovalDelivery({ + now: 100, + session: pendingSession(), + stepInput: { + inputResponses: [{ optionId: "cancel", requestId: "approval-1" }], + }, + tools, + }), + ); + + expect(result.session.state?.["eve.runtime.hitl.approvalState"]).toMatchObject({ + settlements: { "approval-1": { outcome: "cancelled" } }, + }); + }); +}); + describe("resolvePendingInput with a session-limit continuation batch", () => { function createLimitBatchSession(): HarnessSession { return setPendingInputBatch({ diff --git a/packages/eve/src/harness/input-requests.ts b/packages/eve/src/harness/input-requests.ts index 786b8ec44..16a07fb25 100644 --- a/packages/eve/src/harness/input-requests.ts +++ b/packages/eve/src/harness/input-requests.ts @@ -1,14 +1,11 @@ import type { ModelMessage } from "ai"; -import type { - RuntimeToolCallActionRequest, - RuntimeToolResultActionResult, -} from "#runtime/actions/types.js"; +import { getApprovalAuditState } from "#harness/approval-candidates.js"; +import type { RuntimeToolResultActionResult } from "#runtime/actions/types.js"; import type { InputRequest, InputResponse } from "#runtime/input/types.js"; import { resolveTextToResponses } from "#channel/resolve-text.js"; import { classifyInputRequest, isApprovalRequest } from "#harness/input-request-class.js"; import { coalesceTurnInputs } from "#harness/messages.js"; -import { resolveToolCallInputObject } from "#harness/runtime-actions.js"; import { isSessionLimitContinuationRequest, resolveSessionLimitContinuation, @@ -42,6 +39,7 @@ interface PendingInputBatchEvent { */ interface PendingInputBatch { readonly event?: PendingInputBatchEvent; + readonly responseAuthRequiredRequestIds?: readonly string[]; readonly requests: readonly InputRequest[]; readonly responseMessages: readonly ModelMessage[]; } @@ -57,9 +55,7 @@ export interface RejectedActionBatch { type ApprovalTerminalStatus = "approved" | "denied" | "ignored" | "invalid"; -/** - * Returns true when the step input carries user-facing turn input. - */ +/** Returns true when the step input carries user-facing turn input. */ export function hasStepInput(input?: StepInput): boolean { if (input === undefined) { return false; @@ -68,10 +64,6 @@ export function hasStepInput(input?: StepInput): boolean { return input.message !== undefined || (input.inputResponses?.length ?? 0) > 0; } -// --------------------------------------------------------------------------- -// Deferred step input -// --------------------------------------------------------------------------- - /** * Merges any queued follow-up input into the current step input and clears it * from session state. @@ -149,7 +141,11 @@ export function resolvePendingInput(input: { // Pending batch exists -- only resolve if we have actual responses. const resolvedStepInput = resolveTextMessageInput(pendingBatch, stepInput); - const responses = resolvedStepInput?.inputResponses ?? []; + const responses = mergeSettledApprovalResponses({ + pendingBatch, + responses: resolvedStepInput?.inputResponses ?? [], + session, + }); const resolvesApprovalBatch = pendingBatch.requests.some((request) => isApprovalRequest(request)); if (responses.length === 0 && resolvedStepInput?.message === undefined) { @@ -248,6 +244,23 @@ export function resolvePendingInput(input: { }; } +function mergeSettledApprovalResponses(input: { + readonly pendingBatch: PendingInputBatch; + readonly responses: readonly InputResponse[]; + readonly session: HarnessSession; +}): readonly InputResponse[] { + const responses = new Map(input.responses.map((response) => [response.requestId, response])); + const requestIds = new Set(input.pendingBatch.requests.map((request) => request.requestId)); + for (const settlement of getApprovalAuditState(input.session.state).settlements) { + if (!requestIds.has(settlement.requestId)) continue; + responses.set(settlement.requestId, { + optionId: settlement.outcome === "allowed" ? "approve" : "cancel", + requestId: settlement.requestId, + }); + } + return [...responses.values()]; +} + function resolveTextMessageInput( pendingBatch: PendingInputBatch, stepInput: StepInput | undefined, @@ -372,7 +385,15 @@ export function getPendingInputRequestIds(state: SessionStateMap | undefined): R return new Set(getPendingInputBatch(state)?.requests.map((request) => request.requestId)); } -function getPendingInputBatch(state: SessionStateMap | undefined): PendingInputBatch | undefined { +export function getResponseAuthRequiredRequestIds( + state: SessionStateMap | undefined, +): ReadonlySet { + return new Set(getPendingInputBatch(state)?.responseAuthRequiredRequestIds ?? []); +} + +export function getPendingInputBatch( + state: SessionStateMap | undefined, +): PendingInputBatch | undefined { const value = state?.[PENDING_INPUT_BATCH_KEY]; if (typeof value !== "object" || value === null) { @@ -393,6 +414,7 @@ function getPendingInputBatch(state: SessionStateMap | undefined): PendingInputB */ export function setPendingInputBatch(input: { readonly event?: PendingInputBatchEvent; + readonly responseAuthRequiredRequestIds?: readonly string[]; readonly requests: readonly InputRequest[]; readonly responseMessages: readonly ModelMessage[]; readonly session: HarnessSession; @@ -400,6 +422,7 @@ export function setPendingInputBatch(input: { const state = { ...input.session.state }; state[PENDING_INPUT_BATCH_KEY] = { event: input.event, + responseAuthRequiredRequestIds: input.responseAuthRequiredRequestIds, requests: [...input.requests], responseMessages: [...input.responseMessages], } satisfies PendingInputBatch; @@ -418,15 +441,11 @@ function clearPendingInputBatch(session: HarnessSession): HarnessSession { return { ...session, state: Object.keys(state).length > 0 ? state : undefined }; } -// --------------------------------------------------------------------------- -// Deferred step input state -// --------------------------------------------------------------------------- - function getDeferredStepInput(session: HarnessSession): StepInput | undefined { return session.state?.[DEFERRED_STEP_INPUT_KEY] as StepInput | undefined; } -function queueDeferredStepInput(session: HarnessSession, input: StepInput): HarnessSession { +export function queueDeferredStepInput(session: HarnessSession, input: StepInput): HarnessSession { const existing = getDeferredStepInput(session); const deferredInput = existing === undefined ? input : coalesceTurnInputs(existing, input); const state = { ...session.state }; @@ -667,28 +686,3 @@ function buildToolResponsePartsForRequest( }, ]; } - -// --------------------------------------------------------------------------- -// Tool call helpers -// --------------------------------------------------------------------------- - -/** - * Creates a runtime tool-call action shape from an AI SDK tool call. - */ -export function createRuntimeToolCallActionFromToolCall(input: { - readonly toolCall: { - readonly input: unknown; - readonly toolCallId: string; - readonly toolName: string; - }; -}): RuntimeToolCallActionRequest { - return { - callId: input.toolCall.toolCallId, - input: resolveToolCallInputObject(input.toolCall.input, { - callId: input.toolCall.toolCallId, - toolName: input.toolCall.toolName, - }), - kind: "tool-call", - toolName: input.toolCall.toolName, - }; -} diff --git a/packages/eve/src/harness/tool-call-action.ts b/packages/eve/src/harness/tool-call-action.ts new file mode 100644 index 000000000..75dd41023 --- /dev/null +++ b/packages/eve/src/harness/tool-call-action.ts @@ -0,0 +1,21 @@ +import type { RuntimeToolCallActionRequest } from "#runtime/actions/types.js"; +import { resolveToolCallInputObject } from "#harness/runtime-actions.js"; + +/** Creates a runtime tool-call action shape from an AI SDK tool call. */ +export function createRuntimeToolCallActionFromToolCall(input: { + readonly toolCall: { + readonly input: unknown; + readonly toolCallId: string; + readonly toolName: string; + }; +}): RuntimeToolCallActionRequest { + return { + callId: input.toolCall.toolCallId, + input: resolveToolCallInputObject(input.toolCall.input, { + callId: input.toolCall.toolCallId, + toolName: input.toolCall.toolName, + }), + kind: "tool-call", + toolName: input.toolCall.toolName, + }; +} diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index 9851260cc..e9194a75a 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -33,7 +33,10 @@ import { contextStorage } from "#context/container.js"; import { AuthKey, ParentSessionKey } from "#context/keys.js"; import { buildDynamicInstructionMessages } from "#context/dynamic-instruction-lifecycle.js"; import { getActiveDynamicModelSelection } from "#context/dynamic-model-lifecycle.js"; -import { buildDynamicTools } from "#context/build-dynamic-tools.js"; +import { + buildDynamicTools, + buildResponseAuthorizationTools, +} from "#context/build-dynamic-tools.js"; import { PendingSkillAnnouncementKey } from "#context/dynamic-skill-lifecycle.js"; import { toErrorMessage } from "#shared/errors.js"; import { @@ -103,6 +106,7 @@ import { } from "#harness/input-extraction.js"; import { createToolResultMessagePartFromToolError } from "#harness/action-result-helpers.js"; import { activeTurnId } from "#harness/active-turn-id.js"; +import { coordinateApprovalDelivery } from "#harness/approval-delivery-coordinator.js"; import { buildTelemetryRuntimeContext } from "#harness/instrumentation-runtime-context.js"; import { createAiSdkHookBridge } from "#harness/ai-sdk-hook-bridge.js"; import { createInstrumentationHandleEvent } from "#harness/instrumentation-native-events.js"; @@ -113,6 +117,7 @@ import { getPendingInputRequestIds, hasDeferredStepInput, hasStepInput, + queueDeferredStepInput, resolvePendingInput, setPendingInputBatch, } from "#harness/input-requests.js"; @@ -580,11 +585,60 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { ? { ...effectiveStepInput, message: staleConversion.displayMessage } : effectiveStepInput; + const responseAuthorizationTools = buildResponseAuthorizationTools({ + authoredTools: config.tools, + context: contextStorage.getStore(), + }); + const coordinated = await coordinateApprovalDelivery({ + session, + stepInput: effectiveStepInput, + tools: responseAuthorizationTools, + }); + session = coordinated.session; + if (coordinated.kind === "continue-coordination") { + const continuedSession = + coordinated.stepInput === undefined + ? session + : queueDeferredStepInput(session, coordinated.stepInput); + return { next: runStep, session: continuedSession }; + } + if (coordinated.challenges.length > 0) { + if (emit) { + for (const challenge of coordinated.challenges) { + await emit( + createAuthorizationRequiredEvent({ + authorization: challenge.challenge, + description: + challenge.challenge.instructions ?? `Authorization required for ${challenge.name}`, + name: challenge.name, + sequence: emissionState.sequence, + stepIndex: emissionState.stepIndex, + turnId: emissionState.turnId, + webhookUrl: challenge.hookUrl, + }), + ); + } + } + const parkedSession = + coordinated.stepInput === undefined + ? session + : queueDeferredStepInput(session, coordinated.stepInput); + return { + next: null, + session: { + ...parkedSession, + state: setPendingAuthorization(parkedSession.state, { + challenges: coordinated.challenges, + }), + }, + }; + } + const pending = resolvePendingInput({ history: resolvedRuntimeActions.messages, resolveApprovalKey: resolveApprovalKeyFromTools(config.tools), session, - stepInput: effectiveStepInput, + stepInput: coordinated.stepInput, }); if (pending.outcome === "unresolved") { if (emit && pending.deferredMessage === true && hasStepInput(input)) { @@ -1994,6 +2048,10 @@ async function handleStepResult(input: { // --- Park on input requests ----------------------------------------------- if (inputRequests.length > 0) { + const responseAuthorizationTools = buildResponseAuthorizationTools({ + authoredTools: config.tools, + context: contextStorage.getStore(), + }); let parkedSession = setPendingInputBatch({ event: { sequence: emissionState.sequence, @@ -2001,6 +2059,16 @@ async function handleStepResult(input: { turnId: emissionState.turnId, }, requests: inputRequests, + responseAuthRequiredRequestIds: approvalRequests + .filter((request) => { + const approval = responseAuthorizationTools.get(request.action.toolName)?.approval; + return ( + approval !== undefined && + typeof approval !== "function" && + approval.authorizeResponse !== undefined + ); + }) + .map((request) => request.requestId), responseMessages, session: { ...baseSession, history: [...promptMessages] }, });