diff --git a/packages/docs/src/content/docs/extend/index.md b/packages/docs/src/content/docs/extend/index.md index bdd0a2d9..6f15bc45 100644 --- a/packages/docs/src/content/docs/extend/index.md +++ b/packages/docs/src/content/docs/extend/index.md @@ -113,12 +113,15 @@ config-keys: - org - project +api-domains: + - api.example.com +api-headers: + X-Api-Version: "2026-01-01" + credentials: type: oauth-bearer api-domains: - api.example.com - api-headers: - X-Api-Version: "2026-01-01" auth-token-env: EXAMPLE_AUTH_TOKEN auth-token-placeholder: host_managed_credential @@ -153,13 +156,14 @@ runtime-postinstall: - `description`: short summary of what the plugin integrates - `capabilities`: actions the plugin’s skills may request, qualified as `.` - `config-keys`: provider-specific configuration keys, qualified as `.` -- `credentials`: how auth is delivered to tools; current types are `oauth-bearer` and `github-app` +- `api-domains` and `api-headers`: optional host-managed HTTP headers injected for matching sandbox requests +- `credentials`: how token auth is delivered to tools; current types are `oauth-bearer` and `github-app` - `oauth`: user OAuth setup; use it with `credentials.type: oauth-bearer` - `target`: optional credential target scope tied to a declared config key - `runtime-dependencies`: sandbox dependencies required by the plugin’s tools - `runtime-postinstall`: commands that run after dependency install and before snapshot capture - `mcp`: optional MCP server configuration for provider-scoped tool sources; `mcp.url` implies hosted HTTP transport, so `mcp.transport: http` is optional -- `env-vars`: optional map of deployment env vars the manifest may reference from `mcp.url`. Each key names an env var (uppercase, `[A-Z_][A-Z0-9_]*`) and may declare a `default` used when the env var is unset; see [Env-var expansion in `mcp.url`](#env-var-expansion-in-mcpurl). +- `env-vars`: optional map of deployment env vars the manifest may reference from `mcp.url` or `api-headers`. Each key names an env var (uppercase, `[A-Z_][A-Z0-9_]*`) and may declare a `default` for `mcp.url`; API header references cannot use defaults. - `mcp.url`: supports `${VAR}` placeholders that must be declared in `env-vars`. This lets region-pinned providers pick the right host at deploy time without a manifest fork. - `mcp.allowed-tools`: optional raw MCP tool-name allowlist when a plugin should expose only part of a provider's tool surface @@ -178,7 +182,33 @@ mcp: The only supported placeholder form is `${NAME}` — replaced with `process.env[NAME]`, falling back to the declared `default`. Plugin discovery fails loudly at load time if `NAME` is not listed in `env-vars`, or if it is listed without a default and the env var is unset. -`NAME` must match `[A-Z_][A-Z0-9_]*`. Expansion only runs on `mcp.url` — not on other manifest fields — to keep the contract narrow. Every env var a manifest references must be declared in `env-vars`; placeholders that escape the declared allowlist are rejected at load time, so a manifest cannot opportunistically read ambient secrets (e.g. `SLACK_BOT_TOKEN`) from the host process. +`NAME` must match `[A-Z_][A-Z0-9_]*`. Every env var a manifest references must be declared in `env-vars`; placeholders that escape the declared allowlist are rejected at load time, so a manifest cannot opportunistically read ambient secrets (e.g. `SLACK_BOT_TOKEN`) from the host process. + +### API headers + +Use top-level `api-headers` when a provider needs additional HTTP headers in sandbox requests. This can stand alone for header-authenticated providers or pair with token-backed credentials. When paired with token-backed credentials, the credential broker owns token headers such as `Authorization`; if both sources set the same header for the same domain, the credential header wins. Env-backed values use `${NAME}` placeholders declared in `env-vars`; unlike `mcp.url`, API header env vars cannot declare defaults because they may carry secrets. + +```yaml +env-vars: + EXAMPLE_AUTH_HEADER: + +api-domains: + - api.example.com + +api-headers: + Authorization: ${EXAMPLE_AUTH_HEADER} + Content-Type: text/plain +``` + +Literal headers are also valid: + +```yaml +api-domains: + - api.example.com + +api-headers: + X-Api-Version: "2026-01-01" +``` ### Add skills to the plugin diff --git a/packages/junior/src/chat/capabilities/factory.ts b/packages/junior/src/chat/capabilities/factory.ts index cf0fdd7e..2d599ee0 100644 --- a/packages/junior/src/chat/capabilities/factory.ts +++ b/packages/junior/src/chat/capabilities/factory.ts @@ -1,21 +1,46 @@ import { logCapabilityCatalogLoadedOnce } from "@/chat/capabilities/catalog"; import { ProviderCredentialRouter } from "@/chat/capabilities/router"; import { SkillCapabilityRuntime } from "@/chat/capabilities/runtime"; +import type { + CredentialBroker, + CredentialHeaderTransform, +} from "@/chat/credentials/broker"; import { StateAdapterTokenStore } from "@/chat/credentials/state-adapter-token-store"; import { TestCredentialBroker } from "@/chat/credentials/test-broker"; -import type { CredentialBroker } from "@/chat/credentials/broker"; import type { UserTokenStore } from "@/chat/credentials/user-token-store"; import { resolveAuthTokenPlaceholder } from "@/chat/plugins/auth/auth-token-placeholder"; import { createPluginBroker, getPluginProviders, } from "@/chat/plugins/registry"; +import type { PluginManifest } from "@/chat/plugins/types"; import { getStateAdapter } from "@/chat/state/adapter"; +const ENV_PLACEHOLDER_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g; + export function createUserTokenStore(): UserTokenStore { return new StateAdapterTokenStore(getStateAdapter()); } +function resolveTestApiHeaderTransforms( + manifest: PluginManifest, +): CredentialHeaderTransform[] { + const { apiDomains, apiHeaders } = manifest; + if (!apiDomains || !apiHeaders) { + return []; + } + // Eval mode must not read deployment secrets; placeholders become dummy values. + const headers = Object.fromEntries( + Object.entries(apiHeaders).map(([key, value]) => [ + key, + value.replace(ENV_PLACEHOLDER_RE, (_match, name) => { + return `eval-test-${String(name).toLowerCase().replaceAll("_", "-")}`; + }), + ]), + ); + return apiDomains.map((domain) => ({ domain, headers })); +} + // Encapsulation boundary for capability runtime construction. // Swap broker strategy here (provider router, test broker, etc.) without // changing agent orchestration code in respond.ts. @@ -32,16 +57,35 @@ export function createSkillCapabilityRuntime( // Plugin providers for (const plugin of getPluginProviders()) { - const { credentials, name } = plugin.manifest; + const { apiHeaders, credentials, name } = plugin.manifest; + if (!credentials && !apiHeaders) { + continue; + } if (!credentials) { + brokersByProvider[name] = useTestBroker + ? new TestCredentialBroker({ + provider: name, + headerTransforms: () => + resolveTestApiHeaderTransforms(plugin.manifest), + }) + : createPluginBroker(name, { userTokenStore }); continue; } + const placeholder = resolveAuthTokenPlaceholder(credentials); brokersByProvider[name] = useTestBroker ? new TestCredentialBroker({ provider: name, domains: credentials.apiDomains, - apiHeaders: credentials.apiHeaders, + ...(credentials.apiHeaders + ? { apiHeaders: credentials.apiHeaders } + : {}), + ...(apiHeaders + ? { + headerTransforms: () => + resolveTestApiHeaderTransforms(plugin.manifest), + } + : {}), envKey: credentials.authTokenEnv, placeholder, }) diff --git a/packages/junior/src/chat/capabilities/runtime.ts b/packages/junior/src/chat/capabilities/runtime.ts index f9400974..b17af459 100644 --- a/packages/junior/src/chat/capabilities/runtime.ts +++ b/packages/junior/src/chat/capabilities/runtime.ts @@ -81,7 +81,7 @@ export class SkillCapabilityRuntime { } const plugin = getPluginDefinition(provider); - if (!plugin?.manifest.credentials) { + if (!plugin?.manifest.credentials && !plugin?.manifest.apiHeaders) { return undefined; } diff --git a/packages/junior/src/chat/credentials/header-transforms.ts b/packages/junior/src/chat/credentials/header-transforms.ts new file mode 100644 index 00000000..bfdbbb6a --- /dev/null +++ b/packages/junior/src/chat/credentials/header-transforms.ts @@ -0,0 +1,18 @@ +import type { CredentialHeaderTransform } from "@/chat/credentials/broker"; + +/** Merge transforms by domain so later transforms override earlier headers. */ +export function mergeHeaderTransforms( + transforms: CredentialHeaderTransform[], +): CredentialHeaderTransform[] { + const byDomain = new Map>(); + for (const transform of transforms) { + byDomain.set(transform.domain, { + ...(byDomain.get(transform.domain) ?? {}), + ...transform.headers, + }); + } + return [...byDomain.entries()].map(([domain, headers]) => ({ + domain, + headers, + })); +} diff --git a/packages/junior/src/chat/credentials/test-broker.ts b/packages/junior/src/chat/credentials/test-broker.ts index 04fa3519..95625d5d 100644 --- a/packages/junior/src/chat/credentials/test-broker.ts +++ b/packages/junior/src/chat/credentials/test-broker.ts @@ -1,17 +1,21 @@ import { randomUUID } from "node:crypto"; import type { CredentialBroker, + CredentialHeaderTransform, CredentialLease, } from "@/chat/credentials/broker"; +import { mergeHeaderTransforms } from "@/chat/credentials/header-transforms"; interface TestBrokerConfig { provider: string; - domains: string[]; + domains?: string[]; apiHeaders?: Record; - envKey: string; - placeholder: string; + headerTransforms?: () => CredentialHeaderTransform[]; + envKey?: string; + placeholder?: string; } +/** Issue deterministic placeholder credential leases for eval runs. */ export class TestCredentialBroker implements CredentialBroker { private readonly config: TestBrokerConfig; @@ -23,20 +27,27 @@ export class TestCredentialBroker implements CredentialBroker { const token = process.env.EVAL_TEST_CREDENTIAL_TOKEN?.trim() || "eval-test-token"; const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); - - return { - id: randomUUID(), - provider: this.config.provider, - env: { - [this.config.envKey]: this.config.placeholder, - }, - headerTransforms: this.config.domains.map((domain) => ({ + const env = + this.config.envKey && this.config.placeholder + ? { [this.config.envKey]: this.config.placeholder } + : {}; + const tokenTransforms = + this.config.domains?.map((domain) => ({ domain, headers: { ...(this.config.apiHeaders ?? {}), Authorization: `Bearer ${token}`, }, - })), + })) ?? []; + + return { + id: randomUUID(), + provider: this.config.provider, + env, + headerTransforms: mergeHeaderTransforms([ + ...(this.config.headerTransforms?.() ?? []), + ...tokenTransforms, + ]), expiresAt, metadata: { reason: input.reason, diff --git a/packages/junior/src/chat/plugins/auth/api-headers-broker.ts b/packages/junior/src/chat/plugins/auth/api-headers-broker.ts new file mode 100644 index 00000000..ef35550e --- /dev/null +++ b/packages/junior/src/chat/plugins/auth/api-headers-broker.ts @@ -0,0 +1,72 @@ +import { randomUUID } from "node:crypto"; +import type { + CredentialBroker, + CredentialHeaderTransform, + CredentialLease, +} from "@/chat/credentials/broker"; +import type { PluginManifest } from "@/chat/plugins/types"; + +const MAX_LEASE_MS = 60 * 60 * 1000; +const ENV_PLACEHOLDER_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g; + +function resolveHeaders( + provider: string, + headers: Record, +): Record { + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => { + const resolved = value.replace(ENV_PLACEHOLDER_RE, (_match, name) => { + const envName = name as string; + const envValue = process.env[envName]?.trim(); + if (!envValue) { + throw new Error( + `Missing ${envName} for API header provider "${provider}"`, + ); + } + return envValue; + }); + return [key, resolved]; + }), + ); +} + +/** Resolve plugin-level API headers into sandbox header transforms. */ +export function resolveApiHeaderTransforms( + manifest: PluginManifest, +): CredentialHeaderTransform[] { + const { apiDomains, apiHeaders } = manifest; + if (!apiDomains || !apiHeaders) { + return []; + } + const resolvedHeaders = resolveHeaders(manifest.name, apiHeaders); + return apiDomains.map((domain) => ({ + domain, + headers: resolvedHeaders, + })); +} + +/** Issue host-managed API header transforms backed by deployment env vars. */ +export function createApiHeadersBroker( + manifest: PluginManifest, +): CredentialBroker { + const provider = manifest.name; + + return { + async issue(input): Promise { + const headerTransforms = resolveApiHeaderTransforms(manifest); + if (headerTransforms.length === 0) { + throw new Error(`No API headers configured for plugin "${provider}"`); + } + return { + id: randomUUID(), + provider, + env: {}, + headerTransforms, + expiresAt: new Date(Date.now() + MAX_LEASE_MS).toISOString(), + metadata: { + reason: input.reason, + }, + }; + }, + }; +} diff --git a/packages/junior/src/chat/plugins/auth/auth-token-placeholder.ts b/packages/junior/src/chat/plugins/auth/auth-token-placeholder.ts index abb829a7..4312e702 100644 --- a/packages/junior/src/chat/plugins/auth/auth-token-placeholder.ts +++ b/packages/junior/src/chat/plugins/auth/auth-token-placeholder.ts @@ -1,12 +1,16 @@ -import type { PluginCredentials } from "../types"; +import type { GitHubAppCredentials, OAuthBearerCredentials } from "../types"; -const DEFAULT_PLACEHOLDERS: Record = { +const DEFAULT_PLACEHOLDERS: Record< + OAuthBearerCredentials["type"] | GitHubAppCredentials["type"], + string +> = { "oauth-bearer": "host_managed_credential", "github-app": "ghp_host_managed_credential", }; +/** Resolve the non-secret sandbox token placeholder for token-backed credentials. */ export function resolveAuthTokenPlaceholder( - credentials: PluginCredentials, + credentials: OAuthBearerCredentials | GitHubAppCredentials, ): string { return ( credentials.authTokenPlaceholder?.trim() || diff --git a/packages/junior/src/chat/plugins/auth/github-app-broker.ts b/packages/junior/src/chat/plugins/auth/github-app-broker.ts index 2049bc54..eddf765e 100644 --- a/packages/junior/src/chat/plugins/auth/github-app-broker.ts +++ b/packages/junior/src/chat/plugins/auth/github-app-broker.ts @@ -3,6 +3,8 @@ import type { CredentialBroker, CredentialLease, } from "@/chat/credentials/broker"; +import { mergeHeaderTransforms } from "@/chat/credentials/header-transforms"; +import { resolveApiHeaderTransforms } from "./api-headers-broker"; import { resolveAuthTokenPlaceholder } from "./auth-token-placeholder"; import type { GitHubAppCredentials, PluginManifest } from "../types"; @@ -221,6 +223,7 @@ export function createGitHubAppBroker( } = credentials; const apiBase = `https://${apiDomains[0]}`; const placeholder = resolveAuthTokenPlaceholder(credentials); + const pluginHeaderTransforms = () => resolveApiHeaderTransforms(manifest); /** * Capabilities that require git HTTPS auth (github.com, not just api.github.com). @@ -282,13 +285,16 @@ export function createGitHubAppBroker( id: randomUUID(), provider, env: { [authTokenEnv]: placeholder }, - headerTransforms: leaseDomains.map((domain) => ({ - domain, - headers: { - ...(apiHeaders ?? {}), - Authorization: authorizationFor(domain, cached.token), - }, - })), + headerTransforms: mergeHeaderTransforms([ + ...pluginHeaderTransforms(), + ...leaseDomains.map((domain) => ({ + domain, + headers: { + ...(apiHeaders ?? {}), + Authorization: authorizationFor(domain, cached.token), + }, + })), + ]), expiresAt: new Date(cached.expiresAt).toISOString(), metadata: { installationId: String(cached.installationId), @@ -333,13 +339,19 @@ export function createGitHubAppBroker( id: randomUUID(), provider, env: { [authTokenEnv]: placeholder }, - headerTransforms: leaseDomains.map((domain) => ({ - domain, - headers: { - ...(apiHeaders ?? {}), - Authorization: authorizationFor(domain, accessTokenResponse.token), - }, - })), + headerTransforms: mergeHeaderTransforms([ + ...pluginHeaderTransforms(), + ...leaseDomains.map((domain) => ({ + domain, + headers: { + ...(apiHeaders ?? {}), + Authorization: authorizationFor( + domain, + accessTokenResponse.token, + ), + }, + })), + ]), expiresAt: new Date(expiresAtMs).toISOString(), metadata: { installationId: String(installationId), diff --git a/packages/junior/src/chat/plugins/auth/oauth-bearer-broker.ts b/packages/junior/src/chat/plugins/auth/oauth-bearer-broker.ts index 9f78486b..8da97783 100644 --- a/packages/junior/src/chat/plugins/auth/oauth-bearer-broker.ts +++ b/packages/junior/src/chat/plugins/auth/oauth-bearer-broker.ts @@ -4,9 +4,11 @@ import type { CredentialLease, } from "@/chat/credentials/broker"; import { CredentialUnavailableError } from "@/chat/credentials/broker"; +import { mergeHeaderTransforms } from "@/chat/credentials/header-transforms"; import { hasRequiredOAuthScope } from "@/chat/credentials/oauth-scope"; import type { UserTokenStore } from "@/chat/credentials/user-token-store"; import { resolveAuthTokenPlaceholder } from "./auth-token-placeholder"; +import { resolveApiHeaderTransforms } from "./api-headers-broker"; import { buildOAuthTokenRequest, parseOAuthTokenResponse, @@ -72,6 +74,7 @@ export function createOAuthBearerBroker( const provider = manifest.name; const { apiDomains, apiHeaders, authTokenEnv } = credentials; const authTokenPlaceholder = resolveAuthTokenPlaceholder(credentials); + const pluginHeaderTransforms = () => resolveApiHeaderTransforms(manifest); function buildLease( token: string, @@ -82,10 +85,13 @@ export function createOAuthBearerBroker( id: randomUUID(), provider, env: { [authTokenEnv]: authTokenPlaceholder }, - headerTransforms: apiDomains.map((domain) => ({ - domain, - headers: { ...(apiHeaders ?? {}), Authorization: `Bearer ${token}` }, - })), + headerTransforms: mergeHeaderTransforms([ + ...pluginHeaderTransforms(), + ...apiDomains.map((domain) => ({ + domain, + headers: { ...(apiHeaders ?? {}), Authorization: `Bearer ${token}` }, + })), + ]), expiresAt: new Date(expiresAtMs).toISOString(), metadata: { reason }, }; diff --git a/packages/junior/src/chat/plugins/manifest.ts b/packages/junior/src/chat/plugins/manifest.ts index fcb37b54..3bc572c4 100644 --- a/packages/junior/src/chat/plugins/manifest.ts +++ b/packages/junior/src/chat/plugins/manifest.ts @@ -20,6 +20,7 @@ const SHORT_CONFIG_KEY_RE = /^[a-z0-9]+(\.[a-z0-9-]+)*$/; const TARGET_FLAG_RE = /^-{1,2}[A-Za-z0-9][A-Za-z0-9-]*$/; const AUTH_TOKEN_ENV_RE = /^[A-Z][A-Z0-9_]*$/; const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; +const ENV_PLACEHOLDER_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g; const API_DOMAIN_RE = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; const RUNTIME_POSTINSTALL_CMD_RE = /^[A-Za-z0-9._/-]+$/; @@ -133,7 +134,7 @@ const stringMapSchema = z result[key] = rawValue.trim(); } - return Object.keys(result).length > 0 ? result : undefined; + return result; }); const apiDomainsSchema = z @@ -265,6 +266,8 @@ const manifestSourceSchema = z error: "must be an array when provided", }) .optional(), + "api-domains": apiDomainsSchema.optional(), + "api-headers": stringMapSchema.optional(), credentials: z .record(z.string(), z.unknown(), { error: "must be an object when provided", @@ -327,7 +330,12 @@ function normalizeStringMap( return undefined; } - for (const key of Object.keys(value)) { + const keys = Object.keys(value); + if (keys.length === 0) { + return undefined; + } + + for (const key of keys) { const normalizedKey = key.toLowerCase(); if (options.reservedKeys?.has(normalizedKey)) { throw new Error(`${prefix}.${key} is reserved by the runtime`); @@ -340,6 +348,41 @@ function normalizeStringMap( return value; } +function assertDeclaredEnvReferences( + value: string, + envVars: Record, + context: string, +): void { + for (const match of value.matchAll(ENV_PLACEHOLDER_RE)) { + const name = match[1] as string; + if (!Object.prototype.hasOwnProperty.call(envVars, name)) { + throw new Error( + `${context} references env var ${name} which is not declared in env-vars`, + ); + } + if (envVars[name]?.default !== undefined) { + throw new Error( + `${context} references env var ${name}, but API header env vars must not declare defaults`, + ); + } + } +} + +function normalizeRequiredApiHeaders( + value: Record, + prefix: string, + envVars: Record, +): Record { + const apiHeaders = normalizeStringMap(value, prefix); + if (!apiHeaders) { + throw new Error(`${prefix} must contain at least one header`); + } + for (const [key, headerValue] of Object.entries(apiHeaders)) { + assertDeclaredEnvReferences(headerValue, envVars, `${prefix}.${key}`); + } + return apiHeaders; +} + function normalizeCredentials( data: Record, name: string, @@ -363,18 +406,18 @@ function normalizeCredentials( } if (result.data.type === "oauth-bearer") { + const apiHeaders = result.data["api-headers"] + ? normalizeStringMap( + result.data["api-headers"], + `Plugin ${name} credentials.api-headers`, + { forbiddenKeys: FORBIDDEN_API_HEADER_NAMES }, + ) + : undefined; + return { type: "oauth-bearer", apiDomains: result.data["api-domains"], - ...(result.data["api-headers"] - ? { - apiHeaders: normalizeStringMap( - result.data["api-headers"], - `Plugin ${name} credentials.api-headers`, - { forbiddenKeys: FORBIDDEN_API_HEADER_NAMES }, - ), - } - : {}), + ...(apiHeaders ? { apiHeaders } : {}), authTokenEnv: result.data["auth-token-env"], ...(result.data["auth-token-placeholder"] ? { authTokenPlaceholder: result.data["auth-token-placeholder"] } @@ -382,18 +425,18 @@ function normalizeCredentials( } satisfies OAuthBearerCredentials; } + const apiHeaders = result.data["api-headers"] + ? normalizeStringMap( + result.data["api-headers"], + `Plugin ${name} credentials.api-headers`, + { forbiddenKeys: FORBIDDEN_API_HEADER_NAMES }, + ) + : undefined; + return { type: "github-app", apiDomains: result.data["api-domains"], - ...(result.data["api-headers"] - ? { - apiHeaders: normalizeStringMap( - result.data["api-headers"], - `Plugin ${name} credentials.api-headers`, - { forbiddenKeys: FORBIDDEN_API_HEADER_NAMES }, - ), - } - : {}), + ...(apiHeaders ? { apiHeaders } : {}), authTokenEnv: result.data["auth-token-env"], ...(result.data["auth-token-placeholder"] ? { authTokenPlaceholder: result.data["auth-token-placeholder"] } @@ -560,8 +603,6 @@ function normalizeRuntimePostinstall( return parsed.length > 0 ? parsed : undefined; } -const ENV_PLACEHOLDER_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g; - const envVarDeclarationSchema = z.preprocess( (value) => (value === null || value === undefined ? {} : value), z @@ -652,18 +693,16 @@ function normalizeMcp( throw new Error(issueMessage(result.error, `Plugin ${name} mcp`)); } + const headers = result.data.headers + ? normalizeStringMap(result.data.headers, `Plugin ${name} mcp.headers`, { + forbiddenKeys: FORBIDDEN_API_HEADER_NAMES, + }) + : undefined; + return { transport: "http", url: result.data.url, - ...(result.data.headers - ? { - headers: normalizeStringMap( - result.data.headers, - `Plugin ${name} mcp.headers`, - { forbiddenKeys: FORBIDDEN_API_HEADER_NAMES }, - ), - } - : {}), + ...(headers ? { headers } : {}), ...(result.data["allowed-tools"] ? { allowedTools: result.data["allowed-tools"] } : {}), @@ -710,6 +749,16 @@ export function parsePluginManifest(raw: string, dir: string): PluginManifest { `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} config-keys must be an array when provided`, ); } + if (path === "api-domains") { + throw new Error( + `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} api-domains must be a non-empty array of domains`, + ); + } + if (path === "api-headers") { + throw new Error( + `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} api-headers must be an object when provided`, + ); + } if (path === "credentials") { throw new Error( `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} credentials must be an object when provided`, @@ -765,6 +814,23 @@ export function parsePluginManifest(raw: string, dir: string): PluginManifest { return `${data.name}.${key}`; }); + const envVars = data["env-vars"] + ? normalizeEnvVars(data["env-vars"], data.name) + : {}; + const apiHeaders = data["api-headers"] + ? normalizeRequiredApiHeaders( + data["api-headers"], + `Plugin ${data.name} api-headers`, + envVars, + ) + : undefined; + if (apiHeaders && !data["api-domains"]) { + throw new Error(`Plugin ${data.name} api-headers requires api-domains`); + } + if (data["api-domains"] && !apiHeaders) { + throw new Error(`Plugin ${data.name} api-domains requires api-headers`); + } + const credentials = data.credentials ? normalizeCredentials(data.credentials, data.name) : undefined; @@ -774,9 +840,6 @@ export function parsePluginManifest(raw: string, dir: string): PluginManifest { const runtimePostinstall = data["runtime-postinstall"] ? normalizeRuntimePostinstall(data["runtime-postinstall"], data.name) : undefined; - const envVars = data["env-vars"] - ? normalizeEnvVars(data["env-vars"], data.name) - : {}; const mcp = data.mcp ? normalizeMcp(data.mcp, envVars, data.name) : undefined; const manifest: PluginManifest = { @@ -784,6 +847,8 @@ export function parsePluginManifest(raw: string, dir: string): PluginManifest { description: data.description, capabilities, configKeys, + ...(data["api-domains"] ? { apiDomains: data["api-domains"] } : {}), + ...(apiHeaders ? { apiHeaders } : {}), ...(Object.keys(envVars).length > 0 ? { envVars } : {}), ...(credentials ? { credentials } : {}), ...(runtimeDependencies ? { runtimeDependencies } : {}), diff --git a/packages/junior/src/chat/plugins/registry.ts b/packages/junior/src/chat/plugins/registry.ts index 95e317a5..ec7bb1ab 100644 --- a/packages/junior/src/chat/plugins/registry.ts +++ b/packages/junior/src/chat/plugins/registry.ts @@ -7,6 +7,7 @@ import { logInfo, logWarn, setSpanAttributes } from "@/chat/logging"; import { createGitHubAppBroker } from "./auth/github-app-broker"; import { parsePluginManifest } from "./manifest"; import { createOAuthBearerBroker } from "./auth/oauth-bearer-broker"; +import { createApiHeadersBroker } from "./auth/api-headers-broker"; import { discoverInstalledPluginPackageContent } from "./package-discovery"; import type { PluginBrokerDeps, @@ -447,12 +448,16 @@ export function createPluginBroker( } const { credentials, name } = plugin.manifest; - if (!credentials) { - throw new Error(`Provider "${name}" has no credentials configured`); + if (!credentials && !plugin.manifest.apiHeaders) { + throw new Error( + `Provider "${name}" has no credentials or API headers configured`, + ); } let broker: CredentialBroker; - if (credentials.type === "oauth-bearer") { + if (!credentials) { + broker = createApiHeadersBroker(plugin.manifest); + } else if (credentials.type === "oauth-bearer") { broker = createOAuthBearerBroker(plugin.manifest, credentials, deps); } else if (credentials.type === "github-app") { broker = createGitHubAppBroker(plugin.manifest, credentials); diff --git a/packages/junior/src/chat/plugins/types.ts b/packages/junior/src/chat/plugins/types.ts index 246748f7..f376a19a 100644 --- a/packages/junior/src/chat/plugins/types.ts +++ b/packages/junior/src/chat/plugins/types.ts @@ -82,6 +82,8 @@ export interface PluginManifest { description: string; capabilities: string[]; configKeys: string[]; + apiDomains?: string[]; + apiHeaders?: Record; envVars?: Record; credentials?: PluginCredentials; runtimeDependencies?: PluginRuntimeDependency[]; diff --git a/packages/junior/src/chat/services/plugin-auth-orchestration.ts b/packages/junior/src/chat/services/plugin-auth-orchestration.ts index 0c24f93e..58617f5f 100644 --- a/packages/junior/src/chat/services/plugin-auth-orchestration.ts +++ b/packages/junior/src/chat/services/plugin-auth-orchestration.ts @@ -106,13 +106,17 @@ function commandTargetsProvider( const plugin = getPluginDefinition(provider); const candidates = new Set([provider.toLowerCase()]); - const credentials = plugin?.manifest.credentials; + const manifest = plugin?.manifest; + const credentials = manifest?.credentials; if (credentials) { candidates.add(credentials.authTokenEnv.toLowerCase()); for (const domain of credentials.apiDomains) { candidates.add(domain.toLowerCase()); } } + for (const domain of manifest?.apiDomains ?? []) { + candidates.add(domain.toLowerCase()); + } const combinedText = `${normalizedCommand}\n${details.stdout?.toLowerCase() ?? ""}\n${details.stderr?.toLowerCase() ?? ""}`; return [...candidates].some((candidate) => combinedText.includes(candidate)); diff --git a/packages/junior/tests/unit/capabilities/capability-factory.test.ts b/packages/junior/tests/unit/capabilities/capability-factory.test.ts new file mode 100644 index 00000000..ba4f62f5 --- /dev/null +++ b/packages/junior/tests/unit/capabilities/capability-factory.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginDefinition } from "@/chat/plugins/types"; +import type { Skill } from "@/chat/skills"; + +const createPluginBrokerMock = vi.fn(); +const getPluginProvidersMock = vi.fn<() => PluginDefinition[]>(); + +vi.mock("@/chat/capabilities/catalog", () => ({ + logCapabilityCatalogLoadedOnce: vi.fn(), +})); + +vi.mock("@/chat/plugins/registry", () => ({ + createPluginBroker: (...args: unknown[]) => createPluginBrokerMock(...args), + getPluginDefinition: (provider: string) => + getPluginProvidersMock().find( + (plugin) => plugin.manifest.name === provider, + ), + getPluginProviders: () => getPluginProvidersMock(), +})); + +vi.mock("@/chat/state/adapter", () => ({ + getStateAdapter: () => ({ + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + }), +})); + +const headerOnlySkill: Skill = { + name: "example", + description: "Example helper", + skillPath: "/tmp/example", + body: "instructions", + pluginProvider: "example", +}; + +describe("capability runtime factory", () => { + afterEach(() => { + delete process.env.EVAL_ENABLE_TEST_CREDENTIALS; + createPluginBrokerMock.mockReset(); + getPluginProvidersMock.mockReset(); + vi.resetModules(); + }); + + it("uses test header transforms for header-only plugins in eval mode", async () => { + process.env.EVAL_ENABLE_TEST_CREDENTIALS = "1"; + createPluginBrokerMock.mockImplementation(() => { + throw new Error("should not create real plugin broker"); + }); + getPluginProvidersMock.mockReturnValue([ + { + manifest: { + name: "example", + description: "Example", + capabilities: ["example.api"], + configKeys: [], + apiDomains: ["api.example.com"], + apiHeaders: { + Authorization: "Bearer ${EXAMPLE_API_HEADER}", + "X-Api-Version": "2026-01-01", + }, + }, + dir: "/tmp/example", + skillsDir: "/tmp/example/skills", + }, + ]); + + const { createSkillCapabilityRuntime } = + await import("@/chat/capabilities/factory"); + const runtime = createSkillCapabilityRuntime({ requesterId: "U123" }); + + await expect( + runtime.enableCredentialsForTurn({ + activeSkill: headerOnlySkill, + reason: "test:api-headers", + }), + ).resolves.toMatchObject({ reused: false }); + + expect(createPluginBrokerMock).not.toHaveBeenCalled(); + expect(runtime.getTurnEnv()).toBeUndefined(); + expect(runtime.getTurnHeaderTransforms()).toEqual([ + { + domain: "api.example.com", + headers: { + Authorization: "Bearer eval-test-example-api-header", + "X-Api-Version": "2026-01-01", + }, + }, + ]); + }); +}); diff --git a/packages/junior/tests/unit/capabilities/capability-runtime.test.ts b/packages/junior/tests/unit/capabilities/capability-runtime.test.ts index f951f59d..1c5d31d6 100644 --- a/packages/junior/tests/unit/capabilities/capability-runtime.test.ts +++ b/packages/junior/tests/unit/capabilities/capability-runtime.test.ts @@ -47,7 +47,20 @@ vi.mock("@/chat/plugins/registry", () => ({ }, }, } - : undefined, + : provider === "example" + ? { + manifest: { + name: "example", + description: "Example", + capabilities: ["example.api"], + configKeys: [], + apiDomains: ["api.example.com"], + apiHeaders: { + "X-Api-Key": "${EXAMPLE_API_KEY}", + }, + }, + } + : undefined, })); import { SkillCapabilityRuntime } from "@/chat/capabilities/runtime"; @@ -68,6 +81,14 @@ const sentrySkill: Skill = { pluginProvider: "sentry", }; +const exampleSkill: Skill = { + name: "example", + description: "Example helper", + skillPath: "/tmp/example", + body: "instructions", + pluginProvider: "example", +}; + describe("skill capability runtime", () => { it("issues turn-scoped transforms on first enable and reuses them within the turn", async () => { let issueCalls = 0; @@ -201,4 +222,49 @@ describe("skill capability runtime", () => { expect(seenReason).toBe("test:no-target"); }); + + it("issues header transforms for plugins without credentials", async () => { + let issueCalls = 0; + const broker: CredentialBroker = { + issue: async () => { + issueCalls += 1; + return { + id: "lease-1", + provider: "example", + env: {}, + headerTransforms: [ + { + domain: "api.example.com", + headers: { + "X-Api-Key": "secret", + }, + }, + ], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; + }, + }; + + const runtime = new SkillCapabilityRuntime({ + broker, + requesterId: "U123", + }); + + await expect( + runtime.enableCredentialsForTurn({ + activeSkill: exampleSkill, + reason: "test:api-headers", + }), + ).resolves.toMatchObject({ reused: false }); + + expect(issueCalls).toBe(1); + expect(runtime.getTurnHeaderTransforms()).toEqual([ + { + domain: "api.example.com", + headers: { + "X-Api-Key": "secret", + }, + }, + ]); + }); }); diff --git a/packages/junior/tests/unit/plugins/api-headers-broker.test.ts b/packages/junior/tests/unit/plugins/api-headers-broker.test.ts new file mode 100644 index 00000000..7c4143bd --- /dev/null +++ b/packages/junior/tests/unit/plugins/api-headers-broker.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApiHeadersBroker } from "@/chat/plugins/auth/api-headers-broker"; +import type { PluginManifest } from "@/chat/plugins/types"; + +const ORIGINAL_ENV = { ...process.env }; + +const MANIFEST: PluginManifest = { + name: "example", + description: "Example API access", + capabilities: ["example.query"], + configKeys: [], + apiDomains: ["api.example.com"], + apiHeaders: { + Authorization: "${EXAMPLE_AUTH_HEADER}", + "Content-Type": "text/plain", + }, +}; + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.restoreAllMocks(); +}); + +describe("API headers broker", () => { + it("resolves env-backed header values into header transforms", async () => { + process.env.EXAMPLE_AUTH_HEADER = "Basic abc123"; + + const broker = createApiHeadersBroker(MANIFEST); + const lease = await broker.issue({ reason: "test:api-headers" }); + + expect(lease.provider).toBe("example"); + expect(lease.env).toEqual({}); + expect(lease.headerTransforms).toEqual([ + { + domain: "api.example.com", + headers: { + Authorization: "Basic abc123", + "Content-Type": "text/plain", + }, + }, + ]); + }); + + it("throws when an env-backed header references a missing env var", async () => { + delete process.env.EXAMPLE_AUTH_HEADER; + + const broker = createApiHeadersBroker(MANIFEST); + + await expect( + broker.issue({ reason: "test:missing-api-header-env" }), + ).rejects.toThrow( + 'Missing EXAMPLE_AUTH_HEADER for API header provider "example"', + ); + }); +}); diff --git a/packages/junior/tests/unit/plugins/plugin-manifest-api-headers.test.ts b/packages/junior/tests/unit/plugins/plugin-manifest-api-headers.test.ts new file mode 100644 index 00000000..37b56672 --- /dev/null +++ b/packages/junior/tests/unit/plugins/plugin-manifest-api-headers.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { parsePluginManifest } from "@/chat/plugins/manifest"; + +describe("plugin manifest API headers", () => { + it("parses plugin-level API headers with literal and env-backed values", () => { + const manifest = parsePluginManifest( + [ + "name: example", + "description: Example API access", + "env-vars:", + " EXAMPLE_AUTH_HEADER:", + "api-domains:", + " - api.example.com", + "api-headers:", + ' Authorization: "${EXAMPLE_AUTH_HEADER}"', + ' Content-Type: "text/plain"', + ].join("\n"), + "/tmp/example", + ); + + expect(manifest.credentials).toBeUndefined(); + expect(manifest.apiDomains).toEqual(["api.example.com"]); + expect(manifest.apiHeaders).toEqual({ + Authorization: "${EXAMPLE_AUTH_HEADER}", + "Content-Type": "text/plain", + }); + }); + + it("rejects API headers without API domains", () => { + expect(() => + parsePluginManifest( + [ + "name: example", + "description: Example API access", + "api-headers:", + ' Content-Type: "text/plain"', + ].join("\n"), + "/tmp/example", + ), + ).toThrow("Plugin example api-headers requires api-domains"); + }); + + it("rejects empty API headers", () => { + expect(() => + parsePluginManifest( + [ + "name: example", + "description: Example API access", + "api-domains:", + " - api.example.com", + "api-headers: {}", + ].join("\n"), + "/tmp/example", + ), + ).toThrow("Plugin example api-headers must contain at least one header"); + }); + + it("rejects undeclared API header env vars", () => { + expect(() => + parsePluginManifest( + [ + "name: example", + "description: Example API access", + "api-domains:", + " - api.example.com", + "api-headers:", + ' Authorization: "${EXAMPLE_AUTH_HEADER}"', + ].join("\n"), + "/tmp/example", + ), + ).toThrow( + "Plugin example api-headers.Authorization references env var EXAMPLE_AUTH_HEADER which is not declared in env-vars", + ); + }); + + it("rejects API header env vars with defaults", () => { + expect(() => + parsePluginManifest( + [ + "name: example", + "description: Example API access", + "env-vars:", + " EXAMPLE_AUTH_HEADER:", + ' default: "Basic abc123"', + "api-domains:", + " - api.example.com", + "api-headers:", + ' Authorization: "${EXAMPLE_AUTH_HEADER}"', + ].join("\n"), + "/tmp/example", + ), + ).toThrow( + "Plugin example api-headers.Authorization references env var EXAMPLE_AUTH_HEADER, but API header env vars must not declare defaults", + ); + }); +}); diff --git a/packages/junior/tests/unit/plugins/plugin-registry-packages.test.ts b/packages/junior/tests/unit/plugins/plugin-registry-packages.test.ts index ed676789..7dd5dacf 100644 --- a/packages/junior/tests/unit/plugins/plugin-registry-packages.test.ts +++ b/packages/junior/tests/unit/plugins/plugin-registry-packages.test.ts @@ -590,7 +590,7 @@ describe("plugin registry package discovery", () => { delete: async () => {}, }, }), - ).toThrow('Provider "demo" has no credentials configured'); + ).toThrow('Provider "demo" has no credentials or API headers configured'); }); it("parses system URL runtime dependencies with required sha256", async () => { @@ -840,7 +840,7 @@ describe("plugin registry package discovery", () => { }); }); - it("rejects Authorization in plugin api headers", async () => { + it("rejects Authorization in credential api headers", async () => { const tempRoot = await fs.mkdtemp( path.join(os.tmpdir(), "junior-plugin-package-"), ); diff --git a/packages/junior/tests/unit/plugins/sentry-broker.test.ts b/packages/junior/tests/unit/plugins/sentry-broker.test.ts index 6b6bcaaf..aba0aeba 100644 --- a/packages/junior/tests/unit/plugins/sentry-broker.test.ts +++ b/packages/junior/tests/unit/plugins/sentry-broker.test.ts @@ -128,6 +128,53 @@ describe("sentry credential broker (oauth-bearer plugin)", () => { ]); }); + it("merges plugin-level API headers with token-backed credential headers", async () => { + process.env.SENTRY_AUTH_TOKEN = "static-env-token"; + process.env.SENTRY_EXTRA_AUTH = "PluginManaged value"; + const manifest: PluginManifest = { + ...SENTRY_MANIFEST, + apiDomains: ["uploads.sentry.io", "sentry.io"], + apiHeaders: { + Authorization: "${SENTRY_EXTRA_AUTH}", + "X-Sentry-Mode": "sandbox", + }, + }; + + const broker = createOAuthBearerBroker( + manifest, + manifest.credentials as OAuthBearerCredentials, + { userTokenStore: createMockTokenStore() }, + ); + const lease = await broker.issue({ + reason: "test:plugin-api-headers", + }); + + expect(lease.headerTransforms).toEqual([ + { + domain: "uploads.sentry.io", + headers: { + Authorization: "PluginManaged value", + "X-Sentry-Mode": "sandbox", + }, + }, + { + domain: "sentry.io", + headers: { + Authorization: "Bearer static-env-token", + "X-Sentry-Mode": "sandbox", + }, + }, + { + domain: "us.sentry.io", + headers: { Authorization: "Bearer static-env-token" }, + }, + { + domain: "de.sentry.io", + headers: { Authorization: "Bearer static-env-token" }, + }, + ]); + }); + it("throws CredentialUnavailableError when no credentials are available", async () => { delete process.env.SENTRY_AUTH_TOKEN; const broker = createBroker(); diff --git a/packages/junior/tests/unit/plugins/test-broker.test.ts b/packages/junior/tests/unit/plugins/test-broker.test.ts new file mode 100644 index 00000000..ee4bdecd --- /dev/null +++ b/packages/junior/tests/unit/plugins/test-broker.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { CredentialHeaderTransform } from "@/chat/credentials/broker"; +import { TestCredentialBroker } from "@/chat/credentials/test-broker"; + +describe("test credential broker", () => { + afterEach(() => { + delete process.env.EVAL_TEST_CREDENTIAL_TOKEN; + }); + + it("preserves plugin-level header transforms separately from token domains", async () => { + process.env.EVAL_TEST_CREDENTIAL_TOKEN = "test-token"; + const broker = new TestCredentialBroker({ + provider: "example", + domains: ["api.example.com"], + apiHeaders: { + "X-Api-Version": "2026-01-01", + }, + headerTransforms: (): CredentialHeaderTransform[] => [ + { + domain: "uploads.example.com", + headers: { + "X-Upload-Mode": "sandbox", + }, + }, + { + domain: "api.example.com", + headers: { + Authorization: "PluginManaged value", + "X-Shared": "plugin", + }, + }, + ], + envKey: "EXAMPLE_TOKEN", + placeholder: "host_managed_credential", + }); + + const lease = await broker.issue({ reason: "test:headers" }); + + expect(lease.headerTransforms).toEqual([ + { + domain: "uploads.example.com", + headers: { + "X-Upload-Mode": "sandbox", + }, + }, + { + domain: "api.example.com", + headers: { + Authorization: "Bearer test-token", + "X-Shared": "plugin", + "X-Api-Version": "2026-01-01", + }, + }, + ]); + }); + + it("issues header-only leases without token env", async () => { + const broker = new TestCredentialBroker({ + provider: "example", + headerTransforms: () => [ + { + domain: "api.example.com", + headers: { + Authorization: "eval-test-example-api-header", + }, + }, + ], + }); + + const lease = await broker.issue({ reason: "test:headers-only" }); + + expect(lease.env).toEqual({}); + expect(lease.headerTransforms).toEqual([ + { + domain: "api.example.com", + headers: { + Authorization: "eval-test-example-api-header", + }, + }, + ]); + }); +}); diff --git a/specs/plugin-spec.md b/specs/plugin-spec.md index 11be35db..cdfb71c3 100644 --- a/specs/plugin-spec.md +++ b/specs/plugin-spec.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-03-01 -- Last Edited: 2026-04-30 +- Last Edited: 2026-05-03 ## Changelog @@ -21,6 +21,7 @@ - 2026-04-26: Clarified that runtime setup authority belongs to `plugin.yaml`, not arbitrary skill prose. - 2026-04-28: Kept MCP execution behind stable `callMcpTool` while disclosing searchable MCP catalogs through `loadSkill`, `searchMcpTools`, and ``. - 2026-04-30: Added install-wide config defaults via `createApp({ configDefaults })` with channel-scoped override precedence. +- 2026-05-03: Added plugin-level `api-headers` injection backed by declared deployment env vars. ## Status @@ -35,6 +36,7 @@ Implemented (Sentry + GitHub migrated) - Plugin Registry: `packages/junior/src/chat/plugins/registry.ts` - Plugin Types: `packages/junior/src/chat/plugins/types.ts` - Generic OAuth Bearer Broker: `packages/junior/src/chat/plugins/auth/oauth-bearer-broker.ts` +- API Headers Broker: `packages/junior/src/chat/plugins/auth/api-headers-broker.ts` - GitHub App Broker: `packages/junior/src/chat/plugins/auth/github-app-broker.ts` - Provider Catalog: `packages/junior/src/chat/capabilities/catalog.ts` - Broker Factory: `packages/junior/src/chat/capabilities/factory.ts` @@ -52,7 +54,7 @@ Define a plugin model where provider integrations are self-contained directories - an installed npm dependency that contains plugin content in `plugin.yaml` or `plugins/`. 2. At startup, the plugin registry scans local plugin roots and packaged plugin roots, then parses each manifest synchronously (`readFileSync`). 3. The registry registers capabilities, config keys, OAuth config, and skill roots from each manifest. -4. Credential brokers are created on demand only for plugins that declare credentials (`oauth-bearer` or `github-app` type). +4. Credential brokers are created on demand only for plugins that declare credentials (`oauth-bearer` or `github-app` type) or plugin-level API headers. 5. Skills in `plugins//skills/` are auto-discovered alongside existing skill roots. 6. Plugin-declared MCP tools are host-managed and activated only after a skill from the same plugin is loaded for the turn. 7. Pi sees stable native tools (`loadSkill`, `searchMcpTools`, and `callMcpTool`) at turn start. After a plugin-backed skill is loaded, the runtime activates that plugin's discovered MCP tools for search and execution. @@ -90,14 +92,17 @@ config-keys: # short names — qualified to sentry.org, etc. - org - project +api-domains: # domains for plugin-level header transforms + - sentry.io +api-headers: # optional headers injected for matching sandbox requests + X-Api-Version: "2026-01-01" + credentials: # how tokens are delivered to the sandbox type: oauth-bearer # bearer token via Authorization header api-domains: # domains for header transforms - sentry.io - us.sentry.io - de.sentry.io - api-headers: # optional headers applied alongside Authorization - X-Api-Version: 2026-01-01 auth-token-env: SENTRY_AUTH_TOKEN # env var for static fallback + sandbox placeholder auth-token-placeholder: host_managed_credential # optional placeholder value for CLI env checks @@ -142,6 +147,24 @@ mcp: # optional — MCP server config for tool sources - fetch ``` +```yaml +# plugin.yaml — API header injection example +name: better-stack +description: Better Stack access + +capabilities: + - api + +env-vars: + BETTER_STACK_AUTH_HEADER: + +api-domains: + - api.betterstack.com +api-headers: + Authorization: ${BETTER_STACK_AUTH_HEADER} + Content-Type: application/json +``` + ## Plugin manifest contract ### Required fields @@ -153,48 +176,50 @@ mcp: # optional — MCP server config for tool sources ### Optional fields -| Field | Type | Rules | -| ------------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `capabilities` | `string[]` | Short names (e.g. `issues.read`). Qualified to `.issues.read` by the registry. No qualified capability may appear in more than one plugin. | -| `config-keys` | `string[]` | Short names (e.g. `org`). Qualified to `.org` by the registry. | -| `credentials` | `object` | Credential delivery configuration. | -| `credentials.type` | `string` | `"oauth-bearer"` or `"github-app"`. | -| `credentials.api-domains` | `string[]` | Domains for `Authorization: Bearer` header transforms. At least one required. | -| `credentials.api-headers` | `Record` | Optional headers applied to matching API domains alongside `Authorization`. `Authorization` itself is reserved. | -| `credentials.auth-token-env` | `string` | Env var name for static token fallback and sandbox placeholder. | -| `credentials.auth-token-placeholder` | `string` | Optional non-secret placeholder injected into sandbox env for CLI compatibility. | -| `credentials.app-id-env` | `string` | Env var name for GitHub App ID. Required when `credentials.type` is `"github-app"`. | -| `credentials.private-key-env` | `string` | Env var name for GitHub App private key (PEM). Required when `credentials.type` is `"github-app"`. | -| `credentials.installation-id-env` | `string` | Env var name for GitHub App installation ID. Required when `credentials.type` is `"github-app"`. | -| `oauth` | `object` | OAuth provider configuration. Requires `credentials.type` = `"oauth-bearer"`. | -| `oauth.client-id-env` | `string` | Env var name for client ID. | -| `oauth.client-secret-env` | `string` | Env var name for client secret. | -| `oauth.authorize-endpoint` | `string` | Valid HTTPS URL. | -| `oauth.token-endpoint` | `string` | Valid HTTPS URL. | -| `oauth.scope` | `string` | Optional OAuth scope string. | -| `oauth.authorize-params` | `Record` | Optional authorize URL params added alongside core params. Reserved OAuth param names may not be overridden. | -| `oauth.token-auth-method` | `string` | Optional token client auth method: `"body"` (default) or `"basic"`. | -| `oauth.token-extra-headers` | `Record` | Optional token request headers. `Authorization` is reserved; `Content-Type` controls token body serialization. | -| `target` | `object` | Capability target for scoped credentials. | -| `target.type` | `string` | Currently only `"repo"`. | -| `target.config-key` | `string` | Must appear in `config-keys`. | -| `runtime-dependencies` | `object[]` | Optional sandbox dependency declarations used to build reusable snapshots. | -| `runtime-dependencies[].type` | `string` | `"npm"` or `"system"`. | -| `runtime-dependencies[].package` | `string` | Package identifier (npm package name or system package name). Required for `npm`; optional for `system` when `url` is used. | -| `runtime-dependencies[].version` | `string` | Optional for `npm` dependencies. When omitted, runtime uses `latest`. Must be omitted for `system` dependencies. | -| `runtime-dependencies[].url` | `string` | HTTPS URL for direct system package install (RPM). Allowed only for `system` dependencies. | -| `runtime-dependencies[].sha256` | `string` | Required with `url`. Lowercase or uppercase hex SHA-256 checksum used for integrity verification before install. | -| `runtime-postinstall` | `object[]` | Optional post-install command declarations executed after dependency install and before snapshot capture. | -| `runtime-postinstall[].cmd` | `string` | Non-empty command name. | -| `runtime-postinstall[].args` | `string[]` | Optional command arguments. | -| `runtime-postinstall[].sudo` | `boolean` | Optional sudo flag for commands requiring elevated privileges. | -| `env-vars` | `Record` | Optional map declaring deployment env vars the manifest may reference from `mcp.url`. Keys must match `[A-Z_][A-Z0-9_]*`. See [MCP URL env-var expansion](#mcp-url-env-var-expansion). | -| `env-vars..default` | `string` | Optional default value used when `process.env[NAME]` is unset or empty. Omit to require the operator to set it explicitly. | -| `mcp` | `object` | Optional MCP server configuration for host-managed tool discovery. | -| `mcp.transport` | `string` | Optional. When omitted and `mcp.url` is present, Junior infers HTTP. If provided in v1, it must be `"http"`. Stdio/command transports are not supported. | -| `mcp.url` | `string` | HTTPS endpoint for the MCP server. Supports `${NAME}` placeholders declared in `env-vars` — see [MCP URL env-var expansion](#mcp-url-env-var-expansion). Expansion runs before HTTPS validation. | -| `mcp.headers` | `Record` | Optional static non-Authorization headers sent with MCP HTTP requests. `Authorization` is reserved for runtime-managed auth. | -| `mcp.allowed-tools` | `string[]` | Optional non-empty allowlist of raw MCP tool names to expose for this provider. Activation fails if any listed tool is missing from discovery. | +| Field | Type | Rules | +| ------------------------------------ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `capabilities` | `string[]` | Short names (e.g. `issues.read`). Qualified to `.issues.read` by the registry. No qualified capability may appear in more than one plugin. | +| `config-keys` | `string[]` | Short names (e.g. `org`). Qualified to `.org` by the registry. | +| `api-domains` | `string[]` | Optional domains for plugin-level API header injection. Required when `api-headers` is set. | +| `api-headers` | `Record` | Optional headers injected for matching `api-domains`. Values may reference `${NAME}` placeholders declared in `env-vars`; referenced env vars must not declare defaults. | +| `credentials` | `object` | Credential delivery configuration. | +| `credentials.type` | `string` | `"oauth-bearer"` or `"github-app"`. | +| `credentials.api-domains` | `string[]` | Domains for token-backed header transforms. At least one required. | +| `credentials.api-headers` | `Record` | Optional extra headers applied alongside runtime-managed `Authorization` for `oauth-bearer` and `github-app`; `Authorization` itself is reserved for those types. Prefer plugin-level `api-headers` for new manifests. | +| `credentials.auth-token-env` | `string` | Env var name for static token fallback and sandbox placeholder. Required for `oauth-bearer` and `github-app`. | +| `credentials.auth-token-placeholder` | `string` | Optional non-secret placeholder injected into sandbox env for CLI compatibility. Applies to `oauth-bearer` and `github-app`. | +| `credentials.app-id-env` | `string` | Env var name for GitHub App ID. Required when `credentials.type` is `"github-app"`. | +| `credentials.private-key-env` | `string` | Env var name for GitHub App private key (PEM). Required when `credentials.type` is `"github-app"`. | +| `credentials.installation-id-env` | `string` | Env var name for GitHub App installation ID. Required when `credentials.type` is `"github-app"`. | +| `oauth` | `object` | OAuth provider configuration. Requires `credentials.type` = `"oauth-bearer"`. | +| `oauth.client-id-env` | `string` | Env var name for client ID. | +| `oauth.client-secret-env` | `string` | Env var name for client secret. | +| `oauth.authorize-endpoint` | `string` | Valid HTTPS URL. | +| `oauth.token-endpoint` | `string` | Valid HTTPS URL. | +| `oauth.scope` | `string` | Optional OAuth scope string. | +| `oauth.authorize-params` | `Record` | Optional authorize URL params added alongside core params. Reserved OAuth param names may not be overridden. | +| `oauth.token-auth-method` | `string` | Optional token client auth method: `"body"` (default) or `"basic"`. | +| `oauth.token-extra-headers` | `Record` | Optional token request headers. `Authorization` is reserved; `Content-Type` controls token body serialization. | +| `target` | `object` | Capability target for scoped credentials. | +| `target.type` | `string` | Currently only `"repo"`. | +| `target.config-key` | `string` | Must appear in `config-keys`. | +| `runtime-dependencies` | `object[]` | Optional sandbox dependency declarations used to build reusable snapshots. | +| `runtime-dependencies[].type` | `string` | `"npm"` or `"system"`. | +| `runtime-dependencies[].package` | `string` | Package identifier (npm package name or system package name). Required for `npm`; optional for `system` when `url` is used. | +| `runtime-dependencies[].version` | `string` | Optional for `npm` dependencies. When omitted, runtime uses `latest`. Must be omitted for `system` dependencies. | +| `runtime-dependencies[].url` | `string` | HTTPS URL for direct system package install (RPM). Allowed only for `system` dependencies. | +| `runtime-dependencies[].sha256` | `string` | Required with `url`. Lowercase or uppercase hex SHA-256 checksum used for integrity verification before install. | +| `runtime-postinstall` | `object[]` | Optional post-install command declarations executed after dependency install and before snapshot capture. | +| `runtime-postinstall[].cmd` | `string` | Non-empty command name. | +| `runtime-postinstall[].args` | `string[]` | Optional command arguments. | +| `runtime-postinstall[].sudo` | `boolean` | Optional sudo flag for commands requiring elevated privileges. | +| `env-vars` | `Record` | Optional map declaring deployment env vars the manifest may reference from `mcp.url` or plugin-level `api-headers`. Keys must match `[A-Z_][A-Z0-9_]*`. See [MCP URL env-var expansion](#mcp-url-env-var-expansion). | +| `env-vars..default` | `string` | Optional default value used by `mcp.url` when `process.env[NAME]` is unset or empty. Must be omitted for env vars referenced from `api-headers`. | +| `mcp` | `object` | Optional MCP server configuration for host-managed tool discovery. | +| `mcp.transport` | `string` | Optional. When omitted and `mcp.url` is present, Junior infers HTTP. If provided in v1, it must be `"http"`. Stdio/command transports are not supported. | +| `mcp.url` | `string` | HTTPS endpoint for the MCP server. Supports `${NAME}` placeholders declared in `env-vars` — see [MCP URL env-var expansion](#mcp-url-env-var-expansion). Expansion runs before HTTPS validation. | +| `mcp.headers` | `Record` | Optional static non-Authorization headers sent with MCP HTTP requests. `Authorization` is reserved for runtime-managed auth. | +| `mcp.allowed-tools` | `string[]` | Optional non-empty allowlist of raw MCP tool names to expose for this provider. Activation fails if any listed tool is missing from discovery. | Snapshot build/reuse and invalidation behavior for `runtime-dependencies` is defined in [Sandbox Snapshots Spec](./sandbox-snapshots-spec.md). @@ -214,10 +239,13 @@ without a default and `process.env[NAME]` is unset or empty. not listed in `env-vars` are rejected at load time — this makes the set of env vars a manifest may read explicit and auditable, and prevents a manifest from opportunistically reading ambient host env vars (e.g. -`SLACK_BOT_TOKEN`) via `mcp.url`. Expansion applies **only** to `mcp.url`. -Other manifest fields (credentials envs, OAuth endpoints, api-domains, -etc.) already have dedicated env-ref mechanisms (`auth-token-env`, -`client-id-env`, …) or must remain literal for validation. +`SLACK_BOT_TOKEN`). Manifest-load expansion applies to `mcp.url`; API +header placeholders are validated at manifest load and resolved only when a +credential lease is issued, so secret header values are not stored in the +parsed manifest. Other manifest fields (credentials envs, OAuth endpoints, +api-domains, etc.) already have dedicated env-ref mechanisms +(`auth-token-env`, `client-id-env`, …) or must remain literal for +validation. Defaults live in the `env-vars` declaration, not inline in the placeholder. There is no `${NAME:-default}` form. @@ -240,6 +268,13 @@ Operators on US3/US5/EU/AP1/AP2/GovCloud set `DATADOG_SITE=us5.datadoghq.com` (etc.) in their Junior deployment env. No code changes, no app-local plugin copy. +### API header env-var references + +Plugin-level `api-headers` supports `${NAME}` placeholders that must be +declared in `env-vars`. These placeholders are intended for headers that may +carry secrets, so their declarations must not include `default`. Missing env +values fail when the provider's header transforms are issued. + System runtime dependency execution environment: - Sandbox OS is Amazon Linux 2023. @@ -264,7 +299,7 @@ System runtime dependency execution environment: - No two plugins may declare the same capability token. - No two plugins may use the same `name`. - If `target.config-key` is set, it must be listed in `config-keys`. -- If a plugin declares capabilities without credentials, manifest load succeeds and runtime credential enablement fails with an explicit no-broker error when an authenticated command needs that provider. +- If a plugin declares capabilities without credentials or API headers, manifest load succeeds and runtime credential enablement fails with an explicit no-broker error when an authenticated command needs that provider. - `plugin.yaml` remains the enforceable runtime authority. `loadSkill` re-resolves the skill's parent plugin from its path, rejects mismatched plugin metadata, rebuilds metadata from the current skill file, and prepends a host-owned runtime boundary before the skill body. ## Discovery and loading @@ -273,7 +308,7 @@ System runtime dependency execution environment: **Sync phase** (module load): Read `plugin.yaml` manifests via `readFileSync`, register capabilities, config keys, OAuth config, and skill roots. This keeps `catalog.ts` sync-compatible. -**On-demand phase**: Create credential brokers when `factory.ts` constructs the runtime for plugins that declare credentials. The generic `oauth-bearer` broker is created from manifest config — no dynamic imports needed. +**On-demand phase**: Create credential brokers when `factory.ts` constructs the runtime for plugins that declare credentials or API headers. The generic `oauth-bearer` broker is created from manifest config — no dynamic imports needed. ### Load sequence @@ -294,7 +329,8 @@ The registry provides `createPluginBroker(provider, deps)` which constructs the - `oauth-bearer`: Creates a generic `OAuthBearerBroker` that handles per-user OAuth tokens, token refresh, static env fallback, and header transforms — all parameterized from the manifest. - `github-app`: Creates a `GitHubAppBroker` that signs JWTs with an RSA private key and exchanges them for short-lived installation tokens via the GitHub App API. No `UserTokenStore` dependency — tokens are per-installation, not per-user. -- no-credentials plugins: broker creation fails with a provider-scoped no-credentials error. +- plugin-level `api-headers`: Creates an `ApiHeadersBroker` for providers that only need header injection. Token-backed brokers include plugin-level API header transforms alongside their credential transforms; credential headers are applied last and win if both sources set the same header for the same domain. +- no-credentials/no-headers plugins: broker creation fails with a provider-scoped no-credentials error. ### Plugin registry exports @@ -347,14 +383,13 @@ All existing functions (`getCapabilityProvider`, `isKnownCapability`, etc.) work ```typescript for (const plugin of getPluginProviders()) { - const { credentials, name } = plugin.manifest; - if (!credentials) continue; + const { apiHeaders, credentials, name } = plugin.manifest; + if (!credentials && !apiHeaders) continue; brokersByProvider[name] = useTestBroker ? new TestCredentialBroker({ provider: name, - domains: credentials.apiDomains, - envKey: credentials.authTokenEnv, - placeholder: "host_managed_credential", + // token-backed credentials add domains/env placeholder; header-only + // plugins only add header transforms. }) : createPluginBroker(name, { userTokenStore }); } @@ -376,7 +411,7 @@ The OAuth callback route uses `getOAuthProviderConfig()` instead of accessing `O ### Test credential override -`TestCredentialBroker` substitution in eval mode works the same — `factory.ts` checks `EVAL_ENABLE_TEST_CREDENTIALS=1` and substitutes regardless of source. +`TestCredentialBroker` substitution in eval mode works the same — `factory.ts` checks `EVAL_ENABLE_TEST_CREDENTIALS=1` and substitutes regardless of source. For plugin-level `api-headers`, eval mode injects deterministic dummy header values instead of resolving deployment env vars. ### Install-wide config defaults @@ -444,7 +479,7 @@ Plugin skills are subject to the same frontmatter validation and name-deduplicat All existing security invariants from `security-policy.md` are preserved: - **Host-trusted code.** Plugin manifests are YAML files committed to the repository. No dynamic code loading. -- **Credential delivery via header transforms only.** The generic broker delivers tokens as `Authorization: Bearer` headers on each declared `api-domains` entry. The sandbox never sees real token values. +- **Credential delivery via header transforms only.** Token credentials and plugin-level `api-headers` are delivered as host-managed header transforms for declared `api-domains`. Real secret values never enter sandbox env vars, files, or command arguments. - **Short-lived leases.** Lease behavior is unchanged. The `CredentialLease` contract enforces expiry timestamps. - **No env var leakage.** Placeholder values are injected for the `auth-token-env` variable. - **OAuth privacy rules unchanged.** Authorization URLs are delivered privately. The agent never sees token values. @@ -515,7 +550,7 @@ oauth: - **MCP as the plugin protocol.** MCP is an optional tool source, not the plugin discovery protocol. - **Plugin sandboxing.** Broker logic runs on the host with full trust. - **Plugin versioning.** Plugins are part of the monorepo. -- **Custom per-plugin broker modules beyond supported types.** The `oauth-bearer` and `github-app` credential types cover current providers. More types can be added as needed. +- **Custom per-plugin broker modules beyond supported types.** The `oauth-bearer` and `github-app` credential types plus plugin-level `api-headers` cover current providers. More types can be added as needed. ## Spec invariants