Skip to content
Draft
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/steady-authorization-challenges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Reuse pending authorization challenges when multi-connection turns resume. Existing device codes remain valid and no longer appear as failed attempts.
28 changes: 28 additions & 0 deletions packages/eve/src/context/providers/pending-authorization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";

import { ContextContainer } from "#context/container.js";
import { pendingAuthorizationProvider } from "#context/providers/pending-authorization.js";
import { getPendingAuthorization, setPendingAuthorization } from "#harness/authorization.js";
import type { HarnessSession } from "#harness/types.js";

describe("pendingAuthorizationProvider", () => {
it("rebuilds pending challenges from durable session state", async () => {
const state = setPendingAuthorization(undefined, {
challenges: [
{
attemptId: "attempt-linear",
challenge: { userCode: "OLD-CODE", url: "https://idp.example/linear" },
hookUrl: "https://agent.example/linear",
name: "linear",
principal: { id: "user-1", issuer: "idp", type: "user" },
},
],
});

const result = await pendingAuthorizationProvider.create(new ContextContainer(), {
state,
} as HarnessSession);

expect(result?.value).toEqual(getPendingAuthorization(state));
});
});
15 changes: 15 additions & 0 deletions packages/eve/src/context/providers/pending-authorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { FrameworkContextProvider } from "#context/provider.js";
import {
getPendingAuthorization,
PendingAuthorizationStateKey,
type PendingAuthorizationState,
} from "#harness/authorization.js";

/** Rebuilds active authorization state from the durable session for each step. */
export const pendingAuthorizationProvider: FrameworkContextProvider<PendingAuthorizationState> = {
key: PendingAuthorizationStateKey,
create(_ctx, session) {
const pending = getPendingAuthorization(session.state);
return pending === undefined ? undefined : { value: pending };
},
};
2 changes: 2 additions & 0 deletions packages/eve/src/context/run-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { HarnessSession, StepResult } from "#harness/types.js";
import { type ContextContainer, contextStorage } from "#context/container.js";
import type { FrameworkContextProvider } from "#context/provider.js";
import { connectionProvider } from "#context/providers/connection.js";
import { pendingAuthorizationProvider } from "#context/providers/pending-authorization.js";
import { sandboxProvider } from "#context/providers/sandbox.js";
import { sessionProvider } from "#context/providers/session.js";

Expand All @@ -13,6 +14,7 @@ import { sessionProvider } from "#context/providers/session.js";
*/
const frameworkProviders: readonly FrameworkContextProvider<any>[] = [
sessionProvider,
pendingAuthorizationProvider,
connectionProvider,
sandboxProvider,
];
Expand Down
63 changes: 63 additions & 0 deletions packages/eve/src/harness/authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
consumeAuthorizationResult,
getPendingAuthorization,
getHookUrl,
getReusableAuthorizationChallenge,
getSupersededAuthorizationChallenges,
isPendingAuthorizationChallenge,
PendingAuthorizationResultKey,
PendingAuthorizationStateKey,
setPendingAuthorization,
} from "#harness/authorization.js";
import type { ConnectionPrincipal } from "#runtime/connections/types.js";
Expand Down Expand Up @@ -161,4 +165,63 @@ describe("pending authorization attempts", () => {
getPendingAuthorization(clearPendingAuthorization(state, ["linear-2"]))?.challenges,
).toEqual([challenge("github", "github-1")]);
});

it("reuses a still-valid challenge for the same principal", () => {
const ctx = new ContextContainer();
const principal = { id: "user-a", issuer: "idp", type: "user" } as const;
const pending = challenge("linear", "linear-1", principal);
ctx.set(PendingAuthorizationStateKey, { challenges: [pending] });

expect(
contextStorage.run(ctx, () => getReusableAuthorizationChallenge("linear", principal)),
).toBe(pending);

const state = setPendingAuthorization(undefined, { challenges: [pending] });
expect(
getPendingAuthorization(setPendingAuthorization(state, { challenges: [pending] }))
?.challenges,
).toEqual([pending]);
});

it("does not reuse an expired challenge", () => {
const ctx = new ContextContainer();
const principal = { id: "user-a", issuer: "idp", type: "user" } as const;
ctx.set(PendingAuthorizationStateKey, {
challenges: [
{
...challenge("linear", "linear-1", principal),
challenge: {
expiresAt: "2000-01-01T00:00:00.000Z",
url: "https://idp.example/linear-1",
},
},
],
});

expect(
contextStorage.run(ctx, () => getReusableAuthorizationChallenge("linear", principal)),
).toBeUndefined();
});

it("treats a reusable legacy challenge without an attempt ID as the same attempt", () => {
const ctx = new ContextContainer();
const pending = {
challenge: { url: "https://idp.example/linear" },
hookUrl: "https://agent.example/linear",
name: "linear",
principal: { type: "app" } as const,
};
const state = setPendingAuthorization(undefined, { challenges: [pending] });
ctx.set(PendingAuthorizationStateKey, { challenges: [pending] });

expect(
contextStorage.run(ctx, () => getReusableAuthorizationChallenge("linear", pending.principal)),
).toBe(pending);
expect(getSupersededAuthorizationChallenges(state, [pending])).toEqual([]);
expect(isPendingAuthorizationChallenge(state, pending)).toBe(true);

const replacement = { ...pending, hookUrl: "https://agent.example/linear-replacement" };
expect(getSupersededAuthorizationChallenges(state, [replacement])).toEqual([pending]);
expect(isPendingAuthorizationChallenge(state, replacement)).toBe(false);
});
});
66 changes: 62 additions & 4 deletions packages/eve/src/harness/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,17 +283,24 @@ export interface PendingAuthorizationState {
readonly challenges: readonly AuthorizationChallenge[];
}

/** Active challenges rebuilt from session state for each harness step. */
export const PendingAuthorizationStateKey = new ContextKey<PendingAuthorizationState>(
"eve.pendingAuthorizationState",
);

export function setPendingAuthorization(
sessionState: Record<string, unknown> | undefined,
value: PendingAuthorizationState,
): Record<string, unknown> {
const previous = getPendingAuthorization(sessionState)?.challenges ?? [];
const superseded = getSupersededAuthorizationChallenges(sessionState, value.challenges);
const replaced = previous.filter((candidate) =>
value.challenges.some((replacement) => sameAuthorizationScope(candidate, replacement)),
);
return {
...sessionState,
[PENDING_AUTHORIZATION_KEY]: {
challenges: [
...previous.filter((challenge) => !superseded.includes(challenge)),
...previous.filter((challenge) => !replaced.includes(challenge)),
...value.challenges,
],
},
Expand All @@ -309,12 +316,42 @@ export function getSupersededAuthorizationChallenges(
return previous.filter((candidate) =>
replacements.some(
(replacement) =>
candidate.name === replacement.name &&
samePrincipal(candidate.principal, replacement.principal),
sameAuthorizationScope(candidate, replacement) &&
!sameAuthorizationAttempt(candidate, replacement),
),
);
}

/** Whether this exact attempt is already pending in durable session state. */
export function isPendingAuthorizationChallenge(
sessionState: Record<string, unknown> | undefined,
challenge: AuthorizationChallenge,
): boolean {
return (
getPendingAuthorization(sessionState)?.challenges.some((pending) =>
sameAuthorizationAttempt(pending, challenge),
) ?? false
);
}

function sameAuthorizationScope(
left: AuthorizationChallenge,
right: AuthorizationChallenge,
): boolean {
return left.name === right.name && samePrincipal(left.principal, right.principal);
}

function sameAuthorizationAttempt(
left: AuthorizationChallenge,
right: AuthorizationChallenge,
): boolean {
if (!sameAuthorizationScope(left, right)) return false;
if (left.attemptId !== undefined || right.attemptId !== undefined) {
return left.attemptId !== undefined && left.attemptId === right.attemptId;
}
return left.hookUrl === right.hookUrl;
}

function samePrincipal(
left: ConnectionPrincipal | undefined,
right: ConnectionPrincipal | undefined,
Expand Down Expand Up @@ -364,6 +401,27 @@ export function getPendingAuthorization(
return v as PendingAuthorizationState;
}

/** Returns a still-valid pending challenge for the same scope and principal. */
export function getReusableAuthorizationChallenge(
name: string,
principal: ConnectionPrincipal,
): AuthorizationChallenge | undefined {
return loadContext()
.get(PendingAuthorizationStateKey)
?.challenges.find(
(challenge) =>
challenge.name === name &&
samePrincipal(challenge.principal, principal) &&
!authorizationChallengeExpired(challenge.challenge),
);
}

function authorizationChallengeExpired(challenge: ConnectionAuthorizationChallenge): boolean {
if (challenge.expiresAt === undefined) return false;
const expiresAt = Date.parse(challenge.expiresAt);
return Number.isNaN(expiresAt) || expiresAt <= Date.now();
}

export function hasPendingAuthorization(
sessionState: Record<string, unknown> | undefined,
): boolean {
Expand Down
96 changes: 93 additions & 3 deletions packages/eve/src/harness/tool-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
getPendingAuthorization,
modelFacingAuthorizationOutput,
requestAuthorization,
setPendingAuthorization,
} from "#harness/authorization.js";
import {
getPendingInputRequestIds,
Expand Down Expand Up @@ -5869,15 +5870,15 @@ describe("createToolLoopHarness", () => {
});

describe("authorization signal detection", () => {
function createAuthSignals() {
function createAuthSignals(attemptId = "attempt-protected-action", userCode = "GFI-QLM") {
const full = requestAuthorization([
{
attemptId: "attempt-protected-action",
attemptId,
name: "protected_action",
challenge: {
url: "https://idp.example/auth",
instructions: "Sign in to continue",
userCode: "GFI-QLM",
userCode,
},
hookUrl: "https://app.example/callback",
principal: { type: "app" },
Expand Down Expand Up @@ -6043,6 +6044,95 @@ describe("createToolLoopHarness", () => {
const actionResults = events.filter((event) => event.type === "action.result");
expect(actionResults).toHaveLength(0);
});

it("does not report or re-emit a reused pending authorization", async () => {
const { full, modelFacing } = createAuthSignals("attempt-existing", "OLD-CODE");

setupMockAgent({
finishReason: "tool-calls",
fullStreamParts: [
{
input: { action: "run" },
toolCallId: "call-1",
toolName: "protected_action",
type: "tool-call",
},
{
output: modelFacing,
toolCallId: "call-1",
toolName: "protected_action",
type: "tool-result",
},
{ finishReason: "tool-calls", type: "finish-step" },
],
response: {
messages: [
{
content: [
{
input: { action: "run" },
toolCallId: "call-1",
toolName: "protected_action",
type: "tool-call",
},
],
role: "assistant",
},
],
},
text: "",
toolCalls: [
{
input: { action: "run" },
toolCallId: "call-1",
toolName: "protected_action",
type: "tool-call",
},
],
toolResults: [
{
output: modelFacing,
toolCallId: "call-1",
toolName: "protected_action",
type: "tool-result",
},
],
});

const { emit, events } = createEventCollector();
const runStep = createToolLoopHarness(
createTestConfig("conversation", emit, {
tools: new Map([
[
"protected_action",
{
description: "Run a protected action",
execute: vi.fn(),
inputSchema: jsonSchema({ type: "object" }),
name: "protected_action",
},
],
]),
}),
);
const ctx = new ContextContainer();
stashToolInterrupt(ctx, "call-1", full);
const session = createTestSession({
state: setPendingAuthorization(undefined, { challenges: full.challenges }),
});

const result = await contextStorage.run(ctx, () =>
runStep(session, { message: "run protected action" }),
);

expect(
events.filter(
(event) => event.type === "authorization.completed" && event.data.outcome === "failed",
),
).toHaveLength(0);
expect(events.filter((event) => event.type === "authorization.required")).toHaveLength(0);
expect(getPendingAuthorization(result.session.state)?.challenges).toEqual(full.challenges);
});
});

it("persists the SDK's accumulated approval-resume messages into session history", async () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/eve/src/harness/tool-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ import { normalizeProviderToolHistory } from "#harness/provider-tool-history.js"
import {
type AuthorizationSignal,
getSupersededAuthorizationChallenges,
isPendingAuthorizationChallenge,
isAuthorizationSignal,
setPendingAuthorization,
} from "#harness/authorization.js";
Expand Down Expand Up @@ -2789,6 +2790,7 @@ async function handleStepResult(input: {
);
}
for (const ch of challenges) {
if (isPendingAuthorizationChallenge(baseSession.state, ch)) continue;
await emit(
createAuthorizationRequiredEvent({
attemptId: ch.attemptId,
Expand Down
Loading
Loading