From c12d083c5db36400c19cabbcdee6421e655b8ddb Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:59:00 -0700 Subject: [PATCH] fix(eve): consume authorization callback results once Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- .changeset/quiet-rivers-connect.md | 5 ++ packages/eve/src/context/container.ts | 8 +++ .../eve/src/harness/authorization.test.ts | 38 ++++++++++++ packages/eve/src/harness/authorization.ts | 24 ++++++++ .../connections/scoped-authorization.ts | 4 +- .../connection-search-dynamic.test.ts | 60 ++++++++++++++++++- .../connection-search-dynamic.ts | 19 +++--- 7 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 .changeset/quiet-rivers-connect.md create mode 100644 packages/eve/src/harness/authorization.test.ts diff --git a/.changeset/quiet-rivers-connect.md b/.changeset/quiet-rivers-connect.md new file mode 100644 index 000000000..3bd7ebae1 --- /dev/null +++ b/.changeset/quiet-rivers-connect.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Consume interactive authorization callback results once and keep targeted connection searches from replaying callbacks for unrelated connections. diff --git a/packages/eve/src/context/container.ts b/packages/eve/src/context/container.ts index 0f6029f08..a77878dc4 100644 --- a/packages/eve/src/context/container.ts +++ b/packages/eve/src/context/container.ts @@ -15,6 +15,8 @@ const EVE_CONTEXT_STORAGE_KEY = Symbol.for("eve.context-storage"); * the serialization layer. */ export interface AlsContext extends ContextAccessor { + /** Removes a durable or step-local value from the context. */ + delete(key: ContextKey): boolean; /** * Iterates all durable key/value pairs currently stored in the context. * Used by the serialization layer to persist context at step boundaries. @@ -65,6 +67,12 @@ export class ContextContainer implements AlsContext { return this.set(key, create()); } + delete(key: ContextKey): boolean { + const deletedDurable = this._durableValues.delete(key.name); + const deletedVirtual = this._virtualValues.delete(key.name); + return deletedDurable || deletedVirtual; + } + /** * Clears all step-local provider values from the context. * diff --git a/packages/eve/src/harness/authorization.test.ts b/packages/eve/src/harness/authorization.test.ts new file mode 100644 index 000000000..386d922e8 --- /dev/null +++ b/packages/eve/src/harness/authorization.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { ContextContainer, contextStorage } from "#context/container.js"; +import { + consumeAuthorizationResult, + PendingAuthorizationResultKey, +} from "#harness/authorization.js"; + +describe("authorization callback results", () => { + it("consumes each callback result once", () => { + const ctx = new ContextContainer(); + ctx.set(PendingAuthorizationResultKey, [ + { + callback: { method: "GET", params: { code: "notion-code" } }, + hookUrl: "https://agent.example.com/notion", + name: "notion", + }, + { + callback: { method: "GET", params: { code: "linear-code" } }, + hookUrl: "https://agent.example.com/linear", + name: "linear", + }, + ]); + + contextStorage.run(ctx, () => { + expect(consumeAuthorizationResult("notion")).toMatchObject({ + callback: { params: { code: "notion-code" } }, + }); + expect(ctx.get(PendingAuthorizationResultKey)).toMatchObject([{ name: "linear" }]); + expect(consumeAuthorizationResult("notion")).toBeUndefined(); + expect(consumeAuthorizationResult("linear")).toMatchObject({ + callback: { params: { code: "linear-code" } }, + }); + expect(ctx.has(PendingAuthorizationResultKey)).toBe(false); + expect(consumeAuthorizationResult("linear")).toBeUndefined(); + }); + }); +}); diff --git a/packages/eve/src/harness/authorization.ts b/packages/eve/src/harness/authorization.ts index 76bdd41f9..da03dae37 100644 --- a/packages/eve/src/harness/authorization.ts +++ b/packages/eve/src/harness/authorization.ts @@ -125,6 +125,30 @@ export function getAuthorizationResult(name?: string): AuthorizationResult | und return results.find((r) => r.name === name); } +/** + * Removes and returns one authorization callback result. + * + * Callback results are one-shot inputs to `completeAuthorization`. Consuming + * before completion prevents a failed or replayed callback from poisoning + * every later tool call in the same step. + */ +export function consumeAuthorizationResult(name: string): AuthorizationResult | undefined { + const ctx = loadContext(); + const results = ctx.get(PendingAuthorizationResultKey); + if (!results || results.length === 0) return undefined; + + const index = results.findIndex((result) => result.name === name); + if (index === -1) return undefined; + + const result = results[index]!; + const remaining = results.filter((_, resultIndex) => resultIndex !== index); + ctx.delete(PendingAuthorizationResultKey); + if (remaining.length > 0) { + ctx.set(PendingAuthorizationResultKey, remaining); + } + return result; +} + /** * Builds a callback URL for external systems. `name` identifies the * callback in the URL path (e.g. a connection name or custom label). diff --git a/packages/eve/src/runtime/connections/scoped-authorization.ts b/packages/eve/src/runtime/connections/scoped-authorization.ts index abbd1a94b..1bc9f8144 100644 --- a/packages/eve/src/runtime/connections/scoped-authorization.ts +++ b/packages/eve/src/runtime/connections/scoped-authorization.ts @@ -14,7 +14,7 @@ import { type AlsContext, contextStorage, loadContext } from "#context/container import type { ConnectionAuthorizationChallenge } from "#public/connections/errors.js"; import { type AuthorizationSignal, - getAuthorizationResult, + consumeAuthorizationResult, getHookUrl, requestAuthorization, } from "#harness/authorization.js"; @@ -136,7 +136,7 @@ export async function completeScopedAuthorization(input: ScopedAuthorization): P const { scope, authorization, connection } = input; if (!supportsInteractiveAuthorization(authorization)) return false; - const result = getAuthorizationResult(scope); + const result = consumeAuthorizationResult(scope); if (result === undefined) return false; const interactive = authorization as InteractiveAuthorizationDefinition; diff --git a/packages/eve/src/runtime/framework-tools/connection-search-dynamic.test.ts b/packages/eve/src/runtime/framework-tools/connection-search-dynamic.test.ts index 80feaa0a9..a1af7fc77 100644 --- a/packages/eve/src/runtime/framework-tools/connection-search-dynamic.test.ts +++ b/packages/eve/src/runtime/framework-tools/connection-search-dynamic.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it } from "vitest"; import { ConnectionRegistryKey } from "#context/providers/connection-key.js"; import { ContextContainer, contextStorage } from "#context/container.js"; import { AuthKey, SessionIdKey } from "#context/keys.js"; -import { CallbackBaseUrlKey, isAuthorizationSignal } from "#harness/authorization.js"; +import { + CallbackBaseUrlKey, + isAuthorizationSignal, + PendingAuthorizationResultKey, +} from "#harness/authorization.js"; import { ConnectionAuthorizationRequiredError } from "#public/connections/errors.js"; import type { ToolContext } from "#public/definitions/tool.js"; import type { ConnectionRegistry, ConnectionToolMetadata } from "#runtime/connections/types.js"; @@ -203,6 +207,60 @@ describe("connection_search", () => { ]); }); + it("does not complete an unrelated connection authorization", async () => { + let notionCompletions = 0; + const notion: ResolvedConnectionDefinition = { + ...connection("notion"), + authorization: { + completeAuthorization: async () => { + notionCompletions += 1; + throw new Error("stale Notion callback"); + }, + getToken: async () => ({ token: "notion-token" }), + principalType: "user", + startAuthorization: async () => ({ + challenge: { url: "https://idp.example.com/authorize" }, + }), + }, + }; + const linear = connection("linear"); + const connectionRegistry = registry({ + connections: [notion, linear], + loadTools: { + notion: async () => [], + linear: async () => [ + { + description: "List issues", + inputSchema: { type: "object" }, + name: "list_issues", + }, + ], + }, + }); + + await expect( + executeConnectionSearch( + connectionRegistry, + { connection: "linear", keywords: "list issues" }, + (ctx) => { + ctx.set(PendingAuthorizationResultKey, [ + { + callback: { method: "GET", params: {} }, + hookUrl: "https://agent.example.com/eve/v1/connections/notion/callback/auth", + name: "notion", + }, + ]); + }, + ), + ).resolves.toMatchObject([ + { + connection: "linear", + qualifiedName: "linear__list_issues", + }, + ]); + expect(notionCompletions).toBe(0); + }); + it("returns an authorization signal when sign-in can be started", async () => { const salesforce: ResolvedConnectionDefinition = { ...connection("salesforce"), diff --git a/packages/eve/src/runtime/framework-tools/connection-search-dynamic.ts b/packages/eve/src/runtime/framework-tools/connection-search-dynamic.ts index bec57afdb..c230b0375 100644 --- a/packages/eve/src/runtime/framework-tools/connection-search-dynamic.ts +++ b/packages/eve/src/runtime/framework-tools/connection-search-dynamic.ts @@ -5,7 +5,7 @@ import { ContextKey } from "#context/key.js"; import { type AuthorizationChallenge, type AuthorizationSignal, - getAuthorizationResult, + consumeAuthorizationResult, getHookUrl, requestAuthorization, } from "#harness/authorization.js"; @@ -29,7 +29,7 @@ import { type InteractiveAuthorizationDefinition, supportsInteractiveAuthorization, } from "#runtime/connections/types.js"; -import type { ResolvedDynamicToolResolver } from "#runtime/types.js"; +import type { ResolvedConnectionDefinition, ResolvedDynamicToolResolver } from "#runtime/types.js"; import { createLogger } from "#internal/logging.js"; import type { DynamicToolEvents, DynamicToolEntry } from "#shared/dynamic-tool-definition.js"; import { toError } from "#shared/errors.js"; @@ -147,11 +147,14 @@ async function resolveInteractiveAuth( * following load, the freshly minted token is itself being rejected, so * the connection must fail terminally rather than re-challenge forever. */ -async function completePendingAuthorizations(registry: ConnectionRegistry): Promise> { +async function completePendingAuthorizations( + registry: ConnectionRegistry, + connections: readonly ResolvedConnectionDefinition[], +): Promise> { const ctx = loadContext(); const completed = new Set(); - for (const conn of registry.getConnections()) { - const result = getAuthorizationResult(conn.connectionName); + for (const conn of connections) { + const result = consumeAuthorizationResult(conn.connectionName); if (!result) continue; const auth = await resolveInteractiveAuth(registry, conn.connectionName); if (!auth) continue; @@ -180,8 +183,6 @@ async function executeConnectionSearch( return []; } - const justAuthorized = await completePendingAuthorizations(registry); - const limit = input.limit ?? 10; const queryTokens = tokenize(input.keywords); const results: Array<{ item: ConnectionSearchResultItem; score: number }> = []; @@ -198,6 +199,8 @@ async function executeConnectionSearch( ); } + const justAuthorized = await completePendingAuthorizations(registry, targetConnections); + const authChallenges: AuthorizationChallenge[] = []; for (const conn of targetConnections) { @@ -453,7 +456,7 @@ export function createConnectionSearchEvents(): DynamicToolEvents { let justCompletedAuth = false; if (interactiveAuth) { - const authResult = getAuthorizationResult(connectionName); + const authResult = consumeAuthorizationResult(connectionName); if (authResult) { justCompletedAuth = true; const ctx = loadContext();