Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
21594c8
feat(eve): authorize workflow tool steps
ruiconti Sep 5, 2026
1ffa195
test(eve): separate fake provider from workflow auth calls
ruiconti Sep 6, 2026
b98b683
test(eve): authorize workflow steps after HTTP rejection
ruiconti Sep 6, 2026
bc29113
refactor(eve): share authorization across execution runtimes
ruiconti Sep 6, 2026
3532455
Merge remote-tracking branch 'origin/main' into ruiconti/workflow-ste…
ruiconti Sep 6, 2026
2b468e4
chore(eve): retain tool epoch 30 compatibility
ruiconti Sep 6, 2026
e4e588f
fix(eve): preserve native Workflow built-in step calls
ruiconti Sep 6, 2026
a6052bd
Fix workflow auth retries and preserve native step references
ruiconti Sep 6, 2026
4f192c5
refactor(eve): simplify task owner authorization handling
ruiconti Sep 6, 2026
45ca4bd
test(eve): hold busy-worker until continuation checks finish
ruiconti Sep 6, 2026
a5a71dd
Merge main and preserve deterministic busy-agent steering coverage
ruiconti Sep 6, 2026
f783361
refactor(eve): shorten workflow step result kinds
ruiconti Sep 6, 2026
46425eb
refactor(eve): narrow workflow step authorization context
ruiconti Sep 6, 2026
f004de6
fix(eve): guard workflow task auth against older session drivers
ruiconti Sep 6, 2026
75c350a
refactor(eve): clarify workflow authorization docs and evals
ruiconti Sep 8, 2026
17a6cc8
refactor(eve): advertise workflow auth through session capabilities
ruiconti Sep 8, 2026
25ab244
refactor(eve): derive workflow authorization from sender identity
ruiconti Sep 8, 2026
331b1e9
refactor(eve): share authorization across tools and connections
ruiconti Sep 8, 2026
0f98255
Merge shared authorization prerequisite and current main
ruiconti Sep 8, 2026
de9db72
refactor(eve): move busy-worker fixture fix to its own PR
ruiconti Sep 8, 2026
7b3fe43
chore(eve): consolidate workflow authorization into one PR
ruiconti Sep 8, 2026
8bf021d
test(eve): cover connection search authorization boundaries
ruiconti Sep 8, 2026
2912c1e
fix(e2e): keep catalog routes separate from workflow auth service
ruiconti Sep 8, 2026
e1d785b
refactor(eve): confine step authorization to workflow tool modules
ruiconti Sep 8, 2026
706e2eb
fix(eve): resolve the workflow step wrapper without an app eve depend…
ruiconti Sep 8, 2026
82c5633
Merge origin/main and adopt its tool contract epochs
ruiconti Sep 8, 2026
ab68590
chore(eve): retain tool epoch 32 compatibility
ruiconti Sep 8, 2026
9d19f94
refactor(eve): make workflow authorization flow legible
ruiconti Sep 8, 2026
d803b66
docs(eve): render workflow token guidance as a callout
ruiconti Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workflow-step-authorization.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 60 additions & 2 deletions docs/tools/workflows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -154,6 +154,64 @@ 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)`.

> [!WARNING]
> Do not return the token from the helper: step results enter the workflow's durable history.
> eve's token cache stays inside the step. The step's input is recorded too, and after sign-in it
> carries the provider's callback parameters (such as a one-time authorization code), just as an
> agent turn records the callback it receives. Bearer tokens are never written.

When sign-in is required, the step attempt ends and the workflow waits on its own callback hook,
without holding compute. The channel renders the sign-in challenge. After the callback, eve retries
**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
Expand Down
22 changes: 22 additions & 0 deletions e2e/fixtures/agent-workflow-tools/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
58 changes: 58 additions & 0 deletions e2e/fixtures/agent-workflow-tools/agent/channels/catalog.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
}),
],
});
15 changes: 15 additions & 0 deletions e2e/fixtures/agent-workflow-tools/agent/channels/fake-service.ts
Original file line number Diff line number Diff line change
@@ -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");
}),
],
});
Original file line number Diff line number Diff line change
@@ -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 }),
}),
},
});
33 changes: 33 additions & 0 deletions e2e/fixtures/agent-workflow-tools/agent/lib/fake-auth-provider.ts
Original file line number Diff line number Diff line change
@@ -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" };
},
};
}
33 changes: 33 additions & 0 deletions e2e/fixtures/agent-workflow-tools/agent/lib/fake-service.ts
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 27 additions & 0 deletions e2e/fixtures/agent-workflow-tools/agent/tools/authorize_service.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
"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();
}
Loading