From 21594c89ac277e843203a6c3720dc428d484b675 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sat, 5 Sep 2026 00:09:58 -0400 Subject: [PATCH 01/23] feat(eve): authorize workflow tool steps Signed-off-by: Rui Conti --- .changeset/workflow-step-authorization.md | 5 + docs/tools/workflows.mdx | 43 ++- .../agent-workflow-tools/agent/agent.ts | 7 + .../agent/tools/authorize_service.ts | 42 +++ .../evals/agent-probe.shared.ts | 38 ++- .../evals/step-auth.explicit.eval.ts | 10 + .../evals/step-auth.implicit.eval.ts | 10 + .../evals/step-auth.rejected.eval.ts | 11 + .../compatibility/tool/v29.ts | 14 + .../extension-contracts/reports/tool/v30.json | 20 ++ packages/eve/package.json | 10 + .../src/compiler/extension-compatibility.ts | 4 +- .../eve/src/execution/tasks/child/workflow.ts | 37 ++- .../tools/subagent/accept-event-step.test.ts | 22 ++ .../tools/subagent/accept-event-step.ts | 14 + .../eve/src/execution/tools/workflow/ask.ts | 8 +- .../eve/src/execution/tools/workflow/body.ts | 8 +- .../src/execution/tools/workflow/messages.ts | 1 + .../execution/tools/workflow/step-context.ts | 28 ++ .../tools/workflow/step-execution.test.ts | 166 ++++++++++++ .../tools/workflow/step-execution.ts | 68 +++++ .../eve/src/execution/tools/workflow/step.ts | 199 ++++++++++++++ .../workflow-tool-run.integration.test.ts | 245 ++++++++++++++++++ .../src/execution/turn-workflow-tool-run.ts | 2 + packages/eve/src/harness/authorization.ts | 16 ++ .../testing/workflow-tool-fixtures.ts | 47 ++++ .../workflow-bundle/workflow-builders.test.ts | 14 +- .../workflow-bundle/workflow-transformer.ts | 20 +- .../eve/src/tools/workflow-definition.test.ts | 2 +- packages/eve/src/tools/workflow-definition.ts | 7 +- .../dev-server-apps.scenario.test.ts | 8 +- research/tool-suspendability-and-lifetime.md | 33 +-- 32 files changed, 1117 insertions(+), 42 deletions(-) create mode 100644 .changeset/workflow-step-authorization.md create mode 100644 e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts create mode 100644 e2e/fixtures/agent-workflow-tools/evals/step-auth.explicit.eval.ts create mode 100644 e2e/fixtures/agent-workflow-tools/evals/step-auth.implicit.eval.ts create mode 100644 e2e/fixtures/agent-workflow-tools/evals/step-auth.rejected.eval.ts create mode 100644 packages/eve/extension-contracts/compatibility/tool/v29.ts create mode 100644 packages/eve/extension-contracts/reports/tool/v30.json create mode 100644 packages/eve/src/execution/tools/workflow/step-context.ts create mode 100644 packages/eve/src/execution/tools/workflow/step-execution.test.ts create mode 100644 packages/eve/src/execution/tools/workflow/step-execution.ts create mode 100644 packages/eve/src/execution/tools/workflow/step.ts diff --git a/.changeset/workflow-step-authorization.md b/.changeset/workflow-step-authorization.md new file mode 100644 index 0000000000..b261ba6b4a --- /dev/null +++ b/.changeset/workflow-step-authorization.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Workflow tools can resolve requester-scoped credentials with `ctx.getToken` and `ctx.requireAuth` inside step helpers. When sign-in is needed, the workflow waits without holding compute and retries the interrupted step after the callback, including for background tasks. diff --git a/docs/tools/workflows.mdx b/docs/tools/workflows.mdx index f15ef782e7..5955ff789a 100644 --- a/docs/tools/workflows.mdx +++ b/docs/tools/workflows.mdx @@ -79,8 +79,8 @@ or days later, the run resumes, deploys, and returns. The model sees one tool re `start`, `getRun`, and `resumeHook` from `workflow/api` belong in steps. Your app does not install the SDK; for types, new projects list `eve/workflow-modules` in the tsconfig `types`. - In the body, `ctx` has `session`, `callId`, `toolName`, `abortSignal`, `agent`, and `ask`. - `getSandbox`, `getSkill`, `getToken`, and `requireAuth` are not part of `WorkflowToolContext`. - Read credentials from `process.env` in a step; session-scoped workflow authorization is not yet available. + `getToken` and `requireAuth` work inside a `"use step"` helper that receives `ctx` as a direct argument; + they throw in the workflow body. `getSandbox` and `getSkill` remain unavailable. - The tool's input must be a JSON object. Workflow bodies are for static tools under `agent/tools/`, not tools returned from `defineDynamic` resolvers. @@ -148,6 +148,45 @@ Answering resumes the body in either mode. Promise or Node.js timer inside a step also does not create a durable workflow suspension; use the workflow operations for waits that must survive a restart. +## Authorize inside a step + +Pass `ctx` directly to a step helper and call `ctx.getToken(provider)` there. User-scoped providers +resolve as whoever launched this workflow tool, even if another person speaks in the session while it waits. +The provider declaration and the API request both stay inside the step: + +```ts +import { connect } from "@vercel/connect/eve"; +import type { WorkflowToolContext } from "eve/tools"; + +async function readRepository(ctx: WorkflowToolContext, repository: string) { + "use step"; + const provider = connect("github/my-agent"); + const { token } = await ctx.getToken(provider); + const response = await fetch(`https://api.github.com/repos/${repository}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.status === 401) ctx.requireAuth(provider); + if (!response.ok) throw new Error(`GitHub returned ${response.status}`); + return response.json(); +} +``` + +The workflow body calls `await readRepository(ctx, repository)`. Do not return the token from the +helper: step results enter the workflow's durable history. eve's token cache stays inside the step. + +When sign-in is required, the step attempt ends and the workflow waits on its own callback hook, +without holding compute. The channel renders the sign-in challenge. After the callback, eve retries +**the whole interrupted step**, resolves the token, and continues. Previously completed steps are +not rerun. Resolve auth before other side effects in that step, and make operations before +`requireAuth` safe to retry. A token rejected immediately after sign-in fails instead of prompting +again. Provider declarations can be shared imports, but context must be passed directly, not nested +inside another argument or captured in a closure. + +A background task becomes `input_required` during sign-in. The callback resumes that task; it does +not rely on the launching agent turn still being active. Cancelling an authorization wait withdraws +its callback. Cancellation uses the existing turn or task cancellation path rather than a separate +authorization completion event. + ## Ask a human: `ctx.ask` ```ts diff --git a/e2e/fixtures/agent-workflow-tools/agent/agent.ts b/e2e/fixtures/agent-workflow-tools/agent/agent.ts index 28d18f582e..1027a18ce9 100644 --- a/e2e/fixtures/agent-workflow-tools/agent/agent.ts +++ b/e2e/fixtures/agent-workflow-tools/agent/agent.ts @@ -8,6 +8,13 @@ import { mockModel, type MockModelRequest, type MockModelResponse } from "eve/ev */ function respond(request: MockModelRequest): MockModelResponse | string { const message = [...request.userMessages].reverse().find((entry) => entry.trim() !== "") ?? ""; + const stepAuth = /WORKFLOW-STEP-AUTH-(IMPLICIT|EXPLICIT|REJECTED)/u.exec(message); + if (stepAuth !== null) { + const result = request.toolResults.find((entry) => entry.name === "authorize_service"); + return result === undefined + ? { toolCalls: [{ input: { service: stepAuth[1] }, name: "authorize_service" }] } + : String(result.output); + } const probe = /WORKFLOW-PROBE-blocking-local-(hitl|auth)/u.exec(message); if (probe !== null) { const result = request.toolResults.find((entry) => entry.name === "blocking_agent_probe"); diff --git a/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts b/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts new file mode 100644 index 0000000000..712ac63c09 --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts @@ -0,0 +1,42 @@ +import { defineWorkflowTool, type WorkflowToolContext, type ToolAuthProvider } from "eve/tools"; +import { ConnectionAuthorizationRequiredError } from "eve/connections"; +import { z } from "zod"; + +export default defineWorkflowTool({ + description: "Exercise requester authorization inside a durable step.", + inputSchema: z.strictObject({ service: z.string() }), + async execute({ service }, ctx) { + "use workflow"; + return await authorizeService(ctx, service); + }, +}); + +async function authorizeService(ctx: WorkflowToolContext, service: string): Promise { + "use step"; + const provider: ToolAuthProvider = { + principalType: "user", + async getToken() { + if (service === "EXPLICIT") return { token: "expired-fixture-token" }; + throw new ConnectionAuthorizationRequiredError("workflow-step"); + }, + async startAuthorization({ principal, callbackUrl }) { + if (principal.type !== "user") throw new Error("Expected a requester"); + const url = new URL(callbackUrl); + url.searchParams.set("code", principal.id); + return { challenge: { url: url.href }, resume: { user: principal.id } }; + }, + async completeAuthorization({ principal, callback, resume }) { + if ( + principal.type !== "user" || + callback.params.code !== principal.id || + (resume as { user: string }).user !== principal.id + ) { + throw new Error("Authorization did not match the workflow requester"); + } + return { token: "authorized-fixture-token" }; + }, + }; + const { token } = await ctx.getToken(provider); + if (token === "expired-fixture-token" || service === "REJECTED") ctx.requireAuth(provider); + return "WORKFLOW-STEP-AUTH:authorized"; +} diff --git a/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts b/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts index 190f35a6d5..dc997b1719 100644 --- a/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts +++ b/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts @@ -73,6 +73,7 @@ async function waitForEvent; readonly session: SessionCursor; @@ -87,7 +88,7 @@ async function waitForEvent { + const started = await t.send(`WORKFLOW-STEP-AUTH-${explicit ? "EXPLICIT" : "IMPLICIT"}`); + const required = await waitForEvent(t, t, started, "authorization.required"); + const url = required.event.data.authorization?.url; + if (url === undefined || new URL(url).origin !== new URL(t.target.url).origin) { + throw new Error("Expected the fixture authorization callback on this deployment"); + } + const response = await fetch(url); + if (!response.ok) throw new Error(`Authorization callback failed (${response.status})`); + await waitForEvent(t, required.session, undefined, "authorization.completed"); + await waitForMarker(t, required.session, undefined, "WORKFLOW-STEP-AUTH:authorized"); + t.noFailedActions(); +} + +export async function runRejectedStepAuth(t: EveEvalContext): Promise { + const started = await t.send("WORKFLOW-STEP-AUTH-REJECTED"); + const required = await waitForEvent(t, t, started, "authorization.required"); + const url = required.event.data.authorization?.url; + if (url === undefined || new URL(url).origin !== new URL(t.target.url).origin) + throw new Error("Expected the fixture authorization callback on this deployment"); + const response = await fetch(url); + if (!response.ok) throw new Error(`Authorization callback failed (${response.status})`); + const completed = await waitForEvent( + t, + required.session, + undefined, + "authorization.completed", + false, + ); + if (completed.event.data.outcome !== "failed") + throw new Error("A freshly rejected token must fail authorization"); + if ((await fetch(url)).status !== 404) + throw new Error("The completed authorization callback must be disposed"); +} diff --git a/e2e/fixtures/agent-workflow-tools/evals/step-auth.explicit.eval.ts b/e2e/fixtures/agent-workflow-tools/evals/step-auth.explicit.eval.ts new file mode 100644 index 0000000000..c92ccc8eaf --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/evals/step-auth.explicit.eval.ts @@ -0,0 +1,10 @@ +import { defineEval } from "eve/evals"; +import { runStepAuth } from "./agent-probe.shared.ts"; + +export default defineEval({ + description: "Workflow step requireAuth parks for sign-in and resumes under the requester.", + timeoutMs: 90_000, + async test(t) { + await runStepAuth(t, true); + }, +}); diff --git a/e2e/fixtures/agent-workflow-tools/evals/step-auth.implicit.eval.ts b/e2e/fixtures/agent-workflow-tools/evals/step-auth.implicit.eval.ts new file mode 100644 index 0000000000..4312f9c0f0 --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/evals/step-auth.implicit.eval.ts @@ -0,0 +1,10 @@ +import { defineEval } from "eve/evals"; +import { runStepAuth } from "./agent-probe.shared.ts"; + +export default defineEval({ + description: "Workflow step getToken parks for sign-in and resumes under the requester.", + timeoutMs: 90_000, + async test(t) { + await runStepAuth(t, false); + }, +}); diff --git a/e2e/fixtures/agent-workflow-tools/evals/step-auth.rejected.eval.ts b/e2e/fixtures/agent-workflow-tools/evals/step-auth.rejected.eval.ts new file mode 100644 index 0000000000..2ff24580f2 --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/evals/step-auth.rejected.eval.ts @@ -0,0 +1,11 @@ +import { defineEval } from "eve/evals"; +import { runRejectedStepAuth } from "./agent-probe.shared.ts"; + +export default defineEval({ + description: + "Workflow step authorization fails when a fresh token is rejected, without another sign-in prompt.", + timeoutMs: 90_000, + async test(t) { + await runRejectedStepAuth(t); + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/tool/v29.ts b/packages/eve/extension-contracts/compatibility/tool/v29.ts new file mode 100644 index 0000000000..dfea8ada5a --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/tool/v29.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { defineWorkflowTool } from "#public/tools/index.js"; + +defineWorkflowTool({ + description: "Ask before publishing a report.", + execution: "background", + inputSchema: z.object({ reportId: z.string() }), + async *execute(input, ctx, task) { + "use workflow"; + yield task.postMessage(`Preparing ${input.reportId}`); + const answer = await ctx.ask({ prompt: "Publish this report?", allowFreeform: true }); + return { reportId: input.reportId, answer: answer.text, sessionId: ctx.session.id }; + }, +}); diff --git a/packages/eve/extension-contracts/reports/tool/v30.json b/packages/eve/extension-contracts/reports/tool/v30.json new file mode 100644 index 0000000000..a0f38f0829 --- /dev/null +++ b/packages/eve/extension-contracts/reports/tool/v30.json @@ -0,0 +1,20 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "tool", + "epoch": 30, + "sha256": "deb1b3b5ab928c166ee43086798413074b0f90ab1c8ea253e40803b049719da9", + "exports": [ + "defaultWebSearch", + "defineTool", + "defineWorkflowTool", + "disableTool", + "experimental_workflow", + "isDisabledToolSentinel", + "isExperimentalWorkflowToolDefinition", + "isWebSearchToolDefinition", + "toolOutput", + "toolOutputPart", + "toolResultFrom", + "webSearch" + ] +} diff --git a/packages/eve/package.json b/packages/eve/package.json index d5a194e675..692af5d39d 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -265,6 +265,16 @@ "import": "./dist/src/public/context/index.js", "default": "./dist/src/public/context/index.js" }, + "./internal/workflow-step": { + "types": "./dist/src/execution/tools/workflow/step.d.ts", + "import": "./dist/src/execution/tools/workflow/step.js", + "default": "./dist/src/execution/tools/workflow/step.js" + }, + "./internal/workflow-step-execution": { + "types": "./dist/src/execution/tools/workflow/step-execution.d.ts", + "import": "./dist/src/execution/tools/workflow/step-execution.js", + "default": "./dist/src/execution/tools/workflow/step-execution.js" + }, "./internal/programmatic-source-loader": { "types": "./dist/src/internal/programmatic-source-loader.d.ts", "import": "./dist/src/internal/programmatic-source-loader.js", diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index e9c0b50340..8758ad76f5 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -22,8 +22,8 @@ interface ExtensionCapabilityContract { const EXTENSION_CAPABILITY_CONTRACTS = { extension: { current: 1, supported: [1], dropped: {} }, tool: { - current: 29, - supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29], + current: 30, + supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30], dropped: { 14: "TaskExec.delegated was removed; migrate to workflow-backed background tools", 15: "TaskExec replaces stageEffect with send", diff --git a/packages/eve/src/execution/tasks/child/workflow.ts b/packages/eve/src/execution/tasks/child/workflow.ts index 5e1034fa72..236b76c243 100644 --- a/packages/eve/src/execution/tasks/child/workflow.ts +++ b/packages/eve/src/execution/tasks/child/workflow.ts @@ -19,6 +19,7 @@ import { type WorkflowBodyDefinition, type WorkflowBodyResult, } from "#execution/tools/workflow/body.js"; +import { resumeHookStep } from "#execution/tools/workflow/resume-hook-step.js"; import type { WorkflowToolRunRequestMessage } from "#execution/tools/workflow/messages.js"; import { createChannelReader, raceChannelReads } from "#execution/tools/workflow/owner-channels.js"; import { openWorkflowToolRunOwnerInbox } from "#execution/tools/workflow/owner.js"; @@ -315,7 +316,18 @@ export async function taskRunWorkflow(input: TaskRunWorkflowInput): Promise { - if (dispatchRejected || isTerminalTaskStatus(view.status)) return; + const closesAuthorization = + message.request.kind === "authorization-request" && + message.request.stepAuthorization === true && + message.request.event.event.type === "authorization.completed"; + if (dispatchRejected || (isTerminalTaskStatus(view.status) && !closesAuthorization)) { + if ( + message.request.kind === "authorization-request" && + message.request.stepAuthorization === true + ) + await resumeHookStep(message.replyTo, null, { ifPresent: true }); + return; + } if (!dispatchAcknowledged) { pendingTraffic.ownerRequests.push(message); return; @@ -340,11 +352,34 @@ export async function taskRunWorkflow(input: TaskRunWorkflowInput): Promise { if (message.request.kind === "authorization-request") { + if (message.request.stepAuthorization === true) { + const event = message.request.event.event; + const requestId = "attemptId" in event.data ? event.data.attemptId : undefined; + if (requestId !== undefined) { + const command: TaskCommand = + event.type === "authorization.required" + ? { + kind: "require-input", + inputRequests: [ + ...(view.status === "input_required" ? view.inputRequests : []), + { kind: "authorization", requestId, name: event.data.name }, + ], + } + : { kind: "answered", requestIds: [requestId] }; + const transition = applyTaskTransition(view, command); + if (transition.action === "accepted") { + view = transition.view; + await appendTaskViewStep({ activityObserver: input.activityObserver, view }); + } + } + } await wakeTaskAuthorizationParentStep({ request: message.request, taskId: view.taskId, token: input.parentContinuationToken, }); + if (message.request.stepAuthorization === true) + await resumeHookStep(message.replyTo, null, { ifPresent: true }); return; } await wakeTaskAgentRequestParentStep({ diff --git a/packages/eve/src/execution/tools/subagent/accept-event-step.test.ts b/packages/eve/src/execution/tools/subagent/accept-event-step.test.ts index 67be307c18..16e178adf2 100644 --- a/packages/eve/src/execution/tools/subagent/accept-event-step.test.ts +++ b/packages/eve/src/execution/tools/subagent/accept-event-step.test.ts @@ -90,6 +90,28 @@ describe("acceptTaskAuthorizationEventStep", () => { expect(readLatestTaskView).toHaveBeenCalledWith({ taskRunId: "task-run" }); }); + it("accepts the owning workflow tool's event without an agent handle", async () => { + mockSession([]); + const event = { + ...hookPayload, + childSessionId: "task-run", + subagentName: "export", + event: { ...hookPayload.event, data: { ...hookPayload.event.data, turnId: "turn-1" } }, + }; + await expect( + acceptTaskAuthorizationEventStep({ + delivery: { hookPayload: event, taskId: "task-1" }, + sessionState, + }), + ).resolves.toBe(true); + await expect( + acceptTaskAuthorizationEventStep({ + delivery: { hookPayload: { ...event, childSessionId: "different-run" }, taskId: "task-1" }, + sessionState, + }), + ).resolves.toBe(false); + }); + it("accepts the first authorization event while the task child start is still reserved", async () => { mockSession([ { diff --git a/packages/eve/src/execution/tools/subagent/accept-event-step.ts b/packages/eve/src/execution/tools/subagent/accept-event-step.ts index 17d6216463..b07493842c 100644 --- a/packages/eve/src/execution/tools/subagent/accept-event-step.ts +++ b/packages/eve/src/execution/tools/subagent/accept-event-step.ts @@ -16,6 +16,20 @@ export async function acceptTaskAuthorizationEventStep(input: { const entry = findSessionTaskEntry(durableSession.state, taskId); if (entry === undefined) return false; + if ( + entry.metadata.kind === "tool" && + entry.taskRunId === hookPayload.childSessionId && + entry.metadata.name === hookPayload.subagentName && + entry.createdByTurnId === hookPayload.event.data.turnId + ) { + const view = await readLatestTaskView({ taskRunId: entry.taskRunId }); + // Completion can arrive after the body has returned; its sign-in UI must still close. + return ( + view !== undefined && + (hookPayload.event.type === "authorization.completed" || !isTerminalTaskStatus(view.status)) + ); + } + const handles = getAgentHandleStore(durableSession.state)?.handles ?? []; const claimed = handles.find( (candidate) => diff --git a/packages/eve/src/execution/tools/workflow/ask.ts b/packages/eve/src/execution/tools/workflow/ask.ts index c9ed85df3d..913eaeaf5e 100644 --- a/packages/eve/src/execution/tools/workflow/ask.ts +++ b/packages/eve/src/execution/tools/workflow/ask.ts @@ -12,7 +12,7 @@ import { workflowToolContextErrorMessage } from "#shared/workflow-tool-context.j // be different bundled copies of this module. const WORKFLOW_TOOL_RUN_CONTEXT = Symbol.for("eve.workflow-tool-run.context"); -interface WorkflowToolRunContext { +export interface WorkflowToolRunContext { /** Compatibility for already-started two-run background workflows. */ readonly admission?: Promise< { readonly status: "accepted" } | { readonly status: "rejected"; readonly reason: string } @@ -46,6 +46,12 @@ function readWorkflowToolRunContext( return context; } +export function findWorkflowToolRunContext(value: unknown): WorkflowToolRunContext | undefined { + return typeof value === "object" && value !== null + ? (value as WorkflowToolRunContextCarrier)[WORKFLOW_TOOL_RUN_CONTEXT] + : undefined; +} + export function readWorkflowToolRunRef(ctx: ToolContext): WorkflowToolRunRef { return readWorkflowToolRunContext(ctx, "agent").from; } diff --git a/packages/eve/src/execution/tools/workflow/body.ts b/packages/eve/src/execution/tools/workflow/body.ts index 7bfbd73861..8ca943b6e5 100644 --- a/packages/eve/src/execution/tools/workflow/body.ts +++ b/packages/eve/src/execution/tools/workflow/body.ts @@ -142,8 +142,12 @@ function createWorkflowBodyContext( getSandbox: () => unavailable("getSandbox()", "the session sandbox belongs to the turn"), getSkill: () => unavailable("getSkill()", "skills are read through the session sandbox"), getToken: () => - unavailable("getToken()", 'read credentials from the environment inside a "use step" helper'), - requireAuth: () => unavailable("requireAuth()", "a workflow body cannot park on authorization"), + unavailable("getToken()", 'pass ctx directly to a "use step" helper to resolve credentials'), + requireAuth: () => + unavailable( + "requireAuth()", + 'pass ctx directly to a "use step" helper to request authorization', + ), session: input.session, toolName: input.toolName, }; diff --git a/packages/eve/src/execution/tools/workflow/messages.ts b/packages/eve/src/execution/tools/workflow/messages.ts index d6b7b5ec11..ea8bca7768 100644 --- a/packages/eve/src/execution/tools/workflow/messages.ts +++ b/packages/eve/src/execution/tools/workflow/messages.ts @@ -25,6 +25,7 @@ export type WorkflowToolAgentRequest = AgentInvocationRequest | AgentSettlementR * directly, so the owner only re-emits it. */ export interface WorkflowToolAuthorizationRequest { + readonly stepAuthorization?: boolean; readonly event: SubagentAuthorizationEventHookPayload; readonly kind: "authorization-request"; } diff --git a/packages/eve/src/execution/tools/workflow/step-context.ts b/packages/eve/src/execution/tools/workflow/step-context.ts new file mode 100644 index 0000000000..9775501c90 --- /dev/null +++ b/packages/eve/src/execution/tools/workflow/step-context.ts @@ -0,0 +1,28 @@ +import type { SessionContext } from "#context/session-context.js"; +import type { AuthorizationResult, AuthorizationSignal } from "#harness/authorization.js"; +import type { + WorkflowToolRunOwner, + WorkflowToolRunRef, +} from "#execution/tools/workflow/messages.js"; + +export interface WorkflowStepContext { + readonly from: WorkflowToolRunRef; + readonly owner: WorkflowToolRunOwner; + readonly session: SessionContext["session"]; + readonly abortSignal: AbortSignal; + readonly baseUrl: string; + readonly token: string; + readonly authorizationResults: readonly (AuthorizationResult & { readonly name: string })[]; +} + +export type WorkflowStepResult = { readonly authorized: readonly string[] } & ( + | { readonly kind: "eve:workflow-step-result"; readonly output: unknown } + | { readonly kind: "eve:workflow-step-authorization"; readonly signal: AuthorizationSignal } +); + +/** Compiler-owned envelope; authored arguments never select the auth context. */ +export interface WorkflowStepInvocation { + readonly args: readonly unknown[]; + readonly context?: WorkflowStepContext; + readonly contextIndexes?: readonly number[]; +} diff --git a/packages/eve/src/execution/tools/workflow/step-execution.test.ts b/packages/eve/src/execution/tools/workflow/step-execution.test.ts new file mode 100644 index 0000000000..55d9b580c5 --- /dev/null +++ b/packages/eve/src/execution/tools/workflow/step-execution.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { withWorkflowStepAuthorization } from "#execution/tools/workflow/step-execution.js"; +import { ContextContainer, contextStorage } from "#context/container.js"; +import { AuthKey } from "#context/keys.js"; +import { ConnectionAuthorizationRequiredError } from "#connections/errors.js"; +import type { + WorkflowStepContext, + WorkflowStepResult, +} from "#execution/tools/workflow/step-context.js"; +import type { ToolContext } from "#tools/definition.js"; +import type { AuthorizationDefinition } from "#shared/connection-types.js"; + +vi.mock("#compiled/@workflow/core/index.js", () => ({ getStepMetadata: () => ({ attempt: 1 }) })); + +function context(user = "user-1"): WorkflowStepContext { + const auth = { + attributes: {}, + authenticator: "test", + issuer: "test", + principalId: user, + principalType: "user" as const, + }; + return { + baseUrl: "https://agent.example", + token: `callback-${user}`, + authorizationResults: [], + abortSignal: new AbortController().signal, + session: { + id: "session-1", + auth: { current: auth, initiator: auth }, + turn: { id: "turn-1", sequence: 1 }, + }, + from: { + callId: "call-1", + execution: "background", + input: {}, + runId: "run-1", + sequence: 1, + stepIndex: 0, + toolName: "devbox", + turnId: "turn-1", + }, + owner: { inbox: "owner" }, + }; +} + +async function runStep( + execute: (ctx: ToolContext) => unknown, + input = context(), +): Promise { + return (await withWorkflowStepAuthorization(execute)({ + args: [null], + context: input, + contextIndexes: [0], + })) as WorkflowStepResult; +} + +describe("workflow step authorization", () => { + afterEach(() => vi.unstubAllEnvs()); + it("uses the captured requester instead of another ambient user and caches only within a step", async () => { + const principals: string[] = []; + const provider: AuthorizationDefinition = { + principalType: "user", + async getToken({ principal }) { + if (principal.type !== "user") throw new Error("Expected user"); + principals.push(principal.id); + return { token: `secret:${principal.id}` }; + }, + }; + const ambient = new ContextContainer(); + ambient.set(AuthKey, context("other-user").session.auth.current); + const execute = async (ctx: ToolContext) => { + const first = await ctx.getToken(provider); + const second = await ctx.getToken(provider); + return { sameToken: first.token === second.token, session: ctx.session.id }; + }; + const results = await contextStorage.run(ambient, () => + Promise.all([runStep(execute), runStep(execute, context("user-2"))]), + ); + expect(principals.sort()).toEqual(["user-1", "user-2"]); + expect(JSON.stringify(results)).not.toContain("secret:"); + expect(results[0]).toMatchObject({ + kind: "eve:workflow-step-result", + output: { sameToken: true, session: "session-1" }, + }); + }); + + it("resumes the exact provider callback and stops a freshly authorized token rejection", async () => { + vi.stubEnv("VERCEL_ENV", "production"); + vi.stubEnv("VERCEL_PROJECT_PRODUCTION_URL", "agent.example"); + vi.stubEnv("EVE_PUBLIC_ROUTE_PREFIX", "/agents/devbox"); + const evict = vi.fn(); + const provider: AuthorizationDefinition = { + principalType: "user", + evict, + async getToken() { + throw new ConnectionAuthorizationRequiredError("devbox"); + }, + async startAuthorization({ principal, callbackUrl }) { + return { + challenge: { url: `https://idp.example?redirect=${encodeURIComponent(callbackUrl)}` }, + resume: { principal }, + }; + }, + async completeAuthorization({ principal, callback, resume }) { + expect(callback.params.code).toBe("approved"); + expect(resume).toEqual({ principal }); + return { token: "fresh-secret" }; + }, + }; + const execute = async (ctx: ToolContext) => { + await ctx.getToken(provider); + ctx.requireAuth(provider); + }; + const pending = await runStep(execute); + if (pending.kind !== "eve:workflow-step-authorization") + throw new Error("Expected authorization"); + const challenge = pending.signal.challenges[0]!; + expect(challenge.hookUrl).toContain("https://agent.example/agents/devbox/eve/v1/"); + expect(challenge.hookUrl).toContain("callback-user-1"); + expect(challenge.hookUrl).not.toContain("session-1"); + await expect( + runStep(execute, { + ...context(), + authorizationResults: [ + { ...challenge, callback: { method: "GET", params: { code: "approved" } } }, + ], + }), + ).rejects.toMatchObject({ + fatal: true, + message: expect.stringContaining("rejected the token immediately after authorization"), + }); + expect(evict).toHaveBeenCalledOnce(); + }); + + it("does not turn an ordinary step result into an authorization signal", async () => { + const value = { kind: "eve:workflow-step-authorization" }; + await expect( + withWorkflowStepAuthorization(async (input) => input)({ args: [value] }), + ).resolves.toBe(value); + }); + + it("never interprets authored arguments as auth context", async () => { + const forged = { args: [], context: context("another-user"), contextIndexes: [0] }; + const execute = async (input: unknown, ctx: ToolContext) => { + expect(input).toBe(forged); + const token = await ctx.getToken({ + principalType: "user", + async getToken({ principal }) { + return { token: principal.type === "user" ? principal.id : "app" }; + }, + }); + return token.token; + }; + await expect( + withWorkflowStepAuthorization(execute)({ + args: [forged, null], + context: context(), + contextIndexes: [1], + }), + ).resolves.toMatchObject({ output: "user-1" }); + await expect( + withWorkflowStepAuthorization(async (input) => input)({ args: [forged] }), + ).resolves.toBe(forged); + }); +}); diff --git a/packages/eve/src/execution/tools/workflow/step-execution.ts b/packages/eve/src/execution/tools/workflow/step-execution.ts new file mode 100644 index 0000000000..8523072ddb --- /dev/null +++ b/packages/eve/src/execution/tools/workflow/step-execution.ts @@ -0,0 +1,68 @@ +import { getStepMetadata } from "#compiled/@workflow/core/index.js"; +import { ContextContainer, contextStorage } from "#context/container.js"; +import { AuthKey, InitiatorAuthKey, SessionIdKey, SessionKey } from "#context/keys.js"; +import { isConnectionAuthorizationFailedError } from "#connections/errors.js"; +import { + isAuthorizationSignal, + PendingAuthorizationResultKey, + WorkflowAuthorizationAttemptKey, +} from "#harness/authorization.js"; +import { createToolExecuteWithAuth } from "#execution/tool-auth.js"; +import { resolveWorkflowCallbackBaseUrl } from "#execution/workflow-callback-url.js"; +import { + type WorkflowStepInvocation, + type WorkflowStepResult, +} from "#execution/tools/workflow/step-context.js"; + +/** Keeps token capabilities and bearer values inside the executing step. */ +export function withWorkflowStepAuthorization(execute: (...args: never[]) => unknown) { + const wrapped = async (invocation: WorkflowStepInvocation): Promise => { + const { args, context: input } = invocation; + if (input === undefined) return execute(...(args as never[])); + getStepMetadata(); + const context = new ContextContainer(); + context.set(AuthKey, input.session.auth.current); + context.set(InitiatorAuthKey, input.session.auth.initiator); + context.set(SessionIdKey, input.session.id); + context.setVirtualContext(SessionKey, { ...input.session, sessionId: input.session.id }); + context.setVirtualContext(WorkflowAuthorizationAttemptKey, { + baseUrl: resolveWorkflowCallbackBaseUrl(input.baseUrl), + token: input.token, + }); + context.setVirtualContext(PendingAuthorizationResultKey, input.authorizationResults); + + return contextStorage.run(context, async (): Promise => { + const run = createToolExecuteWithAuth({ + scope: input.from.toolName, + execute: (_input, ctx) => + execute( + ...(args.map((arg, index) => + invocation.contextIndexes?.includes(index) ? ctx : arg, + ) as never[]), + ), + }); + let output: unknown; + try { + output = await run( + {}, + { abortSignal: input.abortSignal, toolCallId: input.from.callId, messages: [] }, + ); + } catch (error) { + // The Workflow SDK recognizes fatal=true; preserve eve's classified error fields. + if (isConnectionAuthorizationFailedError(error) && !error.retryable) + Object.assign(error, { fatal: true }); + throw error; + } + const remaining = context.get(PendingAuthorizationResultKey) ?? []; + const authorized = input.authorizationResults + .filter((result) => !remaining.includes(result)) + .map((result) => result.attemptId!); + return isAuthorizationSignal(output) + ? { kind: "eve:workflow-step-authorization", signal: output, authorized } + : { kind: "eve:workflow-step-result", output, authorized }; + }); + }; + // The SDK reads retry policy from the registered function at execution time. + Object.defineProperty(wrapped, "maxRetries", { get: () => Reflect.get(execute, "maxRetries") }); + return wrapped; +} diff --git a/packages/eve/src/execution/tools/workflow/step.ts b/packages/eve/src/execution/tools/workflow/step.ts new file mode 100644 index 0000000000..a80661e4ae --- /dev/null +++ b/packages/eve/src/execution/tools/workflow/step.ts @@ -0,0 +1,199 @@ +import { createHook, getWorkflowMetadata } from "#compiled/@workflow/core/index.js"; +import type { AuthorizationChallenge, AuthorizationResult } from "#harness/authorization.js"; +import type { AuthorizationCallback } from "#shared/connection-types.js"; +import type { ToolContext } from "#tools/definition.js"; +import { findWorkflowToolRunContext } from "#execution/tools/workflow/ask.js"; +import { disposeHook } from "#execution/hook-ownership.js"; +import { resumeHookStep } from "#execution/tools/workflow/resume-hook-step.js"; +import { + createAuthorizationRequiredEvent, + createAuthorizationCompletedEvent, +} from "#protocol/message.js"; +import type { + WorkflowStepContext, + WorkflowStepInvocation, + WorkflowStepResult, +} from "#execution/tools/workflow/step-context.js"; + +/** Wraps a step proxy only when the caller explicitly passes its workflow tool context. */ +export function workflowToolStep( + execute: (invocation: WorkflowStepInvocation) => Promise, +) { + return async (...args: unknown[]): Promise => { + const index = args.findIndex((arg) => findWorkflowToolRunContext(arg) !== undefined); + if (index === -1) return execute({ args }); + const ctx = args[index] as ToolContext; + const run = findWorkflowToolRunContext(ctx)!; + const authorizationResults: (AuthorizationResult & { name: string })[] = []; + const pending = new Map(); + for (;;) { + const callback = createHook(); + const input: WorkflowStepContext = { + from: run.from, + owner: run.owner, + session: ctx.session, + abortSignal: ctx.abortSignal, + baseUrl: getWorkflowMetadata().url, + token: callback.token, + authorizationResults, + }; + try { + const invocation: WorkflowStepInvocation = { + args: args.map((arg) => (arg === ctx ? null : arg)), + context: input, + contextIndexes: args.flatMap((arg, index) => (arg === ctx ? [index] : [])), + }; + let result: WorkflowStepResult; + try { + result = (await execute(invocation)) as WorkflowStepResult; + } catch (error) { + if (!ctx.abortSignal.aborted) + for (const challenge of pending.values()) + await reportAuthorization(input, challenge, "failed"); + throw error; + } + for (const attemptId of result.authorized) { + const challenge = pending.get(attemptId); + if (challenge !== undefined) await reportAuthorization(input, challenge, "authorized"); + pending.delete(attemptId); + } + for (let i = authorizationResults.length - 1; i >= 0; i--) { + if (result.authorized.includes(authorizationResults[i]!.attemptId!)) + authorizationResults.splice(i, 1); + } + if (result.kind === "eve:workflow-step-result") { + for (const challenge of pending.values()) + await reportAuthorization(input, challenge, "failed"); + return result.output; + } + for (const challenge of result.signal.challenges) { + pending.set(challenge.attemptId!, challenge); + await reportAuthorization(input, challenge); + try { + const response = await waitForCallback(callback, challenge, ctx.abortSignal); + authorizationResults.push({ + name: challenge.name, + instanceId: challenge.instanceId, + attemptId: challenge.attemptId, + hookUrl: challenge.hookUrl, + principal: challenge.principal, + resume: challenge.resume, + callback: response, + }); + } catch (error) { + // Cancelled turns close their inbox; cancelled tasks discard further deliveries. + if (!ctx.abortSignal.aborted) await reportAuthorization(input, challenge, "failed"); + throw error; + } + } + } finally { + await disposeHook(callback); + } + } + }; +} + +async function reportAuthorization( + input: WorkflowStepContext, + challenge: AuthorizationChallenge, + outcome?: "authorized" | "failed", +): Promise { + const eventInput = { + attemptId: challenge.attemptId, + name: challenge.name, + sequence: input.from.sequence, + stepIndex: input.from.stepIndex, + turnId: input.from.turnId, + authorization: challenge.challenge, + }; + const acknowledged = createHook(); + try { + await withAbort( + resumeHookStep(input.owner.inbox, { + kind: "request", + from: input.from, + replyTo: acknowledged.token, + request: { + kind: "authorization-request", + stepAuthorization: true, + event: { + kind: "subagent-authorization-event", + callId: input.from.callId, + childSessionId: input.from.runId, + subagentName: input.from.toolName, + event: + outcome === undefined + ? createAuthorizationRequiredEvent({ + ...eventInput, + description: `Sign in to ${challenge.name} to continue.`, + webhookUrl: challenge.hookUrl, + }) + : createAuthorizationCompletedEvent({ ...eventInput, outcome }), + }, + }, + }), + input.abortSignal, + ); + await withAbort(acknowledged, input.abortSignal); + } finally { + await disposeHook(acknowledged); + } +} + +async function waitForCallback( + hook: AsyncIterable, + challenge: AuthorizationChallenge, + signal: AbortSignal, +): Promise { + const iterator = hook[Symbol.asyncIterator](); + for (;;) { + const next = await withAbort(iterator.next(), signal); + if (next.done) throw new Error("Authorization callback closed before sign-in completed."); + const callback = readCallback(next.value, challenge); + if (callback !== undefined) return callback; + } +} + +async function withAbort(pending: PromiseLike, signal: AbortSignal): Promise { + let abort!: () => void; + const cancelled = new Promise((_resolve, reject) => { + abort = () => reject(signal.reason ?? new Error("Workflow authorization cancelled.")); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + }); + try { + return await Promise.race([pending, cancelled]); + } finally { + signal.removeEventListener("abort", abort); + } +} + +function readCallback( + value: unknown, + challenge: AuthorizationChallenge, +): AuthorizationCallback | undefined { + if ( + typeof value !== "object" || + value === null || + !("payloads" in value) || + !Array.isArray(value.payloads) + ) + return undefined; + for (const payload of value.payloads) { + const received = payload?.authorizationCallback; + if (received?.attemptId !== challenge.attemptId || received?.connectionName !== challenge.name) + continue; + const callback = received.callback; + if ( + typeof callback?.method !== "string" || + typeof callback.params !== "object" || + callback.params === null || + Array.isArray(callback.params) + ) + continue; + if (!Object.values(callback.params).every((param) => typeof param === "string")) continue; + if (callback.body !== undefined && typeof callback.body !== "string") continue; + return callback; + } + return undefined; +} diff --git a/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts b/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts index 16f765506f..7ce25e83d3 100644 --- a/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts +++ b/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts @@ -1,5 +1,7 @@ +import { hydrateWorkflowReturnValue } from "@workflow/core/serialization"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { handleConnectionCallbackRequest } from "#execution/connections/callback-route.js"; import { sessionCommandHookToken } from "#execution/session-command-token.js"; import { executeSleepTool, SLEEP_INPUT_SCHEMA } from "#execution/tools/sleep.js"; import { resumeSessionInbox } from "#execution/wire/session-inbox-resume.js"; @@ -8,6 +10,7 @@ import { createTestRuntime, type TestRuntime } from "#internal/testing/app-harne import { captureTurnEvents, filterEventsByType } from "#internal/testing/events.js"; import { askThenRaceWorkflow, + authorizedDeployWorkflow, backgroundDeployWorkflow, confirmDeployWorkflow, deployServiceWorkflow, @@ -68,6 +71,7 @@ function buildSerializedContext(input: { */ async function createWorkflowToolRuntime(input: { readonly agentName: string; + readonly background?: boolean; readonly execute: (...args: never[]) => unknown; readonly inputSchema?: ResolvedToolDefinition["inputSchema"]; readonly toolName: string; @@ -79,6 +83,7 @@ async function createWorkflowToolRuntime(input: { logicalPath: `tools/${input.toolName}.ts`, loadNamespace: async () => ({ default: defineWorkflowTool({ + execution: input.background === true ? "background" : undefined, description: `Deploys a service (${input.toolName}).`, execute: input.execute as BlockingWorkflowToolDefinition["execute"], inputSchema: serializeInputSchema(input.inputSchema ?? DEPLOY_INPUT_SCHEMA) ?? {}, @@ -135,6 +140,246 @@ function eventsText(events: readonly { readonly data?: unknown }[]): string { return events.map((event) => JSON.stringify(event.data ?? null)).join("\n"); } +describe("workflow step authorization", () => { + it.each([false, true])( + "resolves a user token inside a step (background=%s)", + async (background) => { + const runtime = await createWorkflowToolRuntime({ + agentName: "workflow-step-token", + background, + execute: authorizedDeployWorkflow, + toolName: "deploy_service", + }); + await runtime.run(async () => { + const run = await start(workflowEntry, [ + { + input: { message: 'Run deploy_service with service "preauthorized"' }, + serializedContext: { + ...buildSerializedContext({ + continuationToken: "http:step-token", + mode: "conversation", + }), + "eve.auth": { + attributes: {}, + authenticator: "test-idp", + issuer: "test-idp", + principalId: "user-1", + principalType: "user", + }, + }, + }, + ]); + const stream = captureTurnEvents(run); + try { + let text = ""; + for (let i = 0; i < 5 && !text.includes("authenticatedAs"); i++) + text += JSON.stringify(await stream.nextTurn()); + expect(text).toContain("authenticatedAs"); + expect(text).toContain("user-1"); + expect(text).not.toContain("secret:"); + expect(text).not.toContain("authorization.required"); + } finally { + stream.dispose(); + await run.cancel(); + } + }); + }, + 60_000, + ); + + it.each([false, true])( + "parks on its own callback and resumes the step (background=%s)", + async (background) => { + const runtime = await createWorkflowToolRuntime({ + agentName: "workflow-step-auth", + background, + execute: authorizedDeployWorkflow, + toolName: "deploy_service", + }); + await runtime.run(async () => { + const run = await start(workflowEntry, [ + { + input: { message: 'Run deploy_service with service "interactive"' }, + serializedContext: { + ...buildSerializedContext({ + continuationToken: "http:step-auth", + mode: "conversation", + requestInput: true, + }), + "eve.auth": { + attributes: {}, + authenticator: "test-idp", + issuer: "test-idp", + principalId: "user-1", + principalType: "user", + }, + }, + }, + ]); + const stream = captureTurnEvents(run); + try { + const events = []; + for ( + let i = 0; + i < 5 && filterEventsByType(events, "authorization.required").length === 0; + i++ + ) + events.push(...(await stream.nextTurn())); + const required = filterEventsByType(events, "authorization.required")[0]!; + expect(required).toBeDefined(); + const url = new URL(required.data.webhookUrl!); + const parts = url.pathname.split("/").map(decodeURIComponent); + const token = parts.at(-1)!; + expect(token).not.toBe(`${run.runId}:auth`); + const world = await getWorld(); + const executorRunId = (await world.hooks.getByToken(token)).runId; + url.searchParams.set("code", "approved"); + await handleConnectionCallbackRequest(new Request(url), { + params: { token, attemptId: "another-attempt", name: required.data.name }, + } as never); + await handleConnectionCallbackRequest(new Request(url), { + params: { token, attemptId: required.data.attemptId!, name: "another-provider" }, + } as never); + const response = await handleConnectionCallbackRequest(new Request(url), { + params: { token, attemptId: required.data.attemptId!, name: required.data.name }, + } as never); + expect(response.status).toBe(200); + for (let i = 0; i < 6 && !JSON.stringify(events).includes("authenticatedAs"); i++) + events.push(...(await stream.nextTurn())); + expect( + filterEventsByType(events, "authorization.completed").map( + (event) => event.data.outcome, + ), + ).toEqual(["authorized"]); + const text = JSON.stringify(events); + expect(text).toContain("authenticatedAs"); + expect(text).toContain("user-1"); + expect(text).not.toContain("secret:"); + const steps = await world.steps.list({ + runId: executorRunId, + pagination: { limit: 1000 }, + }); + expect( + steps.data.filter((step) => step.stepName.endsWith("//planDeployStep")), + ).toHaveLength(1); + const attempts = steps.data.filter((step) => + step.stepName.endsWith("//authorizedDeployStep"), + ); + expect(attempts).toHaveLength(2); + for (const step of attempts) { + const output = await hydrateWorkflowReturnValue(step.output, executorRunId, undefined); + expect(JSON.stringify(output)).not.toContain("secret:"); + } + const duplicate = await handleConnectionCallbackRequest(new Request(url), { + params: { token, attemptId: required.data.attemptId!, name: required.data.name }, + } as never); + expect(duplicate.status).toBe(404); + } finally { + stream.dispose(); + await run.cancel(); + } + }); + }, + 60_000, + ); + + it.each([ + { background: false, disposition: "denied" }, + { background: true, disposition: "denied" }, + { background: false, disposition: "rejected" }, + { background: true, disposition: "rejected" }, + { background: false, disposition: "cancel" }, + { background: true, disposition: "cancel" }, + ])( + "closes authorization on $disposition (background=$background)", + async ({ background, disposition }) => { + const runtime = await createWorkflowToolRuntime({ + agentName: "workflow-step-auth-failure", + background, + execute: authorizedDeployWorkflow, + toolName: "deploy_service", + }); + await runtime.run(async () => { + const run = await start(workflowEntry, [ + { + input: { message: `Run deploy_service with service "${disposition}"` }, + serializedContext: { + ...buildSerializedContext({ + continuationToken: "http:step-auth-failure", + mode: "conversation", + requestInput: true, + }), + "eve.auth": { + attributes: {}, + authenticator: "test-idp", + issuer: "test-idp", + principalId: "user-1", + principalType: "user", + }, + }, + }, + ]); + const stream = captureTurnEvents(run); + try { + const events = []; + for ( + let i = 0; + i < 5 && filterEventsByType(events, "authorization.required").length === 0; + i++ + ) + events.push(...(await stream.nextTurn())); + const required = filterEventsByType(events, "authorization.required")[0]!; + expect(required).toBeDefined(); + const url = new URL(required.data.webhookUrl!); + const token = decodeURIComponent(url.pathname.split("/").at(-1)!); + const world = await getWorld(); + const executorRunId = (await world.hooks.getByToken(token)).runId; + const params = { token, attemptId: required.data.attemptId!, name: required.data.name }; + if (disposition === "cancel") { + await resumeSessionInbox( + sessionCommandHookToken(run.runId), + background ? { kind: "cancel", tasks: true } : { kind: "cancel", turnId: "turn_0" }, + ); + } else { + url.searchParams.set("code", disposition === "denied" ? "denied" : "approved"); + expect( + (await handleConnectionCallbackRequest(new Request(url), { params } as never)).status, + ).toBe(200); + } + if (disposition === "cancel") { + if (!background) { + events.push(...(await stream.nextTurn())); + expect(filterEventsByType(events, "turn.cancelled")).toHaveLength(1); + } + } else { + for ( + let i = 0; + i < 6 && filterEventsByType(events, "authorization.completed").length === 0; + i++ + ) + events.push(...(await stream.nextTurn())); + expect( + filterEventsByType(events, "authorization.completed").map( + (event) => event.data.outcome, + ), + ).toEqual(["failed"]); + } + expect(filterEventsByType(events, "authorization.required")).toHaveLength(1); + await waitForWorkflowToolRunTerminal(executorRunId); + expect( + (await handleConnectionCallbackRequest(new Request(url), { params } as never)).status, + ).toBe(404); + expect(JSON.stringify(events)).not.toContain("secret:"); + } finally { + stream.dispose(); + await run.cancel(); + } + }); + }, + 60_000, + ); +}); + describe("workflow tools", () => { afterEach(() => vi.unstubAllEnvs()); it("runs the framework sleep tool through the workflow tool path", async () => { diff --git a/packages/eve/src/execution/turn-workflow-tool-run.ts b/packages/eve/src/execution/turn-workflow-tool-run.ts index a0f1c27d0d..d5cb204c65 100644 --- a/packages/eve/src/execution/turn-workflow-tool-run.ts +++ b/packages/eve/src/execution/turn-workflow-tool-run.ts @@ -172,6 +172,8 @@ async function handleWorkflowToolRunRequest( sessionState: cursor.sessionState, }), ); + if (message.request.stepAuthorization === true) + await resumeHookStep(message.replyTo, null, { ifPresent: true }); return; } await cursor.adopt( diff --git a/packages/eve/src/harness/authorization.ts b/packages/eve/src/harness/authorization.ts index 1511f65d15..c7fab7ff5b 100644 --- a/packages/eve/src/harness/authorization.ts +++ b/packages/eve/src/harness/authorization.ts @@ -209,6 +209,16 @@ export function getHookUrl(name: string, attemptId: string): string | undefined export function createAuthorizationAttempt( name: string, ): { readonly attemptId: string; readonly hookUrl: string } | undefined { + const workflowAttempt = loadContext().get(WorkflowAuthorizationAttemptKey); + if (workflowAttempt !== undefined) { + return { + attemptId: workflowAttempt.token, + hookUrl: createWorkflowCallbackUrl( + workflowAttempt.baseUrl, + createEveConnectionCallbackRoutePath(name, workflowAttempt.token, workflowAttempt.token), + ), + }; + } const attemptId = createUlid(); const hookUrl = getHookUrl(name, attemptId); return hookUrl === undefined ? undefined : { attemptId, hookUrl }; @@ -295,6 +305,12 @@ export const PendingAuthorizationResultKey = new ContextKey("eve.callbackBaseUrl"); +/** Step-local callback address owned by an authored workflow, not an agent turn. */ +export const WorkflowAuthorizationAttemptKey = new ContextKey<{ + readonly baseUrl: string; + readonly token: string; +}>("eve.workflowAuthorizationAttempt"); + // --------------------------------------------------------------------------- // Session state persistence (internal — used by framework only) // --------------------------------------------------------------------------- diff --git a/packages/eve/src/internal/testing/workflow-tool-fixtures.ts b/packages/eve/src/internal/testing/workflow-tool-fixtures.ts index 54aff045b3..1955db4a2f 100644 --- a/packages/eve/src/internal/testing/workflow-tool-fixtures.ts +++ b/packages/eve/src/internal/testing/workflow-tool-fixtures.ts @@ -10,6 +10,11 @@ import { createHook, sleep as workflowSleep } from "#compiled/@workflow/core/ind import type { WorkflowToolContext } from "#tools/workflow-definition.js"; import type { TaskExec, TaskMessage } from "#tools/task.js"; +import { + ConnectionAuthorizationFailedError, + ConnectionAuthorizationRequiredError, +} from "#connections/errors.js"; +import type { AuthorizationDefinition } from "#shared/connection-types.js"; export interface DeployInput { readonly service: string; @@ -25,6 +30,48 @@ export async function deployServiceWorkflow( return { callId: ctx.callId, plan, sessionId: ctx.session.id }; } +export async function authorizedDeployWorkflow(input: DeployInput, ctx: WorkflowToolContext) { + "use workflow"; + const plan = await planDeployStep(input.service); + const authenticatedAs = await authorizedDeployStep(input.service, ctx); + return { plan, authenticatedAs }; +} + +async function authorizedDeployStep(service: string, ctx: WorkflowToolContext): Promise { + "use step"; + const provider: AuthorizationDefinition = { + principalType: "user", + async getToken({ principal }) { + if (service !== "preauthorized") throw new ConnectionAuthorizationRequiredError("deploy"); + return { token: `secret:${principal.type === "user" ? principal.id : "app"}` }; + }, + async startAuthorization({ principal, callbackUrl }) { + return { + challenge: { + url: `https://idp.example/authorize?redirect_uri=${encodeURIComponent(callbackUrl)}`, + }, + resume: { user: principal.type === "user" ? principal.id : "app" }, + }; + }, + async completeAuthorization({ principal, callback, resume }) { + if ( + callback.params.code !== "approved" || + principal.type !== "user" || + (resume as { user: string }).user !== principal.id + ) { + throw new ConnectionAuthorizationFailedError("deploy", { + message: "Authorization denied or principal changed.", + retryable: false, + }); + } + return { token: `secret:${principal.id}` }; + }, + }; + const { token } = await ctx.getToken(provider); + if (service === "rejected") ctx.requireAuth(provider); + return token.slice("secret:".length); +} + export async function* confirmDeployWorkflow( input: DeployInput, ctx: WorkflowToolContext, diff --git a/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts b/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts index 4ccbba5b48..6d9c92df61 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts @@ -104,7 +104,9 @@ describe("applyWorkflowTransform", () => { expect(transformed.code).toContain( 'import { registerStepFunction } from "workflow/internal/private";', ); - expect(transformed.code).toContain('registerStepFunction("step//./steps/ping//ping", ping);'); + expect(transformed.code).toContain( + 'registerStepFunction("step//./steps/ping//ping", withWorkflowStepAuthorization(ping));', + ); expect(transformed.code).not.toContain('"use step"'); }); @@ -128,7 +130,7 @@ describe("applyWorkflowTransform", () => { ); expect(transformed.code).toContain( - 'export var localStep = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/task//localStep");', + 'export var localStep = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/task//localStep"));', ); expect(transformed.code).toContain('export const TASK_KIND = "task";'); expect(transformed.code).toContain("export const RETRY_OFFSET = -1;"); @@ -210,7 +212,7 @@ describe("applyWorkflowTransform", () => { }); expect(transformed.code).toContain("async function runWorkflowLoop"); expect(transformed.code).toContain( - 'var notifyDelegatedParentStep = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/workflow-entry//notifyDelegatedParentStep");', + 'var notifyDelegatedParentStep = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/workflow-entry//notifyDelegatedParentStep"));', ); expect(transformed.code).not.toContain("step//./src/execution/workflow-entry//runWorkflowLoop"); }); @@ -315,7 +317,7 @@ describe("applyWorkflowTransform for authored application modules", () => { }, }); expect(transformed.code).toContain( - 'var planDeploy = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/tools/deploy//planDeploy");', + 'var planDeploy = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/tools/deploy//planDeploy"));', ); expect(transformed.code).toContain( 'globalThis.__private_workflows.set("workflow//./agent/tools/deploy//execute", execute);', @@ -358,7 +360,7 @@ describe("applyWorkflowTransform for authored application modules", () => { ); expect(transformed.code).toContain( - 'registerStepFunction("step//./agent/tools/deploy//planDeploy", planDeploy);', + 'registerStepFunction("step//./agent/tools/deploy//planDeploy", withWorkflowStepAuthorization(planDeploy));', ); expect(transformed.code).toContain( 'execute.workflowId = "workflow//./agent/tools/deploy//execute";', @@ -425,7 +427,7 @@ describe("applyWorkflowTransform for authored application modules", () => { expect(transformed.code).toContain("export function formatPlan(plan: string): string {"); expect(transformed.code).toContain( - 'export var hashPlan = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/lib/steps//hashPlan");', + 'export var hashPlan = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/lib/steps//hashPlan"));', ); expect(transformed.code).not.toContain("node:crypto"); }); diff --git a/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts b/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts index 75d8b2b445..c9165ad8a0 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts @@ -137,6 +137,12 @@ export async function transformWorkflowDirectives(input: { const replacements: { end: number; start: number; text: string }[] = []; const suffixes: string[] = []; let hasStepRegistration = false; + const workflowStepImport = + input.authored === true ? "eve/internal/workflow-step" : "#execution/tools/workflow/step.js"; + const stepExecutionImport = + input.authored === true + ? "eve/internal/workflow-step-execution" + : "#execution/tools/workflow/step-execution.js"; for (const fn of functions) { if (fn.directive === "use step") { @@ -150,7 +156,7 @@ export async function transformWorkflowDirectives(input: { replacements.push({ end: fn.rangeEnd, start: fn.rangeStart, - text: `${exportPrefix}var ${fn.name} = globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(stepId)});`, + text: `${exportPrefix}var ${fn.name} = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(stepId)}));`, }); } else if (input.mode === "metadata") { continue; @@ -159,7 +165,9 @@ export async function transformWorkflowDirectives(input: { if (input.mode === "step") { hasStepRegistration = true; - suffixes.push(`registerStepFunction(${JSON.stringify(stepId)}, ${fn.name});`); + suffixes.push( + `registerStepFunction(${JSON.stringify(stepId)}, withWorkflowStepAuthorization(${fn.name}));`, + ); } else { suffixes.push(`${fn.name}.stepId = ${JSON.stringify(stepId)};`); } @@ -199,7 +207,7 @@ export async function transformWorkflowDirectives(input: { if (input.mode === "workflow" && !hasWorkflowDirective && input.authored !== true) { return { - code: `${manifestComment}\n${createWorkflowStepProxySource(input.source, ast, functions, defaultIdBase)}`, + code: `import { workflowToolStep } from ${JSON.stringify(workflowStepImport)};\n${manifestComment}\n${createWorkflowStepProxySource(input.source, ast, functions, defaultIdBase)}`, workflowManifest: manifest, }; } @@ -214,12 +222,12 @@ export async function transformWorkflowDirectives(input: { ? await stripUnusedValueImports(input.filename, replacedSource) : replacedSource; const prefix = hasStepRegistration - ? `import { registerStepFunction } from "workflow/internal/private";\n${manifestComment}\n` + ? `import { registerStepFunction } from "workflow/internal/private";\nimport { withWorkflowStepAuthorization } from ${JSON.stringify(stepExecutionImport)};\n${manifestComment}\n` : `${manifestComment}\n`; const suffix = suffixes.length > 0 ? `\n${suffixes.join("\n")}\n` : ""; return { - code: `${prefix}${transformedSource}${suffix}`, + code: `${input.mode === "workflow" && functions.some((fn) => fn.directive === "use step") ? `import { workflowToolStep } from ${JSON.stringify(workflowStepImport)};\n` : ""}${prefix}${transformedSource}${suffix}`, workflowManifest: manifest, }; } @@ -244,7 +252,7 @@ function createWorkflowStepProxySource( // to importers. const exportPrefix = fn.exported ? "export " : ""; const stepId = createStepId(idBase, fn.name); - return `${exportPrefix}var ${fn.name} = globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(stepId)});`; + return `${exportPrefix}var ${fn.name} = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(stepId)}));`; }); const lines = [...literalExports, ...proxies]; diff --git a/packages/eve/src/tools/workflow-definition.test.ts b/packages/eve/src/tools/workflow-definition.test.ts index 727d809e05..982edc2774 100644 --- a/packages/eve/src/tools/workflow-definition.test.ts +++ b/packages/eve/src/tools/workflow-definition.test.ts @@ -17,7 +17,7 @@ describe("defineWorkflowTool", () => { async execute(input, ctx) { expectTypeOf(input).toEqualTypeOf<{ service: string }>(); expectTypeOf(ctx).toEqualTypeOf(); - // @ts-expect-error Workflow bodies do not have turn-owned token access. + // Token capabilities are available when this context is passed into a step. void ctx.getToken; // @ts-expect-error Workflow bodies do not have a session sandbox. void ctx.getSandbox; diff --git a/packages/eve/src/tools/workflow-definition.ts b/packages/eve/src/tools/workflow-definition.ts index e5aed7b8b3..6b36b6f1c3 100644 --- a/packages/eve/src/tools/workflow-definition.ts +++ b/packages/eve/src/tools/workflow-definition.ts @@ -23,10 +23,13 @@ export interface AgentInput { readonly target: string; } -/** Context supplied only to a defineWorkflowTool executor, inside its durable run. */ +/** + * Context supplied to a workflow tool. Pass it directly to a step helper for + * getToken/requireAuth; those capabilities throw in the workflow body itself. + */ export type WorkflowToolContext = Pick< ToolContext, - "abortSignal" | "callId" | "session" | "toolName" + "abortSignal" | "callId" | "session" | "toolName" | "getToken" | "requireAuth" > & { /** Invoke a visible subagent. The key must be unique within this workflow run. */ agent(input: AgentInput): Promise; diff --git a/packages/eve/test/scenarios/dev-server-apps.scenario.test.ts b/packages/eve/test/scenarios/dev-server-apps.scenario.test.ts index fdc6d4c9f4..e8f63409d3 100644 --- a/packages/eve/test/scenarios/dev-server-apps.scenario.test.ts +++ b/packages/eve/test/scenarios/dev-server-apps.scenario.test.ts @@ -51,14 +51,16 @@ const WORKFLOW_TOOL_DESCRIPTOR: ScenarioAppDescriptor = { ...WEATHER_AGENT_DESCRIPTOR.files, "agent/lib/deploy/plan.ts": [ 'import { createHash } from "node:crypto";', + 'import type { WorkflowToolContext } from "eve/tools";', "", "export function describePlan(service: string): string {", " return `deploy ${service}`;", "}", "", - "export async function hashPlan(plan: string): Promise {", + "export async function hashPlan(plan: string, ctx: WorkflowToolContext): Promise {", ' "use step";', - ' return createHash("sha256").update(plan).digest("hex").slice(0, 8);', + ' const { token } = await ctx.getToken({ principalType: "app", async getToken() { return { token: "workflow-step-secret" }; } });', + ' return createHash("sha256").update(plan + token).digest("hex").slice(0, 8);', "}", "", ].join("\n"), @@ -74,7 +76,7 @@ const WORKFLOW_TOOL_DESCRIPTOR: ScenarioAppDescriptor = { " async execute({ service }, ctx) {", ' "use workflow";', " const plan = describePlan(service);", - " const digest = await hashPlan(plan);", + " const digest = await hashPlan(plan, ctx);", ' await sleep("10ms");', " return { digest, plan, session: ctx.session.id, tool: ctx.toolName };", " },", diff --git a/research/tool-suspendability-and-lifetime.md b/research/tool-suspendability-and-lifetime.md index ca29f1989a..56ce0e68dd 100644 --- a/research/tool-suspendability-and-lifetime.md +++ b/research/tool-suspendability-and-lifetime.md @@ -1,7 +1,7 @@ --- issue: TBD status: draft -last_updated: "2026-09-03" +last_updated: "2026-09-05" --- # Tools: suspendability and lifetime as two explicit axes @@ -31,10 +31,14 @@ a blocker today for any Connect-backed workflow tool and is in scope. [Implementation PR #2997](https://github.com/vercel/eve/pull/2997) implements the compiled shape, fixed receipt, progress and message yields, and removal of -`task.delegated()`. Step-scoped provider authorization (§3.2), cancellation of +`task.delegated()`. Cancellation of parked bodies (§7), consolidation of inbound messages (§4.1), and removal of the remaining deprecated `TaskExec` fields (§6) are still proposed work. The -Devbox example below depends on the authorization and cancellation work. +Devbox example below still depends on the cancellation work. Step-scoped +provider authorization (§3.2) is implemented through a step adapter: pass the +workflow context directly to the helper. Sign-in ends the interrupted step +attempt, parks the workflow, and retries that step with the callback. Earlier +completed steps are retained. Put auth before side effects within a step. Motivating case: [vercel/internal-agents#2173](https://github.com/vercel/internal-agents/pull/2173) runs Devbox as a background tool and had to build a relay workflow, a webhook @@ -103,22 +107,21 @@ channel, the task moves to `input_required`, the channel renders it like deferred. `postMessage` tells the agent something; `ask` asks the human something. A body never posts a question for the parent to relay. -**Provider authorization** is the change. Today `getToken`/`requireAuth` -throw in a workflow body and a step sees only `process.env`, so no -Connect-backed tool can be a workflow. Contract: +**Provider authorization** is the change. `getToken`/`requireAuth` +throw in a workflow body. Passing the context directly into a step installs +requester-scoped auth there. Contract: -- Both are callable inside a `"use step"` that received `ctx`. They resolve +- Both are callable inside a `"use step"` that received `ctx` as a direct argument. They resolve under the session identity in the run's serialized context, through the - scoped-authorization path a step tool uses (`execution/tool-auth.ts`). The - gate is the Workflow SDK's ambient step context: the same `ctx` method - succeeds when that context is present and throws when it is not. + scoped-authorization path a step tool uses (`execution/tool-auth.ts`). The step adapter reconstructs the auth capabilities under that identity; + the workflow body retains throwing implementations. - A token is returned to the step and never enters the body's replay log. - Interactive authorization does not return an `AuthorizationSignal` to the - model. The run parks the way `ask` parks: the challenge is a `RunRequestMessage` - with a new `authorization` variant next to `question`; the task moves to - `input_required`; the parent channel renders it like a subagent's - `authorization.required` today (`tasks/child/steps.ts`); the callback resumes - the step's hook and it re-resolves. To the body this is one `await`. + model. The workflow forwards the challenge through its owner's existing + `authorization-request` message; the task moves to `input_required` and the + parent channel renders `authorization.required`. The callback resumes the + workflow's hook and retries the interrupted step. To the body this is one + `await`; side effects before authorization must be safe to retry. - The loop guard is unchanged: a token rejected immediately after authorization fails the run. - In the body itself both methods keep throwing. From 1ffa1950f6c3faf211dab530b0a1f07c47da8c28 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sun, 6 Sep 2026 10:06:55 -0400 Subject: [PATCH 02/23] test(eve): separate fake provider from workflow auth calls Signed-off-by: Rui Conti --- .../agent/lib/fake-auth-provider.ts | 33 +++++++++++++++++++ .../agent/tools/authorize_service.ts | 33 ++++--------------- 2 files changed, 39 insertions(+), 27 deletions(-) create mode 100644 e2e/fixtures/agent-workflow-tools/agent/lib/fake-auth-provider.ts diff --git a/e2e/fixtures/agent-workflow-tools/agent/lib/fake-auth-provider.ts b/e2e/fixtures/agent-workflow-tools/agent/lib/fake-auth-provider.ts new file mode 100644 index 0000000000..19bbaf8347 --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/agent/lib/fake-auth-provider.ts @@ -0,0 +1,33 @@ +import { ConnectionAuthorizationRequiredError } from "eve/connections"; +import type { ToolAuthProvider } from "eve/tools"; + +/** Simulates the external auth service; the tool uses eve's real ctx auth methods. */ +export function createFakeAuthProvider({ + expiredToken, +}: { + expiredToken: boolean; +}): ToolAuthProvider { + return { + principalType: "user", + async getToken() { + if (expiredToken) return { token: "expired-fixture-token" }; + throw new ConnectionAuthorizationRequiredError("workflow-step"); + }, + async startAuthorization({ principal, callbackUrl }) { + if (principal.type !== "user") throw new Error("Expected a requester"); + const url = new URL(callbackUrl); + url.searchParams.set("code", principal.id); + return { challenge: { url: url.href }, resume: { user: principal.id } }; + }, + async completeAuthorization({ principal, callback, resume }) { + if ( + principal.type !== "user" || + callback.params.code !== principal.id || + (resume as { user: string }).user !== principal.id + ) { + throw new Error("Authorization did not match the workflow requester"); + } + return { token: "authorized-fixture-token" }; + }, + }; +} diff --git a/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts b/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts index 712ac63c09..6d206e5bcd 100644 --- a/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts +++ b/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts @@ -1,7 +1,8 @@ -import { defineWorkflowTool, type WorkflowToolContext, type ToolAuthProvider } from "eve/tools"; -import { ConnectionAuthorizationRequiredError } from "eve/connections"; +import { defineWorkflowTool, type WorkflowToolContext } from "eve/tools"; import { z } from "zod"; +import { createFakeAuthProvider } from "../lib/fake-auth-provider.ts"; + export default defineWorkflowTool({ description: "Exercise requester authorization inside a durable step.", inputSchema: z.strictObject({ service: z.string() }), @@ -13,30 +14,8 @@ export default defineWorkflowTool({ async function authorizeService(ctx: WorkflowToolContext, service: string): Promise { "use step"; - const provider: ToolAuthProvider = { - principalType: "user", - async getToken() { - if (service === "EXPLICIT") return { token: "expired-fixture-token" }; - throw new ConnectionAuthorizationRequiredError("workflow-step"); - }, - async startAuthorization({ principal, callbackUrl }) { - if (principal.type !== "user") throw new Error("Expected a requester"); - const url = new URL(callbackUrl); - url.searchParams.set("code", principal.id); - return { challenge: { url: url.href }, resume: { user: principal.id } }; - }, - async completeAuthorization({ principal, callback, resume }) { - if ( - principal.type !== "user" || - callback.params.code !== principal.id || - (resume as { user: string }).user !== principal.id - ) { - throw new Error("Authorization did not match the workflow requester"); - } - return { token: "authorized-fixture-token" }; - }, - }; - const { token } = await ctx.getToken(provider); - if (token === "expired-fixture-token" || service === "REJECTED") ctx.requireAuth(provider); + const fakeProvider = createFakeAuthProvider({ expiredToken: service === "EXPLICIT" }); + const { token } = await ctx.getToken(fakeProvider); + if (token === "expired-fixture-token" || service === "REJECTED") ctx.requireAuth(fakeProvider); return "WORKFLOW-STEP-AUTH:authorized"; } From b98b683dd30f5f0bacbcefdd8bbdb57c9a7c02e9 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sun, 6 Sep 2026 12:28:50 -0400 Subject: [PATCH 03/23] test(eve): authorize workflow steps after HTTP rejection Signed-off-by: Rui Conti --- .../agent/channels/fake-service.ts | 15 +++++++++++++++ .../agent/lib/fake-service.ts | 12 ++++++++++++ .../agent/tools/authorize_service.ts | 10 ++++++++-- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 e2e/fixtures/agent-workflow-tools/agent/channels/fake-service.ts create mode 100644 e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts diff --git a/e2e/fixtures/agent-workflow-tools/agent/channels/fake-service.ts b/e2e/fixtures/agent-workflow-tools/agent/channels/fake-service.ts new file mode 100644 index 0000000000..d9a27fa3c2 --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/agent/channels/fake-service.ts @@ -0,0 +1,15 @@ +import { defineChannel, GET } from "eve/channels"; + +export default defineChannel({ + routes: [ + GET("/fixture-service/:service", async (request, { params }) => { + if ( + params.service === "REJECTED" || + request.headers.get("authorization") !== "Bearer authorized-fixture-token" + ) { + return new Response("Unauthorized", { status: 401 }); + } + return new Response("WORKFLOW-STEP-AUTH:authorized"); + }), + ], +}); diff --git a/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts b/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts new file mode 100644 index 0000000000..f01fd6d5a7 --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts @@ -0,0 +1,12 @@ +/** Resolves this fixture's HTTP service in local and deployed workflow workers. */ +export function fakeServiceUrl(service: string): URL { + const origin = + process.env.WORKFLOW_LOCAL_BASE_URL ?? + (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined); + if (origin === undefined) throw new Error("Fixture service origin is unavailable"); + const prefix = process.env.EVE_PUBLIC_ROUTE_PREFIX ?? ""; + const url = new URL(`${prefix}/fixture-service/${encodeURIComponent(service)}`, origin); + const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + if (bypass) url.searchParams.set("x-vercel-protection-bypass", bypass); + return url; +} diff --git a/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts b/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts index 6d206e5bcd..f4671e44f4 100644 --- a/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts +++ b/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts @@ -2,6 +2,7 @@ import { defineWorkflowTool, type WorkflowToolContext } from "eve/tools"; import { z } from "zod"; import { createFakeAuthProvider } from "../lib/fake-auth-provider.ts"; +import { fakeServiceUrl } from "../lib/fake-service.ts"; export default defineWorkflowTool({ description: "Exercise requester authorization inside a durable step.", @@ -16,6 +17,11 @@ async function authorizeService(ctx: WorkflowToolContext, service: string): Prom "use step"; const fakeProvider = createFakeAuthProvider({ expiredToken: service === "EXPLICIT" }); const { token } = await ctx.getToken(fakeProvider); - if (token === "expired-fixture-token" || service === "REJECTED") ctx.requireAuth(fakeProvider); - return "WORKFLOW-STEP-AUTH:authorized"; + const response = await fetch(fakeServiceUrl(service), { + headers: { Authorization: `Bearer ${token}` }, + signal: ctx.abortSignal, + }); + if (response.status === 401) ctx.requireAuth(fakeProvider); + if (!response.ok) throw new Error(`Fixture service returned ${response.status}`); + return await response.text(); } From bc291136326967a95091026d72eec3ee5592b09a Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sun, 6 Sep 2026 12:48:18 -0400 Subject: [PATCH 04/23] refactor(eve): share authorization across execution runtimes Signed-off-by: Rui Conti --- .changeset/workflow-step-authorization.md | 2 +- docs/tools/workflows.mdx | 5 + .../agent/lib/fake-service.ts | 22 +- .../evals/agent-probe.shared.ts | 10 +- packages/eve/src/execution/tool-auth.ts | 398 ++---------------- .../execution/tools/connection-search.test.ts | 78 ++++ .../src/execution/tools/connection-search.ts | 199 ++------- .../tools/workflow/step-execution.ts | 32 +- packages/eve/src/harness/authorization.ts | 30 +- .../eve/src/runtime/authorization-context.ts | 116 +++++ .../connections/scoped-authorization.ts | 128 +++++- research/tool-suspendability-and-lifetime.md | 11 +- 12 files changed, 454 insertions(+), 577 deletions(-) create mode 100644 packages/eve/src/runtime/authorization-context.ts diff --git a/.changeset/workflow-step-authorization.md b/.changeset/workflow-step-authorization.md index b261ba6b4a..011d562c56 100644 --- a/.changeset/workflow-step-authorization.md +++ b/.changeset/workflow-step-authorization.md @@ -2,4 +2,4 @@ "eve": patch --- -Workflow tools can resolve requester-scoped credentials with `ctx.getToken` and `ctx.requireAuth` inside step helpers. When sign-in is needed, the workflow waits without holding compute and retries the interrupted step after the callback, including for background tasks. +Workflow tools can use the same requester-scoped `ctx.getToken` and `ctx.requireAuth` as ordinary tools inside step helpers. Connections, tools, and workflow steps share authorization handling; workflow sign-in waits without holding compute and retries the interrupted step after the callback, including for background tasks. diff --git a/docs/tools/workflows.mdx b/docs/tools/workflows.mdx index 5955ff789a..665b697648 100644 --- a/docs/tools/workflows.mdx +++ b/docs/tools/workflows.mdx @@ -171,6 +171,11 @@ async function readRepository(ctx: WorkflowToolContext, repository: string) { } ``` +Connections discovered through `connection_search`, ordinary tools, and workflow steps use the same +authorization machinery for requester identity, token caching, callback completion, and rejection +after sign-in. The execution runtime owns the wait: an agent returns to its model after authorization, +while a workflow retries the interrupted step and continues the authored body. + The workflow body calls `await readRepository(ctx, repository)`. Do not return the token from the helper: step results enter the workflow's durable history. eve's token cache stays inside the step. diff --git a/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts b/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts index f01fd6d5a7..cde3e3271c 100644 --- a/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts +++ b/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts @@ -2,7 +2,8 @@ export function fakeServiceUrl(service: string): URL { const origin = process.env.WORKFLOW_LOCAL_BASE_URL ?? - (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined); + (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined) ?? + (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined); if (origin === undefined) throw new Error("Fixture service origin is unavailable"); const prefix = process.env.EVE_PUBLIC_ROUTE_PREFIX ?? ""; const url = new URL(`${prefix}/fixture-service/${encodeURIComponent(service)}`, origin); @@ -10,3 +11,22 @@ export function fakeServiceUrl(service: string): URL { if (bypass) url.searchParams.set("x-vercel-protection-bypass", bypass); return url; } + +/** The local world's localhost callback and the eval's loopback IP reach the same server. */ +export function fixtureAuthorizationCallback(target: string, callback: string | undefined): URL { + if (callback === undefined) throw new Error("Authorization probe produced no callback URL"); + const url = new URL(callback); + const targetUrl = new URL(target); + const loopback = new Set(["localhost", "127.0.0.1", "[::1]"]); + if ( + url.protocol === "http:" && + targetUrl.protocol === "http:" && + loopback.has(url.hostname) && + loopback.has(targetUrl.hostname) + ) { + url.hostname = targetUrl.hostname; + } + if (url.origin !== targetUrl.origin) + throw new Error("Expected the fixture authorization callback on this deployment"); + return url; +} diff --git a/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts b/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts index dc997b1719..6537148e0d 100644 --- a/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts +++ b/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts @@ -1,4 +1,5 @@ import type { EveEvalContext, EveEvalSession, EveEvalTurn } from "eve/evals"; +import { fixtureAuthorizationCallback } from "../agent/lib/fake-service.ts"; export type ProbeCase = { readonly kind: "auth" | "hitl" }; @@ -104,10 +105,7 @@ function watchNext(t: EveEvalContext, session: SessionCursor) { export async function runStepAuth(t: EveEvalContext, explicit: boolean): Promise { const started = await t.send(`WORKFLOW-STEP-AUTH-${explicit ? "EXPLICIT" : "IMPLICIT"}`); const required = await waitForEvent(t, t, started, "authorization.required"); - const url = required.event.data.authorization?.url; - if (url === undefined || new URL(url).origin !== new URL(t.target.url).origin) { - throw new Error("Expected the fixture authorization callback on this deployment"); - } + const url = fixtureAuthorizationCallback(t.target.url, required.event.data.authorization?.url); const response = await fetch(url); if (!response.ok) throw new Error(`Authorization callback failed (${response.status})`); await waitForEvent(t, required.session, undefined, "authorization.completed"); @@ -118,9 +116,7 @@ export async function runStepAuth(t: EveEvalContext, explicit: boolean): Promise export async function runRejectedStepAuth(t: EveEvalContext): Promise { const started = await t.send("WORKFLOW-STEP-AUTH-REJECTED"); const required = await waitForEvent(t, t, started, "authorization.required"); - const url = required.event.data.authorization?.url; - if (url === undefined || new URL(url).origin !== new URL(t.target.url).origin) - throw new Error("Expected the fixture authorization callback on this deployment"); + const url = fixtureAuthorizationCallback(t.target.url, required.event.data.authorization?.url); const response = await fetch(url); if (!response.ok) throw new Error(`Authorization callback failed (${response.status})`); const completed = await waitForEvent( diff --git a/packages/eve/src/execution/tool-auth.ts b/packages/eve/src/execution/tool-auth.ts index feeea49fd4..b14b88ca08 100644 --- a/packages/eve/src/execution/tool-auth.ts +++ b/packages/eve/src/execution/tool-auth.ts @@ -1,59 +1,11 @@ -/** - * Tool-hosted authorization wiring for authored tools that resolve auth - * providers inline with {@link ToolContext.getToken} and - * {@link ToolContext.requireAuth}. - * - * Mirrors the connection authorization flow used by connection search but scopes the - * per-step token cache and framework-owned callback URL by the tool's - * path-derived name and provider key instead of a connection name. All the shared - * machinery — principal resolution, cache reads/writes, the park/resume - * webhook dance, and the loop guard — lives in - * `runtime/connections/scoped-authorization.ts`; this module is the thin - * execution-layer adapter that wraps one tool's `execute`. - */ - import { buildBaseToolContext } from "#context/build-base-tool-context.js"; import type { SessionAuthContext } from "#channel/types.js"; -import { - ConnectionAuthorizationFailedError, - ConnectionAuthorizationRequiredError, - isConnectionAuthorizationRequiredError, -} from "#connections/errors.js"; import type { ApprovalResponseAuth } from "#approval/definition.js"; -import type { ToolAuthOptions, ToolAuthProvider, ToolContext } from "#tools/definition.js"; -import { type AuthorizationChallenge, requestAuthorization } from "#harness/authorization.js"; -import { - type AuthorizationDefinition, - supportsInteractiveAuthorization, - type TokenResult, -} from "#shared/connection-types.js"; -import { normalizeAuthorizationSpec } from "#shared/validate-authorization.js"; -import { - completeScopedAuthorization, - evictScopedToken, - resolveScopedToken, - startScopedAuthorization, - type ScopedAuthorization, -} from "#runtime/connections/scoped-authorization.js"; -import type { ToolExecuteOptions } from "#tools/definition.js"; +import type { ToolAuthOptions, ToolContext, ToolExecuteOptions } from "#tools/definition.js"; import type { TaskExec } from "#tools/task.js"; -import { isAsyncIterable } from "#shared/async-iterable.js"; +import { createAuthorizationContext } from "#runtime/authorization-context.js"; +import { handleAuthorizationError } from "#runtime/connections/scoped-authorization.js"; -/** - * Wraps one authored tool's `execute` with a context that supports inline - * provider auth (`ctx.getToken(connect("..."))`). - * - * On a thrown provider-scoped authorization request — implicit from - * `ctx.getToken(provider)` or explicit via `ctx.requireAuth(provider)` — the - * wrapper either fails terminally (token rejected immediately after sign-in) - * or evicts the rejected token from the per-step cache and starts the - * interactive flow, returning an `AuthorizationSignal` to park the turn. - * Interactive strategies never rethrow the raw `Required` into the model: if - * no callback URL can be minted, they fail with a classified - * {@link ConnectionAuthorizationFailedError} instead. Non-interactive - * strategies rethrow the original error because they have no consent flow to - * park on. - */ type ToolExecuteWithAuthInput = { readonly scope: string; } & ( @@ -67,101 +19,37 @@ type ToolExecuteWithAuthInput = { } ); -export function createToolExecuteWithAuth( - input: ToolExecuteWithAuthInput, -): ( - toolInput: TInput, - options: ToolExecuteOptions, - task?: TaskExec, -) => Promise | AsyncIterable { - const { scope } = input; - - // An async wrapper would turn an async generator into Promise, - // which the AI SDK treats as one non-serializable terminal output. - return ( - toolInput: TInput, - options: ToolExecuteOptions, - task?: TaskExec, - ): Promise | AsyncIterable => { - const justAuthorizedScopes = new Set(); - const ctx = buildToolContext({ - inlineAuthState: {}, - justAuthorizedScopes, - options, - scope, - }); - - try { - let output: unknown; +/** Supplies the shared auth capability to one authored tool execution. */ +export function createToolExecuteWithAuth(input: ToolExecuteWithAuthInput) { + return (toolInput: TInput, options: ToolExecuteOptions, task?: TaskExec) => { + const auth = createAuthorizationContext({ scope: input.scope }); + const ctx: ToolContext = { + ...buildBaseToolContext({ options, toolName: input.scope }), + getToken: auth.getToken, + requireAuth: auth.requireAuth, + }; + return auth.run(() => { if (input.execution === "background") { - if (task === undefined) { + if (task === undefined) throw new Error("Background tool execution requires a task runtime."); - } - output = input.execute(toolInput, ctx, task); - } else { - output = input.execute(toolInput, ctx, task); - } - if (isAsyncIterable(output)) { - return handleToolIterableErrors(output); - } - return Promise.resolve(output).catch(handleToolError); - } catch (err) { - return handleToolError(err); - } - - async function handleToolError(error: unknown): Promise { - if (isToolAuthorizationRequiredError(error)) { - return await handleAuthorizationRequests(error.requests); + return input.execute(toolInput, ctx, task); } - throw error; - } - - async function* handleToolIterableErrors( - output: AsyncIterable, - ): AsyncIterable { - try { - for await (const value of output) { - yield value; - } - } catch (error) { - yield await handleToolError(error); - } - } + return input.execute(toolInput, ctx, task); + }); }; } -/** Builds the narrow token capability used by approval response authorizers. */ +/** Binds the same capability to the person responding to an approval. */ export function buildApprovalResponseAuth(input: { readonly responder: SessionAuthContext; readonly scope: string; }): ApprovalResponseAuth { - const inlineAuthState: InlineAuthState = {}; - const justAuthorizedScopes = new Set(); + const auth = createAuthorizationContext({ scope: input.scope, boundResponder: input.responder }); return { - async getToken(provider?: ToolAuthProvider, options?: ToolAuthOptions): Promise { - if (provider === undefined) throw missingProviderError("ctx.getToken"); - return await resolveInlineToken({ - boundResponder: input.responder, - 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({ - boundResponder: input.responder, - inlineAuthState, - options: namespaceApprovalAuthOptions(input.scope, options), - provider, - toolScope: input.scope, - }); - throw new ToolAuthorizationRequiredError([ - { justAuthorized: justAuthorizedScopes.has(scoped.scope), scoped }, - ]); - }, + getToken: (provider, options) => + auth.getToken(provider, namespaceApprovalAuthOptions(input.scope, options)), + requireAuth: (provider, options) => + auth.requireAuth(provider, namespaceApprovalAuthOptions(input.scope, options)), }; } @@ -176,243 +64,5 @@ function namespaceApprovalAuthOptions( /** Starts authorization requested by an approval response authorizer. */ export async function handleApprovalResponsePolicyError(error: unknown): Promise { - if (!isToolAuthorizationRequiredError(error)) throw error; - return await handleAuthorizationRequests(error.requests); -} - -function buildToolContext(input: { - readonly options: ToolExecuteOptions; - readonly scope: string; - readonly justAuthorizedScopes: Set; - readonly inlineAuthState: InlineAuthState; -}): ToolContext { - const { scope, justAuthorizedScopes, inlineAuthState } = input; - const base = buildBaseToolContext({ options: input.options, toolName: scope }); - return { - ...base, - async getToken(provider?: ToolAuthProvider, options?: ToolAuthOptions): Promise { - if (provider === undefined) throw missingProviderError("ctx.getToken"); - return await resolveInlineToken({ - inlineAuthState, - justAuthorizedScopes, - options, - provider, - toolScope: scope, - }); - }, - requireAuth(provider?: ToolAuthProvider, options?: ToolAuthOptions): never { - if (provider === undefined) throw missingProviderError("ctx.requireAuth"); - const scoped = buildInlineScopedAuthorization({ - inlineAuthState, - options, - provider, - toolScope: scope, - }); - throw new ToolAuthorizationRequiredError([ - { - justAuthorized: justAuthorizedScopes.has(scoped.scope), - scoped, - }, - ]); - }, - }; -} - -async function resolveInlineToken(input: { - readonly boundResponder?: SessionAuthContext; - readonly toolScope: string; - readonly provider: ToolAuthProvider; - readonly options?: ToolAuthOptions; - readonly justAuthorizedScopes: Set; - readonly inlineAuthState: InlineAuthState; -}): Promise { - const { justAuthorizedScopes } = input; - const scoped = buildInlineScopedAuthorization(input); - if (!justAuthorizedScopes.has(scoped.scope) && (await completeScopedAuthorization(scoped))) { - justAuthorizedScopes.add(scoped.scope); - } - - try { - return await resolveScopedToken(scoped); - } catch (err) { - if (!isConnectionAuthorizationRequiredError(err)) throw err; - throw new ToolAuthorizationRequiredError([ - { - cause: err, - justAuthorized: justAuthorizedScopes.has(scoped.scope), - scoped, - }, - ]); - } -} - -async function handleAuthorizationRequests( - requests: readonly ToolAuthorizationRequiredRequest[], -): Promise { - const challenges: AuthorizationChallenge[] = []; - let nonInteractiveError: Error | undefined; - - for (const request of requests) { - const { scoped } = request; - - // Loop guard: a token minted this turn that is still rejected - // means the grant itself is broken — fail terminally instead of - // re-prompting into an infinite sign-in loop. - if (request.justAuthorized) { - throw new ConnectionAuthorizationFailedError(scoped.scope, { - message: `Tool "${scoped.scope}" rejected the token immediately after authorization.`, - reason: "token_rejected_after_authorization", - retryable: false, - }); - } - - // The resolved bearer was rejected (a downstream 401 mapped to - // requireAuth, or getToken re-reporting Required). Drop it from - // every cache layer — eve's per-step cache and the strategy's own - // (e.g. the @vercel/connect token cache) — so the - // re-authorization re-resolves a genuinely fresh token instead of - // re-reading the rejected one. Mirrors the MCP client. - await evictScopedToken(scoped); - - const signal = await startScopedAuthorization(scoped); - if (signal !== undefined) { - challenges.push(...signal.challenges); - continue; - } - - // No park signal. For an interactive strategy this means the - // framework could not mint a callback URL (no session id / base - // URL in context). Never let the raw `Required` reach the model — - // it improvises by surfacing the auth URL as text and loops - // (see research/per-tool-auth-known-issues.md, issue 2). Fail with - // a classified, terminal authorization error instead. Non-interactive - // strategies have no consent flow, so their original error is the - // right thing for the model to see. - if (supportsInteractiveAuthorization(scoped.authorization)) { - throw new ConnectionAuthorizationFailedError(scoped.scope, { - message: - `Tool "${scoped.scope}" requires sign-in, but no authorization callback URL ` + - `could be minted for this run (missing session context).`, - reason: "authorization_callback_unavailable", - retryable: false, - }); - } - - nonInteractiveError ??= - request.cause instanceof Error - ? request.cause - : new ConnectionAuthorizationRequiredError(scoped.scope); - } - - if (challenges.length > 0) { - return requestAuthorization(challenges); - } - - throw nonInteractiveError ?? new Error("Tool authorization is required."); -} - -function buildInlineScopedAuthorization(input: { - readonly boundResponder?: SessionAuthContext; - readonly toolScope: string; - readonly provider: ToolAuthProvider; - readonly options?: ToolAuthOptions; - readonly inlineAuthState: InlineAuthState; -}): ScopedAuthorization { - const authorization = normalizeInlineProvider(input.provider, input.options); - return { - authorization, - boundResponder: input.boundResponder, - connection: input.options?.connection ?? { url: "" }, - scope: - input.options?.authKey === undefined - ? deriveInlineScope({ - authorization, - inlineAuthState: input.inlineAuthState, - provider: input.provider, - toolScope: input.toolScope, - }) - : validateInlineAuthKey(input.options.authKey), - }; -} - -function normalizeInlineProvider( - provider: ToolAuthProvider, - options: ToolAuthOptions | undefined, -): AuthorizationDefinition { - const authorization = normalizeAuthorizationSpec(provider, "ctx.getToken:", "provider"); - if (options?.displayName === undefined) { - return authorization; - } - if (options.displayName.length === 0) { - throw new Error(`ctx.getToken: The "options.displayName" field must be a non-empty string.`); - } - return { ...authorization, displayName: options.displayName }; -} - -function deriveInlineScope(input: { - readonly toolScope: string; - readonly authorization: AuthorizationDefinition; - readonly provider: ToolAuthProvider; - readonly inlineAuthState: InlineAuthState; -}): string { - const connector = input.authorization.vercelConnect?.connector; - if (connector !== undefined) { - return `${input.toolScope}__${sanitizeScopeSegment(connector)}`; - } - - if (input.inlineAuthState.anonymousProvider === undefined) { - input.inlineAuthState.anonymousProvider = input.provider; - } else if (input.inlineAuthState.anonymousProvider !== input.provider) { - throw new Error( - `ctx.getToken: Multiple inline auth providers without provider metadata need explicit auth keys. ` + - `Pass options.authKey for each provider, for example ` + - `ctx.getToken(auth, { authKey: "github" }).`, - ); - } - - return `${input.toolScope}__inline_auth`; -} - -function validateInlineAuthKey(authKey: string): string { - if (!/^[A-Za-z0-9_.:-]+$/u.test(authKey)) { - throw new Error( - `ctx.getToken: The "options.authKey" field must contain only letters, digits, "_", "-", ".", or ":".`, - ); - } - return authKey; -} - -function sanitizeScopeSegment(value: string): string { - const sanitized = value.replace(/[^A-Za-z0-9_.:-]+/gu, "_").replace(/^_+|_+$/gu, ""); - return sanitized.length > 0 ? sanitized : "provider"; -} - -interface ToolAuthorizationRequiredRequest { - readonly scoped: ScopedAuthorization; - readonly justAuthorized: boolean; - readonly cause?: unknown; -} - -interface InlineAuthState { - anonymousProvider?: ToolAuthProvider; -} - -class ToolAuthorizationRequiredError extends Error { - readonly requests: readonly ToolAuthorizationRequiredRequest[]; - - constructor(requests: readonly ToolAuthorizationRequiredRequest[]) { - super("Tool authorization required."); - this.name = "ToolAuthorizationRequiredError"; - this.requests = requests; - } -} - -function isToolAuthorizationRequiredError(err: unknown): err is ToolAuthorizationRequiredError { - return err instanceof Error && err.name === "ToolAuthorizationRequiredError"; -} - -function missingProviderError(method: "ctx.getToken" | "ctx.requireAuth"): Error { - return new Error( - `${method}: Pass an auth provider, for example ${method}(connect("github/myagent")).`, - ); + return await handleAuthorizationError(error); } diff --git a/packages/eve/src/execution/tools/connection-search.test.ts b/packages/eve/src/execution/tools/connection-search.test.ts index 1b9c9c5c72..810188ff6b 100644 --- a/packages/eve/src/execution/tools/connection-search.test.ts +++ b/packages/eve/src/execution/tools/connection-search.test.ts @@ -20,6 +20,7 @@ import type { ResolvedConnectionDefinition } from "#runtime/types.js"; import { isBrandedToolEntry, type DynamicToolSet } from "#tools/dynamic.js"; import type { DynamicResolveContext } from "#dynamic/definition.js"; import { readDurableDynamicToolCallbacks } from "#tools/durable-callbacks.js"; +import { resolveHeaders } from "#runtime/connections/mcp-client.js"; function connection(name: string): ResolvedConnectionDefinition { return { @@ -479,6 +480,83 @@ describe("connection_search", () => { ]); }); + it.each([false, true])( + "completes connection auth through the shared token cache (fresh token refused: %s)", + async (refused) => { + const getToken = vi.fn(async () => { + throw new ConnectionAuthorizationRequiredError("salesforce"); + }); + const startAuthorization = vi.fn(async () => ({ + challenge: { url: "https://idp.example.com/authorize" }, + })); + const completeAuthorization = vi.fn(async () => ({ token: "fresh-token" })); + const salesforce: ResolvedConnectionDefinition = { + ...connection("salesforce"), + instanceId: "salesforce-instance", + authorization: { + principalType: "user", + getToken, + startAuthorization, + completeAuthorization, + }, + }; + const connectionRegistry = registry({ + connections: [salesforce], + loadTools: { + salesforce: async () => { + const headers = await resolveHeaders(salesforce); + expect(headers.Authorization).toBe("Bearer fresh-token"); + if (refused) throw new ConnectionAuthorizationRequiredError("salesforce"); + return [{ name: "list_accounts", description: "List accounts", inputSchema: {} }]; + }, + }, + }); + const setup = (ctx: ContextContainer) => { + ctx.set(SessionIdKey, "session-auth"); + ctx.set(CallbackBaseUrlKey, "https://agent.example.com"); + ctx.set(AuthKey, { + attributes: {}, + authenticator: "test-idp", + issuer: "test-idp", + principalId: "user-1", + principalType: "user", + }); + }; + const input = { connection: "salesforce", keywords: "accounts" }; + const pending = await executeConnectionSearch(connectionRegistry, input, setup); + if (!isAuthorizationSignal(pending)) throw new Error("expected authorization signal"); + const challenge = pending.challenges[0]!; + expect(challenge).toMatchObject({ + instanceId: "salesforce-instance", + principal: { type: "user", id: "user-1", issuer: "test-idp" }, + }); + const resumed = executeConnectionSearch(connectionRegistry, input, (ctx) => { + setup(ctx); + ctx.set(PendingAuthorizationResultKey, [ + { + ...challenge, + callback: { method: "GET", params: { code: "approved" } }, + }, + ]); + }); + if (refused) { + await expect(resumed).rejects.toThrow("rejected the token immediately after authorization"); + } else { + await expect(resumed).resolves.toMatchObject([ + { qualifiedName: "salesforce__list_accounts" }, + ]); + } + expect(getToken).toHaveBeenCalledOnce(); + expect(startAuthorization).toHaveBeenCalledOnce(); + expect(completeAuthorization).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + principal: challenge.principal, + callback: { method: "GET", params: { code: "approved" } }, + }), + ); + }, + ); + it("replays authorization from the step-scoped durable execute descriptor", async () => { const salesforce: ResolvedConnectionDefinition = { ...connection("salesforce"), diff --git a/packages/eve/src/execution/tools/connection-search.ts b/packages/eve/src/execution/tools/connection-search.ts index edbaaa0794..63f0d92aee 100644 --- a/packages/eve/src/execution/tools/connection-search.ts +++ b/packages/eve/src/execution/tools/connection-search.ts @@ -5,13 +5,10 @@ import { ContextKey } from "#context/key.js"; import { type AuthorizationChallenge, type AuthorizationSignal, - consumeAuthorizationResult, - createAuthorizationAttempt, getAuthorizationResults, requestAuthorization, } from "#harness/authorization.js"; import { - ConnectionAuthorizationFailedError, isConnectionAuthorizationFailedError, isConnectionAuthorizationRequiredError, } from "#connections/errors.js"; @@ -22,20 +19,15 @@ import { type ApprovalContext, type ApprovalResponseContext, } from "#approval/definition.js"; -import type { JsonValue } from "#shared/json.js"; import type { JsonObject } from "#shared/json.js"; import { stampDurableDynamicToolCallbacks } from "#tools/durable-callbacks.js"; -import { writeCachedToken } from "#runtime/connections/authorization-tokens.js"; -import { connectionAuthorizationScope } from "#runtime/connections/instance-identity.js"; -import { principalKey, resolveConnectionPrincipal } from "#runtime/connections/principal.js"; import { resolveConnectionAuthorization } from "#runtime/connections/resolve-authorization.js"; import { - resolveAuthorizationCallbackUrl, - stampChallengeDisplayName, + createAuthorizationExecution, + type ScopedAuthorization, } from "#runtime/connections/scoped-authorization.js"; import { type ConnectionToolMetadata, - type InteractiveAuthorizationDefinition, supportsInteractiveAuthorization, } from "#shared/connection-types.js"; import type { ConnectionRegistry } from "#runtime/connections/registry-types.js"; @@ -138,49 +130,32 @@ function scoreMatch(queryTokens: string[], tool: ConnectionToolMetadata): number async function resolveInteractiveAuth( registry: ConnectionRegistry, connectionName: string, -): Promise { +): Promise { const conn = registry.getConnections().find((c) => c.connectionName === connectionName); if (conn === undefined) return undefined; const authorization = await resolveConnectionAuthorization(conn); - if (!supportsInteractiveAuthorization(authorization)) return undefined; - return authorization as InteractiveAuthorizationDefinition; + if (authorization === undefined || !supportsInteractiveAuthorization(authorization)) + return undefined; + return { + scope: conn.connectionName, + instanceId: conn.instanceId, + connection: { url: conn.url ?? "" }, + authorization, + }; } -/** - * Completes any authorizations whose callback arrived this turn, - * returning the set of connection names that were just (re-)authorized. - * - * Callers use the returned set as a loop guard: if a connection that was - * just authorized still fails with `Required` on the immediately - * following load, the freshly minted token is itself being rejected, so - * the connection must fail terminally rather than re-challenge forever. - */ +/** Complete only callbacks for the connections targeted by this search. */ async function completePendingAuthorizations( registry: ConnectionRegistry, connections: readonly ResolvedConnectionDefinition[], -): Promise> { + auth: ReturnType, +): Promise { assertPendingConnectionAuthorizationInstances(registry); - const ctx = loadContext(); - const completed = new Set(); for (const conn of connections) { - const result = consumeAuthorizationResult(conn.connectionName, conn.instanceId); - if (!result) continue; - const auth = await resolveInteractiveAuth(registry, conn.connectionName); - if (!auth) continue; - const principal = result.principal ?? resolveConnectionPrincipal(conn.connectionName, auth); - const token = await ( - auth as InteractiveAuthorizationDefinition - ).completeAuthorization({ - callbackUrl: result.hookUrl, - connection: { url: conn.url ?? "" }, - principal, - resume: result.resume, - callback: result.callback, - }); - writeCachedToken(ctx, connectionAuthorizationScope(conn), principalKey(principal), token); - completed.add(conn.connectionName); + if (!getAuthorizationResults().some((result) => result.name === conn.connectionName)) continue; + const scoped = await resolveInteractiveAuth(registry, conn.connectionName); + if (scoped !== undefined) await auth.complete(scoped); } - return completed; } async function executeConnectionSearch( @@ -208,7 +183,8 @@ async function executeConnectionSearch( ); } - const justAuthorized = await completePendingAuthorizations(registry, targetConnections); + const auth = createAuthorizationExecution(); + await completePendingAuthorizations(registry, targetConnections, auth); const authChallenges: AuthorizationChallenge[] = []; @@ -219,59 +195,25 @@ async function executeConnectionSearch( tools = await client.getToolMetadata(); } catch (err) { if (isConnectionAuthorizationRequiredError(err)) { - // Loop guard: a connection authorized earlier this turn that is - // still rejected means the new token itself is bad. Fail it - // terminally instead of re-challenging into an infinite sign-in - // loop. - if (justAuthorized.has(conn.connectionName)) { - logger.warn("connection still unauthorized after authorization", { - connection: conn.connectionName, - }); - failedConnections.push({ - connection: conn.connectionName, - description: conn.description, - error: `Authorization for "${conn.connectionName}" did not take effect; the token was rejected after sign-in.`, - }); - continue; - } - - const auth = await resolveInteractiveAuth(registry, conn.connectionName); - if (auth) { - const attempt = createAuthorizationAttempt(conn.connectionName); - if (attempt) { - const principal = resolveConnectionPrincipal(conn.connectionName, auth); - const callbackUrl = resolveAuthorizationCallbackUrl({ - authorization: auth, - callbackUrl: attempt.hookUrl, + const scoped = await resolveInteractiveAuth(registry, conn.connectionName); + if (scoped !== undefined) { + try { + const signal = await auth.handleError(err, scoped); + authChallenges.push(...signal.challenges); + } catch (startErr) { + const error = toError(startErr); + logger.warn("connection authorization failed", { + connection: conn.connectionName, + error, }); - try { - const { challenge, resume } = await auth.startAuthorization({ - callbackUrl, - connection: { url: conn.url ?? "" }, - principal, - }); - authChallenges.push({ - attemptId: attempt.attemptId, - name: conn.connectionName, - challenge: stampChallengeDisplayName(challenge, auth), - hookUrl: callbackUrl, - instanceId: conn.instanceId, - principal, - resume, - }); - } catch (startErr) { - const error = toError(startErr); - logger.warn("startAuthorization failed", { - connection: conn.connectionName, - error, - }); - failedConnections.push({ - connection: conn.connectionName, - description: conn.description, - error: `Failed to start authorization for "${conn.connectionName}": ${error.message}`, - }); - continue; - } + failedConnections.push({ + connection: conn.connectionName, + description: conn.description, + error: isConnectionAuthorizationFailedError(error) + ? error.message + : `Failed to start authorization for "${conn.connectionName}": ${error.message}`, + }); + continue; } } failedConnections.push({ @@ -388,38 +330,10 @@ async function executeDiscoveredConnectionTool( if (registry === undefined) { throw new Error("Connection registry is unavailable while replaying a discovered tool."); } - const conn = registry - .getConnections() - .find((candidate) => candidate.connectionName === connectionName); assertPendingConnectionAuthorizationInstances(registry); - const interactiveAuth = (await resolveInteractiveAuth(registry, connectionName)) as - | InteractiveAuthorizationDefinition - | undefined; - - let justCompletedAuth = false; - if (interactiveAuth) { - const authResult = consumeAuthorizationResult(connectionName, conn?.instanceId); - if (authResult) { - justCompletedAuth = true; - const ctx = loadContext(); - const principal = - authResult.principal ?? resolveConnectionPrincipal(connectionName, interactiveAuth); - const token = await interactiveAuth.completeAuthorization({ - callbackUrl: authResult.hookUrl, - connection: { url: conn?.url ?? "" }, - principal, - resume: authResult.resume, - callback: authResult.callback, - }); - writeCachedToken( - ctx, - conn === undefined ? connectionName : connectionAuthorizationScope(conn), - principalKey(principal), - token, - ); - } - } - + const scoped = await resolveInteractiveAuth(registry, connectionName); + const auth = createAuthorizationExecution(); + if (scoped !== undefined) await auth.complete(scoped); try { const client = registry.getClient(connectionName); return await client.executeTool(toolName, input, { @@ -427,38 +341,7 @@ async function executeDiscoveredConnectionTool( callId: executeCtx.callId, }); } catch (error) { - if (!isConnectionAuthorizationRequiredError(error) || !interactiveAuth) throw error; - if (justCompletedAuth) { - throw new ConnectionAuthorizationFailedError(connectionName, { - retryable: false, - reason: "token_rejected_after_authorization", - message: `Connection "${connectionName}" rejected the token immediately after authorization.`, - }); - } - - const attempt = createAuthorizationAttempt(connectionName); - if (!attempt) throw error; - const principal = resolveConnectionPrincipal(connectionName, interactiveAuth); - const callbackUrl = resolveAuthorizationCallbackUrl({ - authorization: interactiveAuth, - callbackUrl: attempt.hookUrl, - }); - const { challenge, resume } = await interactiveAuth.startAuthorization({ - callbackUrl, - connection: { url: conn?.url ?? "" }, - principal, - }); - return requestAuthorization([ - { - attemptId: attempt.attemptId, - name: connectionName, - challenge: stampChallengeDisplayName(challenge, interactiveAuth), - hookUrl: callbackUrl, - instanceId: conn?.instanceId, - principal, - resume, - }, - ]); + return await auth.handleError(error, scoped); } } diff --git a/packages/eve/src/execution/tools/workflow/step-execution.ts b/packages/eve/src/execution/tools/workflow/step-execution.ts index 8523072ddb..48e9ebd22c 100644 --- a/packages/eve/src/execution/tools/workflow/step-execution.ts +++ b/packages/eve/src/execution/tools/workflow/step-execution.ts @@ -5,9 +5,11 @@ import { isConnectionAuthorizationFailedError } from "#connections/errors.js"; import { isAuthorizationSignal, PendingAuthorizationResultKey, - WorkflowAuthorizationAttemptKey, + AuthorizationHookKey, + CallbackBaseUrlKey, } from "#harness/authorization.js"; -import { createToolExecuteWithAuth } from "#execution/tool-auth.js"; +import { createAuthorizationContext } from "#runtime/authorization-context.js"; +import { buildBaseToolContext } from "#context/build-base-tool-context.js"; import { resolveWorkflowCallbackBaseUrl } from "#execution/workflow-callback-url.js"; import { type WorkflowStepInvocation, @@ -25,27 +27,31 @@ export function withWorkflowStepAuthorization(execute: (...args: never[]) => unk context.set(InitiatorAuthKey, input.session.auth.initiator); context.set(SessionIdKey, input.session.id); context.setVirtualContext(SessionKey, { ...input.session, sessionId: input.session.id }); - context.setVirtualContext(WorkflowAuthorizationAttemptKey, { - baseUrl: resolveWorkflowCallbackBaseUrl(input.baseUrl), + context.set(CallbackBaseUrlKey, resolveWorkflowCallbackBaseUrl(input.baseUrl)); + context.setVirtualContext(AuthorizationHookKey, { token: input.token, + attemptId: input.token, }); context.setVirtualContext(PendingAuthorizationResultKey, input.authorizationResults); return contextStorage.run(context, async (): Promise => { - const run = createToolExecuteWithAuth({ - scope: input.from.toolName, - execute: (_input, ctx) => + const auth = createAuthorizationContext({ scope: input.from.toolName }); + const ctx = { + ...buildBaseToolContext({ + toolName: input.from.toolName, + options: { abortSignal: input.abortSignal, toolCallId: input.from.callId }, + }), + getToken: auth.getToken, + requireAuth: auth.requireAuth, + }; + let output: unknown; + try { + output = await auth.run(() => execute( ...(args.map((arg, index) => invocation.contextIndexes?.includes(index) ? ctx : arg, ) as never[]), ), - }); - let output: unknown; - try { - output = await run( - {}, - { abortSignal: input.abortSignal, toolCallId: input.from.callId, messages: [] }, ); } catch (error) { // The Workflow SDK recognizes fatal=true; preserve eve's classified error fields. diff --git a/packages/eve/src/harness/authorization.ts b/packages/eve/src/harness/authorization.ts index c7fab7ff5b..33503a6b7e 100644 --- a/packages/eve/src/harness/authorization.ts +++ b/packages/eve/src/harness/authorization.ts @@ -187,18 +187,20 @@ export function consumeAuthorizationResult( * Builds a callback URL for external systems. `name` and `attemptId` identify * the exact challenge in the URL path. * - * The URL embeds the session's authorization hook token (`${sessionId}:auth`). + * By default the URL embeds the session's authorization hook token (`${sessionId}:auth`). + * A runtime with its own continuation supplies that hook through AuthorizationHookKey. * It is independent of the continuation token, so channel re-keying mid-turn * does not invalidate the callback URL. * - * Returns `undefined` if the session context isn't available. + * Returns `undefined` if no callback address is available. */ export function getHookUrl(name: string, attemptId: string): string | undefined { const ctx = loadContext(); const sessionId = ctx.get(SessionIdKey); const baseUrl = ctx.get(CallbackBaseUrlKey); - if (!sessionId || !baseUrl) return undefined; - const token = authHookToken(sessionId); + const token = + ctx.get(AuthorizationHookKey)?.token ?? (sessionId ? authHookToken(sessionId) : undefined); + if (!token || !baseUrl) return undefined; return createWorkflowCallbackUrl( baseUrl, createEveConnectionCallbackRoutePath(name, attemptId, token), @@ -209,17 +211,7 @@ export function getHookUrl(name: string, attemptId: string): string | undefined export function createAuthorizationAttempt( name: string, ): { readonly attemptId: string; readonly hookUrl: string } | undefined { - const workflowAttempt = loadContext().get(WorkflowAuthorizationAttemptKey); - if (workflowAttempt !== undefined) { - return { - attemptId: workflowAttempt.token, - hookUrl: createWorkflowCallbackUrl( - workflowAttempt.baseUrl, - createEveConnectionCallbackRoutePath(name, workflowAttempt.token, workflowAttempt.token), - ), - }; - } - const attemptId = createUlid(); + const attemptId = loadContext().get(AuthorizationHookKey)?.attemptId ?? createUlid(); const hookUrl = getHookUrl(name, attemptId); return hookUrl === undefined ? undefined : { attemptId, hookUrl }; } @@ -305,11 +297,11 @@ export const PendingAuthorizationResultKey = new ContextKey("eve.callbackBaseUrl"); -/** Step-local callback address owned by an authored workflow, not an agent turn. */ -export const WorkflowAuthorizationAttemptKey = new ContextKey<{ - readonly baseUrl: string; +/** The executing runtime may own its callback hook instead of using the session hook. */ +export const AuthorizationHookKey = new ContextKey<{ readonly token: string; -}>("eve.workflowAuthorizationAttempt"); + readonly attemptId?: string; +}>("eve.authorizationHook"); // --------------------------------------------------------------------------- // Session state persistence (internal — used by framework only) diff --git a/packages/eve/src/runtime/authorization-context.ts b/packages/eve/src/runtime/authorization-context.ts new file mode 100644 index 0000000000..8558fcd969 --- /dev/null +++ b/packages/eve/src/runtime/authorization-context.ts @@ -0,0 +1,116 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import type { ToolAuthOptions, ToolAuthProvider } from "#tools/definition.js"; +import type { AuthorizationDefinition, TokenResult } from "#shared/connection-types.js"; +import { normalizeAuthorizationSpec } from "#shared/validate-authorization.js"; +import { + createAuthorizationExecution, + type ScopedAuthorization, +} from "#runtime/connections/scoped-authorization.js"; + +/** Shared getToken/requireAuth capability, independent of the executing tool or workflow. */ +export function createAuthorizationContext(input: { + readonly scope: string; + readonly boundResponder?: SessionAuthContext; +}) { + const execution = createAuthorizationExecution(); + const inlineAuthState: InlineAuthState = {}; + const resolve = (provider: ToolAuthProvider, options?: ToolAuthOptions) => + buildInlineScopedAuthorization({ ...input, inlineAuthState, provider, options }); + return { + async getToken(provider?: ToolAuthProvider, options?: ToolAuthOptions): Promise { + if (provider === undefined) throw missingProviderError("ctx.getToken"); + return await execution.getToken(resolve(provider, options)); + }, + requireAuth(provider?: ToolAuthProvider, options?: ToolAuthOptions): never { + if (provider === undefined) throw missingProviderError("ctx.requireAuth"); + return execution.requireAuth(resolve(provider, options)); + }, + run: execution.run, + }; +} + +function buildInlineScopedAuthorization(input: { + readonly boundResponder?: SessionAuthContext; + readonly scope: string; + readonly provider: ToolAuthProvider; + readonly options?: ToolAuthOptions; + readonly inlineAuthState: InlineAuthState; +}): ScopedAuthorization { + const authorization = normalizeInlineProvider(input.provider, input.options); + return { + authorization, + boundResponder: input.boundResponder, + connection: input.options?.connection ?? { url: "" }, + scope: + input.options?.authKey === undefined + ? deriveInlineScope({ + authorization, + inlineAuthState: input.inlineAuthState, + provider: input.provider, + scope: input.scope, + }) + : validateInlineAuthKey(input.options.authKey), + }; +} + +function normalizeInlineProvider( + provider: ToolAuthProvider, + options: ToolAuthOptions | undefined, +): AuthorizationDefinition { + const authorization = normalizeAuthorizationSpec(provider, "ctx.getToken:", "provider"); + if (options?.displayName === undefined) { + return authorization; + } + if (options.displayName.length === 0) { + throw new Error(`ctx.getToken: The "options.displayName" field must be a non-empty string.`); + } + return { ...authorization, displayName: options.displayName }; +} + +function deriveInlineScope(input: { + readonly scope: string; + readonly authorization: AuthorizationDefinition; + readonly provider: ToolAuthProvider; + readonly inlineAuthState: InlineAuthState; +}): string { + const connector = input.authorization.vercelConnect?.connector; + if (connector !== undefined) { + return `${input.scope}__${sanitizeScopeSegment(connector)}`; + } + + if (input.inlineAuthState.anonymousProvider === undefined) { + input.inlineAuthState.anonymousProvider = input.provider; + } else if (input.inlineAuthState.anonymousProvider !== input.provider) { + throw new Error( + `ctx.getToken: Multiple inline auth providers without provider metadata need explicit auth keys. ` + + `Pass options.authKey for each provider, for example ` + + `ctx.getToken(auth, { authKey: "github" }).`, + ); + } + + return `${input.scope}__inline_auth`; +} + +function validateInlineAuthKey(authKey: string): string { + if (!/^[A-Za-z0-9_.:-]+$/u.test(authKey)) { + throw new Error( + `ctx.getToken: The "options.authKey" field must contain only letters, digits, "_", "-", ".", or ":".`, + ); + } + return authKey; +} + +function sanitizeScopeSegment(value: string): string { + const sanitized = value.replace(/[^A-Za-z0-9_.:-]+/gu, "_").replace(/^_+|_+$/gu, ""); + return sanitized.length > 0 ? sanitized : "provider"; +} + +interface InlineAuthState { + anonymousProvider?: ToolAuthProvider; +} + +function missingProviderError(method: "ctx.getToken" | "ctx.requireAuth"): Error { + return new Error( + `${method}: Pass an auth provider, for example ${method}(connect("github/myagent")).`, + ); +} diff --git a/packages/eve/src/runtime/connections/scoped-authorization.ts b/packages/eve/src/runtime/connections/scoped-authorization.ts index 83819b4062..429587c02c 100644 --- a/packages/eve/src/runtime/connections/scoped-authorization.ts +++ b/packages/eve/src/runtime/connections/scoped-authorization.ts @@ -1,6 +1,6 @@ /** * Scope-parameterized authorization flow shared by MCP connections and - * authored tools that declare `auth`. + * authored tools and workflow steps using inline providers. * * A *scope* names the framework-owned callback URL — a connection name for * an MCP connection, a tool name for tool-hosted auth. Connection-hosted @@ -12,7 +12,13 @@ */ import { type AlsContext, contextStorage, loadContext } from "#context/container.js"; -import type { ConnectionAuthorizationChallenge } from "#connections/errors.js"; +import { + type ConnectionAuthorizationChallenge, + ConnectionAuthorizationFailedError, + ConnectionAuthorizationRequiredError, + isConnectionAuthorizationRequiredError, +} from "#connections/errors.js"; +import { isAsyncIterable } from "#shared/async-iterable.js"; import { type AuthorizationSignal, consumeAuthorizationResult, @@ -56,6 +62,124 @@ export interface ScopedAuthorization { readonly connection: ConnectionAuthorizationContext; } +/** One execution's token capability and authorization interruption boundary. */ +export function createAuthorizationExecution() { + const justAuthorized = new Set(); + + async function complete(scoped: ScopedAuthorization): Promise { + const key = scoped.instanceId ?? scoped.scope; + if (!justAuthorized.has(key) && (await completeScopedAuthorization(scoped))) { + justAuthorized.add(key); + } + } + + function requireAuth(scoped: ScopedAuthorization, cause?: unknown): never { + throw new ScopedAuthorizationRequiredError( + scoped, + justAuthorized.has(scoped.instanceId ?? scoped.scope), + cause, + ); + } + + return { + complete, + async getToken(scoped: ScopedAuthorization): Promise { + await complete(scoped); + try { + return await resolveScopedToken(scoped); + } catch (error) { + if (!isConnectionAuthorizationRequiredError(error)) throw error; + return requireAuth(scoped, error); + } + }, + requireAuth, + async handleError(error: unknown, scoped?: ScopedAuthorization): Promise { + if (scoped !== undefined && isConnectionAuthorizationRequiredError(error)) { + return await handleAuthorizationError( + new ScopedAuthorizationRequiredError( + scoped, + justAuthorized.has(scoped.instanceId ?? scoped.scope), + error, + ), + // Connection transports evict refused bearers when classifying their errors. + { evictToken: false }, + ); + } + return await handleAuthorizationError(error); + }, + run: executeWithAuthorization, + }; +} + +class ScopedAuthorizationRequiredError extends Error { + readonly scoped: ScopedAuthorization; + readonly justAuthorized: boolean; + + constructor(scoped: ScopedAuthorization, justAuthorized: boolean, cause?: unknown) { + super("Authorization required.", { cause }); + this.name = "ScopedAuthorizationRequiredError"; + this.scoped = scoped; + this.justAuthorized = justAuthorized; + } +} + +function isScopedAuthorizationRequiredError( + error: unknown, +): error is ScopedAuthorizationRequiredError { + return error instanceof Error && error.name === "ScopedAuthorizationRequiredError"; +} + +/** Produces the shared challenge; the caller owns parking and resumption. */ +export async function handleAuthorizationError( + error: unknown, + options: { readonly evictToken: boolean } = { evictToken: true }, +): Promise { + if (!isScopedAuthorizationRequiredError(error)) throw error; + const { scoped } = error; + if (error.justAuthorized) { + throw new ConnectionAuthorizationFailedError(scoped.scope, { + message: `Authorization for "${scoped.scope}" failed: the service rejected the token immediately after authorization.`, + reason: "token_rejected_after_authorization", + retryable: false, + }); + } + + if (options.evictToken) await evictScopedToken(scoped); + const signal = await startScopedAuthorization(scoped); + if (signal !== undefined) return signal; + + if (supportsInteractiveAuthorization(scoped.authorization)) { + throw new ConnectionAuthorizationFailedError(scoped.scope, { + message: `Authorization for "${scoped.scope}" requires sign-in, but no authorization callback URL could be minted for this run (missing session context).`, + reason: "authorization_callback_unavailable", + retryable: false, + }); + } + throw error.cause ?? new ConnectionAuthorizationRequiredError(scoped.scope); +} + +function executeWithAuthorization( + execute: () => unknown, +): Promise | AsyncIterable { + // Keep generator results as iterables, including errors raised during iteration. + try { + const output = execute(); + return isAsyncIterable(output) + ? handleIterable(output) + : Promise.resolve(output).catch(handleAuthorizationError); + } catch (error) { + return handleAuthorizationError(error); + } +} + +async function* handleIterable(output: AsyncIterable): AsyncIterable { + try { + for await (const value of output) yield value; + } catch (error) { + yield await handleAuthorizationError(error); + } +} + /** * Resolves a bearer token for one scope, consulting the per-step token * cache before invoking the authored `getToken`. diff --git a/research/tool-suspendability-and-lifetime.md b/research/tool-suspendability-and-lifetime.md index 56ce0e68dd..88c0b555bb 100644 --- a/research/tool-suspendability-and-lifetime.md +++ b/research/tool-suspendability-and-lifetime.md @@ -1,7 +1,7 @@ --- issue: TBD status: draft -last_updated: "2026-09-05" +last_updated: "2026-09-06" --- # Tools: suspendability and lifetime as two explicit axes @@ -113,8 +113,15 @@ requester-scoped auth there. Contract: - Both are callable inside a `"use step"` that received `ctx` as a direct argument. They resolve under the session identity in the run's serialized context, through the - scoped-authorization path a step tool uses (`execution/tool-auth.ts`). The step adapter reconstructs the auth capabilities under that identity; + shared authorization capability (`runtime/authorization-context.ts`). Ordinary tools and + workflow steps use the same `getToken`/`requireAuth` implementation. Connection search and + discovered connection tools use its underlying scoped execution for callback completion, + challenges, and rejection after sign-in. The step adapter reconstructs the capability under that identity; the workflow body retains throwing implementations. +- The auth capability returns a shared authorization signal. The executing runtime supplies + the callback hook and owns suspension/resumption; the auth implementation does not choose + between an agent turn and an authored workflow. Interactive providers without a callback + address fail instead of leaving the model to improvise a sign-in flow. - A token is returned to the step and never enters the body's replay log. - Interactive authorization does not return an `AuthorizationSignal` to the model. The workflow forwards the challenge through its owner's existing From 2b468e4f15bcc724be6730af93459e0b6ce9963d Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sun, 6 Sep 2026 12:53:26 -0400 Subject: [PATCH 05/23] chore(eve): retain tool epoch 30 compatibility Signed-off-by: Rui Conti --- .../compatibility/tool/v30.ts | 23 +++++++++++++++++++ .../extension-contracts/reports/tool/v31.json | 20 ++++++++++++++++ .../src/compiler/extension-compatibility.ts | 4 ++-- 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 packages/eve/extension-contracts/compatibility/tool/v30.ts create mode 100644 packages/eve/extension-contracts/reports/tool/v31.json diff --git a/packages/eve/extension-contracts/compatibility/tool/v30.ts b/packages/eve/extension-contracts/compatibility/tool/v30.ts new file mode 100644 index 0000000000..a2f0bb73a9 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/tool/v30.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { defineTool, defineWorkflowTool, disableTool } from "#public/tools/index.js"; + +disableTool(); + +defineTool({ + description: "Write an approved message.", + inputSchema: z.object({ message: z.string() }), + approval: ({ toolInput }) => (toolInput?.message ? "user-approval" : "not-applicable"), + execute: (input) => ({ written: input.message }), +}); + +defineWorkflowTool({ + description: "Ask before publishing a report.", + execution: "background", + inputSchema: z.object({ reportId: z.string() }), + async *execute(input, ctx, task) { + "use workflow"; + yield task.postMessage(`Preparing ${input.reportId}`); + const answer = await ctx.ask({ prompt: "Publish this report?", allowFreeform: true }); + return { reportId: input.reportId, answer: answer.text, sessionId: ctx.session.id }; + }, +}); diff --git a/packages/eve/extension-contracts/reports/tool/v31.json b/packages/eve/extension-contracts/reports/tool/v31.json new file mode 100644 index 0000000000..64b6834a65 --- /dev/null +++ b/packages/eve/extension-contracts/reports/tool/v31.json @@ -0,0 +1,20 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "tool", + "epoch": 31, + "sha256": "1e5ed8d7288c6538857921f0cc3ffc957d25837039f2b015147843f666e5b0e8", + "exports": [ + "defaultWebSearch", + "defineTool", + "defineWorkflowTool", + "disableTool", + "experimental_workflow", + "isDisabledToolSentinel", + "isExperimentalWorkflowToolDefinition", + "isWebSearchToolDefinition", + "toolOutput", + "toolOutputPart", + "toolResultFrom", + "webSearch" + ] +} diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index a126e0edb7..f94e11c00d 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -22,8 +22,8 @@ interface ExtensionCapabilityContract { const EXTENSION_CAPABILITY_CONTRACTS = { extension: { current: 1, supported: [1], dropped: {} }, tool: { - current: 30, - supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30], + current: 31, + supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30, 31], dropped: { 14: "TaskExec.delegated was removed; migrate to workflow-backed background tools", 15: "TaskExec replaces stageEffect with send", From e4e588f575c115fa53fcff73228038a49c342212 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sun, 6 Sep 2026 13:00:49 -0400 Subject: [PATCH 06/23] fix(eve): preserve native Workflow built-in step calls Signed-off-by: Rui Conti --- .../workflow-bundle/workflow-builders.test.ts | 34 +++++++++++++++++++ .../workflow-bundle/workflow-transformer.ts | 22 ++++++++---- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts b/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts index 6d9c92df61..5a064e1f40 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; import { describe, expect, it } from "vitest"; @@ -10,8 +11,41 @@ import { import { applyWorkflowTransform } from "./workflow-builders.js"; import { transformWorkflowDirectives } from "./workflow-transformer.js"; +import { withWorkflowStepAuthorization } from "#execution/tools/workflow/step-execution.js"; describe("applyWorkflowTransform", () => { + it("preserves native arguments and receivers for Workflow built-in steps", async () => { + const filename = "src/internal/workflow/builtins.ts"; + const source = readFileSync(resolvePackageSourceFilePath(filename), "utf8"); + const transformed = await applyWorkflowTransform(filename, source, "step"); + const executable = stripTypeScriptTypes( + transformed.code.replace(/^import[^;]+;\n/gm, "").replace(/^export /gm, ""), + ); + const registered = new Map(); + new Function("registerStepFunction", "withWorkflowStepAuthorization", executable)( + (id: string, execute: Function) => registered.set(id, execute), + withWorkflowStepAuthorization, + ); + + await expect( + registered.get("__builtin_response_json")!.call(Response.json({ ok: true })), + ).resolves.toEqual({ ok: true }); + await expect(registered.get("__builtin_response_text")!.call(new Response("ok"))).resolves.toBe( + "ok", + ); + const bytes = await registered.get("__builtin_response_array_buffer")!.call(new Response("ok")); + expect(new TextDecoder().decode(bytes)).toBe("ok"); + expect(Reflect.get(registered.get("__builtin_set_attributes")!, "maxRetries")).toBe(2); + + const workflow = await applyWorkflowTransform(filename, source, "workflow"); + expect(workflow.code).not.toContain("workflowToolStep"); + for (const name of registered.keys()) { + expect(workflow.code).toContain( + `globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(name)})`, + ); + } + }); + it("keeps eve workflow references stable when eve is the project root", async () => { const filename = "src/execution/turn-workflow.ts"; const transformed = await applyWorkflowTransform( diff --git a/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts b/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts index c9165ad8a0..60efb6b510 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts @@ -122,6 +122,9 @@ export async function transformWorkflowDirectives(input: { const ast = await parseWorkflowSource(input.filename, input.source); const functions = findDirectiveFunctions(ast); + const hasAuthorizationSteps = functions.some( + (fn) => fn.directive === "use step" && !BUILTIN_STEP_NAMES.has(fn.name), + ); if (functions.length === 0) { return { code: input.source, workflowManifest: {} }; @@ -156,7 +159,7 @@ export async function transformWorkflowDirectives(input: { replacements.push({ end: fn.rangeEnd, start: fn.rangeStart, - text: `${exportPrefix}var ${fn.name} = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(stepId)}));`, + text: `${exportPrefix}var ${fn.name} = ${createStepProxy(defaultIdBase, fn.name)};`, }); } else if (input.mode === "metadata") { continue; @@ -166,7 +169,7 @@ export async function transformWorkflowDirectives(input: { if (input.mode === "step") { hasStepRegistration = true; suffixes.push( - `registerStepFunction(${JSON.stringify(stepId)}, withWorkflowStepAuthorization(${fn.name}));`, + `registerStepFunction(${JSON.stringify(stepId)}, ${BUILTIN_STEP_NAMES.has(fn.name) ? fn.name : `withWorkflowStepAuthorization(${fn.name})`});`, ); } else { suffixes.push(`${fn.name}.stepId = ${JSON.stringify(stepId)};`); @@ -207,7 +210,7 @@ export async function transformWorkflowDirectives(input: { if (input.mode === "workflow" && !hasWorkflowDirective && input.authored !== true) { return { - code: `import { workflowToolStep } from ${JSON.stringify(workflowStepImport)};\n${manifestComment}\n${createWorkflowStepProxySource(input.source, ast, functions, defaultIdBase)}`, + code: `${hasAuthorizationSteps ? `import { workflowToolStep } from ${JSON.stringify(workflowStepImport)};\n` : ""}${manifestComment}\n${createWorkflowStepProxySource(input.source, ast, functions, defaultIdBase)}`, workflowManifest: manifest, }; } @@ -222,12 +225,12 @@ export async function transformWorkflowDirectives(input: { ? await stripUnusedValueImports(input.filename, replacedSource) : replacedSource; const prefix = hasStepRegistration - ? `import { registerStepFunction } from "workflow/internal/private";\nimport { withWorkflowStepAuthorization } from ${JSON.stringify(stepExecutionImport)};\n${manifestComment}\n` + ? `import { registerStepFunction } from "workflow/internal/private";\n${hasAuthorizationSteps ? `import { withWorkflowStepAuthorization } from ${JSON.stringify(stepExecutionImport)};\n` : ""}${manifestComment}\n` : `${manifestComment}\n`; const suffix = suffixes.length > 0 ? `\n${suffixes.join("\n")}\n` : ""; return { - code: `${input.mode === "workflow" && functions.some((fn) => fn.directive === "use step") ? `import { workflowToolStep } from ${JSON.stringify(workflowStepImport)};\n` : ""}${prefix}${transformedSource}${suffix}`, + code: `${input.mode === "workflow" && hasAuthorizationSteps ? `import { workflowToolStep } from ${JSON.stringify(workflowStepImport)};\n` : ""}${prefix}${transformedSource}${suffix}`, workflowManifest: manifest, }; } @@ -251,14 +254,19 @@ function createWorkflowStepProxySource( // carry the `export ` keyword whenever the function was reachable // to importers. const exportPrefix = fn.exported ? "export " : ""; - const stepId = createStepId(idBase, fn.name); - return `${exportPrefix}var ${fn.name} = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(stepId)}));`; + return `${exportPrefix}var ${fn.name} = ${createStepProxy(idBase, fn.name)};`; }); const lines = [...literalExports, ...proxies]; return lines.length > 0 ? `${lines.join("\n")}\n` : ""; } +function createStepProxy(idBase: string, name: string): string { + const proxy = `globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(createStepId(idBase, name))})`; + // Workflow invokes built-ins directly, with native arguments and a bound receiver. + return BUILTIN_STEP_NAMES.has(name) ? proxy : `workflowToolStep(${proxy})`; +} + function findDirectiveFunctions(ast: AstProgram): DirectiveFunction[] { const functions: DirectiveFunction[] = []; // Rolldown collapses `export async function foo() {}` into a trailing From a6052bde5a93455e22ad0e3352b9f1c513e4f192 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sun, 6 Sep 2026 13:37:11 -0400 Subject: [PATCH 07/23] Fix workflow auth retries and preserve native step references Signed-off-by: Rui Conti --- docs/tools/workflows.mdx | 5 + .../workflow/authorization-completion.ts | 46 ++++++ .../tools/workflow/step-execution.test.ts | 92 +++++++++++- .../tools/workflow/step-execution.ts | 21 ++- .../src/execution/tools/workflow/step.test.ts | 34 +++++ .../eve/src/execution/tools/workflow/step.ts | 142 ++++++++++-------- .../workflow-tool-run.integration.test.ts | 51 ++++++- .../testing/workflow-tool-fixtures.ts | 36 ++++- .../workflow-bundle/workflow-builders.test.ts | 23 ++- .../workflow-bundle/workflow-transformer.ts | 15 +- .../eve/src/runtime/authorization-context.ts | 3 +- .../connections/scoped-authorization.ts | 11 +- 12 files changed, 385 insertions(+), 94 deletions(-) create mode 100644 packages/eve/src/execution/tools/workflow/authorization-completion.ts create mode 100644 packages/eve/src/execution/tools/workflow/step.test.ts diff --git a/docs/tools/workflows.mdx b/docs/tools/workflows.mdx index 17aa3996ef..fedf94c973 100644 --- a/docs/tools/workflows.mdx +++ b/docs/tools/workflows.mdx @@ -187,6 +187,11 @@ not rerun. Resolve auth before other side effects in that step, and make operati again. Provider declarations can be shared imports, but context must be passed directly, not nested inside another argument or captured in a closure. +After a successful callback exchange, eve records completion before returning to authored code. +If that code later fails and the step retries, eve reads the token from the provider instead of +exchanging the same callback again. The provider must persist the grant or token; the durable +completion marker contains no credentials. + A background task becomes `input_required` during sign-in. The callback resumes that task; it does not rely on the launching agent turn still being active. Cancelling an authorization wait withdraws its callback. Cancellation uses the existing turn or task cancellation path rather than a separate diff --git a/packages/eve/src/execution/tools/workflow/authorization-completion.ts b/packages/eve/src/execution/tools/workflow/authorization-completion.ts new file mode 100644 index 0000000000..d8ebac48af --- /dev/null +++ b/packages/eve/src/execution/tools/workflow/authorization-completion.ts @@ -0,0 +1,46 @@ +import { + getStepMetadata, + getWorkflowMetadata, + getWritable, +} from "#compiled/@workflow/core/index.js"; +import { consumeAuthorizationResult, getAuthorizationResults } from "#harness/authorization.js"; +import { getRun } from "#internal/workflow/runtime.js"; +import { + completeScopedAuthorization, + type ScopedAuthorization, +} from "#runtime/connections/scoped-authorization.js"; + +/** Record successful completion before returning to authored code. */ +export async function completeWorkflowStepAuthorization( + scoped: ScopedAuthorization, +): Promise { + const result = getAuthorizationResults().find( + (result) => result.name === scoped.scope && result.instanceId === scoped.instanceId, + ); + if (result === undefined) return false; + + const { stepId, attempt } = getStepMetadata(); + const namespace = `eve.authorization.${stepId}.${result.attemptId}`; + if (attempt > 1) { + const stream = getRun(getWorkflowMetadata().workflowRunId).getReadable({ namespace }); + try { + if ((await stream.getTailIndex()) >= 0) { + consumeAuthorizationResult(scoped.scope, scoped.instanceId); + // The shared execution resolves the token through the provider's store and retains + // its fresh-token rejection guard. No bearer is stored in this completion stream. + return true; + } + } finally { + await stream.cancel().catch(() => {}); + } + } + + if (!(await completeScopedAuthorization(scoped))) return false; + const writer = getWritable({ namespace }).getWriter(); + try { + await writer.write(true); + } finally { + writer.releaseLock(); + } + return true; +} diff --git a/packages/eve/src/execution/tools/workflow/step-execution.test.ts b/packages/eve/src/execution/tools/workflow/step-execution.test.ts index 55d9b580c5..1580d208b8 100644 --- a/packages/eve/src/execution/tools/workflow/step-execution.test.ts +++ b/packages/eve/src/execution/tools/workflow/step-execution.test.ts @@ -1,8 +1,11 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withWorkflowStepAuthorization } from "#execution/tools/workflow/step-execution.js"; import { ContextContainer, contextStorage } from "#context/container.js"; import { AuthKey } from "#context/keys.js"; -import { ConnectionAuthorizationRequiredError } from "#connections/errors.js"; +import { + ConnectionAuthorizationRequiredError, + ConnectionAuthorizationFailedError, +} from "#connections/errors.js"; import type { WorkflowStepContext, WorkflowStepResult, @@ -10,7 +13,30 @@ import type { import type { ToolContext } from "#tools/definition.js"; import type { AuthorizationDefinition } from "#shared/connection-types.js"; -vi.mock("#compiled/@workflow/core/index.js", () => ({ getStepMetadata: () => ({ attempt: 1 }) })); +const durable = vi.hoisted(() => ({ + attempt: 1, + stepId: "step-1", + entries: new Map(), +})); +vi.mock("#compiled/@workflow/core/index.js", () => ({ + getStepMetadata: () => ({ attempt: durable.attempt, stepId: durable.stepId }), + getWorkflowMetadata: () => ({ workflowRunId: "run-1" }), + getWritable: ({ namespace }: { namespace: string }) => ({ + getWriter: () => ({ + write: async (value: unknown) => + durable.entries.set(namespace, [...(durable.entries.get(namespace) ?? []), value]), + releaseLock: () => {}, + }), + }), +})); +vi.mock("#internal/workflow/runtime.js", () => ({ + getRun: () => ({ + getReadable: ({ namespace }: { namespace: string }) => ({ + getTailIndex: async () => (durable.entries.get(namespace)?.length ?? 0) - 1, + cancel: async () => {}, + }), + }), +})); function context(user = "user-1"): WorkflowStepContext { const auth = { @@ -56,7 +82,67 @@ async function runStep( } describe("workflow step authorization", () => { + beforeEach(() => { + durable.attempt = 1; + durable.stepId = "step-1"; + durable.entries.clear(); + }); afterEach(() => vi.unstubAllEnvs()); + it("does not exchange a consumed code again when the rest of the step retries", async () => { + let exchanged = false; + const complete = vi.fn(async () => { + if (exchanged) + throw new ConnectionAuthorizationFailedError("devbox", { + message: "code already used", + retryable: false, + }); + exchanged = true; + return { token: "provider-stored-secret" }; + }); + const getToken = vi.fn(async () => { + if (!exchanged) throw new ConnectionAuthorizationRequiredError("devbox"); + return { token: "provider-stored-secret" }; + }); + const provider: AuthorizationDefinition = { + principalType: "user", + getToken, + completeAuthorization: complete, + startAuthorization: async () => ({ challenge: { url: "https://idp.example" } }), + }; + const input: WorkflowStepContext = { + ...context(), + authorizationResults: [ + { + name: "devbox__inline_auth", + attemptId: "auth-1", + hookUrl: "https://agent.example/callback", + callback: { method: "GET", params: { code: "single-use" } }, + }, + ], + }; + const execute = async (ctx: ToolContext) => { + await ctx.getToken(provider); + if (durable.attempt === 1) throw new Error("temporary service failure"); + return "done"; + }; + await expect(runStep(execute, input)).rejects.toThrow("temporary service failure"); + durable.attempt = 2; + await expect(runStep(execute, input)).resolves.toMatchObject({ + output: "done", + authorized: ["auth-1"], + }); + expect(complete).toHaveBeenCalledOnce(); + expect(getToken).toHaveBeenCalledOnce(); + expect([...durable.entries.values()]).toEqual([[true]]); + // Retries still reject a fresh token that the service refuses, without another sign-in. + await expect( + runStep(async (ctx) => { + await ctx.getToken(provider); + ctx.requireAuth(provider); + }, input), + ).rejects.toMatchObject({ fatal: true, reason: "token_rejected_after_authorization" }); + expect(complete).toHaveBeenCalledOnce(); + }); it("uses the captured requester instead of another ambient user and caches only within a step", async () => { const principals: string[] = []; const provider: AuthorizationDefinition = { diff --git a/packages/eve/src/execution/tools/workflow/step-execution.ts b/packages/eve/src/execution/tools/workflow/step-execution.ts index 48e9ebd22c..b91b65a3f3 100644 --- a/packages/eve/src/execution/tools/workflow/step-execution.ts +++ b/packages/eve/src/execution/tools/workflow/step-execution.ts @@ -11,6 +11,7 @@ import { import { createAuthorizationContext } from "#runtime/authorization-context.js"; import { buildBaseToolContext } from "#context/build-base-tool-context.js"; import { resolveWorkflowCallbackBaseUrl } from "#execution/workflow-callback-url.js"; +import { completeWorkflowStepAuthorization } from "#execution/tools/workflow/authorization-completion.js"; import { type WorkflowStepInvocation, type WorkflowStepResult, @@ -18,9 +19,12 @@ import { /** Keeps token capabilities and bearer values inside the executing step. */ export function withWorkflowStepAuthorization(execute: (...args: never[]) => unknown) { - const wrapped = async (invocation: WorkflowStepInvocation): Promise => { + const wrapped = async function ( + this: unknown, + invocation: WorkflowStepInvocation, + ): Promise { const { args, context: input } = invocation; - if (input === undefined) return execute(...(args as never[])); + if (input === undefined) return Reflect.apply(execute, this, args); getStepMetadata(); const context = new ContextContainer(); context.set(AuthKey, input.session.auth.current); @@ -35,7 +39,10 @@ export function withWorkflowStepAuthorization(execute: (...args: never[]) => unk context.setVirtualContext(PendingAuthorizationResultKey, input.authorizationResults); return contextStorage.run(context, async (): Promise => { - const auth = createAuthorizationContext({ scope: input.from.toolName }); + const auth = createAuthorizationContext({ + scope: input.from.toolName, + completeAuthorization: completeWorkflowStepAuthorization, + }); const ctx = { ...buildBaseToolContext({ toolName: input.from.toolName, @@ -47,10 +54,10 @@ export function withWorkflowStepAuthorization(execute: (...args: never[]) => unk let output: unknown; try { output = await auth.run(() => - execute( - ...(args.map((arg, index) => - invocation.contextIndexes?.includes(index) ? ctx : arg, - ) as never[]), + Reflect.apply( + execute, + this, + args.map((arg, index) => (invocation.contextIndexes?.includes(index) ? ctx : arg)), ), ); } catch (error) { diff --git a/packages/eve/src/execution/tools/workflow/step.test.ts b/packages/eve/src/execution/tools/workflow/step.test.ts new file mode 100644 index 0000000000..9501130d5b --- /dev/null +++ b/packages/eve/src/execution/tools/workflow/step.test.ts @@ -0,0 +1,34 @@ +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, it, vi } from "vitest"; +import { workflowToolStep } from "#execution/tools/workflow/step.js"; + +it("preserves native arguments and receivers for calls without workflow context", async () => { + const original = vi.fn(async function (this: { prefix: string }, value: string) { + return `${this.prefix}:${value}`; + }); + const authorize = vi.fn(); + const wrapped = workflowToolStep(original as (...args: unknown[]) => Promise, authorize); + await expect(wrapped.call({ prefix: "native" }, "argument")).resolves.toBe("native:argument"); + expect(authorize).not.toHaveBeenCalled(); + expect(original).toHaveBeenCalledWith("argument"); +}); + +it("preserves the SDK step reference and bind metadata through serialization", async () => { + const sdk = dirname(createRequire(import.meta.url).resolve("@workflow/core")); + const { createUseStep } = await import(pathToFileURL(resolve(sdk, "step.js")).href); + const { getStepFunctionReducer } = await import( + pathToFileURL(resolve(sdk, "serialization/reducers/step-function-vm.js")).href + ); + const original = createUseStep({})("step//./steps//read"); + const wrapped = workflowToolStep(original, vi.fn()); + const reduce = getStepFunctionReducer().StepFunction; + expect(reduce(wrapped)).toEqual(reduce(original)); + const receiver = { service: "api" }; + expect(reduce(wrapped.bind(receiver, "argument"))).toEqual({ + stepId: original.stepId, + boundThis: receiver, + boundArgs: ["argument"], + }); +}); diff --git a/packages/eve/src/execution/tools/workflow/step.ts b/packages/eve/src/execution/tools/workflow/step.ts index a80661e4ae..ef539fa1ca 100644 --- a/packages/eve/src/execution/tools/workflow/step.ts +++ b/packages/eve/src/execution/tools/workflow/step.ts @@ -17,80 +17,94 @@ import type { /** Wraps a step proxy only when the caller explicitly passes its workflow tool context. */ export function workflowToolStep( + original: (...args: unknown[]) => Promise, execute: (invocation: WorkflowStepInvocation) => Promise, ) { - return async (...args: unknown[]): Promise => { - const index = args.findIndex((arg) => findWorkflowToolRunContext(arg) !== undefined); - if (index === -1) return execute({ args }); - const ctx = args[index] as ToolContext; - const run = findWorkflowToolRunContext(ctx)!; - const authorizationResults: (AuthorizationResult & { name: string })[] = []; - const pending = new Map(); - for (;;) { - const callback = createHook(); - const input: WorkflowStepContext = { - from: run.from, - owner: run.owner, - session: ctx.session, - abortSignal: ctx.abortSignal, - baseUrl: getWorkflowMetadata().url, - token: callback.token, - authorizationResults, + // Forward the SDK proxy's stepId, bind implementation, and serialization metadata. + // A revived reference still calls the original registered function with native arguments. + return new Proxy(original, { + apply(target, receiver, args: unknown[]) { + const index = args.findIndex((arg) => findWorkflowToolRunContext(arg) !== undefined); + if (index === -1) return Reflect.apply(target, receiver, args); + return executeAuthorizedStep(execute, receiver, args, index); + }, + }); +} + +async function executeAuthorizedStep( + execute: (invocation: WorkflowStepInvocation) => Promise, + receiver: unknown, + args: unknown[], + index: number, +): Promise { + const ctx = args[index] as ToolContext; + const run = findWorkflowToolRunContext(ctx)!; + const authorizationResults: (AuthorizationResult & { name: string })[] = []; + const pending = new Map(); + for (;;) { + const callback = createHook(); + const input: WorkflowStepContext = { + from: run.from, + owner: run.owner, + session: ctx.session, + abortSignal: ctx.abortSignal, + baseUrl: getWorkflowMetadata().url, + token: callback.token, + authorizationResults, + }; + try { + const invocation: WorkflowStepInvocation = { + args: args.map((arg) => (arg === ctx ? null : arg)), + context: input, + contextIndexes: args.flatMap((arg, index) => (arg === ctx ? [index] : [])), }; + let result: WorkflowStepResult; try { - const invocation: WorkflowStepInvocation = { - args: args.map((arg) => (arg === ctx ? null : arg)), - context: input, - contextIndexes: args.flatMap((arg, index) => (arg === ctx ? [index] : [])), - }; - let result: WorkflowStepResult; + result = (await execute.call(receiver, invocation)) as WorkflowStepResult; + } catch (error) { + if (!ctx.abortSignal.aborted) + for (const challenge of pending.values()) + await reportAuthorization(input, challenge, "failed"); + throw error; + } + for (const attemptId of result.authorized) { + const challenge = pending.get(attemptId); + if (challenge !== undefined) await reportAuthorization(input, challenge, "authorized"); + pending.delete(attemptId); + } + for (let i = authorizationResults.length - 1; i >= 0; i--) { + if (result.authorized.includes(authorizationResults[i]!.attemptId!)) + authorizationResults.splice(i, 1); + } + if (result.kind === "eve:workflow-step-result") { + for (const challenge of pending.values()) + await reportAuthorization(input, challenge, "failed"); + return result.output; + } + for (const challenge of result.signal.challenges) { + pending.set(challenge.attemptId!, challenge); + await reportAuthorization(input, challenge); try { - result = (await execute(invocation)) as WorkflowStepResult; + const response = await waitForCallback(callback, challenge, ctx.abortSignal); + authorizationResults.push({ + name: challenge.name, + instanceId: challenge.instanceId, + attemptId: challenge.attemptId, + hookUrl: challenge.hookUrl, + principal: challenge.principal, + resume: challenge.resume, + callback: response, + }); } catch (error) { - if (!ctx.abortSignal.aborted) - for (const challenge of pending.values()) - await reportAuthorization(input, challenge, "failed"); + // Cancelled turns close their inbox; cancelled tasks discard further deliveries. + if (!ctx.abortSignal.aborted) await reportAuthorization(input, challenge, "failed"); throw error; } - for (const attemptId of result.authorized) { - const challenge = pending.get(attemptId); - if (challenge !== undefined) await reportAuthorization(input, challenge, "authorized"); - pending.delete(attemptId); - } - for (let i = authorizationResults.length - 1; i >= 0; i--) { - if (result.authorized.includes(authorizationResults[i]!.attemptId!)) - authorizationResults.splice(i, 1); - } - if (result.kind === "eve:workflow-step-result") { - for (const challenge of pending.values()) - await reportAuthorization(input, challenge, "failed"); - return result.output; - } - for (const challenge of result.signal.challenges) { - pending.set(challenge.attemptId!, challenge); - await reportAuthorization(input, challenge); - try { - const response = await waitForCallback(callback, challenge, ctx.abortSignal); - authorizationResults.push({ - name: challenge.name, - instanceId: challenge.instanceId, - attemptId: challenge.attemptId, - hookUrl: challenge.hookUrl, - principal: challenge.principal, - resume: challenge.resume, - callback: response, - }); - } catch (error) { - // Cancelled turns close their inbox; cancelled tasks discard further deliveries. - if (!ctx.abortSignal.aborted) await reportAuthorization(input, challenge, "failed"); - throw error; - } - } - } finally { - await disposeHook(callback); } + } finally { + await disposeHook(callback); } - }; + } } async function reportAuthorization( diff --git a/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts b/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts index 7ce25e83d3..b1a610fdf4 100644 --- a/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts +++ b/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts @@ -18,6 +18,7 @@ import { holdUntilAbortedWorkflow, reportingDeployWorkflow, stepThenRaceWorkflow, + stepReferenceWorkflow, } from "#internal/testing/workflow-tool-fixtures.js"; import { waitForHook } from "#internal/testing/workflow-test-helpers.js"; import { getRun, getWorld, start } from "#internal/workflow/runtime.js"; @@ -187,9 +188,14 @@ describe("workflow step authorization", () => { 60_000, ); - it.each([false, true])( - "parks on its own callback and resumes the step (background=%s)", - async (background) => { + it.each([ + { background: false, service: "interactive" }, + { background: true, service: "interactive" }, + { background: false, service: "retry" }, + { background: true, service: "retry" }, + ])( + "parks on its own callback and resumes the step (background=$background, service=$service)", + async ({ background, service }) => { const runtime = await createWorkflowToolRuntime({ agentName: "workflow-step-auth", background, @@ -199,7 +205,7 @@ describe("workflow step authorization", () => { await runtime.run(async () => { const run = await start(workflowEntry, [ { - input: { message: 'Run deploy_service with service "interactive"' }, + input: { message: `Run deploy_service with service "${service}"` }, serializedContext: { ...buildSerializedContext({ continuationToken: "http:step-auth", @@ -263,9 +269,23 @@ describe("workflow step authorization", () => { steps.data.filter((step) => step.stepName.endsWith("//planDeployStep")), ).toHaveLength(1); const attempts = steps.data.filter((step) => - step.stepName.endsWith("//authorizedDeployStep"), + step.stepName.endsWith("//authorizedDeployStep:eve-authorization"), ); expect(attempts).toHaveLength(2); + if (service === "retry") { + expect(attempts.map((step) => step.attempt).sort()).toEqual([1, 2]); + const retried = attempts.find((step) => step.attempt === 2)!; + const marker = getRun(executorRunId).getReadable({ + namespace: `eve.authorization.${retried.stepId}.${required.data.attemptId}`, + }); + const reader = marker.getReader(); + try { + expect((await reader.read()).value).toBe(true); + } finally { + await reader.cancel(); + reader.releaseLock(); + } + } for (const step of attempts) { const output = await hydrateWorkflowReturnValue(step.output, executorRunId, undefined); expect(JSON.stringify(output)).not.toContain("secret:"); @@ -382,6 +402,27 @@ describe("workflow step authorization", () => { describe("workflow tools", () => { afterEach(() => vi.unstubAllEnvs()); + it("invokes restored step references with bound arguments and receivers", async () => { + const runtime = await createWorkflowToolRuntime({ + agentName: "workflow-step-reference", + execute: stepReferenceWorkflow, + toolName: "deploy_service", + }); + const output = await runtime.run(async () => { + const run = await start(workflowEntry, [ + { + input: { message: 'Run deploy_service with service "api"' }, + serializedContext: buildSerializedContext({ + continuationToken: "schedule:step-reference", + mode: "task", + }), + }, + ]); + return String((await run.returnValue).output); + }); + expect(output).toContain('"argument":"plan:api"'); + expect(output).toContain('"receiver":"api"'); + }); it("runs the framework sleep tool through the workflow tool path", async () => { const runtime = await createWorkflowToolRuntime({ agentName: "workflow-tool-sleep", diff --git a/packages/eve/src/internal/testing/workflow-tool-fixtures.ts b/packages/eve/src/internal/testing/workflow-tool-fixtures.ts index 1955db4a2f..d2315ce0b0 100644 --- a/packages/eve/src/internal/testing/workflow-tool-fixtures.ts +++ b/packages/eve/src/internal/testing/workflow-tool-fixtures.ts @@ -6,7 +6,11 @@ * `agent/tools/*.ts`. */ -import { createHook, sleep as workflowSleep } from "#compiled/@workflow/core/index.js"; +import { + createHook, + getStepMetadata, + sleep as workflowSleep, +} from "#compiled/@workflow/core/index.js"; import type { WorkflowToolContext } from "#tools/workflow-definition.js"; import type { TaskExec, TaskMessage } from "#tools/task.js"; @@ -37,12 +41,33 @@ export async function authorizedDeployWorkflow(input: DeployInput, ctx: Workflow return { plan, authenticatedAs }; } +export async function stepReferenceWorkflow(input: DeployInput) { + "use workflow"; + const byArgument = await returnStepReference(planDeployStep); + const byReceiver = await returnStepReference(readServiceStep); + return { + argument: await byArgument.bind(undefined, input.service)(), + receiver: await byReceiver.call({ service: input.service }), + }; +} + +async function returnStepReference Promise>(step: T) { + "use step"; + return step; +} + +async function readServiceStep(this: DeployInput) { + "use step"; + return this.service; +} + async function authorizedDeployStep(service: string, ctx: WorkflowToolContext): Promise { "use step"; const provider: AuthorizationDefinition = { principalType: "user", async getToken({ principal }) { - if (service !== "preauthorized") throw new ConnectionAuthorizationRequiredError("deploy"); + if (service !== "preauthorized" && !(service === "retry" && getStepMetadata().attempt > 1)) + throw new ConnectionAuthorizationRequiredError("deploy"); return { token: `secret:${principal.type === "user" ? principal.id : "app"}` }; }, async startAuthorization({ principal, callbackUrl }) { @@ -54,6 +79,11 @@ async function authorizedDeployStep(service: string, ctx: WorkflowToolContext): }; }, async completeAuthorization({ principal, callback, resume }) { + if (service === "retry" && getStepMetadata().attempt > 1) + throw new ConnectionAuthorizationFailedError("deploy", { + message: "Authorization code was already exchanged.", + retryable: false, + }); if ( callback.params.code !== "approved" || principal.type !== "user" || @@ -68,6 +98,8 @@ async function authorizedDeployStep(service: string, ctx: WorkflowToolContext): }, }; const { token } = await ctx.getToken(provider); + if (service === "retry" && getStepMetadata().attempt === 1) + throw new Error("Transient service failure after sign-in."); if (service === "rejected") ctx.requireAuth(provider); return token.slice("secret:".length); } diff --git a/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts b/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts index 5a064e1f40..d2999a85a1 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts @@ -132,6 +132,7 @@ describe("applyWorkflowTransform", () => { ping: { stepId: "step//./steps/ping//ping", }, + "ping:eve-authorization": { stepId: "step//./steps/ping//ping:eve-authorization" }, }, }, }); @@ -139,7 +140,7 @@ describe("applyWorkflowTransform", () => { 'import { registerStepFunction } from "workflow/internal/private";', ); expect(transformed.code).toContain( - 'registerStepFunction("step//./steps/ping//ping", withWorkflowStepAuthorization(ping));', + 'registerStepFunction("step//./steps/ping//ping:eve-authorization", withWorkflowStepAuthorization(ping));', ); expect(transformed.code).not.toContain('"use step"'); }); @@ -164,7 +165,7 @@ describe("applyWorkflowTransform", () => { ); expect(transformed.code).toContain( - 'export var localStep = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/task//localStep"));', + 'export var localStep = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/task//localStep"), globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/task//localStep:eve-authorization"));', ); expect(transformed.code).toContain('export const TASK_KIND = "task";'); expect(transformed.code).toContain("export const RETRY_OFFSET = -1;"); @@ -234,6 +235,10 @@ describe("applyWorkflowTransform", () => { notifyDelegatedParentStep: { stepId: "step//./src/execution/workflow-entry//notifyDelegatedParentStep", }, + "notifyDelegatedParentStep:eve-authorization": { + stepId: + "step//./src/execution/workflow-entry//notifyDelegatedParentStep:eve-authorization", + }, }, }, workflows: { @@ -246,7 +251,7 @@ describe("applyWorkflowTransform", () => { }); expect(transformed.code).toContain("async function runWorkflowLoop"); expect(transformed.code).toContain( - 'var notifyDelegatedParentStep = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/workflow-entry//notifyDelegatedParentStep"));', + 'var notifyDelegatedParentStep = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/workflow-entry//notifyDelegatedParentStep"), globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/workflow-entry//notifyDelegatedParentStep:eve-authorization"));', ); expect(transformed.code).not.toContain("step//./src/execution/workflow-entry//runWorkflowLoop"); }); @@ -282,6 +287,9 @@ describe("applyWorkflowTransform", () => { notifyDriverStep: { stepId: "step//eve@1.2.3//notifyDriverStep", }, + "notifyDriverStep:eve-authorization": { + stepId: "step//eve@1.2.3//notifyDriverStep:eve-authorization", + }, }, }, workflows: { @@ -342,6 +350,9 @@ describe("applyWorkflowTransform for authored application modules", () => { steps: { "agent/tools/deploy.ts": { planDeploy: { stepId: "step//./agent/tools/deploy//planDeploy" }, + "planDeploy:eve-authorization": { + stepId: "step//./agent/tools/deploy//planDeploy:eve-authorization", + }, }, }, workflows: { @@ -351,7 +362,7 @@ describe("applyWorkflowTransform for authored application modules", () => { }, }); expect(transformed.code).toContain( - 'var planDeploy = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/tools/deploy//planDeploy"));', + 'var planDeploy = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/tools/deploy//planDeploy"), globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/tools/deploy//planDeploy:eve-authorization"));', ); expect(transformed.code).toContain( 'globalThis.__private_workflows.set("workflow//./agent/tools/deploy//execute", execute);', @@ -394,7 +405,7 @@ describe("applyWorkflowTransform for authored application modules", () => { ); expect(transformed.code).toContain( - 'registerStepFunction("step//./agent/tools/deploy//planDeploy", withWorkflowStepAuthorization(planDeploy));', + 'registerStepFunction("step//./agent/tools/deploy//planDeploy:eve-authorization", withWorkflowStepAuthorization(planDeploy));', ); expect(transformed.code).toContain( 'execute.workflowId = "workflow//./agent/tools/deploy//execute";', @@ -461,7 +472,7 @@ describe("applyWorkflowTransform for authored application modules", () => { expect(transformed.code).toContain("export function formatPlan(plan: string): string {"); expect(transformed.code).toContain( - 'export var hashPlan = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/lib/steps//hashPlan"));', + 'export var hashPlan = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/lib/steps//hashPlan"), globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/lib/steps//hashPlan:eve-authorization"));', ); expect(transformed.code).not.toContain("node:crypto"); }); diff --git a/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts b/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts index 60efb6b510..950704da8c 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts @@ -153,6 +153,10 @@ export async function transformWorkflowDirectives(input: { manifest.steps ??= {}; const stepsForFile = (manifest.steps[input.filename] ??= {}); stepsForFile[fn.name] = { stepId }; + const authorizationName = `${fn.name}:eve-authorization`; + const authorizationStepId = createStepId(defaultIdBase, authorizationName); + if (!BUILTIN_STEP_NAMES.has(fn.name)) + stepsForFile[authorizationName] = { stepId: authorizationStepId }; if (input.mode === "workflow") { const exportPrefix = fn.exportPrefix.length > 0 ? "export " : ""; @@ -168,9 +172,11 @@ export async function transformWorkflowDirectives(input: { if (input.mode === "step") { hasStepRegistration = true; - suffixes.push( - `registerStepFunction(${JSON.stringify(stepId)}, ${BUILTIN_STEP_NAMES.has(fn.name) ? fn.name : `withWorkflowStepAuthorization(${fn.name})`});`, - ); + suffixes.push(`registerStepFunction(${JSON.stringify(stepId)}, ${fn.name});`); + if (!BUILTIN_STEP_NAMES.has(fn.name)) + suffixes.push( + `registerStepFunction(${JSON.stringify(authorizationStepId)}, withWorkflowStepAuthorization(${fn.name}));`, + ); } else { suffixes.push(`${fn.name}.stepId = ${JSON.stringify(stepId)};`); } @@ -264,7 +270,8 @@ function createWorkflowStepProxySource( function createStepProxy(idBase: string, name: string): string { const proxy = `globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(createStepId(idBase, name))})`; // Workflow invokes built-ins directly, with native arguments and a bound receiver. - return BUILTIN_STEP_NAMES.has(name) ? proxy : `workflowToolStep(${proxy})`; + const authorized = `globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(createStepId(idBase, `${name}:eve-authorization`))})`; + return BUILTIN_STEP_NAMES.has(name) ? proxy : `workflowToolStep(${proxy}, ${authorized})`; } function findDirectiveFunctions(ast: AstProgram): DirectiveFunction[] { diff --git a/packages/eve/src/runtime/authorization-context.ts b/packages/eve/src/runtime/authorization-context.ts index 8558fcd969..3cce7b5565 100644 --- a/packages/eve/src/runtime/authorization-context.ts +++ b/packages/eve/src/runtime/authorization-context.ts @@ -11,8 +11,9 @@ import { export function createAuthorizationContext(input: { readonly scope: string; readonly boundResponder?: SessionAuthContext; + readonly completeAuthorization?: (scoped: ScopedAuthorization) => Promise; }) { - const execution = createAuthorizationExecution(); + const execution = createAuthorizationExecution(input); const inlineAuthState: InlineAuthState = {}; const resolve = (provider: ToolAuthProvider, options?: ToolAuthOptions) => buildInlineScopedAuthorization({ ...input, inlineAuthState, provider, options }); diff --git a/packages/eve/src/runtime/connections/scoped-authorization.ts b/packages/eve/src/runtime/connections/scoped-authorization.ts index 429587c02c..d3bbf28362 100644 --- a/packages/eve/src/runtime/connections/scoped-authorization.ts +++ b/packages/eve/src/runtime/connections/scoped-authorization.ts @@ -63,12 +63,19 @@ export interface ScopedAuthorization { } /** One execution's token capability and authorization interruption boundary. */ -export function createAuthorizationExecution() { +export function createAuthorizationExecution( + options: { + readonly completeAuthorization?: typeof completeScopedAuthorization; + } = {}, +) { const justAuthorized = new Set(); async function complete(scoped: ScopedAuthorization): Promise { const key = scoped.instanceId ?? scoped.scope; - if (!justAuthorized.has(key) && (await completeScopedAuthorization(scoped))) { + if ( + !justAuthorized.has(key) && + (await (options.completeAuthorization ?? completeScopedAuthorization)(scoped)) + ) { justAuthorized.add(key); } } From 4f192c5b3f374bbda66c7390a22ca8b69a66621b Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sun, 6 Sep 2026 17:38:51 -0400 Subject: [PATCH 08/23] refactor(eve): simplify task owner authorization handling Signed-off-by: Rui Conti --- .../execution/tasks/child/workflow.test.ts | 138 +++++++++++++++++- .../eve/src/execution/tasks/child/workflow.ts | 128 ++++++++-------- 2 files changed, 205 insertions(+), 61 deletions(-) diff --git a/packages/eve/src/execution/tasks/child/workflow.test.ts b/packages/eve/src/execution/tasks/child/workflow.test.ts index b7a7ccd98d..8c094251e1 100644 --- a/packages/eve/src/execution/tasks/child/workflow.test.ts +++ b/packages/eve/src/execution/tasks/child/workflow.test.ts @@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { WorkflowToolRunMessage } from "#execution/tools/workflow/messages.js"; import { taskRunWorkflow } from "#execution/tasks/child/workflow.js"; import type { TaskView } from "#tasks/types.js"; +import { + createAuthorizationRequiredEvent, + createAuthorizationCompletedEvent, +} from "#protocol/message.js"; const mocks = vi.hoisted(() => ({ appendTaskProgressStep: vi.fn(), @@ -19,6 +23,7 @@ const mocks = vi.hoisted(() => ({ raceChannelReads: vi.fn(), resumeHookStep: vi.fn(), wakeTaskAgentRequestParentStep: vi.fn(), + wakeTaskAuthorizationParentStep: vi.fn(), wakeTaskMessageParentStep: vi.fn(), wakeTaskParentStep: vi.fn(), wakeTaskUpdateParentStep: vi.fn(), @@ -50,7 +55,7 @@ vi.mock("#execution/tasks/child/steps.js", () => ({ deliverTaskInputResponsesStep: mocks.deliverTaskInputResponsesStep, wakeTaskAgentRequestParentStep: mocks.wakeTaskAgentRequestParentStep, wakeTaskMessageParentStep: mocks.wakeTaskMessageParentStep, - wakeTaskAuthorizationParentStep: vi.fn(), + wakeTaskAuthorizationParentStep: mocks.wakeTaskAuthorizationParentStep, wakeTaskParentStep: mocks.wakeTaskParentStep, wakeTaskUpdateParentStep: mocks.wakeTaskUpdateParentStep, wakeWorkflowTaskInputRequestParentStep: mocks.wakeWorkflowTaskInputRequestParentStep, @@ -108,6 +113,47 @@ const workflowAgentRequest = { }, } satisfies WorkflowToolRunMessage; +function authorizationRequest(attemptId: string, completed = false): WorkflowToolRunMessage { + const data = { attemptId, name: "github", sequence: 0, stepIndex: 0, turnId: "turn-parent" }; + return { + ...bufferedAgentRequest, + replyTo: `ack-${attemptId}`, + request: { + kind: "authorization-request", + stepAuthorization: true, + event: { + kind: "subagent-authorization-event", + callId: "tool-call-1", + childSessionId: "run-1", + subagentName: "approval-worker", + event: completed + ? createAuthorizationCompletedEvent({ ...data, outcome: "authorized" }) + : createAuthorizationRequiredEvent({ ...data, description: "Sign in" }), + }, + }, + }; +} + +function queueOwnerRequest(value: WorkflowToolRunMessage) { + mocks.raceChannelReads.mockResolvedValueOnce({ + channel: "workflow", + next: { done: false, value }, + }); +} + +function queueCommand(command: import("#tasks/types.js").TaskCommand) { + mocks.raceChannelReads.mockResolvedValueOnce({ + channel: "commands", + next: { done: false, value: { kind: "task-command", command } }, + }); +} + +const workflowInput = { + initialView, + parentContinuationToken: "parent-token", + taskInboxToken: "task-token", +}; + describe("taskRunWorkflow", () => { beforeEach(() => { vi.resetAllMocks(); @@ -122,6 +168,96 @@ describe("taskRunWorkflow", () => { }); }); + it("persists auth requests and answers before forwarding and acknowledging each event", async () => { + queueCommand({ kind: "ready" }); + queueOwnerRequest(authorizationRequest("a")); + queueOwnerRequest(authorizationRequest("b")); + queueOwnerRequest(authorizationRequest("a", true)); + queueOwnerRequest(authorizationRequest("b", true)); + mocks.raceChannelReads.mockResolvedValueOnce({ channel: "commands", next: { done: true } }); + + await taskRunWorkflow(workflowInput); + + const views = mocks.appendTaskViewStep.mock.calls.slice(-4).map(([input]) => input.view); + expect(views.map((view) => view.status)).toEqual([ + "input_required", + "input_required", + "input_required", + "working", + ]); + expect( + views + .slice(0, 3) + .map((view) => + view.inputRequests.map((request: { requestId: string }) => request.requestId), + ), + ).toEqual([["a"], ["a", "b"], ["b"]]); + expect(mocks.wakeTaskAuthorizationParentStep).toHaveBeenCalledTimes(4); + expect(mocks.resumeHookStep).toHaveBeenCalledTimes(4); + expect(mocks.wakeTaskParentStep).not.toHaveBeenCalled(); + for (let i = 0; i < 4; i++) { + expect(mocks.appendTaskViewStep.mock.invocationCallOrder[i + 2]).toBeLessThan( + mocks.wakeTaskAuthorizationParentStep.mock.invocationCallOrder[i]!, + ); + expect(mocks.wakeTaskAuthorizationParentStep.mock.invocationCallOrder[i]).toBeLessThan( + mocks.resumeHookStep.mock.invocationCallOrder[i]!, + ); + } + }); + + it("acknowledges buffered auth when dispatch is rejected without forwarding it", async () => { + queueOwnerRequest(authorizationRequest("a")); + queueCommand({ kind: "reject-dispatch", data: "rejected" }); + + await taskRunWorkflow(workflowInput); + + expect(mocks.wakeTaskAuthorizationParentStep).not.toHaveBeenCalled(); + expect(mocks.resumeHookStep).toHaveBeenCalledExactlyOnceWith("ack-a", null, { + ifPresent: true, + }); + expect( + mocks.appendTaskViewStep.mock.calls.some(([input]) => input.view.status === "input_required"), + ).toBe(false); + }); + + it.each([false, true])( + "does not reopen a cancelled task for buffered auth (completed=%s)", + async (completed) => { + queueOwnerRequest(authorizationRequest("a", completed)); + queueCommand({ kind: "cancel" }); + queueCommand({ kind: "ready" }); + + await taskRunWorkflow(workflowInput); + + expect(mocks.wakeTaskAuthorizationParentStep).toHaveBeenCalledTimes(completed ? 1 : 0); + expect(mocks.resumeHookStep).toHaveBeenCalledExactlyOnceWith("ack-a", null, { + ifPresent: true, + }); + expect(mocks.appendTaskViewStep.mock.calls.map(([input]) => input.view.status)).toEqual([ + "working", + "cancelled", + ]); + }, + ); + + it.each(["persistence", "forwarding"])( + "does not acknowledge auth after failed %s", + async (failure) => { + queueCommand({ kind: "ready" }); + queueOwnerRequest(authorizationRequest("a")); + if (failure === "persistence") { + mocks.appendTaskViewStep.mockImplementation(async ({ view }) => { + if (view.status === "input_required") throw new Error("failed persistence"); + }); + } else { + mocks.wakeTaskAuthorizationParentStep.mockRejectedValue(new Error("failed forwarding")); + } + + await expect(taskRunWorkflow(workflowInput)).rejects.toThrow(`failed ${failure}`); + expect(mocks.resumeHookStep).not.toHaveBeenCalled(); + }, + ); + it("delivers an authored message queued before completion and dispatch acknowledgement", async () => { const message = { callId: "call-1", diff --git a/packages/eve/src/execution/tasks/child/workflow.ts b/packages/eve/src/execution/tasks/child/workflow.ts index 236b76c243..14437f5c89 100644 --- a/packages/eve/src/execution/tasks/child/workflow.ts +++ b/packages/eve/src/execution/tasks/child/workflow.ts @@ -20,7 +20,10 @@ import { type WorkflowBodyResult, } from "#execution/tools/workflow/body.js"; import { resumeHookStep } from "#execution/tools/workflow/resume-hook-step.js"; -import type { WorkflowToolRunRequestMessage } from "#execution/tools/workflow/messages.js"; +import type { + WorkflowToolAuthorizationRequest, + WorkflowToolRunRequestMessage, +} from "#execution/tools/workflow/messages.js"; import { createChannelReader, raceChannelReads } from "#execution/tools/workflow/owner-channels.js"; import { openWorkflowToolRunOwnerInbox } from "#execution/tools/workflow/owner.js"; import { @@ -271,10 +274,7 @@ export async function taskRunWorkflow(input: TaskRunWorkflowInput): Promise { + const result = applyTaskTransition(view, command); + if (result.action !== "accepted") return false; + view = result.view; + await appendTaskViewStep({ activityObserver: input.activityObserver, view }); + return true; + } + + // Owner traffic must wait until the parent has acknowledged task dispatch. async function handleOwnerRequest(message: WorkflowToolRunRequestMessage): Promise { - const closesAuthorization = - message.request.kind === "authorization-request" && - message.request.stepAuthorization === true && - message.request.event.event.type === "authorization.completed"; - if (dispatchRejected || (isTerminalTaskStatus(view.status) && !closesAuthorization)) { - if ( - message.request.kind === "authorization-request" && - message.request.stepAuthorization === true - ) - await resumeHookStep(message.replyTo, null, { ifPresent: true }); - return; - } if (!dispatchAcknowledged) { pendingTraffic.ownerRequests.push(message); return; } - await wakeTaskOwnerRequestParent(message); + const { request, replyTo } = message; + if (request.kind === "authorization-request" && request.stepAuthorization === true) { + await handleStepAuthorization(request, replyTo); + return; + } + if (dispatchRejected || isTerminalTaskStatus(view.status)) return; + if (request.kind === "authorization-request") { + await wakeTaskAuthorizationParentStep({ + request, + taskId: view.taskId, + token: input.parentContinuationToken, + }); + return; + } + await wakeTaskAgentRequestParentStep({ + request: message, + taskId: view.taskId, + token: input.parentContinuationToken, + }); + } + + async function handleStepAuthorization( + request: WorkflowToolAuthorizationRequest, + replyTo: string, + ): Promise { + const event = request.event.event; + // A terminal task may still need to close an already-displayed auth prompt. + if ( + !dispatchRejected && + (!isTerminalTaskStatus(view.status) || event.type === "authorization.completed") + ) { + const requestId = "attemptId" in event.data ? event.data.attemptId : undefined; + if (requestId !== undefined) { + await transitionTask( + event.type === "authorization.required" + ? { + kind: "require-input", + inputRequests: [ + ...(view.status === "input_required" ? view.inputRequests : []), + { kind: "authorization", requestId, name: event.data.name }, + ], + } + : { kind: "answered", requestIds: [requestId] }, + ); + } + await wakeTaskAuthorizationParentStep({ + request, + taskId: view.taskId, + token: input.parentContinuationToken, + }); + } + // Acknowledge intentional discards too, but never failed persistence or delivery. + await resumeHookStep(replyTo, null, { ifPresent: true }); } async function flushPendingTraffic(): Promise { @@ -344,50 +391,11 @@ export async function taskRunWorkflow(input: TaskRunWorkflowInput): Promise { - if (message.request.kind === "authorization-request") { - if (message.request.stepAuthorization === true) { - const event = message.request.event.event; - const requestId = "attemptId" in event.data ? event.data.attemptId : undefined; - if (requestId !== undefined) { - const command: TaskCommand = - event.type === "authorization.required" - ? { - kind: "require-input", - inputRequests: [ - ...(view.status === "input_required" ? view.inputRequests : []), - { kind: "authorization", requestId, name: event.data.name }, - ], - } - : { kind: "answered", requestIds: [requestId] }; - const transition = applyTaskTransition(view, command); - if (transition.action === "accepted") { - view = transition.view; - await appendTaskViewStep({ activityObserver: input.activityObserver, view }); - } - } - } - await wakeTaskAuthorizationParentStep({ - request: message.request, - taskId: view.taskId, - token: input.parentContinuationToken, - }); - if (message.request.stepAuthorization === true) - await resumeHookStep(message.replyTo, null, { ifPresent: true }); - return; - } - await wakeTaskAgentRequestParentStep({ - request: message, - taskId: view.taskId, - token: input.parentContinuationToken, - }); - } } async function* awaitBodyResult( From 45ca4bd4d9842cbcf06e657ef3ca82a43d4f8475 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sun, 6 Sep 2026 17:38:52 -0400 Subject: [PATCH 09/23] test(eve): hold busy-worker until continuation checks finish Signed-off-by: Rui Conti --- e2e/fixtures/fixture-tasks/agent/agent.ts | 4 ++-- .../fixture-tasks/agent/subagents/busy-worker/agent.ts | 8 +++++++- .../agent/subagents/busy-worker/tools/hold.ts | 8 +++++--- .../evals/task.agent.continue.rejected-agent-busy.eval.ts | 5 ++++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/e2e/fixtures/fixture-tasks/agent/agent.ts b/e2e/fixtures/fixture-tasks/agent/agent.ts index 038245332f..459aa20d8b 100644 --- a/e2e/fixtures/fixture-tasks/agent/agent.ts +++ b/e2e/fixtures/fixture-tasks/agent/agent.ts @@ -509,12 +509,12 @@ function raceBusyWorker(request: MockModelRequest): MockModelResponse | string { toolCalls: [ { id: "child-task-exclusivity-send-a", - input: { agentId, message: "Return BUSY-WORKER-A." }, + input: { agentId, message: "EXCLUSIVITY-GATE: Return BUSY-WORKER-A." }, name: "busy-worker", }, { id: "child-task-exclusivity-send-b", - input: { agentId, message: "Return BUSY-WORKER-B." }, + input: { agentId, message: "EXCLUSIVITY-GATE: Return BUSY-WORKER-B." }, name: "busy-worker", }, ], diff --git a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts index bc7e211994..a055527833 100644 --- a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts +++ b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts @@ -11,7 +11,13 @@ export default defineAgent({ if (message.includes("BUSY-WORKER-A") || message.includes("BUSY-WORKER-B")) { if (!request.toolResults.some((result) => result.id === "exclusivity-hold")) { return { - toolCalls: [{ id: "exclusivity-hold", input: { marker: "HOLD" }, name: "hold" }], + toolCalls: [ + { + id: "exclusivity-hold", + input: { marker: message.includes("EXCLUSIVITY-GATE") ? "EXCLUSIVITY" : "HOLD" }, + name: "hold", + }, + ], }; } } diff --git a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts index d843ccc01b..03bd33645a 100644 --- a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts +++ b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts @@ -2,10 +2,12 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; export default defineTool({ - description: "Keep an admitted continuation nonterminal long enough for a later-turn check.", - inputSchema: z.object({ marker: z.literal("HOLD") }), + description: "Hold a continuation until approval, or briefly delay other fixture work.", + inputSchema: z.object({ marker: z.enum(["HOLD", "EXCLUSIVITY"]) }), + approval: ({ toolInput }) => + toolInput?.marker === "EXCLUSIVITY" ? "user-approval" : "not-applicable", execute: async ({ marker }) => { - await new Promise((resolve) => setTimeout(resolve, 5_000)); + if (marker === "HOLD") await new Promise((resolve) => setTimeout(resolve, 5_000)); return { marker, released: true }; }, }); diff --git a/e2e/fixtures/fixture-tasks/evals/task.agent.continue.rejected-agent-busy.eval.ts b/e2e/fixtures/fixture-tasks/evals/task.agent.continue.rejected-agent-busy.eval.ts index d9ea0583a9..6cc2d5dd13 100644 --- a/e2e/fixtures/fixture-tasks/evals/task.agent.continue.rejected-agent-busy.eval.ts +++ b/e2e/fixtures/fixture-tasks/evals/task.agent.continue.rejected-agent-busy.eval.ts @@ -4,6 +4,7 @@ import { parseToolErrorOutput, sendAndFollowQueuedTurn, waitForCompletedTask, + waitForTaskInput, } from "./shared.js"; /** A persistent child with a nonterminal task rejects every competing continuation. */ @@ -57,10 +58,11 @@ export default defineTaskEval({ status: "failed", }); + const held = await waitForTaskInput(t, race.session, "hold"); const later = await sendAndFollowQueuedTurn( t, `CHILD-TASK-EXCLUSIVITY-LATER ${agentId}`, - race.session, + held.session, { allowFailedActions: true }, ); later.turn.calledTool("busy-worker", { @@ -69,6 +71,7 @@ export default defineTaskEval({ status: "failed", }); + await later.session.respond([{ optionId: "approve", requestId: held.request.requestId }]); await waitForCompletedTask(t, later.session, "CHILD-TASK-EXCLUSIVITY-VERIFY", admittedTaskId); }, }); From f783361fdc6f32ed695ab6a043e2c0b9a0c60e9c Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sun, 6 Sep 2026 18:17:19 -0400 Subject: [PATCH 10/23] refactor(eve): shorten workflow step result kinds Signed-off-by: Rui Conti --- packages/eve/src/execution/tools/workflow/step-context.ts | 4 ++-- .../src/execution/tools/workflow/step-execution.test.ts | 7 +++---- .../eve/src/execution/tools/workflow/step-execution.ts | 4 ++-- packages/eve/src/execution/tools/workflow/step.ts | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/eve/src/execution/tools/workflow/step-context.ts b/packages/eve/src/execution/tools/workflow/step-context.ts index 9775501c90..ab76e23716 100644 --- a/packages/eve/src/execution/tools/workflow/step-context.ts +++ b/packages/eve/src/execution/tools/workflow/step-context.ts @@ -16,8 +16,8 @@ export interface WorkflowStepContext { } export type WorkflowStepResult = { readonly authorized: readonly string[] } & ( - | { readonly kind: "eve:workflow-step-result"; readonly output: unknown } - | { readonly kind: "eve:workflow-step-authorization"; readonly signal: AuthorizationSignal } + | { readonly kind: "result"; readonly output: unknown } + | { readonly kind: "authorization-required"; readonly signal: AuthorizationSignal } ); /** Compiler-owned envelope; authored arguments never select the auth context. */ diff --git a/packages/eve/src/execution/tools/workflow/step-execution.test.ts b/packages/eve/src/execution/tools/workflow/step-execution.test.ts index 1580d208b8..be19f7f30f 100644 --- a/packages/eve/src/execution/tools/workflow/step-execution.test.ts +++ b/packages/eve/src/execution/tools/workflow/step-execution.test.ts @@ -166,7 +166,7 @@ describe("workflow step authorization", () => { expect(principals.sort()).toEqual(["user-1", "user-2"]); expect(JSON.stringify(results)).not.toContain("secret:"); expect(results[0]).toMatchObject({ - kind: "eve:workflow-step-result", + kind: "result", output: { sameToken: true, session: "session-1" }, }); }); @@ -199,8 +199,7 @@ describe("workflow step authorization", () => { ctx.requireAuth(provider); }; const pending = await runStep(execute); - if (pending.kind !== "eve:workflow-step-authorization") - throw new Error("Expected authorization"); + if (pending.kind !== "authorization-required") throw new Error("Expected authorization"); const challenge = pending.signal.challenges[0]!; expect(challenge.hookUrl).toContain("https://agent.example/agents/devbox/eve/v1/"); expect(challenge.hookUrl).toContain("callback-user-1"); @@ -220,7 +219,7 @@ describe("workflow step authorization", () => { }); it("does not turn an ordinary step result into an authorization signal", async () => { - const value = { kind: "eve:workflow-step-authorization" }; + const value = { kind: "authorization-required" }; await expect( withWorkflowStepAuthorization(async (input) => input)({ args: [value] }), ).resolves.toBe(value); diff --git a/packages/eve/src/execution/tools/workflow/step-execution.ts b/packages/eve/src/execution/tools/workflow/step-execution.ts index b91b65a3f3..c3110229f6 100644 --- a/packages/eve/src/execution/tools/workflow/step-execution.ts +++ b/packages/eve/src/execution/tools/workflow/step-execution.ts @@ -71,8 +71,8 @@ export function withWorkflowStepAuthorization(execute: (...args: never[]) => unk .filter((result) => !remaining.includes(result)) .map((result) => result.attemptId!); return isAuthorizationSignal(output) - ? { kind: "eve:workflow-step-authorization", signal: output, authorized } - : { kind: "eve:workflow-step-result", output, authorized }; + ? { kind: "authorization-required", signal: output, authorized } + : { kind: "result", output, authorized }; }); }; // The SDK reads retry policy from the registered function at execution time. diff --git a/packages/eve/src/execution/tools/workflow/step.ts b/packages/eve/src/execution/tools/workflow/step.ts index ef539fa1ca..213ed770d3 100644 --- a/packages/eve/src/execution/tools/workflow/step.ts +++ b/packages/eve/src/execution/tools/workflow/step.ts @@ -76,7 +76,7 @@ async function executeAuthorizedStep( if (result.authorized.includes(authorizationResults[i]!.attemptId!)) authorizationResults.splice(i, 1); } - if (result.kind === "eve:workflow-step-result") { + if (result.kind === "result") { for (const challenge of pending.values()) await reportAuthorization(input, challenge, "failed"); return result.output; From 46425eb131fbbdd121515e6643fc7d956ee6b599 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sun, 6 Sep 2026 18:47:39 -0400 Subject: [PATCH 11/23] refactor(eve): narrow workflow step authorization context Signed-off-by: Rui Conti --- .../execution/tools/workflow/step-context.ts | 12 ++--- .../tools/workflow/step-execution.test.ts | 24 ++++------ .../tools/workflow/step-execution.ts | 9 ++-- .../eve/src/execution/tools/workflow/step.ts | 44 +++++++++++-------- 4 files changed, 41 insertions(+), 48 deletions(-) diff --git a/packages/eve/src/execution/tools/workflow/step-context.ts b/packages/eve/src/execution/tools/workflow/step-context.ts index ab76e23716..0829ec9114 100644 --- a/packages/eve/src/execution/tools/workflow/step-context.ts +++ b/packages/eve/src/execution/tools/workflow/step-context.ts @@ -1,13 +1,9 @@ import type { SessionContext } from "#context/session-context.js"; import type { AuthorizationResult, AuthorizationSignal } from "#harness/authorization.js"; -import type { - WorkflowToolRunOwner, - WorkflowToolRunRef, -} from "#execution/tools/workflow/messages.js"; export interface WorkflowStepContext { - readonly from: WorkflowToolRunRef; - readonly owner: WorkflowToolRunOwner; + readonly callId: string; + readonly toolName: string; readonly session: SessionContext["session"]; readonly abortSignal: AbortSignal; readonly baseUrl: string; @@ -23,6 +19,6 @@ export type WorkflowStepResult = { readonly authorized: readonly string[] } & ( /** Compiler-owned envelope; authored arguments never select the auth context. */ export interface WorkflowStepInvocation { readonly args: readonly unknown[]; - readonly context?: WorkflowStepContext; - readonly contextIndexes?: readonly number[]; + readonly context: WorkflowStepContext; + readonly contextIndexes: readonly number[]; } diff --git a/packages/eve/src/execution/tools/workflow/step-execution.test.ts b/packages/eve/src/execution/tools/workflow/step-execution.test.ts index be19f7f30f..5cf9672eec 100644 --- a/packages/eve/src/execution/tools/workflow/step-execution.test.ts +++ b/packages/eve/src/execution/tools/workflow/step-execution.test.ts @@ -56,17 +56,8 @@ function context(user = "user-1"): WorkflowStepContext { auth: { current: auth, initiator: auth }, turn: { id: "turn-1", sequence: 1 }, }, - from: { - callId: "call-1", - execution: "background", - input: {}, - runId: "run-1", - sequence: 1, - stepIndex: 0, - toolName: "devbox", - turnId: "turn-1", - }, - owner: { inbox: "owner" }, + callId: "call-1", + toolName: "devbox", }; } @@ -221,8 +212,12 @@ describe("workflow step authorization", () => { it("does not turn an ordinary step result into an authorization signal", async () => { const value = { kind: "authorization-required" }; await expect( - withWorkflowStepAuthorization(async (input) => input)({ args: [value] }), - ).resolves.toBe(value); + withWorkflowStepAuthorization(async (input) => input)({ + args: [value, null], + context: context(), + contextIndexes: [1], + }), + ).resolves.toMatchObject({ kind: "result", output: value }); }); it("never interprets authored arguments as auth context", async () => { @@ -244,8 +239,5 @@ describe("workflow step authorization", () => { contextIndexes: [1], }), ).resolves.toMatchObject({ output: "user-1" }); - await expect( - withWorkflowStepAuthorization(async (input) => input)({ args: [forged] }), - ).resolves.toBe(forged); }); }); diff --git a/packages/eve/src/execution/tools/workflow/step-execution.ts b/packages/eve/src/execution/tools/workflow/step-execution.ts index c3110229f6..b74cdc0a37 100644 --- a/packages/eve/src/execution/tools/workflow/step-execution.ts +++ b/packages/eve/src/execution/tools/workflow/step-execution.ts @@ -24,7 +24,6 @@ export function withWorkflowStepAuthorization(execute: (...args: never[]) => unk invocation: WorkflowStepInvocation, ): Promise { const { args, context: input } = invocation; - if (input === undefined) return Reflect.apply(execute, this, args); getStepMetadata(); const context = new ContextContainer(); context.set(AuthKey, input.session.auth.current); @@ -40,13 +39,13 @@ export function withWorkflowStepAuthorization(execute: (...args: never[]) => unk return contextStorage.run(context, async (): Promise => { const auth = createAuthorizationContext({ - scope: input.from.toolName, + scope: input.toolName, completeAuthorization: completeWorkflowStepAuthorization, }); const ctx = { ...buildBaseToolContext({ - toolName: input.from.toolName, - options: { abortSignal: input.abortSignal, toolCallId: input.from.callId }, + toolName: input.toolName, + options: { abortSignal: input.abortSignal, toolCallId: input.callId }, }), getToken: auth.getToken, requireAuth: auth.requireAuth, @@ -57,7 +56,7 @@ export function withWorkflowStepAuthorization(execute: (...args: never[]) => unk Reflect.apply( execute, this, - args.map((arg, index) => (invocation.contextIndexes?.includes(index) ? ctx : arg)), + args.map((arg, index) => (invocation.contextIndexes.includes(index) ? ctx : arg)), ), ); } catch (error) { diff --git a/packages/eve/src/execution/tools/workflow/step.ts b/packages/eve/src/execution/tools/workflow/step.ts index 213ed770d3..082f51f829 100644 --- a/packages/eve/src/execution/tools/workflow/step.ts +++ b/packages/eve/src/execution/tools/workflow/step.ts @@ -2,7 +2,10 @@ import { createHook, getWorkflowMetadata } from "#compiled/@workflow/core/index. import type { AuthorizationChallenge, AuthorizationResult } from "#harness/authorization.js"; import type { AuthorizationCallback } from "#shared/connection-types.js"; import type { ToolContext } from "#tools/definition.js"; -import { findWorkflowToolRunContext } from "#execution/tools/workflow/ask.js"; +import { + findWorkflowToolRunContext, + type WorkflowToolRunContext, +} from "#execution/tools/workflow/ask.js"; import { disposeHook } from "#execution/hook-ownership.js"; import { resumeHookStep } from "#execution/tools/workflow/resume-hook-step.js"; import { @@ -44,8 +47,8 @@ async function executeAuthorizedStep( for (;;) { const callback = createHook(); const input: WorkflowStepContext = { - from: run.from, - owner: run.owner, + callId: ctx.callId, + toolName: ctx.toolName, session: ctx.session, abortSignal: ctx.abortSignal, baseUrl: getWorkflowMetadata().url, @@ -64,12 +67,13 @@ async function executeAuthorizedStep( } catch (error) { if (!ctx.abortSignal.aborted) for (const challenge of pending.values()) - await reportAuthorization(input, challenge, "failed"); + await reportAuthorization(run, ctx.abortSignal, challenge, "failed"); throw error; } for (const attemptId of result.authorized) { const challenge = pending.get(attemptId); - if (challenge !== undefined) await reportAuthorization(input, challenge, "authorized"); + if (challenge !== undefined) + await reportAuthorization(run, ctx.abortSignal, challenge, "authorized"); pending.delete(attemptId); } for (let i = authorizationResults.length - 1; i >= 0; i--) { @@ -78,12 +82,12 @@ async function executeAuthorizedStep( } if (result.kind === "result") { for (const challenge of pending.values()) - await reportAuthorization(input, challenge, "failed"); + await reportAuthorization(run, ctx.abortSignal, challenge, "failed"); return result.output; } for (const challenge of result.signal.challenges) { pending.set(challenge.attemptId!, challenge); - await reportAuthorization(input, challenge); + await reportAuthorization(run, ctx.abortSignal, challenge); try { const response = await waitForCallback(callback, challenge, ctx.abortSignal); authorizationResults.push({ @@ -97,7 +101,8 @@ async function executeAuthorizedStep( }); } catch (error) { // Cancelled turns close their inbox; cancelled tasks discard further deliveries. - if (!ctx.abortSignal.aborted) await reportAuthorization(input, challenge, "failed"); + if (!ctx.abortSignal.aborted) + await reportAuthorization(run, ctx.abortSignal, challenge, "failed"); throw error; } } @@ -108,33 +113,34 @@ async function executeAuthorizedStep( } async function reportAuthorization( - input: WorkflowStepContext, + run: WorkflowToolRunContext, + signal: AbortSignal, challenge: AuthorizationChallenge, outcome?: "authorized" | "failed", ): Promise { const eventInput = { attemptId: challenge.attemptId, name: challenge.name, - sequence: input.from.sequence, - stepIndex: input.from.stepIndex, - turnId: input.from.turnId, + sequence: run.from.sequence, + stepIndex: run.from.stepIndex, + turnId: run.from.turnId, authorization: challenge.challenge, }; const acknowledged = createHook(); try { await withAbort( - resumeHookStep(input.owner.inbox, { + resumeHookStep(run.owner.inbox, { kind: "request", - from: input.from, + from: run.from, replyTo: acknowledged.token, request: { kind: "authorization-request", stepAuthorization: true, event: { kind: "subagent-authorization-event", - callId: input.from.callId, - childSessionId: input.from.runId, - subagentName: input.from.toolName, + callId: run.from.callId, + childSessionId: run.from.runId, + subagentName: run.from.toolName, event: outcome === undefined ? createAuthorizationRequiredEvent({ @@ -146,9 +152,9 @@ async function reportAuthorization( }, }, }), - input.abortSignal, + signal, ); - await withAbort(acknowledged, input.abortSignal); + await withAbort(acknowledged, signal); } finally { await disposeHook(acknowledged); } From f004de6f1cefb006307d790f8b9a36a53689ccfc Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Sun, 6 Sep 2026 19:19:02 -0400 Subject: [PATCH 12/23] fix(eve): guard workflow task auth against older session drivers Signed-off-by: Rui Conti --- .changeset/workflow-step-authorization.md | 2 +- docs/tools/workflows.mdx | 8 +++ .../execution/session-command-inbox.test.ts | 2 +- .../src/execution/session-command-inbox.ts | 2 + .../parent/tool-execution.integration.test.ts | 30 ++++++++++ .../execution/tasks/parent/tool-execution.ts | 8 +++ .../eve/src/execution/tools/workflow/ask.ts | 1 + .../src/execution/tools/workflow/body.test.ts | 42 +++++++++++++- .../eve/src/execution/tools/workflow/body.ts | 8 ++- .../execution/tools/workflow/step-context.ts | 1 + .../tools/workflow/step-execution.test.ts | 36 ++++++++++++ .../tools/workflow/step-execution.ts | 16 ++++- .../eve/src/execution/tools/workflow/step.ts | 1 + .../workflow-tool-run.integration.test.ts | 58 +++++++++++++++++++ .../execution/wire/session-inbox-contract.ts | 3 + 15 files changed, 211 insertions(+), 7 deletions(-) diff --git a/.changeset/workflow-step-authorization.md b/.changeset/workflow-step-authorization.md index 011d562c56..a7093fea5e 100644 --- a/.changeset/workflow-step-authorization.md +++ b/.changeset/workflow-step-authorization.md @@ -2,4 +2,4 @@ "eve": patch --- -Workflow tools can use the same requester-scoped `ctx.getToken` and `ctx.requireAuth` as ordinary tools inside step helpers. Connections, tools, and workflow steps share authorization handling; workflow sign-in waits without holding compute and retries the interrupted step after the callback, including for background tasks. +Workflow tools can use the same requester-scoped `ctx.getToken` and `ctx.requireAuth` as ordinary tools inside step helpers, automatically waiting for sign-in and retrying the interrupted step. In background workflows, these APIs require a supporting session driver; older conversations fail before calling the auth provider with an instruction to start a new session. diff --git a/docs/tools/workflows.mdx b/docs/tools/workflows.mdx index fedf94c973..0bfeadf9e6 100644 --- a/docs/tools/workflows.mdx +++ b/docs/tools/workflows.mdx @@ -197,6 +197,14 @@ not rely on the launching agent turn still being active. Cancelling an authoriza its callback. Cancellation uses the existing turn or task cancellation path rather than a separate authorization completion event. +Background workflow auth requires a session driver that supports displaying workflow-task +sign-in events. A conversation created before this support was deployed keeps its older driver, +even when a later turn runs newer code. The launching turn reads the driver's advertised support. +If it is absent, calling `ctx.getToken` or `ctx.requireAuth` in the background workflow fails without +retrying or calling the auth provider, with an instruction to start a new session. Background +workflows that do not use these methods continue to work; blocking workflow tools use their owning +turn's handler. + ## Ask a human: `ctx.ask` ```ts diff --git a/packages/eve/src/execution/session-command-inbox.test.ts b/packages/eve/src/execution/session-command-inbox.test.ts index d105dafae2..9cca2d3231 100644 --- a/packages/eve/src/execution/session-command-inbox.test.ts +++ b/packages/eve/src/execution/session-command-inbox.test.ts @@ -123,7 +123,7 @@ describe("createSessionCommandInbox", () => { ); expect(createHookMock).toHaveBeenCalledOnce(); expect(createHookMock).toHaveBeenCalledWith({ - metadata: { sessionInboxWireVersion: 6 }, + metadata: { sessionInboxWireVersion: 6, workflowTaskAuthorization: true }, token: "stable", }); await inbox.dispose(); diff --git a/packages/eve/src/execution/session-command-inbox.ts b/packages/eve/src/execution/session-command-inbox.ts index 14ee3da183..2f4c43b6b0 100644 --- a/packages/eve/src/execution/session-command-inbox.ts +++ b/packages/eve/src/execution/session-command-inbox.ts @@ -10,6 +10,7 @@ import { claimHookOwnership, disposeHook } from "#execution/hook-ownership.js"; import { SESSION_INBOX_WIRE_VERSION, SESSION_INBOX_WIRE_VERSION_METADATA_KEY, + WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY, } from "#execution/wire/session-inbox-contract.js"; /** * Payloads accepted by a session driver's stable and channel aliases. @@ -138,6 +139,7 @@ export function createSessionCommandInbox(): SessionCommandInboxHandle { const hook = createHook({ metadata: { [SESSION_INBOX_WIRE_VERSION_METADATA_KEY]: SESSION_INBOX_WIRE_VERSION, + [WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY]: true, }, token, }); diff --git a/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts b/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts index e6030f7e69..e92f7ae085 100644 --- a/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts +++ b/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHookByToken } from "#internal/workflow/runtime.js"; import { ContextContainer, contextStorage } from "#context/container.js"; import { SessionKey } from "#context/keys.js"; import { cancelOwnedTask } from "#execution/tasks/parent/dispatch.js"; @@ -20,6 +21,11 @@ import { getAgentHandleStore, setAgentHandleStore } from "#subagents/handles/sto import { applyTaskAgentHandleCommand } from "#subagents/handles/transitions.js"; import { getSessionTaskIndex, recordSessionTask } from "#tasks/session-index.js"; +vi.mock("#internal/workflow/runtime.js", async (importOriginal) => ({ + ...(await importOriginal()), + getHookByToken: vi.fn(), +})); + vi.mock("#execution/tasks/parent/dispatch.js", () => ({ cancelOwnedTask: vi.fn() })); vi.mock("#execution/tools/subagent/task-cancel.js", () => ({ cancelBackgroundAgentTask: vi.fn() })); vi.mock("#execution/tasks/parent/run-parent.js", () => ({ @@ -116,11 +122,35 @@ async function createScope(session = createSession()) { describe("background subagent steering", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(getHookByToken).mockResolvedValue({ + metadata: { workflowTaskAuthorization: true }, + } as never); vi.mocked(cancelOwnedTask).mockResolvedValue(cancelledView); vi.mocked(startTaskRun).mockResolvedValue(undefined as never); vi.mocked(waitForTaskCommandOwner).mockResolvedValue({ runId: "steering-task-run" } as never); }); + it.each([ + { metadata: undefined, supported: false }, + { metadata: { sessionInboxWireVersion: 6 }, supported: false }, + { metadata: { workflowTaskAuthorization: false }, supported: false }, + { metadata: { workflowTaskAuthorization: "true" }, supported: false }, + { metadata: { workflowTaskAuthorization: true }, supported: true }, + ])( + "passes the receiving driver's auth capability to the task ($supported)", + async ({ metadata, supported }) => { + vi.mocked(getHookByToken).mockResolvedValue({ metadata } as never); + const scope = await createScope(); + await expect(scope.execute()).resolves.toMatchObject({ status: "working" }); + expect(getHookByToken).toHaveBeenCalledWith("eve:session:parent:inbox"); + expect(startTaskRun).toHaveBeenCalledWith( + expect.objectContaining({ + workflow: expect.objectContaining({ authorizationSupported: supported }), + }), + ); + }, + ); + it("cancels the old task before starting a new task in the same child", async () => { const scope = await createScope(); const cancellation = Promise.withResolvers(); diff --git a/packages/eve/src/execution/tasks/parent/tool-execution.ts b/packages/eve/src/execution/tasks/parent/tool-execution.ts index 808707e654..c2aed4e4c3 100644 --- a/packages/eve/src/execution/tasks/parent/tool-execution.ts +++ b/packages/eve/src/execution/tasks/parent/tool-execution.ts @@ -1,4 +1,7 @@ import type { ContextContainer } from "#context/container.js"; +import { getHookByToken } from "#internal/workflow/runtime.js"; +import { WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY } from "#execution/wire/session-inbox-contract.js"; +import { isObject } from "#shared/guards.js"; import { loadContext } from "#context/container.js"; import { ActivityObserverKey } from "#context/keys.js"; import type { FrameworkContextProvider } from "#context/provider.js"; @@ -453,12 +456,17 @@ class BackgroundToolExecutionScope implements BackgroundToolExecutor { }; } } + const driver = await getHookByToken(sessionCommandHookToken(this.initialSession.sessionId)); + const authorizationSupported = + isObject(driver.metadata) && + driver.metadata[WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY] === true; await startTaskRun({ activityObserver: taskInput.activityObserver, initialView: { metadata: task.metadata, status: "working", taskId: task.taskId }, parentContinuationToken: sessionCommandHookToken(this.initialSession.sessionId), taskInboxToken: task.taskInboxToken, workflow: { + authorizationSupported, callId: taskInput.callId, executeInput: workflow.executeInput?.(workflowInput), input: workflowInput, diff --git a/packages/eve/src/execution/tools/workflow/ask.ts b/packages/eve/src/execution/tools/workflow/ask.ts index 913eaeaf5e..ace2d7b368 100644 --- a/packages/eve/src/execution/tools/workflow/ask.ts +++ b/packages/eve/src/execution/tools/workflow/ask.ts @@ -13,6 +13,7 @@ import { workflowToolContextErrorMessage } from "#shared/workflow-tool-context.j const WORKFLOW_TOOL_RUN_CONTEXT = Symbol.for("eve.workflow-tool-run.context"); export interface WorkflowToolRunContext { + readonly authorizationSupported?: boolean; /** Compatibility for already-started two-run background workflows. */ readonly admission?: Promise< { readonly status: "accepted" } | { readonly status: "rejected"; readonly reason: string } diff --git a/packages/eve/src/execution/tools/workflow/body.test.ts b/packages/eve/src/execution/tools/workflow/body.test.ts index 881006d6b9..8519542117 100644 --- a/packages/eve/src/execution/tools/workflow/body.test.ts +++ b/packages/eve/src/execution/tools/workflow/body.test.ts @@ -2,7 +2,10 @@ import { expect, it, vi } from "vitest"; import type { ToolContext } from "#tools/definition.js"; import type { WorkflowToolContext } from "#tools/workflow-definition.js"; import { executeWorkflowBody, type WorkflowBodyInput } from "#execution/tools/workflow/body.js"; -import { readWorkflowToolRunRef } from "#execution/tools/workflow/ask.js"; +import { + findWorkflowToolRunContext, + readWorkflowToolRunRef, +} from "#execution/tools/workflow/ask.js"; const mocks = vi.hoisted(() => ({ execute: vi.fn(), agent: vi.fn(), ask: vi.fn() })); vi.mock("#execution/workflow-registry.js", () => ({ readRegisteredWorkflow: () => mocks.execute })); @@ -43,3 +46,40 @@ it("binds workflow-only methods to the run context", async () => { reportCount: 0, }); }); + +it.each([ + { execution: "background", authorizationSupported: undefined, expected: false }, + { execution: "background", authorizationSupported: false, expected: false }, + { execution: "background", authorizationSupported: true, expected: true }, + { execution: "blocking", authorizationSupported: undefined, expected: true }, +] as const)( + "binds auth support to the actual owner ($execution, $authorizationSupported)", + async ({ execution, authorizationSupported, expected }) => { + mocks.execute.mockImplementation(async (_input, ctx) => { + expect(findWorkflowToolRunContext(ctx)?.authorizationSupported).toBe(expected); + return "done"; + }); + await expect( + executeWorkflowBody( + { + authorizationSupported, + callId: "call", + input: {}, + session: { + id: "session", + auth: { current: null, initiator: null }, + turn: { id: "turn", sequence: 1 }, + }, + stepIndex: 0, + taskId: "task", + toolName: "deploy", + workflowId: "workflow//test//execute", + owner: { inbox: "inbox" }, + execution, + runId: "run", + }, + new AbortController().signal, + ), + ).resolves.toMatchObject({ outcome: { status: "completed", output: "done" } }); + }, +); diff --git a/packages/eve/src/execution/tools/workflow/body.ts b/packages/eve/src/execution/tools/workflow/body.ts index 8ca943b6e5..e901539287 100644 --- a/packages/eve/src/execution/tools/workflow/body.ts +++ b/packages/eve/src/execution/tools/workflow/body.ts @@ -18,6 +18,8 @@ import type { ToolContext } from "#tools/definition.js"; import { createTaskMessage, type TaskExec } from "#tools/task.js"; export interface WorkflowBodyDefinition { + /** Advertised by the parent driver; absent on runs started before this capability. */ + readonly authorizationSupported?: boolean; readonly callId: string; readonly executeInput?: JsonValue; readonly input: JsonObject; @@ -54,7 +56,11 @@ export async function executeWorkflowBody( ): Promise { const from = createWorkflowBodyRef(input); const ctx = createWorkflowBodyContext(input, signal); - attachWorkflowToolRunContext(ctx, { from, owner: input.owner }); + attachWorkflowToolRunContext(ctx, { + from, + owner: input.owner, + authorizationSupported: input.execution === "blocking" || input.authorizationSupported === true, + }); let reportCount = 0; try { diff --git a/packages/eve/src/execution/tools/workflow/step-context.ts b/packages/eve/src/execution/tools/workflow/step-context.ts index 0829ec9114..f406666849 100644 --- a/packages/eve/src/execution/tools/workflow/step-context.ts +++ b/packages/eve/src/execution/tools/workflow/step-context.ts @@ -2,6 +2,7 @@ import type { SessionContext } from "#context/session-context.js"; import type { AuthorizationResult, AuthorizationSignal } from "#harness/authorization.js"; export interface WorkflowStepContext { + readonly authorizationSupported: boolean; readonly callId: string; readonly toolName: string; readonly session: SessionContext["session"]; diff --git a/packages/eve/src/execution/tools/workflow/step-execution.test.ts b/packages/eve/src/execution/tools/workflow/step-execution.test.ts index 5cf9672eec..a311debdc7 100644 --- a/packages/eve/src/execution/tools/workflow/step-execution.test.ts +++ b/packages/eve/src/execution/tools/workflow/step-execution.test.ts @@ -47,6 +47,7 @@ function context(user = "user-1"): WorkflowStepContext { principalType: "user" as const, }; return { + authorizationSupported: true, baseUrl: "https://agent.example", token: `callback-${user}`, authorizationResults: [], @@ -79,6 +80,41 @@ describe("workflow step authorization", () => { durable.entries.clear(); }); afterEach(() => vi.unstubAllEnvs()); + it.each(["getToken", "requireAuth"] as const)( + "rejects %s before calling the provider when the parent driver lacks authorization support", + async (method) => { + const provider = { + principalType: "user" as const, + getToken: vi.fn(async () => ({ token: "secret" })), + startAuthorization: vi.fn(), + completeAuthorization: vi.fn(), + }; + await expect( + runStep(async (ctx) => ctx[method](provider), { + ...context(), + authorizationSupported: false, + }), + ).rejects.toMatchObject({ + fatal: true, + retryable: false, + reason: "workflow_task_authorization_unsupported", + message: expect.stringContaining("Start a new session"), + }); + expect(provider.getToken).not.toHaveBeenCalled(); + expect(provider.startAuthorization).not.toHaveBeenCalled(); + expect(provider.completeAuthorization).not.toHaveBeenCalled(); + }, + ); + + it("runs steps that do not use auth when the parent driver lacks authorization support", async () => { + await expect( + runStep(async (ctx) => ({ session: ctx.session.id }), { + ...context(), + authorizationSupported: false, + }), + ).resolves.toMatchObject({ kind: "result", output: { session: "session-1" } }); + }); + it("does not exchange a consumed code again when the rest of the step retries", async () => { let exchanged = false; const complete = vi.fn(async () => { diff --git a/packages/eve/src/execution/tools/workflow/step-execution.ts b/packages/eve/src/execution/tools/workflow/step-execution.ts index b74cdc0a37..9df0c2699b 100644 --- a/packages/eve/src/execution/tools/workflow/step-execution.ts +++ b/packages/eve/src/execution/tools/workflow/step-execution.ts @@ -1,7 +1,10 @@ import { getStepMetadata } from "#compiled/@workflow/core/index.js"; import { ContextContainer, contextStorage } from "#context/container.js"; import { AuthKey, InitiatorAuthKey, SessionIdKey, SessionKey } from "#context/keys.js"; -import { isConnectionAuthorizationFailedError } from "#connections/errors.js"; +import { + ConnectionAuthorizationFailedError, + isConnectionAuthorizationFailedError, +} from "#connections/errors.js"; import { isAuthorizationSignal, PendingAuthorizationResultKey, @@ -42,13 +45,20 @@ export function withWorkflowStepAuthorization(execute: (...args: never[]) => unk scope: input.toolName, completeAuthorization: completeWorkflowStepAuthorization, }); + const unavailable = (): never => { + throw new ConnectionAuthorizationFailedError(input.toolName, { + message: `Background workflow tool "${input.toolName}" cannot use ctx.getToken or ctx.requireAuth in this session because its driver predates workflow-task authorization. Start a new session and try again.`, + reason: "workflow_task_authorization_unsupported", + retryable: false, + }); + }; const ctx = { ...buildBaseToolContext({ toolName: input.toolName, options: { abortSignal: input.abortSignal, toolCallId: input.callId }, }), - getToken: auth.getToken, - requireAuth: auth.requireAuth, + getToken: input.authorizationSupported ? auth.getToken : unavailable, + requireAuth: input.authorizationSupported ? auth.requireAuth : unavailable, }; let output: unknown; try { diff --git a/packages/eve/src/execution/tools/workflow/step.ts b/packages/eve/src/execution/tools/workflow/step.ts index 082f51f829..109caa3ea6 100644 --- a/packages/eve/src/execution/tools/workflow/step.ts +++ b/packages/eve/src/execution/tools/workflow/step.ts @@ -47,6 +47,7 @@ async function executeAuthorizedStep( for (;;) { const callback = createHook(); const input: WorkflowStepContext = { + authorizationSupported: run.authorizationSupported === true, callId: ctx.callId, toolName: ctx.toolName, session: ctx.session, diff --git a/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts b/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts index b1a610fdf4..46573b60a1 100644 --- a/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts +++ b/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts @@ -142,6 +142,64 @@ function eventsText(events: readonly { readonly data?: unknown }[]): string { } describe("workflow step authorization", () => { + it.each([false, true])( + "handles a driver without the auth capability (background=%s)", + async (background) => { + const runtime = await createWorkflowToolRuntime({ + agentName: "workflow-step-old-driver", + background, + execute: authorizedDeployWorkflow, + toolName: "deploy_service", + }); + await runtime.run(async () => { + const world = await getWorld(); + const getByToken = world.hooks.getByToken.bind(world.hooks); + // Reproduce the old driver's persisted advertisement, without replacing ctx APIs. + const legacyDriver = vi + .spyOn(world.hooks, "getByToken") + .mockImplementation(async (...args) => { + const hook = await getByToken(...args); + return args[0] === sessionCommandHookToken(hook.runId) + ? { ...hook, metadata: undefined } + : hook; + }); + const run = await start(workflowEntry, [ + { + input: { message: 'Run deploy_service with service "preauthorized"' }, + serializedContext: { + ...buildSerializedContext({ + continuationToken: "http:step-old-driver", + mode: "conversation", + }), + "eve.auth": { + attributes: {}, + authenticator: "test-idp", + issuer: "test-idp", + principalId: "user-1", + principalType: "user", + }, + }, + }, + ]); + const stream = captureTurnEvents(run); + try { + const expected = background ? "Start a new session" : "authenticatedAs"; + const events = []; + for (let i = 0; i < 5 && !JSON.stringify(events).includes(expected); i++) + events.push(...(await stream.nextTurn())); + expect(JSON.stringify(events)).toContain(expected); + expect(filterEventsByType(events, "authorization.required")).toHaveLength(0); + expect(JSON.stringify(events)).not.toContain("secret:"); + } finally { + stream.dispose(); + await run.cancel(); + legacyDriver.mockRestore(); + } + }); + }, + 60_000, + ); + it.each([false, true])( "resolves a user token inside a step (background=%s)", async (background) => { diff --git a/packages/eve/src/execution/wire/session-inbox-contract.ts b/packages/eve/src/execution/wire/session-inbox-contract.ts index b50fffbb4a..707e0b1f2c 100644 --- a/packages/eve/src/execution/wire/session-inbox-contract.ts +++ b/packages/eve/src/execution/wire/session-inbox-contract.ts @@ -10,6 +10,9 @@ export const SESSION_INBOX_WIRE_VERSION = /** Hook metadata field advertising the consumer's inbox wire capability. */ export const SESSION_INBOX_WIRE_VERSION_METADATA_KEY = "sessionInboxWireVersion"; +/** The driver can display authorization events emitted by a workflow task itself. */ +export const WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY = "workflowTaskAuthorization"; + export const SESSION_INBOX_CONTEXT_KEY = "eve.sessionInbox"; /** Immutable inbox coordinates advertised by the receiving session's driver. */ From 75c350af1d8b63a1fef436b602cd153ac7b6b723 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 8 Sep 2026 00:33:14 -0400 Subject: [PATCH 13/23] refactor(eve): clarify workflow authorization docs and evals Signed-off-by: Rui Conti --- docs/tools/workflows.mdx | 17 ++-- .../evals/agent-probe.shared.ts | 87 +++++++++++++------ .../evals/step-auth.explicit.eval.ts | 6 +- .../evals/step-auth.implicit.eval.ts | 6 +- .../evals/step-auth.rejected.eval.ts | 4 +- .../eve/src/execution/tasks/child/workflow.ts | 3 +- .../tools/subagent/accept-event-step.ts | 5 +- .../execution/wire/session-inbox-contract.ts | 5 +- 8 files changed, 90 insertions(+), 43 deletions(-) diff --git a/docs/tools/workflows.mdx b/docs/tools/workflows.mdx index 0bfeadf9e6..4b3fdd280d 100644 --- a/docs/tools/workflows.mdx +++ b/docs/tools/workflows.mdx @@ -176,8 +176,11 @@ authorization machinery for requester identity, token caching, callback completi after sign-in. The execution runtime owns the wait: an agent returns to its model after authorization, while a workflow retries the interrupted step and continues the authored body. -The workflow body calls `await readRepository(ctx, repository)`. Do not return the token from the -helper: step results enter the workflow's durable history. eve's token cache stays inside the step. +The workflow body calls `await readRepository(ctx, repository)`. + +> [!WARNING] +> Do not return the token from the helper: step results enter the workflow's durable history. +> eve's token cache stays inside the step. When sign-in is required, the step attempt ends and the workflow waits on its own callback hook, without holding compute. The channel renders the sign-in challenge. After the callback, eve retries @@ -197,13 +200,9 @@ not rely on the launching agent turn still being active. Cancelling an authoriza its callback. Cancellation uses the existing turn or task cancellation path rather than a separate authorization completion event. -Background workflow auth requires a session driver that supports displaying workflow-task -sign-in events. A conversation created before this support was deployed keeps its older driver, -even when a later turn runs newer code. The launching turn reads the driver's advertised support. -If it is absent, calling `ctx.getToken` or `ctx.requireAuth` in the background workflow fails without -retrying or calling the auth provider, with an instruction to start a new session. Background -workflows that do not use these methods continue to work; blocking workflow tools use their owning -turn's handler. +Background workflow authorization requires a new session after upgrading. In older sessions, +`ctx.getToken` and `ctx.requireAuth` fail immediately with an instruction to start a new session. +Blocking workflows and background workflows without auth are unaffected. ## Ask a human: `ctx.ask` diff --git a/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts b/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts index 6537148e0d..78800bb4d1 100644 --- a/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts +++ b/e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts @@ -1,4 +1,5 @@ -import type { EveEvalContext, EveEvalSession, EveEvalTurn } from "eve/evals"; +import type { EveEvalContext, EveEvalSession, EveEvalStreamEvent, EveEvalTurn } from "eve/evals"; + import { fixtureAuthorizationCallback } from "../agent/lib/fake-service.ts"; export type ProbeCase = { readonly kind: "auth" | "hitl" }; @@ -21,9 +22,15 @@ export async function runProbe(t: EveEvalContext, probe: ProbeCase): Promise { let session = initial; + for (let attempt = 0; attempt < 10; attempt += 1) { if (session.pendingInputRequests.some((request) => request.action.toolName === toolName)) { session.requireInputRequest({ toolName }); return session; } + const live = watchNext(t, session); const turn = await live.result(); turn.noFailedActions(); session = live.session; } + throw new Error(`Probe did not surface input for ${toolName}.`); } @@ -57,15 +67,23 @@ async function waitForMarker( initialTurn: EveEvalTurn | undefined, marker: string, ): Promise { - if (initialTurn?.message?.includes(marker) === true) return initialTurn; + if (initialTurn?.message?.includes(marker)) { + return initialTurn; + } + let session = initial; + for (let attempt = 0; attempt < 10; attempt += 1) { const live = watchNext(t, session); const turn = await live.result(); turn.noFailedActions(); - if (turn.message?.includes(marker) === true) return turn; + if (turn.message?.includes(marker)) { + return turn; + } + session = live.session; } + throw new Error(`Probe did not produce ${marker}.`); } @@ -74,24 +92,30 @@ async function waitForEvent; + readonly event: EveEvalStreamEvent; readonly session: SessionCursor; }> { let session = initial; let turn = initialTurn; + for (let attempt = 0; attempt < 10; attempt += 1) { const event = turn?.events.find( - (candidate): candidate is Extract => - candidate.type === type, + (candidate): candidate is EveEvalStreamEvent => candidate.type === type, ); - if (event !== undefined) return { event, session }; + if (event !== undefined) { + return { event, session }; + } + const live = watchNext(t, session); turn = await live.result(); - if (expectNoFailedActions) turn.noFailedActions(); + if (!options.allowFailedActions) { + turn.noFailedActions(); + } session = live.session; } + throw new Error(`Probe did not surface ${type}.`); } @@ -102,12 +126,19 @@ function watchNext(t: EveEvalContext, session: SessionCursor) { return t.target.watchTurn(session.sessionId, { startIndex: session.state.streamIndex }); } -export async function runStepAuth(t: EveEvalContext, explicit: boolean): Promise { - const started = await t.send(`WORKFLOW-STEP-AUTH-${explicit ? "EXPLICIT" : "IMPLICIT"}`); +export async function runStepAuth( + t: EveEvalContext, + scenario: "EXPLICIT" | "IMPLICIT", +): Promise { + const started = await t.send(`WORKFLOW-STEP-AUTH-${scenario}`); const required = await waitForEvent(t, t, started, "authorization.required"); + const url = fixtureAuthorizationCallback(t.target.url, required.event.data.authorization?.url); const response = await fetch(url); - if (!response.ok) throw new Error(`Authorization callback failed (${response.status})`); + if (!response.ok) { + throw new Error(`Authorization callback failed (${response.status}).`); + } + await waitForEvent(t, required.session, undefined, "authorization.completed"); await waitForMarker(t, required.session, undefined, "WORKFLOW-STEP-AUTH:authorized"); t.noFailedActions(); @@ -116,18 +147,22 @@ export async function runStepAuth(t: EveEvalContext, explicit: boolean): Promise export async function runRejectedStepAuth(t: EveEvalContext): Promise { const started = await t.send("WORKFLOW-STEP-AUTH-REJECTED"); const required = await waitForEvent(t, t, started, "authorization.required"); + const url = fixtureAuthorizationCallback(t.target.url, required.event.data.authorization?.url); const response = await fetch(url); - if (!response.ok) throw new Error(`Authorization callback failed (${response.status})`); - const completed = await waitForEvent( - t, - required.session, - undefined, - "authorization.completed", - false, - ); - if (completed.event.data.outcome !== "failed") - throw new Error("A freshly rejected token must fail authorization"); - if ((await fetch(url)).status !== 404) - throw new Error("The completed authorization callback must be disposed"); + if (!response.ok) { + throw new Error(`Authorization callback failed (${response.status}).`); + } + + const completed = await waitForEvent(t, required.session, undefined, "authorization.completed", { + allowFailedActions: true, + }); + if (completed.event.data.outcome !== "failed") { + throw new Error("A token rejected immediately after sign-in must fail authorization."); + } + + const repeatedCallback = await fetch(url); + if (repeatedCallback.status !== 404) { + throw new Error("The completed authorization callback must be disposed."); + } } diff --git a/e2e/fixtures/agent-workflow-tools/evals/step-auth.explicit.eval.ts b/e2e/fixtures/agent-workflow-tools/evals/step-auth.explicit.eval.ts index c92ccc8eaf..4087672d9b 100644 --- a/e2e/fixtures/agent-workflow-tools/evals/step-auth.explicit.eval.ts +++ b/e2e/fixtures/agent-workflow-tools/evals/step-auth.explicit.eval.ts @@ -1,10 +1,12 @@ import { defineEval } from "eve/evals"; + import { runStepAuth } from "./agent-probe.shared.ts"; export default defineEval({ - description: "Workflow step requireAuth parks for sign-in and resumes under the requester.", + description: "A rejected token triggers sign-in through ctx.requireAuth, then the step succeeds.", timeoutMs: 90_000, + async test(t) { - await runStepAuth(t, true); + await runStepAuth(t, "EXPLICIT"); }, }); diff --git a/e2e/fixtures/agent-workflow-tools/evals/step-auth.implicit.eval.ts b/e2e/fixtures/agent-workflow-tools/evals/step-auth.implicit.eval.ts index 4312f9c0f0..571f5012ee 100644 --- a/e2e/fixtures/agent-workflow-tools/evals/step-auth.implicit.eval.ts +++ b/e2e/fixtures/agent-workflow-tools/evals/step-auth.implicit.eval.ts @@ -1,10 +1,12 @@ import { defineEval } from "eve/evals"; + import { runStepAuth } from "./agent-probe.shared.ts"; export default defineEval({ - description: "Workflow step getToken parks for sign-in and resumes under the requester.", + description: "A missing token triggers sign-in through ctx.getToken, then the step succeeds.", timeoutMs: 90_000, + async test(t) { - await runStepAuth(t, false); + await runStepAuth(t, "IMPLICIT"); }, }); diff --git a/e2e/fixtures/agent-workflow-tools/evals/step-auth.rejected.eval.ts b/e2e/fixtures/agent-workflow-tools/evals/step-auth.rejected.eval.ts index 2ff24580f2..70eb9e6284 100644 --- a/e2e/fixtures/agent-workflow-tools/evals/step-auth.rejected.eval.ts +++ b/e2e/fixtures/agent-workflow-tools/evals/step-auth.rejected.eval.ts @@ -1,10 +1,12 @@ import { defineEval } from "eve/evals"; + import { runRejectedStepAuth } from "./agent-probe.shared.ts"; export default defineEval({ description: - "Workflow step authorization fails when a fresh token is rejected, without another sign-in prompt.", + "If the service rejects the token after sign-in, authorization fails without prompting again.", timeoutMs: 90_000, + async test(t) { await runRejectedStepAuth(t); }, diff --git a/packages/eve/src/execution/tasks/child/workflow.ts b/packages/eve/src/execution/tasks/child/workflow.ts index 14437f5c89..744b32b0ac 100644 --- a/packages/eve/src/execution/tasks/child/workflow.ts +++ b/packages/eve/src/execution/tasks/child/workflow.ts @@ -274,7 +274,8 @@ export async function taskRunWorkflow(input: TaskRunWorkflowInput): Promise Date: Tue, 8 Sep 2026 00:46:08 -0400 Subject: [PATCH 14/23] refactor(eve): advertise workflow auth through session capabilities Signed-off-by: Rui Conti --- packages/eve/src/channel/types.ts | 9 ++- .../eve/src/execution/runtime-context.test.ts | 21 +++++ packages/eve/src/execution/runtime-context.ts | 3 +- .../execution/session-command-inbox.test.ts | 2 +- .../src/execution/session-command-inbox.ts | 2 - .../parent/tool-execution.integration.test.ts | 33 +++----- .../execution/tasks/parent/tool-execution.ts | 11 +-- .../workflow-tool-run.integration.test.ts | 80 +++++++++---------- .../execution/wire/session-inbox-contract.ts | 6 -- .../eve/src/execution/workflow-entry.test.ts | 27 ++++++- packages/eve/src/execution/workflow-entry.ts | 10 ++- .../testing/workflow-tool-fixtures.ts | 10 +++ 12 files changed, 128 insertions(+), 86 deletions(-) diff --git a/packages/eve/src/channel/types.ts b/packages/eve/src/channel/types.ts index cca4a3e8a4..631b140a4e 100644 --- a/packages/eve/src/channel/types.ts +++ b/packages/eve/src/channel/types.ts @@ -426,10 +426,17 @@ export interface SessionCallback { * * Channel routes that can reach a human (HTTP, Slack, etc.) set * `requestInput: true` when starting a run. Subagent dispatch inherits the - * parent's capabilities pointwise, so HITL bubbles up transparently through a + * parent's input capability, so HITL bubbles up transparently through a * conversation chain and stays disabled in a scheduled chain. */ export interface SessionCapabilities { + /** + * The session driver supports authorization events from background workflow tools. + * Set by the driver, never inherited or granted through RunInput. Older drivers + * leave this absent, so newer turns fail fast when these tools request auth. + */ + readonly workflowTaskAuthorization?: boolean; + /** * True when the session may request input from a human (tool approvals, * `ask_question`). The runtime reads this in every HITL gate: diff --git a/packages/eve/src/execution/runtime-context.test.ts b/packages/eve/src/execution/runtime-context.test.ts index 94799a58fb..eea1e703ed 100644 --- a/packages/eve/src/execution/runtime-context.test.ts +++ b/packages/eve/src/execution/runtime-context.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { ContextContainer, contextStorage, loadContext } from "#context/container.js"; import { AuthKey, + CapabilitiesKey, ChannelInstrumentationKey, ContinuationTokenKey, ParentTraceContextKey, @@ -151,6 +152,26 @@ function createMinimalBundle(): Parameters[0]["bundle"] } describe("buildRunContext", () => { + it.each(["http", "subagent"])("does not inherit driver support from a %s caller", (kind) => { + const capabilities = { requestInput: true, workflowTaskAuthorization: true }; + const ctx = buildRunContext({ + bundle: createMinimalBundle(), + run: { + adapter: { kind }, + auth: null, + capabilities, + input: { message: "hello" }, + mode: "conversation", + }, + }); + + expect(ctx.get(CapabilitiesKey)).toEqual({ + requestInput: true, + workflowTaskAuthorization: false, + }); + expect(capabilities.workflowTaskAuthorization).toBe(true); + }); + it("seeds auth from the run input", () => { const ctx = buildRunContext({ bundle: createMinimalBundle(), diff --git a/packages/eve/src/execution/runtime-context.ts b/packages/eve/src/execution/runtime-context.ts index d45c0e37ad..5e075b9789 100644 --- a/packages/eve/src/execution/runtime-context.ts +++ b/packages/eve/src/execution/runtime-context.ts @@ -56,7 +56,8 @@ export function buildRunContext(input: { } if (run.capabilities !== undefined) { - ctx.set(CapabilitiesKey, run.capabilities); + // Driver support belongs to this session, not the caller or parent session. + ctx.set(CapabilitiesKey, { ...run.capabilities, workflowTaskAuthorization: false }); } if (run.requestId !== undefined) { diff --git a/packages/eve/src/execution/session-command-inbox.test.ts b/packages/eve/src/execution/session-command-inbox.test.ts index 9cca2d3231..d105dafae2 100644 --- a/packages/eve/src/execution/session-command-inbox.test.ts +++ b/packages/eve/src/execution/session-command-inbox.test.ts @@ -123,7 +123,7 @@ describe("createSessionCommandInbox", () => { ); expect(createHookMock).toHaveBeenCalledOnce(); expect(createHookMock).toHaveBeenCalledWith({ - metadata: { sessionInboxWireVersion: 6, workflowTaskAuthorization: true }, + metadata: { sessionInboxWireVersion: 6 }, token: "stable", }); await inbox.dispose(); diff --git a/packages/eve/src/execution/session-command-inbox.ts b/packages/eve/src/execution/session-command-inbox.ts index 2f4c43b6b0..14ee3da183 100644 --- a/packages/eve/src/execution/session-command-inbox.ts +++ b/packages/eve/src/execution/session-command-inbox.ts @@ -10,7 +10,6 @@ import { claimHookOwnership, disposeHook } from "#execution/hook-ownership.js"; import { SESSION_INBOX_WIRE_VERSION, SESSION_INBOX_WIRE_VERSION_METADATA_KEY, - WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY, } from "#execution/wire/session-inbox-contract.js"; /** * Payloads accepted by a session driver's stable and channel aliases. @@ -139,7 +138,6 @@ export function createSessionCommandInbox(): SessionCommandInboxHandle { const hook = createHook({ metadata: { [SESSION_INBOX_WIRE_VERSION_METADATA_KEY]: SESSION_INBOX_WIRE_VERSION, - [WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY]: true, }, token, }); diff --git a/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts b/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts index e92f7ae085..f3c4b94599 100644 --- a/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts +++ b/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getHookByToken } from "#internal/workflow/runtime.js"; +import type { SessionCapabilities } from "#channel/types.js"; import { ContextContainer, contextStorage } from "#context/container.js"; -import { SessionKey } from "#context/keys.js"; +import { CapabilitiesKey, SessionKey } from "#context/keys.js"; import { cancelOwnedTask } from "#execution/tasks/parent/dispatch.js"; import { startTaskRun, waitForTaskCommandOwner } from "#execution/tasks/parent/run-parent.js"; import { @@ -21,11 +21,6 @@ import { getAgentHandleStore, setAgentHandleStore } from "#subagents/handles/sto import { applyTaskAgentHandleCommand } from "#subagents/handles/transitions.js"; import { getSessionTaskIndex, recordSessionTask } from "#tasks/session-index.js"; -vi.mock("#internal/workflow/runtime.js", async (importOriginal) => ({ - ...(await importOriginal()), - getHookByToken: vi.fn(), -})); - vi.mock("#execution/tasks/parent/dispatch.js", () => ({ cancelOwnedTask: vi.fn() })); vi.mock("#execution/tools/subagent/task-cancel.js", () => ({ cancelBackgroundAgentTask: vi.fn() })); vi.mock("#execution/tasks/parent/run-parent.js", () => ({ @@ -76,8 +71,12 @@ function createSession(owned = true): HarnessSession { return owned ? recordSessionTask(session, entry) : session; } -async function createScope(session = createSession()) { +async function createScope( + session = createSession(), + capabilities: SessionCapabilities | undefined = undefined, +) { const ctx = new ContextContainer(); + if (capabilities !== undefined) ctx.set(CapabilitiesKey, capabilities); ctx.setVirtualContext(SessionKey, { auth: { current: null, initiator: null }, sessionId: session.sessionId, @@ -122,27 +121,21 @@ async function createScope(session = createSession()) { describe("background subagent steering", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getHookByToken).mockResolvedValue({ - metadata: { workflowTaskAuthorization: true }, - } as never); vi.mocked(cancelOwnedTask).mockResolvedValue(cancelledView); vi.mocked(startTaskRun).mockResolvedValue(undefined as never); vi.mocked(waitForTaskCommandOwner).mockResolvedValue({ runId: "steering-task-run" } as never); }); it.each([ - { metadata: undefined, supported: false }, - { metadata: { sessionInboxWireVersion: 6 }, supported: false }, - { metadata: { workflowTaskAuthorization: false }, supported: false }, - { metadata: { workflowTaskAuthorization: "true" }, supported: false }, - { metadata: { workflowTaskAuthorization: true }, supported: true }, + { capabilities: undefined, supported: false }, + { capabilities: { requestInput: true }, supported: false }, + { capabilities: { workflowTaskAuthorization: false }, supported: false }, + { capabilities: { workflowTaskAuthorization: true }, supported: true }, ])( "passes the receiving driver's auth capability to the task ($supported)", - async ({ metadata, supported }) => { - vi.mocked(getHookByToken).mockResolvedValue({ metadata } as never); - const scope = await createScope(); + async ({ capabilities, supported }) => { + const scope = await createScope(createSession(), capabilities); await expect(scope.execute()).resolves.toMatchObject({ status: "working" }); - expect(getHookByToken).toHaveBeenCalledWith("eve:session:parent:inbox"); expect(startTaskRun).toHaveBeenCalledWith( expect.objectContaining({ workflow: expect.objectContaining({ authorizationSupported: supported }), diff --git a/packages/eve/src/execution/tasks/parent/tool-execution.ts b/packages/eve/src/execution/tasks/parent/tool-execution.ts index c2aed4e4c3..49cb464a93 100644 --- a/packages/eve/src/execution/tasks/parent/tool-execution.ts +++ b/packages/eve/src/execution/tasks/parent/tool-execution.ts @@ -1,9 +1,6 @@ import type { ContextContainer } from "#context/container.js"; -import { getHookByToken } from "#internal/workflow/runtime.js"; -import { WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY } from "#execution/wire/session-inbox-contract.js"; -import { isObject } from "#shared/guards.js"; import { loadContext } from "#context/container.js"; -import { ActivityObserverKey } from "#context/keys.js"; +import { ActivityObserverKey, CapabilitiesKey } from "#context/keys.js"; import type { FrameworkContextProvider } from "#context/provider.js"; import { runStep } from "#context/run-step.js"; import { buildCallbackContext } from "#context/build-callback-context.js"; @@ -456,17 +453,13 @@ class BackgroundToolExecutionScope implements BackgroundToolExecutor { }; } } - const driver = await getHookByToken(sessionCommandHookToken(this.initialSession.sessionId)); - const authorizationSupported = - isObject(driver.metadata) && - driver.metadata[WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY] === true; await startTaskRun({ activityObserver: taskInput.activityObserver, initialView: { metadata: task.metadata, status: "working", taskId: task.taskId }, parentContinuationToken: sessionCommandHookToken(this.initialSession.sessionId), taskInboxToken: task.taskInboxToken, workflow: { - authorizationSupported, + authorizationSupported: input.ctx.get(CapabilitiesKey)?.workflowTaskAuthorization === true, callId: taskInput.callId, executeInput: workflow.executeInput?.(workflowInput), input: workflowInput, diff --git a/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts b/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts index 46573b60a1..9b69dcd8b2 100644 --- a/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts +++ b/packages/eve/src/execution/tools/workflow/workflow-tool-run.integration.test.ts @@ -19,6 +19,7 @@ import { reportingDeployWorkflow, stepThenRaceWorkflow, stepReferenceWorkflow, + workflowAuthorizationCapabilityProbe, } from "#internal/testing/workflow-tool-fixtures.js"; import { waitForHook } from "#internal/testing/workflow-test-helpers.js"; import { getRun, getWorld, start } from "#internal/workflow/runtime.js"; @@ -142,59 +143,54 @@ function eventsText(events: readonly { readonly data?: unknown }[]): string { } describe("workflow step authorization", () => { - it.each([false, true])( - "handles a driver without the auth capability (background=%s)", - async (background) => { + it.each(["blocking", "background"] as const)( + "runs %s auth with no advertised driver support", + async (execution) => { const runtime = await createWorkflowToolRuntime({ agentName: "workflow-step-old-driver", - background, execute: authorizedDeployWorkflow, toolName: "deploy_service", }); await runtime.run(async () => { - const world = await getWorld(); - const getByToken = world.hooks.getByToken.bind(world.hooks); - // Reproduce the old driver's persisted advertisement, without replacing ctx APIs. - const legacyDriver = vi - .spyOn(world.hooks, "getByToken") - .mockImplementation(async (...args) => { - const hook = await getByToken(...args); - return args[0] === sessionCommandHookToken(hook.runId) - ? { ...hook, metadata: undefined } - : hook; - }); - const run = await start(workflowEntry, [ + const workflowId = Reflect.get(authorizedDeployWorkflow, "workflowId"); + if (typeof workflowId !== "string") throw new Error("Missing fixture workflow id"); + const auth = { + attributes: {}, + authenticator: "test-idp", + issuer: "test-idp", + principalId: "user-1", + principalType: "user" as const, + }; + const run = await start(workflowAuthorizationCapabilityProbe, [ { - input: { message: 'Run deploy_service with service "preauthorized"' }, - serializedContext: { - ...buildSerializedContext({ - continuationToken: "http:step-old-driver", - mode: "conversation", - }), - "eve.auth": { - attributes: {}, - authenticator: "test-idp", - issuer: "test-idp", - principalId: "user-1", - principalType: "user", - }, + callId: "auth-call", + execution, + input: { service: "preauthorized" }, + owner: { inbox: "unused-owner-inbox" }, + session: { + auth: { current: auth, initiator: auth }, + id: "old-session", + turn: { id: "new-turn", sequence: 1 }, }, + stepIndex: 0, + taskId: "auth-task", + toolName: "deploy_service", + workflowId, }, ]); - const stream = captureTurnEvents(run); - try { - const expected = background ? "Start a new session" : "authenticatedAs"; - const events = []; - for (let i = 0; i < 5 && !JSON.stringify(events).includes(expected); i++) - events.push(...(await stream.nextTurn())); - expect(JSON.stringify(events)).toContain(expected); - expect(filterEventsByType(events, "authorization.required")).toHaveLength(0); - expect(JSON.stringify(events)).not.toContain("secret:"); - } finally { - stream.dispose(); - await run.cancel(); - legacyDriver.mockRestore(); + const result = await run.returnValue; + if (execution === "background") { + expect(result.outcome).toMatchObject({ + status: "failed", + error: { message: expect.stringContaining("Start a new session") }, + }); + } else { + expect(result.outcome).toMatchObject({ + status: "completed", + output: { authenticatedAs: "user-1" }, + }); } + expect(JSON.stringify(result)).not.toContain("secret:"); }); }, 60_000, diff --git a/packages/eve/src/execution/wire/session-inbox-contract.ts b/packages/eve/src/execution/wire/session-inbox-contract.ts index 3e30baa7e8..b50fffbb4a 100644 --- a/packages/eve/src/execution/wire/session-inbox-contract.ts +++ b/packages/eve/src/execution/wire/session-inbox-contract.ts @@ -10,12 +10,6 @@ export const SESSION_INBOX_WIRE_VERSION = /** Hook metadata field advertising the consumer's inbox wire capability. */ export const SESSION_INBOX_WIRE_VERSION_METADATA_KEY = "sessionInboxWireVersion"; -/** - * The receiving driver supports workflow-task authorization events. SessionCapabilities - * describes what the channel permits, which does not identify the driver's deployed code. - */ -export const WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY = "workflowTaskAuthorization"; - export const SESSION_INBOX_CONTEXT_KEY = "eve.sessionInbox"; /** Immutable inbox coordinates advertised by the receiving session's driver. */ diff --git a/packages/eve/src/execution/workflow-entry.test.ts b/packages/eve/src/execution/workflow-entry.test.ts index 0b9eb5ff0d..a370377835 100644 --- a/packages/eve/src/execution/workflow-entry.test.ts +++ b/packages/eve/src/execution/workflow-entry.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createHook } from "#compiled/@workflow/core/index.js"; import { resumeHook } from "#internal/workflow/runtime.js"; -import type { HookPayload } from "#channel/types.js"; +import type { HookPayload, SessionCapabilities } from "#channel/types.js"; import { ChannelRequestIdKey } from "#context/keys.js"; import { createSessionStep } from "#execution/create-session-step.js"; import { @@ -170,6 +170,31 @@ describe("workflowEntry", () => { vi.unstubAllEnvs(); }); + it.each([undefined, { requestInput: true, workflowTaskAuthorization: false }])( + "advertises its own auth support while preserving session capabilities (%j)", + async (supplied) => { + const sessionState = createBaseSessionState(); + vi.mocked(createSessionStep).mockResolvedValue(createSessionStepResultForMock(sessionState)); + installHookMocks({ + deliveryHooks: [{ token: "http:test" }], + turnControls: [turnResult({ action: "done", output: "ok", sessionState })], + }); + + await workflowEntry({ + input: { message: "hello" }, + serializedContext: createSerializedContext({ "eve.capabilities": supplied }), + }); + + const expected: SessionCapabilities = { ...supplied, workflowTaskAuthorization: true }; + expect(dispatchTurnStep).toHaveBeenCalledWith( + expect.objectContaining({ + capabilities: expected, + serializedContext: expect.objectContaining({ "eve.capabilities": expected }), + }), + ); + }, + ); + it("injects the workflow run id as the canonical session id before the first turn", async () => { const sessionState = createBaseSessionState(); const getConflict = vi.fn(async () => null); diff --git a/packages/eve/src/execution/workflow-entry.ts b/packages/eve/src/execution/workflow-entry.ts index d3cfb0bd37..e1cee46519 100644 --- a/packages/eve/src/execution/workflow-entry.ts +++ b/packages/eve/src/execution/workflow-entry.ts @@ -153,9 +153,13 @@ export async function workflowEntry(input: WorkflowEntryInput): Promise Date: Tue, 8 Sep 2026 08:53:57 -0400 Subject: [PATCH 15/23] refactor(eve): derive workflow authorization from sender identity Signed-off-by: Rui Conti --- .../execution/tasks/child/workflow.test.ts | 25 ++++++++++++++++--- .../eve/src/execution/tasks/child/workflow.ts | 5 +++- .../src/execution/tools/workflow/messages.ts | 1 - .../eve/src/execution/tools/workflow/step.ts | 1 - .../src/execution/turn-workflow-tool-run.ts | 2 +- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/eve/src/execution/tasks/child/workflow.test.ts b/packages/eve/src/execution/tasks/child/workflow.test.ts index 8c094251e1..a864b583c3 100644 --- a/packages/eve/src/execution/tasks/child/workflow.test.ts +++ b/packages/eve/src/execution/tasks/child/workflow.test.ts @@ -113,14 +113,13 @@ const workflowAgentRequest = { }, } satisfies WorkflowToolRunMessage; -function authorizationRequest(attemptId: string, completed = false): WorkflowToolRunMessage { +function authorizationRequest(attemptId: string, completed = false) { const data = { attemptId, name: "github", sequence: 0, stepIndex: 0, turnId: "turn-parent" }; return { ...bufferedAgentRequest, replyTo: `ack-${attemptId}`, request: { kind: "authorization-request", - stepAuthorization: true, event: { kind: "subagent-authorization-event", callId: "tool-call-1", @@ -131,7 +130,7 @@ function authorizationRequest(attemptId: string, completed = false): WorkflowToo : createAuthorizationRequiredEvent({ ...data, description: "Sign in" }), }, }, - }; + } satisfies WorkflowToolRunMessage; } function queueOwnerRequest(value: WorkflowToolRunMessage) { @@ -205,6 +204,26 @@ describe("taskRunWorkflow", () => { } }); + it("forwards child-agent auth without treating it as the workflow's own request", async () => { + const message = authorizationRequest("child"); + message.request.event.childSessionId = "child-session"; + queueCommand({ kind: "ready" }); + queueOwnerRequest(message); + mocks.raceChannelReads.mockResolvedValueOnce({ channel: "commands", next: { done: true } }); + + await taskRunWorkflow(workflowInput); + + expect(mocks.wakeTaskAuthorizationParentStep).toHaveBeenCalledExactlyOnceWith({ + request: message.request, + taskId: initialView.taskId, + token: workflowInput.parentContinuationToken, + }); + expect(mocks.resumeHookStep).not.toHaveBeenCalled(); + expect( + mocks.appendTaskViewStep.mock.calls.some(([input]) => input.view.status === "input_required"), + ).toBe(false); + }); + it("acknowledges buffered auth when dispatch is rejected without forwarding it", async () => { queueOwnerRequest(authorizationRequest("a")); queueCommand({ kind: "reject-dispatch", data: "rejected" }); diff --git a/packages/eve/src/execution/tasks/child/workflow.ts b/packages/eve/src/execution/tasks/child/workflow.ts index 744b32b0ac..214ea25f2d 100644 --- a/packages/eve/src/execution/tasks/child/workflow.ts +++ b/packages/eve/src/execution/tasks/child/workflow.ts @@ -329,7 +329,10 @@ export async function taskRunWorkflow(input: TaskRunWorkflowInput): Promise Date: Tue, 8 Sep 2026 08:55:00 -0400 Subject: [PATCH 16/23] refactor(eve): share authorization across tools and connections Signed-off-by: Rui Conti --- .changeset/shared-authorization-runtime.md | 5 + packages/eve/src/execution/tool-auth.ts | 398 ++---------------- .../execution/tools/connection-search.test.ts | 78 ++++ .../src/execution/tools/connection-search.ts | 199 ++------- .../eve/src/runtime/authorization-context.ts | 117 +++++ .../connections/scoped-authorization.ts | 135 +++++- 6 files changed, 398 insertions(+), 534 deletions(-) create mode 100644 .changeset/shared-authorization-runtime.md create mode 100644 packages/eve/src/runtime/authorization-context.ts diff --git a/.changeset/shared-authorization-runtime.md b/.changeset/shared-authorization-runtime.md new file mode 100644 index 0000000000..0eb93e8302 --- /dev/null +++ b/.changeset/shared-authorization-runtime.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Share authorization handling across connection search, authored tools, and approval responses so token resolution, callback completion, and rejected-token handling use the same implementation. diff --git a/packages/eve/src/execution/tool-auth.ts b/packages/eve/src/execution/tool-auth.ts index feeea49fd4..b14b88ca08 100644 --- a/packages/eve/src/execution/tool-auth.ts +++ b/packages/eve/src/execution/tool-auth.ts @@ -1,59 +1,11 @@ -/** - * Tool-hosted authorization wiring for authored tools that resolve auth - * providers inline with {@link ToolContext.getToken} and - * {@link ToolContext.requireAuth}. - * - * Mirrors the connection authorization flow used by connection search but scopes the - * per-step token cache and framework-owned callback URL by the tool's - * path-derived name and provider key instead of a connection name. All the shared - * machinery — principal resolution, cache reads/writes, the park/resume - * webhook dance, and the loop guard — lives in - * `runtime/connections/scoped-authorization.ts`; this module is the thin - * execution-layer adapter that wraps one tool's `execute`. - */ - import { buildBaseToolContext } from "#context/build-base-tool-context.js"; import type { SessionAuthContext } from "#channel/types.js"; -import { - ConnectionAuthorizationFailedError, - ConnectionAuthorizationRequiredError, - isConnectionAuthorizationRequiredError, -} from "#connections/errors.js"; import type { ApprovalResponseAuth } from "#approval/definition.js"; -import type { ToolAuthOptions, ToolAuthProvider, ToolContext } from "#tools/definition.js"; -import { type AuthorizationChallenge, requestAuthorization } from "#harness/authorization.js"; -import { - type AuthorizationDefinition, - supportsInteractiveAuthorization, - type TokenResult, -} from "#shared/connection-types.js"; -import { normalizeAuthorizationSpec } from "#shared/validate-authorization.js"; -import { - completeScopedAuthorization, - evictScopedToken, - resolveScopedToken, - startScopedAuthorization, - type ScopedAuthorization, -} from "#runtime/connections/scoped-authorization.js"; -import type { ToolExecuteOptions } from "#tools/definition.js"; +import type { ToolAuthOptions, ToolContext, ToolExecuteOptions } from "#tools/definition.js"; import type { TaskExec } from "#tools/task.js"; -import { isAsyncIterable } from "#shared/async-iterable.js"; +import { createAuthorizationContext } from "#runtime/authorization-context.js"; +import { handleAuthorizationError } from "#runtime/connections/scoped-authorization.js"; -/** - * Wraps one authored tool's `execute` with a context that supports inline - * provider auth (`ctx.getToken(connect("..."))`). - * - * On a thrown provider-scoped authorization request — implicit from - * `ctx.getToken(provider)` or explicit via `ctx.requireAuth(provider)` — the - * wrapper either fails terminally (token rejected immediately after sign-in) - * or evicts the rejected token from the per-step cache and starts the - * interactive flow, returning an `AuthorizationSignal` to park the turn. - * Interactive strategies never rethrow the raw `Required` into the model: if - * no callback URL can be minted, they fail with a classified - * {@link ConnectionAuthorizationFailedError} instead. Non-interactive - * strategies rethrow the original error because they have no consent flow to - * park on. - */ type ToolExecuteWithAuthInput = { readonly scope: string; } & ( @@ -67,101 +19,37 @@ type ToolExecuteWithAuthInput = { } ); -export function createToolExecuteWithAuth( - input: ToolExecuteWithAuthInput, -): ( - toolInput: TInput, - options: ToolExecuteOptions, - task?: TaskExec, -) => Promise | AsyncIterable { - const { scope } = input; - - // An async wrapper would turn an async generator into Promise, - // which the AI SDK treats as one non-serializable terminal output. - return ( - toolInput: TInput, - options: ToolExecuteOptions, - task?: TaskExec, - ): Promise | AsyncIterable => { - const justAuthorizedScopes = new Set(); - const ctx = buildToolContext({ - inlineAuthState: {}, - justAuthorizedScopes, - options, - scope, - }); - - try { - let output: unknown; +/** Supplies the shared auth capability to one authored tool execution. */ +export function createToolExecuteWithAuth(input: ToolExecuteWithAuthInput) { + return (toolInput: TInput, options: ToolExecuteOptions, task?: TaskExec) => { + const auth = createAuthorizationContext({ scope: input.scope }); + const ctx: ToolContext = { + ...buildBaseToolContext({ options, toolName: input.scope }), + getToken: auth.getToken, + requireAuth: auth.requireAuth, + }; + return auth.run(() => { if (input.execution === "background") { - if (task === undefined) { + if (task === undefined) throw new Error("Background tool execution requires a task runtime."); - } - output = input.execute(toolInput, ctx, task); - } else { - output = input.execute(toolInput, ctx, task); - } - if (isAsyncIterable(output)) { - return handleToolIterableErrors(output); - } - return Promise.resolve(output).catch(handleToolError); - } catch (err) { - return handleToolError(err); - } - - async function handleToolError(error: unknown): Promise { - if (isToolAuthorizationRequiredError(error)) { - return await handleAuthorizationRequests(error.requests); + return input.execute(toolInput, ctx, task); } - throw error; - } - - async function* handleToolIterableErrors( - output: AsyncIterable, - ): AsyncIterable { - try { - for await (const value of output) { - yield value; - } - } catch (error) { - yield await handleToolError(error); - } - } + return input.execute(toolInput, ctx, task); + }); }; } -/** Builds the narrow token capability used by approval response authorizers. */ +/** Binds the same capability to the person responding to an approval. */ export function buildApprovalResponseAuth(input: { readonly responder: SessionAuthContext; readonly scope: string; }): ApprovalResponseAuth { - const inlineAuthState: InlineAuthState = {}; - const justAuthorizedScopes = new Set(); + const auth = createAuthorizationContext({ scope: input.scope, boundResponder: input.responder }); return { - async getToken(provider?: ToolAuthProvider, options?: ToolAuthOptions): Promise { - if (provider === undefined) throw missingProviderError("ctx.getToken"); - return await resolveInlineToken({ - boundResponder: input.responder, - 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({ - boundResponder: input.responder, - inlineAuthState, - options: namespaceApprovalAuthOptions(input.scope, options), - provider, - toolScope: input.scope, - }); - throw new ToolAuthorizationRequiredError([ - { justAuthorized: justAuthorizedScopes.has(scoped.scope), scoped }, - ]); - }, + getToken: (provider, options) => + auth.getToken(provider, namespaceApprovalAuthOptions(input.scope, options)), + requireAuth: (provider, options) => + auth.requireAuth(provider, namespaceApprovalAuthOptions(input.scope, options)), }; } @@ -176,243 +64,5 @@ function namespaceApprovalAuthOptions( /** Starts authorization requested by an approval response authorizer. */ export async function handleApprovalResponsePolicyError(error: unknown): Promise { - if (!isToolAuthorizationRequiredError(error)) throw error; - return await handleAuthorizationRequests(error.requests); -} - -function buildToolContext(input: { - readonly options: ToolExecuteOptions; - readonly scope: string; - readonly justAuthorizedScopes: Set; - readonly inlineAuthState: InlineAuthState; -}): ToolContext { - const { scope, justAuthorizedScopes, inlineAuthState } = input; - const base = buildBaseToolContext({ options: input.options, toolName: scope }); - return { - ...base, - async getToken(provider?: ToolAuthProvider, options?: ToolAuthOptions): Promise { - if (provider === undefined) throw missingProviderError("ctx.getToken"); - return await resolveInlineToken({ - inlineAuthState, - justAuthorizedScopes, - options, - provider, - toolScope: scope, - }); - }, - requireAuth(provider?: ToolAuthProvider, options?: ToolAuthOptions): never { - if (provider === undefined) throw missingProviderError("ctx.requireAuth"); - const scoped = buildInlineScopedAuthorization({ - inlineAuthState, - options, - provider, - toolScope: scope, - }); - throw new ToolAuthorizationRequiredError([ - { - justAuthorized: justAuthorizedScopes.has(scoped.scope), - scoped, - }, - ]); - }, - }; -} - -async function resolveInlineToken(input: { - readonly boundResponder?: SessionAuthContext; - readonly toolScope: string; - readonly provider: ToolAuthProvider; - readonly options?: ToolAuthOptions; - readonly justAuthorizedScopes: Set; - readonly inlineAuthState: InlineAuthState; -}): Promise { - const { justAuthorizedScopes } = input; - const scoped = buildInlineScopedAuthorization(input); - if (!justAuthorizedScopes.has(scoped.scope) && (await completeScopedAuthorization(scoped))) { - justAuthorizedScopes.add(scoped.scope); - } - - try { - return await resolveScopedToken(scoped); - } catch (err) { - if (!isConnectionAuthorizationRequiredError(err)) throw err; - throw new ToolAuthorizationRequiredError([ - { - cause: err, - justAuthorized: justAuthorizedScopes.has(scoped.scope), - scoped, - }, - ]); - } -} - -async function handleAuthorizationRequests( - requests: readonly ToolAuthorizationRequiredRequest[], -): Promise { - const challenges: AuthorizationChallenge[] = []; - let nonInteractiveError: Error | undefined; - - for (const request of requests) { - const { scoped } = request; - - // Loop guard: a token minted this turn that is still rejected - // means the grant itself is broken — fail terminally instead of - // re-prompting into an infinite sign-in loop. - if (request.justAuthorized) { - throw new ConnectionAuthorizationFailedError(scoped.scope, { - message: `Tool "${scoped.scope}" rejected the token immediately after authorization.`, - reason: "token_rejected_after_authorization", - retryable: false, - }); - } - - // The resolved bearer was rejected (a downstream 401 mapped to - // requireAuth, or getToken re-reporting Required). Drop it from - // every cache layer — eve's per-step cache and the strategy's own - // (e.g. the @vercel/connect token cache) — so the - // re-authorization re-resolves a genuinely fresh token instead of - // re-reading the rejected one. Mirrors the MCP client. - await evictScopedToken(scoped); - - const signal = await startScopedAuthorization(scoped); - if (signal !== undefined) { - challenges.push(...signal.challenges); - continue; - } - - // No park signal. For an interactive strategy this means the - // framework could not mint a callback URL (no session id / base - // URL in context). Never let the raw `Required` reach the model — - // it improvises by surfacing the auth URL as text and loops - // (see research/per-tool-auth-known-issues.md, issue 2). Fail with - // a classified, terminal authorization error instead. Non-interactive - // strategies have no consent flow, so their original error is the - // right thing for the model to see. - if (supportsInteractiveAuthorization(scoped.authorization)) { - throw new ConnectionAuthorizationFailedError(scoped.scope, { - message: - `Tool "${scoped.scope}" requires sign-in, but no authorization callback URL ` + - `could be minted for this run (missing session context).`, - reason: "authorization_callback_unavailable", - retryable: false, - }); - } - - nonInteractiveError ??= - request.cause instanceof Error - ? request.cause - : new ConnectionAuthorizationRequiredError(scoped.scope); - } - - if (challenges.length > 0) { - return requestAuthorization(challenges); - } - - throw nonInteractiveError ?? new Error("Tool authorization is required."); -} - -function buildInlineScopedAuthorization(input: { - readonly boundResponder?: SessionAuthContext; - readonly toolScope: string; - readonly provider: ToolAuthProvider; - readonly options?: ToolAuthOptions; - readonly inlineAuthState: InlineAuthState; -}): ScopedAuthorization { - const authorization = normalizeInlineProvider(input.provider, input.options); - return { - authorization, - boundResponder: input.boundResponder, - connection: input.options?.connection ?? { url: "" }, - scope: - input.options?.authKey === undefined - ? deriveInlineScope({ - authorization, - inlineAuthState: input.inlineAuthState, - provider: input.provider, - toolScope: input.toolScope, - }) - : validateInlineAuthKey(input.options.authKey), - }; -} - -function normalizeInlineProvider( - provider: ToolAuthProvider, - options: ToolAuthOptions | undefined, -): AuthorizationDefinition { - const authorization = normalizeAuthorizationSpec(provider, "ctx.getToken:", "provider"); - if (options?.displayName === undefined) { - return authorization; - } - if (options.displayName.length === 0) { - throw new Error(`ctx.getToken: The "options.displayName" field must be a non-empty string.`); - } - return { ...authorization, displayName: options.displayName }; -} - -function deriveInlineScope(input: { - readonly toolScope: string; - readonly authorization: AuthorizationDefinition; - readonly provider: ToolAuthProvider; - readonly inlineAuthState: InlineAuthState; -}): string { - const connector = input.authorization.vercelConnect?.connector; - if (connector !== undefined) { - return `${input.toolScope}__${sanitizeScopeSegment(connector)}`; - } - - if (input.inlineAuthState.anonymousProvider === undefined) { - input.inlineAuthState.anonymousProvider = input.provider; - } else if (input.inlineAuthState.anonymousProvider !== input.provider) { - throw new Error( - `ctx.getToken: Multiple inline auth providers without provider metadata need explicit auth keys. ` + - `Pass options.authKey for each provider, for example ` + - `ctx.getToken(auth, { authKey: "github" }).`, - ); - } - - return `${input.toolScope}__inline_auth`; -} - -function validateInlineAuthKey(authKey: string): string { - if (!/^[A-Za-z0-9_.:-]+$/u.test(authKey)) { - throw new Error( - `ctx.getToken: The "options.authKey" field must contain only letters, digits, "_", "-", ".", or ":".`, - ); - } - return authKey; -} - -function sanitizeScopeSegment(value: string): string { - const sanitized = value.replace(/[^A-Za-z0-9_.:-]+/gu, "_").replace(/^_+|_+$/gu, ""); - return sanitized.length > 0 ? sanitized : "provider"; -} - -interface ToolAuthorizationRequiredRequest { - readonly scoped: ScopedAuthorization; - readonly justAuthorized: boolean; - readonly cause?: unknown; -} - -interface InlineAuthState { - anonymousProvider?: ToolAuthProvider; -} - -class ToolAuthorizationRequiredError extends Error { - readonly requests: readonly ToolAuthorizationRequiredRequest[]; - - constructor(requests: readonly ToolAuthorizationRequiredRequest[]) { - super("Tool authorization required."); - this.name = "ToolAuthorizationRequiredError"; - this.requests = requests; - } -} - -function isToolAuthorizationRequiredError(err: unknown): err is ToolAuthorizationRequiredError { - return err instanceof Error && err.name === "ToolAuthorizationRequiredError"; -} - -function missingProviderError(method: "ctx.getToken" | "ctx.requireAuth"): Error { - return new Error( - `${method}: Pass an auth provider, for example ${method}(connect("github/myagent")).`, - ); + return await handleAuthorizationError(error); } diff --git a/packages/eve/src/execution/tools/connection-search.test.ts b/packages/eve/src/execution/tools/connection-search.test.ts index 1b9c9c5c72..810188ff6b 100644 --- a/packages/eve/src/execution/tools/connection-search.test.ts +++ b/packages/eve/src/execution/tools/connection-search.test.ts @@ -20,6 +20,7 @@ import type { ResolvedConnectionDefinition } from "#runtime/types.js"; import { isBrandedToolEntry, type DynamicToolSet } from "#tools/dynamic.js"; import type { DynamicResolveContext } from "#dynamic/definition.js"; import { readDurableDynamicToolCallbacks } from "#tools/durable-callbacks.js"; +import { resolveHeaders } from "#runtime/connections/mcp-client.js"; function connection(name: string): ResolvedConnectionDefinition { return { @@ -479,6 +480,83 @@ describe("connection_search", () => { ]); }); + it.each([false, true])( + "completes connection auth through the shared token cache (fresh token refused: %s)", + async (refused) => { + const getToken = vi.fn(async () => { + throw new ConnectionAuthorizationRequiredError("salesforce"); + }); + const startAuthorization = vi.fn(async () => ({ + challenge: { url: "https://idp.example.com/authorize" }, + })); + const completeAuthorization = vi.fn(async () => ({ token: "fresh-token" })); + const salesforce: ResolvedConnectionDefinition = { + ...connection("salesforce"), + instanceId: "salesforce-instance", + authorization: { + principalType: "user", + getToken, + startAuthorization, + completeAuthorization, + }, + }; + const connectionRegistry = registry({ + connections: [salesforce], + loadTools: { + salesforce: async () => { + const headers = await resolveHeaders(salesforce); + expect(headers.Authorization).toBe("Bearer fresh-token"); + if (refused) throw new ConnectionAuthorizationRequiredError("salesforce"); + return [{ name: "list_accounts", description: "List accounts", inputSchema: {} }]; + }, + }, + }); + const setup = (ctx: ContextContainer) => { + ctx.set(SessionIdKey, "session-auth"); + ctx.set(CallbackBaseUrlKey, "https://agent.example.com"); + ctx.set(AuthKey, { + attributes: {}, + authenticator: "test-idp", + issuer: "test-idp", + principalId: "user-1", + principalType: "user", + }); + }; + const input = { connection: "salesforce", keywords: "accounts" }; + const pending = await executeConnectionSearch(connectionRegistry, input, setup); + if (!isAuthorizationSignal(pending)) throw new Error("expected authorization signal"); + const challenge = pending.challenges[0]!; + expect(challenge).toMatchObject({ + instanceId: "salesforce-instance", + principal: { type: "user", id: "user-1", issuer: "test-idp" }, + }); + const resumed = executeConnectionSearch(connectionRegistry, input, (ctx) => { + setup(ctx); + ctx.set(PendingAuthorizationResultKey, [ + { + ...challenge, + callback: { method: "GET", params: { code: "approved" } }, + }, + ]); + }); + if (refused) { + await expect(resumed).rejects.toThrow("rejected the token immediately after authorization"); + } else { + await expect(resumed).resolves.toMatchObject([ + { qualifiedName: "salesforce__list_accounts" }, + ]); + } + expect(getToken).toHaveBeenCalledOnce(); + expect(startAuthorization).toHaveBeenCalledOnce(); + expect(completeAuthorization).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + principal: challenge.principal, + callback: { method: "GET", params: { code: "approved" } }, + }), + ); + }, + ); + it("replays authorization from the step-scoped durable execute descriptor", async () => { const salesforce: ResolvedConnectionDefinition = { ...connection("salesforce"), diff --git a/packages/eve/src/execution/tools/connection-search.ts b/packages/eve/src/execution/tools/connection-search.ts index edbaaa0794..63f0d92aee 100644 --- a/packages/eve/src/execution/tools/connection-search.ts +++ b/packages/eve/src/execution/tools/connection-search.ts @@ -5,13 +5,10 @@ import { ContextKey } from "#context/key.js"; import { type AuthorizationChallenge, type AuthorizationSignal, - consumeAuthorizationResult, - createAuthorizationAttempt, getAuthorizationResults, requestAuthorization, } from "#harness/authorization.js"; import { - ConnectionAuthorizationFailedError, isConnectionAuthorizationFailedError, isConnectionAuthorizationRequiredError, } from "#connections/errors.js"; @@ -22,20 +19,15 @@ import { type ApprovalContext, type ApprovalResponseContext, } from "#approval/definition.js"; -import type { JsonValue } from "#shared/json.js"; import type { JsonObject } from "#shared/json.js"; import { stampDurableDynamicToolCallbacks } from "#tools/durable-callbacks.js"; -import { writeCachedToken } from "#runtime/connections/authorization-tokens.js"; -import { connectionAuthorizationScope } from "#runtime/connections/instance-identity.js"; -import { principalKey, resolveConnectionPrincipal } from "#runtime/connections/principal.js"; import { resolveConnectionAuthorization } from "#runtime/connections/resolve-authorization.js"; import { - resolveAuthorizationCallbackUrl, - stampChallengeDisplayName, + createAuthorizationExecution, + type ScopedAuthorization, } from "#runtime/connections/scoped-authorization.js"; import { type ConnectionToolMetadata, - type InteractiveAuthorizationDefinition, supportsInteractiveAuthorization, } from "#shared/connection-types.js"; import type { ConnectionRegistry } from "#runtime/connections/registry-types.js"; @@ -138,49 +130,32 @@ function scoreMatch(queryTokens: string[], tool: ConnectionToolMetadata): number async function resolveInteractiveAuth( registry: ConnectionRegistry, connectionName: string, -): Promise { +): Promise { const conn = registry.getConnections().find((c) => c.connectionName === connectionName); if (conn === undefined) return undefined; const authorization = await resolveConnectionAuthorization(conn); - if (!supportsInteractiveAuthorization(authorization)) return undefined; - return authorization as InteractiveAuthorizationDefinition; + if (authorization === undefined || !supportsInteractiveAuthorization(authorization)) + return undefined; + return { + scope: conn.connectionName, + instanceId: conn.instanceId, + connection: { url: conn.url ?? "" }, + authorization, + }; } -/** - * Completes any authorizations whose callback arrived this turn, - * returning the set of connection names that were just (re-)authorized. - * - * Callers use the returned set as a loop guard: if a connection that was - * just authorized still fails with `Required` on the immediately - * following load, the freshly minted token is itself being rejected, so - * the connection must fail terminally rather than re-challenge forever. - */ +/** Complete only callbacks for the connections targeted by this search. */ async function completePendingAuthorizations( registry: ConnectionRegistry, connections: readonly ResolvedConnectionDefinition[], -): Promise> { + auth: ReturnType, +): Promise { assertPendingConnectionAuthorizationInstances(registry); - const ctx = loadContext(); - const completed = new Set(); for (const conn of connections) { - const result = consumeAuthorizationResult(conn.connectionName, conn.instanceId); - if (!result) continue; - const auth = await resolveInteractiveAuth(registry, conn.connectionName); - if (!auth) continue; - const principal = result.principal ?? resolveConnectionPrincipal(conn.connectionName, auth); - const token = await ( - auth as InteractiveAuthorizationDefinition - ).completeAuthorization({ - callbackUrl: result.hookUrl, - connection: { url: conn.url ?? "" }, - principal, - resume: result.resume, - callback: result.callback, - }); - writeCachedToken(ctx, connectionAuthorizationScope(conn), principalKey(principal), token); - completed.add(conn.connectionName); + if (!getAuthorizationResults().some((result) => result.name === conn.connectionName)) continue; + const scoped = await resolveInteractiveAuth(registry, conn.connectionName); + if (scoped !== undefined) await auth.complete(scoped); } - return completed; } async function executeConnectionSearch( @@ -208,7 +183,8 @@ async function executeConnectionSearch( ); } - const justAuthorized = await completePendingAuthorizations(registry, targetConnections); + const auth = createAuthorizationExecution(); + await completePendingAuthorizations(registry, targetConnections, auth); const authChallenges: AuthorizationChallenge[] = []; @@ -219,59 +195,25 @@ async function executeConnectionSearch( tools = await client.getToolMetadata(); } catch (err) { if (isConnectionAuthorizationRequiredError(err)) { - // Loop guard: a connection authorized earlier this turn that is - // still rejected means the new token itself is bad. Fail it - // terminally instead of re-challenging into an infinite sign-in - // loop. - if (justAuthorized.has(conn.connectionName)) { - logger.warn("connection still unauthorized after authorization", { - connection: conn.connectionName, - }); - failedConnections.push({ - connection: conn.connectionName, - description: conn.description, - error: `Authorization for "${conn.connectionName}" did not take effect; the token was rejected after sign-in.`, - }); - continue; - } - - const auth = await resolveInteractiveAuth(registry, conn.connectionName); - if (auth) { - const attempt = createAuthorizationAttempt(conn.connectionName); - if (attempt) { - const principal = resolveConnectionPrincipal(conn.connectionName, auth); - const callbackUrl = resolveAuthorizationCallbackUrl({ - authorization: auth, - callbackUrl: attempt.hookUrl, + const scoped = await resolveInteractiveAuth(registry, conn.connectionName); + if (scoped !== undefined) { + try { + const signal = await auth.handleError(err, scoped); + authChallenges.push(...signal.challenges); + } catch (startErr) { + const error = toError(startErr); + logger.warn("connection authorization failed", { + connection: conn.connectionName, + error, }); - try { - const { challenge, resume } = await auth.startAuthorization({ - callbackUrl, - connection: { url: conn.url ?? "" }, - principal, - }); - authChallenges.push({ - attemptId: attempt.attemptId, - name: conn.connectionName, - challenge: stampChallengeDisplayName(challenge, auth), - hookUrl: callbackUrl, - instanceId: conn.instanceId, - principal, - resume, - }); - } catch (startErr) { - const error = toError(startErr); - logger.warn("startAuthorization failed", { - connection: conn.connectionName, - error, - }); - failedConnections.push({ - connection: conn.connectionName, - description: conn.description, - error: `Failed to start authorization for "${conn.connectionName}": ${error.message}`, - }); - continue; - } + failedConnections.push({ + connection: conn.connectionName, + description: conn.description, + error: isConnectionAuthorizationFailedError(error) + ? error.message + : `Failed to start authorization for "${conn.connectionName}": ${error.message}`, + }); + continue; } } failedConnections.push({ @@ -388,38 +330,10 @@ async function executeDiscoveredConnectionTool( if (registry === undefined) { throw new Error("Connection registry is unavailable while replaying a discovered tool."); } - const conn = registry - .getConnections() - .find((candidate) => candidate.connectionName === connectionName); assertPendingConnectionAuthorizationInstances(registry); - const interactiveAuth = (await resolveInteractiveAuth(registry, connectionName)) as - | InteractiveAuthorizationDefinition - | undefined; - - let justCompletedAuth = false; - if (interactiveAuth) { - const authResult = consumeAuthorizationResult(connectionName, conn?.instanceId); - if (authResult) { - justCompletedAuth = true; - const ctx = loadContext(); - const principal = - authResult.principal ?? resolveConnectionPrincipal(connectionName, interactiveAuth); - const token = await interactiveAuth.completeAuthorization({ - callbackUrl: authResult.hookUrl, - connection: { url: conn?.url ?? "" }, - principal, - resume: authResult.resume, - callback: authResult.callback, - }); - writeCachedToken( - ctx, - conn === undefined ? connectionName : connectionAuthorizationScope(conn), - principalKey(principal), - token, - ); - } - } - + const scoped = await resolveInteractiveAuth(registry, connectionName); + const auth = createAuthorizationExecution(); + if (scoped !== undefined) await auth.complete(scoped); try { const client = registry.getClient(connectionName); return await client.executeTool(toolName, input, { @@ -427,38 +341,7 @@ async function executeDiscoveredConnectionTool( callId: executeCtx.callId, }); } catch (error) { - if (!isConnectionAuthorizationRequiredError(error) || !interactiveAuth) throw error; - if (justCompletedAuth) { - throw new ConnectionAuthorizationFailedError(connectionName, { - retryable: false, - reason: "token_rejected_after_authorization", - message: `Connection "${connectionName}" rejected the token immediately after authorization.`, - }); - } - - const attempt = createAuthorizationAttempt(connectionName); - if (!attempt) throw error; - const principal = resolveConnectionPrincipal(connectionName, interactiveAuth); - const callbackUrl = resolveAuthorizationCallbackUrl({ - authorization: interactiveAuth, - callbackUrl: attempt.hookUrl, - }); - const { challenge, resume } = await interactiveAuth.startAuthorization({ - callbackUrl, - connection: { url: conn?.url ?? "" }, - principal, - }); - return requestAuthorization([ - { - attemptId: attempt.attemptId, - name: connectionName, - challenge: stampChallengeDisplayName(challenge, interactiveAuth), - hookUrl: callbackUrl, - instanceId: conn?.instanceId, - principal, - resume, - }, - ]); + return await auth.handleError(error, scoped); } } diff --git a/packages/eve/src/runtime/authorization-context.ts b/packages/eve/src/runtime/authorization-context.ts new file mode 100644 index 0000000000..3cce7b5565 --- /dev/null +++ b/packages/eve/src/runtime/authorization-context.ts @@ -0,0 +1,117 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import type { ToolAuthOptions, ToolAuthProvider } from "#tools/definition.js"; +import type { AuthorizationDefinition, TokenResult } from "#shared/connection-types.js"; +import { normalizeAuthorizationSpec } from "#shared/validate-authorization.js"; +import { + createAuthorizationExecution, + type ScopedAuthorization, +} from "#runtime/connections/scoped-authorization.js"; + +/** Shared getToken/requireAuth capability, independent of the executing tool or workflow. */ +export function createAuthorizationContext(input: { + readonly scope: string; + readonly boundResponder?: SessionAuthContext; + readonly completeAuthorization?: (scoped: ScopedAuthorization) => Promise; +}) { + const execution = createAuthorizationExecution(input); + const inlineAuthState: InlineAuthState = {}; + const resolve = (provider: ToolAuthProvider, options?: ToolAuthOptions) => + buildInlineScopedAuthorization({ ...input, inlineAuthState, provider, options }); + return { + async getToken(provider?: ToolAuthProvider, options?: ToolAuthOptions): Promise { + if (provider === undefined) throw missingProviderError("ctx.getToken"); + return await execution.getToken(resolve(provider, options)); + }, + requireAuth(provider?: ToolAuthProvider, options?: ToolAuthOptions): never { + if (provider === undefined) throw missingProviderError("ctx.requireAuth"); + return execution.requireAuth(resolve(provider, options)); + }, + run: execution.run, + }; +} + +function buildInlineScopedAuthorization(input: { + readonly boundResponder?: SessionAuthContext; + readonly scope: string; + readonly provider: ToolAuthProvider; + readonly options?: ToolAuthOptions; + readonly inlineAuthState: InlineAuthState; +}): ScopedAuthorization { + const authorization = normalizeInlineProvider(input.provider, input.options); + return { + authorization, + boundResponder: input.boundResponder, + connection: input.options?.connection ?? { url: "" }, + scope: + input.options?.authKey === undefined + ? deriveInlineScope({ + authorization, + inlineAuthState: input.inlineAuthState, + provider: input.provider, + scope: input.scope, + }) + : validateInlineAuthKey(input.options.authKey), + }; +} + +function normalizeInlineProvider( + provider: ToolAuthProvider, + options: ToolAuthOptions | undefined, +): AuthorizationDefinition { + const authorization = normalizeAuthorizationSpec(provider, "ctx.getToken:", "provider"); + if (options?.displayName === undefined) { + return authorization; + } + if (options.displayName.length === 0) { + throw new Error(`ctx.getToken: The "options.displayName" field must be a non-empty string.`); + } + return { ...authorization, displayName: options.displayName }; +} + +function deriveInlineScope(input: { + readonly scope: string; + readonly authorization: AuthorizationDefinition; + readonly provider: ToolAuthProvider; + readonly inlineAuthState: InlineAuthState; +}): string { + const connector = input.authorization.vercelConnect?.connector; + if (connector !== undefined) { + return `${input.scope}__${sanitizeScopeSegment(connector)}`; + } + + if (input.inlineAuthState.anonymousProvider === undefined) { + input.inlineAuthState.anonymousProvider = input.provider; + } else if (input.inlineAuthState.anonymousProvider !== input.provider) { + throw new Error( + `ctx.getToken: Multiple inline auth providers without provider metadata need explicit auth keys. ` + + `Pass options.authKey for each provider, for example ` + + `ctx.getToken(auth, { authKey: "github" }).`, + ); + } + + return `${input.scope}__inline_auth`; +} + +function validateInlineAuthKey(authKey: string): string { + if (!/^[A-Za-z0-9_.:-]+$/u.test(authKey)) { + throw new Error( + `ctx.getToken: The "options.authKey" field must contain only letters, digits, "_", "-", ".", or ":".`, + ); + } + return authKey; +} + +function sanitizeScopeSegment(value: string): string { + const sanitized = value.replace(/[^A-Za-z0-9_.:-]+/gu, "_").replace(/^_+|_+$/gu, ""); + return sanitized.length > 0 ? sanitized : "provider"; +} + +interface InlineAuthState { + anonymousProvider?: ToolAuthProvider; +} + +function missingProviderError(method: "ctx.getToken" | "ctx.requireAuth"): Error { + return new Error( + `${method}: Pass an auth provider, for example ${method}(connect("github/myagent")).`, + ); +} diff --git a/packages/eve/src/runtime/connections/scoped-authorization.ts b/packages/eve/src/runtime/connections/scoped-authorization.ts index 83819b4062..d3bbf28362 100644 --- a/packages/eve/src/runtime/connections/scoped-authorization.ts +++ b/packages/eve/src/runtime/connections/scoped-authorization.ts @@ -1,6 +1,6 @@ /** * Scope-parameterized authorization flow shared by MCP connections and - * authored tools that declare `auth`. + * authored tools and workflow steps using inline providers. * * A *scope* names the framework-owned callback URL — a connection name for * an MCP connection, a tool name for tool-hosted auth. Connection-hosted @@ -12,7 +12,13 @@ */ import { type AlsContext, contextStorage, loadContext } from "#context/container.js"; -import type { ConnectionAuthorizationChallenge } from "#connections/errors.js"; +import { + type ConnectionAuthorizationChallenge, + ConnectionAuthorizationFailedError, + ConnectionAuthorizationRequiredError, + isConnectionAuthorizationRequiredError, +} from "#connections/errors.js"; +import { isAsyncIterable } from "#shared/async-iterable.js"; import { type AuthorizationSignal, consumeAuthorizationResult, @@ -56,6 +62,131 @@ export interface ScopedAuthorization { readonly connection: ConnectionAuthorizationContext; } +/** One execution's token capability and authorization interruption boundary. */ +export function createAuthorizationExecution( + options: { + readonly completeAuthorization?: typeof completeScopedAuthorization; + } = {}, +) { + const justAuthorized = new Set(); + + async function complete(scoped: ScopedAuthorization): Promise { + const key = scoped.instanceId ?? scoped.scope; + if ( + !justAuthorized.has(key) && + (await (options.completeAuthorization ?? completeScopedAuthorization)(scoped)) + ) { + justAuthorized.add(key); + } + } + + function requireAuth(scoped: ScopedAuthorization, cause?: unknown): never { + throw new ScopedAuthorizationRequiredError( + scoped, + justAuthorized.has(scoped.instanceId ?? scoped.scope), + cause, + ); + } + + return { + complete, + async getToken(scoped: ScopedAuthorization): Promise { + await complete(scoped); + try { + return await resolveScopedToken(scoped); + } catch (error) { + if (!isConnectionAuthorizationRequiredError(error)) throw error; + return requireAuth(scoped, error); + } + }, + requireAuth, + async handleError(error: unknown, scoped?: ScopedAuthorization): Promise { + if (scoped !== undefined && isConnectionAuthorizationRequiredError(error)) { + return await handleAuthorizationError( + new ScopedAuthorizationRequiredError( + scoped, + justAuthorized.has(scoped.instanceId ?? scoped.scope), + error, + ), + // Connection transports evict refused bearers when classifying their errors. + { evictToken: false }, + ); + } + return await handleAuthorizationError(error); + }, + run: executeWithAuthorization, + }; +} + +class ScopedAuthorizationRequiredError extends Error { + readonly scoped: ScopedAuthorization; + readonly justAuthorized: boolean; + + constructor(scoped: ScopedAuthorization, justAuthorized: boolean, cause?: unknown) { + super("Authorization required.", { cause }); + this.name = "ScopedAuthorizationRequiredError"; + this.scoped = scoped; + this.justAuthorized = justAuthorized; + } +} + +function isScopedAuthorizationRequiredError( + error: unknown, +): error is ScopedAuthorizationRequiredError { + return error instanceof Error && error.name === "ScopedAuthorizationRequiredError"; +} + +/** Produces the shared challenge; the caller owns parking and resumption. */ +export async function handleAuthorizationError( + error: unknown, + options: { readonly evictToken: boolean } = { evictToken: true }, +): Promise { + if (!isScopedAuthorizationRequiredError(error)) throw error; + const { scoped } = error; + if (error.justAuthorized) { + throw new ConnectionAuthorizationFailedError(scoped.scope, { + message: `Authorization for "${scoped.scope}" failed: the service rejected the token immediately after authorization.`, + reason: "token_rejected_after_authorization", + retryable: false, + }); + } + + if (options.evictToken) await evictScopedToken(scoped); + const signal = await startScopedAuthorization(scoped); + if (signal !== undefined) return signal; + + if (supportsInteractiveAuthorization(scoped.authorization)) { + throw new ConnectionAuthorizationFailedError(scoped.scope, { + message: `Authorization for "${scoped.scope}" requires sign-in, but no authorization callback URL could be minted for this run (missing session context).`, + reason: "authorization_callback_unavailable", + retryable: false, + }); + } + throw error.cause ?? new ConnectionAuthorizationRequiredError(scoped.scope); +} + +function executeWithAuthorization( + execute: () => unknown, +): Promise | AsyncIterable { + // Keep generator results as iterables, including errors raised during iteration. + try { + const output = execute(); + return isAsyncIterable(output) + ? handleIterable(output) + : Promise.resolve(output).catch(handleAuthorizationError); + } catch (error) { + return handleAuthorizationError(error); + } +} + +async function* handleIterable(output: AsyncIterable): AsyncIterable { + try { + for await (const value of output) yield value; + } catch (error) { + yield await handleAuthorizationError(error); + } +} + /** * Resolves a bearer token for one scope, consulting the per-step token * cache before invoking the authored `getToken`. From de9db724ef3c3a40ea888022878dbbb8b42f819f Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 8 Sep 2026 08:56:47 -0400 Subject: [PATCH 17/23] refactor(eve): move busy-worker fixture fix to its own PR Signed-off-by: Rui Conti --- e2e/fixtures/fixture-tasks/agent/agent.ts | 4 ++-- .../fixture-tasks/agent/subagents/busy-worker/agent.ts | 8 +------- .../agent/subagents/busy-worker/tools/hold.ts | 8 +++----- .../evals/task.agent.steer.accepted-busy.eval.ts | 4 +--- 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/e2e/fixtures/fixture-tasks/agent/agent.ts b/e2e/fixtures/fixture-tasks/agent/agent.ts index 459aa20d8b..038245332f 100644 --- a/e2e/fixtures/fixture-tasks/agent/agent.ts +++ b/e2e/fixtures/fixture-tasks/agent/agent.ts @@ -509,12 +509,12 @@ function raceBusyWorker(request: MockModelRequest): MockModelResponse | string { toolCalls: [ { id: "child-task-exclusivity-send-a", - input: { agentId, message: "EXCLUSIVITY-GATE: Return BUSY-WORKER-A." }, + input: { agentId, message: "Return BUSY-WORKER-A." }, name: "busy-worker", }, { id: "child-task-exclusivity-send-b", - input: { agentId, message: "EXCLUSIVITY-GATE: Return BUSY-WORKER-B." }, + input: { agentId, message: "Return BUSY-WORKER-B." }, name: "busy-worker", }, ], diff --git a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts index a055527833..bc7e211994 100644 --- a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts +++ b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts @@ -11,13 +11,7 @@ export default defineAgent({ if (message.includes("BUSY-WORKER-A") || message.includes("BUSY-WORKER-B")) { if (!request.toolResults.some((result) => result.id === "exclusivity-hold")) { return { - toolCalls: [ - { - id: "exclusivity-hold", - input: { marker: message.includes("EXCLUSIVITY-GATE") ? "EXCLUSIVITY" : "HOLD" }, - name: "hold", - }, - ], + toolCalls: [{ id: "exclusivity-hold", input: { marker: "HOLD" }, name: "hold" }], }; } } diff --git a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts index 03bd33645a..d843ccc01b 100644 --- a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts +++ b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts @@ -2,12 +2,10 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; export default defineTool({ - description: "Hold a continuation until approval, or briefly delay other fixture work.", - inputSchema: z.object({ marker: z.enum(["HOLD", "EXCLUSIVITY"]) }), - approval: ({ toolInput }) => - toolInput?.marker === "EXCLUSIVITY" ? "user-approval" : "not-applicable", + description: "Keep an admitted continuation nonterminal long enough for a later-turn check.", + inputSchema: z.object({ marker: z.literal("HOLD") }), execute: async ({ marker }) => { - if (marker === "HOLD") await new Promise((resolve) => setTimeout(resolve, 5_000)); + await new Promise((resolve) => setTimeout(resolve, 5_000)); return { marker, released: true }; }, }); diff --git a/e2e/fixtures/fixture-tasks/evals/task.agent.steer.accepted-busy.eval.ts b/e2e/fixtures/fixture-tasks/evals/task.agent.steer.accepted-busy.eval.ts index 59cd6bf4f5..455ef55e54 100644 --- a/e2e/fixtures/fixture-tasks/evals/task.agent.steer.accepted-busy.eval.ts +++ b/e2e/fixtures/fixture-tasks/evals/task.agent.steer.accepted-busy.eval.ts @@ -7,7 +7,6 @@ import { parseToolErrorOutput, sendAndFollowQueuedTurn, waitForCompletedTask, - waitForTaskInput, waitForTaskNotification, } from "./shared.js"; @@ -63,11 +62,10 @@ export default defineTaskEval({ status: "failed", }); - const held = await waitForTaskInput(t, race.session, "hold"); const later = await sendAndFollowQueuedTurn( t, `CHILD-TASK-EXCLUSIVITY-LATER ${agentId}`, - held.session, + race.session, ); later.turn.expectOk(); later.turn.calledSubagent("busy-worker", { count: 1, status: "completed" }); From 7b3fe4312dacb0674acfa299e8dc93a8c10bb4af Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 8 Sep 2026 09:47:10 -0400 Subject: [PATCH 18/23] chore(eve): consolidate workflow authorization into one PR Signed-off-by: Rui Conti --- .changeset/shared-authorization-runtime.md | 5 ----- e2e/fixtures/fixture-tasks/agent/agent.ts | 4 ++-- .../fixture-tasks/agent/subagents/busy-worker/agent.ts | 8 +++++++- .../agent/subagents/busy-worker/tools/hold.ts | 8 +++++--- .../evals/task.agent.steer.accepted-busy.eval.ts | 4 +++- 5 files changed, 17 insertions(+), 12 deletions(-) delete mode 100644 .changeset/shared-authorization-runtime.md diff --git a/.changeset/shared-authorization-runtime.md b/.changeset/shared-authorization-runtime.md deleted file mode 100644 index 0eb93e8302..0000000000 --- a/.changeset/shared-authorization-runtime.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"eve": patch ---- - -Share authorization handling across connection search, authored tools, and approval responses so token resolution, callback completion, and rejected-token handling use the same implementation. diff --git a/e2e/fixtures/fixture-tasks/agent/agent.ts b/e2e/fixtures/fixture-tasks/agent/agent.ts index 038245332f..459aa20d8b 100644 --- a/e2e/fixtures/fixture-tasks/agent/agent.ts +++ b/e2e/fixtures/fixture-tasks/agent/agent.ts @@ -509,12 +509,12 @@ function raceBusyWorker(request: MockModelRequest): MockModelResponse | string { toolCalls: [ { id: "child-task-exclusivity-send-a", - input: { agentId, message: "Return BUSY-WORKER-A." }, + input: { agentId, message: "EXCLUSIVITY-GATE: Return BUSY-WORKER-A." }, name: "busy-worker", }, { id: "child-task-exclusivity-send-b", - input: { agentId, message: "Return BUSY-WORKER-B." }, + input: { agentId, message: "EXCLUSIVITY-GATE: Return BUSY-WORKER-B." }, name: "busy-worker", }, ], diff --git a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts index bc7e211994..a055527833 100644 --- a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts +++ b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/agent.ts @@ -11,7 +11,13 @@ export default defineAgent({ if (message.includes("BUSY-WORKER-A") || message.includes("BUSY-WORKER-B")) { if (!request.toolResults.some((result) => result.id === "exclusivity-hold")) { return { - toolCalls: [{ id: "exclusivity-hold", input: { marker: "HOLD" }, name: "hold" }], + toolCalls: [ + { + id: "exclusivity-hold", + input: { marker: message.includes("EXCLUSIVITY-GATE") ? "EXCLUSIVITY" : "HOLD" }, + name: "hold", + }, + ], }; } } diff --git a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts index d843ccc01b..03bd33645a 100644 --- a/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts +++ b/e2e/fixtures/fixture-tasks/agent/subagents/busy-worker/tools/hold.ts @@ -2,10 +2,12 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; export default defineTool({ - description: "Keep an admitted continuation nonterminal long enough for a later-turn check.", - inputSchema: z.object({ marker: z.literal("HOLD") }), + description: "Hold a continuation until approval, or briefly delay other fixture work.", + inputSchema: z.object({ marker: z.enum(["HOLD", "EXCLUSIVITY"]) }), + approval: ({ toolInput }) => + toolInput?.marker === "EXCLUSIVITY" ? "user-approval" : "not-applicable", execute: async ({ marker }) => { - await new Promise((resolve) => setTimeout(resolve, 5_000)); + if (marker === "HOLD") await new Promise((resolve) => setTimeout(resolve, 5_000)); return { marker, released: true }; }, }); diff --git a/e2e/fixtures/fixture-tasks/evals/task.agent.steer.accepted-busy.eval.ts b/e2e/fixtures/fixture-tasks/evals/task.agent.steer.accepted-busy.eval.ts index 455ef55e54..59cd6bf4f5 100644 --- a/e2e/fixtures/fixture-tasks/evals/task.agent.steer.accepted-busy.eval.ts +++ b/e2e/fixtures/fixture-tasks/evals/task.agent.steer.accepted-busy.eval.ts @@ -7,6 +7,7 @@ import { parseToolErrorOutput, sendAndFollowQueuedTurn, waitForCompletedTask, + waitForTaskInput, waitForTaskNotification, } from "./shared.js"; @@ -62,10 +63,11 @@ export default defineTaskEval({ status: "failed", }); + const held = await waitForTaskInput(t, race.session, "hold"); const later = await sendAndFollowQueuedTurn( t, `CHILD-TASK-EXCLUSIVITY-LATER ${agentId}`, - race.session, + held.session, ); later.turn.expectOk(); later.turn.calledSubagent("busy-worker", { count: 1, status: "completed" }); From 8bf021dc613916602483d0ede680512652f6d533 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 8 Sep 2026 10:24:26 -0400 Subject: [PATCH 19/23] test(eve): cover connection search authorization boundaries Signed-off-by: Rui Conti --- .../agent-workflow-tools/agent/agent.ts | 15 ++ .../agent/channels/catalog.ts | 58 ++++++++ .../agent/connections/private-catalog.ts | 16 +++ .../connection-search.authorization.eval.ts | 57 ++++++++ ...n-search-authorization.integration.test.ts | 131 ++++++++++++++++++ 5 files changed, 277 insertions(+) create mode 100644 e2e/fixtures/agent-workflow-tools/agent/channels/catalog.ts create mode 100644 e2e/fixtures/agent-workflow-tools/agent/connections/private-catalog.ts create mode 100644 e2e/fixtures/agent-workflow-tools/evals/connection-search.authorization.eval.ts create mode 100644 packages/eve/src/execution/tools/connection-search-authorization.integration.test.ts diff --git a/e2e/fixtures/agent-workflow-tools/agent/agent.ts b/e2e/fixtures/agent-workflow-tools/agent/agent.ts index 45faeadf96..34306b10f2 100644 --- a/e2e/fixtures/agent-workflow-tools/agent/agent.ts +++ b/e2e/fixtures/agent-workflow-tools/agent/agent.ts @@ -8,6 +8,21 @@ import { mockModel, type MockModelRequest, type MockModelResponse } from "eve/ev */ function respond(request: MockModelRequest): MockModelResponse | string { const message = [...request.userMessages].reverse().find((entry) => entry.trim() !== "") ?? ""; + if (message.includes("private-catalog")) { + const result = request.toolResults.find((entry) => entry.name === "connection_search"); + if (result === undefined) { + return { + toolCalls: [ + { + name: "connection_search", + input: { connection: "private-catalog", keywords: "items" }, + }, + ], + }; + } + return JSON.stringify(result.output); + } + const stepAuth = /WORKFLOW-STEP-AUTH-(IMPLICIT|EXPLICIT|REJECTED)/u.exec(message); if (stepAuth !== null) { const result = request.toolResults.find((entry) => entry.name === "authorize_service"); diff --git a/e2e/fixtures/agent-workflow-tools/agent/channels/catalog.ts b/e2e/fixtures/agent-workflow-tools/agent/channels/catalog.ts new file mode 100644 index 0000000000..4bcbd5cab8 --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/agent/channels/catalog.ts @@ -0,0 +1,58 @@ +import { defineChannel, GET, POST } from "eve/channels"; +import { z } from "zod"; + +const rpcRequest = z.object({ + id: z.union([z.string(), z.number()]).optional(), + method: z.string(), +}); + +// A fixture-owned MCP server: eve still resolves auth and performs real HTTP discovery. +export default defineChannel({ + routes: [ + GET("/fixture-service/catalog", async () => new Response(null, { status: 405 })), + POST("/fixture-service/catalog", async (request) => { + if (request.headers.get("authorization") !== "Bearer authorized-fixture-token") { + return new Response("Unauthorized", { status: 401 }); + } + + const { id, method } = rpcRequest.parse(await request.json()); + if (id === undefined) { + return new Response(null, { status: 202 }); + } + + if (method === "initialize") { + return Response.json({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2025-03-26", + capabilities: { tools: {} }, + serverInfo: { name: "fixture-catalog", version: "1.0.0" }, + }, + }); + } + + if (method === "tools/list") { + return Response.json({ + jsonrpc: "2.0", + id, + result: { + tools: [ + { + name: "list_items", + description: "List catalog items.", + inputSchema: { type: "object", properties: {} }, + }, + ], + }, + }); + } + + return Response.json({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: "Method not found" }, + }); + }), + ], +}); diff --git a/e2e/fixtures/agent-workflow-tools/agent/connections/private-catalog.ts b/e2e/fixtures/agent-workflow-tools/agent/connections/private-catalog.ts new file mode 100644 index 0000000000..23c159eb44 --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/agent/connections/private-catalog.ts @@ -0,0 +1,16 @@ +import { defineDynamic, defineMcpClientConnection } from "eve/connections"; + +import { createFakeAuthProvider } from "../lib/fake-auth-provider.ts"; +import { fakeServiceUrl } from "../lib/fake-service.ts"; + +export default defineDynamic({ + events: { + "session.started": () => + defineMcpClientConnection({ + description: "Private catalog that requires sign-in before discovering its tools.", + url: fakeServiceUrl("catalog").href, + instanceKey: "fixture-private-catalog", + auth: createFakeAuthProvider({ expiredToken: false }), + }), + }, +}); diff --git a/e2e/fixtures/agent-workflow-tools/evals/connection-search.authorization.eval.ts b/e2e/fixtures/agent-workflow-tools/evals/connection-search.authorization.eval.ts new file mode 100644 index 0000000000..eea033f19e --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/evals/connection-search.authorization.eval.ts @@ -0,0 +1,57 @@ +import { defineEval } from "eve/evals"; +import { z } from "zod"; + +import { fixtureAuthorizationCallback } from "../agent/lib/fake-service.ts"; + +const searchResult = z.array(z.object({ qualifiedName: z.string() })); + +export default defineEval({ + description: + "Connection search pauses for sign-in, then discovers tools over authenticated HTTP.", + timeoutMs: 90_000, + + async test(t) { + const started = await t.send( + "Alice wants to see which tools are available in private-catalog. Search for its items tools, then report the available tool names.", + ); + started.expectOk(); + started.event("authorization.required", { count: 1 }); + started.notEvent("authorization.completed"); + started.event("session.waiting", { count: 1 }); + + const required = started.events.find((event) => event.type === "authorization.required"); + if (required?.type !== "authorization.required") { + throw new Error("Connection search did not produce an authorization challenge."); + } + const callback = fixtureAuthorizationCallback(t.target.url, required.data.authorization?.url); + if (t.sessionId === undefined || t.state === undefined) { + throw new Error("Connection search did not create a session."); + } + + const resumed = t.target.watchTurn(t.sessionId, { startIndex: t.state.streamIndex }); + const response = await fetch(callback); + if (!response.ok) { + throw new Error(`Authorization callback failed (${response.status}).`); + } + + const completed = await resumed.result(); + completed.expectOk(); + completed.noFailedActions(); + completed.notEvent("authorization.required"); + completed.event("authorization.completed", { + count: 1, + data: { candidateId: required.data.candidateId, outcome: "authorized" }, + }); + completed.calledTool("connection_search", { + count: 1, + output: (value) => { + const result = searchResult.safeParse(value); + return ( + result.success && + result.data.some((entry) => entry.qualifiedName === "private-catalog__list_items") + ); + }, + }); + completed.messageIncludes("private-catalog__list_items"); + }, +}); diff --git a/packages/eve/src/execution/tools/connection-search-authorization.integration.test.ts b/packages/eve/src/execution/tools/connection-search-authorization.integration.test.ts new file mode 100644 index 0000000000..bec34b8e71 --- /dev/null +++ b/packages/eve/src/execution/tools/connection-search-authorization.integration.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ConnectionAuthorizationRequiredError } from "#connections/errors.js"; +import { ContextContainer, contextStorage } from "#context/container.js"; +import { AuthKey, SessionIdKey } from "#context/keys.js"; +import { ConnectionRegistryKey } from "#context/providers/connection-key.js"; +import { resolveConnectionSearchDynamicTools } from "#execution/tools/connection-search.js"; +import { CallbackBaseUrlKey, isAuthorizationSignal } from "#harness/authorization.js"; +import { ConnectionRegistryImpl } from "#runtime/connections/registry.js"; +import type { ResolvedConnectionDefinition } from "#runtime/types.js"; +import type { ToolContext } from "#tools/definition.js"; +import type { DynamicToolSet } from "#tools/dynamic.js"; + +function setup() { + const getToken = vi.fn(async () => { + throw new ConnectionAuthorizationRequiredError("private-catalog"); + }); + const startAuthorization = vi.fn(async () => ({ + challenge: { url: "https://identity.example/authorize" }, + })); + const privateCatalog: ResolvedConnectionDefinition = { + connectionName: "private-catalog", + description: "Private catalog", + logicalPath: "agent/connections/private-catalog.ts", + sourceId: "connections/private-catalog", + sourceKind: "module", + protocol: "mcp", + url: "https://private.example/mcp", + authorization: { + principalType: "user", + getToken, + startAuthorization, + completeAuthorization: async () => ({ token: "fixture-token" }), + }, + }; + const publicCatalog: ResolvedConnectionDefinition = { + ...privateCatalog, + connectionName: "public-catalog", + description: "Public catalog", + authorization: undefined, + protocol: "openapi", + spec: { + openapi: "3.0.0", + info: { title: "Public catalog", version: "1.0.0" }, + paths: { + "/items": { + get: { + operationId: "list_items", + summary: "List catalog items", + responses: { "200": { description: "Catalog items" } }, + }, + }, + }, + }, + }; + const registry = new ConnectionRegistryImpl([privateCatalog, publicCatalog]); + const context = new ContextContainer(); + context.set(ConnectionRegistryKey, registry); + context.set(SessionIdKey, "catalog-session"); + context.set(CallbackBaseUrlKey, "https://agent.example"); + context.set(AuthKey, { + attributes: {}, + authenticator: "fixture", + issuer: "fixture", + principalId: "alice", + principalType: "user", + }); + + async function search(connection?: string) { + return contextStorage.run(context, async () => { + const tools = (await resolveConnectionSearchDynamicTools()) as DynamicToolSet; + return tools.connection_search!.execute({ connection, keywords: "items" }, {} as ToolContext); + }); + } + + return { context, getToken, registry, search, startAuthorization }; +} + +describe("connection search callback availability", () => { + it("starts authorization when session identity and callback origin are present", async () => { + const state = setup(); + try { + expect(isAuthorizationSignal(await state.search("private-catalog"))).toBe(true); + expect(state.getToken).toHaveBeenCalledOnce(); + expect(state.startAuthorization).toHaveBeenCalledOnce(); + } finally { + await state.registry.dispose(); + } + }); + + // Characterize the PR's stricter policy. Main returned needsAuthorization instead. + // The real MCP client asks the provider for a token before making any HTTP request. + for (const missing of ["session", "origin", "both"] as const) { + it(`fails a private-only search when ${missing} is missing`, async () => { + const state = setup(); + if (missing !== "origin") state.context.delete(SessionIdKey); + if (missing !== "session") state.context.delete(CallbackBaseUrlKey); + + try { + await expect(state.search("private-catalog")).rejects.toThrow( + "no authorization callback URL could be minted", + ); + expect(state.getToken).toHaveBeenCalledOnce(); + expect(state.startAuthorization).not.toHaveBeenCalled(); + } finally { + await state.registry.dispose(); + } + }); + } + + it("keeps public tools discoverable alongside the private connection's callback error", async () => { + const state = setup(); + state.context.delete(CallbackBaseUrlKey); + + try { + const result = await state.search(); + expect(result).toEqual([ + expect.objectContaining({ qualifiedName: "public-catalog__list_items" }), + { + connection: "private-catalog", + description: "Private catalog", + error: expect.stringContaining("no authorization callback URL could be minted"), + }, + ]); + expect(state.getToken).toHaveBeenCalledOnce(); + expect(state.startAuthorization).not.toHaveBeenCalled(); + } finally { + await state.registry.dispose(); + } + }); +}); From 2912c1e132016f8e27d9228ba416e25eeda59dd6 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 8 Sep 2026 10:41:45 -0400 Subject: [PATCH 20/23] fix(e2e): keep catalog routes separate from workflow auth service Signed-off-by: Rui Conti --- e2e/fixtures/agent-workflow-tools/agent/channels/catalog.ts | 4 ++-- .../agent-workflow-tools/agent/connections/private-catalog.ts | 4 ++-- e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts | 4 ++-- .../agent-workflow-tools/agent/tools/authorize_service.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/e2e/fixtures/agent-workflow-tools/agent/channels/catalog.ts b/e2e/fixtures/agent-workflow-tools/agent/channels/catalog.ts index 4bcbd5cab8..282d061a4d 100644 --- a/e2e/fixtures/agent-workflow-tools/agent/channels/catalog.ts +++ b/e2e/fixtures/agent-workflow-tools/agent/channels/catalog.ts @@ -9,8 +9,8 @@ const rpcRequest = z.object({ // A fixture-owned MCP server: eve still resolves auth and performs real HTTP discovery. export default defineChannel({ routes: [ - GET("/fixture-service/catalog", async () => new Response(null, { status: 405 })), - POST("/fixture-service/catalog", async (request) => { + GET("/fixture-catalog/mcp", async () => new Response(null, { status: 405 })), + POST("/fixture-catalog/mcp", async (request) => { if (request.headers.get("authorization") !== "Bearer authorized-fixture-token") { return new Response("Unauthorized", { status: 401 }); } diff --git a/e2e/fixtures/agent-workflow-tools/agent/connections/private-catalog.ts b/e2e/fixtures/agent-workflow-tools/agent/connections/private-catalog.ts index 23c159eb44..0fadc20c76 100644 --- a/e2e/fixtures/agent-workflow-tools/agent/connections/private-catalog.ts +++ b/e2e/fixtures/agent-workflow-tools/agent/connections/private-catalog.ts @@ -1,14 +1,14 @@ import { defineDynamic, defineMcpClientConnection } from "eve/connections"; import { createFakeAuthProvider } from "../lib/fake-auth-provider.ts"; -import { fakeServiceUrl } from "../lib/fake-service.ts"; +import { fixtureUrl } from "../lib/fake-service.ts"; export default defineDynamic({ events: { "session.started": () => defineMcpClientConnection({ description: "Private catalog that requires sign-in before discovering its tools.", - url: fakeServiceUrl("catalog").href, + url: fixtureUrl("/fixture-catalog/mcp").href, instanceKey: "fixture-private-catalog", auth: createFakeAuthProvider({ expiredToken: false }), }), diff --git a/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts b/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts index cde3e3271c..079334c69b 100644 --- a/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts +++ b/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts @@ -1,12 +1,12 @@ /** Resolves this fixture's HTTP service in local and deployed workflow workers. */ -export function fakeServiceUrl(service: string): URL { +export function fixtureUrl(path: string): URL { const origin = process.env.WORKFLOW_LOCAL_BASE_URL ?? (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined) ?? (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined); if (origin === undefined) throw new Error("Fixture service origin is unavailable"); const prefix = process.env.EVE_PUBLIC_ROUTE_PREFIX ?? ""; - const url = new URL(`${prefix}/fixture-service/${encodeURIComponent(service)}`, origin); + const url = new URL(`${prefix}${path}`, origin); const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; if (bypass) url.searchParams.set("x-vercel-protection-bypass", bypass); return url; diff --git a/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts b/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts index f4671e44f4..b50bb47418 100644 --- a/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts +++ b/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts @@ -2,7 +2,7 @@ import { defineWorkflowTool, type WorkflowToolContext } from "eve/tools"; import { z } from "zod"; import { createFakeAuthProvider } from "../lib/fake-auth-provider.ts"; -import { fakeServiceUrl } from "../lib/fake-service.ts"; +import { fixtureUrl } from "../lib/fake-service.ts"; export default defineWorkflowTool({ description: "Exercise requester authorization inside a durable step.", @@ -17,7 +17,7 @@ async function authorizeService(ctx: WorkflowToolContext, service: string): Prom "use step"; const fakeProvider = createFakeAuthProvider({ expiredToken: service === "EXPLICIT" }); const { token } = await ctx.getToken(fakeProvider); - const response = await fetch(fakeServiceUrl(service), { + const response = await fetch(fixtureUrl(`/fixture-service/${encodeURIComponent(service)}`), { headers: { Authorization: `Bearer ${token}` }, signal: ctx.abortSignal, }); From e1d785b4fba0e81c11ad512f5b02c6ac9f68c8cb Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 8 Sep 2026 12:30:17 -0400 Subject: [PATCH 21/23] refactor(eve): confine step authorization to workflow tool modules Only authored modules and the package test fixtures register an authorization twin and route context-bearing calls to it; eve's own steps keep the plain Workflow proxy. The session driver advertises workflow-task authorization through its command hook metadata, next to the inbox wire version, instead of a SessionCapabilities field. Each challenge mints its own attempt id rather than reusing the hook token. Docs state that step input records the provider callback parameters. Signed-off-by: Rui Conti --- docs/tools/workflows.mdx | 12 ++-- packages/eve/src/channel/types.ts | 9 +-- .../eve/src/execution/runtime-context.test.ts | 21 ------- packages/eve/src/execution/runtime-context.ts | 3 +- .../execution/session-command-inbox.test.ts | 2 +- .../src/execution/session-command-inbox.ts | 2 + .../parent/tool-execution.integration.test.ts | 34 ++++++----- .../execution/tasks/parent/tool-execution.ts | 7 ++- .../tools/workflow/step-execution.ts | 5 +- .../execution/wire/session-inbox-contract.ts | 8 +++ .../execution/wire/session-inbox-resume.ts | 11 ++++ .../eve/src/execution/workflow-entry.test.ts | 27 +-------- packages/eve/src/execution/workflow-entry.ts | 10 +--- packages/eve/src/harness/authorization.ts | 12 ++-- .../workflow-bundle/workflow-builders.test.ts | 58 +++++++++++++++---- .../workflow-bundle/workflow-builders.ts | 7 +++ .../workflow-bundle/workflow-transformer.ts | 56 +++++++++++------- 17 files changed, 153 insertions(+), 131 deletions(-) diff --git a/docs/tools/workflows.mdx b/docs/tools/workflows.mdx index 43c96cbaad..5882356c0e 100644 --- a/docs/tools/workflows.mdx +++ b/docs/tools/workflows.mdx @@ -186,7 +186,9 @@ The workflow body calls `await readRepository(ctx, repository)`. > [!WARNING] > Do not return the token from the helper: step results enter the workflow's durable history. -> eve's token cache stays inside the step. +> eve's token cache stays inside the step. The step's input is recorded too, and after sign-in it +> carries the provider's callback parameters (such as a one-time authorization code), just as an +> agent turn records the callback it receives. Bearer tokens are never written. When sign-in is required, the step attempt ends and the workflow waits on its own callback hook, without holding compute. The channel renders the sign-in challenge. After the callback, eve retries @@ -196,10 +198,10 @@ not rerun. Resolve auth before other side effects in that step, and make operati again. Provider declarations can be shared imports, but context must be passed directly, not nested inside another argument or captured in a closure. -After a successful callback exchange, eve records completion before returning to authored code. -If that code later fails and the step retries, eve reads the token from the provider instead of -exchanging the same callback again. The provider must persist the grant or token; the durable -completion marker contains no credentials. +After a successful callback exchange, eve records a completion marker before returning to authored +code. If that code later fails and the step retries, eve reads the token from the provider instead +of exchanging the same callback again. The provider must persist the grant or token; the marker +itself contains no credentials. A background task becomes `input_required` during sign-in. The callback resumes that task; it does not rely on the launching agent turn still being active. Cancelling an authorization wait withdraws diff --git a/packages/eve/src/channel/types.ts b/packages/eve/src/channel/types.ts index cbceb8e0de..877bf91867 100644 --- a/packages/eve/src/channel/types.ts +++ b/packages/eve/src/channel/types.ts @@ -426,17 +426,10 @@ export interface SessionCallback { * * Channel routes that can reach a human (HTTP, Slack, etc.) set * `requestInput: true` when starting a run. Subagent dispatch inherits the - * parent's input capability, so HITL bubbles up transparently through a + * parent's capabilities pointwise, so HITL bubbles up transparently through a * conversation chain and stays disabled in a scheduled chain. */ export interface SessionCapabilities { - /** - * The session driver supports authorization events from background workflow tools. - * Set by the driver, never inherited or granted through RunInput. Older drivers - * leave this absent, so newer turns fail fast when these tools request auth. - */ - readonly workflowTaskAuthorization?: boolean; - /** * True when the session may request input from a human (tool approvals, * `ask_question`). The runtime reads this in every HITL gate: diff --git a/packages/eve/src/execution/runtime-context.test.ts b/packages/eve/src/execution/runtime-context.test.ts index eea1e703ed..94799a58fb 100644 --- a/packages/eve/src/execution/runtime-context.test.ts +++ b/packages/eve/src/execution/runtime-context.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { ContextContainer, contextStorage, loadContext } from "#context/container.js"; import { AuthKey, - CapabilitiesKey, ChannelInstrumentationKey, ContinuationTokenKey, ParentTraceContextKey, @@ -152,26 +151,6 @@ function createMinimalBundle(): Parameters[0]["bundle"] } describe("buildRunContext", () => { - it.each(["http", "subagent"])("does not inherit driver support from a %s caller", (kind) => { - const capabilities = { requestInput: true, workflowTaskAuthorization: true }; - const ctx = buildRunContext({ - bundle: createMinimalBundle(), - run: { - adapter: { kind }, - auth: null, - capabilities, - input: { message: "hello" }, - mode: "conversation", - }, - }); - - expect(ctx.get(CapabilitiesKey)).toEqual({ - requestInput: true, - workflowTaskAuthorization: false, - }); - expect(capabilities.workflowTaskAuthorization).toBe(true); - }); - it("seeds auth from the run input", () => { const ctx = buildRunContext({ bundle: createMinimalBundle(), diff --git a/packages/eve/src/execution/runtime-context.ts b/packages/eve/src/execution/runtime-context.ts index 5e075b9789..d45c0e37ad 100644 --- a/packages/eve/src/execution/runtime-context.ts +++ b/packages/eve/src/execution/runtime-context.ts @@ -56,8 +56,7 @@ export function buildRunContext(input: { } if (run.capabilities !== undefined) { - // Driver support belongs to this session, not the caller or parent session. - ctx.set(CapabilitiesKey, { ...run.capabilities, workflowTaskAuthorization: false }); + ctx.set(CapabilitiesKey, run.capabilities); } if (run.requestId !== undefined) { diff --git a/packages/eve/src/execution/session-command-inbox.test.ts b/packages/eve/src/execution/session-command-inbox.test.ts index d105dafae2..9cca2d3231 100644 --- a/packages/eve/src/execution/session-command-inbox.test.ts +++ b/packages/eve/src/execution/session-command-inbox.test.ts @@ -123,7 +123,7 @@ describe("createSessionCommandInbox", () => { ); expect(createHookMock).toHaveBeenCalledOnce(); expect(createHookMock).toHaveBeenCalledWith({ - metadata: { sessionInboxWireVersion: 6 }, + metadata: { sessionInboxWireVersion: 6, workflowTaskAuthorization: true }, token: "stable", }); await inbox.dispose(); diff --git a/packages/eve/src/execution/session-command-inbox.ts b/packages/eve/src/execution/session-command-inbox.ts index 14ee3da183..2f4c43b6b0 100644 --- a/packages/eve/src/execution/session-command-inbox.ts +++ b/packages/eve/src/execution/session-command-inbox.ts @@ -10,6 +10,7 @@ import { claimHookOwnership, disposeHook } from "#execution/hook-ownership.js"; import { SESSION_INBOX_WIRE_VERSION, SESSION_INBOX_WIRE_VERSION_METADATA_KEY, + WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY, } from "#execution/wire/session-inbox-contract.js"; /** * Payloads accepted by a session driver's stable and channel aliases. @@ -138,6 +139,7 @@ export function createSessionCommandInbox(): SessionCommandInboxHandle { const hook = createHook({ metadata: { [SESSION_INBOX_WIRE_VERSION_METADATA_KEY]: SESSION_INBOX_WIRE_VERSION, + [WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY]: true, }, token, }); diff --git a/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts b/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts index f3c4b94599..c8c4fd9110 100644 --- a/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts +++ b/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts @@ -1,8 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { SessionCapabilities } from "#channel/types.js"; import { ContextContainer, contextStorage } from "#context/container.js"; -import { CapabilitiesKey, SessionKey } from "#context/keys.js"; +import { SessionKey } from "#context/keys.js"; import { cancelOwnedTask } from "#execution/tasks/parent/dispatch.js"; import { startTaskRun, waitForTaskCommandOwner } from "#execution/tasks/parent/run-parent.js"; import { @@ -20,7 +19,13 @@ import type { HarnessSession } from "#harness/types.js"; import { getAgentHandleStore, setAgentHandleStore } from "#subagents/handles/store.js"; import { applyTaskAgentHandleCommand } from "#subagents/handles/transitions.js"; import { getSessionTaskIndex, recordSessionTask } from "#tasks/session-index.js"; +import { getHookByToken } from "#internal/workflow/runtime.js"; +import { sessionCommandHookToken } from "#execution/session-command-token.js"; +vi.mock("#internal/workflow/runtime.js", async (importOriginal) => ({ + ...(await importOriginal()), + getHookByToken: vi.fn(), +})); vi.mock("#execution/tasks/parent/dispatch.js", () => ({ cancelOwnedTask: vi.fn() })); vi.mock("#execution/tools/subagent/task-cancel.js", () => ({ cancelBackgroundAgentTask: vi.fn() })); vi.mock("#execution/tasks/parent/run-parent.js", () => ({ @@ -71,12 +76,8 @@ function createSession(owned = true): HarnessSession { return owned ? recordSessionTask(session, entry) : session; } -async function createScope( - session = createSession(), - capabilities: SessionCapabilities | undefined = undefined, -) { +async function createScope(session = createSession()) { const ctx = new ContextContainer(); - if (capabilities !== undefined) ctx.set(CapabilitiesKey, capabilities); ctx.setVirtualContext(SessionKey, { auth: { current: null, initiator: null }, sessionId: session.sessionId, @@ -124,18 +125,23 @@ describe("background subagent steering", () => { vi.mocked(cancelOwnedTask).mockResolvedValue(cancelledView); vi.mocked(startTaskRun).mockResolvedValue(undefined as never); vi.mocked(waitForTaskCommandOwner).mockResolvedValue({ runId: "steering-task-run" } as never); + vi.mocked(getHookByToken).mockResolvedValue({ + metadata: { workflowTaskAuthorization: true }, + } as never); }); it.each([ - { capabilities: undefined, supported: false }, - { capabilities: { requestInput: true }, supported: false }, - { capabilities: { workflowTaskAuthorization: false }, supported: false }, - { capabilities: { workflowTaskAuthorization: true }, supported: true }, + { metadata: undefined, supported: false }, + { metadata: { sessionInboxWireVersion: 6 }, supported: false }, + { metadata: { workflowTaskAuthorization: "true" }, supported: false }, + { metadata: { workflowTaskAuthorization: true }, supported: true }, ])( - "passes the receiving driver's auth capability to the task ($supported)", - async ({ capabilities, supported }) => { - const scope = await createScope(createSession(), capabilities); + "passes the receiving driver's auth support to the task ($supported)", + async ({ metadata, supported }) => { + vi.mocked(getHookByToken).mockResolvedValue({ metadata } as never); + const scope = await createScope(); await expect(scope.execute()).resolves.toMatchObject({ status: "working" }); + expect(getHookByToken).toHaveBeenCalledWith(sessionCommandHookToken("parent")); expect(startTaskRun).toHaveBeenCalledWith( expect.objectContaining({ workflow: expect.objectContaining({ authorizationSupported: supported }), diff --git a/packages/eve/src/execution/tasks/parent/tool-execution.ts b/packages/eve/src/execution/tasks/parent/tool-execution.ts index 49cb464a93..0716e708fa 100644 --- a/packages/eve/src/execution/tasks/parent/tool-execution.ts +++ b/packages/eve/src/execution/tasks/parent/tool-execution.ts @@ -1,6 +1,6 @@ import type { ContextContainer } from "#context/container.js"; import { loadContext } from "#context/container.js"; -import { ActivityObserverKey, CapabilitiesKey } from "#context/keys.js"; +import { ActivityObserverKey } from "#context/keys.js"; import type { FrameworkContextProvider } from "#context/provider.js"; import { runStep } from "#context/run-step.js"; import { buildCallbackContext } from "#context/build-callback-context.js"; @@ -37,6 +37,7 @@ import { waitForTaskCommandOwner, } from "#execution/tasks/parent/run-parent.js"; import { sessionCommandHookToken } from "#execution/session-command-token.js"; +import { sessionDriverSupportsWorkflowTaskAuthorization } from "#execution/wire/session-inbox-resume.js"; import { projectSubagentTask } from "#execution/tasks/parent/subagent-task-projection.js"; import { deriveAgentOperationId } from "#subagents/handles/operation-id.js"; import { AGENT_BUSY, AGENT_MISMATCH, AGENT_UNREACHABLE } from "#subagents/agent-handle-errors.js"; @@ -459,7 +460,9 @@ class BackgroundToolExecutionScope implements BackgroundToolExecutor { parentContinuationToken: sessionCommandHookToken(this.initialSession.sessionId), taskInboxToken: task.taskInboxToken, workflow: { - authorizationSupported: input.ctx.get(CapabilitiesKey)?.workflowTaskAuthorization === true, + authorizationSupported: await sessionDriverSupportsWorkflowTaskAuthorization( + this.initialSession.sessionId, + ), callId: taskInput.callId, executeInput: workflow.executeInput?.(workflowInput), input: workflowInput, diff --git a/packages/eve/src/execution/tools/workflow/step-execution.ts b/packages/eve/src/execution/tools/workflow/step-execution.ts index 9df0c2699b..a6671cb5c2 100644 --- a/packages/eve/src/execution/tools/workflow/step-execution.ts +++ b/packages/eve/src/execution/tools/workflow/step-execution.ts @@ -34,10 +34,7 @@ export function withWorkflowStepAuthorization(execute: (...args: never[]) => unk context.set(SessionIdKey, input.session.id); context.setVirtualContext(SessionKey, { ...input.session, sessionId: input.session.id }); context.set(CallbackBaseUrlKey, resolveWorkflowCallbackBaseUrl(input.baseUrl)); - context.setVirtualContext(AuthorizationHookKey, { - token: input.token, - attemptId: input.token, - }); + context.setVirtualContext(AuthorizationHookKey, input.token); context.setVirtualContext(PendingAuthorizationResultKey, input.authorizationResults); return contextStorage.run(context, async (): Promise => { diff --git a/packages/eve/src/execution/wire/session-inbox-contract.ts b/packages/eve/src/execution/wire/session-inbox-contract.ts index b50fffbb4a..eef65f157d 100644 --- a/packages/eve/src/execution/wire/session-inbox-contract.ts +++ b/packages/eve/src/execution/wire/session-inbox-contract.ts @@ -10,6 +10,14 @@ export const SESSION_INBOX_WIRE_VERSION = /** Hook metadata field advertising the consumer's inbox wire capability. */ export const SESSION_INBOX_WIRE_VERSION_METADATA_KEY = "sessionInboxWireVersion"; +/** + * Hook metadata field advertising that the driver displays authorization + * events raised by a workflow task itself. Drivers pinned before this marker + * drop those events, so producers fail fast instead of waiting on a callback + * no channel will render. + */ +export const WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY = "workflowTaskAuthorization"; + export const SESSION_INBOX_CONTEXT_KEY = "eve.sessionInbox"; /** Immutable inbox coordinates advertised by the receiving session's driver. */ diff --git a/packages/eve/src/execution/wire/session-inbox-resume.ts b/packages/eve/src/execution/wire/session-inbox-resume.ts index aa89089876..d59f5dd57f 100644 --- a/packages/eve/src/execution/wire/session-inbox-resume.ts +++ b/packages/eve/src/execution/wire/session-inbox-resume.ts @@ -11,6 +11,7 @@ import { } from "#execution/session-command-token.js"; import { SESSION_INBOX_WIRE_VERSION_METADATA_KEY, + WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY, isSessionInboxAddress, isSessionInboxWireVersion, SessionInboxWireError, @@ -67,6 +68,16 @@ function isStableInboxFastPathCompatible( type SessionInboxHook = Awaited>; +/** Whether the session's pinned driver displays authorization events raised by workflow tasks. */ +export async function sessionDriverSupportsWorkflowTaskAuthorization( + sessionId: string, +): Promise { + const driver = await getHookByToken(sessionCommandHookToken(sessionId)); + return ( + isObject(driver.metadata) && driver.metadata[WORKFLOW_TASK_AUTHORIZATION_METADATA_KEY] === true + ); +} + /** Selects the encoder understood by a persisted hook's consumer deployment. */ export async function resolveSessionInboxWireTarget( hook: SessionInboxHook, diff --git a/packages/eve/src/execution/workflow-entry.test.ts b/packages/eve/src/execution/workflow-entry.test.ts index a370377835..0b9eb5ff0d 100644 --- a/packages/eve/src/execution/workflow-entry.test.ts +++ b/packages/eve/src/execution/workflow-entry.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createHook } from "#compiled/@workflow/core/index.js"; import { resumeHook } from "#internal/workflow/runtime.js"; -import type { HookPayload, SessionCapabilities } from "#channel/types.js"; +import type { HookPayload } from "#channel/types.js"; import { ChannelRequestIdKey } from "#context/keys.js"; import { createSessionStep } from "#execution/create-session-step.js"; import { @@ -170,31 +170,6 @@ describe("workflowEntry", () => { vi.unstubAllEnvs(); }); - it.each([undefined, { requestInput: true, workflowTaskAuthorization: false }])( - "advertises its own auth support while preserving session capabilities (%j)", - async (supplied) => { - const sessionState = createBaseSessionState(); - vi.mocked(createSessionStep).mockResolvedValue(createSessionStepResultForMock(sessionState)); - installHookMocks({ - deliveryHooks: [{ token: "http:test" }], - turnControls: [turnResult({ action: "done", output: "ok", sessionState })], - }); - - await workflowEntry({ - input: { message: "hello" }, - serializedContext: createSerializedContext({ "eve.capabilities": supplied }), - }); - - const expected: SessionCapabilities = { ...supplied, workflowTaskAuthorization: true }; - expect(dispatchTurnStep).toHaveBeenCalledWith( - expect.objectContaining({ - capabilities: expected, - serializedContext: expect.objectContaining({ "eve.capabilities": expected }), - }), - ); - }, - ); - it("injects the workflow run id as the canonical session id before the first turn", async () => { const sessionState = createBaseSessionState(); const getConflict = vi.fn(async () => null); diff --git a/packages/eve/src/execution/workflow-entry.ts b/packages/eve/src/execution/workflow-entry.ts index e1cee46519..d3cfb0bd37 100644 --- a/packages/eve/src/execution/workflow-entry.ts +++ b/packages/eve/src/execution/workflow-entry.ts @@ -153,13 +153,9 @@ export async function workflowEntry(input: WorkflowEntryInput): Promise("eve.callbackBaseUrl"); -/** The executing runtime may own its callback hook instead of using the session hook. */ -export const AuthorizationHookKey = new ContextKey<{ - readonly token: string; - readonly attemptId?: string; -}>("eve.authorizationHook"); +/** Hook token of a runtime that owns its callback instead of using the session hook. */ +export const AuthorizationHookKey = new ContextKey("eve.authorizationHook"); // --------------------------------------------------------------------------- // Session state persistence (internal — used by framework only) diff --git a/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts b/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts index d2999a85a1..31472baba5 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts @@ -132,17 +132,57 @@ describe("applyWorkflowTransform", () => { ping: { stepId: "step//./steps/ping//ping", }, - "ping:eve-authorization": { stepId: "step//./steps/ping//ping:eve-authorization" }, }, }, }); expect(transformed.code).toContain( 'import { registerStepFunction } from "workflow/internal/private";', ); + expect(transformed.code).toContain('registerStepFunction("step//./steps/ping//ping", ping);'); + expect(transformed.code).not.toContain("eve-authorization"); + expect(transformed.code).not.toContain('"use step"'); + }); + + it("registers authorization twins only for package test fixtures", async () => { + const source = [ + "export async function ping(input: { value: string }): Promise {", + ' "use step";', + " return input.value;", + "}", + "", + ].join("\n"); + const fixturePath = resolvePackageSourceFilePath("src/internal/testing/ping.ts"); + const transformed = await applyWorkflowTransform( + "src/internal/testing/ping.ts", + source, + "step", + fixturePath, + ); + expect(transformed.workflowManifest.steps?.["src/internal/testing/ping.ts"]).toEqual({ + ping: { stepId: "step//./src/internal/testing/ping//ping" }, + "ping:eve-authorization": { + stepId: "step//./src/internal/testing/ping//ping:eve-authorization", + }, + }); expect(transformed.code).toContain( - 'registerStepFunction("step//./steps/ping//ping:eve-authorization", withWorkflowStepAuthorization(ping));', + 'import { withWorkflowStepAuthorization } from "#execution/tools/workflow/step-execution.js";', + ); + expect(transformed.code).toContain( + 'registerStepFunction("step//./src/internal/testing/ping//ping:eve-authorization", withWorkflowStepAuthorization(ping));', + ); + + const workflow = await applyWorkflowTransform( + "src/internal/testing/ping.ts", + source, + "workflow", + fixturePath, + ); + expect(workflow.code).toContain( + 'import { workflowToolStep } from "#execution/tools/workflow/step.js";', + ); + expect(workflow.code).toContain( + 'export var ping = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/internal/testing/ping//ping"), globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/internal/testing/ping//ping:eve-authorization"));', ); - expect(transformed.code).not.toContain('"use step"'); }); it("replaces step functions with workflow proxies in workflow mode", async () => { @@ -165,8 +205,9 @@ describe("applyWorkflowTransform", () => { ); expect(transformed.code).toContain( - 'export var localStep = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/task//localStep"), globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/task//localStep:eve-authorization"));', + 'export var localStep = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/task//localStep");', ); + expect(transformed.code).not.toContain("workflowToolStep"); expect(transformed.code).toContain('export const TASK_KIND = "task";'); expect(transformed.code).toContain("export const RETRY_OFFSET = -1;"); expect(transformed.code).not.toContain("node:crypto"); @@ -235,10 +276,6 @@ describe("applyWorkflowTransform", () => { notifyDelegatedParentStep: { stepId: "step//./src/execution/workflow-entry//notifyDelegatedParentStep", }, - "notifyDelegatedParentStep:eve-authorization": { - stepId: - "step//./src/execution/workflow-entry//notifyDelegatedParentStep:eve-authorization", - }, }, }, workflows: { @@ -251,7 +288,7 @@ describe("applyWorkflowTransform", () => { }); expect(transformed.code).toContain("async function runWorkflowLoop"); expect(transformed.code).toContain( - 'var notifyDelegatedParentStep = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/workflow-entry//notifyDelegatedParentStep"), globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/workflow-entry//notifyDelegatedParentStep:eve-authorization"));', + 'var notifyDelegatedParentStep = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./src/execution/workflow-entry//notifyDelegatedParentStep");', ); expect(transformed.code).not.toContain("step//./src/execution/workflow-entry//runWorkflowLoop"); }); @@ -287,9 +324,6 @@ describe("applyWorkflowTransform", () => { notifyDriverStep: { stepId: "step//eve@1.2.3//notifyDriverStep", }, - "notifyDriverStep:eve-authorization": { - stepId: "step//eve@1.2.3//notifyDriverStep:eve-authorization", - }, }, }, workflows: { diff --git a/packages/eve/src/internal/workflow-bundle/workflow-builders.ts b/packages/eve/src/internal/workflow-bundle/workflow-builders.ts index 3fd696272b..4035d16bed 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-builders.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-builders.ts @@ -87,6 +87,7 @@ export async function applyWorkflowTransform( // package directory) and the server bundle (built from the app) agree. return transformWorkflowDirectives({ authored: true, + authorizeSteps: true, filename: authoredRelativePath(absolutePath, resolvedProjectRoot), mode: prepared?.hasDirectives === true ? mode : false, moduleSpecifier: authoredModuleIdBase(absolutePath, resolvedProjectRoot), @@ -97,6 +98,8 @@ export async function applyWorkflowTransform( } return transformWorkflowDirectives({ + // The test harness authors workflow tools inside eve's own package. + authorizeSteps: isPackageTestFixtureModule(absoluteFilename), filename, mode, moduleSpecifier, @@ -106,6 +109,10 @@ export async function applyWorkflowTransform( }); } +function isPackageTestFixtureModule(absolutePath: string): boolean { + return absolutePath.replace(/\\/g, "/").includes("/src/internal/testing/"); +} + export function isAuthoredApplicationModule(absolutePath: string, appRoot: string): boolean { const normalizedRoot = toRealPath(appRoot).replace(/\\/g, "/").replace(/\/$/, ""); const normalizedPath = toRealPath(absolutePath).replace(/\\/g, "/"); diff --git a/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts b/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts index 950704da8c..fddaae0299 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts @@ -95,6 +95,11 @@ export async function transformWorkflowDirectives(input: { * evaluates the tool definition or its schema dependencies. */ authored?: boolean; + /** + * Register an authorization twin per step and route calls that pass a `WorkflowToolContext` + * to it. Only modules that can define workflow tools need this, never eve's own steps. + */ + authorizeSteps?: boolean; filename: string; mode: WorkflowDirectiveMode; moduleSpecifier: string | undefined; @@ -122,9 +127,10 @@ export async function transformWorkflowDirectives(input: { const ast = await parseWorkflowSource(input.filename, input.source); const functions = findDirectiveFunctions(ast); - const hasAuthorizationSteps = functions.some( - (fn) => fn.directive === "use step" && !BUILTIN_STEP_NAMES.has(fn.name), - ); + const authorizeSteps = input.authorizeSteps === true; + const hasAuthorizationSteps = + authorizeSteps && + functions.some((fn) => fn.directive === "use step" && !BUILTIN_STEP_NAMES.has(fn.name)); if (functions.length === 0) { return { code: input.source, workflowManifest: {} }; @@ -140,12 +146,11 @@ export async function transformWorkflowDirectives(input: { const replacements: { end: number; start: number; text: string }[] = []; const suffixes: string[] = []; let hasStepRegistration = false; - const workflowStepImport = - input.authored === true ? "eve/internal/workflow-step" : "#execution/tools/workflow/step.js"; - const stepExecutionImport = + // Authored bundles resolve eve's package exports; the test harness resolves source aliases. + const [workflowStepImport, stepExecutionImport] = input.authored === true - ? "eve/internal/workflow-step-execution" - : "#execution/tools/workflow/step-execution.js"; + ? ["eve/internal/workflow-step", "eve/internal/workflow-step-execution"] + : ["#execution/tools/workflow/step.js", "#execution/tools/workflow/step-execution.js"]; for (const fn of functions) { if (fn.directive === "use step") { @@ -153,17 +158,16 @@ export async function transformWorkflowDirectives(input: { manifest.steps ??= {}; const stepsForFile = (manifest.steps[input.filename] ??= {}); stepsForFile[fn.name] = { stepId }; - const authorizationName = `${fn.name}:eve-authorization`; - const authorizationStepId = createStepId(defaultIdBase, authorizationName); - if (!BUILTIN_STEP_NAMES.has(fn.name)) - stepsForFile[authorizationName] = { stepId: authorizationStepId }; + const authorizationStepId = authorizationTwinId(defaultIdBase, fn.name, authorizeSteps); + if (authorizationStepId !== undefined) + stepsForFile[`${fn.name}${AUTHORIZATION_STEP_SUFFIX}`] = { stepId: authorizationStepId }; if (input.mode === "workflow") { const exportPrefix = fn.exportPrefix.length > 0 ? "export " : ""; replacements.push({ end: fn.rangeEnd, start: fn.rangeStart, - text: `${exportPrefix}var ${fn.name} = ${createStepProxy(defaultIdBase, fn.name)};`, + text: `${exportPrefix}var ${fn.name} = ${createStepProxy(defaultIdBase, fn.name, authorizeSteps)};`, }); } else if (input.mode === "metadata") { continue; @@ -173,7 +177,7 @@ export async function transformWorkflowDirectives(input: { if (input.mode === "step") { hasStepRegistration = true; suffixes.push(`registerStepFunction(${JSON.stringify(stepId)}, ${fn.name});`); - if (!BUILTIN_STEP_NAMES.has(fn.name)) + if (authorizationStepId !== undefined) suffixes.push( `registerStepFunction(${JSON.stringify(authorizationStepId)}, withWorkflowStepAuthorization(${fn.name}));`, ); @@ -216,7 +220,7 @@ export async function transformWorkflowDirectives(input: { if (input.mode === "workflow" && !hasWorkflowDirective && input.authored !== true) { return { - code: `${hasAuthorizationSteps ? `import { workflowToolStep } from ${JSON.stringify(workflowStepImport)};\n` : ""}${manifestComment}\n${createWorkflowStepProxySource(input.source, ast, functions, defaultIdBase)}`, + code: `${hasAuthorizationSteps ? `import { workflowToolStep } from ${JSON.stringify(workflowStepImport)};\n` : ""}${manifestComment}\n${createWorkflowStepProxySource(input.source, ast, functions, defaultIdBase, authorizeSteps)}`, workflowManifest: manifest, }; } @@ -250,6 +254,7 @@ function createWorkflowStepProxySource( ast: AstProgram, functions: readonly DirectiveFunction[], idBase: string, + authorizeSteps: boolean, ): string { const literalExports = findExportedLiteralValueDeclarations(source, ast); const proxies = functions @@ -260,18 +265,27 @@ function createWorkflowStepProxySource( // carry the `export ` keyword whenever the function was reachable // to importers. const exportPrefix = fn.exported ? "export " : ""; - return `${exportPrefix}var ${fn.name} = ${createStepProxy(idBase, fn.name)};`; + return `${exportPrefix}var ${fn.name} = ${createStepProxy(idBase, fn.name, authorizeSteps)};`; }); const lines = [...literalExports, ...proxies]; return lines.length > 0 ? `${lines.join("\n")}\n` : ""; } -function createStepProxy(idBase: string, name: string): string { - const proxy = `globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(createStepId(idBase, name))})`; - // Workflow invokes built-ins directly, with native arguments and a bound receiver. - const authorized = `globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(createStepId(idBase, `${name}:eve-authorization`))})`; - return BUILTIN_STEP_NAMES.has(name) ? proxy : `workflowToolStep(${proxy}, ${authorized})`; +const AUTHORIZATION_STEP_SUFFIX = ":eve-authorization"; + +/** Workflow invokes built-ins directly, with native arguments and a bound receiver. */ +function authorizationTwinId(idBase: string, name: string, enabled: boolean): string | undefined { + if (!enabled || BUILTIN_STEP_NAMES.has(name)) return undefined; + return createStepId(idBase, `${name}${AUTHORIZATION_STEP_SUFFIX}`); +} + +function createStepProxy(idBase: string, name: string, authorizeSteps: boolean): string { + const useStep = (id: string) => + `globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(id)})`; + const proxy = useStep(createStepId(idBase, name)); + const twinId = authorizationTwinId(idBase, name, authorizeSteps); + return twinId === undefined ? proxy : `workflowToolStep(${proxy}, ${useStep(twinId)})`; } function findDirectiveFunctions(ast: AstProgram): DirectiveFunction[] { From 706e2ebeddfe4aa49ea95432fdb1120aa71749e7 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 8 Sep 2026 16:32:12 -0400 Subject: [PATCH 22/23] fix(eve): resolve the workflow step wrapper without an app eve dependency The driver bundle always resolves eve source aliases, so the transform imports the step wrapper through #execution instead of a package export that a bare app cannot resolve. The eve imports plugin falls back to eve's own package root when the bundle builds from another directory. Removes the unused eve/internal/workflow-step export. Signed-off-by: Rui Conti --- packages/eve/package.json | 5 --- .../workflow-bundle/builder-support.ts | 35 ++++++++++++------- .../workflow-bundle/workflow-builders.test.ts | 8 +++++ .../workflow-bundle/workflow-transformer.ts | 10 +++--- 4 files changed, 35 insertions(+), 23 deletions(-) diff --git a/packages/eve/package.json b/packages/eve/package.json index 33fc9d0e74..1937775036 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -290,11 +290,6 @@ "import": "./dist/src/public/context/index.js", "default": "./dist/src/public/context/index.js" }, - "./internal/workflow-step": { - "types": "./dist/src/execution/tools/workflow/step.d.ts", - "import": "./dist/src/execution/tools/workflow/step.js", - "default": "./dist/src/execution/tools/workflow/step.js" - }, "./internal/workflow-step-execution": { "types": "./dist/src/execution/tools/workflow/step-execution.d.ts", "import": "./dist/src/execution/tools/workflow/step-execution.js", diff --git a/packages/eve/src/internal/workflow-bundle/builder-support.ts b/packages/eve/src/internal/workflow-bundle/builder-support.ts index c5d4c79b79..e9bcfdc68e 100644 --- a/packages/eve/src/internal/workflow-bundle/builder-support.ts +++ b/packages/eve/src/internal/workflow-bundle/builder-support.ts @@ -8,7 +8,7 @@ import { atomicWriteFile } from "#shared/atomic-write-file.js"; import { buildSingleRolldownChunk } from "#internal/bundler/nitro-rolldown.js"; import { normalizeEsmImportSpecifier } from "#internal/application/import-specifier.js"; -import { resolveWorkflowModulePath } from "#internal/application/package.js"; +import { resolvePackageRoot, resolveWorkflowModulePath } from "#internal/application/package.js"; import { applyWorkflowTransform, getImportPath, @@ -269,6 +269,9 @@ export function createEvePackageImportsPlugin( workingDir: string, options: { workflowCondition?: boolean } = {}, ): WorkflowRolldownPlugin { + // Production builds from eve's package root. Fixtures that build from another + // directory still need eve's own modules, e.g. the step wrapper the transform injects. + const roots = [...new Set([workingDir, resolvePackageRoot()])]; return { name: "eve-package-imports", resolveId(source: string) { @@ -276,16 +279,20 @@ export function createEvePackageImportsPlugin( if (compiledSubpath !== undefined) { if (options.workflowCondition === true && compiledSubpath === "@workflow/core/index.js") { - return resolveFirstExistingPath([ - join(workingDir, "src", "internal", "workflow-bundle", "workflow-core-shim.ts"), - join(workingDir, "dist", "src", "internal", "workflow-bundle", "workflow-core-shim.js"), - ]); + return resolveFirstExistingPath( + roots.flatMap((root) => [ + join(root, "src", "internal", "workflow-bundle", "workflow-core-shim.ts"), + join(root, "dist", "src", "internal", "workflow-bundle", "workflow-core-shim.js"), + ]), + ); } - return resolveFirstExistingPath([ - join(workingDir, ".generated", "compiled", compiledSubpath), - join(workingDir, "dist", "src", "compiled", compiledSubpath), - ]); + return resolveFirstExistingPath( + roots.flatMap((root) => [ + join(root, ".generated", "compiled", compiledSubpath), + join(root, "dist", "src", "compiled", compiledSubpath), + ]), + ); } const sourceSubpath = source.match(/^#(.+)\.js$/)?.[1]; @@ -295,10 +302,12 @@ export function createEvePackageImportsPlugin( } return resolveFirstExistingPath( - WORKFLOW_SOURCE_EXTENSIONS.flatMap((extension) => [ - join(workingDir, "src", `${sourceSubpath}${extension}`), - join(workingDir, "dist", "src", `${sourceSubpath}${extension}`), - ]), + roots.flatMap((root) => + WORKFLOW_SOURCE_EXTENSIONS.flatMap((extension) => [ + join(root, "src", `${sourceSubpath}${extension}`), + join(root, "dist", "src", `${sourceSubpath}${extension}`), + ]), + ), ); }, }; diff --git a/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts b/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts index 31472baba5..33a6780d9e 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-builders.test.ts @@ -395,6 +395,10 @@ describe("applyWorkflowTransform for authored application modules", () => { }, }, }); + // The driver bundle resolves eve source aliases even when the app has no eve dependency. + expect(transformed.code).toContain( + 'import { workflowToolStep } from "#execution/tools/workflow/step.js";', + ); expect(transformed.code).toContain( 'var planDeploy = workflowToolStep(globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/tools/deploy//planDeploy"), globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./agent/tools/deploy//planDeploy:eve-authorization"));', ); @@ -438,6 +442,10 @@ describe("applyWorkflowTransform for authored application modules", () => { appRoot, ); + // Step registrations are bundled by the app, which resolves eve's package export. + expect(transformed.code).toContain( + 'import { withWorkflowStepAuthorization } from "eve/internal/workflow-step-execution";', + ); expect(transformed.code).toContain( 'registerStepFunction("step//./agent/tools/deploy//planDeploy:eve-authorization", withWorkflowStepAuthorization(planDeploy));', ); diff --git a/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts b/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts index fddaae0299..7297381e50 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts @@ -146,11 +146,11 @@ export async function transformWorkflowDirectives(input: { const replacements: { end: number; start: number; text: string }[] = []; const suffixes: string[] = []; let hasStepRegistration = false; - // Authored bundles resolve eve's package exports; the test harness resolves source aliases. - const [workflowStepImport, stepExecutionImport] = - input.authored === true - ? ["eve/internal/workflow-step", "eve/internal/workflow-step-execution"] - : ["#execution/tools/workflow/step.js", "#execution/tools/workflow/step-execution.js"]; + // The driver bundle resolves eve aliases itself; app-bundled step registrations need the export. + const workflowStepImport = "#execution/tools/workflow/step.js"; + const stepExecutionImport = input.authored + ? "eve/internal/workflow-step-execution" + : "#execution/tools/workflow/step-execution.js"; for (const fn of functions) { if (fn.directive === "use step") { From ab68590eff13a15db05440d22bf1b454d7873de6 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 8 Sep 2026 16:37:24 -0400 Subject: [PATCH 23/23] chore(eve): retain tool epoch 32 compatibility Main advanced the tool capability to epoch 32. Retain a representative label-enabled epoch 32 fixture and publish epoch 33 for workflow step authorization. Signed-off-by: Rui Conti --- .../compatibility/tool/v32.ts | 24 +++++++++++++++++++ .../extension-contracts/reports/tool/v33.json | 20 ++++++++++++++++ .../src/compiler/extension-compatibility.ts | 4 ++-- .../eve/src/context/build-dynamic-tools.ts | 14 +++++++++-- 4 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 packages/eve/extension-contracts/compatibility/tool/v32.ts create mode 100644 packages/eve/extension-contracts/reports/tool/v33.json diff --git a/packages/eve/extension-contracts/compatibility/tool/v32.ts b/packages/eve/extension-contracts/compatibility/tool/v32.ts new file mode 100644 index 0000000000..8666de217f --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/tool/v32.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { defineTool, defineWorkflowTool } from "#public/tools/index.js"; + +export const write = defineTool({ + description: "Write an approved message.", + inputSchema: z.object({ message: z.string() }), + outputSchema: z.object({ written: z.string() }), + approval: ({ toolInput }) => (toolInput?.message ? "user-approval" : "not-applicable"), + label: { + start: (input) => `Write ${input.message}`, + complete: (_input, output) => `Wrote ${output.written}`, + }, + execute: (input) => ({ written: input.message }), + toModelOutput: (output) => ({ type: "text", value: output.written }), +}); + +export const workflow = defineWorkflowTool({ + description: "Run a report workflow.", + inputSchema: z.object({ report: z.string() }), + async execute(input) { + "use workflow"; + return { report: input.report }; + }, +}); diff --git a/packages/eve/extension-contracts/reports/tool/v33.json b/packages/eve/extension-contracts/reports/tool/v33.json new file mode 100644 index 0000000000..bca8de69b1 --- /dev/null +++ b/packages/eve/extension-contracts/reports/tool/v33.json @@ -0,0 +1,20 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "tool", + "epoch": 33, + "sha256": "480b742f27c409b93203eb233e86ea6923cd2a8727d410c10493407c412fb4a0", + "exports": [ + "defaultWebSearch", + "defineTool", + "defineWorkflowTool", + "disableTool", + "experimental_workflow", + "isDisabledToolSentinel", + "isExperimentalWorkflowToolDefinition", + "isWebSearchToolDefinition", + "toolOutput", + "toolOutputPart", + "toolResultFrom", + "webSearch" + ] +} diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index 4922160fa8..4f6da39e7b 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -22,8 +22,8 @@ interface ExtensionCapabilityContract { const EXTENSION_CAPABILITY_CONTRACTS = { extension: { current: 1, supported: [1], dropped: {} }, tool: { - current: 32, - supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30, 31, 32], + current: 33, + supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30, 31, 32, 33], dropped: { 14: "TaskExec.delegated was removed; migrate to workflow-backed background tools", 15: "TaskExec replaces stageEffect with send", diff --git a/packages/eve/src/context/build-dynamic-tools.ts b/packages/eve/src/context/build-dynamic-tools.ts index af27c3d919..a80d55db3d 100644 --- a/packages/eve/src/context/build-dynamic-tools.ts +++ b/packages/eve/src/context/build-dynamic-tools.ts @@ -111,8 +111,18 @@ export function replayDynamicTools( "labelComplete", entry.callbacks.label?.complete, ); - const labelDelta = bindDynamicCallback(entry, owner, "labelDelta", entry.callbacks.label?.delta); - const labelStart = bindDynamicCallback(entry, owner, "labelStart", entry.callbacks.label?.start); + const labelDelta = bindDynamicCallback( + entry, + owner, + "labelDelta", + entry.callbacks.label?.delta, + ); + const labelStart = bindDynamicCallback( + entry, + owner, + "labelStart", + entry.callbacks.label?.start, + ); const toModelOutput = bindDynamicCallback( entry, owner,