From 4bf86b8cc37905775a839aa75cedc19d1741df8a Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 6 Oct 2026 15:33:57 +0200 Subject: [PATCH 1/3] feat(appkit): add on-behalf-of-user mode for agents Add `auth: "on-behalf-of-user"` on agents({ auth }), createAgent({ auth }) and agent.md frontmatter. An OBO agent runs the model call, plugin tools, hand-rolled tools and sub-agent dispatch as the user. Omitting it keeps today's mixed behavior unchanged. - DatabricksAdapter accepts a client provider resolved per call; agents built from a model string use the caller's client in an OBO run. - Routes reject an OBO request without a user token with 401 before any model or tool call, then open the user scope once per request. - Sub-agents use their own mode but never widen to the service principal under an OBO parent. Standalone runAgent requires a caller. - A model 401 mid-run surfaces as IDENTITY_EXPIRED with no SP retry. - Thread-store writes and MLflow tracing stay service principal. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- .../api/appkit/Interface.AgentDefinition.md | 14 + .../appkit/Interface.AgentsPluginConfig.md | 11 + docs/docs/api/appkit/Interface.IndexConfig.md | 2 +- .../api/appkit/Interface.RegisteredAgent.md | 10 + docs/docs/api/appkit/TypeAlias.AgentAuth.md | 7 + docs/docs/api/appkit/index.md | 1 + docs/docs/api/appkit/typedoc-sidebar.ts | 5 + docs/docs/plugins/agents.md | 42 +++ packages/appkit/src/agents/databricks.ts | 41 ++- .../src/agents/tests/databricks.test.ts | 40 +++ packages/appkit/src/beta.ts | 1 + .../appkit/src/context/execution-context.ts | 9 + packages/appkit/src/core/agent/load-agents.ts | 10 + packages/appkit/src/core/agent/run-agent.ts | 40 ++- .../src/core/agent/tests/load-agents.test.ts | 20 ++ .../agent/tests/run-agent-identity.test.ts | 66 ++++ packages/appkit/src/core/agent/types.ts | 18 + .../src/core/tests/appkit-user-scope.test.ts | 315 +++++++++++++++++- packages/appkit/src/plugins/agents/agents.ts | 135 ++++++-- .../appkit/src/plugins/agents/auth-mode.ts | 56 ++++ packages/appkit/src/plugins/agents/index.ts | 1 + .../src/plugins/agents/tool-dispatch.ts | 15 + 22 files changed, 809 insertions(+), 50 deletions(-) create mode 100644 docs/docs/api/appkit/TypeAlias.AgentAuth.md create mode 100644 packages/appkit/src/plugins/agents/auth-mode.ts diff --git a/docs/docs/api/appkit/Interface.AgentDefinition.md b/docs/docs/api/appkit/Interface.AgentDefinition.md index 3010adf52..f67d198b2 100644 --- a/docs/docs/api/appkit/Interface.AgentDefinition.md +++ b/docs/docs/api/appkit/Interface.AgentDefinition.md @@ -12,6 +12,20 @@ Sub-agents, exposed as `agent-` tools on this agent. *** +### auth? + +```ts +optional auth: "on-behalf-of-user"; +``` + +Run this agent on behalf of the signed-in user: the model call, plugin +tools, hand-rolled tools, and sub-agents all use the user's credentials. +Overrides `agents({ auth })` for this agent. Omit for the default, where +the model and hand-rolled tools run as the app service principal and +plugin tools run as the user. + +*** + ### baseSystemPrompt? ```ts diff --git a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md index 2f888b9d0..5547cf6a9 100644 --- a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md +++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md @@ -69,6 +69,17 @@ Milliseconds to wait before auto-denying. Default: 60_000. *** +### auth? + +```ts +optional auth: "on-behalf-of-user"; +``` + +Default identity for agents that don't set their own `auth`. +`"on-behalf-of-user"` runs the whole agent as the signed-in user. + +*** + ### autoInheritSkills? ```ts diff --git a/docs/docs/api/appkit/Interface.IndexConfig.md b/docs/docs/api/appkit/Interface.IndexConfig.md index 95b6797cc..2be1bb2cd 100644 --- a/docs/docs/api/appkit/Interface.IndexConfig.md +++ b/docs/docs/api/appkit/Interface.IndexConfig.md @@ -5,7 +5,7 @@ ### auth? ```ts -optional auth: "service-principal" | "on-behalf-of-user"; +optional auth: "on-behalf-of-user" | "service-principal"; ``` Auth mode for the built-in HTTP routes — "service-principal" (default) diff --git a/docs/docs/api/appkit/Interface.RegisteredAgent.md b/docs/docs/api/appkit/Interface.RegisteredAgent.md index 78a70f9a1..28b64f4bd 100644 --- a/docs/docs/api/appkit/Interface.RegisteredAgent.md +++ b/docs/docs/api/appkit/Interface.RegisteredAgent.md @@ -10,6 +10,16 @@ adapter: AgentAdapter; *** +### auth? + +```ts +optional auth: "on-behalf-of-user"; +``` + +Effective identity: the agent's `auth`, else the plugin default. + +*** + ### baseSystemPrompt? ```ts diff --git a/docs/docs/api/appkit/TypeAlias.AgentAuth.md b/docs/docs/api/appkit/TypeAlias.AgentAuth.md new file mode 100644 index 000000000..c6097a67a --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.AgentAuth.md @@ -0,0 +1,7 @@ +# Type Alias: AgentAuth + +```ts +type AgentAuth = "on-behalf-of-user"; +``` + +Identity an agent runs under. The only value is on-behalf-of-user. diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index f32f79917..59cfa0c69 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -138,6 +138,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | Type Alias | Description | | ------ | ------ | +| [AgentAuth](TypeAlias.AgentAuth.md) | Identity an agent runs under. The only value is on-behalf-of-user. | | [AgentEvent](TypeAlias.AgentEvent.md) | - | | [AgentTool](TypeAlias.AgentTool.md) | Any tool an agent can invoke: inline function tools (`tool()`), hosted MCP tools (`mcpServer()` / raw hosted), toolkit references from plugins (`analytics().toolkit()`), or adapter-hosted Supervisor-API tools (`supervisorTools.*`). | | [AgentTools](TypeAlias.AgentTools.md) | Per-agent tool record. String keys map to inline tools, toolkit entries, hosted tools, etc. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 176a322cb..82e59f192 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -603,6 +603,11 @@ const typedocSidebar: SidebarsConfig = { type: "category", label: "Type Aliases", items: [ + { + type: "doc", + id: "api/appkit/TypeAlias.AgentAuth", + label: "AgentAuth" + }, { type: "doc", id: "api/appkit/TypeAlias.AgentEvent", diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index cee6bf538..09ac4a0c3 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -317,6 +317,46 @@ const result = await runAgent(classifier, { MCP hosted tools (`mcpServer(...)`) still require `agents()` (they need a live MCP client). Supervisor-API hosted tools (`supervisorTools.*`), by contrast, **work in standalone `runAgent`** — the adapter has everything it needs to execute them server-side. This makes batch-eval / CI use of supervisor agents possible without `createApp`. Plugin tool dispatch in standalone mode runs as the service principal (no OBO) and **bypasses the agents-plugin approval gate** — treat standalone runAgent as a trusted-prompt environment (CI, batch eval, internal scripts), not as an exposed user-facing surface. +## Execution identity + +By default an agent runs **mixed**: the model call and hand-rolled `tool({ execute })` tools run as the app's service principal, and plugin-toolkit tools run as the requesting user. Set `auth: "on-behalf-of-user"` to run the whole agent as the user: + +```ts +agents({ auth: "on-behalf-of-user" }); // default for every agent +createAgent({ instructions: "...", auth: "on-behalf-of-user" }); // one agent +``` + +In markdown, set `auth: on-behalf-of-user` in the frontmatter. A per-agent value overrides the plugin default. Omitting `auth` keeps the mixed behavior; there is no all-service-principal mode. + +| Piece | Default (mixed) | `on-behalf-of-user` | +|---|---|---| +| Model call | service principal | user | +| Plugin-toolkit tools | user | user | +| Hand-rolled `tool({ execute })` | service principal | user | +| Sub-agents | own mode | own mode, never service principal | +| Standalone `runAgent` | service principal, or user tools with `caller` | requires `caller` (or an ambient user scope); model and tools as user | +| MLflow tracing | service principal | service principal (exception) | +| Thread store | service principal | service principal (exception) | +| Missing user token | plugin tools reject; the rest runs as the service principal | the request is rejected with 401 before any model or tool call | + +An on-behalf-of-user agent fails closed: + +- No forwarded user token: `401` before any model or tool call, in production and in development. There is no service-principal fallback. +- A `401` from the model mid-run becomes an `IDENTITY_EXPIRED` error event and the stream ends. The run is not retried as the service principal. +- A sub-agent never widens: under an on-behalf-of-user parent, a mixed sub-agent also runs as the user. + +**Exceptions.** MLflow tracing and the thread store are app-owned and stay service principal in every mode; thread rows are keyed by the user id. + +**Pre-built adapters.** The user's client is applied when AppKit builds the adapter from a model string (`model: "my-endpoint"`, `defaultModel`, or `DATABRICKS_SERVING_ENDPOINT_NAME`). An adapter you build yourself keeps the client you gave it; pass a provider so it resolves per call: + +```ts +DatabricksAdapter.fromModelServing("my-endpoint", { + workspaceClient: () => getWorkspaceClient(), +}); +``` + +**Provisioning.** Each user needs the `model-serving` user API scope on the app, and `CAN_QUERY` on any custom serving endpoint the agent calls. Plugin tools still need their own scopes and grants as in mixed mode. + ## Adding agents to an existing app Already have an app and want to add agents? What you touch depends on the kind: @@ -477,6 +517,7 @@ agents({ agents?: Record, // DEPRECATED — use server/agents// discovery defaultAgent?: string, defaultModel?: AgentAdapter | Promise | string, + auth?: "on-behalf-of-user", // default: mixed (see Execution identity) tools?: Record, autoInheritTools?: boolean | { file?: boolean, code?: boolean }, autoInheritSkills?: boolean | { file?: boolean, code?: boolean }, // default off @@ -916,6 +957,7 @@ Skip `--experiment` (and `MLFLOW_EXPERIMENT_ID`) to run evals purely locally wit | `maxTokens` | number | Adapter max-token hint. | | `generationParams` | object | Adapter generation params (e.g. `temperature`, `top_p`) passed through when AppKit builds the adapter. | | `baseSystemPrompt` | false \| string | Per-agent override. `false` disables the AppKit base prompt. | +| `auth` | string | `on-behalf-of-user` runs this agent as the user. Any other value throws at boot. See [Execution identity](#execution-identity). | | `ephemeral` | boolean | If `true`, the thread created for a chat request against this agent is deleted from `ThreadStore` after the stream finishes. Use for stateless one-shot agents (e.g. autocomplete) so history does not accumulate or contaminate future calls. Defaults to `false`. | Unknown keys are logged and ignored. Invalid YAML and missing plugin/tool references throw at boot. diff --git a/packages/appkit/src/agents/databricks.ts b/packages/appkit/src/agents/databricks.ts index d0d18f3bb..56d939183 100644 --- a/packages/appkit/src/agents/databricks.ts +++ b/packages/appkit/src/agents/databricks.ts @@ -180,14 +180,27 @@ function isStreamBodyOptions( * interface rather than importing the SDK type directly. This keeps the adapter * free of a hard compile-time dependency on `@databricks/sdk-experimental`. */ -interface WorkspaceClientLike { +export interface WorkspaceClientLike { apiClient: { request(options: Record): Promise; }; } +/** + * A fixed client, or a provider resolved on every model call. A provider lets + * one adapter follow the active execution scope (for example + * `() => getWorkspaceClient()` for on-behalf-of-user agents). + */ +type WorkspaceClientSource = WorkspaceClientLike | (() => WorkspaceClientLike); + +function clientResolver( + source: WorkspaceClientSource, +): () => WorkspaceClientLike { + return typeof source === "function" ? source : () => source; +} + interface ServingEndpointOptions { - workspaceClient: WorkspaceClientLike; + workspaceClient: WorkspaceClientSource; endpointName: string; maxSteps?: number; maxTokens?: number; @@ -201,7 +214,7 @@ interface ModelServingOptions { maxSteps?: number; maxTokens?: number; generationParams?: GenerationParams; - workspaceClient?: WorkspaceClientLike; + workspaceClient?: WorkspaceClientSource; maxSseLineChars?: number; maxStreamTextChars?: number; maxToolArgumentsChars?: number; @@ -218,8 +231,10 @@ interface AiGatewayOptions { * is created from the ambient client options (SDK credential chain). It is * captured once and reused across requests — do not pass a per-request OBO * client (it would leak the first request's identity into later ones). + * To follow the caller per request, pass a provider such as + * `() => getWorkspaceClient()`; it is resolved on every model call. */ - workspaceClient?: WorkspaceClientLike; + workspaceClient?: WorkspaceClientSource; maxSteps?: number; maxTokens?: number; generationParams?: GenerationParams; @@ -386,13 +401,14 @@ export class DatabricksAdapter implements AgentAdapter { maxStreamTextChars, maxToolArgumentsChars, } = options; + const resolveClient = clientResolver(workspaceClient); return new DatabricksAdapter({ streamBody: (body, signal) => // Cast through the structural shape: the connector types // `workspaceClient` as the SDK's concrete `WorkspaceClient`, but we // only need `apiClient.request`. servingStream( - workspaceClient as unknown as Parameters[0], + resolveClient() as unknown as Parameters[0], endpointName, body, signal, @@ -440,7 +456,7 @@ export class DatabricksAdapter implements AgentAdapter { ); } - let workspaceClient: WorkspaceClientLike | undefined = + let workspaceClient: WorkspaceClientSource | undefined = options?.workspaceClient; if (!workspaceClient) { workspaceClient = createWorkspaceClient({ @@ -508,11 +524,12 @@ export class DatabricksAdapter implements AgentAdapter { maxToolArgumentsChars, } = options; - const client = + const resolveClient = clientResolver( workspaceClient ?? - (createWorkspaceClient({ - clientOptions: getClientOptions(), - }) as unknown as WorkspaceClientLike); + (createWorkspaceClient({ + clientOptions: getClientOptions(), + }) as unknown as WorkspaceClientLike), + ); return new DatabricksAdapter({ streamBody: (body, signal) => @@ -520,7 +537,7 @@ export class DatabricksAdapter implements AgentAdapter { // the client as the SDK's `WorkspaceClient`, but we only need // `apiClient.request`. streamAiGateway( - client as unknown as Parameters[0], + resolveClient() as unknown as Parameters[0], body, signal, ), @@ -961,7 +978,7 @@ export class DatabricksAdapter implements AgentAdapter { */ type ModelStringOptions = Pick< AiGatewayOptions, - "maxSteps" | "maxTokens" | "generationParams" + "maxSteps" | "maxTokens" | "generationParams" | "workspaceClient" >; /** diff --git a/packages/appkit/src/agents/tests/databricks.test.ts b/packages/appkit/src/agents/tests/databricks.test.ts index 722d943e7..a511ef6d4 100644 --- a/packages/appkit/src/agents/tests/databricks.test.ts +++ b/packages/appkit/src/agents/tests/databricks.test.ts @@ -1091,6 +1091,46 @@ describe("DatabricksAdapter", () => { }); describe("DatabricksAdapter.fromServingEndpoint", () => { + test.each([ + [ + "fromServingEndpoint", + (workspaceClient: () => { apiClient: unknown }) => + DatabricksAdapter.fromServingEndpoint({ + workspaceClient: workspaceClient as never, + endpointName: "my-model", + }), + ], + [ + "fromAiGateway", + (workspaceClient: () => { apiClient: unknown }) => + DatabricksAdapter.fromAiGateway({ + workspaceClient: workspaceClient as never, + model: "system.ai.claude", + }), + ], + ])("%s resolves a client provider on every run", async (_name, build) => { + const clients = ["alice", "bob"].map((user) => ({ + user, + apiClient: { + request: vi.fn(async () => ({ + contents: createReadableStream([textDelta(user), sseChunk("[DONE]")]), + })), + }, + })); + let next = 0; + const adapter = await build(() => clients[next++]); + for (const _ of clients) { + for await (const _event of adapter.run( + { messages: createTestMessages(), tools: [], threadId: "t1" }, + { executeTool: vi.fn() }, + )) { + // drain + } + } + expect(clients[0].apiClient.request).toHaveBeenCalledTimes(1); + expect(clients[1].apiClient.request).toHaveBeenCalledTimes(1); + }); + test("routes tool-free chat through apiClient.request with a streaming payload", async () => { const apiClient = { request: vi.fn().mockResolvedValue({ diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 68126551b..03d574653 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -87,6 +87,7 @@ export { export * from "./evals"; // Agent types export type { + AgentAuth, AgentDefinition, AgentsPluginConfig, AgentTool, diff --git a/packages/appkit/src/context/execution-context.ts b/packages/appkit/src/context/execution-context.ts index e3ab8e3c2..675f1b344 100644 --- a/packages/appkit/src/context/execution-context.ts +++ b/packages/appkit/src/context/execution-context.ts @@ -194,6 +194,15 @@ export function isInUserContext(): boolean { return ctx !== undefined; } +/** + * @internal Run `fn` with no caller scope, so it executes as the app service + * principal even inside an on-behalf-of-user run. Used for app-owned + * state such as the agents thread store. + */ +export function runOutsideCallerScope(fn: () => T): T { + return executionContextStorage.exit(fn); +} + /** * Get the caller context if one is active, otherwise `undefined`. * Unlike `getExecutionContext()`, this does not require `ServiceContext` diff --git a/packages/appkit/src/core/agent/load-agents.ts b/packages/appkit/src/core/agent/load-agents.ts index 2952ccaae..15088bd5e 100644 --- a/packages/appkit/src/core/agent/load-agents.ts +++ b/packages/appkit/src/core/agent/load-agents.ts @@ -7,6 +7,7 @@ import type { AgentAdapter } from "shared"; import type { GenerationParams } from "../../agents/databricks"; import type { + AgentAuth, AgentDefinition, AgentTool, BaseSystemPromptOption, @@ -96,6 +97,7 @@ interface Frontmatter { default?: boolean; baseSystemPrompt?: false | string; ephemeral?: boolean; + auth?: AgentAuth; } /** @@ -143,6 +145,7 @@ const ALLOWED_KEYS = new Set([ "default", "baseSystemPrompt", "ephemeral", + "auth", ]); /** @@ -469,6 +472,12 @@ function buildDefinition( const tools = resolveFrontmatterTools(name, fm, filePath, ctx); const model = fm.model ?? fm.endpoint ?? ctx.defaultModel; + if (fm.auth !== undefined && fm.auth !== "on-behalf-of-user") { + throw new Error( + `Agent '${name}' (${filePath}) has invalid 'auth:' frontmatter: ` + + `expected "on-behalf-of-user", got ${JSON.stringify(fm.auth)}.`, + ); + } let baseSystemPrompt: BaseSystemPromptOption | undefined; if (fm.baseSystemPrompt === false) baseSystemPrompt = false; @@ -486,6 +495,7 @@ function buildDefinition( generationParams: parseGenerationParams(fm.generationParams, filePath), baseSystemPrompt, ephemeral: typeof fm.ephemeral === "boolean" ? fm.ephemeral : undefined, + auth: fm.auth, }; } diff --git a/packages/appkit/src/core/agent/run-agent.ts b/packages/appkit/src/core/agent/run-agent.ts index b1ce8efef..847dd4f1a 100644 --- a/packages/appkit/src/core/agent/run-agent.ts +++ b/packages/appkit/src/core/agent/run-agent.ts @@ -10,12 +10,18 @@ import type { ToolProvider, } from "shared"; +import type { WorkspaceClientLike } from "../../agents/databricks"; import { isSupervisorTool, SUPERVISOR_EXTENSION_KEY, type SupervisorTool, } from "../../agents/supervisor-api"; -import { type CallerPrincipal, runInCallerContext } from "../../context"; +import { + type CallerPrincipal, + getCallerContext, + getWorkspaceClient, + runInCallerContext, +} from "../../context"; import { getClientOptions } from "../../context/client-options"; import { assertPluginExecution } from "../../context/resource-capabilities"; import { AuthenticationError, ConfigurationError } from "../../errors"; @@ -106,6 +112,16 @@ export async function runAgent( def: AgentDefinition, input: RunAgentInput, ): Promise { + // An ambient user scope (e.g. inside a request) counts as the caller. + if ( + def.auth === "on-behalf-of-user" && + !input.caller && + getCallerContext()?.principal.type !== "user" + ) { + throw AuthenticationError.missingToken( + "user token (runAgent caller is required for an on-behalf-of-user agent)", + ); + } if (input.caller) { const { principal, host, workspaceId } = input.caller; const token = input.caller.token.trim(); @@ -150,15 +166,19 @@ async function runStandalone( // (e.g. query result caches, connection pools). const providerCache = new Map(); await initStandalonePlugins(input.plugins ?? [], providerCache); - return runAgentInternal(def, input, providerCache); + return runAgentInternal(def, input, providerCache, false); } async function runAgentInternal( def: AgentDefinition, input: RunAgentInput, providerCache: Map, + parentOnBehalfOfUser: boolean, ): Promise { - const adapter = await resolveAdapter(def); + // Never widen: under an on-behalf-of-user parent every child stays the user. + const onBehalfOfUser = + parentOnBehalfOfUser || def.auth === "on-behalf-of-user"; + const adapter = await resolveAdapter(def, onBehalfOfUser); const messages = normalizeMessages(input.messages, def.instructions); const toolIndex = buildStandaloneToolIndex( def, @@ -206,6 +226,7 @@ async function runAgentInternal( entry.agentDef, subInput, providerCache, + onBehalfOfUser, ); return res.text; } @@ -317,7 +338,10 @@ async function initStandalonePlugins( } } -async function resolveAdapter(def: AgentDefinition): Promise { +async function resolveAdapter( + def: AgentDefinition, + onBehalfOfUser: boolean, +): Promise { // Explicit model wins; otherwise fall back to the // DATABRICKS_SERVING_ENDPOINT_NAME env default. A string from either source // routes by name (system.* → AI Gateway, else Model Serving). @@ -330,7 +354,13 @@ async function resolveAdapter(def: AgentDefinition): Promise { } if (typeof source === "string") { const { adapterFromModelString } = await import("../../agents/databricks"); - return adapterFromModelString(source); + // On behalf of the user the model client is the caller's, per call. + return onBehalfOfUser + ? adapterFromModelString(source, { + workspaceClient: () => + getWorkspaceClient() as unknown as WorkspaceClientLike, + }) + : adapterFromModelString(source); } return await source; } diff --git a/packages/appkit/src/core/agent/tests/load-agents.test.ts b/packages/appkit/src/core/agent/tests/load-agents.test.ts index cc927d9a8..107dfb1d4 100644 --- a/packages/appkit/src/core/agent/tests/load-agents.test.ts +++ b/packages/appkit/src/core/agent/tests/load-agents.test.ts @@ -88,6 +88,26 @@ describe("parseFrontmatter", () => { }); describe("loadAgentFromFile", () => { + test("honors auth: on-behalf-of-user and rejects any other value", async () => { + const obo = writeRoot( + "obo.md", + "---\nendpoint: e-1\nauth: on-behalf-of-user\n---\nHi.", + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect((await loadAgentFromFile(obo, {})).auth).toBe("on-behalf-of-user"); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + const mixed = writeRoot("mixed.md", "---\nendpoint: e-1\n---\nHi."); + expect((await loadAgentFromFile(mixed, {})).auth).toBeUndefined(); + const typo = writeRoot( + "typo.md", + "---\nendpoint: e-1\nauth: obo\n---\nHi.", + ); + await expect(loadAgentFromFile(typo, {})).rejects.toThrow( + /invalid 'auth:'/, + ); + }); + test("returns AgentDefinition with body as instructions", async () => { const p = writeRoot( "assistant.md", diff --git a/packages/appkit/src/core/agent/tests/run-agent-identity.test.ts b/packages/appkit/src/core/agent/tests/run-agent-identity.test.ts index 1562fb6c5..36bbbceb4 100644 --- a/packages/appkit/src/core/agent/tests/run-agent-identity.test.ts +++ b/packages/appkit/src/core/agent/tests/run-agent-identity.test.ts @@ -170,4 +170,70 @@ describe("standalone caller identity", () => { vi.unstubAllEnvs(); } }); + + test("an on-behalf-of-user agent rejects a run without a caller", async () => { + const run = vi.fn(probe.run); + const def = createAgent({ + instructions: "identity", + model: { run }, + auth: "on-behalf-of-user", + }); + await expect(runAgent(def, { messages: "hi" })).rejects.toThrow( + /user token/, + ); + expect(run).not.toHaveBeenCalled(); + expect( + (await runAgent(def, { messages: "hi", caller: credentials("alice") })) + .text, + ).toBe("user:alice"); + }); + + test("a string model runs on the caller's client, and a mixed sub-agent never widens", async () => { + const used: string[] = []; + const real = workspace.createWorkspaceClient; + vi.spyOn(workspace, "createWorkspaceClient").mockImplementation( + (options) => { + const client = real(options); + const caller = options?.token ? "user" : "app"; + client.apiClient.request = (async () => { + used.push(caller); + return { + contents: new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n', + ), + ); + controller.close(); + }, + }), + }; + }) as never; + return client; + }, + ); + const child = createAgent({ instructions: "child", model: "my-endpoint" }); + const delegate: AgentAdapter = { + async *run(_input, ctx) { + yield { + type: "message_delta", + content: String( + await ctx.executeTool("agent-child", { input: "go" }), + ), + }; + }, + }; + const parent = (auth?: "on-behalf-of-user") => + createAgent({ + instructions: "parent", + model: delegate, + agents: { child }, + ...(auth && { auth }), + }); + const caller = credentials("alice"); + await runAgent(parent("on-behalf-of-user"), { messages: "hi", caller }); + await runAgent(parent(), { messages: "hi", caller }); + expect(used).toEqual(["user", "app"]); + }); }); diff --git a/packages/appkit/src/core/agent/types.ts b/packages/appkit/src/core/agent/types.ts index 53dae3c87..0fe15c043 100644 --- a/packages/appkit/src/core/agent/types.ts +++ b/packages/appkit/src/core/agent/types.ts @@ -202,8 +202,19 @@ export interface AgentDefinition { * `InMemoryThreadStore`. Defaults to `false`. */ ephemeral?: boolean; + /** + * Run this agent on behalf of the signed-in user: the model call, plugin + * tools, hand-rolled tools, and sub-agents all use the user's credentials. + * Overrides `agents({ auth })` for this agent. Omit for the default, where + * the model and hand-rolled tools run as the app service principal and + * plugin tools run as the user. + */ + auth?: AgentAuth; } +/** Identity an agent runs under. The only value is on-behalf-of-user. */ +export type AgentAuth = "on-behalf-of-user"; + /** * Auto-inherit configuration. When enabled for a given agent origin, agents * with no explicit `tools:` declaration receive every registered ToolProvider @@ -237,6 +248,11 @@ export interface AgentsPluginConfig extends BasePluginConfig { defaultAgent?: string; /** Default model for agents that don't specify their own (in code or frontmatter). */ defaultModel?: AgentAdapter | Promise | string; + /** + * Default identity for agents that don't set their own `auth`. + * `"on-behalf-of-user"` runs the whole agent as the signed-in user. + */ + auth?: AgentAuth; /** Ambient tool library. Keys may be referenced by markdown frontmatter via `tools: [key1, key2]`. */ tools?: Record; /** Whether to auto-inherit every ToolProvider plugin's toolkit. Accepts a boolean shorthand. */ @@ -399,6 +415,8 @@ export interface RegisteredAgent { generationParams?: GenerationParams; /** Mirrors `AgentDefinition.ephemeral` — skip thread persistence. */ ephemeral?: boolean; + /** Effective identity: the agent's `auth`, else the plugin default. */ + auth?: AgentAuth; /** * Resolved per-agent skill catalog (visibility + collision rules applied). * Present when any skill is visible to this agent; drives the always-on diff --git a/packages/appkit/src/core/tests/appkit-user-scope.test.ts b/packages/appkit/src/core/tests/appkit-user-scope.test.ts index f96405b76..702e8e64d 100644 --- a/packages/appkit/src/core/tests/appkit-user-scope.test.ts +++ b/packages/appkit/src/core/tests/appkit-user-scope.test.ts @@ -15,7 +15,13 @@ import { import { isDevOboFallback } from "../../context/request-scope"; import { Plugin, toPlugin } from "../../plugin"; import { agents } from "../../plugins/agents"; -import { createMockRequest, createTestApp } from "../../testing"; +import { InMemoryThreadStore } from "../../plugins/agents/thread-store"; +import { + createMockRequest, + createMockWorkspaceClient, + createTestApp, +} from "../../testing"; +import * as workspace from "../../workspace-client"; import { tool } from "../agent/tools/tool"; import type { AgentDefinition } from "../agent/types"; @@ -519,3 +525,310 @@ describe("agents HTTP identity boundary", () => { }, ); }); + +describe("agents on-behalf-of-user mode", () => { + const paths = ["/invocations", "/responses", "/api/agents/chat"]; + const body = (path: string) => + path.endsWith("chat") ? { message: "hello" } : { input: "hello" }; + + const adapter: AgentAdapter = { + async *run(_input, ctx) { + const model = getCurrentPrincipalKey(); + const plugin = await ctx.executeTool("identity.read", {}); + const handRolled = await ctx.executeTool("whoami", {}); + yield { + type: "message_delta", + content: `model=${model} plugin=${plugin} handRolled=${handRolled}`, + }; + yield { type: "status", status: "complete" }; + }, + }; + const whoami = tool({ + description: "Report the principal seen by a hand-rolled tool", + schema: z.object({}), + execute: () => getCurrentPrincipalKey(), + }); + const probe = (extra: Partial = {}): AgentDefinition => ({ + instructions: "Identify the caller", + model: adapter, + tools: (plugins) => ({ ...plugins.identity.toolkit(), whoami }), + ...extra, + }); + const delegate: AgentAdapter = { + async *run(_input, ctx) { + const model = getCurrentPrincipalKey(); + const child = await ctx.executeTool("agent-child", { input: "go" }); + yield { + type: "message_delta", + content: `parent=${model} child[${child}]`, + }; + yield { type: "status", status: "complete" }; + }, + }; + const userEverywhere = + "model=user:alice plugin=user:alice handRolled=user:alice"; + + test.each(paths)( + "%s runs the model, plugin tools and hand-rolled tools as the user", + async (path) => { + await using app = await createTestApp({ + plugins: [ + identity(), + agents({ auth: "on-behalf-of-user", agents: { probe: probe() } }), + ], + }); + const response = await app.post(path, { + body: body(path), + obo: { userId: "alice" }, + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain(userEverywhere); + expect(getCurrentPrincipalKey()).toBe("app"); + }, + ); + + test("a per-agent auth overrides the mixed plugin default", async () => { + await using app = await createTestApp({ + plugins: [ + identity(), + agents({ + agents: { + probe: probe({ auth: "on-behalf-of-user", default: true }), + mixed: probe(), + }, + }), + ], + }); + const obo = await app.post("/api/agents/chat", { + body: { message: "hello" }, + obo: { userId: "alice" }, + }); + expect(await obo.text()).toContain(userEverywhere); + const mixed = await app.post("/api/agents/chat", { + body: { message: "hello", agent: "mixed" }, + obo: { userId: "alice" }, + }); + expect(await mixed.text()).toContain( + "model=app plugin=user:alice handRolled=app", + ); + }); + + test("a mixed sub-agent under an on-behalf-of-user parent never widens to the app", async () => { + const child = probe(); + await using app = await createTestApp({ + plugins: [ + identity(), + agents({ + agents: { + probe: { + default: true, + auth: "on-behalf-of-user", + instructions: "Delegate", + model: delegate, + agents: { child }, + }, + child, + }, + }), + ], + }); + const response = await app.post("/api/agents/chat", { + body: { message: "hello" }, + obo: { userId: "alice" }, + }); + expect(await response.text()).toContain( + `parent=user:alice child[${userEverywhere}]`, + ); + }); + + test("an on-behalf-of-user sub-agent under a mixed parent runs as the user", async () => { + const child = probe({ auth: "on-behalf-of-user" }); + await using app = await createTestApp({ + plugins: [ + identity(), + agents({ + agents: { + probe: { + default: true, + instructions: "Delegate", + model: delegate, + agents: { child }, + }, + child, + }, + }), + ], + }); + const response = await app.post("/api/agents/chat", { + body: { message: "hello" }, + obo: { userId: "alice" }, + }); + expect(await response.text()).toContain( + `parent=app child[${userEverywhere}]`, + ); + }); + + test.each([ + ["production", "/invocations"], + ["production", "/responses"], + ["production", "/api/agents/chat"], + ["development", "/invocations"], + ["development", "/responses"], + ["development", "/api/agents/chat"], + ])( + "%s %s returns 401 without a user token before any model or tool call", + async (env, path) => { + vi.stubEnv("NODE_ENV", env); + const run = vi.fn(adapter.run); + try { + await using app = await createTestApp({ + plugins: [ + identity(), + agents({ + auth: "on-behalf-of-user", + agents: { probe: probe({ model: { run } }) }, + }), + ], + }); + const response = await app.post(path, { + body: body(path), + headers: { "x-forwarded-user": "alice" }, + }); + expect(response.status).toBe(401); + expect(await response.json()).toMatchObject({ + code: "AUTHENTICATION_ERROR", + }); + expect(run).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + } + }, + ); + + test("a model 401 mid-run ends the stream with IDENTITY_EXPIRED and is not retried as the app", async () => { + const principals: string[] = []; + const expiring: AgentAdapter = { + async *run() { + principals.push(getCurrentPrincipalKey()); + yield { type: "message_delta", content: "partial" }; + throw Object.assign(new Error("upstream"), { status: 401 }); + }, + }; + await using app = await createTestApp({ + plugins: [ + identity(), + agents({ + auth: "on-behalf-of-user", + agents: { probe: probe({ model: expiring }) }, + }), + ], + }); + const streamed = await app.post("/api/agents/chat", { + body: { message: "hello" }, + obo: { userId: "alice" }, + }); + expect(await streamed.text()).toContain("IDENTITY_EXPIRED"); + const invoked = await app.post("/invocations", { + body: { input: "hello", stream: false }, + obo: { userId: "alice" }, + }); + expect(invoked.status).toBe(401); + expect(await invoked.json()).toMatchObject({ code: "IDENTITY_EXPIRED" }); + expect(principals).toEqual(["user:alice", "user:alice"]); + }); + + test("thread-store writes stay the app under an on-behalf-of-user agent", async () => { + const writers: string[] = []; + class RecordingStore extends InMemoryThreadStore { + override addMessage( + ...args: Parameters + ) { + writers.push(getCurrentPrincipalKey()); + return super.addMessage(...args); + } + } + await using app = await createTestApp({ + plugins: [ + identity(), + agents({ + auth: "on-behalf-of-user", + threadStore: new RecordingStore(), + agents: { probe: probe() }, + }), + ], + }); + const response = await app.post("/api/agents/chat", { + body: { message: "hello" }, + obo: { userId: "alice" }, + }); + expect(await response.text()).toContain(userEverywhere); + expect(writers.length).toBeGreaterThan(0); + expect(new Set(writers)).toEqual(new Set(["app"])); + }); + + test.each([ + ["serving endpoint", "my-endpoint"], + ["AI Gateway", "system.ai.claude"], + ])( + "a %s model string calls the model with the user client, mixed with the app client", + async (_label, model) => { + const used: string[] = []; + const answer = (caller: string) => + (async () => { + used.push(caller); + return { + contents: new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n', + ), + ); + controller.close(); + }, + }), + }; + }) as never; + // The harness backs the user scope with `client`; the app's own model + // client comes from createWorkspaceClient. + const client = createMockWorkspaceClient(); + client.apiClient.request = answer("user"); + const real = workspace.createWorkspaceClient; + vi.spyOn(workspace, "createWorkspaceClient").mockImplementation( + (options) => { + const sp = real(options); + sp.apiClient.request = answer("app"); + return sp; + }, + ); + try { + await using app = await createTestApp({ + client, + plugins: [ + agents({ + agents: { + probe: { + default: true, + auth: "on-behalf-of-user", + instructions: "hi", + model, + }, + mixed: { instructions: "hi", model }, + }, + }), + ], + }); + for (const agent of ["probe", "mixed"]) { + const response = await app.post("/api/agents/chat", { + body: { message: "hello", agent }, + obo: { userId: "alice" }, + }); + expect(await response.text()).toContain("ok"); + } + expect(used).toEqual(["user", "app"]); + } finally { + vi.restoreAllMocks(); + } + }, + ); +}); diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index 6856b2907..ec40aa690 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -19,7 +19,11 @@ import type { import { isSupervisorTool } from "../../agents/supervisor-api"; import { AppKitMcpClient, buildMcpHostPolicy } from "../../connectors/mcp"; import { getWorkspaceClient } from "../../context"; -import { normalizeIdentityError } from "../../context/execution-context"; +import { + normalizeIdentityError, + runOutsideCallerScope, +} from "../../context/execution-context"; +import { createRequestScope } from "../../context/request-scope"; import { consumeAdapterStream } from "../../core/agent/consume-adapter-stream"; import { loadAgentsFromDir } from "../../core/agent/load-agents"; import { CODE_AGENTS_SOURCE_DIR } from "../../core/agent/load-code-agents"; @@ -45,6 +49,7 @@ import type { ResolvedToolEntry, } from "../../core/agent/types"; import { isToolkitEntry } from "../../core/agent/types"; +import { AuthenticationError } from "../../errors"; import { IdentityExpiredError } from "../../errors/identity-expired"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; @@ -57,6 +62,12 @@ import { warnOnCapabilityMismatch, } from "./adapter-extensions"; import { requiresApproval } from "./approval"; +import { + isOnBehalfOfUser, + modelClientProvider, + requireOboCaller, + runInOboAgentRun, +} from "./auth-mode"; import { LOAD_SKILL_TOOL_DEF, READ_SKILL_FILE_TOOL_DEF } from "./builtin-tools"; import { agentStreamDefaults } from "./defaults"; import { EventChannel } from "./event-channel"; @@ -503,6 +514,9 @@ export class AgentsPlugin extends Plugin implements ToolProvider { generationParams: def.generationParams, ephemeral: def.ephemeral, skills, + ...((def.auth ?? this.config.auth) && { + auth: def.auth ?? this.config.auth, + }), }; } @@ -575,7 +589,12 @@ export class AgentsPlugin extends Plugin implements ToolProvider { if (typeof source === "string") { const { adapterFromModelString } = await import("../../agents/databricks"); - return adapterFromModelString(source, adapterOptions); + return adapterFromModelString(source, { + ...adapterOptions, + // Resolved per call: the caller inside an on-behalf-of-user run, the + // app service principal otherwise (unchanged default). + workspaceClient: modelClientProvider(), + }); } return await source; } @@ -1009,6 +1028,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { }); return; } + if (!this.allowsCaller(registered, req, res)) return; const userId = this.resolveUserId(req); @@ -1052,14 +1072,16 @@ export class AgentsPlugin extends Plugin implements ToolProvider { res.status(500).json({ error: "Thread operation failed" }); return; } - return this._streamAgent( - req, - res, - registered, - thread, - userId, - mlflowRunId, - skill, + return this.runInAgentScope(registered, req, () => + this._streamAgent( + req, + res, + registered, + thread, + userId, + mlflowRunId, + skill, + ), ); } @@ -1112,6 +1134,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { res.status(400).json({ error: "No agent registered" }); return; } + if (!this.allowsCaller(registered, req, res)) return; // Pre-flight HITL gate. The non-streaming invoke surface has no way to // surface an approval prompt back to the caller and no way to receive @@ -1178,16 +1201,58 @@ export class AgentsPlugin extends Plugin implements ToolProvider { return; } - return this._runAgentNonStreaming( - req, - res, - registered, - thread, - userId, - mlflowRunId, + return this.runInAgentScope(registered, req, () => + this._runAgentNonStreaming( + req, + res, + registered, + thread, + userId, + mlflowRunId, + ), ); } + /** + * Fail closed for an on-behalf-of-user agent: without a forwarded user token + * the request is rejected with 401 before any model or tool call, in + * production and in development. Other agents are unaffected. + */ + private allowsCaller( + registered: RegisteredAgent, + req: express.Request, + res: express.Response, + ): boolean { + if (!isOnBehalfOfUser(registered.auth)) return true; + try { + requireOboCaller(req); + return true; + } catch (error) { + const failure = + error instanceof AuthenticationError + ? error + : AuthenticationError.missingToken("user token"); + res + .status(401) + .json({ error: failure.clientMessage, code: failure.code }); + return false; + } + } + + /** + * Run an on-behalf-of-user agent inside the request's user scope, so the + * model call, hand-rolled tools, and sub-agents all act as the user. Other + * agents run exactly as before. + */ + private runInAgentScope( + registered: RegisteredAgent, + req: express.Request, + fn: () => Promise, + ): Promise { + if (!isOnBehalfOfUser(registered.auth)) return fn(); + return createRequestScope(req).run(() => runInOboAgentRun(fn)); + } + private async _streamAgent( req: express.Request, res: express.Response, @@ -1323,12 +1388,14 @@ export class AgentsPlugin extends Plugin implements ToolProvider { if (fullContent) { span.setOutputs({ role: "assistant", content: fullContent }); - await this.threadStore.addMessage(thread.id, userId, { - id: randomUUID(), - role: "assistant", - content: fullContent, - createdAt: new Date(), - }); + await runOutsideCallerScope(() => + this.threadStore.addMessage(thread.id, userId, { + id: randomUUID(), + role: "assistant", + content: fullContent, + createdAt: new Date(), + }), + ); } // Surface the MLflow trace id so eval runs can attach assessments @@ -1366,7 +1433,9 @@ export class AgentsPlugin extends Plugin implements ToolProvider { // finished and the client has the response. if (registered.ephemeral) { try { - await this.threadStore.delete(thread.id, userId); + await runOutsideCallerScope(() => + this.threadStore.delete(thread.id, userId), + ); } catch (err) { logger.warn( "Failed to delete ephemeral thread %s: %O", @@ -1518,12 +1587,14 @@ export class AgentsPlugin extends Plugin implements ToolProvider { if (fullContent) { span.setOutputs({ role: "assistant", content: fullContent }); - await this.threadStore.addMessage(thread.id, userId, { - id: randomUUID(), - role: "assistant", - content: fullContent, - createdAt: new Date(), - }); + await runOutsideCallerScope(() => + this.threadStore.addMessage(thread.id, userId, { + id: randomUUID(), + role: "assistant", + content: fullContent, + createdAt: new Date(), + }), + ); } mlflowTraceId = currentTraceId(); @@ -1555,7 +1626,9 @@ export class AgentsPlugin extends Plugin implements ToolProvider { this.untrackStream(requestId); if (registered.ephemeral) { try { - await this.threadStore.delete(thread.id, userId); + await runOutsideCallerScope(() => + this.threadStore.delete(thread.id, userId), + ); } catch (err) { logger.warn( "Failed to delete ephemeral thread %s: %O", diff --git a/packages/appkit/src/plugins/agents/auth-mode.ts b/packages/appkit/src/plugins/agents/auth-mode.ts new file mode 100644 index 000000000..49f00d676 --- /dev/null +++ b/packages/appkit/src/plugins/agents/auth-mode.ts @@ -0,0 +1,56 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import type express from "express"; + +import { getWorkspaceClient } from "../../context"; +import { getClientOptions } from "../../context/client-options"; +import type { AgentAuth } from "../../core/agent/types"; +import { AuthenticationError } from "../../errors"; +import { createWorkspaceClient } from "../../workspace-client"; + +/** Marks the part of a run that executes on behalf of the user. */ +const oboRun = new AsyncLocalStorage(); + +export function isOboAgentRun(): boolean { + return oboRun.getStore() === true; +} + +export function runInOboAgentRun(fn: () => T): T { + return oboRun.run(true, fn); +} + +export function isOnBehalfOfUser(auth: AgentAuth | undefined): boolean { + return auth === "on-behalf-of-user"; +} + +/** + * Fail closed before an on-behalf-of-user run: a forwarded user token and user + * id are required in production and in development alike. There is no + * service-principal fallback. + */ +export function requireOboCaller(req: express.Request): void { + const token = req.header("x-forwarded-access-token")?.trim(); + if (!token) throw AuthenticationError.missingToken("user token"); + if (!req.header("x-forwarded-user")?.trim()) { + throw AuthenticationError.missingUserId(); + } +} + +type ClientLike = { + apiClient: { request(options: Record): Promise }; +}; + +/** + * Client for AppKit-built model adapters. Inside an on-behalf-of-user run it + * is the caller's client; otherwise it is the app service principal client, + * created once at build time exactly as before. + */ +export function modelClientProvider(): () => ClientLike { + const servicePrincipal = createWorkspaceClient({ + clientOptions: getClientOptions(), + }) as unknown as ClientLike; + return () => + isOboAgentRun() + ? (getWorkspaceClient() as unknown as ClientLike) + : servicePrincipal; +} diff --git a/packages/appkit/src/plugins/agents/index.ts b/packages/appkit/src/plugins/agents/index.ts index 869333afb..43496f539 100644 --- a/packages/appkit/src/plugins/agents/index.ts +++ b/packages/appkit/src/plugins/agents/index.ts @@ -11,6 +11,7 @@ export { parseFrontmatter, } from "../../core/agent/load-agents"; export { + type AgentAuth, type AgentDefinition, type AgentsPluginConfig, type AgentTool, diff --git a/packages/appkit/src/plugins/agents/tool-dispatch.ts b/packages/appkit/src/plugins/agents/tool-dispatch.ts index 492209341..73deb94ef 100644 --- a/packages/appkit/src/plugins/agents/tool-dispatch.ts +++ b/packages/appkit/src/plugins/agents/tool-dispatch.ts @@ -5,6 +5,7 @@ import type { AgentRunContext, Message, ResponseStreamEvent } from "shared"; import type { AppKitMcpClient } from "../../connectors/mcp"; import { normalizeIdentityError } from "../../context/execution-context"; +import { createRequestScope } from "../../context/request-scope"; import { consumeAdapterStream } from "../../core/agent/consume-adapter-stream"; import { normalizeToolResult } from "../../core/agent/normalize-result"; import type { @@ -16,6 +17,12 @@ import type { PluginContext } from "../../core/plugin-context"; import { createLogger } from "../../logging/logger"; import { buildAdapterExtensions } from "./adapter-extensions"; import { requiresApproval } from "./approval"; +import { + isOboAgentRun, + isOnBehalfOfUser, + requireOboCaller, + runInOboAgentRun, +} from "./auth-mode"; import type { EventChannel } from "./event-channel"; import type { AgentEventTranslator } from "./event-translator"; import { traceTool } from "./mlflow"; @@ -255,6 +262,14 @@ export async function runSubAgent( `Raise agents({ limits: { maxSubAgentDepth } }) or break the delegation cycle.`, ); } + // An on-behalf-of-user child opens the user scope; under an on-behalf-of-user + // parent the marker is already set, so every child stays the user. + if (isOnBehalfOfUser(child.auth) && !isOboAgentRun()) { + requireOboCaller(runState.req); + return createRequestScope(runState.req).run(() => + runInOboAgentRun(() => runSubAgent(deps, runState, child, args, depth)), + ); + } const input = typeof args === "object" && From c9b96af5fed55b6053d9e3c58600fefdbbfe0e82 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 6 Oct 2026 15:38:38 +0200 Subject: [PATCH 2/3] feat(appkit): keep agent catalog-skill reads on the service principal Catalog skills are discovered at boot as the service principal and form a shared pool. Run read_skill_file outside the caller scope so an on-behalf-of-user agent cannot list a skill as the app and then fail to read it as the user. Document it as the third always-SP exception. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- docs/docs/plugins/agents.md | 3 +- packages/appkit/src/plugins/agents/agents.ts | 5 +++- .../plugins/agents/tests/skill-volume.test.ts | 29 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index 09ac4a0c3..51d5ec5c4 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -337,6 +337,7 @@ In markdown, set `auth: on-behalf-of-user` in the frontmatter. A per-agent value | Standalone `runAgent` | service principal, or user tools with `caller` | requires `caller` (or an ambient user scope); model and tools as user | | MLflow tracing | service principal | service principal (exception) | | Thread store | service principal | service principal (exception) | +| Catalog skills volume | service principal | service principal (exception) | | Missing user token | plugin tools reject; the rest runs as the service principal | the request is rejected with 401 before any model or tool call | An on-behalf-of-user agent fails closed: @@ -345,7 +346,7 @@ An on-behalf-of-user agent fails closed: - A `401` from the model mid-run becomes an `IDENTITY_EXPIRED` error event and the stream ends. The run is not retried as the service principal. - A sub-agent never widens: under an on-behalf-of-user parent, a mixed sub-agent also runs as the user. -**Exceptions.** MLflow tracing and the thread store are app-owned and stay service principal in every mode; thread rows are keyed by the user id. +**Exceptions.** MLflow tracing, the thread store, and catalog skills (see [Catalog skills](#catalog-skills-unity-catalog-volume)) are app-owned and stay service principal in every mode. Thread rows are keyed by the user id. **Pre-built adapters.** The user's client is applied when AppKit builds the adapter from a model string (`model: "my-endpoint"`, `defaultModel`, or `DATABRICKS_SERVING_ENDPOINT_NAME`). An adapter you build yourself keeps the client you gave it; pass a provider so it resolves per call: diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index ec40aa690..dcfa28a74 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -1670,7 +1670,10 @@ export class AgentsPlugin extends Plugin implements ToolProvider { entry: Extract, args: unknown, ): Promise { - return dispatchSkillTool(entry, args, () => this.skillWorkspaceClient()); + // Catalog skills are a shared, app-owned pool: SP in every agent mode. + return runOutsideCallerScope(() => + dispatchSkillTool(entry, args, () => this.skillWorkspaceClient()), + ); } /** diff --git a/packages/appkit/src/plugins/agents/tests/skill-volume.test.ts b/packages/appkit/src/plugins/agents/tests/skill-volume.test.ts index 72f282806..ef9b6b922 100644 --- a/packages/appkit/src/plugins/agents/tests/skill-volume.test.ts +++ b/packages/appkit/src/plugins/agents/tests/skill-volume.test.ts @@ -145,4 +145,33 @@ describe("read_skill_file reads a volume resource", () => { expect(result).toBe("the reference"); expect(h.read).toHaveBeenCalledWith(h.client, `${VOL}/pdf/reference.md`); }); + + test("stays the service principal inside an on-behalf-of-user run", async () => { + const { getCurrentPrincipalKey, runInCallerContext } = + await import("../../../context"); + const plugin = new AgentsPlugin({ dir: false, skillsVolume: VOL }); + const catalog = resolveSkillCatalog({ + agentName: "a", + perAgentSkills: [], + globalSkills: await (plugin as any).loadVolumeSkills(), + autoInherit: true, + }); + let reader: string | undefined; + h.read.mockImplementationOnce(async () => { + reader = getCurrentPrincipalKey(); + return "the reference"; + }); + const caller = { + principal: { type: "user" as const, userId: "alice" }, + client: {} as never, + workspaceId: Promise.resolve("workspace"), + }; + await runInCallerContext(caller, () => + (plugin as any).dispatchSkillTool( + { source: "skill", builtin: "read_skill_file", catalog }, + { skill: "pdf", path: "reference.md" }, + ), + ); + expect(reader).toBe("app"); + }); }); From 007ade67dfe8637bcaa268afc2734b96c00dd50f Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 6 Oct 2026 15:49:23 +0200 Subject: [PATCH 3/3] feat(appkit): reject fixed-client adapters on on-behalf-of-user agents An on-behalf-of-user agent whose DatabricksAdapter was built with a fixed workspaceClient would silently run the model as the service principal. Throw at boot instead and point to `workspaceClient: () => getWorkspaceClient()` or a model string. Adapters built from a model string or with a client provider, and mixed agents, are unaffected. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- docs/docs/plugins/agents.md | 2 +- packages/appkit/src/agents/databricks.ts | 25 ++++++++++++-- .../src/core/tests/appkit-user-scope.test.ts | 33 +++++++++++++++++++ packages/appkit/src/plugins/agents/agents.ts | 16 +++++++-- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index 51d5ec5c4..23167a0b5 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -348,7 +348,7 @@ An on-behalf-of-user agent fails closed: **Exceptions.** MLflow tracing, the thread store, and catalog skills (see [Catalog skills](#catalog-skills-unity-catalog-volume)) are app-owned and stay service principal in every mode. Thread rows are keyed by the user id. -**Pre-built adapters.** The user's client is applied when AppKit builds the adapter from a model string (`model: "my-endpoint"`, `defaultModel`, or `DATABRICKS_SERVING_ENDPOINT_NAME`). An adapter you build yourself keeps the client you gave it; pass a provider so it resolves per call: +**Pre-built adapters.** The user's client is applied when AppKit builds the adapter from a model string (`model: "my-endpoint"`, `defaultModel`, or `DATABRICKS_SERVING_ENDPOINT_NAME`). If you build a `DatabricksAdapter` yourself with a fixed `workspaceClient`, an on-behalf-of-user agent throws at boot instead of running the model as the service principal. Pass a provider so the client resolves per call, or use a model string. Mixed agents accept fixed-client adapters as before: ```ts DatabricksAdapter.fromModelServing("my-endpoint", { diff --git a/packages/appkit/src/agents/databricks.ts b/packages/appkit/src/agents/databricks.ts index 56d939183..3e5d9317a 100644 --- a/packages/appkit/src/agents/databricks.ts +++ b/packages/appkit/src/agents/databricks.ts @@ -199,6 +199,25 @@ function clientResolver( return typeof source === "function" ? source : () => source; } +const fixedClientAdapters = new WeakSet(); + +/** Record whether an adapter's client is fixed (not a per-call provider). */ +function withClientSource( + source: WorkspaceClientSource | undefined, + adapter: T, +): T { + if (typeof source !== "function") fixedClientAdapters.add(adapter); + return adapter; +} + +/** + * @internal True when a Databricks adapter was built with a fixed workspace + * client, so it cannot follow the caller in an on-behalf-of-user agent. + */ +export function hasFixedWorkspaceClient(adapter: AgentAdapter): boolean { + return fixedClientAdapters.has(adapter); +} + interface ServingEndpointOptions { workspaceClient: WorkspaceClientSource; endpointName: string; @@ -402,7 +421,7 @@ export class DatabricksAdapter implements AgentAdapter { maxToolArgumentsChars, } = options; const resolveClient = clientResolver(workspaceClient); - return new DatabricksAdapter({ + const adapter = new DatabricksAdapter({ streamBody: (body, signal) => // Cast through the structural shape: the connector types // `workspaceClient` as the SDK's concrete `WorkspaceClient`, but we @@ -420,6 +439,7 @@ export class DatabricksAdapter implements AgentAdapter { maxStreamTextChars, maxToolArgumentsChars, }); + return withClientSource(workspaceClient, adapter); } /** @@ -531,7 +551,7 @@ export class DatabricksAdapter implements AgentAdapter { }) as unknown as WorkspaceClientLike), ); - return new DatabricksAdapter({ + const adapter = new DatabricksAdapter({ streamBody: (body, signal) => // Same structural cast as `fromServingEndpoint`: the connector types // the client as the SDK's `WorkspaceClient`, but we only need @@ -549,6 +569,7 @@ export class DatabricksAdapter implements AgentAdapter { maxStreamTextChars, maxToolArgumentsChars, }); + return withClientSource(workspaceClient, adapter); } /** diff --git a/packages/appkit/src/core/tests/appkit-user-scope.test.ts b/packages/appkit/src/core/tests/appkit-user-scope.test.ts index 702e8e64d..49a48e952 100644 --- a/packages/appkit/src/core/tests/appkit-user-scope.test.ts +++ b/packages/appkit/src/core/tests/appkit-user-scope.test.ts @@ -6,6 +6,7 @@ import type { AgentAdapter, AgentToolDefinition, ToolProvider } from "shared"; import { describe, expect, expectTypeOf, test, vi } from "vitest"; import { z } from "zod"; +import { DatabricksAdapter } from "../../agents/databricks"; import { CacheManager } from "../../cache"; import { getCallerContext, @@ -831,4 +832,36 @@ describe("agents on-behalf-of-user mode", () => { } }, ); + + test("an on-behalf-of-user agent with a fixed-client adapter throws at boot", async () => { + const fixed = { apiClient: { request: vi.fn() } }; + const boot = async ( + auth: "on-behalf-of-user" | undefined, + workspaceClient: typeof fixed | (() => typeof fixed), + ) => { + await using _app = await createTestApp({ + plugins: [ + agents({ + ...(auth && { auth }), + agents: { + probe: { + instructions: "hi", + model: DatabricksAdapter.fromServingEndpoint({ + workspaceClient, + endpointName: "my-endpoint", + }), + }, + }, + }), + ], + }); + }; + await expect(boot("on-behalf-of-user", fixed)).rejects.toThrow( + /Agent 'probe' is on-behalf-of-user, but its model adapter has a fixed workspaceClient/, + ); + await expect( + boot("on-behalf-of-user", () => fixed), + ).resolves.toBeUndefined(); + await expect(boot(undefined, fixed)).resolves.toBeUndefined(); + }); }); diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index dcfa28a74..298c0cf0b 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -498,6 +498,18 @@ export class AgentsPlugin extends Plugin implements ToolProvider { src: AgentSource, ): Promise { const adapter = await this.resolveAdapter(def, name); + const auth = def.auth ?? this.config.auth; + if ( + isOnBehalfOfUser(auth) && + (await import("../../agents/databricks")).hasFixedWorkspaceClient(adapter) + ) { + throw new Error( + `Agent '${name}' is on-behalf-of-user, but its model adapter has a ` + + "fixed workspaceClient, so the model would run as the service " + + "principal. Pass `workspaceClient: () => getWorkspaceClient()` " + + "to the adapter, or use a model string.", + ); + } const skills = await this.resolveAgentSkills(name, def, src); const toolIndex = await this.buildToolIndex(name, def, src, skills); @@ -514,9 +526,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { generationParams: def.generationParams, ephemeral: def.ephemeral, skills, - ...((def.auth ?? this.config.auth) && { - auth: def.auth ?? this.config.auth, - }), + ...(auth && { auth }), }; }