Skip to content

Commit 7f72082

Browse files
stubbiclaude
andauthored
feat: declarative adapter registry (PAPERCLIP_ADAPTERS) (#104)
* feat(shared): add AdapterRegistryEntry declarative adapter type * feat(shared): zod validator for the declarative adapter registry * feat(plugin-kubernetes): resolve adapter defaults from a supplied registry + defaultEnv Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(plugin-kubernetes): accept declarative adapters registry in provider config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(plugin-kubernetes): use the configured registry + defaultEnv when building agent env Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(plugin-kubernetes): define adapter registry schema locally (no workspace dep) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(server): parse PAPERCLIP_ADAPTERS + reconcile adapter availability (fail loud) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(server): thread declared adapter registry into the kubernetes env config * feat(server): reconcile adapter availability from PAPERCLIP_ADAPTERS at startup --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 66c03ae commit 7f72082

16 files changed

Lines changed: 492 additions & 28 deletions

packages/plugins/sandbox-providers/kubernetes/src/adapter-defaults.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import type { AdapterRegistryEntry } from "./adapter-registry.js";
2+
13
export interface AdapterDefaults {
24
runtimeImage: string;
35
envKeys: string[];
46
allowFqdns: string[];
57
probeCommand: string[];
8+
/** Non-secret env injected as the base layer for the Job (process-env wins on top). */
9+
defaultEnv?: Record<string, string>;
610
}
711

812
const REGISTRY: Record<string, AdapterDefaults> = {
@@ -52,10 +56,61 @@ const REGISTRY: Record<string, AdapterDefaults> = {
5256

5357
export const KNOWN_ADAPTER_TYPES: ReadonlySet<string> = new Set(Object.keys(REGISTRY));
5458

55-
export function getAdapterDefaults(adapterType: string): AdapterDefaults {
59+
function fromRegistryEntry(entry: AdapterRegistryEntry): AdapterDefaults {
60+
// Only runtimeImage is strictly required. The array fields are optional and
61+
// default to []: the operator emits them with `omitempty`, so a genuinely
62+
// empty allowFqdns/envKeys/probeCommand arrives as undefined, which is valid
63+
// (no extra egress / no forwarded secrets / no probe), NOT an error.
64+
if (!entry.runtimeImage) {
65+
throw new Error(
66+
`Adapter "${entry.adapterType}" is missing required runtime field: runtimeImage`,
67+
);
68+
}
69+
return {
70+
runtimeImage: entry.runtimeImage,
71+
envKeys: entry.envKeys ?? [],
72+
allowFqdns: entry.allowFqdns ?? [],
73+
probeCommand: entry.probeCommand ?? [],
74+
defaultEnv: entry.defaultEnv,
75+
};
76+
}
77+
78+
/**
79+
* Resolve the runtime defaults for an adapter. When a `registry` is supplied it
80+
* is authoritative (replace semantics): the type MUST be present and complete,
81+
* else this throws. With no registry, falls back to the built-in REGISTRY.
82+
*/
83+
export function getAdapterDefaults(
84+
adapterType: string,
85+
registry?: readonly AdapterRegistryEntry[],
86+
): AdapterDefaults {
87+
if (registry && registry.length > 0) {
88+
const entry = registry.find((e) => e.adapterType === adapterType);
89+
if (!entry) {
90+
throw new Error(`Adapter "${adapterType}" is not in the configured adapter registry`);
91+
}
92+
return fromRegistryEntry(entry);
93+
}
5694
const defaults = REGISTRY[adapterType];
5795
if (!defaults) {
5896
throw new Error(`Unknown adapter type: ${adapterType}`);
5997
}
6098
return defaults;
6199
}
100+
101+
/**
102+
* Build the per-run env for the Job: the non-secret `defaultEnv` is the base
103+
* and the process-env values (the secret API keys named by `envKeys`) override
104+
* it. Pure for testability.
105+
*/
106+
export function buildAdapterEnv(
107+
defaults: AdapterDefaults,
108+
processEnv: NodeJS.ProcessEnv = process.env,
109+
): Record<string, string> {
110+
const out: Record<string, string> = { ...(defaults.defaultEnv ?? {}) };
111+
for (const k of defaults.envKeys) {
112+
const v = processEnv[k];
113+
if (v) out[k] = v;
114+
}
115+
return out;
116+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* One declarative agent-harness ("adapter") entry. Governs picker availability
5+
* and, for sandboxed (Kubernetes) runs, the runtime wiring.
6+
*
7+
* NOTE: this shape is intentionally duplicated across the package boundary. It
8+
* MUST stay structurally in sync with:
9+
* - server `@paperclipai/shared` `adapterRegistryEntrySchema` (the parser side)
10+
* - operator `AdapterRegistryEntry` Go struct (PAPERCLIP_ADAPTERS emitter)
11+
* The duplication is deliberate: this plugin is standalone-installable and must
12+
* not pull in heavy workspace packages at runtime.
13+
*/
14+
export const adapterRegistryEntrySchema = z
15+
.object({
16+
adapterType: z.string().min(1),
17+
enabled: z.boolean().default(true),
18+
runtimeImage: z.string().optional(),
19+
envKeys: z.array(z.string()).optional(),
20+
allowFqdns: z.array(z.string()).optional(),
21+
probeCommand: z.array(z.string()).optional(),
22+
defaultEnv: z.record(z.string()).optional(),
23+
})
24+
.strict();
25+
26+
export const adapterRegistrySchema = z.array(adapterRegistryEntrySchema);
27+
28+
export type AdapterRegistryEntry = z.infer<typeof adapterRegistryEntrySchema>;

packages/plugins/sandbox-providers/kubernetes/src/plugin.ts

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
type KubernetesLeaseMetadata,
2020
} from "./types.js";
2121
import { createKubeConfig, makeKubeClients } from "./kube-client.js";
22-
import { getAdapterDefaults } from "./adapter-defaults.js";
22+
import { getAdapterDefaults, buildAdapterEnv } from "./adapter-defaults.js";
2323
import { resolveImage } from "./image-allowlist.js";
2424
import { buildJobManifest } from "./pod-spec-builder.js";
2525
import { buildSandboxCrManifest } from "./sandbox-cr-builder.js";
@@ -64,24 +64,6 @@ function deriveTenantNamespace(config: KubernetesProviderConfig, companyId: stri
6464
return deriveNamespaceName(config.namespacePrefix, slug);
6565
}
6666

67-
/**
68-
* Reads adapter env keys (e.g. ANTHROPIC_API_KEY) from the current process
69-
* environment. The plugin worker runs inside paperclip-server's pod, which has
70-
* these vars injected at deploy time.
71-
*
72-
* M4b approach: env vars sourced from process.env at acquire time.
73-
* TODO: future milestones may thread per-run secrets differently (e.g. via
74-
* a secret store reference on the environment config).
75-
*/
76-
function extractAdapterEnvFromProcess(envKeys: string[]): Record<string, string> {
77-
const out: Record<string, string> = {};
78-
for (const k of envKeys) {
79-
const v = process.env[k];
80-
if (v) out[k] = v;
81-
}
82-
return out;
83-
}
84-
8567
function generateBootstrapToken(): string {
8668
// TODO: paperclip-server's actual callback auth scheme is separate and is
8769
// out of M4b scope. This per-run random token is stored in the per-run
@@ -136,7 +118,7 @@ const plugin = definePlugin({
136118
}
137119
const warnings: string[] = [];
138120
const cfg = parsed.data;
139-
const adapterDefaults = getAdapterDefaults(cfg.adapterType);
121+
const adapterDefaults = getAdapterDefaults(cfg.adapterType, cfg.adapters);
140122
const totalFqdns = [...adapterDefaults.allowFqdns, ...cfg.egressAllowFqdns];
141123
if (cfg.egressMode === "standard" && totalFqdns.length > 0) {
142124
if (cfg.egressAllowCidrs.length === 0) {
@@ -212,7 +194,7 @@ const plugin = definePlugin({
212194
// Emit a runtime warning if FQDNs are configured but egressMode=standard
213195
// cannot enforce them. Mirrors the validateConfig warning so operators see
214196
// it in paperclip-server logs even if they missed the validation step.
215-
const adapterDefaultsForWarn = getAdapterDefaults(config.adapterType);
197+
const adapterDefaultsForWarn = getAdapterDefaults(config.adapterType, config.adapters);
216198
const totalFqdnsForWarn = [...adapterDefaultsForWarn.allowFqdns, ...config.egressAllowFqdns];
217199
if (config.egressMode === "standard" && totalFqdnsForWarn.length > 0) {
218200
if (config.egressAllowCidrs.length === 0) {
@@ -234,7 +216,7 @@ const plugin = definePlugin({
234216

235217
// Ensure the tenant namespace and all its RBAC / network policy resources
236218
// exist before we try to create the Job.
237-
const adapterDefaults = getAdapterDefaults(config.adapterType);
219+
const adapterDefaults = getAdapterDefaults(config.adapterType, config.adapters);
238220

239221
await ensureTenant(clients, {
240222
namespace,
@@ -299,9 +281,9 @@ const plugin = definePlugin({
299281

300282
const { uid: ownerUid } = await orchestrator.claim(clients, namespace, manifest);
301283

302-
// M4b: adapter env vars are sourced from the plugin worker's own process
303-
// environment (paperclip-server pod has them injected at deploy time).
304-
const adapterEnv = extractAdapterEnvFromProcess(adapterDefaults.envKeys);
284+
// defaultEnv (non-secret base, e.g. the inference base URL) is layered first;
285+
// the process-env secrets named by envKeys override it.
286+
const adapterEnv = buildAdapterEnv(adapterDefaults);
305287
const bootstrapToken = generateBootstrapToken();
306288

307289
// Secret ownerRef: for job backend, the Job owns the Secret (cascade delete).

packages/plugins/sandbox-providers/kubernetes/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from "zod";
2+
import { adapterRegistrySchema } from "./adapter-registry.js";
23
import { KNOWN_ADAPTER_TYPES } from "./adapter-defaults.js";
34

45
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
@@ -45,6 +46,13 @@ export const kubernetesProviderConfigSchema = z
4546
message: "adapterType must be one of the known adapter types",
4647
}),
4748

49+
/**
50+
* Optional declarative adapter registry. When present it is authoritative
51+
* for runtime image / envKeys / allowFqdns / probe / defaultEnv resolution
52+
* (replace semantics). Absent = built-in defaults.
53+
*/
54+
adapters: adapterRegistrySchema.optional(),
55+
4856
/**
4957
* The sandbox backend to use.
5058
*

packages/plugins/sandbox-providers/kubernetes/test/unit/adapter-defaults.test.ts

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { describe, it, expect } from "vitest";
2-
import { getAdapterDefaults, KNOWN_ADAPTER_TYPES } from "../../src/adapter-defaults.js";
2+
import {
3+
getAdapterDefaults,
4+
buildAdapterEnv,
5+
KNOWN_ADAPTER_TYPES,
6+
type AdapterDefaults,
7+
} from "../../src/adapter-defaults.js";
8+
import type { AdapterRegistryEntry } from "../../src/adapter-registry.js";
39

4-
describe("adapter-defaults", () => {
10+
describe("adapter-defaults (built-in)", () => {
511
it("returns defaults for claude_local", () => {
612
const d = getAdapterDefaults("claude_local");
713
expect(d.runtimeImage).toBe("ghcr.io/paperclipai/agent-runtime-claude:v1");
@@ -35,3 +41,94 @@ describe("adapter-defaults", () => {
3541
);
3642
});
3743
});
44+
45+
describe("getAdapterDefaults", () => {
46+
it("returns built-in defaults when no registry is supplied", () => {
47+
const d = getAdapterDefaults("claude_local");
48+
expect(d.runtimeImage).toContain("agent-runtime-claude");
49+
expect(d.envKeys).toEqual(["ANTHROPIC_API_KEY"]);
50+
expect(d.defaultEnv).toBeUndefined();
51+
});
52+
53+
it("throws on an unknown built-in type when no registry is supplied", () => {
54+
expect(() => getAdapterDefaults("nope")).toThrow(/Unknown adapter type/);
55+
});
56+
57+
it("resolves from the supplied registry (replace semantics, not merge)", () => {
58+
const registry: AdapterRegistryEntry[] = [
59+
{
60+
adapterType: "opencode_local",
61+
enabled: true,
62+
runtimeImage: "registry.example/opencode:eu",
63+
envKeys: ["ANTHROPIC_API_KEY"],
64+
allowFqdns: [],
65+
probeCommand: ["opencode", "--version"],
66+
defaultEnv: { ANTHROPIC_BASE_URL: "http://bifrost:8080" },
67+
},
68+
];
69+
const d = getAdapterDefaults("opencode_local", registry);
70+
expect(d.runtimeImage).toBe("registry.example/opencode:eu");
71+
expect(d.defaultEnv).toEqual({ ANTHROPIC_BASE_URL: "http://bifrost:8080" });
72+
});
73+
74+
it("throws when the type is absent from a supplied registry", () => {
75+
const registry: AdapterRegistryEntry[] = [
76+
{
77+
adapterType: "opencode_local",
78+
runtimeImage: "x",
79+
envKeys: [],
80+
allowFqdns: [],
81+
probeCommand: ["x"],
82+
},
83+
];
84+
expect(() => getAdapterDefaults("claude_local", registry)).toThrow(
85+
/not in the configured adapter registry/,
86+
);
87+
});
88+
89+
it("throws when a supplied registry entry is missing runtimeImage", () => {
90+
const registry: AdapterRegistryEntry[] = [
91+
{ adapterType: "opencode_local", envKeys: [], allowFqdns: [], probeCommand: ["x"] },
92+
];
93+
expect(() => getAdapterDefaults("opencode_local", registry)).toThrow(
94+
/missing required runtime field: runtimeImage/,
95+
);
96+
});
97+
98+
it("defaults the optional array fields to [] when the registry omits them", () => {
99+
const registry: AdapterRegistryEntry[] = [
100+
{ adapterType: "opencode_local", runtimeImage: "img" },
101+
];
102+
const d = getAdapterDefaults("opencode_local", registry);
103+
expect(d.envKeys).toEqual([]);
104+
expect(d.allowFqdns).toEqual([]);
105+
expect(d.probeCommand).toEqual([]);
106+
});
107+
});
108+
109+
describe("buildAdapterEnv", () => {
110+
it("layers process-env (secret) over defaultEnv (non-secret base)", () => {
111+
const defaults: AdapterDefaults = {
112+
runtimeImage: "x",
113+
envKeys: ["ANTHROPIC_API_KEY"],
114+
allowFqdns: [],
115+
probeCommand: ["x"],
116+
defaultEnv: { ANTHROPIC_BASE_URL: "http://bifrost:8080", ANTHROPIC_API_KEY: "should-be-overridden" },
117+
};
118+
const env = buildAdapterEnv(defaults, { ANTHROPIC_API_KEY: "sk-real" });
119+
expect(env).toEqual({
120+
ANTHROPIC_BASE_URL: "http://bifrost:8080",
121+
ANTHROPIC_API_KEY: "sk-real",
122+
});
123+
});
124+
125+
it("omits process-env keys that are absent", () => {
126+
const defaults: AdapterDefaults = {
127+
runtimeImage: "x",
128+
envKeys: ["ANTHROPIC_API_KEY"],
129+
allowFqdns: [],
130+
probeCommand: ["x"],
131+
};
132+
expect(buildAdapterEnv(defaults, {})).toEqual({});
133+
});
134+
});

packages/shared/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,3 +1375,11 @@ export type {
13751375
EnvironmentProviderCapability,
13761376
EnvironmentSupportStatus,
13771377
} from "./environment-support.js";
1378+
1379+
export type { AdapterRegistryEntry } from "./types/adapter-registry.js";
1380+
1381+
export {
1382+
adapterRegistryEntrySchema,
1383+
adapterRegistrySchema,
1384+
type AdapterRegistryEntryParsed,
1385+
} from "./validators/adapter-registry.js";
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* One declarative agent-harness ("adapter") entry. The same shape is used for
3+
* local self-hosting and our operator/cloud: it governs both availability (the
4+
* picker) and, when the run is sandboxed on Kubernetes, the runtime wiring.
5+
*
6+
* Replace semantics: when a registry is supplied it is the COMPLETE declared
7+
* set. Adopt (built-in defaults) = no registry at all. Remove = omit the entry.
8+
* Add = include a new entry. Override = redefine an existing adapterType.
9+
*/
10+
export interface AdapterRegistryEntry {
11+
/** The harness, e.g. "opencode_local". */
12+
adapterType: string;
13+
/** Availability (both local + k8s). Default true. */
14+
enabled?: boolean;
15+
/** k8s-sandbox-only: container image the Job/Sandbox runs. */
16+
runtimeImage?: string;
17+
/** k8s-sandbox-only: process-env keys forwarded into the Job (e.g. ANTHROPIC_API_KEY). */
18+
envKeys?: string[];
19+
/** k8s-sandbox-only: egress FQDN allow-list for the agent pod. */
20+
allowFqdns?: string[];
21+
/** k8s-sandbox-only: liveness/probe command. */
22+
probeCommand?: string[];
23+
/**
24+
* Non-secret env injected into the Job/Sandbox as the BASE; the process-env
25+
* values (the secret API key, via envKeys) override it. Carries e.g.
26+
* ANTHROPIC_BASE_URL pointing at the in-cluster Bifrost gateway. NEVER put
27+
* secrets here.
28+
*/
29+
defaultEnv?: Record<string, string>;
30+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "vitest";
2+
import { adapterRegistrySchema } from "./adapter-registry.js";
3+
4+
describe("adapterRegistrySchema", () => {
5+
it("parses a full entry", () => {
6+
const parsed = adapterRegistrySchema.parse([
7+
{
8+
adapterType: "opencode_local",
9+
runtimeImage: "ghcr.io/paperclipai/agent-runtime-opencode:v1",
10+
envKeys: ["ANTHROPIC_API_KEY"],
11+
allowFqdns: [],
12+
probeCommand: ["opencode", "--version"],
13+
defaultEnv: { ANTHROPIC_BASE_URL: "http://bifrost.bifrost.svc.cluster.local:8080" },
14+
},
15+
]);
16+
expect(parsed[0].adapterType).toBe("opencode_local");
17+
expect(parsed[0].enabled).toBe(true); // defaulted
18+
expect(parsed[0].defaultEnv?.ANTHROPIC_BASE_URL).toContain("bifrost");
19+
});
20+
21+
it("defaults enabled to true and optional collections to undefined", () => {
22+
const parsed = adapterRegistrySchema.parse([{ adapterType: "pi_local" }]);
23+
expect(parsed[0]).toMatchObject({ adapterType: "pi_local", enabled: true });
24+
expect(parsed[0].runtimeImage).toBeUndefined();
25+
});
26+
27+
it("rejects an entry with no adapterType", () => {
28+
expect(() => adapterRegistrySchema.parse([{ enabled: true }])).toThrow();
29+
});
30+
31+
it("rejects a non-array", () => {
32+
expect(() => adapterRegistrySchema.parse({ adapterType: "x" })).toThrow();
33+
});
34+
});

0 commit comments

Comments
 (0)