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.