diff --git a/.changeset/workflow-step-authorization.md b/.changeset/workflow-step-authorization.md new file mode 100644 index 0000000000..a7093fea5e --- /dev/null +++ b/.changeset/workflow-step-authorization.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +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 e4bad779c5..6835aaa381 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. @@ -154,6 +154,65 @@ 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(); +} +``` + +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)`. + + + Step results enter the workflow's durable history, so do not return the token from the helper. + eve's token cache stays inside the step. The step input is recorded too and, after sign-in, + carries the provider's callback parameters, such as a one-time authorization code. This matches + how agent turns record callbacks. 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 +**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. + +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 +its callback. Cancellation uses the existing turn or task cancellation path rather than a separate +authorization completion event. + +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` ```ts diff --git a/e2e/fixtures/agent-workflow-tools/agent/agent.ts b/e2e/fixtures/agent-workflow-tools/agent/agent.ts index 46b455cc7e..34306b10f2 100644 --- a/e2e/fixtures/agent-workflow-tools/agent/agent.ts +++ b/e2e/fixtures/agent-workflow-tools/agent/agent.ts @@ -8,6 +8,28 @@ 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"); + 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/channels/catalog.ts b/e2e/fixtures/agent-workflow-tools/agent/channels/catalog.ts new file mode 100644 index 0000000000..282d061a4d --- /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-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 }); + } + + 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/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/connections/private-catalog.ts b/e2e/fixtures/agent-workflow-tools/agent/connections/private-catalog.ts new file mode 100644 index 0000000000..0fadc20c76 --- /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 { 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: fixtureUrl("/fixture-catalog/mcp").href, + instanceKey: "fixture-private-catalog", + auth: createFakeAuthProvider({ expiredToken: false }), + }), + }, +}); 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/lib/fake-service.ts b/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts new file mode 100644 index 0000000000..69c12a1b64 --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts @@ -0,0 +1,33 @@ +/** Resolves this fixture's HTTP service in local and deployed workflow workers. */ +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}${path}`, origin); + const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + 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/agent/tools/authorize_service.ts b/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts new file mode 100644 index 0000000000..b50bb47418 --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts @@ -0,0 +1,27 @@ +import { defineWorkflowTool, type WorkflowToolContext } from "eve/tools"; +import { z } from "zod"; + +import { createFakeAuthProvider } from "../lib/fake-auth-provider.ts"; +import { fixtureUrl } from "../lib/fake-service.ts"; + +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 fakeProvider = createFakeAuthProvider({ expiredToken: service === "EXPLICIT" }); + const { token } = await ctx.getToken(fakeProvider); + const response = await fetch(fixtureUrl(`/fixture-service/${encodeURIComponent(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(); +} 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..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,6 @@ -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" }; @@ -20,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}.`); } @@ -56,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}.`); } @@ -73,23 +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(); - turn.noFailedActions(); + if (!options.allowFailedActions) { + turn.noFailedActions(); + } session = live.session; } + throw new Error(`Probe did not surface ${type}.`); } @@ -99,3 +125,44 @@ function watchNext(t: EveEvalContext, session: SessionCursor) { } return t.target.watchTurn(session.sessionId, { startIndex: session.state.streamIndex }); } + +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}).`); + } + + 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 = 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", { + 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/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/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..4087672d9b --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/evals/step-auth.explicit.eval.ts @@ -0,0 +1,12 @@ +import { defineEval } from "eve/evals"; + +import { runStepAuth } from "./agent-probe.shared.ts"; + +export default defineEval({ + description: "A rejected token triggers sign-in through ctx.requireAuth, then the step succeeds.", + timeoutMs: 90_000, + + async test(t) { + 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 new file mode 100644 index 0000000000..571f5012ee --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/evals/step-auth.implicit.eval.ts @@ -0,0 +1,12 @@ +import { defineEval } from "eve/evals"; + +import { runStepAuth } from "./agent-probe.shared.ts"; + +export default defineEval({ + description: "A missing token triggers sign-in through ctx.getToken, then the step succeeds.", + timeoutMs: 90_000, + + async test(t) { + 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 new file mode 100644 index 0000000000..70eb9e6284 --- /dev/null +++ b/e2e/fixtures/agent-workflow-tools/evals/step-auth.rejected.eval.ts @@ -0,0 +1,13 @@ +import { defineEval } from "eve/evals"; + +import { runRejectedStepAuth } from "./agent-probe.shared.ts"; + +export default defineEval({ + description: + "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/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" }); 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/package.json b/packages/eve/package.json index e2dc5f7ca3..1937775036 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -290,6 +290,11 @@ "import": "./dist/src/public/context/index.js", "default": "./dist/src/public/context/index.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 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, 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/child/workflow.test.ts b/packages/eve/src/execution/tasks/child/workflow.test.ts index b7a7ccd98d..a864b583c3 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,46 @@ const workflowAgentRequest = { }, } satisfies 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", + 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" }), + }, + }, + } satisfies WorkflowToolRunMessage; +} + +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 +167,116 @@ 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("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" }); + + 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 5e1034fa72..29536591fc 100644 --- a/packages/eve/src/execution/tasks/child/workflow.ts +++ b/packages/eve/src/execution/tasks/child/workflow.ts @@ -19,7 +19,11 @@ import { type WorkflowBodyDefinition, type WorkflowBodyResult, } from "#execution/tools/workflow/body.js"; -import type { WorkflowToolRunRequestMessage } from "#execution/tools/workflow/messages.js"; +import { resumeHookStep } from "#execution/tools/workflow/resume-hook-step.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 { @@ -270,10 +274,8 @@ 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 { - if (dispatchRejected || isTerminalTaskStatus(view.status)) return; if (!dispatchAcknowledged) { pendingTraffic.ownerRequests.push(message); return; } - await wakeTaskOwnerRequestParent(message); + const { request, replyTo } = message; + if ( + request.kind === "authorization-request" && + request.event.childSessionId === message.from.runId + ) { + 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; + const closesDisplayedPrompt = event.type === "authorization.completed"; + const canForward = + !dispatchRejected && (!isTerminalTaskStatus(view.status) || closesDisplayedPrompt); + + if (canForward) { + const requestId = "attemptId" in event.data ? event.data.attemptId : undefined; + if (requestId !== undefined && event.type === "authorization.required") { + const existingRequests = view.status === "input_required" ? view.inputRequests : []; + await transitionTask({ + kind: "require-input", + inputRequests: [ + ...existingRequests, + { kind: "authorization", requestId, name: event.data.name }, + ], + }); + } else if (requestId !== undefined) { + await transitionTask({ kind: "answered", requestIds: [requestId] }); + } + + await wakeTaskAuthorizationParentStep({ + request, + taskId: view.taskId, + token: input.parentContinuationToken, + }); + } + + // Discarded events are acknowledged too; persistence and delivery failures are not. + await resumeHookStep(replyTo, null, { ifPresent: true }); } async function flushPendingTraffic(): Promise { @@ -332,27 +398,13 @@ export async function taskRunWorkflow(input: TaskRunWorkflowInput): Promise { - if (message.request.kind === "authorization-request") { - await wakeTaskAuthorizationParentStep({ - request: message.request, - taskId: view.taskId, - token: input.parentContinuationToken, - }); - return; - } - await wakeTaskAgentRequestParentStep({ - request: message, - taskId: view.taskId, - token: input.parentContinuationToken, - }); - } } async function* awaitBodyResult( 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..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 @@ -19,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", () => ({ @@ -119,8 +125,31 @@ 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([ + { 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 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 }), + }), + ); + }, + ); + 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..0716e708fa 100644 --- a/packages/eve/src/execution/tasks/parent/tool-execution.ts +++ b/packages/eve/src/execution/tasks/parent/tool-execution.ts @@ -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,6 +460,9 @@ class BackgroundToolExecutionScope implements BackgroundToolExecutor { parentContinuationToken: sessionCommandHookToken(this.initialSession.sessionId), taskInboxToken: task.taskInboxToken, workflow: { + authorizationSupported: await sessionDriverSupportsWorkflowTaskAuthorization( + this.initialSession.sessionId, + ), callId: taskInput.callId, executeInput: workflow.executeInput?.(workflowInput), input: workflowInput, diff --git a/packages/eve/src/execution/tool-auth.ts b/packages/eve/src/execution/tool-auth.ts index feeea49fd4..5f2172bc37 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,38 @@ 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) { 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); - } - 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); } - } + 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 +65,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-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(); + } + }); +}); 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..eca9a738f8 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,33 @@ 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 +184,8 @@ async function executeConnectionSearch( ); } - const justAuthorized = await completePendingAuthorizations(registry, targetConnections); + const auth = createAuthorizationExecution(); + await completePendingAuthorizations(registry, targetConnections, auth); const authChallenges: AuthorizationChallenge[] = []; @@ -219,59 +196,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, + }); + failedConnections.push({ + connection: conn.connectionName, + description: conn.description, + error: isConnectionAuthorizationFailedError(error) + ? error.message + : `Failed to start authorization for "${conn.connectionName}": ${error.message}`, }); - 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; - } + continue; } } failedConnections.push({ @@ -388,38 +331,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 +342,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/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..9b571d3590 100644 --- a/packages/eve/src/execution/tools/subagent/accept-event-step.ts +++ b/packages/eve/src/execution/tools/subagent/accept-event-step.ts @@ -4,7 +4,7 @@ import { getAgentHandleStore } from "#subagents/handles/store.js"; import { findSessionTaskEntry } from "#tasks/session-index.js"; import { isTerminalTaskStatus, type TaskAuthorizationEventDelivery } from "#tasks/types.js"; -/** Validates that a task child's authorization event came from an agent the task owns. */ +/** Accepts authorization events from the workflow task itself or an agent it owns. */ export async function acceptTaskAuthorizationEventStep(input: { readonly delivery: TaskAuthorizationEventDelivery; readonly sessionState: DurableSessionState; @@ -16,6 +16,23 @@ export async function acceptTaskAuthorizationEventStep(input: { const entry = findSessionTaskEntry(durableSession.state, taskId); if (entry === undefined) return false; + // A workflow tool can request authorization without invoking a child agent. + // It has no agent handle, so bind its sender to the recorded task run, tool, + // and launching turn before accepting the event. + 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..ace2d7b368 100644 --- a/packages/eve/src/execution/tools/workflow/ask.ts +++ b/packages/eve/src/execution/tools/workflow/ask.ts @@ -12,7 +12,8 @@ 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 { + readonly authorizationSupported?: boolean; /** Compatibility for already-started two-run background workflows. */ readonly admission?: Promise< { readonly status: "accepted" } | { readonly status: "rejected"; readonly reason: string } @@ -46,6 +47,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/authorization-completion.ts b/packages/eve/src/execution/tools/workflow/authorization-completion.ts new file mode 100644 index 0000000000..3b22189306 --- /dev/null +++ b/packages/eve/src/execution/tools/workflow/authorization-completion.ts @@ -0,0 +1,50 @@ +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/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 7bfbd73861..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 { @@ -142,8 +148,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/step-context.ts b/packages/eve/src/execution/tools/workflow/step-context.ts new file mode 100644 index 0000000000..c6818a6458 --- /dev/null +++ b/packages/eve/src/execution/tools/workflow/step-context.ts @@ -0,0 +1,30 @@ +import type { SessionContext } from "#context/session-context.js"; +import type { AuthorizationResult, AuthorizationSignal } from "#harness/authorization.js"; + +export type WorkflowStepAuthorizationResult = AuthorizationResult & { + readonly attemptId: string; + readonly name: string; +}; + +export interface WorkflowStepContext { + readonly authorizationSupported: boolean; + readonly callId: string; + readonly toolName: string; + readonly session: SessionContext["session"]; + readonly abortSignal: AbortSignal; + readonly baseUrl: string; + readonly token: string; + readonly authorizationResults: readonly WorkflowStepAuthorizationResult[]; +} + +export type WorkflowStepResult = { readonly authorized: readonly string[] } & ( + | { readonly kind: "result"; readonly output: unknown } + | { readonly kind: "authorization-required"; readonly signal: AuthorizationSignal } +); + +/** Compiler-owned envelope; contextIndexes marks arguments replaced with step-local 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..c1bd8e55bc --- /dev/null +++ b/packages/eve/src/execution/tools/workflow/step-execution.test.ts @@ -0,0 +1,286 @@ +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, + ConnectionAuthorizationFailedError, +} 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"; + +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 = { + attributes: {}, + authenticator: "test", + issuer: "test", + principalId: user, + principalType: "user" as const, + }; + return { + authorizationSupported: true, + 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 }, + }, + callId: "call-1", + toolName: "devbox", + }; +} + +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", () => { + beforeEach(() => { + durable.attempt = 1; + durable.stepId = "step-1"; + 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 () => { + 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 = { + 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: "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 !== "authorization-required") throw new Error("Expected authorization"); + const challenge = pending.signal.challenges[0]!; + if (challenge.attemptId === undefined) { + throw new Error("Expected authorization attempt id"); + } + 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, + attemptId: challenge.attemptId, + 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: "authorization-required" }; + await expect( + 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 () => { + 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" }); + }); +}); 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..e4f97cc791 --- /dev/null +++ b/packages/eve/src/execution/tools/workflow/step-execution.ts @@ -0,0 +1,88 @@ +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 { + ConnectionAuthorizationFailedError, + isConnectionAuthorizationFailedError, +} from "#connections/errors.js"; +import { + isAuthorizationSignal, + PendingAuthorizationResultKey, + AuthorizationHookKey, + CallbackBaseUrlKey, +} from "#harness/authorization.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 { completeWorkflowStepAuthorization } from "#execution/tools/workflow/authorization-completion.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 function ( + this: unknown, + invocation: WorkflowStepInvocation, + ): Promise { + const { args, context: input } = invocation; + 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.set(CallbackBaseUrlKey, resolveWorkflowCallbackBaseUrl(input.baseUrl)); + context.setVirtualContext(AuthorizationHookKey, input.token); + context.setVirtualContext(PendingAuthorizationResultKey, input.authorizationResults); + + return contextStorage.run(context, async (): Promise => { + const auth = createAuthorizationContext({ + 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: input.authorizationSupported ? auth.getToken : unavailable, + requireAuth: input.authorizationSupported ? auth.requireAuth : unavailable, + }; + let output: unknown; + try { + output = await auth.run(() => + Reflect.apply( + execute, + this, + args.map((arg, index) => (invocation.contextIndexes.includes(index) ? ctx : arg)), + ), + ); + } 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: "authorization-required", signal: output, authorized } + : { kind: "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.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 new file mode 100644 index 0000000000..4b1bf780e5 --- /dev/null +++ b/packages/eve/src/execution/tools/workflow/step.ts @@ -0,0 +1,342 @@ +import { createHook, getWorkflowMetadata } from "#compiled/@workflow/core/index.js"; +import type { AuthorizationChallenge } from "#harness/authorization.js"; +import type { AuthorizationCallback } from "#shared/connection-types.js"; +import type { ToolContext } from "#tools/definition.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 { + createAuthorizationRequiredEvent, + createAuthorizationCompletedEvent, +} from "#protocol/message.js"; +import type { + WorkflowStepAuthorizationResult, + WorkflowStepContext, + WorkflowStepInvocation, + WorkflowStepResult, +} from "#execution/tools/workflow/step-context.js"; + +type IdentifiedAuthorizationChallenge = AuthorizationChallenge & { readonly attemptId: string }; + +interface WorkflowContextArgument { + readonly ctx: ToolContext; + readonly run: WorkflowToolRunContext; +} + +/** 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, +) { + // 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 contextArgument = findWorkflowContextArgument(args); + if (contextArgument === undefined) { + return Reflect.apply(target, receiver, args); + } + return executeAuthorizedStep(execute, receiver, args, contextArgument); + }, + }); +} + +function findWorkflowContextArgument( + args: readonly unknown[], +): WorkflowContextArgument | undefined { + for (const arg of args) { + const run = findWorkflowToolRunContext(arg); + if (run !== undefined) { + return { ctx: arg as ToolContext, run }; + } + } + return undefined; +} + +async function executeAuthorizedStep( + execute: (invocation: WorkflowStepInvocation) => Promise, + receiver: unknown, + args: unknown[], + contextArgument: WorkflowContextArgument, +): Promise { + const { ctx, run } = contextArgument; + const authorizationResults: WorkflowStepAuthorizationResult[] = []; + const pending = new Map(); + + for (;;) { + const callback = createHook(); + try { + let result: WorkflowStepResult; + try { + result = await invokeAuthorizedStep({ + args, + authorizationResults, + callbackToken: callback.token, + ctx, + execute, + receiver, + run, + }); + } catch (error) { + if (!ctx.abortSignal.aborted) { + await reportPendingAsFailed(run, ctx.abortSignal, pending); + } + throw error; + } + + await reconcileCompletedAuthorizations( + run, + ctx.abortSignal, + pending, + authorizationResults, + result.authorized, + ); + + if (result.kind === "result") { + await reportPendingAsFailed(run, ctx.abortSignal, pending); + return result.output; + } + + await collectAuthorizationCallbacks({ + authorizationResults, + callback, + challenges: result.signal.challenges, + ctx, + pending, + run, + }); + } finally { + await disposeHook(callback); + } + } +} + +async function invokeAuthorizedStep(input: { + readonly args: unknown[]; + readonly authorizationResults: readonly WorkflowStepAuthorizationResult[]; + readonly callbackToken: string; + readonly ctx: ToolContext; + readonly execute: (invocation: WorkflowStepInvocation) => Promise; + readonly receiver: unknown; + readonly run: WorkflowToolRunContext; +}): Promise { + const { args, authorizationResults, callbackToken, ctx, execute, receiver, run } = input; + const context: WorkflowStepContext = { + authorizationSupported: run.authorizationSupported === true, + callId: ctx.callId, + toolName: ctx.toolName, + session: ctx.session, + abortSignal: ctx.abortSignal, + baseUrl: getWorkflowMetadata().url, + token: callbackToken, + authorizationResults, + }; + const invocation: WorkflowStepInvocation = { + args: args.map((arg) => (arg === ctx ? null : arg)), + context, + contextIndexes: args.flatMap((arg, index) => (arg === ctx ? [index] : [])), + }; + return (await execute.call(receiver, invocation)) as WorkflowStepResult; +} + +async function reconcileCompletedAuthorizations( + run: WorkflowToolRunContext, + signal: AbortSignal, + pending: Map, + authorizationResults: WorkflowStepAuthorizationResult[], + authorizedAttemptIds: readonly string[], +): Promise { + const authorized = new Set(authorizedAttemptIds); + for (const attemptId of authorized) { + const challenge = pending.get(attemptId); + if (challenge !== undefined) { + await reportAuthorization(run, signal, challenge, "authorized"); + } + pending.delete(attemptId); + } + + for (let index = authorizationResults.length - 1; index >= 0; index--) { + const result = authorizationResults[index]; + if (result !== undefined && authorized.has(result.attemptId)) { + authorizationResults.splice(index, 1); + } + } +} + +async function collectAuthorizationCallbacks(input: { + readonly authorizationResults: WorkflowStepAuthorizationResult[]; + readonly callback: AsyncIterable; + readonly challenges: readonly AuthorizationChallenge[]; + readonly ctx: ToolContext; + readonly pending: Map; + readonly run: WorkflowToolRunContext; +}): Promise { + const { authorizationResults, callback, challenges, ctx, pending, run } = input; + for (const challenge of challenges) { + const identified = requireAttemptId(challenge); + pending.set(identified.attemptId, identified); + await reportAuthorization(run, ctx.abortSignal, identified); + + try { + const response = await waitForCallback(callback, identified, ctx.abortSignal); + authorizationResults.push({ + name: identified.name, + instanceId: identified.instanceId, + attemptId: identified.attemptId, + hookUrl: identified.hookUrl, + principal: identified.principal, + resume: identified.resume, + callback: response, + }); + } catch (error) { + // Cancelled turns close their inbox; cancelled tasks discard further deliveries. + if (!ctx.abortSignal.aborted) { + await reportAuthorization(run, ctx.abortSignal, identified, "failed"); + } + throw error; + } + } +} + +function requireAttemptId(challenge: AuthorizationChallenge): IdentifiedAuthorizationChallenge { + if (challenge.attemptId === undefined) { + throw new Error(`Workflow authorization challenge "${challenge.name}" has no attempt id.`); + } + return challenge as IdentifiedAuthorizationChallenge; +} + +async function reportPendingAsFailed( + run: WorkflowToolRunContext, + signal: AbortSignal, + pending: ReadonlyMap, +): Promise { + for (const challenge of pending.values()) { + await reportAuthorization(run, signal, challenge, "failed"); + } +} + +async function reportAuthorization( + run: WorkflowToolRunContext, + signal: AbortSignal, + challenge: IdentifiedAuthorizationChallenge, + outcome?: "authorized" | "failed", +): Promise { + const eventInput = { + attemptId: challenge.attemptId, + name: challenge.name, + sequence: run.from.sequence, + stepIndex: run.from.stepIndex, + turnId: run.from.turnId, + authorization: challenge.challenge, + }; + const event = + outcome === undefined + ? createAuthorizationRequiredEvent({ + ...eventInput, + description: `Sign in to ${challenge.name} to continue.`, + webhookUrl: challenge.hookUrl, + }) + : createAuthorizationCompletedEvent({ ...eventInput, outcome }); + const acknowledged = createHook(); + try { + await withAbort( + resumeHookStep(run.owner.inbox, { + kind: "request", + from: run.from, + replyTo: acknowledged.token, + request: { + kind: "authorization-request", + event: { + kind: "subagent-authorization-event", + callId: run.from.callId, + childSessionId: run.from.runId, + subagentName: run.from.toolName, + event, + }, + }, + }), + signal, + ); + await withAbort(acknowledged, signal); + } finally { + await disposeHook(acknowledged); + } +} + +async function waitForCallback( + hook: AsyncIterable, + challenge: IdentifiedAuthorizationChallenge, + 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: IdentifiedAuthorizationChallenge, +): 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..abb28e5d34 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, @@ -15,6 +18,8 @@ import { holdUntilAbortedWorkflow, 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"; @@ -68,6 +73,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 +85,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,8 +142,343 @@ 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(["blocking", "background"] as const)( + "runs %s auth with no advertised driver support", + async (execution) => { + const runtime = await createWorkflowToolRuntime({ + agentName: "workflow-step-old-driver", + execute: authorizedDeployWorkflow, + toolName: "deploy_service", + }); + await runtime.run(async () => { + 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, [ + { + 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 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, + ); + + 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([ + { 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, + execute: authorizedDeployWorkflow, + toolName: "deploy_service", + }); + await runtime.run(async () => { + const run = await start(workflowEntry, [ + { + input: { message: `Run deploy_service with service "${service}"` }, + 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: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:"); + } + 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("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/execution/turn-workflow-tool-run.ts b/packages/eve/src/execution/turn-workflow-tool-run.ts index a0f1c27d0d..84fe44823d 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.event.childSessionId === message.from.runId) + await resumeHookStep(message.replyTo, null, { ifPresent: true }); return; } await cursor.adopt( 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/harness/authorization.ts b/packages/eve/src/harness/authorization.ts index 1511f65d15..6496033135 100644 --- a/packages/eve/src/harness/authorization.ts +++ b/packages/eve/src/harness/authorization.ts @@ -187,18 +187,19 @@ 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) ?? (sessionId ? authHookToken(sessionId) : undefined); + if (!token || !baseUrl) return undefined; return createWorkflowCallbackUrl( baseUrl, createEveConnectionCallbackRoutePath(name, attemptId, token), @@ -295,6 +296,9 @@ export const PendingAuthorizationResultKey = new ContextKey("eve.callbackBaseUrl"); +/** 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/testing/workflow-tool-fixtures.ts b/packages/eve/src/internal/testing/workflow-tool-fixtures.ts index 54aff045b3..4e1b767bae 100644 --- a/packages/eve/src/internal/testing/workflow-tool-fixtures.ts +++ b/packages/eve/src/internal/testing/workflow-tool-fixtures.ts @@ -6,10 +6,20 @@ * `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 { executeWorkflowBody, type WorkflowBodyInput } from "#execution/tools/workflow/body.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 +35,78 @@ 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 }; +} + +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" && !(service === "retry" && getStepMetadata().attempt > 1)) { + 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 (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" || + (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 === "retry" && getStepMetadata().attempt === 1) { + throw new Error("Transient service failure after sign-in."); + } + if (service === "rejected") ctx.requireAuth(provider); + return token.slice("secret:".length); +} + export async function* confirmDeployWorkflow( input: DeployInput, ctx: WorkflowToolContext, @@ -167,3 +249,12 @@ export async function askThenRaceWorkflow( const answer = await Promise.race([pending, workflowSleep("50ms")]); return { decided: answer === undefined ? "timed out" : "answered", service: input.service }; } + +/** Runs the actual workflow body with the capability passed by its launching turn. */ +export async function workflowAuthorizationCapabilityProbe( + input: WorkflowBodyInput & { execution: "background" | "blocking" }, +) { + "use workflow"; + + return executeWorkflowBody(input, new AbortController().signal); +} 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 4ccbba5b48..33a6780d9e 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( @@ -105,9 +139,52 @@ describe("applyWorkflowTransform", () => { '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( + '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"));', + ); + }); + it("replaces step functions with workflow proxies in workflow mode", async () => { const transformed = await applyWorkflowTransform( "src/execution/task.ts", @@ -130,6 +207,7 @@ describe("applyWorkflowTransform", () => { expect(transformed.code).toContain( '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"); @@ -306,6 +384,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: { @@ -314,8 +395,12 @@ 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 = 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);', @@ -357,8 +442,12 @@ 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", planDeploy);', + 'registerStepFunction("step//./agent/tools/deploy//planDeploy:eve-authorization", withWorkflowStepAuthorization(planDeploy));', ); expect(transformed.code).toContain( 'execute.workflowId = "workflow//./agent/tools/deploy//execute";', @@ -425,7 +514,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"), 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-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 75d8b2b445..1927da6e3e 100644 --- a/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts +++ b/packages/eve/src/internal/workflow-bundle/workflow-transformer.ts @@ -89,12 +89,10 @@ export async function findWorkflowDirectiveFunctions( } export async function transformWorkflowDirectives(input: { - /** - * Authored modules keep their body in workflow mode (steps become proxies in - * place) and lose an eve-definer default export, so the driver never - * evaluates the tool definition or its schema dependencies. - */ + /** Authored modules retain their body but drop the eve-definer default export. */ authored?: boolean; + /** Route context-bearing calls through an authorization twin for modules that define tools. */ + authorizeSteps?: boolean; filename: string; mode: WorkflowDirectiveMode; moduleSpecifier: string | undefined; @@ -122,6 +120,10 @@ export async function transformWorkflowDirectives(input: { const ast = await parseWorkflowSource(input.filename, input.source); const functions = findDirectiveFunctions(ast); + 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: {} }; @@ -137,6 +139,11 @@ export async function transformWorkflowDirectives(input: { const replacements: { end: number; start: number; text: string }[] = []; const suffixes: string[] = []; let hasStepRegistration = false; + // 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") { @@ -144,13 +151,16 @@ export async function transformWorkflowDirectives(input: { manifest.steps ??= {}; const stepsForFile = (manifest.steps[input.filename] ??= {}); stepsForFile[fn.name] = { stepId }; + 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} = globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(stepId)});`, + text: `${exportPrefix}var ${fn.name} = ${createStepProxy(defaultIdBase, fn.name, authorizeSteps)};`, }); } else if (input.mode === "metadata") { continue; @@ -160,6 +170,11 @@ export async function transformWorkflowDirectives(input: { if (input.mode === "step") { hasStepRegistration = true; suffixes.push(`registerStepFunction(${JSON.stringify(stepId)}, ${fn.name});`); + if (authorizationStepId !== undefined) { + suffixes.push( + `registerStepFunction(${JSON.stringify(authorizationStepId)}, withWorkflowStepAuthorization(${fn.name}));`, + ); + } } else { suffixes.push(`${fn.name}.stepId = ${JSON.stringify(stepId)};`); } @@ -193,35 +208,40 @@ export async function transformWorkflowDirectives(input: { suffixes.push(`${fn.name}.workflowId = ${JSON.stringify(workflowId)};`); } } - const manifestComment = `/**__internal_workflows${JSON.stringify(manifest)}*/;`; + const imports: string[] = []; + if (hasStepRegistration) { + imports.push('import { registerStepFunction } from "workflow/internal/private";'); + } + if (hasAuthorizationSteps && input.mode === "workflow") { + imports.push(`import { workflowToolStep } from ${JSON.stringify(workflowStepImport)};`); + } else if (hasAuthorizationSteps && hasStepRegistration) { + imports.push( + `import { withWorkflowStepAuthorization } from ${JSON.stringify(stepExecutionImport)};`, + ); + } + const prefix = [...imports, manifestComment].join("\n"); const hasWorkflowDirective = functions.some((fn) => fn.directive === "use workflow"); - if (input.mode === "workflow" && !hasWorkflowDirective && input.authored !== true) { - return { - code: `${manifestComment}\n${createWorkflowStepProxySource(input.source, ast, functions, defaultIdBase)}`, - workflowManifest: manifest, - }; + const proxies = createWorkflowStepProxySource( + input.source, + ast, + functions, + defaultIdBase, + authorizeSteps, + ); + return { code: `${prefix}\n${proxies}`, workflowManifest: manifest }; } - if (input.mode === "workflow" && input.authored === true) { replacements.push(...removeEveDefinerDefaultExport(ast)); } - const replacedSource = applySourceReplacements(input.source, replacements); const transformedSource = input.mode === "workflow" ? await stripUnusedValueImports(input.filename, replacedSource) : replacedSource; - const prefix = hasStepRegistration - ? `import { registerStepFunction } from "workflow/internal/private";\n${manifestComment}\n` - : `${manifestComment}\n`; const suffix = suffixes.length > 0 ? `\n${suffixes.join("\n")}\n` : ""; - - return { - code: `${prefix}${transformedSource}${suffix}`, - workflowManifest: manifest, - }; + return { code: `${prefix}\n${transformedSource}${suffix}`, workflowManifest: manifest }; } async function parseWorkflowSource(filename: string, source: string): Promise { @@ -233,6 +253,7 @@ function createWorkflowStepProxySource( ast: AstProgram, functions: readonly DirectiveFunction[], idBase: string, + authorizeSteps: boolean, ): string { const literalExports = findExportedLiteralValueDeclarations(source, ast); const proxies = functions @@ -243,14 +264,29 @@ 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} = globalThis[Symbol.for("WORKFLOW_USE_STEP")](${JSON.stringify(stepId)});`; + return `${exportPrefix}var ${fn.name} = ${createStepProxy(idBase, fn.name, authorizeSteps)};`; }); const lines = [...literalExports, ...proxies]; return lines.length > 0 ? `${lines.join("\n")}\n` : ""; } +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[] { const functions: DirectiveFunction[] = []; // Rolldown collapses `export async function foo() {}` into a trailing 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..f679a00ca5 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,141 @@ 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`. diff --git a/packages/eve/src/tools/workflow-definition.test.ts b/packages/eve/src/tools/workflow-definition.test.ts index 0c1431cfee..38c461b5d3 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..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-03" +last_updated: "2026-09-06" --- # 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,28 @@ 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. + 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 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.