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
15 changes: 15 additions & 0 deletions packages/eve/src/context/build-dynamic-tools.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { HarnessToolDefinition } from "#harness/execute-tool.js";
import type { HarnessToolMap } from "#harness/types.js";
import type { ContextKey } from "#context/key.js";
import {
SessionDynamicToolMetadataKey,
Expand Down Expand Up @@ -121,6 +122,20 @@ function buildReplayedApproval(
* `LiveStepToolsKey`). Session/turn tools are replayed from durable
* metadata via the bundler's registered step functions.
*/
export function buildResponseAuthorizationTools(input: {
readonly authoredTools: HarnessToolMap;
readonly context?: { get<T>(key: ContextKey<T>): T | undefined };
}): HarnessToolMap {
const tools = new Map<string, HarnessToolDefinition>();
for (const tool of input.context === undefined ? [] : buildDynamicTools(input.context)) {
if (!tools.has(tool.name)) tools.set(tool.name, tool);
}
for (const [name, tool] of input.authoredTools) {
if (!tools.has(name)) tools.set(name, tool);
}
return tools;
}

export function buildDynamicTools(ctx: {
get<T>(key: ContextKey<T>): T | undefined;
}): readonly HarnessToolDefinition[] {
Expand Down
41 changes: 39 additions & 2 deletions packages/eve/src/context/dynamic-tool-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { asSchema } from "ai";
import { asSchema, jsonSchema } from "ai";
import { describe, expect, it, vi } from "vitest";

import type { DynamicToolEntry } from "#shared/dynamic-tool-definition.js";
Expand All @@ -19,10 +19,12 @@ const {
dispatchDynamicToolEvent,
refreshDynamicSessionToolsForRuntimeRevision,
} = await import("#context/dynamic-tool-lifecycle.js");
const { buildDynamicTools } = await import("#context/build-dynamic-tools.js");
const { buildDynamicTools, buildResponseAuthorizationTools } =
await import("#context/build-dynamic-tools.js");

import { ContextContainer } from "#context/container.js";
import {
LiveStepToolsKey,
SessionIdKey,
SessionDynamicToolMetadataKey,
SessionDynamicToolRuntimeRevisionKey,
Expand Down Expand Up @@ -1184,6 +1186,41 @@ describe("framework dynamic tools (no bundler transform)", () => {
expect(approvalFn).toHaveBeenCalledExactlyOnceWith(approvalCtx);
});

it("uses the first dynamic definition for response authorization", () => {
const ctx = createCtx();
ctx.set(LiveStepToolsKey, [
{
approval: {
authorizeResponse: async () => "allowed" as const,
policy: () => "user-approval",
},
description: "step",
execute: () => null,
inputSchema: jsonSchema({ type: "object" }),
name: "guarded",
},
]);
ctx.set(SessionDynamicToolMetadataKey, [
{
approvalResponseStepFnName: "session-authorizer",
approvalStepFnName: "session-policy",
description: "session",
entryKey: "session:guarded",
executeStepFnName: "session-execute",
inputSchema: { type: "object" },
name: "guarded",
resolverSlug: "session",
},
]);

const tools = buildResponseAuthorizationTools({
authoredTools: new Map(),
context: ctx,
});

expect(tools.get("guarded")?.description).toBe("step");
});

it("replays response authorization from session-scoped dynamic tools", async () => {
const ctx = createCtx();
const authorizeResponse = vi.fn(async () => "allowed" as const);
Expand Down
99 changes: 85 additions & 14 deletions packages/eve/src/execution/tool-auth.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";

import { buildApprovalResponseAuth, createToolExecuteWithAuth } from "#execution/tool-auth.js";
import {
createApprovalCandidate,
getActiveApprovalCandidate,
} from "#harness/approval-candidates.js";
import { evictScopedToken, resolveScopedToken } from "#runtime/connections/scoped-authorization.js";
import { loadContext } from "#context/container.js";
import { AuthKey, SessionIdKey } from "#context/keys.js";
Expand Down Expand Up @@ -72,15 +76,89 @@ function authoredTool(input: {
}

describe("approval response authorization", () => {
it("resolves user tokens for the explicitly bound responder instead of ambient auth", async () => {
it("resolves user tokens for the durable responder instead of ambient auth", async () => {
let resolvedPrincipal: ConnectionPrincipal | undefined;
const provider: AuthorizationDefinition = {
principalType: "user",
async getToken({ principal }): Promise<TokenResult> {
resolvedPrincipal = principal;
return { token: "durable-responder-token" };
},
};
const created = createApprovalCandidate({
candidateIdPrefix: "candidate-1",
createdAt: 100,
expiresAt: 700,
requestId: "request-1",
responder: {
attributes: { environment: "production", role: ["approver"] },
authenticator: "test-idp",
issuer: "test-idp",
principalId: "durable-U1",
principalType: "user",
subject: "durable-subject-U1",
},
state: undefined,
});
const candidate = getActiveApprovalCandidate(created.state, "candidate-1");
if (candidate === undefined) throw new Error("Expected durable candidate.");
const runtime = createTestRuntime({ tools: [] });

const token = await runtime.runAsSession(undefined, async () => {
loadContext().set(AuthKey, {
attributes: {},
authenticator: "test-idp",
issuer: "test-idp",
principalId: "ambient-U2",
principalType: "user",
});
const auth = buildApprovalResponseAuth({
responder: candidate.responder,
scope: "candidate-1",
});
return await auth.getToken(provider);
});

expect(token.token).toBe("durable-responder-token");
expect(candidate.responder).toMatchObject({
attributes: { environment: "production", role: ["approver"] },
subject: "durable-subject-U1",
});
expect(resolvedPrincipal).toMatchObject({
attributes: { environment: "production", role: ["approver"] },
id: "durable-U1",
issuer: "test-idp",
type: "user",
});
});

it("preserves Vercel development subject projection through candidate state", async () => {
let resolvedPrincipal: ConnectionPrincipal | undefined;
const provider: AuthorizationDefinition = {
principalType: "user",
vercelConnect: { connector: "oauth/github" },
async getToken({ principal }): Promise<TokenResult> {
resolvedPrincipal = principal;
return { token: "bound-responder-token" };
return { token: "development-user-token" };
},
};
const created = createApprovalCandidate({
candidateIdPrefix: "candidate-vercel",
createdAt: 100,
expiresAt: 700,
requestId: "request-vercel",
responder: {
attributes: { environment: "development", user_id: "vercel-user-1" },
authenticator: "oidc",
issuer: "https://oidc.vercel.com/team",
principalId: "channel-user-1",
principalType: "user",
subject: "vercel-user-1",
},
state: undefined,
});
const candidate = getActiveApprovalCandidate(created.state, "candidate-vercel");
if (candidate === undefined) throw new Error("Expected durable candidate.");
const runtime = createTestRuntime({ tools: [] });

await runtime.runAsSession(undefined, async () => {
Expand All @@ -91,24 +169,17 @@ describe("approval response authorization", () => {
principalType: "user",
});
return await buildApprovalResponseAuth({
responder: {
attributes: { role: ["approver"] },
authenticator: "test-idp",
issuer: "test-idp",
principalId: "bound-U1",
principalType: "user",
subject: "bound-subject-U1",
},
scope: "candidate-1",
responder: candidate.responder,
scope: "candidate-vercel",
}).getToken(provider);
});

expect(resolvedPrincipal).toMatchObject({
attributes: { role: ["approver"] },
id: "bound-U1",
issuer: "test-idp",
attributes: { environment: "development", user_id: "vercel-user-1" },
id: "vercel-user-1",
type: "user",
});
expect(resolvedPrincipal).not.toHaveProperty("issuer");
});
});

Expand Down
Loading
Loading