Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions packages/docs/src/content/docs/extend/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -153,13 +156,14 @@ runtime-postinstall:
- `description`: short summary of what the plugin integrates
- `capabilities`: actions the plugin’s skills may request, qualified as `<plugin>.<capability>`
- `config-keys`: provider-specific configuration keys, qualified as `<plugin>.<key>`
- `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

Expand All @@ -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

Expand Down
50 changes: 47 additions & 3 deletions packages/junior/src/chat/capabilities/factory.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
})
Expand Down
2 changes: 1 addition & 1 deletion packages/junior/src/chat/capabilities/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export class SkillCapabilityRuntime {
}

const plugin = getPluginDefinition(provider);
if (!plugin?.manifest.credentials) {
if (!plugin?.manifest.credentials && !plugin?.manifest.apiHeaders) {
return undefined;
}

Expand Down
18 changes: 18 additions & 0 deletions packages/junior/src/chat/credentials/header-transforms.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, string>>();
for (const transform of transforms) {
byDomain.set(transform.domain, {
...(byDomain.get(transform.domain) ?? {}),
...transform.headers,
});
}
return [...byDomain.entries()].map(([domain, headers]) => ({
domain,
headers,
}));
}
35 changes: 23 additions & 12 deletions packages/junior/src/chat/credentials/test-broker.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
envKey: string;
placeholder: string;
headerTransforms?: () => CredentialHeaderTransform[];
envKey?: string;
placeholder?: string;
}
Comment thread
cursor[bot] marked this conversation as resolved.

/** Issue deterministic placeholder credential leases for eval runs. */
export class TestCredentialBroker implements CredentialBroker {
private readonly config: TestBrokerConfig;

Expand All @@ -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,
Expand Down
72 changes: 72 additions & 0 deletions packages/junior/src/chat/plugins/auth/api-headers-broker.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
): Record<string, string> {
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<CredentialLease> {
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,
},
};
},
};
}
10 changes: 7 additions & 3 deletions packages/junior/src/chat/plugins/auth/auth-token-placeholder.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import type { PluginCredentials } from "../types";
import type { GitHubAppCredentials, OAuthBearerCredentials } from "../types";

const DEFAULT_PLACEHOLDERS: Record<PluginCredentials["type"], string> = {
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() ||
Expand Down
Loading
Loading