From 83b3fbddab6acfe6d8b7d6345c86a3ae922909c0 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 3 Sep 2026 23:22:04 +0000 Subject: [PATCH 1/5] feat(providers): add environment contribution hook --- apps/host-daemon/src/command-dispatch.test.ts | 11 + .../src/command-handlers/thread.ts | 5 + .../test/command/command-router.test.ts | 2 + .../test/command/thread-dispatch.test.ts | 33 +++ .../test/command/thread-stop-races.test.ts | 2 + apps/server/src/internal/events.ts | 1 + .../plugins/plugin-agent-contributions.ts | 15 +- .../server/src/services/plugins/plugin-api.ts | 37 +++- .../plugins/plugin-service-internal.ts | 15 +- .../src/services/plugins/plugin-service.ts | 66 ++++++ .../references/backend-api-index.md | 2 + .../references/providers.md | 30 +++ .../src/services/threads/thread-commands.ts | 2 + .../services/threads/thread-runtime-config.ts | 16 +- .../plugin-agent-contributions.test.ts | 152 +++++++++++++ docs/api_to_audit.md | 22 ++ .../src/runtime.lifecycle.test.ts | 41 +++- packages/agent-runtime/src/runtime.ts | 204 ++++++++++++++---- .../src/thread-shell-environment.ts | 53 ++++- packages/agent-runtime/src/types.ts | 13 ++ packages/domain/src/plugin-sdk-version.ts | 2 +- packages/domain/src/provider-event.ts | 21 ++ packages/domain/src/thread-event-scope.ts | 5 + packages/host-daemon-contract/src/commands.ts | 21 +- packages/host-daemon-contract/src/protocol.ts | 2 +- .../test/contract.test.ts | 27 ++- packages/plugin-api-map/src/surfaces.ts | 3 + packages/plugin-sdk/package.json | 2 +- .../src/__tests__/public-types.test.ts | 2 + packages/plugin-sdk/src/backend-contract.ts | 21 ++ .../plugin-sdk/src/internal/host-policy.ts | 50 ++++- .../__tests__/fake-plugin-host.test.ts | 69 ++++++ .../src/testing/fake-plugin-host.ts | 57 +++++ .../src/session-params.test.ts | 7 +- .../thread-view/src/build-thread-timeline.ts | 2 + packages/thread-view/src/event-decode.ts | 2 + .../src/event-projection-message.ts | 1 + .../src/parse-operation-message.ts | 17 ++ .../test/parse-operation-message.test.ts | 30 +++ .../src/bridge/__tests__/bridge.test.ts | 76 +++++++ .../provider-claude-code/src/bridge/bridge.ts | 35 +++ .../src/session-params.ts | 13 +- .../provider-codex/src/session-params.test.ts | 5 +- 43 files changed, 1122 insertions(+), 70 deletions(-) diff --git a/apps/host-daemon/src/command-dispatch.test.ts b/apps/host-daemon/src/command-dispatch.test.ts index 92a7c2ca52..dc2b2d713f 100644 --- a/apps/host-daemon/src/command-dispatch.test.ts +++ b/apps/host-daemon/src/command-dispatch.test.ts @@ -247,6 +247,7 @@ function createTurnSubmitCommand( providerThreadId: "provider-thread-1", instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -347,6 +348,7 @@ function createInstallationGatedThreadStart( }, instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }; @@ -784,6 +786,7 @@ describe("dispatchCommand", () => { providerThreadId: "provider-thread-1", instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -862,6 +865,7 @@ describe("dispatchCommand", () => { providerThreadId: "provider-thread-1", instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1237,6 +1241,7 @@ describe("dispatchCommand", () => { providerThreadId: "provider-thread-1", instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1330,6 +1335,7 @@ describe("dispatchCommand", () => { }, instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }; @@ -1409,6 +1415,7 @@ describe("dispatchCommand", () => { }, instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }; @@ -1476,6 +1483,7 @@ describe("dispatchCommand", () => { }, instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }; @@ -1735,6 +1743,7 @@ describe("dispatchCommand", () => { options: start.options, instructions: start.instructions, dynamicTools: start.dynamicTools, + contributedEnv: [], injectedSkillSources: start.injectedSkillSources, instructionMode: start.instructionMode, }; @@ -2301,6 +2310,7 @@ describe("dispatchCommand", () => { }, instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [fixture.source], instructionMode: "append", }; @@ -2364,6 +2374,7 @@ describe("dispatchCommand", () => { providerThreadId: "provider-thread-1", instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [fixture.source], instructionMode: "append", }, diff --git a/apps/host-daemon/src/command-handlers/thread.ts b/apps/host-daemon/src/command-handlers/thread.ts index 3942a9853b..f0e74ecc66 100644 --- a/apps/host-daemon/src/command-handlers/thread.ts +++ b/apps/host-daemon/src/command-handlers/thread.ts @@ -196,6 +196,7 @@ async function resumeThreadRuntimeIfMissing( projectId: resumeContext.projectId, providerThreadId: resumeContext.providerThreadId, providerId: resumeContext.providerId, + contributedEnv: resumeContext.contributedEnv, options: command.options, instructions: resumeContext.instructions, dynamicTools: resumeContext.dynamicTools, @@ -241,6 +242,7 @@ export async function startThread( threadId: command.threadId, projectId: command.projectId, providerId: command.providerId, + contributedEnv: command.contributedEnv, clientRequestId: command.requestId, input: staged.input, ...(staged.inputGroups !== undefined @@ -284,6 +286,7 @@ export async function prepareThreadRewind( leaseId: command.leaseId, projectId: command.projectId, providerId: command.providerId, + contributedEnv: command.contributedEnv, sourceProviderThreadId: command.sourceProviderThreadId, retainThroughProviderCheckpoint: command.retainThroughProviderCheckpoint, options: command.options, @@ -349,6 +352,7 @@ async function runSubmittedTurn( : {}), clientRequestId: command.requestId, options: command.options, + contributedEnv: command.resumeContext.contributedEnv, instructions: command.resumeContext.instructions, }); return { appliedAs: "new-turn" }; @@ -371,6 +375,7 @@ async function steerSubmittedTurn( : {}), clientRequestId: command.requestId, options: command.options, + contributedEnv: command.resumeContext.contributedEnv, instructions: command.resumeContext.instructions, }); diff --git a/apps/host-daemon/test/command/command-router.test.ts b/apps/host-daemon/test/command/command-router.test.ts index 3616e0c27b..4e29f8974e 100644 --- a/apps/host-daemon/test/command/command-router.test.ts +++ b/apps/host-daemon/test/command/command-router.test.ts @@ -128,6 +128,7 @@ function createTurnSubmitCommand( providerThreadId: args.providerThreadId ?? "provider-thread-router", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -161,6 +162,7 @@ function createThreadStartCommand(): ThreadStartCommand { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }; diff --git a/apps/host-daemon/test/command/thread-dispatch.test.ts b/apps/host-daemon/test/command/thread-dispatch.test.ts index 6134be65f7..27906ccd7f 100644 --- a/apps/host-daemon/test/command/thread-dispatch.test.ts +++ b/apps/host-daemon/test/command/thread-dispatch.test.ts @@ -94,6 +94,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }; @@ -159,6 +160,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-thread-stale-turn", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -221,6 +223,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -332,6 +335,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-submit-attachments", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -421,6 +425,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -599,6 +604,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -698,6 +704,7 @@ describe("thread command dispatch", () => { providerThreadId, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -750,6 +757,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -814,6 +822,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -876,6 +885,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -954,6 +964,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1026,6 +1037,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1088,6 +1100,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1152,6 +1165,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-runtime-failed-turn-attachments", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1204,6 +1218,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1308,6 +1323,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1407,6 +1423,7 @@ describe("thread command dispatch", () => { }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1461,6 +1478,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-thread-resume-after-archive", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1552,6 +1570,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1588,6 +1607,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1647,6 +1667,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1686,6 +1707,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1741,6 +1763,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1808,6 +1831,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1884,6 +1908,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1943,6 +1968,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1997,6 +2023,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -2047,6 +2074,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -2146,6 +2174,7 @@ describe("thread command dispatch", () => { providerThreadId: "provider-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -2297,6 +2326,7 @@ describe("thread command dispatch", () => { }, }, ], + contributedEnv: [], injectedSkillSources: [], instructionMode: "replace", }, @@ -2343,6 +2373,7 @@ describe("thread command dispatch", () => { }, instructions: "test", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", threadStoragePath: storagePath, @@ -2383,6 +2414,7 @@ describe("thread command dispatch", () => { }, instructions: "test", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -2423,6 +2455,7 @@ describe("thread command dispatch", () => { }, instructions: "test", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", threadStoragePath: "/tmp/evil-escape", diff --git a/apps/host-daemon/test/command/thread-stop-races.test.ts b/apps/host-daemon/test/command/thread-stop-races.test.ts index 82af8f7621..52d976ac05 100644 --- a/apps/host-daemon/test/command/thread-stop-races.test.ts +++ b/apps/host-daemon/test/command/thread-stop-races.test.ts @@ -238,6 +238,7 @@ function threadStartCommand( }, instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }; @@ -275,6 +276,7 @@ function turnSubmitCommand( providerThreadId: "prov-1", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, diff --git a/apps/server/src/internal/events.ts b/apps/server/src/internal/events.ts index 3c98206ccf..c6e19b4275 100644 --- a/apps/server/src/internal/events.ts +++ b/apps/server/src/internal/events.ts @@ -234,6 +234,7 @@ function resolveProviderIdentifiers(event: HostDaemonEventEnvelope["event"]): { case "provider/warning": case "provider/modelFallback": case "provider/rateLimits/updated": + case "provider.env-resolved": return { providerThreadId: event.providerThreadId }; case "thread/compacted": return { providerThreadId: event.providerThreadId }; diff --git a/apps/server/src/services/plugins/plugin-agent-contributions.ts b/apps/server/src/services/plugins/plugin-agent-contributions.ts index b7cc4f79bd..f2551cdc93 100644 --- a/apps/server/src/services/plugins/plugin-agent-contributions.ts +++ b/apps/server/src/services/plugins/plugin-agent-contributions.ts @@ -1,4 +1,6 @@ import type { ToolCallResponse } from "@bb/domain"; +import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; +import type { PluginProviderEnvContext } from "@get-bb/plugin-sdk"; import type { PluginAgentConfigurationContext, PluginAgentToolContext, @@ -20,7 +22,9 @@ type PluginAgentContributions = Pick< | "invokeAgentTool" | "resolveMention" > & - Partial>; + Partial< + Pick + >; let contributions: PluginAgentContributions | undefined; @@ -60,6 +64,15 @@ export function listPluginInstructionContributions(): Array<{ return contributions?.listInstructionContributions() ?? []; } +export async function resolvePluginProviderEnv(args: { + providerId: string; + context: PluginProviderEnvContext; +}): Promise { + const active = contributions; + if (!active?.resolveProviderEnv) return []; + return (await active.resolveProviderEnv(args)).entries; +} + export function findPluginAgentTool( name: string, ): { pluginId: string; record: PluginAgentToolRecord } | undefined { diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts index 4d878463d6..bd1517abba 100644 --- a/apps/server/src/services/plugins/plugin-api.ts +++ b/apps/server/src/services/plugins/plugin-api.ts @@ -44,6 +44,8 @@ import type { PluginAiServiceDeclaration, PluginAiServices, PluginProviderDeclaration, + PluginProviderEnvContext, + PluginProviderEnvEntry, PluginProviders, PluginRealtime, PluginRpc, @@ -241,6 +243,7 @@ export interface PluginApiHandle { cli: { registration: PluginCliRegistrationRecord | null }; agentTools: PluginAgentToolRecord[]; listProviderDeclarations(): NormalizedPluginProviderDeclaration[]; + providerEnvResolvers: ReadonlyMap; agentConfigurationProvider: PluginAgentConfigurationProvider | null; instructionProvider: PluginInstructionProvider | null; mentionProviders: PluginMentionProviderRecord[]; @@ -270,6 +273,12 @@ type PluginAgentConfigurationProvider = ( context: PluginAgentConfigurationContext, ) => PluginAgentConfiguration; +export type PluginProviderEnvResolver = ( + context: PluginProviderEnvContext, +) => + | readonly PluginProviderEnvEntry[] + | Promise; + function wrapSdkForPlugin(sdk: BbSdk, pluginId: string): BbSdk { return { ...sdk, @@ -656,9 +665,10 @@ export function createPluginApi(options: { ); } const rows = database - .prepare<[], { id: number; statement_hash: string | null }>( - "SELECT id, statement_hash FROM _bb_migrations ORDER BY id", - ) + .prepare< + [], + { id: number; statement_hash: string | null } + >("SELECT id, statement_hash FROM _bb_migrations ORDER BY id") .all(); const applied = new Map(); for (const row of rows) applied.set(row.id, row.statement_hash); @@ -950,6 +960,7 @@ export function createPluginApi(options: { isActivated: () => activated, disposeHooks, }); + const providerEnvResolvers = new Map(); let agentConfigurationProvider: PluginAgentConfigurationProvider | null = null; let instructionProvider: PluginInstructionProvider | null = null; @@ -1382,6 +1393,25 @@ export function createPluginApi(options: { const providers: PluginProviders = { register: providerRegistrations.register, + experimental_contributeEnv(providerId, resolve) { + assertLive(); + if (typeof providerId !== "string" || providerId.trim().length === 0) { + throw new Error( + "provider environment contribution requires a provider id", + ); + } + if (providerEnvResolvers.has(providerId)) { + throw new Error( + `provider environment contribution for "${providerId}" is already registered`, + ); + } + if (typeof resolve !== "function") { + throw new Error( + "provider environment contribution requires a resolver function", + ); + } + providerEnvResolvers.set(providerId, resolve); + }, }; const aiServiceRegistrations = createStagedRegistrations({ @@ -1451,6 +1481,7 @@ export function createPluginApi(options: { cli: cliRecord, agentTools, listProviderDeclarations: providerRegistrations.values, + providerEnvResolvers, get agentConfigurationProvider() { return agentConfigurationProvider; }, diff --git a/apps/server/src/services/plugins/plugin-service-internal.ts b/apps/server/src/services/plugins/plugin-service-internal.ts index 4f224be744..d8648b9d01 100644 --- a/apps/server/src/services/plugins/plugin-service-internal.ts +++ b/apps/server/src/services/plugins/plugin-service-internal.ts @@ -6,7 +6,10 @@ import type { Thread, ThreadQueuedMessage, } from "@bb/domain"; -import type { HostDaemonConnectTunnelIdentity } from "@bb/host-daemon-contract"; +import type { + HostDaemonConnectTunnelIdentity, + HostDaemonContributedEnvEntry, +} from "@bb/host-daemon-contract"; import { pluginUpdateCheckEntrySchema, type InstalledPlugin, @@ -116,6 +119,7 @@ export interface PluginServiceDeps { serviceRestartBaseMs?: number; mentionSearchTimeoutMs?: number; mentionResolveTimeoutMs?: number; + providerEnvResolveTimeoutMs?: number; stabilizationWindowMs?: number; artifactRetentionMs?: number; now?: () => number; @@ -167,6 +171,10 @@ export interface PluginResolvedAgentConfiguration { dynamicInstructions: Array<{ pluginId: string; text: string }>; } +export interface PluginResolvedProviderEnv { + entries: HostDaemonContributedEnvEntry[]; +} + export interface PluginMentionProviderContribution { pluginId: string; id: string; @@ -199,10 +207,7 @@ export interface PluginThreadEventEmitter { emitThreadFailed(thread: Thread): void; emitThreadArchived(thread: Thread): void; emitThreadDeleted(thread: Thread): void; - emitInteractionPending( - thread: Thread, - interaction: PendingInteraction, - ): void; + emitInteractionPending(thread: Thread, interaction: PendingInteraction): void; /** * Queue lifecycle. The row is already in its new state when these fire; the * DTO is built once and shared by every listener, exactly like the thread diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index bc7b5d2fd9..0680c02f6f 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -20,6 +20,7 @@ import { } from "@bb/domain"; import { type PluginCliExecutionResult, + type PluginProviderEnvContext, type PluginRpcError, type PluginRpcValidationIssue, type StandardSchemaV1, @@ -34,6 +35,7 @@ import { PLUGIN_AGENT_TOOL_PARAMETERS_MAX_BYTES, RESERVED_AGENT_TOOL_NAMES, adoptHttpRouteResponse, + validatePluginProviderEnvEntries, } from "@get-bb/plugin-sdk/internal/host-policy"; import { buildPluginApp, @@ -145,6 +147,7 @@ import type { PluginUpdateCheckEntry, PluginWireLookup, PluginResolvedAgentConfiguration, + PluginResolvedProviderEnv, } from "./plugin-service-internal.js"; export type { PluginAgentToolContribution, @@ -306,6 +309,10 @@ export interface PluginService { context: PluginAgentConfigurationContext; skillIdsByPlugin: ReadonlyMap; }): Promise; + resolveProviderEnv(args: { + providerId: string; + context: PluginProviderEnvContext; + }): Promise; listInstructionContributions(): PluginInstructionContribution[]; findAgentTool( name: string, @@ -333,6 +340,7 @@ export interface PluginService { const DEFAULT_MENTION_SEARCH_TIMEOUT_MS = 2_000; const DEFAULT_MENTION_RESOLVE_TIMEOUT_MS = 10_000; +const DEFAULT_PROVIDER_ENV_RESOLVE_TIMEOUT_MS = 5_000; /** * Per-handler decision box. A hook handler is on the dispatch hot path and * holds a server-wide lock while it runs, so it must decide in milliseconds; @@ -828,6 +836,8 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { deps.mentionSearchTimeoutMs ?? DEFAULT_MENTION_SEARCH_TIMEOUT_MS; const mentionResolveTimeoutMs = deps.mentionResolveTimeoutMs ?? DEFAULT_MENTION_RESOLVE_TIMEOUT_MS; + const providerEnvResolveTimeoutMs = + deps.providerEnvResolveTimeoutMs ?? DEFAULT_PROVIDER_ENV_RESOLVE_TIMEOUT_MS; const pluginHookTimeoutMs = deps.pluginHookTimeoutMs ?? DEFAULT_PLUGIN_HOOK_TIMEOUT_MS; const stabilizationWindowMs = @@ -2220,6 +2230,62 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { return { tools, selectedSkillIdsByPlugin, dynamicInstructions }; }, + async resolveProviderEnv({ providerId, context }) { + const entries: PluginResolvedProviderEnv["entries"] = []; + const ownerByName = new Map(); + for (const [pluginId, plugin] of loaded) { + const resolve = plugin.handle.providerEnvResolvers.get(providerId); + if (resolve === undefined) continue; + const outcome = await invokeWrapped( + pluginId, + `provider environment for ${providerId}`, + async () => { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + Promise.resolve(resolve(context)).then((value) => + validatePluginProviderEnvEntries(value), + ), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `timed out after ${providerEnvResolveTimeoutMs}ms`, + ), + ), + providerEnvResolveTimeoutMs, + ); + timer.unref?.(); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + }, + ); + if (!outcome.ok) continue; + for (const entry of outcome.value) { + const earlierPluginId = ownerByName.get(entry.name); + if (earlierPluginId !== undefined) { + logger.error( + { + providerId, + name: entry.name, + winnerPluginId: earlierPluginId, + loserPluginId: pluginId, + }, + "Plugin provider environment conflict; later contribution dropped", + ); + continue; + } + ownerByName.set(entry.name, pluginId); + entries.push({ ...entry, source: { plugin: pluginId } }); + } + } + return { entries }; + }, + listInstructionContributions() { const out: PluginInstructionContribution[] = []; for (const [id, plugin] of [...loaded.entries()].sort(([a], [b]) => diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md index 1137186e1c..234e739c19 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md @@ -149,6 +149,8 @@ Read the installed declarations for exact current signatures. - `PluginProviderCapabilities` - `PluginProviderComposerAction` - `PluginProviderDeclaration` +- `PluginProviderEnvContext` +- `PluginProviderEnvEntry` - `PluginProviderExtensionKindDeclaration` - `PluginProviderFallbackModel` - `PluginProviderIconRegistration` diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/providers.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/providers.md index 06e80c69d1..22fe21cf71 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/providers.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/providers.md @@ -144,6 +144,36 @@ health; bridge failures hide only that provider. Its plain JSON result has the same 64 KiB limit. The runtime merges the result over `experimental_bridgeOptions`; a derived key replaces its static key. +### `bb.providers.experimental_contributeEnv` — per-command provider environment + +A plugin can contribute environment variables to any provider, including one +registered by another plugin. Register one resolver per provider id: + +```ts +bb.providers.experimental_contributeEnv("claude-code", async (context) => [ + { + name: "ANTHROPIC_BASE_URL", + value: { serverPath: `/plugins/my-proxy/${context.hostId}` }, + reason: "Route Claude through the plugin's authenticated proxy", + secret: true, + }, +]); +``` + +The server calls the resolver for every matching start, resume, fork, and turn +command with `threadId`, `projectId`, and `hostId`. Return at most 32 entries. +Names must match `[A-Z_][A-Z0-9_]*`; `reason` and `secret` are required. A +literal `value` is forwarded as-is. `{ serverPath: "/..." }` is expanded by +the selected host against its authenticated `BB_SERVER_URL`, which is the +right form for a server route that must work from enrolled machines. + +Contributions override the host shell environment. If multiple plugins return +the same name, the earlier registration wins and BB logs the conflict. A +resolver that throws, times out after five seconds, or returns invalid entries +contributes nothing for that command without blocking other plugins. Mark +credentials and sensitive URLs with `secret: true`; BB passes the real value +to the provider but masks it in `provider.env-resolved` timeline events. + Use `extensionKinds` to declare provider-specific item or state payloads. Each kind needs an item schema, a state schema, or both. The server validates each payload at ingest. It persists an unhandled-provider event when validation diff --git a/apps/server/src/services/threads/thread-commands.ts b/apps/server/src/services/threads/thread-commands.ts index 1c815daf32..0e07a73c20 100644 --- a/apps/server/src/services/threads/thread-commands.ts +++ b/apps/server/src/services/threads/thread-commands.ts @@ -294,6 +294,7 @@ export async function buildThreadStartCommand( }), instructions: runtimeContext.instructions, dynamicTools: runtimeContext.dynamicTools, + contributedEnv: runtimeContext.contributedEnv, injectedSkillSources: runtimeContext.injectedSkillSources, instructionMode: runtimeContext.instructionMode, threadStoragePath: runtimeContext.threadStoragePath, @@ -335,6 +336,7 @@ function buildPreparedTurnSubmitCommandPayload( providerThreadId: args.providerThreadId, instructions: args.runtimeContext.instructions, dynamicTools: args.runtimeContext.dynamicTools, + contributedEnv: args.runtimeContext.contributedEnv, injectedSkillSources: args.runtimeContext.injectedSkillSources, instructionMode: args.runtimeContext.instructionMode, }, diff --git a/apps/server/src/services/threads/thread-runtime-config.ts b/apps/server/src/services/threads/thread-runtime-config.ts index 12969013e5..ebd705fe70 100644 --- a/apps/server/src/services/threads/thread-runtime-config.ts +++ b/apps/server/src/services/threads/thread-runtime-config.ts @@ -12,7 +12,10 @@ import type { WorkspaceProvisionType, EnvironmentStatus, } from "@bb/domain"; -import type { HostDaemonInjectedSkillSource } from "@bb/host-daemon-contract"; +import type { + HostDaemonContributedEnvEntry, + HostDaemonInjectedSkillSource, +} from "@bb/host-daemon-contract"; import { renderTemplate } from "@bb/templates"; import { ApiError } from "../../errors.js"; import type { AppDeps, LoggedWorkSessionDeps } from "../../types.js"; @@ -27,6 +30,7 @@ import { listPluginInstructionContributions, getPluginSkillRootContributions, resolvePluginAgentConfiguration, + resolvePluginProviderEnv, } from "../plugins/plugin-agent-contributions.js"; import { resolveSkillCatalog } from "../skills/skill-catalog.js"; import { discoverPluginSkillIds } from "../skills/injected-skills.js"; @@ -78,6 +82,7 @@ interface ResolvePermissionEscalationArgs { } export interface ResolvedThreadRuntimeCommandConfig { + contributedEnv: HostDaemonContributedEnvEntry[]; dynamicTools: DynamicTool[]; injectedSkillSources: HostDaemonInjectedSkillSource[]; instructionMode: InstructionMode; @@ -224,6 +229,14 @@ export async function resolveThreadRuntimeCommandConfig( }, skillIdsByPlugin, }); + const contributedEnv = await resolvePluginProviderEnv({ + providerId: args.thread.providerId, + context: { + threadId: args.thread.id, + projectId: project.id, + hostId: host.id, + }, + }); const injectedSkillSources = resolveSkillCatalog(deps, { projectSkillSources, sharedSkillSources: sharedSkills.runtimeSources, @@ -302,6 +315,7 @@ export async function resolveThreadRuntimeCommandConfig( threadId: args.thread.id, }); return { + contributedEnv, dynamicTools, injectedSkillSources, instructionMode: "append", diff --git a/apps/server/test/services/plugins/plugin-agent-contributions.test.ts b/apps/server/test/services/plugins/plugin-agent-contributions.test.ts index 0ba30391e6..c7dd00e124 100644 --- a/apps/server/test/services/plugins/plugin-agent-contributions.test.ts +++ b/apps/server/test/services/plugins/plugin-agent-contributions.test.ts @@ -226,6 +226,54 @@ describe("plugin agent contributions reach thread runtime config", () => { pluginsDir = await mkdtemp(join(tmpdir(), "bb-plugin-runtime-test-")); }); + it("isolates resolver failures and timeouts", async () => { + const db = createConnection(":memory:"); + migrate(db); + const service = createPluginService({ + aiServices: createAiServiceRegistry(), + telemetry: createNoopTelemetryService(), + db, + hub: { + getDaemonSessionIdForHost: () => null, + notifyPluginSignal: () => 0, + notifySystem: () => {}, + }, + logger, + dataDir: join(pluginsDir, "timeout-data"), + appVersion: "0.9.0", + loadTimeoutMs: 2_000, + providerEnvResolveTimeoutMs: 10, + }); + try { + const root = await writePlugin(pluginsDir, { + name: "bb-plugin-env-failures", + serverSource: ` + export default function plugin(bb) { + bb.providers.experimental_contributeEnv("codex", () => { + throw new Error("resolver exploded"); + }); + bb.providers.experimental_contributeEnv("claude-code", () => new Promise(() => {})); + } + `, + }); + await service.installPath(root); + const context = { + threadId: "thread-timeout", + projectId: "project-timeout", + hostId: "host-timeout", + }; + + await expect( + service.resolveProviderEnv({ providerId: "codex", context }), + ).resolves.toEqual({ entries: [] }); + await expect( + service.resolveProviderEnv({ providerId: "claude-code", context }), + ).resolves.toEqual({ entries: [] }); + } finally { + await service.stop(); + } + }); + afterEach(async () => { await harness.pluginService.stop(); await harness.cleanup(); @@ -292,4 +340,108 @@ describe("plugin agent contributions reach thread runtime config", () => { reloaded.injectedSkillSources.map((source) => source.name), ).toContain("late-skill"); }); + + it("resolves provider environment per command and keeps the first plugin on conflicts", async () => { + const firstRoot = await writePlugin(pluginsDir, { + name: "bb-plugin-env-first", + serverSource: ` + export default function plugin(bb) { + bb.providers.experimental_contributeEnv("codex", (context) => [ + { + name: "PLUGIN_CONTEXT", + value: context.threadId + ":" + context.projectId + ":" + context.hostId, + reason: "Expose resolution context", + secret: false, + }, + { + name: "SHARED_TOKEN", + value: "first", + reason: "First registration wins", + secret: true, + }, + ]); + } + `, + }); + const secondRoot = await writePlugin(pluginsDir, { + name: "bb-plugin-env-second", + serverSource: ` + export default function plugin(bb) { + bb.providers.experimental_contributeEnv("codex", () => [ + { + name: "SHARED_TOKEN", + value: "second", + reason: "Conflicting registration", + secret: true, + }, + { + name: "PLUGIN_PROXY_URL", + value: { serverPath: "/plugins/env-second/proxy" }, + reason: "Use the server auth proxy", + secret: false, + }, + ]); + } + `, + }); + await harness.pluginService.installPath(firstRoot); + await harness.pluginService.installPath(secondRoot); + + const { host } = seedHostSession(harness.deps, { + id: "host-provider-env", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: join(harness.config.dataDir, "provider-env-workspace"), + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + providerId: "codex", + }); + const execution = await resolveExecutionOptions(harness.deps, { + threadId: thread.id, + requestedExecution: { model: "gpt-5", source: "client/turn/requested" }, + }); + const command = await buildThreadStartCommand(harness.deps, { + environment, + execution, + fork: null, + permissionEscalation: "ask", + input: textInput("hello"), + projectId: project.id, + providerId: "codex", + requestId: encodeClientTurnRequestIdNumber({ value: 3 }), + syncGeneratedTitle: false, + thread, + }); + + expect(command.contributedEnv).toEqual([ + { + name: "PLUGIN_CONTEXT", + value: `${thread.id}:${project.id}:${host.id}`, + reason: "Expose resolution context", + secret: false, + source: { plugin: "env-first" }, + }, + { + name: "SHARED_TOKEN", + value: "first", + reason: "First registration wins", + secret: true, + source: { plugin: "env-first" }, + }, + { + name: "PLUGIN_PROXY_URL", + value: { serverPath: "/plugins/env-second/proxy" }, + reason: "Use the server auth proxy", + secret: false, + source: { plugin: "env-second" }, + }, + ]); + }); }); diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index c5d10f75b2..f12d38c738 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -1,5 +1,27 @@ # APIs To Audit +## `bb.providers.experimental_contributeEnv` + +**What it does.** Registers one resolver per provider per plugin. The server +calls it for each matching session and turn with the thread, project, and host +ids, validates at most 32 environment entries, resolves registration conflicts +in plugin load order, and sends the winning values to the host. A value may be +a literal string or a server-relative path that the host expands against its +authenticated `BB_SERVER_URL`. Contributions override the shell environment; +entries marked `secret` are masked in provider environment events. + +**Audit before stabilizing.** + +1. Confirm one resolver per provider is sufficient when plugins need multiple + independently disposable features. +2. Confirm plugin load order is the right deterministic conflict policy. +3. Confirm the 32-entry cap and five-second timeout fit providers that resolve + short-lived credentials. +4. Decide whether `serverPath` needs structured query parameters or non-HTTP + server endpoints before accepting more shapes. +5. Confirm `reason` should remain required and whether event consumers need a + stable machine-readable purpose beside it. + Every public plugin API member ships with an `experimental_` prefix and an entry here (see [AGENTS.md](../AGENTS.md), "Plugin API"). Dropping the prefix is the deliberate stabilization step: audit the entry, rename project-wide, diff --git a/packages/agent-runtime/src/runtime.lifecycle.test.ts b/packages/agent-runtime/src/runtime.lifecycle.test.ts index 212bb2bf4a..af274848a8 100644 --- a/packages/agent-runtime/src/runtime.lifecycle.test.ts +++ b/packages/agent-runtime/src/runtime.lifecycle.test.ts @@ -236,6 +236,7 @@ describe("createAgentRuntime lifecycle", () => { it("merges runtime shell env with per-thread context on start", async () => { const record = createScriptedEchoRequestRecord(); + const events: ThreadEvent[] = []; const threadStorageRootPath = join(tmpDir, "thread-storage"); const runtime = createScriptedEchoRuntime({ runtime: { @@ -249,7 +250,7 @@ describe("createAgentRuntime lifecycle", () => { BB_SERVER_URL: "http://127.0.0.1:3334", BB_THREAD_ID: "wrong-thread", }, - onEvent: () => undefined, + onEvent: (event) => events.push(event), }, }); @@ -258,6 +259,22 @@ describe("createAgentRuntime lifecycle", () => { threadId: "t1", projectId: "p1", providerId: "fake", + contributedEnv: [ + { + name: "PATH", + value: "/plugin/bin", + source: { plugin: "env-test" }, + reason: "Use the plugin toolchain", + secret: false, + }, + { + name: "AUTH_PROXY_URL", + value: { serverPath: "/plugins/env-test/auth" }, + source: { plugin: "env-test" }, + reason: "Use the authenticated server proxy", + secret: true, + }, + ], options: fullRuntimeOptions, }); @@ -269,7 +286,8 @@ describe("createAgentRuntime lifecycle", () => { cwd: tmpDir, options: expect.objectContaining({ envVars: { - PATH: "/tmp/bb-bin:/usr/bin", + PATH: "/plugin/bin", + AUTH_PROXY_URL: "http://127.0.0.1:3334/plugins/env-test/auth", BB_HOST_DAEMON_PORT: "3002", BB_PROJECT_ID: "p1", BB_SERVER_URL: "http://127.0.0.1:3334", @@ -280,6 +298,25 @@ describe("createAgentRuntime lifecycle", () => { }), }), ); + expect( + events.find((event) => event.type === "provider.env-resolved"), + ).toMatchObject({ + entries: expect.arrayContaining([ + { + name: "PATH", + source: { plugin: "env-test" }, + value: "/plugin/bin", + reason: "Use the plugin toolchain", + }, + { + name: "AUTH_PROXY_URL", + source: { plugin: "env-test" }, + value: { masked: true }, + reason: "Use the authenticated server proxy", + }, + ]), + }); + expect(JSON.stringify(events)).not.toContain("/plugins/env-test/auth"); await runtime.shutdown(); }); diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index f18811906f..39126f871e 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -52,6 +52,7 @@ import { RuntimeThreadGoalState } from "./runtime-thread-goal-state.js"; import { RuntimeBackgroundWorkState } from "./runtime-background-work-state.js"; import { RuntimeTurnState } from "./runtime-turn-state.js"; import type { + AgentRuntimeContributedEnvEntry, AgentRuntime, AgentRuntimeProviderRecoveryHint, AgentRuntimeBridgeLaunch, @@ -59,7 +60,10 @@ import type { AgentRuntimeOptions, ReapedIdleProviderSession, } from "./types.js"; -import { buildThreadShellEnvironment } from "./thread-shell-environment.js"; +import { + resolveThreadEnvironment, + type ResolvedThreadEnvironmentEntry, +} from "./thread-shell-environment.js"; import { bridgeLaunchProcessKey } from "./bridge-launch-process-key.js"; interface RecordThreadExecutionOptionsArgs { @@ -180,11 +184,13 @@ const PREPARED_THREAD_REWIND_RETRY_MS = 30_000; interface ThreadRuntimeConfig { bridgeLaunch: AgentRuntimeBridgeLaunch; + contributedEnv: readonly AgentRuntimeContributedEnvEntry[]; dynamicTools?: DynamicTool[]; disallowedTools?: readonly string[]; environmentId: string; instructionMode: InstructionMode; instructions?: string; + envVars: Record; options: AgentRuntimeExecutionOptions; processKey: string; projectId?: string; @@ -1032,6 +1038,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { : {}), providerThreadId: args.providerThreadId, providerId: currentConfig.providerId, + contributedEnv: currentConfig.contributedEnv, options: args.options, ...(resumeInstructions !== undefined ? { instructions: resumeInstructions } @@ -1109,6 +1116,54 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { }); } + function environmentRecordsEqual( + left: Readonly>, + right: Readonly>, + ): boolean { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + return ( + leftEntries.length === rightEntries.length && + leftEntries.every(([name, value]) => right[name] === value) + ); + } + + function emitResolvedProviderEnvironment(args: { + entries: ResolvedThreadEnvironmentEntry[]; + providerThreadId: string; + threadId: string; + }): void { + options.onEvent({ + type: "provider.env-resolved", + threadId: args.threadId, + providerThreadId: args.providerThreadId, + entries: args.entries, + scope: { kind: "thread" }, + }); + } + + function resolveRuntimeThreadEnvironment(args: { + contributedEnv: readonly AgentRuntimeContributedEnvEntry[]; + environmentId: string; + projectId?: string; + threadId: string; + }): { + envVars: Record; + entries: ResolvedThreadEnvironmentEntry[]; + } { + return resolveThreadEnvironment({ + baseShellEnv: options.shellEnv, + contributedEnv: args.contributedEnv, + environmentId: args.environmentId, + projectId: args.projectId, + threadStoragePath: resolveThreadStoragePath({ + options, + threadId: args.threadId, + }), + threadId: args.threadId, + }); + } + function emitTranslatedEvents(args: EmitTranslatedEventsArgs): void { for (const event of args.events) { if (event.type !== "thread/identity" || !event.providerThreadId) { @@ -1398,6 +1453,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { projectId, providerId, bridgeLaunch, + contributedEnv = [], clientRequestId, input, inputGroups, @@ -1426,6 +1482,12 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { options: execOpts, providerId, }); + const resolvedEnvironment = resolveRuntimeThreadEnvironment({ + contributedEnv, + environmentId, + projectId, + threadId, + }); threadIdentityRegistry.registerThreadProvider({ providerId, providerState: proc.identity, @@ -1434,9 +1496,11 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { }); setThreadRuntimeConfig(threadId, { bridgeLaunch, + contributedEnv, dynamicTools, disallowedTools, environmentId, + envVars: resolvedEnvironment.envVars, instructionMode, instructions, options: execOpts, @@ -1446,19 +1510,8 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { sessionRestorable: false, }); - const envVars = buildThreadShellEnvironment({ - baseShellEnv: options.shellEnv, - environmentId, - projectId, - threadStoragePath: resolveThreadStoragePath({ - options, - threadId, - }), - threadId, - }); - const providerExecutionContext = toProviderExecutionContext({ - envVars, + envVars: resolvedEnvironment.envVars, execOpts, instructions, skillRoots, @@ -1518,6 +1571,11 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { result.providerThreadId, ); resolved = result.providerThreadId; + emitResolvedProviderEnvironment({ + entries: resolvedEnvironment.entries, + providerThreadId: resolved, + threadId, + }); } catch (startError) { await abandonFailedSessionConstruction({ proc, threadId }); throw startError; @@ -1535,6 +1593,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { ...(inputGroups !== undefined ? { inputGroups } : {}), clientRequestId, options: execOpts, + contributedEnv, instructions, }); } @@ -1551,6 +1610,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { leaseId, projectId, providerId, + contributedEnv = [], sourceProviderThreadId, retainThroughProviderCheckpoint, bridgeLaunch, @@ -1601,14 +1661,10 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { let retainedForDiscard = false; let providerThreadIdForCleanup: string | undefined; try { - const envVars = buildThreadShellEnvironment({ - baseShellEnv: options.shellEnv, + const resolvedEnvironment = resolveRuntimeThreadEnvironment({ + contributedEnv, environmentId, projectId, - threadStoragePath: resolveThreadStoragePath({ - options, - threadId, - }), threadId, }); const adapterCommand: AdapterCommand = { @@ -1618,7 +1674,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { sourceProviderThreadId, sourceProviderCheckpointId: retainThroughProviderCheckpoint, options: toProviderExecutionContext({ - envVars, + envVars: resolvedEnvironment.envVars, execOpts, instructions, skillRoots, @@ -1725,6 +1781,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { providerThreadId, providerId, bridgeLaunch, + contributedEnv = [], options: execOpts, instructions, dynamicTools, @@ -1749,6 +1806,12 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { options: execOpts, providerId, }); + const resolvedEnvironment = resolveRuntimeThreadEnvironment({ + contributedEnv, + environmentId, + projectId, + threadId, + }); threadIdentityRegistry.registerThreadProvider({ providerId, providerState: proc.identity, @@ -1757,9 +1820,11 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { }); setThreadRuntimeConfig(threadId, { bridgeLaunch, + contributedEnv, dynamicTools, disallowedTools, environmentId, + envVars: resolvedEnvironment.envVars, instructionMode, instructions, options: execOpts, @@ -1773,17 +1838,6 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { recordProviderThreadIdentity(proc, threadId, providerThreadId); } - const envVars = buildThreadShellEnvironment({ - baseShellEnv: options.shellEnv, - environmentId, - projectId, - threadStoragePath: resolveThreadStoragePath({ - options, - threadId, - }), - threadId, - }); - const adapterCommand: AdapterCommand = { type: "thread/resume", threadId, @@ -1791,7 +1845,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { providerThreadId: providerThreadId ?? requireProviderThreadId(threadId), options: toProviderExecutionContext({ - envVars, + envVars: resolvedEnvironment.envVars, execOpts, instructions, skillRoots, @@ -1802,6 +1856,11 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { }; const plan = proc.adapter.buildCommandPlan(adapterCommand); if (plan.kind === "noop") { + emitResolvedProviderEnvironment({ + entries: resolvedEnvironment.entries, + providerThreadId: adapterCommand.providerThreadId, + threadId, + }); return { providerThreadId: adapterCommand.providerThreadId }; } @@ -1824,6 +1883,11 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { ); updateSessionRestoreCapability(threadId, result.sessionRestorable); resolved = result.providerThreadId; + emitResolvedProviderEnvironment({ + entries: resolvedEnvironment.entries, + providerThreadId: resolved, + threadId, + }); } catch (resumeError) { await abandonFailedSessionConstruction({ proc, threadId }); throw resumeError; @@ -1840,6 +1904,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { inputGroups, clientRequestId, options: execOpts, + contributedEnv, instructions, }) { return runThreadOperation({ @@ -1859,20 +1924,37 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { options: execOpts, providerId: pid, }); + const currentConfig = threadRuntimeConfigs.get(threadId); + if (!currentConfig) { + throw new Error(`No runtime configuration for thread ${threadId}`); + } + const resolvedContributedEnv = + contributedEnv ?? currentConfig.contributedEnv; + const resolvedEnvironment = resolveRuntimeThreadEnvironment({ + contributedEnv: resolvedContributedEnv, + environmentId: currentConfig.environmentId, + projectId: currentConfig.projectId, + threadId, + }); + const environmentChanged = !environmentRecordsEqual( + currentConfig.envVars, + resolvedEnvironment.envVars, + ); recordThreadExecutionOptions({ threadId, options: execOpts, }); + const providerThreadId = requireProviderThreadId(threadId); const adapterCommand: AdapterCommand = { type: "turn/start", threadId, - providerThreadId: requireProviderThreadId(threadId), + providerThreadId, input, ...(inputGroups !== undefined ? { inputGroups } : {}), clientRequestId, options: toProviderExecutionContext({ - envVars: {}, + envVars: resolvedEnvironment.envVars, execOpts, instructions, }), @@ -1899,6 +1981,19 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { threadId, }, }); + setThreadRuntimeConfig(threadId, { + ...currentConfig, + contributedEnv: resolvedContributedEnv, + envVars: resolvedEnvironment.envVars, + options: execOpts, + }); + if (environmentChanged) { + emitResolvedProviderEnvironment({ + entries: resolvedEnvironment.entries, + providerThreadId, + threadId, + }); + } } catch (error) { pendingTurnStarts.delete(threadId); markHostedProviderSessionIdle(threadId); @@ -1915,6 +2010,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { inputGroups, clientRequestId, options: execOpts, + contributedEnv, instructions, }) { return runThreadOperation({ @@ -1945,21 +2041,38 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { instructions, }); const proc = requireProviderProcessForThread(threadId); + const currentConfig = threadRuntimeConfigs.get(threadId); + if (!currentConfig) { + throw new Error(`No runtime configuration for thread ${threadId}`); + } + const resolvedContributedEnv = + contributedEnv ?? currentConfig.contributedEnv; + const resolvedEnvironment = resolveRuntimeThreadEnvironment({ + contributedEnv: resolvedContributedEnv, + environmentId: currentConfig.environmentId, + projectId: currentConfig.projectId, + threadId, + }); + const environmentChanged = !environmentRecordsEqual( + currentConfig.envVars, + resolvedEnvironment.envVars, + ); recordThreadExecutionOptions({ threadId, options: execOpts, }); + const providerThreadId = requireProviderThreadId(threadId); const adapterCommand: AdapterCommand = { type: "turn/steer", threadId, - providerThreadId: requireProviderThreadId(threadId), + providerThreadId, expectedTurnId, input, ...(inputGroups !== undefined ? { inputGroups } : {}), clientRequestId, options: toProviderExecutionContext({ - envVars: {}, + envVars: resolvedEnvironment.envVars, execOpts, instructions, }), @@ -1980,6 +2093,19 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { threadId, }, }); + setThreadRuntimeConfig(threadId, { + ...currentConfig, + contributedEnv: resolvedContributedEnv, + envVars: resolvedEnvironment.envVars, + options: execOpts, + }); + if (environmentChanged) { + emitResolvedProviderEnvironment({ + entries: resolvedEnvironment.entries, + providerThreadId, + threadId, + }); + } } catch (error) { if ( error instanceof JsonRpcResponseError && @@ -2292,11 +2418,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { return threadIdentityRegistry.getProviderSession(threadId); }, - async reapIdleProviderSessions({ - idleForMs, - nowMs, - runThreadExclusive, - }) { + async reapIdleProviderSessions({ idleForMs, nowMs, runThreadExclusive }) { const reapedSessions: ReapedIdleProviderSession[] = []; for (const threadId of [...threadRuntimeConfigs.keys()]) { const release = async (): Promise => { diff --git a/packages/agent-runtime/src/thread-shell-environment.ts b/packages/agent-runtime/src/thread-shell-environment.ts index d0391cff2c..4263792017 100644 --- a/packages/agent-runtime/src/thread-shell-environment.ts +++ b/packages/agent-runtime/src/thread-shell-environment.ts @@ -1,4 +1,7 @@ -import type { AgentRuntimeShellEnvironment } from "./types.js"; +import type { + AgentRuntimeContributedEnvEntry, + AgentRuntimeShellEnvironment, +} from "./types.js"; interface ThreadShellEnvironmentArgs { environmentId: string; @@ -24,3 +27,51 @@ export function buildThreadShellEnvironment( BB_ENVIRONMENT_ID: args.environmentId, }; } + +export interface ResolvedThreadEnvironmentEntry { + name: string; + source: "shell" | { plugin: string }; + value: string | { masked: true }; + reason?: string; +} + +interface ResolveThreadEnvironmentArgs extends ThreadShellEnvironmentArgs { + baseShellEnv: AgentRuntimeShellEnvironment | undefined; + contributedEnv: readonly AgentRuntimeContributedEnvEntry[]; +} + +export function resolveThreadEnvironment(args: ResolveThreadEnvironmentArgs): { + envVars: Record; + entries: ResolvedThreadEnvironmentEntry[]; +} { + const envVars = buildThreadShellEnvironment(args); + const entries: ResolvedThreadEnvironmentEntry[] = Object.entries(envVars).map( + ([name, value]) => ({ name, source: "shell", value }), + ); + for (const contribution of args.contributedEnv) { + let value: string; + if (typeof contribution.value === "string") { + value = contribution.value; + } else { + const serverUrl = args.baseShellEnv?.BB_SERVER_URL; + if (serverUrl === undefined) { + throw new Error( + `Cannot resolve serverPath environment contribution ${contribution.name} without BB_SERVER_URL`, + ); + } + value = `${serverUrl}${contribution.value.serverPath}`; + } + envVars[contribution.name] = value; + const existingIndex = entries.findIndex( + (entry) => entry.name === contribution.name, + ); + if (existingIndex !== -1) entries.splice(existingIndex, 1); + entries.push({ + name: contribution.name, + source: contribution.source, + value: contribution.secret ? { masked: true } : value, + reason: contribution.reason, + }); + } + return { envVars, entries }; +} diff --git a/packages/agent-runtime/src/types.ts b/packages/agent-runtime/src/types.ts index 17b65ca1a2..8f56d26ebd 100644 --- a/packages/agent-runtime/src/types.ts +++ b/packages/agent-runtime/src/types.ts @@ -25,6 +25,14 @@ import type { export type AgentRuntimeShellEnvironment = Record; +export interface AgentRuntimeContributedEnvEntry { + name: string; + value: string | { serverPath: string }; + source: { plugin: string }; + reason: string; + secret: boolean; +} + export type AgentRuntimeExecutionOptions = RuntimeThreadExecutionOptions; export type AgentRuntimeSkillRoot = SkillsConfigureRoot; @@ -113,6 +121,7 @@ export interface StartThreadArgs { threadId: string; projectId: string; providerId: string; + contributedEnv?: readonly AgentRuntimeContributedEnvEntry[]; clientRequestId?: ClientTurnRequestId; input?: PromptInput[]; inputGroups?: PromptInput[][]; @@ -138,6 +147,7 @@ interface PrepareThreadRewindArgs { leaseId: string; projectId: string; providerId: string; + contributedEnv?: readonly AgentRuntimeContributedEnvEntry[]; sourceProviderThreadId: string; retainThroughProviderCheckpoint: string; options: AgentRuntimeExecutionOptions; @@ -162,6 +172,7 @@ export interface ResumeThreadArgs { projectId?: string; providerThreadId?: string; providerId: string; + contributedEnv?: readonly AgentRuntimeContributedEnvEntry[]; options: AgentRuntimeExecutionOptions; instructions?: string; dynamicTools?: DynamicTool[]; @@ -179,6 +190,7 @@ export interface RunTurnArgs { inputGroups?: PromptInput[][]; clientRequestId: ClientTurnRequestId; options: AgentRuntimeExecutionOptions; + contributedEnv?: readonly AgentRuntimeContributedEnvEntry[]; instructions?: string; } @@ -189,6 +201,7 @@ export interface SteerTurnArgs { inputGroups?: PromptInput[][]; clientRequestId: ClientTurnRequestId; options: AgentRuntimeExecutionOptions; + contributedEnv?: readonly AgentRuntimeContributedEnvEntry[]; instructions?: string; } diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index d149a23c13..710cdecb67 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.4.43"; +export const PLUGIN_SDK_VERSION = "0.4.44"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/domain/src/provider-event.ts b/packages/domain/src/provider-event.ts index 72cbbd8061..4982f94aa1 100644 --- a/packages/domain/src/provider-event.ts +++ b/packages/domain/src/provider-event.ts @@ -675,6 +675,27 @@ const unscopedProviderEventSchema = z.discriminatedUnion("type", [ providerThreadId: z.string(), rateLimits: providerRateLimitStateSchema, }), + z.object({ + type: z.literal("provider.env-resolved"), + threadId: z.string(), + providerThreadId: z.string(), + entries: z.array( + z + .object({ + name: z.string(), + source: z.union([ + z.literal("shell"), + z.object({ plugin: z.string() }).strict(), + ]), + value: z.union([ + z.string(), + z.object({ masked: z.literal(true) }).strict(), + ]), + reason: z.string().optional(), + }) + .strict(), + ), + }), z.object({ type: z.literal("thread/extensionState/updated"), threadId: z.string(), diff --git a/packages/domain/src/thread-event-scope.ts b/packages/domain/src/thread-event-scope.ts index 7d75515075..341df2ddb0 100644 --- a/packages/domain/src/thread-event-scope.ts +++ b/packages/domain/src/thread-event-scope.ts @@ -143,6 +143,11 @@ const threadEventScopeDefinitionByType = { rationale: "Subscription usage is account-scoped state that can affect multiple turns and threads.", }, + "provider.env-resolved": { + policy: "thread", + rationale: + "Resolved provider environment is session state and can change between turns.", + }, "thread/extensionState/updated": { policy: "thread", rationale: diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index 275634b8fe..fa6b9a7c98 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -178,6 +178,22 @@ export type HostDaemonBridgeLaunch = z.infer< typeof hostDaemonBridgeLaunchSchema >; +export const hostDaemonContributedEnvEntrySchema = z + .object({ + name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/u), + value: z.union([ + z.string(), + z.object({ serverPath: z.string().startsWith("/") }).strict(), + ]), + source: z.object({ plugin: z.string().min(1) }).strict(), + reason: z.string(), + secret: z.boolean(), + }) + .strict(); +export type HostDaemonContributedEnvEntry = z.infer< + typeof hostDaemonContributedEnvEntrySchema +>; + const hostDaemonThreadRuntimeContextSchema = z .object({ workspaceContext: workspaceContextSchema, @@ -187,6 +203,7 @@ const hostDaemonThreadRuntimeContextSchema = z options: runtimeThreadExecutionOptionsSchema, instructions: z.string().min(1), dynamicTools: z.array(dynamicToolSchema), + contributedEnv: z.array(hostDaemonContributedEnvEntrySchema).default([]), injectedSkillSources: z.array(hostDaemonInjectedSkillSourceSchema), disallowedTools: z.array(z.string()).optional(), instructionMode: instructionModeSchema, @@ -1786,9 +1803,7 @@ type HostDaemonRetryableOnlineRpcCommandSchema = type HostDaemonResultSchemaMapForTransport< Transport extends HostDaemonCommandTransport, > = { - [ - Descriptor in HostDaemonCommandDescriptorForTransport as Descriptor["type"] - ]: Descriptor["resultSchema"]; + [Descriptor in HostDaemonCommandDescriptorForTransport as Descriptor["type"]]: Descriptor["resultSchema"]; }; type HostDaemonCommandResultSchemaMap = diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index 6348848767..7c0af7b6c9 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -1,3 +1,3 @@ -export const HOST_DAEMON_PROTOCOL_VERSION = 179 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 180 as const; export const HOST_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024; diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 07dd306df8..3756995c38 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -928,9 +928,19 @@ const ACP_BRIDGE_LAUNCH = { providerOptions: { acpLaunchSpec: ACP_LAUNCH_SPEC }, } as const; +const CONTRIBUTED_ENV = [ + { + name: "PLUGIN_API_URL", + value: { serverPath: "/plugins/auth-proxy/api" }, + source: { plugin: "auth-proxy" }, + reason: "Route provider traffic through the plugin", + secret: true, + }, +] as const; + describe("host-daemon command schemas", () => { it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(179); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(180); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); @@ -1682,6 +1692,7 @@ describe("host-daemon command schemas", () => { }, instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", requestId: CLIENT_REQUEST_ID, @@ -1717,6 +1728,7 @@ describe("host-daemon command schemas", () => { providerThreadId: "prov_123", instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1774,6 +1786,7 @@ describe("host-daemon command schemas", () => { inputSchema: { type: "object" }, }, ], + contributedEnv: [], injectedSkillSources: [], instructionMode: "replace", }), @@ -1839,6 +1852,7 @@ describe("host-daemon command schemas", () => { }, instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append" as const, }; @@ -1901,6 +1915,7 @@ describe("host-daemon command schemas", () => { providerThreadId: "provider_123", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -1952,6 +1967,7 @@ describe("host-daemon command schemas", () => { }, instructions: "Be a helpful thread.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "replace", }; @@ -1996,6 +2012,7 @@ describe("host-daemon command schemas", () => { providerThreadId: "provider_123", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -2057,6 +2074,7 @@ describe("host-daemon command schemas", () => { }, instructions: "Be a helpful thread.", dynamicTools: [], + contributedEnv: CONTRIBUTED_ENV, injectedSkillSources: [], instructionMode: "append", }; @@ -2094,6 +2112,7 @@ describe("host-daemon command schemas", () => { providerThreadId: "provider_123", instructions: "Be a helpful thread.", dynamicTools: [], + contributedEnv: CONTRIBUTED_ENV, injectedSkillSources: [], instructionMode: "append", }, @@ -2171,6 +2190,7 @@ describe("host-daemon command schemas", () => { }, instructions: "Be a helpful thread.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }; @@ -2194,6 +2214,7 @@ describe("host-daemon command schemas", () => { bridgeLaunch, instructions: "Be a helpful thread.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -2336,6 +2357,7 @@ describe("host-daemon command schemas", () => { providerThreadId: "provider_123", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -2383,6 +2405,7 @@ describe("host-daemon command schemas", () => { providerThreadId: "provider_123", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, @@ -2485,6 +2508,7 @@ describe("host-daemon command schemas", () => { }, instructions: "Be concise.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }), @@ -2520,6 +2544,7 @@ describe("host-daemon command schemas", () => { providerThreadId: "provider_123", instructions: "Be a helpful coding agent.", dynamicTools: [], + contributedEnv: [], injectedSkillSources: [], instructionMode: "append", }, diff --git a/packages/plugin-api-map/src/surfaces.ts b/packages/plugin-api-map/src/surfaces.ts index 996cb5529c..195d9919d0 100644 --- a/packages/plugin-api-map/src/surfaces.ts +++ b/packages/plugin-api-map/src/surfaces.ts @@ -387,10 +387,13 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Declare what the provider supports, then serve its model list at runtime", "Supply a small icon that appears next to its name", "Receive every message in a thread started with it, through a bridge process the plugin ships", + "Contribute validated environment variables to any provider for each session and turn", ], apiSymbols: [ "PluginProviderDeclaration", "PluginProviderIconRegistration", + "PluginProviderEnvContext", + "PluginProviderEnvEntry", ], firstParty: [ "ACP providers", diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 8902e94ea1..88c4988af5 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.43", + "version": "0.4.44", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/plugin-sdk/src/__tests__/public-types.test.ts b/packages/plugin-sdk/src/__tests__/public-types.test.ts index 4775a74534..41465b4f82 100644 --- a/packages/plugin-sdk/src/__tests__/public-types.test.ts +++ b/packages/plugin-sdk/src/__tests__/public-types.test.ts @@ -74,6 +74,8 @@ const EXPECTED_BACKEND_ROOT_TYPE_EXPORTS = [ "PluginProviderCapabilities", "PluginProviderComposerAction", "PluginProviderDeclaration", + "PluginProviderEnvContext", + "PluginProviderEnvEntry", "PluginProviderExtensionKindDeclaration", "PluginProviderFallbackModel", "PluginProviderMaintenance", diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts index e8c4272e0d..af82cfda77 100644 --- a/packages/plugin-sdk/src/backend-contract.ts +++ b/packages/plugin-sdk/src/backend-contract.ts @@ -1338,6 +1338,27 @@ export interface PluginProviders { register(declaration: PluginProviderDeclaration): { dispose(): void; }; + experimental_contributeEnv( + providerId: string, + resolve: ( + context: PluginProviderEnvContext, + ) => + | readonly PluginProviderEnvEntry[] + | Promise, + ): void; +} + +export interface PluginProviderEnvContext { + threadId: string; + projectId: string; + hostId: string; +} + +export interface PluginProviderEnvEntry { + name: string; + value: string | { serverPath: string }; + reason: string; + secret: boolean; } // --------------------------------------------------------------------------- diff --git a/packages/plugin-sdk/src/internal/host-policy.ts b/packages/plugin-sdk/src/internal/host-policy.ts index 8084a7c784..d38e567db0 100644 --- a/packages/plugin-sdk/src/internal/host-policy.ts +++ b/packages/plugin-sdk/src/internal/host-policy.ts @@ -25,6 +25,7 @@ import type { PluginProviderCapabilities, PluginProviderComposerAction, PluginProviderDeclaration, + PluginProviderEnvEntry, PluginProviderExtensionKindDeclaration, PluginProviderFallbackModel, PluginProviderModelCatalogScope, @@ -83,8 +84,7 @@ export const PLUGIN_HTTP_METHODS: ReadonlySet = new Set([ ]); // Rpc method names become URL path segments. -export const RPC_METHOD_PATTERN = - /^[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*$/; +export const RPC_METHOD_PATTERN = /^[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*$/; // Service/schedule names appear in status text and plugin_schedules rows. export const BACKGROUND_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; @@ -94,6 +94,52 @@ export const CLI_COMMAND_NAME_PATTERN = /^[a-z0-9-]+$/; // Agent tool names are shown to (and called by) the model. export const AGENT_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; +export const PLUGIN_PROVIDER_ENV_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/; +export const PLUGIN_PROVIDER_ENV_MAX_ENTRIES = 32; + +const pluginProviderEnvEntrySchema = z + .object({ + name: z.string().regex(PLUGIN_PROVIDER_ENV_NAME_PATTERN), + value: z.union([ + z.string(), + z.object({ serverPath: z.string().startsWith("/") }).strict(), + ]), + reason: z.string(), + secret: z.boolean(), + }) + .strict(); + +const pluginProviderEnvEntriesSchema = z + .array(pluginProviderEnvEntrySchema) + .max(PLUGIN_PROVIDER_ENV_MAX_ENTRIES) + .superRefine((entries, context) => { + const names = new Set(); + for (let index = 0; index < entries.length; index += 1) { + const name = entries[index]?.name; + if (name !== undefined && names.has(name)) { + context.addIssue({ + code: "custom", + path: [index, "name"], + message: "must be unique within one resolver", + }); + } + if (name !== undefined) names.add(name); + } + }); + +export function validatePluginProviderEnvEntries( + value: unknown, +): PluginProviderEnvEntry[] { + const parsed = pluginProviderEnvEntriesSchema.safeParse(value); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const path = issue?.path.length ? `[${issue.path.join(".")}] ` : ""; + throw new Error( + `provider environment contribution ${path}${issue?.message ?? "is invalid"}`, + ); + } + return parsed.data; +} export const PLUGIN_AGENT_STATIC_INSTRUCTIONS_MAX_CHARS = 4096; /** Status labels ride on every tool-call event and share one timeline row. */ diff --git a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts index 726f78a056..9283574ee5 100644 --- a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts +++ b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts @@ -1576,6 +1576,75 @@ describe("providers.register", () => { }); }); +describe("providers.experimental_contributeEnv", () => { + it("round trips validated entries with the provider context", async () => { + const { bb, harness } = createFakePluginHost({ pluginId: "auth-proxy" }); + const contexts: unknown[] = []; + bb.providers.experimental_contributeEnv("claude-code", (context) => { + contexts.push(context); + return [ + { + name: "PLUGIN_API_URL", + value: { serverPath: "/plugins/auth-proxy/api" }, + reason: "Route provider traffic through the plugin", + secret: true, + }, + ]; + }); + + await expect( + harness.behavior.resolveProviderEnv("claude-code", { + threadId: "thread-1", + projectId: "project-1", + hostId: "host-1", + }), + ).resolves.toEqual([ + { + name: "PLUGIN_API_URL", + value: { serverPath: "/plugins/auth-proxy/api" }, + reason: "Route provider traffic through the plugin", + secret: true, + }, + ]); + expect(contexts).toEqual([ + { + threadId: "thread-1", + projectId: "project-1", + hostId: "host-1", + }, + ]); + }); + + it("fails a malformed resolver closed and rejects duplicate registration", async () => { + const { bb, harness } = createFakePluginHost(); + bb.providers.experimental_contributeEnv("codex", () => [ + { + name: "lowercase", + value: "hidden", + reason: "invalid name", + secret: false, + }, + ]); + expect(() => + bb.providers.experimental_contributeEnv("codex", () => []), + ).toThrow("already registered"); + + await expect( + harness.behavior.resolveProviderEnv("codex", { + threadId: "thread-1", + projectId: "project-1", + hostId: "host-1", + }), + ).resolves.toEqual([]); + expect(harness.inspection.logEntries.at(-1)).toMatchObject({ + level: "warn", + }); + expect(JSON.stringify(harness.inspection.logEntries)).not.toContain( + "hidden", + ); + }); +}); + describe("experimental_aiServices.register", () => { const declaration = { id: "acme-ai", diff --git a/packages/plugin-sdk/src/testing/fake-plugin-host.ts b/packages/plugin-sdk/src/testing/fake-plugin-host.ts index a4882945a0..caf966b5f0 100644 --- a/packages/plugin-sdk/src/testing/fake-plugin-host.ts +++ b/packages/plugin-sdk/src/testing/fake-plugin-host.ts @@ -42,6 +42,7 @@ import { undeclaredIconProblem, validatePluginAiServiceDeclaration, validatePluginProviderDeclaration, + validatePluginProviderEnvEntries, validateSettingsUpdate, zodSchemaToJsonSchema, type NormalizedPluginProviderDeclaration, @@ -79,6 +80,8 @@ import type { PluginAiServiceDeclaration, PluginAiServices, PluginProviderDeclaration, + PluginProviderEnvContext, + PluginProviderEnvEntry, PluginProviders, PluginRealtime, PluginRpc, @@ -258,6 +261,14 @@ export interface FakePluginRegistrations { /** Live provider registrations from `bb.providers.register` * (normalized declarations, registration order; dispose removes). */ providerRegistrations: NormalizedPluginProviderDeclaration[]; + providerEnvResolvers: ReadonlyMap< + string, + ( + context: PluginProviderEnvContext, + ) => + | Promise + | readonly PluginProviderEnvEntry[] + >; /** Live AI-service registrations from `experimental_aiServices.register` * (normalized declarations, registration order; dispose removes). */ aiServiceRegistrations: PluginAiServiceDeclaration[]; @@ -378,6 +389,10 @@ export interface FakePluginBehaviorDrivers { skills: string[]; instructions: string | null; }>; + resolveProviderEnv( + providerId: string, + context: PluginProviderEnvContext, + ): Promise; } /** Reload/shutdown controls, kept separate from behavior and inspection. */ @@ -1359,6 +1374,14 @@ function createFakePluginHostInternal( // --- agents --- const agentTools: FakeAgentToolRecord[] = []; const providerRegistrations: NormalizedPluginProviderDeclaration[] = []; + const providerEnvResolvers = new Map< + string, + ( + context: PluginProviderEnvContext, + ) => + | readonly PluginProviderEnvEntry[] + | Promise + >(); let agentConfigurationProvider: | ((context: PluginAgentConfigurationContext) => PluginAgentConfiguration) | null = null; @@ -1941,6 +1964,25 @@ function createFakePluginHostInternal( register(declaration) { return registerProviderDeclaration(declaration); }, + experimental_contributeEnv(providerId, resolve) { + assertLive(); + if (typeof providerId !== "string" || providerId.trim().length === 0) { + throw new Error( + "provider environment contribution requires a provider id", + ); + } + if (providerEnvResolvers.has(providerId)) { + throw new Error( + `provider environment contribution for "${providerId}" is already registered`, + ); + } + if (typeof resolve !== "function") { + throw new Error( + "provider environment contribution requires a resolver function", + ); + } + providerEnvResolvers.set(providerId, resolve); + }, }; const experimental_hooks: PluginHooks = { @@ -2081,6 +2123,7 @@ function createFakePluginHostInternal( }, mentionProviders, providerRegistrations, + providerEnvResolvers, aiServiceRegistrations, }, get pendingInteractions() { @@ -2098,6 +2141,20 @@ function createFakePluginHostInternal( await handler({ hostId }); } }, + async resolveProviderEnv(providerId, context) { + assertLive(); + const resolve = providerEnvResolvers.get(providerId); + if (resolve === undefined) return []; + try { + return validatePluginProviderEnvEntries(await resolve(context)); + } catch (error) { + emitLog( + "warn", + `provider environment contribution failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + } + }, async experimental_emitHostSignal(hostId, signal, payload) { assertLive(); if (hostId.trim().length === 0) { diff --git a/packages/provider-bridge-acp/src/session-params.test.ts b/packages/provider-bridge-acp/src/session-params.test.ts index 03e4625a76..d0c2c667b9 100644 --- a/packages/provider-bridge-acp/src/session-params.test.ts +++ b/packages/provider-bridge-acp/src/session-params.test.ts @@ -150,7 +150,10 @@ describe("buildAcpSessionParams", () => { cwd: "/workspace", options: { ...BASE_OPTIONS, - envVars: { BB_THREAD_ID: "thread-1" }, + envVars: { + BB_THREAD_ID: "thread-1", + CUSTOM_AGENT_TOKEN: "contributed-token", + }, }, parameterizedModelPicker: false, launchSpec: launchSpecFor({ @@ -172,7 +175,7 @@ describe("buildAcpSessionParams", () => { cwd: "/agent-home", agent: { command: "custom-agent", args: ["serve"] }, envVars: { - CUSTOM_AGENT_TOKEN: "token", + CUSTOM_AGENT_TOKEN: "contributed-token", BB_THREAD_ID: "thread-1", }, workspaceWriteRoots: ["/agent-home", "/extra-root"], diff --git a/packages/thread-view/src/build-thread-timeline.ts b/packages/thread-view/src/build-thread-timeline.ts index 28b703a941..527dd05926 100644 --- a/packages/thread-view/src/build-thread-timeline.ts +++ b/packages/thread-view/src/build-thread-timeline.ts @@ -221,6 +221,8 @@ function operationKindForMessage( case "warning": case "deprecation": return message.opType; + case "provider-environment": + return "generic"; case "operation": return parentChange !== null ? "parent-change" : "generic"; default: diff --git a/packages/thread-view/src/event-decode.ts b/packages/thread-view/src/event-decode.ts index 2ee88b619a..c5c1ed37b2 100644 --- a/packages/thread-view/src/event-decode.ts +++ b/packages/thread-view/src/event-decode.ts @@ -40,6 +40,7 @@ export function getEventProviderThreadId( case "provider/warning": case "provider/modelFallback": case "provider/rateLimits/updated": + case "provider.env-resolved": case "thread/extensionState/updated": case "provider/unhandled": return decoded.providerThreadId; @@ -104,6 +105,7 @@ export function getEventParentToolCallId( case "provider/warning": case "provider/modelFallback": case "provider/rateLimits/updated": + case "provider.env-resolved": case "thread/extensionState/updated": case "client/thread/start": case "client/turn/requested": diff --git a/packages/thread-view/src/event-projection-message.ts b/packages/thread-view/src/event-projection-message.ts index 182f756586..cc00da5cd9 100644 --- a/packages/thread-view/src/event-projection-message.ts +++ b/packages/thread-view/src/event-projection-message.ts @@ -285,6 +285,7 @@ export interface EventProjectionFileEditMessage const eventProjectionOperationTypeValues = [ "provider-unhandled", + "provider-environment", "warning", "deprecation", "thread-interrupted", diff --git a/packages/thread-view/src/parse-operation-message.ts b/packages/thread-view/src/parse-operation-message.ts index 2a296f7adc..11c204dde7 100644 --- a/packages/thread-view/src/parse-operation-message.ts +++ b/packages/thread-view/src/parse-operation-message.ts @@ -469,6 +469,23 @@ export function parseOperationMessage( }); } + if (decoded.type === "provider.env-resolved") { + const detail = decoded.entries + .map((entry) => { + const source = entry.source === "shell" ? "shell" : entry.source.plugin; + const value = typeof entry.value === "string" ? entry.value : "••••••"; + const reason = entry.reason ? ` — ${entry.reason}` : ""; + return `${entry.name}=${value} (${source})${reason}`; + }) + .join("\n"); + return op(decoded, meta, "provider-environment", { + opType: "provider-environment", + title: "Provider environment resolved", + detail: detail || undefined, + status: "completed", + }); + } + if (decoded.type === "provider/warning") { const category = decoded.category; const isDeprecation = category === "deprecation"; diff --git a/packages/thread-view/test/parse-operation-message.test.ts b/packages/thread-view/test/parse-operation-message.test.ts index 635d9d0b2d..a9bb3b1899 100644 --- a/packages/thread-view/test/parse-operation-message.test.ts +++ b/packages/thread-view/test/parse-operation-message.test.ts @@ -3,6 +3,7 @@ import type { OwnershipChangeOperationAction, SystemThreadInterruptedReason, SystemThreadProvisioningStatus, + ThreadEvent, ThreadEventRow, } from "@bb/domain"; import { decodeThreadEventRow } from "../src/event-decode.js"; @@ -73,6 +74,35 @@ function ownershipTitle( } describe("parseOperationMessage operation titles", () => { + it("renders provider environment provenance without revealing masked values", () => { + const event: ThreadEvent = { + type: "provider.env-resolved", + threadId: THREAD_ID, + providerThreadId: "provider-thread-1", + scope: { kind: "thread" }, + entries: [ + { + name: "PLUGIN_TOKEN", + source: { plugin: "auth-proxy" }, + value: { masked: true }, + reason: "Authenticate provider traffic", + }, + ], + }; + const message = parseOperationMessage(event, { + id: "event-provider-env", + seq: 1, + createdAt: 1, + }); + + expect(message).toMatchObject({ + kind: "operation", + title: "Provider environment resolved", + detail: + "PLUGIN_TOKEN=•••••• (auth-proxy) — Authenticate provider traffic", + }); + }); + describe("provider-unhandled", () => { it("uses the projected provider display name for dynamic providers", () => { const row = factory().providerUnhandled({ diff --git a/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts b/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts index d313cb93f6..813d4d5f3c 100644 --- a/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts +++ b/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts @@ -5450,6 +5450,82 @@ describe("canonical model context-window hint", () => { permissionEscalation: null, }; + it("rebuilds with the same provider session when the turn environment changes", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const queries: ControlledClaudeQuery[] = []; + queryMock.mockImplementation(() => { + const query = createControlledClaudeQuery(); + queries.push(query); + return query; + }); + + try { + const threadId = "thread-env-change"; + bridge.sendRequest(1, "thread/start", { + threadId, + cwd: "/tmp/worktree", + instructionMode: "append", + options: { + ...canonicalOptions, + envVars: { PLUGIN_ACCESS_TOKEN: "first" }, + }, + }); + const startResponse = await bridge.waitForResponse(1); + const providerThreadId = getProviderThreadIdFromResult(startResponse); + + bridge.sendRequest(2, "turn/start", { + threadId, + providerThreadId, + clientRequestId: "creq_23456789ab", + input: [{ type: "text", text: "continue", mentions: [] }], + options: { + ...canonicalOptions, + envVars: { PLUGIN_ACCESS_TOKEN: "second" }, + }, + }); + await bridge.flushWork(); + + expect(queries).toHaveLength(2); + expect(queries[0]?.close).toHaveBeenCalledOnce(); + expect(getLatestQueryOptions()).toMatchObject({ + env: { PLUGIN_ACCESS_TOKEN: "second" }, + resume: providerThreadId, + }); + await expect(readNextPromptText(getLatestQueryCall())).resolves.toBe( + "continue", + ); + await bridge.waitForResponse(2); + expect( + bridge.messages.filter( + (message) => message.method === "session/replaced", + ), + ).toContainEqual( + expect.objectContaining({ + params: expect.objectContaining({ + contextLost: false, + providerThreadId, + reason: + "Execution settings changed; the Claude session was rebuilt to apply them.", + threadId, + }), + }), + ); + + bridge.sendRequest(3, "thread/stop", { + threadId, + providerThreadId, + intent: "interrupt", + activeTurnId: null, + }); + await bridge.flushWork(); + queries[1]?.finish(); + await bridge.waitForResponse(3); + } finally { + queries.forEach((query) => query.finish()); + bridge.restore(); + } + }); + it("uses Fable's Claude Code capacity through a custom API endpoint", async () => { const bridge = createBridgeJsonRpcTestHarness(handleLine); const queries: ControlledClaudeQuery[] = []; diff --git a/plugins/provider-claude-code/src/bridge/bridge.ts b/plugins/provider-claude-code/src/bridge/bridge.ts index 6b1ee6b1e0..c9dbdaf3e9 100644 --- a/plugins/provider-claude-code/src/bridge/bridge.ts +++ b/plugins/provider-claude-code/src/bridge/bridge.ts @@ -229,6 +229,7 @@ interface ThreadSession { } interface ThreadAttachment { + envSignature: string; sessionConstructionConfig: SessionConstructionConfig; sessionOptions: SdkSessionOptions; closing: boolean; @@ -1035,6 +1036,9 @@ function createThreadAttachment( args: CreateThreadAttachmentArgs, ): ThreadAttachment { const attachment: ThreadAttachment = { + envSignature: environmentSignature( + readConfigEnvOverrides(args.sessionConstructionConfig.config), + ), sessionConstructionConfig: args.sessionConstructionConfig, sessionOptions: args.sessionOptions, closing: false, @@ -1709,6 +1713,36 @@ function readConfigEnvOverrides( return parsed.success ? parsed.data : {}; } +function environmentSignature(env: Readonly>): string { + return JSON.stringify( + Object.entries(env).sort(([left], [right]) => left.localeCompare(right)), + ); +} + +function applyTurnEnvironment( + attachment: ThreadAttachment, + config: TurnStartParams["config"], +): void { + if (config === undefined) { + return; + } + const envOverrides = readConfigEnvOverrides(config); + const signature = environmentSignature(envOverrides); + if (attachment.envSignature === signature) { + return; + } + attachment.envSignature = signature; + attachment.sessionConstructionConfig = { + ...attachment.sessionConstructionConfig, + config, + }; + attachment.sessionOptions.env = buildSessionEnv(envOverrides); + if (attachment.residentSession) { + attachment.residentSession.restartBeforeNextTurnReason = + "Execution settings changed; the Claude session was rebuilt to apply them."; + } +} + function parseClaudeSuggestedPermissionUpdates( value: unknown, ): ClaudeSuggestedPermissionUpdate[] | undefined { @@ -2504,6 +2538,7 @@ async function runTurnStart( const attachment = threadAttachments.get(params.threadId); if (attachment) { + applyTurnEnvironment(attachment, params.config); applyIdleQueryReleaseSetting(attachment, params.idleQueryReleaseEnabled); applyChromeSetting(attachment, params.chromeEnabled); } diff --git a/plugins/provider-claude-code/src/session-params.ts b/plugins/provider-claude-code/src/session-params.ts index 46af842522..8153abffa4 100644 --- a/plugins/provider-claude-code/src/session-params.ts +++ b/plugins/provider-claude-code/src/session-params.ts @@ -48,10 +48,12 @@ function buildClaudeSkillConfigParams( } return { - plugins: skillRoots.map((skillRoot): ClaudeLocalPluginConfig => ({ - type: "local", - path: skillRoot.localPluginPath, - })), + plugins: skillRoots.map( + (skillRoot): ClaudeLocalPluginConfig => ({ + type: "local", + path: skillRoot.localPluginPath, + }), + ), }; } @@ -179,6 +181,7 @@ export function buildClaudeSessionParams( const providerOptions = claudeProviderOptionsSchema.parse( args.options.providerOptions ?? {}, ); + const config = buildClaudeCodeConfig(args.options.envVars); return buildInternalSessionParams({ additionalWorkspaceWriteRoots: providerOptions.additionalWorkspaceWriteRoots ?? [], @@ -227,6 +230,7 @@ export function buildClaudeTurnParams( const providerOptions = claudeProviderOptionsSchema.parse( args.options.providerOptions ?? {}, ); + const config = buildClaudeCodeConfig(args.options.envVars); return { threadId: args.threadId, providerThreadId: args.providerThreadId, @@ -246,6 +250,7 @@ export function buildClaudeTurnParams( chromeEnabled: providerOptions.chromeEnabled, memoryEnabled: providerOptions.memoryEnabled, providerSubagentsEnabled: providerOptions.providerSubagentsEnabled, + ...(config ? { config } : {}), permissionEscalation: args.options.permissionEscalation, ...(providerOptions.claudeCodePermissionMode !== undefined ? { claudeCodePermissionMode: providerOptions.claudeCodePermissionMode } diff --git a/plugins/provider-codex/src/session-params.test.ts b/plugins/provider-codex/src/session-params.test.ts index 6c4f9b7a6c..a73d645aba 100644 --- a/plugins/provider-codex/src/session-params.test.ts +++ b/plugins/provider-codex/src/session-params.test.ts @@ -632,17 +632,20 @@ describe("buildCodexConfig", () => { }); }); - it("injects the bb thread id into the shell env and drops invalid keys", () => { + it("injects contributed environment into session params and drops invalid keys", () => { const config = configFor({ ...FULL_OPTIONS, envVars: { "BAD.KEY": "ignored", + PLUGIN_API_URL: "http://127.0.0.1:3334/plugins/example/auth", TEST_VAR: "123", }, }); expect(config).toMatchObject({ "shell_environment_policy.set.BB_THREAD_ID": "bb-thread-1", + "shell_environment_policy.set.PLUGIN_API_URL": + "http://127.0.0.1:3334/plugins/example/auth", "shell_environment_policy.set.TEST_VAR": "123", }); expect(config).not.toMatchObject({ From f490bc17e5d51618d2520f90b4c3e0888c1bdb88 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 3 Sep 2026 23:28:05 +0000 Subject: [PATCH 2/5] test(providers): cover stable contributed environment --- .../src/runtime.lifecycle.test.ts | 44 ++++++++++++------- .../provider-pi/src/session-params.test.ts | 17 +++++++ 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/agent-runtime/src/runtime.lifecycle.test.ts b/packages/agent-runtime/src/runtime.lifecycle.test.ts index af274848a8..16c57a3025 100644 --- a/packages/agent-runtime/src/runtime.lifecycle.test.ts +++ b/packages/agent-runtime/src/runtime.lifecycle.test.ts @@ -238,6 +238,22 @@ describe("createAgentRuntime lifecycle", () => { const record = createScriptedEchoRequestRecord(); const events: ThreadEvent[] = []; const threadStorageRootPath = join(tmpDir, "thread-storage"); + const contributedEnv = [ + { + name: "PATH", + value: "/plugin/bin", + source: { plugin: "env-test" }, + reason: "Use the plugin toolchain", + secret: false, + }, + { + name: "AUTH_PROXY_URL", + value: { serverPath: "/plugins/env-test/auth" }, + source: { plugin: "env-test" }, + reason: "Use the authenticated server proxy", + secret: true, + }, + ] as const; const runtime = createScriptedEchoRuntime({ runtime: { workspacePath: tmpDir, @@ -259,22 +275,7 @@ describe("createAgentRuntime lifecycle", () => { threadId: "t1", projectId: "p1", providerId: "fake", - contributedEnv: [ - { - name: "PATH", - value: "/plugin/bin", - source: { plugin: "env-test" }, - reason: "Use the plugin toolchain", - secret: false, - }, - { - name: "AUTH_PROXY_URL", - value: { serverPath: "/plugins/env-test/auth" }, - source: { plugin: "env-test" }, - reason: "Use the authenticated server proxy", - secret: true, - }, - ], + contributedEnv, options: fullRuntimeOptions, }); @@ -318,6 +319,17 @@ describe("createAgentRuntime lifecycle", () => { }); expect(JSON.stringify(events)).not.toContain("/plugins/env-test/auth"); + await runtime.runTurn({ + clientRequestId: "creq_222222224c", + threadId: "t1", + input: [promptTextInput({ text: "follow up" })], + contributedEnv, + options: fullRuntimeOptions, + }); + expect( + events.filter((event) => event.type === "provider.env-resolved"), + ).toHaveLength(1); + await runtime.shutdown(); }); diff --git a/plugins/provider-pi/src/session-params.test.ts b/plugins/provider-pi/src/session-params.test.ts index 9d13d49515..830ee11077 100644 --- a/plugins/provider-pi/src/session-params.test.ts +++ b/plugins/provider-pi/src/session-params.test.ts @@ -21,6 +21,23 @@ describe("buildPiSessionParams", () => { }); }); + it("passes contributed variables into Pi session parameters", () => { + expect( + buildPiSessionParams({ + threadId: "bb-thread-1", + cwd: "/tmp/worktree", + instructionMode: "append", + options: { + envVars: { + POOL_E2E_URL: "http://127.0.0.1:3334/plugins/pool-e2e/auth", + }, + }, + }).shellEnvOverrides, + ).toMatchObject({ + POOL_E2E_URL: "http://127.0.0.1:3334/plugins/pool-e2e/auth", + }); + }); + it("maps the bb reasoning ladder onto Pi thinking levels", () => { const params = (reasoningLevel: "none" | "high" | "ultracode") => buildPiSessionParams({ From 0e31b7d6f0e95b8bbcc503debb4b8560a60cc50e Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 3 Sep 2026 23:52:02 +0000 Subject: [PATCH 3/5] fix(claude): surface environment rebuild note --- .../src/bridge-protocol-adapter.test.ts | 21 +++++++++++++++++++ .../src/bridge-protocol-adapter.ts | 15 ++++++++++++- .../src/notifications.ts | 1 + .../src/bridge/__tests__/bridge.test.ts | 1 + .../provider-claude-code/src/bridge/bridge.ts | 5 +++++ 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/agent-runtime/src/bridge-protocol-adapter.test.ts b/packages/agent-runtime/src/bridge-protocol-adapter.test.ts index c12f4c07eb..a515873b59 100644 --- a/packages/agent-runtime/src/bridge-protocol-adapter.test.ts +++ b/packages/agent-runtime/src/bridge-protocol-adapter.test.ts @@ -389,6 +389,27 @@ describe("translateEvent", () => { }), ).toStrictEqual([]); + expect( + adapter.translateEvent({ + jsonrpc: "2.0", + method: "session/replaced", + params: { + threadId: "thr_1", + providerThreadId: "p_2", + reason: + "Execution settings changed; the Claude session was rebuilt to apply them.", + contextLost: false, + showRuntimeNote: true, + }, + }), + ).toMatchObject([ + { + type: "provider/warning", + summary: + "Execution settings changed; the Claude session was rebuilt to apply them.", + }, + ]); + const events = adapter.translateEvent({ jsonrpc: "2.0", method: "session/replaced", diff --git a/packages/agent-runtime/src/bridge-protocol-adapter.ts b/packages/agent-runtime/src/bridge-protocol-adapter.ts index 88964a6e05..0574ee8b0c 100644 --- a/packages/agent-runtime/src/bridge-protocol-adapter.ts +++ b/packages/agent-runtime/src/bridge-protocol-adapter.ts @@ -104,6 +104,7 @@ const sessionReplacedNotificationParamsSchema = z providerThreadId: z.string().min(1).nullable(), reason: z.string().min(1), contextLost: z.boolean().default(false), + showRuntimeNote: z.boolean().default(false), }) .passthrough(); @@ -544,10 +545,22 @@ export function createBridgeProtocolAdapter( if ( !parsed.success || parsed.data.providerThreadId === null || - !parsed.data.contextLost + (!parsed.data.contextLost && !parsed.data.showRuntimeNote) ) { return []; } + if (!parsed.data.contextLost) { + return [ + { + type: "provider/warning", + threadId: parsed.data.threadId, + providerThreadId: parsed.data.providerThreadId, + category: "general", + summary: parsed.data.reason, + scope: { kind: "thread" }, + }, + ]; + } return [ { type: "provider/warning", diff --git a/packages/provider-bridge-protocol/src/notifications.ts b/packages/provider-bridge-protocol/src/notifications.ts index 5ca4ff66c3..0f3b9d9c42 100644 --- a/packages/provider-bridge-protocol/src/notifications.ts +++ b/packages/provider-bridge-protocol/src/notifications.ts @@ -23,6 +23,7 @@ export const sessionReplacedNotificationSchema = z providerThreadId: z.string().min(1).nullable(), reason: z.string().min(1), contextLost: z.boolean().default(false), + showRuntimeNote: z.boolean().default(false), }) .passthrough(); diff --git a/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts b/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts index 813d4d5f3c..b6833e845c 100644 --- a/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts +++ b/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts @@ -5506,6 +5506,7 @@ describe("canonical model context-window hint", () => { providerThreadId, reason: "Execution settings changed; the Claude session was rebuilt to apply them.", + showRuntimeNote: true, threadId, }), }), diff --git a/plugins/provider-claude-code/src/bridge/bridge.ts b/plugins/provider-claude-code/src/bridge/bridge.ts index c9dbdaf3e9..c0f39fd829 100644 --- a/plugins/provider-claude-code/src/bridge/bridge.ts +++ b/plugins/provider-claude-code/src/bridge/bridge.ts @@ -906,6 +906,7 @@ function emitSessionReplacement(args: { contextLost: boolean; providerThreadId: string | null; reason: string; + showRuntimeNote?: boolean; threadId: string; threadSession: ThreadSession; }): void { @@ -921,6 +922,7 @@ function emitSessionReplacement(args: { providerThreadId: args.providerThreadId, reason: args.reason, contextLost: args.contextLost, + showRuntimeNote: args.showRuntimeNote ?? false, }, }); } @@ -1435,6 +1437,9 @@ function replaceThreadSession(args: ReplaceThreadSessionArgs): ThreadSession { contextLost: false, providerThreadId: args.providerThreadId, reason: args.reason, + showRuntimeNote: + args.reason === + "Execution settings changed; the Claude session was rebuilt to apply them.", threadId: args.threadId, threadSession: args.threadSession, }); From 0809b2a2ba428f4fd8ea042b19e7dfbfb25bf9cb Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 4 Sep 2026 00:18:20 +0000 Subject: [PATCH 4/5] fix(providers): handle unavailable server env base --- .../src/runtime.lifecycle.test.ts | 65 ++++++++++++++++ packages/agent-runtime/src/runtime.ts | 20 +++++ .../src/thread-shell-environment.ts | 23 +++++- .../provider-claude-code/src/bridge/bridge.ts | 76 ++++++++++++------- 4 files changed, 152 insertions(+), 32 deletions(-) diff --git a/packages/agent-runtime/src/runtime.lifecycle.test.ts b/packages/agent-runtime/src/runtime.lifecycle.test.ts index 16c57a3025..afeac5b6a8 100644 --- a/packages/agent-runtime/src/runtime.lifecycle.test.ts +++ b/packages/agent-runtime/src/runtime.lifecycle.test.ts @@ -333,6 +333,71 @@ describe("createAgentRuntime lifecycle", () => { await runtime.shutdown(); }); + it("drops unresolved server paths without preventing thread start", async () => { + const record = createScriptedEchoRequestRecord(); + const events: ThreadEvent[] = []; + const runtime = createScriptedEchoRuntime({ + runtime: { + workspacePath: tmpDir, + env: record.env, + shellEnv: { PATH: "/usr/bin" }, + onEvent: (event) => events.push(event), + }, + }); + + await runtime.startThread({ + environmentId: "env-1", + threadId: "t1", + projectId: "p1", + providerId: "fake", + contributedEnv: [ + { + name: "AUTH_PROXY_URL", + value: { serverPath: "/plugins/env-test/auth" }, + source: { plugin: "env-test" }, + reason: "Use the authenticated server proxy", + secret: true, + }, + ], + options: fullRuntimeOptions, + }); + + const threadStart = record.last("thread/start"); + expect(threadStart).toBeDefined(); + expect(threadStart?.params).toEqual( + expect.objectContaining({ + options: expect.objectContaining({ + envVars: expect.not.objectContaining({ + AUTH_PROXY_URL: expect.anything(), + }), + }), + }), + ); + expect( + events.find((event) => event.type === "provider.env-resolved"), + ).toMatchObject({ + entries: expect.arrayContaining([ + { + name: "AUTH_PROXY_URL", + source: { plugin: "env-test" }, + value: { masked: true }, + reason: + "Use the authenticated server proxy (dropped: no BB_SERVER_URL)", + }, + ]), + }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider/warning", + category: "config", + summary: + 'Dropped environment variable "AUTH_PROXY_URL" from plugin "env-test".', + }), + ); + + await runtime.shutdown(); + }); + it("does not configure provider skills unless skill roots are supplied", async () => { const record = createScriptedEchoRequestRecord(); const runtime = createScriptedEchoRuntime({ diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 39126f871e..3d64e4d7c8 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -62,6 +62,7 @@ import type { } from "./types.js"; import { resolveThreadEnvironment, + type DroppedThreadEnvironmentContribution, type ResolvedThreadEnvironmentEntry, } from "./thread-shell-environment.js"; import { bridgeLaunchProcessKey } from "./bridge-launch-process-key.js"; @@ -1129,6 +1130,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { } function emitResolvedProviderEnvironment(args: { + droppedContributions: DroppedThreadEnvironmentContribution[]; entries: ResolvedThreadEnvironmentEntry[]; providerThreadId: string; threadId: string; @@ -1140,6 +1142,18 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { entries: args.entries, scope: { kind: "thread" }, }); + for (const contribution of args.droppedContributions) { + options.onEvent({ + type: "provider/warning", + threadId: args.threadId, + providerThreadId: args.providerThreadId, + category: "config", + summary: `Dropped environment variable "${contribution.name}" from plugin "${contribution.plugin}".`, + details: + "BB_SERVER_URL is unavailable, so its serverPath contribution was not applied.", + scope: { kind: "thread" }, + }); + } } function resolveRuntimeThreadEnvironment(args: { @@ -1148,6 +1162,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { projectId?: string; threadId: string; }): { + droppedContributions: DroppedThreadEnvironmentContribution[]; envVars: Record; entries: ResolvedThreadEnvironmentEntry[]; } { @@ -1572,6 +1587,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { ); resolved = result.providerThreadId; emitResolvedProviderEnvironment({ + droppedContributions: resolvedEnvironment.droppedContributions, entries: resolvedEnvironment.entries, providerThreadId: resolved, threadId, @@ -1857,6 +1873,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { const plan = proc.adapter.buildCommandPlan(adapterCommand); if (plan.kind === "noop") { emitResolvedProviderEnvironment({ + droppedContributions: resolvedEnvironment.droppedContributions, entries: resolvedEnvironment.entries, providerThreadId: adapterCommand.providerThreadId, threadId, @@ -1884,6 +1901,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { updateSessionRestoreCapability(threadId, result.sessionRestorable); resolved = result.providerThreadId; emitResolvedProviderEnvironment({ + droppedContributions: resolvedEnvironment.droppedContributions, entries: resolvedEnvironment.entries, providerThreadId: resolved, threadId, @@ -1989,6 +2007,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { }); if (environmentChanged) { emitResolvedProviderEnvironment({ + droppedContributions: resolvedEnvironment.droppedContributions, entries: resolvedEnvironment.entries, providerThreadId, threadId, @@ -2101,6 +2120,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { }); if (environmentChanged) { emitResolvedProviderEnvironment({ + droppedContributions: resolvedEnvironment.droppedContributions, entries: resolvedEnvironment.entries, providerThreadId, threadId, diff --git a/packages/agent-runtime/src/thread-shell-environment.ts b/packages/agent-runtime/src/thread-shell-environment.ts index 4263792017..c6aedd5b41 100644 --- a/packages/agent-runtime/src/thread-shell-environment.ts +++ b/packages/agent-runtime/src/thread-shell-environment.ts @@ -35,16 +35,23 @@ export interface ResolvedThreadEnvironmentEntry { reason?: string; } +export interface DroppedThreadEnvironmentContribution { + name: string; + plugin: string; +} + interface ResolveThreadEnvironmentArgs extends ThreadShellEnvironmentArgs { baseShellEnv: AgentRuntimeShellEnvironment | undefined; contributedEnv: readonly AgentRuntimeContributedEnvEntry[]; } export function resolveThreadEnvironment(args: ResolveThreadEnvironmentArgs): { + droppedContributions: DroppedThreadEnvironmentContribution[]; envVars: Record; entries: ResolvedThreadEnvironmentEntry[]; } { const envVars = buildThreadShellEnvironment(args); + const droppedContributions: DroppedThreadEnvironmentContribution[] = []; const entries: ResolvedThreadEnvironmentEntry[] = Object.entries(envVars).map( ([name, value]) => ({ name, source: "shell", value }), ); @@ -55,9 +62,17 @@ export function resolveThreadEnvironment(args: ResolveThreadEnvironmentArgs): { } else { const serverUrl = args.baseShellEnv?.BB_SERVER_URL; if (serverUrl === undefined) { - throw new Error( - `Cannot resolve serverPath environment contribution ${contribution.name} without BB_SERVER_URL`, - ); + entries.push({ + name: contribution.name, + source: contribution.source, + value: { masked: true }, + reason: `${contribution.reason} (dropped: no BB_SERVER_URL)`, + }); + droppedContributions.push({ + name: contribution.name, + plugin: contribution.source.plugin, + }); + continue; } value = `${serverUrl}${contribution.value.serverPath}`; } @@ -73,5 +88,5 @@ export function resolveThreadEnvironment(args: ResolveThreadEnvironmentArgs): { reason: contribution.reason, }); } - return { envVars, entries }; + return { droppedContributions, envVars, entries }; } diff --git a/plugins/provider-claude-code/src/bridge/bridge.ts b/plugins/provider-claude-code/src/bridge/bridge.ts index c0f39fd829..1aba4d49cd 100644 --- a/plugins/provider-claude-code/src/bridge/bridge.ts +++ b/plugins/provider-claude-code/src/bridge/bridge.ts @@ -206,6 +206,11 @@ type ClaudeSdkSessionState = Extract< { type: "system"; subtype: "session_state_changed" } >["state"]; +interface ClaudeSessionRestart { + reason: string; + showRuntimeNote: boolean; +} + interface ThreadSession { session: SdkSession; attachment: ThreadAttachment; @@ -213,7 +218,7 @@ interface ThreadSession { closing: boolean; pendingForwardedToolCalls: number; pendingSessionCronIds: Set; - restartBeforeNextTurnReason: string | null; + restartBeforeNextTurn: ClaudeSessionRestart | null; recoveryHintRaisedThisTurn: "authRequired" | "rateLimited" | null; sdkSessionState: ClaudeSdkSessionState | undefined; streamEnded: boolean; @@ -297,14 +302,14 @@ type SessionConstructionParams = interface ReplaceThreadSessionArgs { attachment: ThreadAttachment; providerThreadId: string; - reason: string; + restart: ClaudeSessionRestart; threadId: string; threadSession: ThreadSession; } interface ReplaceThreadSessionBeforeNextTurnArgs { attachment: ThreadAttachment; - reason: string; + restart: ClaudeSessionRestart; threadId: string; threadSession: ThreadSession; } @@ -512,8 +517,10 @@ function applyChromeSetting( delete attachment.sessionOptions.extraArgs; } if (attachment.residentSession) { - attachment.residentSession.restartBeforeNextTurnReason = - CLAUDE_CHROME_SETTING_RESTART_REASON; + attachment.residentSession.restartBeforeNextTurn = { + reason: CLAUDE_CHROME_SETTING_RESTART_REASON, + showRuntimeNote: false, + }; } } @@ -1095,7 +1102,7 @@ function createThreadSession(attachment: ThreadAttachment): ThreadSession { closing: false, pendingForwardedToolCalls: 0, pendingSessionCronIds: new Set(), - restartBeforeNextTurnReason: null, + restartBeforeNextTurn: null, recoveryHintRaisedThisTurn: null, sdkSessionState: undefined, streamEnded: false, @@ -1432,14 +1439,12 @@ function buildTrackedSessionOptions( function replaceThreadSession(args: ReplaceThreadSessionArgs): ThreadSession { args.threadSession.closing = true; - resolvePendingSessionWork(args.threadSession, args.reason); + resolvePendingSessionWork(args.threadSession, args.restart.reason); emitSessionReplacement({ contextLost: false, providerThreadId: args.providerThreadId, - reason: args.reason, - showRuntimeNote: - args.reason === - "Execution settings changed; the Claude session was rebuilt to apply them.", + reason: args.restart.reason, + showRuntimeNote: args.restart.showRuntimeNote, threadId: args.threadId, threadSession: args.threadSession, }); @@ -1467,7 +1472,7 @@ function replaceThreadSessionBeforeNextTurn( return replaceThreadSession({ attachment: args.attachment, providerThreadId, - reason: args.reason, + restart: args.restart, threadId: args.threadId, threadSession: args.threadSession, }); @@ -1489,14 +1494,20 @@ async function getWritableThreadSession( } const threadSession = attachment.residentSession; - const replacementReason = !threadSession - ? "Claude query resumed after idle release" + const replacement: ClaudeSessionRestart | null = !threadSession + ? { + reason: "Claude query resumed after idle release", + showRuntimeNote: false, + } : threadSession.streamEnded - ? "Thread session replaced after Claude SDK stream ended" + ? { + reason: "Thread session replaced after Claude SDK stream ended", + showRuntimeNote: false, + } : intent === "new-turn" - ? threadSession.restartBeforeNextTurnReason + ? threadSession.restartBeforeNextTurn : null; - if (threadSession && replacementReason === null) { + if (threadSession && replacement === null) { return threadSession; } @@ -1511,16 +1522,20 @@ async function getWritableThreadSession( const currentSession = attachment.residentSession; if (currentSession) { - const currentReason = currentSession.streamEnded - ? "Thread session replaced after Claude SDK stream ended" - : intent === "new-turn" - ? currentSession.restartBeforeNextTurnReason - : null; - return currentReason === null + const currentRestart: ClaudeSessionRestart | null = + currentSession.streamEnded + ? { + reason: "Thread session replaced after Claude SDK stream ended", + showRuntimeNote: false, + } + : intent === "new-turn" + ? currentSession.restartBeforeNextTurn + : null; + return currentRestart === null ? currentSession : replaceThreadSessionBeforeNextTurn({ attachment, - reason: currentReason, + restart: currentRestart, threadId, threadSession: currentSession, }); @@ -1607,8 +1622,10 @@ function createOnSdkMessage( const authenticationFailureRestartReason = getAuthenticationFailureRestartReason(message); if (authenticationFailureRestartReason !== null) { - threadSession.restartBeforeNextTurnReason = - authenticationFailureRestartReason; + threadSession.restartBeforeNextTurn = { + reason: authenticationFailureRestartReason, + showRuntimeNote: false, + }; } trackSdkAssistantPermissionEscalation(threadSession, message); if ( @@ -1743,8 +1760,11 @@ function applyTurnEnvironment( }; attachment.sessionOptions.env = buildSessionEnv(envOverrides); if (attachment.residentSession) { - attachment.residentSession.restartBeforeNextTurnReason = - "Execution settings changed; the Claude session was rebuilt to apply them."; + attachment.residentSession.restartBeforeNextTurn = { + reason: + "Execution settings changed; the Claude session was rebuilt to apply them.", + showRuntimeNote: true, + }; } } From 969ab39ae8c3a7f1bd900d3609d27d9f1066f41b Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 4 Sep 2026 02:25:26 +0000 Subject: [PATCH 5/5] fix(plugin-sdk): mark provider env types experimental --- .../plugins/plugin-agent-contributions.ts | 4 ++-- .../server/src/services/plugins/plugin-api.ts | 10 +++++----- .../src/services/plugins/plugin-service.ts | 4 ++-- .../references/backend-api-index.md | 4 ++-- .../references/providers.md | 3 ++- docs/api_to_audit.md | 6 +++++- packages/plugin-api-map/src/surfaces.ts | 4 ++-- .../src/__tests__/public-types.test.ts | 4 ++-- packages/plugin-sdk/src/backend-contract.ts | 10 +++++----- .../plugin-sdk/src/internal/host-policy.ts | 4 ++-- .../src/testing/fake-plugin-host.ts | 20 +++++++++---------- 11 files changed, 39 insertions(+), 34 deletions(-) diff --git a/apps/server/src/services/plugins/plugin-agent-contributions.ts b/apps/server/src/services/plugins/plugin-agent-contributions.ts index f2551cdc93..7c4c589f12 100644 --- a/apps/server/src/services/plugins/plugin-agent-contributions.ts +++ b/apps/server/src/services/plugins/plugin-agent-contributions.ts @@ -1,6 +1,6 @@ import type { ToolCallResponse } from "@bb/domain"; import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; -import type { PluginProviderEnvContext } from "@get-bb/plugin-sdk"; +import type { ExperimentalPluginProviderEnvContext } from "@get-bb/plugin-sdk"; import type { PluginAgentConfigurationContext, PluginAgentToolContext, @@ -66,7 +66,7 @@ export function listPluginInstructionContributions(): Array<{ export async function resolvePluginProviderEnv(args: { providerId: string; - context: PluginProviderEnvContext; + context: ExperimentalPluginProviderEnvContext; }): Promise { const active = contributions; if (!active?.resolveProviderEnv) return []; diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts index bd1517abba..52028a049c 100644 --- a/apps/server/src/services/plugins/plugin-api.ts +++ b/apps/server/src/services/plugins/plugin-api.ts @@ -44,8 +44,8 @@ import type { PluginAiServiceDeclaration, PluginAiServices, PluginProviderDeclaration, - PluginProviderEnvContext, - PluginProviderEnvEntry, + ExperimentalPluginProviderEnvContext, + ExperimentalPluginProviderEnvEntry, PluginProviders, PluginRealtime, PluginRpc, @@ -274,10 +274,10 @@ type PluginAgentConfigurationProvider = ( ) => PluginAgentConfiguration; export type PluginProviderEnvResolver = ( - context: PluginProviderEnvContext, + context: ExperimentalPluginProviderEnvContext, ) => - | readonly PluginProviderEnvEntry[] - | Promise; + | readonly ExperimentalPluginProviderEnvEntry[] + | Promise; function wrapSdkForPlugin(sdk: BbSdk, pluginId: string): BbSdk { return { diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index 0680c02f6f..54915fcf1f 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -20,7 +20,7 @@ import { } from "@bb/domain"; import { type PluginCliExecutionResult, - type PluginProviderEnvContext, + type ExperimentalPluginProviderEnvContext, type PluginRpcError, type PluginRpcValidationIssue, type StandardSchemaV1, @@ -311,7 +311,7 @@ export interface PluginService { }): Promise; resolveProviderEnv(args: { providerId: string; - context: PluginProviderEnvContext; + context: ExperimentalPluginProviderEnvContext; }): Promise; listInstructionContributions(): PluginInstructionContribution[]; findAgentTool( diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md index 234e739c19..20a45f541b 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md @@ -149,8 +149,8 @@ Read the installed declarations for exact current signatures. - `PluginProviderCapabilities` - `PluginProviderComposerAction` - `PluginProviderDeclaration` -- `PluginProviderEnvContext` -- `PluginProviderEnvEntry` +- `ExperimentalPluginProviderEnvContext` +- `ExperimentalPluginProviderEnvEntry` - `PluginProviderExtensionKindDeclaration` - `PluginProviderFallbackModel` - `PluginProviderIconRegistration` diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/providers.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/providers.md index 22fe21cf71..47dc984984 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/providers.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/providers.md @@ -161,7 +161,8 @@ bb.providers.experimental_contributeEnv("claude-code", async (context) => [ ``` The server calls the resolver for every matching start, resume, fork, and turn -command with `threadId`, `projectId`, and `hostId`. Return at most 32 entries. +command. Its `ExperimentalPluginProviderEnvContext` has `threadId`, `projectId`, +and `hostId`; return at most 32 `ExperimentalPluginProviderEnvEntry` values. Names must match `[A-Z_][A-Z0-9_]*`; `reason` and `secret` are required. A literal `value` is forwarded as-is. `{ serverPath: "/..." }` is expanded by the selected host against its authenticated `BB_SERVER_URL`, which is the diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index f12d38c738..4690ff692d 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -8,7 +8,9 @@ ids, validates at most 32 environment entries, resolves registration conflicts in plugin load order, and sends the winning values to the host. A value may be a literal string or a server-relative path that the host expands against its authenticated `BB_SERVER_URL`. Contributions override the shell environment; -entries marked `secret` are masked in provider environment events. +entries marked `secret` are masked in provider environment events. The resolver +receives `ExperimentalPluginProviderEnvContext` and returns +`ExperimentalPluginProviderEnvEntry` values. **Audit before stabilizing.** @@ -21,6 +23,8 @@ entries marked `secret` are masked in provider environment events. server endpoints before accepting more shapes. 5. Confirm `reason` should remain required and whether event consumers need a stable machine-readable purpose beside it. +6. Decide whether the context and entry types should stabilize with the method + or remain experimental for a longer compatibility window. Every public plugin API member ships with an `experimental_` prefix and an entry here (see [AGENTS.md](../AGENTS.md), "Plugin API"). Dropping the prefix diff --git a/packages/plugin-api-map/src/surfaces.ts b/packages/plugin-api-map/src/surfaces.ts index 195d9919d0..621fe08fb5 100644 --- a/packages/plugin-api-map/src/surfaces.ts +++ b/packages/plugin-api-map/src/surfaces.ts @@ -392,8 +392,8 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ apiSymbols: [ "PluginProviderDeclaration", "PluginProviderIconRegistration", - "PluginProviderEnvContext", - "PluginProviderEnvEntry", + "ExperimentalPluginProviderEnvContext", + "ExperimentalPluginProviderEnvEntry", ], firstParty: [ "ACP providers", diff --git a/packages/plugin-sdk/src/__tests__/public-types.test.ts b/packages/plugin-sdk/src/__tests__/public-types.test.ts index 41465b4f82..171d890e05 100644 --- a/packages/plugin-sdk/src/__tests__/public-types.test.ts +++ b/packages/plugin-sdk/src/__tests__/public-types.test.ts @@ -74,8 +74,8 @@ const EXPECTED_BACKEND_ROOT_TYPE_EXPORTS = [ "PluginProviderCapabilities", "PluginProviderComposerAction", "PluginProviderDeclaration", - "PluginProviderEnvContext", - "PluginProviderEnvEntry", + "ExperimentalPluginProviderEnvContext", + "ExperimentalPluginProviderEnvEntry", "PluginProviderExtensionKindDeclaration", "PluginProviderFallbackModel", "PluginProviderMaintenance", diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts index af82cfda77..892eb6b0ec 100644 --- a/packages/plugin-sdk/src/backend-contract.ts +++ b/packages/plugin-sdk/src/backend-contract.ts @@ -1341,20 +1341,20 @@ export interface PluginProviders { experimental_contributeEnv( providerId: string, resolve: ( - context: PluginProviderEnvContext, + context: ExperimentalPluginProviderEnvContext, ) => - | readonly PluginProviderEnvEntry[] - | Promise, + | readonly ExperimentalPluginProviderEnvEntry[] + | Promise, ): void; } -export interface PluginProviderEnvContext { +export interface ExperimentalPluginProviderEnvContext { threadId: string; projectId: string; hostId: string; } -export interface PluginProviderEnvEntry { +export interface ExperimentalPluginProviderEnvEntry { name: string; value: string | { serverPath: string }; reason: string; diff --git a/packages/plugin-sdk/src/internal/host-policy.ts b/packages/plugin-sdk/src/internal/host-policy.ts index d38e567db0..7948b1922a 100644 --- a/packages/plugin-sdk/src/internal/host-policy.ts +++ b/packages/plugin-sdk/src/internal/host-policy.ts @@ -25,7 +25,7 @@ import type { PluginProviderCapabilities, PluginProviderComposerAction, PluginProviderDeclaration, - PluginProviderEnvEntry, + ExperimentalPluginProviderEnvEntry, PluginProviderExtensionKindDeclaration, PluginProviderFallbackModel, PluginProviderModelCatalogScope, @@ -129,7 +129,7 @@ const pluginProviderEnvEntriesSchema = z export function validatePluginProviderEnvEntries( value: unknown, -): PluginProviderEnvEntry[] { +): ExperimentalPluginProviderEnvEntry[] { const parsed = pluginProviderEnvEntriesSchema.safeParse(value); if (!parsed.success) { const issue = parsed.error.issues[0]; diff --git a/packages/plugin-sdk/src/testing/fake-plugin-host.ts b/packages/plugin-sdk/src/testing/fake-plugin-host.ts index caf966b5f0..6e28aee42a 100644 --- a/packages/plugin-sdk/src/testing/fake-plugin-host.ts +++ b/packages/plugin-sdk/src/testing/fake-plugin-host.ts @@ -80,8 +80,8 @@ import type { PluginAiServiceDeclaration, PluginAiServices, PluginProviderDeclaration, - PluginProviderEnvContext, - PluginProviderEnvEntry, + ExperimentalPluginProviderEnvContext, + ExperimentalPluginProviderEnvEntry, PluginProviders, PluginRealtime, PluginRpc, @@ -264,10 +264,10 @@ export interface FakePluginRegistrations { providerEnvResolvers: ReadonlyMap< string, ( - context: PluginProviderEnvContext, + context: ExperimentalPluginProviderEnvContext, ) => - | Promise - | readonly PluginProviderEnvEntry[] + | Promise + | readonly ExperimentalPluginProviderEnvEntry[] >; /** Live AI-service registrations from `experimental_aiServices.register` * (normalized declarations, registration order; dispose removes). */ @@ -391,8 +391,8 @@ export interface FakePluginBehaviorDrivers { }>; resolveProviderEnv( providerId: string, - context: PluginProviderEnvContext, - ): Promise; + context: ExperimentalPluginProviderEnvContext, + ): Promise; } /** Reload/shutdown controls, kept separate from behavior and inspection. */ @@ -1377,10 +1377,10 @@ function createFakePluginHostInternal( const providerEnvResolvers = new Map< string, ( - context: PluginProviderEnvContext, + context: ExperimentalPluginProviderEnvContext, ) => - | readonly PluginProviderEnvEntry[] - | Promise + | readonly ExperimentalPluginProviderEnvEntry[] + | Promise >(); let agentConfigurationProvider: | ((context: PluginAgentConfigurationContext) => PluginAgentConfiguration)