@crewhaus/plugin-sdk 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ import type { RegisteredTool, ToolDefinition } from "@crewhaus/tool-catalog";
3
+ /**
4
+ * Section 41 — `@crewhaus/plugin-sdk` v2.
5
+ *
6
+ * Public typed surface for third-party plugins. A plugin is a single
7
+ * TS/JS module exporting `definePlugin({ … })`. The §41 `plugin-loader`
8
+ * loads + activates plugins at runtime; the §42 `plugin-registry`
9
+ * discovers them; the §40 sigstore-style signature verification runs
10
+ * before either.
11
+ *
12
+ * v2 widens the v1 surface (Studio-only — see `crewhaus/utilities/studio-plugin-sdk`)
13
+ * to cover the five extension points the catalog cares about:
14
+ *
15
+ * 1. **Tools** — anything you would otherwise pass to `buildTool()`.
16
+ * 2. **Channels** — `ChannelAdapter`-shaped inbound surface (Slack,
17
+ * Telegram, … plus future plugins like Mastodon, IRC, etc.).
18
+ * 3. **Models** — provider adapters that match the canonical
19
+ * `ProviderAdapter` contract from `adapter-anthropic`.
20
+ * 4. **Graders** — evaluators that match the `RegisteredGrader`
21
+ * contract from `grader-registry`.
22
+ * 5. **Target emitters** — compile-time target backends that match
23
+ * the `Emitter` contract from `compiler-core`.
24
+ *
25
+ * The contributions are *declarations*; `plugin-loader` is responsible
26
+ * for wiring each declaration into the host's registry at runtime
27
+ * (and for enforcing the `permissions` allow-list).
28
+ *
29
+ * The SDK is intentionally **dependency-light**: it only imports
30
+ * `@crewhaus/errors` + `@crewhaus/tool-catalog` (to expose the
31
+ * `ToolDefinition` type plugins already know). Other contract types
32
+ * are re-exported as structural shapes so a plugin author doesn't
33
+ * have to pull in five workspace packages just to declare a manifest.
34
+ */
35
+ export declare class PluginSdkError extends CrewhausError {
36
+ readonly name = "PluginSdkError";
37
+ constructor(message: string, cause?: unknown);
38
+ }
39
+ export type { RegisteredTool, ToolDefinition } from "@crewhaus/tool-catalog";
40
+ /**
41
+ * Structural shape of a channel adapter contribution. Matches the
42
+ * `ChannelAdapter` interface duplicated across `channel-adapter-slack`
43
+ * / `-telegram` / `-discord` / `-whatsapp` / `-imessage`. Plugins
44
+ * implement this shape directly; `plugin-loader` adapts it into the
45
+ * channel registry slot for the target shape.
46
+ */
47
+ export interface PluginChannelAdapter {
48
+ readonly id: string;
49
+ verify(req: {
50
+ headers: Headers;
51
+ body: string;
52
+ }): Promise<boolean>;
53
+ parseInbound(req: {
54
+ headers: Headers;
55
+ body: string;
56
+ }): Promise<unknown>;
57
+ sendReply(args: {
58
+ channelId: string;
59
+ threadKey?: string;
60
+ text: string;
61
+ [k: string]: unknown;
62
+ }): Promise<void>;
63
+ setTyping?(args: {
64
+ channelId: string;
65
+ on: boolean;
66
+ }): Promise<void>;
67
+ }
68
+ /**
69
+ * Structural shape of a provider adapter. Mirrors the `ProviderAdapter`
70
+ * exported from `adapter-anthropic` without forcing the SDK to import
71
+ * that package's transitive deps. The full provider request / stream
72
+ * event types live in `adapter-anthropic`; plugins implementing this
73
+ * type should import those for accurate parameter shapes.
74
+ */
75
+ export interface PluginModelAdapter {
76
+ readonly id: string;
77
+ readonly features: {
78
+ readonly caching?: boolean;
79
+ readonly tool_use?: boolean;
80
+ readonly vision?: boolean;
81
+ readonly thinking?: boolean;
82
+ readonly web_search?: boolean;
83
+ };
84
+ stream(request: unknown): AsyncIterable<unknown>;
85
+ countTokens?(messages: unknown): Promise<number>;
86
+ }
87
+ /**
88
+ * Structural shape of a grader contribution. Matches `RegisteredGrader`
89
+ * from `grader-registry`.
90
+ */
91
+ export interface PluginGrader {
92
+ readonly id: string;
93
+ readonly description?: string;
94
+ grade(sample: {
95
+ input: unknown;
96
+ output: unknown;
97
+ expected?: unknown;
98
+ }): Promise<{
99
+ pass: boolean;
100
+ score?: number;
101
+ notes?: string;
102
+ }>;
103
+ }
104
+ /**
105
+ * Structural shape of a target emitter contribution. Matches the
106
+ * `Emitter` contract from `compiler-core` — a function that takes the
107
+ * IR variant and returns a `Bundle` (file list).
108
+ */
109
+ export interface PluginTargetEmitter {
110
+ readonly targetShape: string;
111
+ emit(ir: unknown): {
112
+ readonly files: ReadonlyArray<{
113
+ readonly path: string;
114
+ readonly contents: string;
115
+ }>;
116
+ };
117
+ }
118
+ /**
119
+ * Capability declarations. Fail-closed — an undefined section means the
120
+ * plugin has zero access to that resource class.
121
+ */
122
+ export type PluginPermissions = {
123
+ /** Filesystem allow-list (minimatch globs relative to the plugin's sandbox root). */
124
+ readonly fs?: ReadonlyArray<string>;
125
+ /** URL prefix allow-list for `fetch()` from inside the plugin. */
126
+ readonly net?: ReadonlyArray<string>;
127
+ /** Names of host-provided tools the plugin's tools may call. */
128
+ readonly tools?: ReadonlyArray<string>;
129
+ /** Env-var names the plugin is permitted to read via the host's `secrets-manager`. */
130
+ readonly secrets?: ReadonlyArray<string>;
131
+ };
132
+ export type PluginSignatureAlgorithm = "ed25519";
133
+ /**
134
+ * Detached signature over the canonical-JSON serialisation of the
135
+ * manifest with `signature` set to `undefined`. Verified by §42
136
+ * `plugin-registry` before any source is read.
137
+ */
138
+ export type PluginSignature = {
139
+ readonly algorithm: PluginSignatureAlgorithm;
140
+ readonly publicKeyB64: string;
141
+ readonly sigB64: string;
142
+ /** Optional ISO-8601 timestamp; advisory only. */
143
+ readonly issuedAt?: string;
144
+ };
145
+ export type PluginContributions = {
146
+ readonly tools?: ReadonlyArray<RegisteredTool | ToolDefinition>;
147
+ readonly channels?: ReadonlyArray<PluginChannelAdapter>;
148
+ readonly models?: ReadonlyArray<PluginModelAdapter>;
149
+ readonly graders?: ReadonlyArray<PluginGrader>;
150
+ readonly targetEmitters?: ReadonlyArray<PluginTargetEmitter>;
151
+ };
152
+ export type PluginManifest = {
153
+ /** Globally-unique kebab-case plugin id. */
154
+ readonly name: string;
155
+ /** Semver-shaped version string (validated by `validatePluginManifest`). */
156
+ readonly version: string;
157
+ readonly description?: string;
158
+ readonly author?: string;
159
+ readonly homepage?: string;
160
+ readonly license?: string;
161
+ /** Minimum crewhaus runtime version this plugin requires (semver range). */
162
+ readonly engines?: {
163
+ readonly crewhaus?: string;
164
+ };
165
+ readonly permissions?: PluginPermissions;
166
+ readonly contributions?: PluginContributions;
167
+ /**
168
+ * Lowercase hex SHA-256 of the plugin's entrypoint (`index.js`). It is part
169
+ * of the manifest, so `manifestPayloadForSigning` includes it and the
170
+ * signature therefore commits to the CODE, not just the metadata. The loader
171
+ * refuses to `import()` an entrypoint whose hash does not match. Optional for
172
+ * back-compat; signed plugins SHOULD set it (compute via `entrypointDigest`).
173
+ */
174
+ readonly entrypointDigest?: string;
175
+ readonly signature?: PluginSignature;
176
+ };
177
+ /**
178
+ * Throw `PluginSdkError` if `m` is not a valid `PluginManifest`. Returns
179
+ * the input typed as `PluginManifest` on success (used as a type guard).
180
+ */
181
+ export declare function validatePluginManifest(m: unknown): PluginManifest;
182
+ /**
183
+ * Type-only helper plugins use:
184
+ * export default definePlugin({ name, version, contributions, … });
185
+ *
186
+ * Runs `validatePluginManifest` so misconfiguration fails at load time
187
+ * (before the plugin's tools are exposed to a host).
188
+ */
189
+ export declare function definePlugin<T extends PluginManifest>(def: T): T;
190
+ /**
191
+ * Deterministic JSON serialisation: sorted keys, no whitespace, `undefined`
192
+ * keys omitted. This is the byte string that the `signature` is computed
193
+ * over (with `signature` itself set to `undefined`).
194
+ *
195
+ * Mirrors the §40 template-marketplace-client canonical-JSON convention
196
+ * so plugin signatures verify with the same crypto primitive.
197
+ */
198
+ export declare function canonicalJson(value: unknown): string;
199
+ /**
200
+ * Returns the byte string the plugin's signature should verify against
201
+ * — the manifest's canonical JSON with `signature` cleared.
202
+ */
203
+ export declare function manifestPayloadForSigning(manifest: PluginManifest): string;
204
+ /**
205
+ * Compute the `entrypointDigest` for plugin code — the lowercase hex SHA-256 of
206
+ * the entrypoint file's bytes. Plugin signing tools set the result on the
207
+ * manifest's `entrypointDigest` before signing; the loader recomputes it from
208
+ * the on-disk `index.js` and refuses to import on mismatch.
209
+ */
210
+ export declare function entrypointDigest(code: string | Uint8Array): string;
package/dist/index.js ADDED
@@ -0,0 +1,193 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ /**
4
+ * Section 41 — `@crewhaus/plugin-sdk` v2.
5
+ *
6
+ * Public typed surface for third-party plugins. A plugin is a single
7
+ * TS/JS module exporting `definePlugin({ … })`. The §41 `plugin-loader`
8
+ * loads + activates plugins at runtime; the §42 `plugin-registry`
9
+ * discovers them; the §40 sigstore-style signature verification runs
10
+ * before either.
11
+ *
12
+ * v2 widens the v1 surface (Studio-only — see `crewhaus/utilities/studio-plugin-sdk`)
13
+ * to cover the five extension points the catalog cares about:
14
+ *
15
+ * 1. **Tools** — anything you would otherwise pass to `buildTool()`.
16
+ * 2. **Channels** — `ChannelAdapter`-shaped inbound surface (Slack,
17
+ * Telegram, … plus future plugins like Mastodon, IRC, etc.).
18
+ * 3. **Models** — provider adapters that match the canonical
19
+ * `ProviderAdapter` contract from `adapter-anthropic`.
20
+ * 4. **Graders** — evaluators that match the `RegisteredGrader`
21
+ * contract from `grader-registry`.
22
+ * 5. **Target emitters** — compile-time target backends that match
23
+ * the `Emitter` contract from `compiler-core`.
24
+ *
25
+ * The contributions are *declarations*; `plugin-loader` is responsible
26
+ * for wiring each declaration into the host's registry at runtime
27
+ * (and for enforcing the `permissions` allow-list).
28
+ *
29
+ * The SDK is intentionally **dependency-light**: it only imports
30
+ * `@crewhaus/errors` + `@crewhaus/tool-catalog` (to expose the
31
+ * `ToolDefinition` type plugins already know). Other contract types
32
+ * are re-exported as structural shapes so a plugin author doesn't
33
+ * have to pull in five workspace packages just to declare a manifest.
34
+ */
35
+ export class PluginSdkError extends CrewhausError {
36
+ name = "PluginSdkError";
37
+ constructor(message, cause) {
38
+ super("config", message, cause);
39
+ }
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Validation
43
+ // ---------------------------------------------------------------------------
44
+ const NAME_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
45
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[\w.+-]+)?(?:\+[\w.-]+)?$/;
46
+ function assertString(value, field) {
47
+ if (typeof value !== "string" || value.length === 0) {
48
+ throw new PluginSdkError(`plugin manifest: \`${field}\` must be a non-empty string`);
49
+ }
50
+ }
51
+ function assertOptionalString(value, field) {
52
+ if (value !== undefined && (typeof value !== "string" || value.length === 0)) {
53
+ throw new PluginSdkError(`plugin manifest: \`${field}\` must be a non-empty string when present`);
54
+ }
55
+ }
56
+ function assertOptionalStringArray(value, field) {
57
+ if (value === undefined)
58
+ return;
59
+ if (!Array.isArray(value)) {
60
+ throw new PluginSdkError(`plugin manifest: \`${field}\` must be an array of strings`);
61
+ }
62
+ for (const item of value) {
63
+ if (typeof item !== "string" || item.length === 0) {
64
+ throw new PluginSdkError(`plugin manifest: \`${field}\` entries must be non-empty strings`);
65
+ }
66
+ }
67
+ }
68
+ /**
69
+ * Throw `PluginSdkError` if `m` is not a valid `PluginManifest`. Returns
70
+ * the input typed as `PluginManifest` on success (used as a type guard).
71
+ */
72
+ export function validatePluginManifest(m) {
73
+ if (m === null || typeof m !== "object") {
74
+ throw new PluginSdkError("plugin manifest must be an object");
75
+ }
76
+ const manifest = m;
77
+ assertString(manifest["name"], "name");
78
+ if (!NAME_PATTERN.test(manifest["name"])) {
79
+ throw new PluginSdkError(`plugin manifest: \`name\` must be 3-64 chars, lowercase a-z / 0-9 / "-", start with a letter, no trailing hyphen (got "${manifest["name"]}")`);
80
+ }
81
+ assertString(manifest["version"], "version");
82
+ if (!SEMVER_PATTERN.test(manifest["version"])) {
83
+ throw new PluginSdkError(`plugin manifest: \`version\` must be semver-shaped (got "${manifest["version"]}")`);
84
+ }
85
+ assertOptionalString(manifest["description"], "description");
86
+ assertOptionalString(manifest["author"], "author");
87
+ assertOptionalString(manifest["homepage"], "homepage");
88
+ assertOptionalString(manifest["license"], "license");
89
+ if (manifest["entrypointDigest"] !== undefined) {
90
+ assertString(manifest["entrypointDigest"], "entrypointDigest");
91
+ if (!/^[a-f0-9]{64}$/.test(manifest["entrypointDigest"])) {
92
+ throw new PluginSdkError("plugin manifest: `entrypointDigest` must be a lowercase hex SHA-256 (64 chars)");
93
+ }
94
+ }
95
+ if (manifest["engines"] !== undefined) {
96
+ const engines = manifest["engines"];
97
+ if (engines === null || typeof engines !== "object") {
98
+ throw new PluginSdkError("plugin manifest: `engines` must be an object");
99
+ }
100
+ assertOptionalString(engines["crewhaus"], "engines.crewhaus");
101
+ }
102
+ if (manifest["permissions"] !== undefined) {
103
+ const perms = manifest["permissions"];
104
+ if (perms === null || typeof perms !== "object") {
105
+ throw new PluginSdkError("plugin manifest: `permissions` must be an object");
106
+ }
107
+ const p = perms;
108
+ assertOptionalStringArray(p["fs"], "permissions.fs");
109
+ assertOptionalStringArray(p["net"], "permissions.net");
110
+ assertOptionalStringArray(p["tools"], "permissions.tools");
111
+ assertOptionalStringArray(p["secrets"], "permissions.secrets");
112
+ }
113
+ if (manifest["signature"] !== undefined) {
114
+ const sig = manifest["signature"];
115
+ if (sig === null || typeof sig !== "object") {
116
+ throw new PluginSdkError("plugin manifest: `signature` must be an object");
117
+ }
118
+ const s = sig;
119
+ if (s["algorithm"] !== "ed25519") {
120
+ throw new PluginSdkError('plugin manifest: `signature.algorithm` must be "ed25519"');
121
+ }
122
+ assertString(s["publicKeyB64"], "signature.publicKeyB64");
123
+ assertString(s["sigB64"], "signature.sigB64");
124
+ assertOptionalString(s["issuedAt"], "signature.issuedAt");
125
+ }
126
+ return manifest;
127
+ }
128
+ /**
129
+ * Type-only helper plugins use:
130
+ * export default definePlugin({ name, version, contributions, … });
131
+ *
132
+ * Runs `validatePluginManifest` so misconfiguration fails at load time
133
+ * (before the plugin's tools are exposed to a host).
134
+ */
135
+ export function definePlugin(def) {
136
+ validatePluginManifest(def);
137
+ return def;
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // Canonical JSON (for signature payload)
141
+ // ---------------------------------------------------------------------------
142
+ /**
143
+ * Deterministic JSON serialisation: sorted keys, no whitespace, `undefined`
144
+ * keys omitted. This is the byte string that the `signature` is computed
145
+ * over (with `signature` itself set to `undefined`).
146
+ *
147
+ * Mirrors the §40 template-marketplace-client canonical-JSON convention
148
+ * so plugin signatures verify with the same crypto primitive.
149
+ */
150
+ export function canonicalJson(value) {
151
+ if (value === null)
152
+ return "null";
153
+ if (typeof value === "boolean")
154
+ return value ? "true" : "false";
155
+ if (typeof value === "number") {
156
+ if (!Number.isFinite(value)) {
157
+ throw new PluginSdkError("canonical JSON: non-finite numbers are not representable");
158
+ }
159
+ return JSON.stringify(value);
160
+ }
161
+ if (typeof value === "string")
162
+ return JSON.stringify(value);
163
+ if (Array.isArray(value)) {
164
+ return `[${value.map((v) => canonicalJson(v)).join(",")}]`;
165
+ }
166
+ if (typeof value === "object") {
167
+ const obj = value;
168
+ const keys = Object.keys(obj)
169
+ .filter((k) => obj[k] !== undefined)
170
+ .sort();
171
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
172
+ }
173
+ throw new PluginSdkError(`canonical JSON: unsupported type ${typeof value}`);
174
+ }
175
+ /**
176
+ * Returns the byte string the plugin's signature should verify against
177
+ * — the manifest's canonical JSON with `signature` cleared.
178
+ */
179
+ export function manifestPayloadForSigning(manifest) {
180
+ // Shallow clone, drop signature, then canonical-encode. `entrypointDigest`
181
+ // is NOT dropped, so the signature commits to the plugin code via its hash.
182
+ const { signature: _signature, ...rest } = manifest;
183
+ return canonicalJson(rest);
184
+ }
185
+ /**
186
+ * Compute the `entrypointDigest` for plugin code — the lowercase hex SHA-256 of
187
+ * the entrypoint file's bytes. Plugin signing tools set the result on the
188
+ * manifest's `entrypointDigest` before signing; the loader recomputes it from
189
+ * the on-disk `index.js` and refuses to import on mismatch.
190
+ */
191
+ export function entrypointDigest(code) {
192
+ return createHash("sha256").update(code).digest("hex");
193
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/plugin-sdk",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Plugin SDK v2 — public typed surface for third-party tools / channels / models / graders / target backends, with manifest validation and Ed25519 signature verification (Section 41)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.3",
16
- "@crewhaus/tool-catalog": "0.1.3"
18
+ "@crewhaus/errors": "0.1.5",
19
+ "@crewhaus/tool-catalog": "0.1.5"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }
package/src/index.test.ts DELETED
@@ -1,237 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- PluginSdkError,
4
- canonicalJson,
5
- definePlugin,
6
- manifestPayloadForSigning,
7
- validatePluginManifest,
8
- } from "./index";
9
-
10
- describe("validatePluginManifest", () => {
11
- test("accepts a minimal valid manifest", () => {
12
- const m = validatePluginManifest({ name: "my-plugin", version: "1.2.3" });
13
- expect(m.name).toBe("my-plugin");
14
- expect(m.version).toBe("1.2.3");
15
- });
16
-
17
- test("accepts pre-release + build metadata in semver", () => {
18
- expect(() =>
19
- validatePluginManifest({ name: "my-plugin", version: "1.2.3-beta.1+build.42" }),
20
- ).not.toThrow();
21
- });
22
-
23
- test("rejects missing name", () => {
24
- expect(() => validatePluginManifest({ version: "1.0.0" })).toThrow(PluginSdkError);
25
- });
26
-
27
- test("rejects invalid name", () => {
28
- expect(() => validatePluginManifest({ name: "MyPlugin", version: "1.0.0" })).toThrow(
29
- PluginSdkError,
30
- );
31
- expect(() => validatePluginManifest({ name: "-leading", version: "1.0.0" })).toThrow(
32
- PluginSdkError,
33
- );
34
- expect(() => validatePluginManifest({ name: "trailing-", version: "1.0.0" })).toThrow(
35
- PluginSdkError,
36
- );
37
- expect(() => validatePluginManifest({ name: "1starts-with-digit", version: "1.0.0" })).toThrow(
38
- PluginSdkError,
39
- );
40
- });
41
-
42
- test("rejects non-semver version", () => {
43
- expect(() => validatePluginManifest({ name: "ok-name", version: "v1.2.3" })).toThrow(
44
- PluginSdkError,
45
- );
46
- expect(() => validatePluginManifest({ name: "ok-name", version: "1.2" })).toThrow(
47
- PluginSdkError,
48
- );
49
- });
50
-
51
- test("validates engines.crewhaus when present", () => {
52
- expect(() =>
53
- validatePluginManifest({
54
- name: "ok-name",
55
- version: "1.0.0",
56
- engines: { crewhaus: "^0.2.0" },
57
- }),
58
- ).not.toThrow();
59
- expect(() =>
60
- validatePluginManifest({
61
- name: "ok-name",
62
- version: "1.0.0",
63
- engines: { crewhaus: 42 },
64
- }),
65
- ).toThrow(PluginSdkError);
66
- });
67
-
68
- test("validates permissions arrays are string arrays", () => {
69
- expect(() =>
70
- validatePluginManifest({
71
- name: "ok-name",
72
- version: "1.0.0",
73
- permissions: { fs: ["read:./data/**"], net: ["fetch:https://api.example.com/**"] },
74
- }),
75
- ).not.toThrow();
76
- expect(() =>
77
- validatePluginManifest({
78
- name: "ok-name",
79
- version: "1.0.0",
80
- permissions: { fs: [42] },
81
- }),
82
- ).toThrow(PluginSdkError);
83
- expect(() =>
84
- validatePluginManifest({
85
- name: "ok-name",
86
- version: "1.0.0",
87
- permissions: { tools: "Bash" }, // must be array
88
- }),
89
- ).toThrow(PluginSdkError);
90
- });
91
-
92
- test("validates signature shape", () => {
93
- const ok = {
94
- name: "ok-name",
95
- version: "1.0.0",
96
- signature: {
97
- algorithm: "ed25519",
98
- publicKeyB64: "base64-data",
99
- sigB64: "base64-sig",
100
- },
101
- };
102
- expect(() => validatePluginManifest(ok)).not.toThrow();
103
- });
104
-
105
- test("rejects non-ed25519 algorithms", () => {
106
- expect(() =>
107
- validatePluginManifest({
108
- name: "ok-name",
109
- version: "1.0.0",
110
- signature: { algorithm: "rsa", publicKeyB64: "x", sigB64: "y" },
111
- }),
112
- ).toThrow(PluginSdkError);
113
- });
114
-
115
- test("rejects signature missing publicKeyB64 / sigB64", () => {
116
- expect(() =>
117
- validatePluginManifest({
118
- name: "ok-name",
119
- version: "1.0.0",
120
- signature: { algorithm: "ed25519", publicKeyB64: "x" },
121
- }),
122
- ).toThrow(PluginSdkError);
123
- });
124
-
125
- test("rejects non-object manifest", () => {
126
- expect(() => validatePluginManifest(null)).toThrow(PluginSdkError);
127
- expect(() => validatePluginManifest("not an object")).toThrow(PluginSdkError);
128
- expect(() => validatePluginManifest(42)).toThrow(PluginSdkError);
129
- });
130
- });
131
-
132
- describe("definePlugin", () => {
133
- test("returns the input unchanged on success", () => {
134
- const def = definePlugin({
135
- name: "my-plugin",
136
- version: "1.0.0",
137
- description: "demo",
138
- });
139
- expect(def.name).toBe("my-plugin");
140
- expect(def.description).toBe("demo");
141
- });
142
-
143
- test("throws on invalid manifest", () => {
144
- expect(() => definePlugin({ name: "MyPlugin", version: "1.0.0" } as never)).toThrow(
145
- PluginSdkError,
146
- );
147
- });
148
-
149
- test("carries contribution types through", () => {
150
- const def = definePlugin({
151
- name: "my-plugin",
152
- version: "1.0.0",
153
- contributions: {
154
- graders: [
155
- {
156
- id: "my-grader",
157
- async grade() {
158
- return { pass: true };
159
- },
160
- },
161
- ],
162
- },
163
- });
164
- expect(def.contributions?.graders?.length).toBe(1);
165
- });
166
- });
167
-
168
- describe("canonicalJson", () => {
169
- test("serialises primitives", () => {
170
- expect(canonicalJson(null)).toBe("null");
171
- expect(canonicalJson(true)).toBe("true");
172
- expect(canonicalJson(false)).toBe("false");
173
- expect(canonicalJson(42)).toBe("42");
174
- expect(canonicalJson("hi")).toBe('"hi"');
175
- });
176
-
177
- test("rejects non-finite numbers", () => {
178
- expect(() => canonicalJson(Number.NaN)).toThrow(PluginSdkError);
179
- expect(() => canonicalJson(Number.POSITIVE_INFINITY)).toThrow(PluginSdkError);
180
- });
181
-
182
- test("sorts object keys deterministically", () => {
183
- const a = canonicalJson({ z: 1, a: 2, m: 3 });
184
- const b = canonicalJson({ a: 2, m: 3, z: 1 });
185
- expect(a).toBe(b);
186
- expect(a).toBe('{"a":2,"m":3,"z":1}');
187
- });
188
-
189
- test("recurses into nested objects + arrays", () => {
190
- const out = canonicalJson({
191
- list: [{ b: 2, a: 1 }, "str"],
192
- obj: { z: { y: 1 } },
193
- });
194
- expect(out).toBe('{"list":[{"a":1,"b":2},"str"],"obj":{"z":{"y":1}}}');
195
- });
196
-
197
- test("drops undefined keys", () => {
198
- const out = canonicalJson({ a: 1, b: undefined, c: 3 });
199
- expect(out).toBe('{"a":1,"c":3}');
200
- });
201
- });
202
-
203
- describe("manifestPayloadForSigning", () => {
204
- test("excludes signature from the canonical payload", () => {
205
- const manifest = {
206
- name: "my-plugin",
207
- version: "1.0.0",
208
- signature: {
209
- algorithm: "ed25519" as const,
210
- publicKeyB64: "pk",
211
- sigB64: "sig",
212
- },
213
- };
214
- const payload = manifestPayloadForSigning(manifest);
215
- expect(payload).not.toContain("signature");
216
- expect(payload).not.toContain("ed25519");
217
- expect(payload).toContain("my-plugin");
218
- });
219
-
220
- test("matches canonicalJson when no signature is present", () => {
221
- const manifest = { name: "my-plugin", version: "1.0.0" };
222
- expect(manifestPayloadForSigning(manifest)).toBe(canonicalJson(manifest));
223
- });
224
-
225
- test("signing payload is stable across signature mutations", () => {
226
- const base = { name: "my-plugin", version: "1.0.0", description: "demo" };
227
- const signed1 = {
228
- ...base,
229
- signature: { algorithm: "ed25519" as const, publicKeyB64: "a", sigB64: "b" },
230
- };
231
- const signed2 = {
232
- ...base,
233
- signature: { algorithm: "ed25519" as const, publicKeyB64: "c", sigB64: "d" },
234
- };
235
- expect(manifestPayloadForSigning(signed1)).toBe(manifestPayloadForSigning(signed2));
236
- });
237
- });
package/src/index.ts DELETED
@@ -1,357 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { CrewhausError } from "@crewhaus/errors";
3
- import type { RegisteredTool, ToolDefinition } from "@crewhaus/tool-catalog";
4
-
5
- /**
6
- * Section 41 — `@crewhaus/plugin-sdk` v2.
7
- *
8
- * Public typed surface for third-party plugins. A plugin is a single
9
- * TS/JS module exporting `definePlugin({ … })`. The §41 `plugin-loader`
10
- * loads + activates plugins at runtime; the §42 `plugin-registry`
11
- * discovers them; the §40 sigstore-style signature verification runs
12
- * before either.
13
- *
14
- * v2 widens the v1 surface (Studio-only — see `crewhaus/utilities/plugin-sdk`)
15
- * to cover the five extension points the catalog cares about:
16
- *
17
- * 1. **Tools** — anything you would otherwise pass to `buildTool()`.
18
- * 2. **Channels** — `ChannelAdapter`-shaped inbound surface (Slack,
19
- * Telegram, … plus future plugins like Mastodon, IRC, etc.).
20
- * 3. **Models** — provider adapters that match the canonical
21
- * `ProviderAdapter` contract from `adapter-anthropic`.
22
- * 4. **Graders** — evaluators that match the `RegisteredGrader`
23
- * contract from `grader-registry`.
24
- * 5. **Target emitters** — compile-time target backends that match
25
- * the `Emitter` contract from `compiler-core`.
26
- *
27
- * The contributions are *declarations*; `plugin-loader` is responsible
28
- * for wiring each declaration into the host's registry at runtime
29
- * (and for enforcing the `permissions` allow-list).
30
- *
31
- * The SDK is intentionally **dependency-light**: it only imports
32
- * `@crewhaus/errors` + `@crewhaus/tool-catalog` (to expose the
33
- * `ToolDefinition` type plugins already know). Other contract types
34
- * are re-exported as structural shapes so a plugin author doesn't
35
- * have to pull in five workspace packages just to declare a manifest.
36
- */
37
-
38
- export class PluginSdkError extends CrewhausError {
39
- override readonly name = "PluginSdkError";
40
- constructor(message: string, cause?: unknown) {
41
- super("config", message, cause);
42
- }
43
- }
44
-
45
- // ---------------------------------------------------------------------------
46
- // Re-exported contract types
47
- // ---------------------------------------------------------------------------
48
-
49
- export type { RegisteredTool, ToolDefinition } from "@crewhaus/tool-catalog";
50
-
51
- /**
52
- * Structural shape of a channel adapter contribution. Matches the
53
- * `ChannelAdapter` interface duplicated across `channel-adapter-slack`
54
- * / `-telegram` / `-discord` / `-whatsapp` / `-imessage`. Plugins
55
- * implement this shape directly; `plugin-loader` adapts it into the
56
- * channel registry slot for the target shape.
57
- */
58
- export interface PluginChannelAdapter {
59
- readonly id: string;
60
- verify(req: { headers: Headers; body: string }): Promise<boolean>;
61
- parseInbound(req: { headers: Headers; body: string }): Promise<unknown>;
62
- sendReply(args: {
63
- channelId: string;
64
- threadKey?: string;
65
- text: string;
66
- [k: string]: unknown;
67
- }): Promise<void>;
68
- setTyping?(args: { channelId: string; on: boolean }): Promise<void>;
69
- }
70
-
71
- /**
72
- * Structural shape of a provider adapter. Mirrors the `ProviderAdapter`
73
- * exported from `adapter-anthropic` without forcing the SDK to import
74
- * that package's transitive deps. The full provider request / stream
75
- * event types live in `adapter-anthropic`; plugins implementing this
76
- * type should import those for accurate parameter shapes.
77
- */
78
- export interface PluginModelAdapter {
79
- readonly id: string;
80
- readonly features: {
81
- readonly caching?: boolean;
82
- readonly tool_use?: boolean;
83
- readonly vision?: boolean;
84
- readonly thinking?: boolean;
85
- readonly web_search?: boolean;
86
- };
87
- stream(request: unknown): AsyncIterable<unknown>;
88
- countTokens?(messages: unknown): Promise<number>;
89
- }
90
-
91
- /**
92
- * Structural shape of a grader contribution. Matches `RegisteredGrader`
93
- * from `grader-registry`.
94
- */
95
- export interface PluginGrader {
96
- readonly id: string;
97
- readonly description?: string;
98
- grade(sample: { input: unknown; output: unknown; expected?: unknown }): Promise<{
99
- pass: boolean;
100
- score?: number;
101
- notes?: string;
102
- }>;
103
- }
104
-
105
- /**
106
- * Structural shape of a target emitter contribution. Matches the
107
- * `Emitter` contract from `compiler-core` — a function that takes the
108
- * IR variant and returns a `Bundle` (file list).
109
- */
110
- export interface PluginTargetEmitter {
111
- readonly targetShape: string;
112
- emit(ir: unknown): {
113
- readonly files: ReadonlyArray<{ readonly path: string; readonly contents: string }>;
114
- };
115
- }
116
-
117
- // ---------------------------------------------------------------------------
118
- // Manifest shape
119
- // ---------------------------------------------------------------------------
120
-
121
- /**
122
- * Capability declarations. Fail-closed — an undefined section means the
123
- * plugin has zero access to that resource class.
124
- */
125
- export type PluginPermissions = {
126
- /** Filesystem allow-list (minimatch globs relative to the plugin's sandbox root). */
127
- readonly fs?: ReadonlyArray<string>;
128
- /** URL prefix allow-list for `fetch()` from inside the plugin. */
129
- readonly net?: ReadonlyArray<string>;
130
- /** Names of host-provided tools the plugin's tools may call. */
131
- readonly tools?: ReadonlyArray<string>;
132
- /** Env-var names the plugin is permitted to read via the host's `secrets-manager`. */
133
- readonly secrets?: ReadonlyArray<string>;
134
- };
135
-
136
- export type PluginSignatureAlgorithm = "ed25519";
137
-
138
- /**
139
- * Detached signature over the canonical-JSON serialisation of the
140
- * manifest with `signature` set to `undefined`. Verified by §42
141
- * `plugin-registry` before any source is read.
142
- */
143
- export type PluginSignature = {
144
- readonly algorithm: PluginSignatureAlgorithm;
145
- readonly publicKeyB64: string;
146
- readonly sigB64: string;
147
- /** Optional ISO-8601 timestamp; advisory only. */
148
- readonly issuedAt?: string;
149
- };
150
-
151
- export type PluginContributions = {
152
- readonly tools?: ReadonlyArray<RegisteredTool | ToolDefinition>;
153
- readonly channels?: ReadonlyArray<PluginChannelAdapter>;
154
- readonly models?: ReadonlyArray<PluginModelAdapter>;
155
- readonly graders?: ReadonlyArray<PluginGrader>;
156
- readonly targetEmitters?: ReadonlyArray<PluginTargetEmitter>;
157
- };
158
-
159
- export type PluginManifest = {
160
- /** Globally-unique kebab-case plugin id. */
161
- readonly name: string;
162
- /** Semver-shaped version string (validated by `validatePluginManifest`). */
163
- readonly version: string;
164
- readonly description?: string;
165
- readonly author?: string;
166
- readonly homepage?: string;
167
- readonly license?: string;
168
- /** Minimum crewhaus runtime version this plugin requires (semver range). */
169
- readonly engines?: { readonly crewhaus?: string };
170
- readonly permissions?: PluginPermissions;
171
- readonly contributions?: PluginContributions;
172
- /**
173
- * Lowercase hex SHA-256 of the plugin's entrypoint (`index.js`). It is part
174
- * of the manifest, so `manifestPayloadForSigning` includes it and the
175
- * signature therefore commits to the CODE, not just the metadata. The loader
176
- * refuses to `import()` an entrypoint whose hash does not match. Optional for
177
- * back-compat; signed plugins SHOULD set it (compute via `entrypointDigest`).
178
- */
179
- readonly entrypointDigest?: string;
180
- readonly signature?: PluginSignature;
181
- };
182
-
183
- // ---------------------------------------------------------------------------
184
- // Validation
185
- // ---------------------------------------------------------------------------
186
-
187
- const NAME_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
188
- const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[\w.+-]+)?(?:\+[\w.-]+)?$/;
189
-
190
- function assertString(value: unknown, field: string): asserts value is string {
191
- if (typeof value !== "string" || value.length === 0) {
192
- throw new PluginSdkError(`plugin manifest: \`${field}\` must be a non-empty string`);
193
- }
194
- }
195
-
196
- function assertOptionalString(value: unknown, field: string): asserts value is string | undefined {
197
- if (value !== undefined && (typeof value !== "string" || value.length === 0)) {
198
- throw new PluginSdkError(
199
- `plugin manifest: \`${field}\` must be a non-empty string when present`,
200
- );
201
- }
202
- }
203
-
204
- function assertOptionalStringArray(
205
- value: unknown,
206
- field: string,
207
- ): asserts value is ReadonlyArray<string> | undefined {
208
- if (value === undefined) return;
209
- if (!Array.isArray(value)) {
210
- throw new PluginSdkError(`plugin manifest: \`${field}\` must be an array of strings`);
211
- }
212
- for (const item of value) {
213
- if (typeof item !== "string" || item.length === 0) {
214
- throw new PluginSdkError(`plugin manifest: \`${field}\` entries must be non-empty strings`);
215
- }
216
- }
217
- }
218
-
219
- /**
220
- * Throw `PluginSdkError` if `m` is not a valid `PluginManifest`. Returns
221
- * the input typed as `PluginManifest` on success (used as a type guard).
222
- */
223
- export function validatePluginManifest(m: unknown): PluginManifest {
224
- if (m === null || typeof m !== "object") {
225
- throw new PluginSdkError("plugin manifest must be an object");
226
- }
227
- const manifest = m as Record<string, unknown>;
228
- assertString(manifest["name"], "name");
229
- if (!NAME_PATTERN.test(manifest["name"])) {
230
- throw new PluginSdkError(
231
- `plugin manifest: \`name\` must be 3-64 chars, lowercase a-z / 0-9 / "-", start with a letter, no trailing hyphen (got "${manifest["name"]}")`,
232
- );
233
- }
234
- assertString(manifest["version"], "version");
235
- if (!SEMVER_PATTERN.test(manifest["version"])) {
236
- throw new PluginSdkError(
237
- `plugin manifest: \`version\` must be semver-shaped (got "${manifest["version"]}")`,
238
- );
239
- }
240
- assertOptionalString(manifest["description"], "description");
241
- assertOptionalString(manifest["author"], "author");
242
- assertOptionalString(manifest["homepage"], "homepage");
243
- assertOptionalString(manifest["license"], "license");
244
-
245
- if (manifest["entrypointDigest"] !== undefined) {
246
- assertString(manifest["entrypointDigest"], "entrypointDigest");
247
- if (!/^[a-f0-9]{64}$/.test(manifest["entrypointDigest"] as string)) {
248
- throw new PluginSdkError(
249
- "plugin manifest: `entrypointDigest` must be a lowercase hex SHA-256 (64 chars)",
250
- );
251
- }
252
- }
253
-
254
- if (manifest["engines"] !== undefined) {
255
- const engines = manifest["engines"];
256
- if (engines === null || typeof engines !== "object") {
257
- throw new PluginSdkError("plugin manifest: `engines` must be an object");
258
- }
259
- assertOptionalString((engines as Record<string, unknown>)["crewhaus"], "engines.crewhaus");
260
- }
261
-
262
- if (manifest["permissions"] !== undefined) {
263
- const perms = manifest["permissions"];
264
- if (perms === null || typeof perms !== "object") {
265
- throw new PluginSdkError("plugin manifest: `permissions` must be an object");
266
- }
267
- const p = perms as Record<string, unknown>;
268
- assertOptionalStringArray(p["fs"], "permissions.fs");
269
- assertOptionalStringArray(p["net"], "permissions.net");
270
- assertOptionalStringArray(p["tools"], "permissions.tools");
271
- assertOptionalStringArray(p["secrets"], "permissions.secrets");
272
- }
273
-
274
- if (manifest["signature"] !== undefined) {
275
- const sig = manifest["signature"];
276
- if (sig === null || typeof sig !== "object") {
277
- throw new PluginSdkError("plugin manifest: `signature` must be an object");
278
- }
279
- const s = sig as Record<string, unknown>;
280
- if (s["algorithm"] !== "ed25519") {
281
- throw new PluginSdkError('plugin manifest: `signature.algorithm` must be "ed25519"');
282
- }
283
- assertString(s["publicKeyB64"], "signature.publicKeyB64");
284
- assertString(s["sigB64"], "signature.sigB64");
285
- assertOptionalString(s["issuedAt"], "signature.issuedAt");
286
- }
287
-
288
- return manifest as unknown as PluginManifest;
289
- }
290
-
291
- /**
292
- * Type-only helper plugins use:
293
- * export default definePlugin({ name, version, contributions, … });
294
- *
295
- * Runs `validatePluginManifest` so misconfiguration fails at load time
296
- * (before the plugin's tools are exposed to a host).
297
- */
298
- export function definePlugin<T extends PluginManifest>(def: T): T {
299
- validatePluginManifest(def);
300
- return def;
301
- }
302
-
303
- // ---------------------------------------------------------------------------
304
- // Canonical JSON (for signature payload)
305
- // ---------------------------------------------------------------------------
306
-
307
- /**
308
- * Deterministic JSON serialisation: sorted keys, no whitespace, `undefined`
309
- * keys omitted. This is the byte string that the `signature` is computed
310
- * over (with `signature` itself set to `undefined`).
311
- *
312
- * Mirrors the §40 template-marketplace-client canonical-JSON convention
313
- * so plugin signatures verify with the same crypto primitive.
314
- */
315
- export function canonicalJson(value: unknown): string {
316
- if (value === null) return "null";
317
- if (typeof value === "boolean") return value ? "true" : "false";
318
- if (typeof value === "number") {
319
- if (!Number.isFinite(value)) {
320
- throw new PluginSdkError("canonical JSON: non-finite numbers are not representable");
321
- }
322
- return JSON.stringify(value);
323
- }
324
- if (typeof value === "string") return JSON.stringify(value);
325
- if (Array.isArray(value)) {
326
- return `[${value.map((v) => canonicalJson(v)).join(",")}]`;
327
- }
328
- if (typeof value === "object") {
329
- const obj = value as Record<string, unknown>;
330
- const keys = Object.keys(obj)
331
- .filter((k) => obj[k] !== undefined)
332
- .sort();
333
- return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
334
- }
335
- throw new PluginSdkError(`canonical JSON: unsupported type ${typeof value}`);
336
- }
337
-
338
- /**
339
- * Returns the byte string the plugin's signature should verify against
340
- * — the manifest's canonical JSON with `signature` cleared.
341
- */
342
- export function manifestPayloadForSigning(manifest: PluginManifest): string {
343
- // Shallow clone, drop signature, then canonical-encode. `entrypointDigest`
344
- // is NOT dropped, so the signature commits to the plugin code via its hash.
345
- const { signature: _signature, ...rest } = manifest;
346
- return canonicalJson(rest);
347
- }
348
-
349
- /**
350
- * Compute the `entrypointDigest` for plugin code — the lowercase hex SHA-256 of
351
- * the entrypoint file's bytes. Plugin signing tools set the result on the
352
- * manifest's `entrypointDigest` before signing; the loader recomputes it from
353
- * the on-disk `index.js` and refuses to import on mismatch.
354
- */
355
- export function entrypointDigest(code: string | Uint8Array): string {
356
- return createHash("sha256").update(code).digest("hex");
357
- }