Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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.
60 changes: 58 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 @@ -148,6 +148,62 @@ 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.

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 completion before returning to authored code.
If that code later fails and the step retries, eve reads the token from the provider instead of
exchanging the same callback again. The provider must persist the grant or token; the durable
completion marker contains no credentials.

A background task becomes `input_required` during sign-in. The callback resumes that task; it does
not rely on the launching agent turn still being active. Cancelling an authorization wait withdraws
its callback. Cancellation uses the existing turn or task cancellation path rather than a separate
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
7 changes: 7 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,13 @@ import { mockModel, type MockModelRequest, type MockModelResponse } from "eve/ev
*/
function respond(request: MockModelRequest): MockModelResponse | string {
const message = [...request.userMessages].reverse().find((entry) => entry.trim() !== "") ?? "";
const stepAuth = /WORKFLOW-STEP-AUTH-(IMPLICIT|EXPLICIT|REJECTED)/u.exec(message);
if (stepAuth !== null) {
const result = request.toolResults.find((entry) => entry.name === "authorize_service");
return result === undefined
? { toolCalls: [{ input: { service: stepAuth[1] }, name: "authorize_service" }] }
: String(result.output);
}
const probe = /WORKFLOW-PROBE-blocking-local-(hitl|auth)/u.exec(message);
if (probe !== null) {
const result = request.toolResults.find((entry) => entry.name === "blocking_agent_probe");
Expand Down
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");
}),
],
});
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" };
},
};
}
32 changes: 32 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,32 @@
/** Resolves this fixture's HTTP service in local and deployed workflow workers. */
export function fakeServiceUrl(service: string): URL {
const origin =
process.env.WORKFLOW_LOCAL_BASE_URL ??
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined) ??
(process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined);
if (origin === undefined) throw new Error("Fixture service origin is unavailable");
const prefix = process.env.EVE_PUBLIC_ROUTE_PREFIX ?? "";
const url = new URL(`${prefix}/fixture-service/${encodeURIComponent(service)}`, origin);
const 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 { fakeServiceUrl } 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(fakeServiceUrl(service), {
headers: { Authorization: `Bearer ${token}` },
signal: ctx.abortSignal,
});
if (response.status === 401) ctx.requireAuth(fakeProvider);
if (!response.ok) throw new Error(`Fixture service returned ${response.status}`);
return await response.text();
}
87 changes: 77 additions & 10 deletions e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts
Original file line number Diff line number Diff line change
@@ -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" };

Expand All @@ -20,9 +22,15 @@ export async function runProbe(t: EveEvalContext, probe: ProbeCase): Promise<voi
} else {
const required = await waitForEvent(t, t, started, "authorization.required");
const url = required.event.data.authorization?.url;
if (url === undefined) throw new Error("Authorization probe produced no callback URL.");
if (url === undefined) {
throw new Error("Authorization probe produced no callback URL.");
}

const response = await fetch(url);
if (!response.ok) throw new Error(`Authorization callback failed (${response.status}).`);
if (!response.ok) {
throw new Error(`Authorization callback failed (${response.status}).`);
}

await waitForEvent(t, required.session, undefined, "authorization.completed");
await waitForMarker(t, required.session, undefined, "WORKFLOW-AUTH:authorized");
}
Expand All @@ -37,16 +45,19 @@ async function waitForInput(
toolName: string,
): Promise<SessionCursor> {
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}.`);
}

Expand All @@ -56,15 +67,23 @@ async function waitForMarker(
initialTurn: EveEvalTurn | undefined,
marker: string,
): Promise<EveEvalTurn> {
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}.`);
}

Expand All @@ -73,23 +92,30 @@ async function waitForEvent<T extends "authorization.completed" | "authorization
initial: SessionCursor,
initialTurn: EveEvalTurn | undefined,
type: T,
options: { allowFailedActions?: boolean } = {},
): Promise<{
readonly event: Extract<EveEvalTurn["events"][number], { readonly type: T }>;
readonly event: EveEvalStreamEvent<T>;
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<EveEvalTurn["events"][number], { readonly type: T }> =>
candidate.type === type,
(candidate): candidate is EveEvalStreamEvent<T> => 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}.`);
}

Expand All @@ -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<void> {
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<void> {
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.");
}
}
12 changes: 12 additions & 0 deletions e2e/fixtures/agent-workflow-tools/evals/step-auth.explicit.eval.ts
Original file line number Diff line number Diff line change
@@ -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");
},
});
12 changes: 12 additions & 0 deletions e2e/fixtures/agent-workflow-tools/evals/step-auth.implicit.eval.ts
Original file line number Diff line number Diff line change
@@ -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");
},
});
13 changes: 13 additions & 0 deletions e2e/fixtures/agent-workflow-tools/evals/step-auth.rejected.eval.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
Loading
Loading