From 735bf8a1c3b44f35793da6aba537fc0c27bc8dcd Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 23 Sep 2026 18:04:20 +0200 Subject: [PATCH 1/5] feat(appkit): support explicit standalone agent caller identity Signed-off-by: MarioCadenas --- docs/docs/plugins/execution-context.md | 23 +++ packages/appkit/src/core/agent/run-agent.ts | 73 ++++++-- .../agent/tests/run-agent-identity.test.ts | 173 ++++++++++++++++++ 3 files changed, 257 insertions(+), 12 deletions(-) create mode 100644 packages/appkit/src/core/agent/tests/run-agent-identity.test.ts diff --git a/docs/docs/plugins/execution-context.md b/docs/docs/plugins/execution-context.md index 837101b60..00a833c91 100644 --- a/docs/docs/plugins/execution-context.md +++ b/docs/docs/plugins/execution-context.md @@ -64,6 +64,29 @@ now include the principal namespace even when an explicit legacy user key is supplied. Existing stored entries will have a cold miss after upgrading; users and SP continue to have separate cache entries and in-flight work. +## Standalone agents + +Standalone `runAgent` can opt into user execution without an HTTP request: + +```ts +await runAgent(agent, { + messages: "Summarize my data", + caller: { + token: userToken, + principal: { type: "user", userId }, + host: "https://your-workspace.cloud.databricks.com", + workspaceId, + }, +}); +``` + +Get credentials through a trusted authentication flow, never from model output. +The caller applies to plugin initialization, model adapters, tools, and nested +agents. Omitting it inherits an existing caller scope or defaults to SP. +Invalid explicit credentials reject even in development. No service context or +CLI profile is initialized implicitly. Standalone execution still has no approval +gate and is intended for trusted scripts and evaluations. + ## Development fallback With `NODE_ENV=development`, `asUser(req)` without a token logs a warning and diff --git a/packages/appkit/src/core/agent/run-agent.ts b/packages/appkit/src/core/agent/run-agent.ts index abb14782b..7bfede5c4 100644 --- a/packages/appkit/src/core/agent/run-agent.ts +++ b/packages/appkit/src/core/agent/run-agent.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import type { AgentAdapter, @@ -15,7 +15,11 @@ import { SUPERVISOR_EXTENSION_KEY, type SupervisorTool, } from "../../agents/supervisor-api"; +import { type Principal, runInCallerContext } from "../../context"; +import { getClientOptions } from "../../context/client-options"; +import { AuthenticationError, ConfigurationError } from "../../errors"; import { createLogger } from "../../logging/logger"; +import { createWorkspaceClient } from "../../workspace-client"; import { consumeAdapterStream } from "./consume-adapter-stream"; import { createPluginsProxy } from "./plugins-map"; import { resolveToolkitFromProvider } from "./toolkit-resolver"; @@ -41,12 +45,23 @@ export interface RunAgentInput { messages: string | Message[]; /** Abort signal for cancellation. */ signal?: AbortSignal; + /** + * Explicit user credentials for standalone execution. Host and workspace ID + * are required, so no CLI profile or service-principal identity is selected. + * Omit to inherit the ambient scope, or use SP when no caller scope is open. + * Obtain the token through a trusted authentication flow, not model input. + */ + caller?: { + readonly token: string; + readonly principal: Principal; + readonly host: string; + readonly workspaceId: string; + }; /** * Optional plugin list. Required when `def.tools` is the function form * `(plugins) => Record` and the function dereferences * any plugins. `runAgent` constructs a fresh instance per plugin and - * dispatches tool calls against it as the service principal (no OBO — - * there is no HTTP request in standalone mode). + * dispatches tool calls with the run's ambient principal. */ plugins?: PluginData[]; } @@ -63,12 +78,12 @@ export interface RunAgentResult { * inline tools, and drives the adapter's `run()` loop to completion. * * Limitations vs. running through the agents() plugin: - * - **No OBO and no approval gate** — there is no HTTP request, so plugin - * tools run as the service principal. The agents-plugin approval gate + * - **No approval gate**: tools inherit the run's principal, SP by default. + * Explicit caller credentials enable user execution. The agents-plugin gate * that prompts for human confirmation on `effect: "write" | "update" | * "destructive"` tools is also absent. LLM-controlled tool arguments - * flow straight through to the SP. Treat standalone runAgent as a - * trusted-prompt environment (CI, batch eval, internal scripts) — not + * flow straight through to the tools. Treat standalone runAgent as a + * trusted-prompt environment (CI, batch eval, internal scripts), not * as an exposed user-facing surface. * - **Hosted tools (MCP) are not supported** — they require a live MCP * client that only exists inside the agents plugin's lifecycle. @@ -89,6 +104,43 @@ export interface RunAgentResult { export async function runAgent( def: AgentDefinition, input: RunAgentInput, +): Promise { + if (input.caller) { + const { principal, host, workspaceId } = input.caller; + const token = input.caller.token.trim(); + if (!token) throw AuthenticationError.missingToken("user token"); + if (principal.type !== "user" || !principal.userId.trim()) { + throw AuthenticationError.missingUserId(); + } + if (!host.trim() || !workspaceId.trim()) { + throw new ConfigurationError( + "runAgent caller requires host and workspaceId", + ); + } + const caller = { + principal: { ...principal, userId: principal.userId.trim() }, + client: createWorkspaceClient({ + token, + host, + authType: "pat", + clientOptions: getClientOptions(), + }), + workspaceId: Promise.resolve(workspaceId), + tokenFingerprint: createHash("sha256") + .update(token) + .digest("hex") + .slice(0, 16), + }; + // Credentials are not carried into adapter inputs, tools, or sub-agent options. + const { caller: _credentials, ...scopedInput } = input; + return runInCallerContext(caller, () => runStandalone(def, scopedInput)); + } + return runStandalone(def, input); +} + +async function runStandalone( + def: AgentDefinition, + input: RunAgentInput, ): Promise { // Single shared cache for the whole call graph: parent + every nested // sub-agent dispatch share constructed plugin instances. Without this, @@ -585,11 +637,8 @@ function providerCacheLookup( /** * Lightweight `ToolProvider` shape check used by standalone `runAgent`. * - * Distinct from `core/plugin-context.isToolProvider` which also requires - * `asUser` (request-scoped, only meaningful when running inside `createApp` - * with a live HTTP context). Standalone plugins are constructed without a - * `WorkspaceClient` and have no request to scope to, so checking only the - * two `ToolProvider` methods is the right narrowing here. + * Standalone plugins need only these methods. Execution identity is inherited + * from the run's caller scope, without requiring a per-plugin asUser helper. */ function isStandaloneToolProvider(value: unknown): value is ToolProvider { if (typeof value !== "object" || value === null) return false; 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 new file mode 100644 index 000000000..1562fb6c5 --- /dev/null +++ b/packages/appkit/src/core/agent/tests/run-agent-identity.test.ts @@ -0,0 +1,173 @@ +import { createHash } from "node:crypto"; + +import type { + AgentAdapter, + AgentToolDefinition, + PluginConstructor, +} from "shared"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { z } from "zod"; + +import { + getCallerContext, + getCurrentPrincipalKey, + runInCallerContext, + ServiceContext, +} from "../../../context"; +import { createMockWorkspaceClient } from "../../../testing"; +import * as workspace from "../../../workspace-client"; +import { createAgent } from "../create-agent"; +import { runAgent, type RunAgentInput } from "../run-agent"; +import { tool } from "../tools/tool"; + +afterEach(() => vi.restoreAllMocks()); + +const credentials = (userId: string): NonNullable => ({ + token: `token-${userId}`, + principal: { type: "user", userId }, + host: "https://workspace.example.com", + workspaceId: "workspace", +}); + +const probe: AgentAdapter = { + async *run(_input, ctx) { + await Promise.resolve(); + const values = [getCurrentPrincipalKey()]; + for (const name of _input.tools?.map((t) => t.name) ?? []) { + values.push(String(await ctx.executeTool(name, {}))); + } + yield { type: "message_delta", content: values.join("/") }; + }, +}; + +describe("standalone caller identity", () => { + test("defaults to SP and inherits a caller without widening", async () => { + const def = createAgent({ instructions: "identity", model: probe }); + expect((await runAgent(def, { messages: "hi" })).text).toBe("app"); + const caller = { + principal: { type: "user" as const, userId: "alice" }, + client: createMockWorkspaceClient(), + workspaceId: Promise.resolve("workspace"), + }; + expect( + ( + await runInCallerContext(caller, () => + runAgent(def, { messages: "hi" }), + ) + ).text, + ).toBe("user:alice"); + expect(getCurrentPrincipalKey()).toBe("app"); + }); + + test("scopes setup, adapters, inline tools, plugin tools, and sub-agents without service initialization", async () => { + const client = createMockWorkspaceClient(); + const factory = vi + .spyOn(workspace, "createWorkspaceClient") + .mockReturnValue(client); + expect(ServiceContext.isInitialized()).toBe(false); + const seen: string[] = []; + class Provider { + name = "identity"; + async setup() { + seen.push(getCurrentPrincipalKey()); + } + getAgentTools(): AgentToolDefinition[] { + return [ + { + name: "read", + description: "identity", + parameters: { type: "object" }, + }, + ]; + } + async executeAgentTool() { + return getCurrentPrincipalKey(); + } + } + const inline = tool({ + name: "inline", + description: "identity", + schema: z.object({}), + execute: async () => { + const caller = getCallerContext(); + expect(caller?.client).toBe(client); + expect(caller?.tokenFingerprint).toBe( + createHash("sha256").update("token-alice").digest("hex").slice(0, 16), + ); + return getCurrentPrincipalKey(); + }, + }); + const def = createAgent({ + instructions: "identity", + model: probe, + tools: (plugins) => ({ ...plugins.identity.toolkit(), inline }), + agents: { child: createAgent({ instructions: "child", model: probe }) }, + }); + const result = await runAgent(def, { + messages: "hi", + caller: credentials("alice"), + plugins: [ + { + name: "identity", + config: {}, + plugin: Provider as unknown as PluginConstructor, + }, + ], + }); + expect(result.text).toBe("user:alice/user:alice/user:alice/user:alice"); + expect(seen).toEqual(["user:alice"]); + expect(factory).toHaveBeenCalledOnce(); + expect(factory).toHaveBeenCalledWith( + expect.objectContaining({ token: "token-alice", authType: "pat" }), + ); + expect(JSON.stringify(result)).not.toContain("token-alice"); + expect(getCurrentPrincipalKey()).toBe("app"); + expect(ServiceContext.isInitialized()).toBe(false); + }); + + test("isolates concurrent injected users and restores context on failure", async () => { + vi.spyOn(workspace, "createWorkspaceClient").mockReturnValue( + createMockWorkspaceClient(), + ); + const def = createAgent({ instructions: "identity", model: probe }); + const results = await Promise.all( + ["alice", "bob"].map((user) => + runAgent(def, { messages: "hi", caller: credentials(user) }), + ), + ); + expect(results.map((r) => r.text)).toEqual(["user:alice", "user:bob"]); + const failure = createAgent({ + instructions: "fail", + model: { + async *run() { + yield { type: "message_delta", content: "" }; + throw new Error("failed"); + }, + }, + }); + await expect( + runAgent(failure, { messages: "hi", caller: credentials("alice") }), + ).rejects.toThrow("failed"); + expect(getCallerContext()).toBeUndefined(); + }); + + test("rejects incomplete explicit credentials even in development", async () => { + const factory = vi.spyOn(workspace, "createWorkspaceClient"); + const def = createAgent({ instructions: "identity", model: probe }); + vi.stubEnv("NODE_ENV", "development"); + try { + for (const caller of [ + { ...credentials("alice"), token: " " }, + credentials(" "), + { ...credentials("alice"), host: "" }, + { ...credentials("alice"), workspaceId: "" }, + ]) + await expect( + runAgent(def, { messages: "hi", caller }), + ).rejects.toThrow(); + expect(factory).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + } + }); +}); From da86c5fea3ba44c1118038cf12fc2f47b154c935 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 25 Sep 2026 10:30:28 +0200 Subject: [PATCH 2/5] docs(appkit): sync standalone caller API reference Signed-off-by: MarioCadenas --- docs/docs/api/appkit/Function.runAgent.md | 8 ++-- .../api/appkit/Interface.RunAgentInput.md | 45 ++++++++++++++++++- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/docs/docs/api/appkit/Function.runAgent.md b/docs/docs/api/appkit/Function.runAgent.md index 3a13a40bb..a812c2b16 100644 --- a/docs/docs/api/appkit/Function.runAgent.md +++ b/docs/docs/api/appkit/Function.runAgent.md @@ -8,12 +8,12 @@ Standalone agent execution without `createApp`. Resolves the adapter, binds inline tools, and drives the adapter's `run()` loop to completion. Limitations vs. running through the agents() plugin: -- **No OBO and no approval gate** — there is no HTTP request, so plugin - tools run as the service principal. The agents-plugin approval gate +- **No approval gate**: tools inherit the run's principal, SP by default. + Explicit caller credentials enable user execution. The agents-plugin gate that prompts for human confirmation on `effect: "write" | "update" | "destructive"` tools is also absent. LLM-controlled tool arguments - flow straight through to the SP. Treat standalone runAgent as a - trusted-prompt environment (CI, batch eval, internal scripts) — not + flow straight through to the tools. Treat standalone runAgent as a + trusted-prompt environment (CI, batch eval, internal scripts), not as an exposed user-facing surface. - **Hosted tools (MCP) are not supported** — they require a live MCP client that only exists inside the agents plugin's lifecycle. diff --git a/docs/docs/api/appkit/Interface.RunAgentInput.md b/docs/docs/api/appkit/Interface.RunAgentInput.md index b17b4a301..93ec3ea50 100644 --- a/docs/docs/api/appkit/Interface.RunAgentInput.md +++ b/docs/docs/api/appkit/Interface.RunAgentInput.md @@ -2,6 +2,48 @@ ## Properties +### caller? + +```ts +optional caller: { + host: string; + principal: Principal; + token: string; + workspaceId: string; +}; +``` + +Explicit user credentials for standalone execution. Host and workspace ID +are required, so no CLI profile or service-principal identity is selected. +Omit to inherit the ambient scope, or use SP when no caller scope is open. +Obtain the token through a trusted authentication flow, not model input. + +#### host + +```ts +readonly host: string; +``` + +#### principal + +```ts +readonly principal: Principal; +``` + +#### token + +```ts +readonly token: string; +``` + +#### workspaceId + +```ts +readonly workspaceId: string; +``` + +*** + ### messages ```ts @@ -21,8 +63,7 @@ optional plugins: PluginData[]; Optional plugin list. Required when `def.tools` is the function form `(plugins) => Record` and the function dereferences any plugins. `runAgent` constructs a fresh instance per plugin and -dispatches tool calls against it as the service principal (no OBO — -there is no HTTP request in standalone mode). +dispatches tool calls with the run's ambient principal. *** From 5366d34d97b20810648a9212a45bc3fe732d1f26 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 1 Oct 2026 10:52:13 +0200 Subject: [PATCH 3/5] docs(appkit): note the explicit caller in the runAgent identity row The surface table from #625 says standalone runAgent runs as the service principal. That is now the default only: passing `caller` runs it as the user. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- docs/docs/plugins/execution-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/plugins/execution-context.md b/docs/docs/plugins/execution-context.md index 915f33ce6..fe1d108a3 100644 --- a/docs/docs/plugins/execution-context.md +++ b/docs/docs/plugins/execution-context.md @@ -62,7 +62,7 @@ The default is the **service principal**. Work runs on behalf of the user only i | Agents HTTP routes: the model (LLM) call | app service principal | the routes open user scope, but the model adapter builds its own service-principal client, so the model call does not use the user token | | Agents HTTP routes: plugin-toolkit tool calls (`plugin:`) | signed-in user (OBO) | `executeTool` inherits the route's user scope | | Agents HTTP routes: hand-rolled `tool({ execute })` | signed-in user (OBO), for AppKit calls inside `execute` | `execute` runs inside the route's user scope, so plugin handles and `getWorkspaceClient()` resolve to the user | -| Standalone `runAgent` (no HTTP request) | app service principal | there is no request, so no user scope | +| Standalone `runAgent` (no HTTP request) | app service principal by default | there is no request, so no user scope unless you pass `caller` (see [Standalone agents](#standalone-agents)) | So an agent's **model inference runs as the service principal**, while the tools it calls over the built-in HTTP routes run on behalf of the user. See the [agents plugin](./agents.md) for the tool-level detail. From 9f8d5ab55de7d6ab77854e0d490a711fc47bd69e Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 1 Oct 2026 14:08:24 +0200 Subject: [PATCH 4/5] refactor(appkit): use CallerPrincipal in standalone runAgent Switch the runAgent caller option off the deprecated `Principal` alias (removed in the base PR) to `CallerPrincipal`. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- packages/appkit/src/core/agent/run-agent.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/appkit/src/core/agent/run-agent.ts b/packages/appkit/src/core/agent/run-agent.ts index 7bfede5c4..e0c4245ff 100644 --- a/packages/appkit/src/core/agent/run-agent.ts +++ b/packages/appkit/src/core/agent/run-agent.ts @@ -15,7 +15,7 @@ import { SUPERVISOR_EXTENSION_KEY, type SupervisorTool, } from "../../agents/supervisor-api"; -import { type Principal, runInCallerContext } from "../../context"; +import { type CallerPrincipal, runInCallerContext } from "../../context"; import { getClientOptions } from "../../context/client-options"; import { AuthenticationError, ConfigurationError } from "../../errors"; import { createLogger } from "../../logging/logger"; @@ -53,7 +53,7 @@ export interface RunAgentInput { */ caller?: { readonly token: string; - readonly principal: Principal; + readonly principal: CallerPrincipal; readonly host: string; readonly workspaceId: string; }; From b9594cf25dd39361e78f6be5ad43ec66f995ca01 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 1 Oct 2026 14:40:56 +0200 Subject: [PATCH 5/5] docs(appkit): sync API reference after CallerPrincipal rename Co-authored-by: Isaac Signed-off-by: MarioCadenas --- docs/docs/api/appkit/Interface.RunAgentInput.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/api/appkit/Interface.RunAgentInput.md b/docs/docs/api/appkit/Interface.RunAgentInput.md index 93ec3ea50..e7aceeb97 100644 --- a/docs/docs/api/appkit/Interface.RunAgentInput.md +++ b/docs/docs/api/appkit/Interface.RunAgentInput.md @@ -7,7 +7,7 @@ ```ts optional caller: { host: string; - principal: Principal; + principal: CallerPrincipal; token: string; workspaceId: string; }; @@ -27,7 +27,7 @@ readonly host: string; #### principal ```ts -readonly principal: Principal; +readonly principal: CallerPrincipal; ``` #### token