Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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. Connections, tools, and workflow steps share authorization handling; workflow sign-in waits without holding compute and retries the interrupted step after the callback, including for background tasks.
48 changes: 46 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,50 @@ 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)`. Do not return the token from the
helper: step results enter the workflow's durable history. eve's token cache stays inside the step.

When sign-in is required, the step attempt ends and the workflow waits on its own callback hook,
without holding compute. The channel renders the sign-in challenge. After the callback, eve retries
**the whole interrupted step**, resolves the token, and continues. Previously completed steps are
not rerun. Resolve auth before other side effects in that step, and make operations before
`requireAuth` safe to retry. A token rejected immediately after sign-in fails instead of prompting
again. Provider declarations can be shared imports, but context must be passed directly, not nested
inside another argument or captured in a closure.

A background task becomes `input_required` during sign-in. The callback resumes that task; it does
not rely on the launching agent turn still being active. Cancelling an authorization wait withdraws
its callback. Cancellation uses the existing turn or task cancellation path rather than a separate
authorization completion event.

## Ask a human: `ctx.ask`

```ts
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();
}
34 changes: 33 additions & 1 deletion e2e/fixtures/agent-workflow-tools/evals/agent-probe.shared.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EveEvalContext, EveEvalSession, EveEvalTurn } from "eve/evals";
import { fixtureAuthorizationCallback } from "../agent/lib/fake-service.ts";

export type ProbeCase = { readonly kind: "auth" | "hitl" };

Expand Down Expand Up @@ -73,6 +74,7 @@ async function waitForEvent<T extends "authorization.completed" | "authorization
initial: SessionCursor,
initialTurn: EveEvalTurn | undefined,
type: T,
expectNoFailedActions = true,
): Promise<{
readonly event: Extract<EveEvalTurn["events"][number], { readonly type: T }>;
readonly session: SessionCursor;
Expand All @@ -87,7 +89,7 @@ async function waitForEvent<T extends "authorization.completed" | "authorization
if (event !== undefined) return { event, session };
const live = watchNext(t, session);
turn = await live.result();
turn.noFailedActions();
if (expectNoFailedActions) turn.noFailedActions();
session = live.session;
}
throw new Error(`Probe did not surface ${type}.`);
Expand All @@ -99,3 +101,33 @@ function watchNext(t: EveEvalContext, session: SessionCursor) {
}
return t.target.watchTurn(session.sessionId, { startIndex: session.state.streamIndex });
}

export async function runStepAuth(t: EveEvalContext, explicit: boolean): Promise<void> {
const started = await t.send(`WORKFLOW-STEP-AUTH-${explicit ? "EXPLICIT" : "IMPLICIT"}`);
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",
false,
);
if (completed.event.data.outcome !== "failed")
throw new Error("A freshly rejected token must fail authorization");
if ((await fetch(url)).status !== 404)
throw new Error("The completed authorization callback must be disposed");
}
10 changes: 10 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,10 @@
import { defineEval } from "eve/evals";
import { runStepAuth } from "./agent-probe.shared.ts";

export default defineEval({
description: "Workflow step requireAuth parks for sign-in and resumes under the requester.",
timeoutMs: 90_000,
async test(t) {
await runStepAuth(t, true);
},
});
10 changes: 10 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,10 @@
import { defineEval } from "eve/evals";
import { runStepAuth } from "./agent-probe.shared.ts";

export default defineEval({
description: "Workflow step getToken parks for sign-in and resumes under the requester.",
timeoutMs: 90_000,
async test(t) {
await runStepAuth(t, false);
},
});
11 changes: 11 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,11 @@
import { defineEval } from "eve/evals";
import { runRejectedStepAuth } from "./agent-probe.shared.ts";

export default defineEval({
description:
"Workflow step authorization fails when a fresh token is rejected, without another sign-in prompt.",
timeoutMs: 90_000,
async test(t) {
await runRejectedStepAuth(t);
},
});
23 changes: 23 additions & 0 deletions packages/eve/extension-contracts/compatibility/tool/v30.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { z } from "zod";
import { defineTool, defineWorkflowTool, disableTool } from "#public/tools/index.js";

disableTool();

defineTool({
description: "Write an approved message.",
inputSchema: z.object({ message: z.string() }),
approval: ({ toolInput }) => (toolInput?.message ? "user-approval" : "not-applicable"),
execute: (input) => ({ written: input.message }),
});

defineWorkflowTool({
description: "Ask before publishing a report.",
execution: "background",
inputSchema: z.object({ reportId: z.string() }),
async *execute(input, ctx, task) {
"use workflow";
yield task.postMessage(`Preparing ${input.reportId}`);
const answer = await ctx.ask({ prompt: "Publish this report?", allowFreeform: true });
return { reportId: input.reportId, answer: answer.text, sessionId: ctx.session.id };
},
});
20 changes: 20 additions & 0 deletions packages/eve/extension-contracts/reports/tool/v31.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"kind": "eve-extension-capability-contract",
"capability": "tool",
"epoch": 31,
"sha256": "1e5ed8d7288c6538857921f0cc3ffc957d25837039f2b015147843f666e5b0e8",
"exports": [
"defaultWebSearch",
"defineTool",
"defineWorkflowTool",
"disableTool",
"experimental_workflow",
"isDisabledToolSentinel",
"isExperimentalWorkflowToolDefinition",
"isWebSearchToolDefinition",
"toolOutput",
"toolOutputPart",
"toolResultFrom",
"webSearch"
]
}
10 changes: 10 additions & 0 deletions packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,16 @@
"import": "./dist/src/public/context/index.js",
"default": "./dist/src/public/context/index.js"
},
"./internal/workflow-step": {
"types": "./dist/src/execution/tools/workflow/step.d.ts",
"import": "./dist/src/execution/tools/workflow/step.js",
"default": "./dist/src/execution/tools/workflow/step.js"
},
"./internal/workflow-step-execution": {
"types": "./dist/src/execution/tools/workflow/step-execution.d.ts",
"import": "./dist/src/execution/tools/workflow/step-execution.js",
"default": "./dist/src/execution/tools/workflow/step-execution.js"
},
"./internal/programmatic-source-loader": {
"types": "./dist/src/internal/programmatic-source-loader.d.ts",
"import": "./dist/src/internal/programmatic-source-loader.js",
Expand Down
4 changes: 2 additions & 2 deletions packages/eve/src/compiler/extension-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ interface ExtensionCapabilityContract {
const EXTENSION_CAPABILITY_CONTRACTS = {
extension: { current: 1, supported: [1], dropped: {} },
tool: {
current: 30,
supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30],
current: 31,
supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30, 31],
dropped: {
14: "TaskExec.delegated was removed; migrate to workflow-backed background tools",
15: "TaskExec replaces stageEffect with send",
Expand Down
Loading
Loading