diff --git a/packages/eve/src/execution/tool-auth.ts b/packages/eve/src/execution/tool-auth.ts index d040ff465..853308b2f 100644 --- a/packages/eve/src/execution/tool-auth.ts +++ b/packages/eve/src/execution/tool-auth.ts @@ -19,6 +19,7 @@ import { ConnectionAuthorizationRequiredError, isConnectionAuthorizationRequiredError, } from "#public/connections/errors.js"; +import type { ApprovalResponseAuth } from "#public/definitions/approval.js"; import type { ToolAuthOptions, ToolAuthProvider, ToolContext } from "#public/definitions/tool.js"; import { type AuthorizationChallenge, requestAuthorization } from "#harness/authorization.js"; import { @@ -80,6 +81,49 @@ export function createToolExecuteWithAuth(input: { }; } +/** Builds the narrow token capability used by approval response authorizers. */ +export function buildApprovalResponseAuth(input: { readonly scope: string }): ApprovalResponseAuth { + const inlineAuthState: InlineAuthState = {}; + const justAuthorizedScopes = new Set(); + return { + async getToken(provider?: ToolAuthProvider, options?: ToolAuthOptions): Promise { + if (provider === undefined) throw missingProviderError("ctx.getToken"); + return await resolveInlineToken({ + inlineAuthState, + justAuthorizedScopes, + options: namespaceApprovalAuthOptions(input.scope, options), + provider, + toolScope: input.scope, + }); + }, + requireAuth(provider?: ToolAuthProvider, options?: ToolAuthOptions): never { + if (provider === undefined) throw missingProviderError("ctx.requireAuth"); + const scoped = buildInlineScopedAuthorization({ + inlineAuthState, + options: namespaceApprovalAuthOptions(input.scope, options), + provider, + toolScope: input.scope, + }); + throw new ToolAuthorizationRequiredError([ + { justAuthorized: justAuthorizedScopes.has(scoped.scope), scoped }, + ]); + }, + }; +} + +function namespaceApprovalAuthOptions( + scope: string, + options: ToolAuthOptions | undefined, +): ToolAuthOptions { + return { ...options, authKey: `${scope}:${options?.authKey ?? "inline-auth"}` }; +} + +/** Starts authorization requested by an approval response authorizer. */ +export async function handleApprovalResponseAuthorizationError(error: unknown): Promise { + if (!isToolAuthorizationRequiredError(error)) throw error; + return await handleAuthorizationRequests(error.requests); +} + function buildToolContext(input: { readonly options: ToolExecuteOptions; readonly scope: string; diff --git a/packages/eve/src/harness/approval-candidates.ts b/packages/eve/src/harness/approval-candidates.ts index 6a6bddf5d..b754d0139 100644 --- a/packages/eve/src/harness/approval-candidates.ts +++ b/packages/eve/src/harness/approval-candidates.ts @@ -228,6 +228,14 @@ export function cancelApprovalRequest(input: { }); } +/** 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[]; diff --git a/packages/eve/src/harness/input-requests.test.ts b/packages/eve/src/harness/input-requests.test.ts index 246b5ae79..815080613 100644 --- a/packages/eve/src/harness/input-requests.test.ts +++ b/packages/eve/src/harness/input-requests.test.ts @@ -7,6 +7,7 @@ import { once } from "#public/tools/approval/approval-helpers.js"; import type { InputRequest } from "#runtime/input/types.js"; import type { HarnessToolDefinition } from "#harness/execute-tool.js"; import { + authorizePendingApprovalResponse, consumeDeferredStepInput, createRuntimeToolCallActionFromToolCall, getApprovedTools, @@ -866,6 +867,255 @@ describe("resolvePendingInput", () => { }); }); +describe("authorizePendingApprovalResponse", () => { + function pendingSession(): HarnessSession { + return setPendingInputBatch({ + requests: [ + { + action: { + callId: "call-1", + input: { owner: "vercel", repo: "eve" }, + kind: "tool-call", + toolName: "create_issue", + }, + options: [ + { id: "approve", label: "Approve" }, + { id: "cancel", label: "Cancel" }, + ], + prompt: "Approve tool call: create_issue", + requestId: "approval-1", + }, + ], + responseAuthRequiredRequestIds: ["approval-1"], + responseMessages: [], + session: createHarnessSession(), + }); + } + + 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(() => + authorizePendingApprovalResponse({ + now: 100, + session: pendingSession(), + stepInput: { + inputResponses: [{ optionId: "approve", requestId: "approval-1" }], + }, + tools: new Map(), + }), + ); + + expect(result).toMatchObject({ + kind: "failed", + safeReason: "Approval authorization is temporarily unavailable. Please try again.", + stepInput: { inputResponses: [] }, + }); + 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", + { + approval: { + authorizeResponse: ({ responder }) => ({ + safeReason: `${responder.principalId} lacks repository write access.`, + status: "rejected", + }), + policy: () => "user-approval", + }, + description: "Create issue", + inputSchema: jsonSchema({ type: "object" }), + name: "create_issue", + }, + ], + ]); + const result = await approvalContext(() => + authorizePendingApprovalResponse({ + now: 100, + session: pendingSession(), + stepInput: { + inputResponses: [{ optionId: "approve", requestId: "approval-1" }], + }, + tools, + }), + ); + + expect(result).toMatchObject({ + kind: "rejected", + safeReason: "U1 lacks repository write access.", + stepInput: { inputResponses: [] }, + }); + 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", + { + approval: { + authorizeResponse: () => "allowed", + policy: () => "user-approval", + }, + description: "Create issue", + inputSchema: jsonSchema({ type: "object" }), + name: "create_issue", + }, + ], + ]); + const result = await approvalContext(() => + authorizePendingApprovalResponse({ + now: 100, + session: pendingSession(), + stepInput: { + inputResponses: [{ optionId: "approve", requestId: "approval-1" }], + }, + tools, + }), + ); + + expect(result.kind).toBe("continue"); + if (result.kind !== "continue") throw new Error("Expected allowed candidate."); + 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("does not pass an unvalidated sibling response into batch resolution", async () => { + const second = { + action: { + callId: "call-2", + input: { owner: "vercel", repo: "eve" }, + kind: "tool-call" as const, + toolName: "delete_issue", + }, + options: [ + { id: "approve", label: "Approve" }, + { id: "cancel", label: "Cancel" }, + ], + prompt: "Approve tool call: delete_issue", + requestId: "approval-2", + }; + const session = setPendingInputBatch({ + requests: [ + ...( + pendingSession().state?.["eve.runtime.pendingInputBatch"] as { + requests: readonly InputRequest[]; + } + ).requests, + second, + ], + responseAuthRequiredRequestIds: ["approval-1", "approval-2"], + responseMessages: [], + session: createHarnessSession(), + }); + const tools: HarnessToolMap = new Map([ + [ + "create_issue", + { + approval: { authorizeResponse: () => "allowed", policy: () => "user-approval" }, + description: "Create issue", + inputSchema: jsonSchema({ type: "object" }), + name: "create_issue", + }, + ], + [ + "delete_issue", + { + approval: { + authorizeResponse: () => { + throw new Error("must not run in the same attempt"); + }, + policy: () => "user-approval", + }, + description: "Delete issue", + inputSchema: jsonSchema({ type: "object" }), + name: "delete_issue", + }, + ], + ]); + const result = await approvalContext(() => + authorizePendingApprovalResponse({ + now: 100, + session, + stepInput: { + inputResponses: [ + { optionId: "approve", requestId: "approval-1" }, + { optionId: "approve", requestId: "approval-2" }, + ], + }, + tools, + }), + ); + + expect(result.kind).toBe("continue"); + if (result.kind !== "continue") throw new Error("Expected first candidate to settle."); + expect(result.stepInput?.inputResponses).toEqual([ + { optionId: "approve", requestId: "approval-1" }, + ]); + expect( + resolvePendingInput({ session: result.session, stepInput: result.stepInput }).outcome, + ).toBe("unresolved"); + }); + + 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(() => + authorizePendingApprovalResponse({ + now: 100, + session: pendingSession(), + stepInput: { + inputResponses: [{ optionId: "cancel", requestId: "approval-1" }], + }, + tools, + }), + ); + + expect(result.kind).toBe("continue"); + 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 8118fe91f..2779a019f 100644 --- a/packages/eve/src/harness/input-requests.ts +++ b/packages/eve/src/harness/input-requests.ts @@ -1,5 +1,26 @@ import type { ModelMessage } from "ai"; +import type { SessionAuthContext } from "#channel/types.js"; +import { buildCallbackContext } from "#context/build-callback-context.js"; +import { + buildApprovalResponseAuth, + handleApprovalResponseAuthorizationError, +} from "#execution/tool-auth.js"; +import { + cancelApprovalRequest, + createApprovalCandidate, + finishApprovalCandidate, + getApprovalAuditState, + markApprovalCandidateAuthorizationRequired, + settleAllowedCandidate, +} from "#harness/approval-candidates.js"; +import { + getAuthorizationResult, + isAuthorizationSignal, + type AuthorizationSignal, +} from "#harness/authorization.js"; +import type { HarnessToolMap } from "#harness/types.js"; + import type { RuntimeToolCallActionRequest, RuntimeToolResultActionResult, @@ -17,6 +38,8 @@ import type { HarnessSession, SessionStateMap, StepInput } from "#harness/types. const PENDING_INPUT_BATCH_KEY = "eve.runtime.pendingInputBatch"; const APPROVED_TOOLS_KEY = "eve.runtime.hitl.approvedTools"; const DEFERRED_STEP_INPUT_KEY = "eve.runtime.deferredStepInput"; +const APPROVAL_AUTHORIZER_TIMEOUT_MS = 10_000; +const APPROVAL_CANDIDATE_TTL_MS = 10 * 60_000; const IGNORED_INPUT_REASON = "Ignored because the user continued without responding."; @@ -41,6 +64,7 @@ interface PendingInputBatchEvent { */ interface PendingInputBatch { readonly event?: PendingInputBatchEvent; + readonly responseAuthRequiredRequestIds?: readonly string[]; readonly requests: readonly InputRequest[]; readonly responseMessages: readonly ModelMessage[]; } @@ -56,6 +80,306 @@ export interface RejectedActionBatch { type ApprovalTerminalStatus = "approved" | "denied" | "ignored" | "invalid"; +export type PendingApprovalAuthorizationResult = + | { readonly kind: "continue"; readonly session: HarnessSession; readonly stepInput?: StepInput } + | { + readonly authorization: AuthorizationSignal; + readonly candidateId: string; + readonly kind: "authorization-required"; + readonly session: HarnessSession; + } + | { + readonly candidateId?: string; + readonly kind: "rejected" | "duplicate" | "stale" | "failed"; + readonly safeReason?: string; + readonly session: HarnessSession; + readonly stepInput?: StepInput; + }; + +/** Runs response authorization for the first approval response in this delivery. */ +export async function authorizePendingApprovalResponse(input: { + readonly now?: number; + readonly runtimeRevision?: string; + readonly session: HarnessSession; + readonly stepInput?: StepInput; + readonly tools: HarnessToolMap; +}): Promise { + return await authorizePendingApprovalResponseInternal(input); +} + +async function authorizePendingApprovalResponseInternal(input: { + readonly now?: number; + readonly runtimeRevision?: string; + readonly session: HarnessSession; + readonly stepInput?: StepInput; + readonly tools: HarnessToolMap; +}): Promise { + const batch = getPendingInputBatch(input.session.state); + const activeCandidate = getApprovalAuditState(input.session.state).activeCandidates.find( + (candidate) => + candidate.status === "authorization-required" && + getAuthorizationResult(candidate.candidateId) !== undefined, + ); + const response = + input.stepInput?.inputResponses?.find((entry) => + batch?.requests.some( + (request) => request.requestId === entry.requestId && isApprovalRequest(request), + ), + ) ?? + (activeCandidate === undefined + ? undefined + : { optionId: "approve", requestId: activeCandidate.requestId }); + if (batch === undefined || response === undefined) { + return { kind: "continue", session: input.session, stepInput: input.stepInput }; + } + if (response.optionId === "cancel") { + if (!getResponseAuthRequiredRequestIds(input.session.state).has(response.requestId)) { + return { kind: "continue", session: input.session, stepInput: input.stepInput }; + } + const responder = buildCallbackContext().session.auth.current; + if (responder === null) { + return rejectWithoutCandidate( + input, + response.requestId, + "An authenticated responder is required.", + ); + } + const settled = cancelApprovalRequest({ + actor: responder, + requestId: response.requestId, + settledAt: input.now ?? Date.now(), + state: input.session.state, + }); + return { + kind: settled.result.kind === "settled" ? "continue" : "stale", + session: { ...input.session, state: settled.state }, + stepInput: + settled.result.kind === "settled" + ? onlyInputResponse(input.stepInput, response) + : removeInputResponse(input.stepInput, response.requestId), + }; + } + if (response.optionId !== "approve") { + return { kind: "continue", session: input.session, stepInput: input.stepInput }; + } + + const request = batch.requests.find((entry) => entry.requestId === response.requestId)!; + if (!getResponseAuthRequiredRequestIds(input.session.state).has(request.requestId)) { + return { kind: "continue", session: input.session, stepInput: input.stepInput }; + } + + const responder = buildCallbackContext().session.auth.current; + if (responder === null) { + return rejectWithoutCandidate( + input, + response.requestId, + "An authenticated responder is required.", + ); + } + const now = input.now ?? Date.now(); + const candidateId = + activeCandidate?.candidateId ?? approvalCandidateId(request.requestId, responder); + const created = createApprovalCandidate({ + candidateId, + createdAt: now, + expiresAt: now + APPROVAL_CANDIDATE_TTL_MS, + requestId: request.requestId, + responder, + runtimeRevision: input.runtimeRevision, + state: input.session.state, + }); + let session = { ...input.session, state: created.state }; + if ( + activeCandidate === undefined && + (created.result.kind === "duplicate" || created.result.kind === "stale") + ) { + return { + candidateId: created.result.kind === "duplicate" ? candidateId : undefined, + kind: created.result.kind, + session, + stepInput: removeInputResponse(input.stepInput, response.requestId), + }; + } + + const approval = input.tools.get(request.action.toolName)?.approval; + const authorizer = + approval !== undefined && typeof approval !== "function" + ? approval.authorizeResponse + : undefined; + if (authorizer === undefined) { + return failCandidate({ + candidateId, + now, + safeReason: "Approval authorization is temporarily unavailable. Please try again.", + session, + requestId: response.requestId, + stepInput: input.stepInput, + }); + } + + try { + const context = buildCallbackContext(); + const outcome = await withAuthorizerTimeout( + authorizer({ + auth: buildApprovalResponseAuth({ scope: candidateId }), + request: { + callId: request.action.callId, + requestId: request.requestId, + toolInput: request.action.input, + toolName: request.action.toolName, + }, + 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, + completedAt: now, + safeReason: outcome.safeReason, + state: session.state, + status: "rejected", + }), + }; + return { + candidateId, + kind: "rejected", + safeReason: outcome.safeReason, + session, + stepInput: removeInputResponse(input.stepInput, response.requestId), + }; + } + const settled = settleAllowedCandidate({ candidateId, settledAt: now, state: session.state }); + return { + kind: settled.result.kind === "settled" ? "continue" : "stale", + session: { ...session, state: settled.state }, + stepInput: + settled.result.kind === "settled" + ? onlyInputResponse(input.stepInput, response) + : removeInputResponse(input.stepInput, response.requestId), + }; + } 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({ + candidateId, + expiresAt: providerExpiresAt, + provider: authorization.challenges[0]?.challenge.displayName, + state: session.state, + }), + }; + return { authorization, candidateId, kind: "authorization-required", session }; + } + return failCandidate({ + candidateId, + now, + safeReason: "We couldn’t verify your approval. Please try again.", + session, + requestId: response.requestId, + stepInput: input.stepInput, + }); + } +} + +function rejectWithoutCandidate( + input: { readonly session: HarnessSession; readonly stepInput?: StepInput }, + requestId: string, + safeReason: string, +): PendingApprovalAuthorizationResult { + return { + kind: "rejected", + safeReason, + session: input.session, + stepInput: removeInputResponse(input.stepInput, requestId), + }; +} + +function failCandidate(input: { + readonly candidateId: string; + readonly now: number; + readonly safeReason: string; + readonly session: HarnessSession; + readonly requestId: string; + readonly stepInput?: StepInput; +}): PendingApprovalAuthorizationResult { + return { + candidateId: input.candidateId, + kind: "failed", + safeReason: input.safeReason, + session: { + ...input.session, + state: finishApprovalCandidate({ + candidateId: input.candidateId, + completedAt: input.now, + state: input.session.state, + status: "failed", + }), + }, + stepInput: removeInputResponse(input.stepInput, input.requestId), + }; +} + +function onlyInputResponse(stepInput: StepInput | undefined, response: InputResponse): StepInput { + return { ...stepInput, inputResponses: [response] }; +} + +function removeInputResponse( + stepInput: StepInput | undefined, + requestId: string, +): StepInput | undefined { + if (stepInput?.inputResponses === undefined) return stepInput; + return { + ...stepInput, + inputResponses: stepInput.inputResponses.filter((response) => response.requestId !== requestId), + }; +} + +function approvalCandidateId(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); + } +} + /** * Returns true when the step input carries user-facing turn input. */ @@ -148,7 +472,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) { @@ -247,6 +575,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, @@ -347,6 +692,12 @@ export function getPendingInputRequestIds(state: SessionStateMap | undefined): R return new Set(getPendingInputBatch(state)?.requests.map((request) => request.requestId)); } +function getResponseAuthRequiredRequestIds( + state: SessionStateMap | undefined, +): ReadonlySet { + return new Set(getPendingInputBatch(state)?.responseAuthRequiredRequestIds ?? []); +} + function getPendingInputBatch(state: SessionStateMap | undefined): PendingInputBatch | undefined { const value = state?.[PENDING_INPUT_BATCH_KEY]; @@ -368,6 +719,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; @@ -375,6 +727,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; diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index a9f205249..f7c99b0a4 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -103,6 +103,7 @@ import { import { createToolResultMessagePartFromToolError } from "#harness/action-result-helpers.js"; import { buildTelemetryRuntimeContext } from "#harness/instrumentation-runtime-context.js"; import { + authorizePendingApprovalResponse, consumeDeferredStepInput, getApprovedTools, getPendingInputRequestIds, @@ -559,11 +560,49 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { ? { ...effectiveStepInput, message: staleConversion.displayMessage } : effectiveStepInput; + const responseAuthorizationTools = new Map(config.tools); + const ctx = contextStorage.getStore(); + if (ctx !== undefined) { + for (const tool of buildDynamicTools(ctx)) responseAuthorizationTools.set(tool.name, tool); + } + const authorized = await authorizePendingApprovalResponse({ + session, + stepInput: effectiveStepInput, + tools: responseAuthorizationTools, + }); + session = authorized.session; + if (authorized.kind === "authorization-required") { + const { challenges } = authorized.authorization; + if (emit) { + for (const challenge of 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, + }), + ); + } + } + return { + next: null, + session: { + ...session, + state: setPendingAuthorization(session.state, { challenges }), + }, + }; + } + const pending = resolvePendingInput({ history: resolvedRuntimeActions.messages, resolveApprovalKey: resolveApprovalKeyFromTools(config.tools), session, - stepInput: effectiveStepInput, + stepInput: authorized.stepInput, }); if (pending.outcome === "unresolved") { if (emit && pending.deferredMessage === true && hasStepInput(input)) { @@ -659,7 +698,6 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { // --- Model + tools ------------------------------------------------------ // Direct harness unit tests may run without an ambient context. - const ctx = contextStorage.getStore(); if (ctx !== undefined && config.dispatchDynamicModelEvent !== undefined) { await config.dispatchDynamicModelEvent({ ctx, @@ -1926,12 +1964,27 @@ async function handleStepResult(input: { // --- Park on input requests ----------------------------------------------- if (inputRequests.length > 0) { + const responseAuthorizationTools = new Map(config.tools); + const ctx = contextStorage.getStore(); + if (ctx !== undefined) { + for (const tool of buildDynamicTools(ctx)) responseAuthorizationTools.set(tool.name, tool); + } let parkedSession = setPendingInputBatch({ event: { sequence: emissionState.sequence, stepIndex: emissionState.stepIndex, turnId: emissionState.turnId, }, + 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), requests: inputRequests, responseMessages, session: { ...baseSession, history: [...promptMessages] },