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/quiet-rivers-connect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Consume interactive authorization callback results once and keep targeted connection searches from replaying callbacks for unrelated connections.
8 changes: 8 additions & 0 deletions packages/eve/src/context/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(key: ContextKey<T>): boolean;
/**
* Iterates all durable key/value pairs currently stored in the context.
* Used by the serialization layer to persist context at step boundaries.
Expand Down Expand Up @@ -65,6 +67,12 @@ export class ContextContainer implements AlsContext {
return this.set(key, create());
}

delete<T>(key: ContextKey<T>): 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.
*
Expand Down
38 changes: 38 additions & 0 deletions packages/eve/src/harness/authorization.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
24 changes: 24 additions & 0 deletions packages/eve/src/harness/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions packages/eve/src/runtime/connections/scoped-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<JsonValue>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ContextKey } from "#context/key.js";
import {
type AuthorizationChallenge,
type AuthorizationSignal,
getAuthorizationResult,
consumeAuthorizationResult,
getHookUrl,
requestAuthorization,
} from "#harness/authorization.js";
Expand All @@ -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";
Expand Down Expand Up @@ -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<Set<string>> {
async function completePendingAuthorizations(
registry: ConnectionRegistry,
connections: readonly ResolvedConnectionDefinition[],
): Promise<Set<string>> {
const ctx = loadContext();
const completed = new Set<string>();
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;
Expand Down Expand Up @@ -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 }> = [];
Expand All @@ -198,6 +199,8 @@ async function executeConnectionSearch(
);
}

const justAuthorized = await completePendingAuthorizations(registry, targetConnections);

const authChallenges: AuthorizationChallenge[] = [];

for (const conn of targetConnections) {
Expand Down Expand Up @@ -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();
Expand Down
Loading