@crewhaus/plugin-sdk 0.1.4 → 0.1.6
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.
- package/dist/index.d.ts +210 -0
- package/dist/index.js +193 -0
- package/package.json +10 -7
- package/src/index.test.ts +0 -237
- package/src/index.ts +0 -357
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
"version": "0.1.6",
|
|
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": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
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.
|
|
16
|
-
"@crewhaus/tool-catalog": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.6",
|
|
19
|
+
"@crewhaus/tool-catalog": "0.1.6"
|
|
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": ["
|
|
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
|
-
}
|