@alfe.ai/integration-manifest 0.3.4 → 0.3.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/README.md +14 -1
- package/dist/index.d.ts +13 -7
- package/dist/index.js +270 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,6 +61,19 @@ hooks:
|
|
|
61
61
|
|
|
62
62
|
Only `id`, `name`, `version`, `description`, and `author` are required — everything else has sensible defaults.
|
|
63
63
|
|
|
64
|
+
Manifest input is executable control-plane data. Identity fields must be
|
|
65
|
+
unique, record keys must be identifier-safe, and repository-owned hook, skill,
|
|
66
|
+
and command paths must remain relative to the integration checkout. Filesystem
|
|
67
|
+
consumers must additionally resolve real paths before I/O so repository
|
|
68
|
+
symlinks cannot escape that checkout.
|
|
69
|
+
|
|
70
|
+
`config_schema` validation is strict: undeclared keys are rejected, default
|
|
71
|
+
values must match the declared type/options, and `secret` or `oauth_connect`
|
|
72
|
+
fields cannot declare shared defaults. Consumers must persist the parsed
|
|
73
|
+
validation result, never the original caller object.
|
|
74
|
+
|
|
75
|
+
`supported_scopes` accepts `agent`, `org`, `team`, and `project`.
|
|
76
|
+
|
|
64
77
|
## Custom Connections — `expected_credentials`
|
|
65
78
|
|
|
66
79
|
For manifests consumed by [Custom Connections](../../services/connect/DEVELOPING.md#custom-connections-pr-8a-of-channels-and-credential-driven-integrations) (`requires_connection: ["custom"]`), the manifest declares the credential fields it needs the tenant operator to fill in via an `expected_credentials` block:
|
|
@@ -91,7 +104,7 @@ Supported field `type`s — same vocabulary as the dashboard form renderer:
|
|
|
91
104
|
|-----------|------------------------------------|-----------------------------------------|
|
|
92
105
|
| `secret` | KMS-encrypted (`encryptedAccessToken`) | Joined into a JSON bundle by services/connect at write time |
|
|
93
106
|
| `string` | `providerMetadata.{key}` | Plain string |
|
|
94
|
-
| `url` | `providerMetadata.{key}` | Plain string; URL validation client
|
|
107
|
+
| `url` | `providerMetadata.{key}` | Plain string; URL validation on client and backend |
|
|
95
108
|
| `number` | `providerMetadata.{key}` | Stored as JS number |
|
|
96
109
|
| `boolean` | `providerMetadata.{key}` | Stored as JS boolean |
|
|
97
110
|
|
package/dist/index.d.ts
CHANGED
|
@@ -5,8 +5,9 @@ import { z } from "zod";
|
|
|
5
5
|
/**
|
|
6
6
|
* Connect provider ids that a manifest's `requires_connection` field may
|
|
7
7
|
* reference. The source of truth lives in `services/connect/src/providers/`
|
|
8
|
-
* (one `ProviderDefinition` per id) — this list mirrors that registry
|
|
9
|
-
*
|
|
8
|
+
* (one `ProviderDefinition` per id) — this list mirrors that registry for
|
|
9
|
+
* authoring, lint, and dashboard tooling. The runtime parser deliberately
|
|
10
|
+
* accepts unknown provider ids for forward compatibility (see below).
|
|
10
11
|
*
|
|
11
12
|
* **Keep in sync** with `services/connect/src/providers/index.ts`. Adding a
|
|
12
13
|
* new connect provider requires updating both: add the provider definition
|
|
@@ -38,10 +39,11 @@ type KnownConnectProviderId = (typeof KNOWN_CONNECT_PROVIDER_IDS)[number];
|
|
|
38
39
|
* The enum is locked — channel adapters and the dashboard's compatibility
|
|
39
40
|
* matrix both depend on this exact list.
|
|
40
41
|
*/
|
|
41
|
-
declare const LOCKED_CHANNEL_TYPES: readonly ["whatsapp", "sms", "voice", "google_chat", "slack", "discord", "teams"];
|
|
42
|
+
declare const LOCKED_CHANNEL_TYPES: readonly ["whatsapp", "mobile", "sms", "voice", "google_chat", "slack", "discord", "teams"];
|
|
42
43
|
type ChannelType = (typeof LOCKED_CHANNEL_TYPES)[number];
|
|
43
44
|
declare const ChannelTypeSchema: z.ZodEnum<{
|
|
44
45
|
discord: "discord";
|
|
46
|
+
mobile: "mobile";
|
|
45
47
|
slack: "slack";
|
|
46
48
|
whatsapp: "whatsapp";
|
|
47
49
|
sms: "sms";
|
|
@@ -79,7 +81,7 @@ declare const ConfigSchemaFieldSchema: z.ZodObject<{
|
|
|
79
81
|
label: z.ZodString;
|
|
80
82
|
description: z.ZodOptional<z.ZodString>;
|
|
81
83
|
required: z.ZodDefault<z.ZodBoolean>;
|
|
82
|
-
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
|
|
84
|
+
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>;
|
|
83
85
|
editable: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
|
|
84
86
|
admin: "admin";
|
|
85
87
|
agent: "agent";
|
|
@@ -91,6 +93,8 @@ declare const ConfigSchemaFieldSchema: z.ZodObject<{
|
|
|
91
93
|
label: z.ZodString;
|
|
92
94
|
}, z.core.$strip>>>;
|
|
93
95
|
oauth_provider: z.ZodOptional<z.ZodString>;
|
|
96
|
+
oauth_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
97
|
+
oauth_integration_id: z.ZodOptional<z.ZodString>;
|
|
94
98
|
depends_on_field: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
95
99
|
key: z.ZodString;
|
|
96
100
|
value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
|
|
@@ -240,7 +244,7 @@ declare const IntegrationManifestSchema: z.ZodObject<{
|
|
|
240
244
|
label: z.ZodString;
|
|
241
245
|
description: z.ZodOptional<z.ZodString>;
|
|
242
246
|
required: z.ZodDefault<z.ZodBoolean>;
|
|
243
|
-
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
|
|
247
|
+
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>;
|
|
244
248
|
editable: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
|
|
245
249
|
admin: "admin";
|
|
246
250
|
agent: "agent";
|
|
@@ -252,6 +256,8 @@ declare const IntegrationManifestSchema: z.ZodObject<{
|
|
|
252
256
|
label: z.ZodString;
|
|
253
257
|
}, z.core.$strip>>>;
|
|
254
258
|
oauth_provider: z.ZodOptional<z.ZodString>;
|
|
259
|
+
oauth_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
260
|
+
oauth_integration_id: z.ZodOptional<z.ZodString>;
|
|
255
261
|
depends_on_field: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
256
262
|
key: z.ZodString;
|
|
257
263
|
value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
|
|
@@ -355,7 +361,7 @@ interface ConfigSchemaField {
|
|
|
355
361
|
label: string;
|
|
356
362
|
description?: string;
|
|
357
363
|
required: boolean;
|
|
358
|
-
default?: string | number | boolean;
|
|
364
|
+
default?: string | number | boolean | string[];
|
|
359
365
|
/** Who can mutate this field at runtime. Default: 'admin' */
|
|
360
366
|
editable: 'admin' | 'agent';
|
|
361
367
|
/** Only used when type === 'enum' */
|
|
@@ -516,7 +522,7 @@ interface IntegrationManifest {
|
|
|
516
522
|
* Default: ['agent'] (per-agent only).
|
|
517
523
|
* 'org' means it can be installed at the org level and cascades to all agents.
|
|
518
524
|
*/
|
|
519
|
-
supported_scopes?: ('agent' | 'org')[];
|
|
525
|
+
supported_scopes?: ('agent' | 'org' | 'team' | 'project')[];
|
|
520
526
|
/**
|
|
521
527
|
* Connect provider ids whose credentials this integration resolves at
|
|
522
528
|
* runtime. OR-semantics: any one in the list satisfies activation. See
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { parse } from "yaml";
|
|
4
|
+
import { posix } from "node:path";
|
|
4
5
|
//#region src/schema.ts
|
|
5
6
|
/**
|
|
6
7
|
* Zod validation schema for the Alfe integration manifest.
|
|
@@ -9,11 +10,54 @@ import { parse } from "yaml";
|
|
|
9
10
|
* Used by the parser and by the lifecycle manager to ensure manifest
|
|
10
11
|
* correctness before installation.
|
|
11
12
|
*/
|
|
13
|
+
const IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
14
|
+
const INTEGRATION_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
15
|
+
const UNSAFE_RECORD_KEYS = new Set([
|
|
16
|
+
"__proto__",
|
|
17
|
+
"constructor",
|
|
18
|
+
"prototype"
|
|
19
|
+
]);
|
|
20
|
+
const HttpsUrlSchema = z.url().refine((value) => {
|
|
21
|
+
const url = new URL(value);
|
|
22
|
+
return url.protocol === "https:" && url.username === "" && url.password === "";
|
|
23
|
+
}, "URL must use HTTPS and must not contain embedded credentials");
|
|
24
|
+
/**
|
|
25
|
+
* Repository-owned file references are portable relative paths. They may use
|
|
26
|
+
* `./`, but may not be absolute or walk above the integration checkout.
|
|
27
|
+
* Consumers still need realpath checks before filesystem I/O because a cloned
|
|
28
|
+
* repository can contain symlinks.
|
|
29
|
+
*/
|
|
30
|
+
const RepositoryRelativePathSchema = z.string().min(1, "Path must not be empty").refine((value) => {
|
|
31
|
+
if (value.includes("\0")) return false;
|
|
32
|
+
const portable = value.replaceAll("\\", "/");
|
|
33
|
+
if (portable.startsWith("/") || /^[a-zA-Z]:\//.test(portable)) return false;
|
|
34
|
+
let depth = 0;
|
|
35
|
+
for (const segment of portable.split("/")) {
|
|
36
|
+
if (segment === "" || segment === ".") continue;
|
|
37
|
+
if (segment === "..") {
|
|
38
|
+
depth -= 1;
|
|
39
|
+
if (depth < 0) return false;
|
|
40
|
+
} else depth += 1;
|
|
41
|
+
}
|
|
42
|
+
return depth > 0;
|
|
43
|
+
}, "Path must stay within the integration directory");
|
|
44
|
+
function addDuplicateIssues(values, ctx, path, label) {
|
|
45
|
+
const seen = /* @__PURE__ */ new Set();
|
|
46
|
+
for (const [index, value] of values.entries()) {
|
|
47
|
+
if (seen.has(value)) ctx.addIssue({
|
|
48
|
+
code: "custom",
|
|
49
|
+
path: [...path, index],
|
|
50
|
+
message: `Duplicate ${label}: ${value}`
|
|
51
|
+
});
|
|
52
|
+
seen.add(value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
12
55
|
/**
|
|
13
56
|
* Connect provider ids that a manifest's `requires_connection` field may
|
|
14
57
|
* reference. The source of truth lives in `services/connect/src/providers/`
|
|
15
|
-
* (one `ProviderDefinition` per id) — this list mirrors that registry
|
|
16
|
-
*
|
|
58
|
+
* (one `ProviderDefinition` per id) — this list mirrors that registry for
|
|
59
|
+
* authoring, lint, and dashboard tooling. The runtime parser deliberately
|
|
60
|
+
* accepts unknown provider ids for forward compatibility (see below).
|
|
17
61
|
*
|
|
18
62
|
* **Keep in sync** with `services/connect/src/providers/index.ts`. Adding a
|
|
19
63
|
* new connect provider requires updating both: add the provider definition
|
|
@@ -63,6 +107,7 @@ const KNOWN_CONNECT_PROVIDER_IDS = [
|
|
|
63
107
|
*/
|
|
64
108
|
const LOCKED_CHANNEL_TYPES = [
|
|
65
109
|
"whatsapp",
|
|
110
|
+
"mobile",
|
|
66
111
|
"sms",
|
|
67
112
|
"voice",
|
|
68
113
|
"google_chat",
|
|
@@ -87,7 +132,7 @@ const SelectOptionSchema = z.object({
|
|
|
87
132
|
label: z.string().min(1)
|
|
88
133
|
});
|
|
89
134
|
const ConfigSchemaFieldSchema = z.object({
|
|
90
|
-
key: z.string().min(1, "Config key must not be empty"),
|
|
135
|
+
key: z.string().min(1, "Config key must not be empty").regex(IDENTIFIER_RE, "Config key must be a valid identifier").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "Config key must be a valid identifier"),
|
|
91
136
|
type: ConfigFieldTypeSchema,
|
|
92
137
|
label: z.string().min(1, "Config label must not be empty"),
|
|
93
138
|
description: z.string().optional(),
|
|
@@ -95,15 +140,18 @@ const ConfigSchemaFieldSchema = z.object({
|
|
|
95
140
|
default: z.union([
|
|
96
141
|
z.string(),
|
|
97
142
|
z.number(),
|
|
98
|
-
z.boolean()
|
|
143
|
+
z.boolean(),
|
|
144
|
+
z.array(z.string())
|
|
99
145
|
]).optional(),
|
|
100
146
|
editable: z.enum(["admin", "agent"]).optional().default("admin"),
|
|
101
147
|
hidden: z.boolean().optional(),
|
|
102
|
-
options: z.array(z.string()).optional(),
|
|
148
|
+
options: z.array(z.string().min(1, "Enum option must not be empty")).optional(),
|
|
103
149
|
select_options: z.array(SelectOptionSchema).optional(),
|
|
104
150
|
oauth_provider: z.string().optional(),
|
|
105
|
-
|
|
106
|
-
|
|
151
|
+
oauth_scopes: z.array(z.string().min(1, "OAuth scope must not be empty")).optional(),
|
|
152
|
+
oauth_integration_id: z.string().regex(INTEGRATION_ID_RE, "OAuth integration id must be a valid integration id").refine((id) => !UNSAFE_RECORD_KEYS.has(id), "OAuth integration id is reserved").optional(),
|
|
153
|
+
depends_on_field: z.union([z.string().regex(IDENTIFIER_RE, "Dependency key must be a valid identifier").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "Dependency key must be a valid identifier"), z.object({
|
|
154
|
+
key: z.string().regex(IDENTIFIER_RE, "Dependency key must be a valid identifier").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "Dependency key must be a valid identifier"),
|
|
107
155
|
value: z.union([
|
|
108
156
|
z.string(),
|
|
109
157
|
z.number(),
|
|
@@ -120,7 +168,47 @@ const ConfigSchemaFieldSchema = z.object({
|
|
|
120
168
|
}, { message: "Select/multi_select fields must have at least one option in select_options" }).refine((field) => {
|
|
121
169
|
if (field.type === "oauth_connect") return field.oauth_provider !== void 0 && field.oauth_provider.length > 0;
|
|
122
170
|
return true;
|
|
123
|
-
}, { message: "oauth_connect fields must specify an oauth_provider" })
|
|
171
|
+
}, { message: "oauth_connect fields must specify an oauth_provider" }).superRefine((field, ctx) => {
|
|
172
|
+
if (field.options) addDuplicateIssues(field.options, ctx, ["options"], "enum option");
|
|
173
|
+
if (field.select_options) addDuplicateIssues(field.select_options.map((option) => option.value), ctx, ["select_options"], "select option value");
|
|
174
|
+
if (field.oauth_scopes) addDuplicateIssues(field.oauth_scopes, ctx, ["oauth_scopes"], "OAuth scope");
|
|
175
|
+
if (field.type !== "oauth_connect" && (field.oauth_scopes || field.oauth_integration_id)) ctx.addIssue({
|
|
176
|
+
code: "custom",
|
|
177
|
+
path: ["type"],
|
|
178
|
+
message: "OAuth scope and integration metadata is only valid on oauth_connect fields"
|
|
179
|
+
});
|
|
180
|
+
if (field.default === void 0) return;
|
|
181
|
+
let valid = false;
|
|
182
|
+
switch (field.type) {
|
|
183
|
+
case "string":
|
|
184
|
+
valid = typeof field.default === "string";
|
|
185
|
+
break;
|
|
186
|
+
case "secret":
|
|
187
|
+
case "oauth_connect":
|
|
188
|
+
valid = false;
|
|
189
|
+
break;
|
|
190
|
+
case "number":
|
|
191
|
+
valid = typeof field.default === "number";
|
|
192
|
+
break;
|
|
193
|
+
case "boolean":
|
|
194
|
+
valid = typeof field.default === "boolean";
|
|
195
|
+
break;
|
|
196
|
+
case "enum":
|
|
197
|
+
valid = typeof field.default === "string" && (field.options?.includes(field.default) ?? false);
|
|
198
|
+
break;
|
|
199
|
+
case "select":
|
|
200
|
+
valid = typeof field.default === "string" && (field.select_options?.some((option) => option.value === field.default) ?? false);
|
|
201
|
+
break;
|
|
202
|
+
case "multi_select":
|
|
203
|
+
valid = Array.isArray(field.default) && field.default.every((value) => field.select_options?.some((option) => option.value === value));
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
if (!valid) ctx.addIssue({
|
|
207
|
+
code: "custom",
|
|
208
|
+
path: ["default"],
|
|
209
|
+
message: `Default value is invalid for config field type ${field.type}`
|
|
210
|
+
});
|
|
211
|
+
});
|
|
124
212
|
/**
|
|
125
213
|
* A field a manifest's owner expects the user to fill in when creating
|
|
126
214
|
* a Custom Connection. Used ONLY by manifests authored against the
|
|
@@ -141,7 +229,7 @@ const CredentialFieldTypeSchema = z.enum([
|
|
|
141
229
|
"boolean"
|
|
142
230
|
]);
|
|
143
231
|
const CredentialFieldSpecSchema = z.object({
|
|
144
|
-
key: z.string().min(1, "Credential field key must not be empty").regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "Credential field key must be a valid identifier (letters, digits, underscores; not starting with a digit)"),
|
|
232
|
+
key: z.string().min(1, "Credential field key must not be empty").regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "Credential field key must be a valid identifier (letters, digits, underscores; not starting with a digit)").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "Credential field key must be a valid identifier"),
|
|
145
233
|
type: CredentialFieldTypeSchema,
|
|
146
234
|
label: z.string().min(1, "Credential field label must not be empty"),
|
|
147
235
|
description: z.string().optional(),
|
|
@@ -150,25 +238,25 @@ const CredentialFieldSpecSchema = z.object({
|
|
|
150
238
|
pattern: z.string().max(256).optional()
|
|
151
239
|
});
|
|
152
240
|
const McpServerDeclarationSchema = z.object({
|
|
153
|
-
id: z.string().min(1, "MCP server id must not be empty").regex(
|
|
241
|
+
id: z.string().min(1, "MCP server id must not be empty").regex(INTEGRATION_ID_RE, "MCP server id must be lowercase alphanumeric with hyphens").refine((id) => !UNSAFE_RECORD_KEYS.has(id), "MCP server id is reserved"),
|
|
154
242
|
command: z.string().min(1, "MCP server command must not be empty"),
|
|
155
243
|
args: z.array(z.string()).optional().default([]),
|
|
156
|
-
env: z.record(z.string(), z.string()).optional(),
|
|
244
|
+
env: z.record(z.string().regex(IDENTIFIER_RE, "MCP environment keys must be valid identifiers").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "MCP environment keys must be valid identifiers"), z.string()).optional(),
|
|
157
245
|
cwd: z.string().optional(),
|
|
158
246
|
requires_credentials: z.string().min(1).optional(),
|
|
159
247
|
hook_managed: z.boolean().optional().default(false)
|
|
160
248
|
});
|
|
161
249
|
const CommandDeclarationSchema = z.object({
|
|
162
250
|
name: z.string().min(1, "Command name must not be empty").regex(/^[a-z][a-z0-9]*\.[a-z][a-z0-9_]*$/, "Command name must be dot-namespaced (e.g. \"support.diagnostic\")"),
|
|
163
|
-
handler:
|
|
164
|
-
method: z.string().optional().default("handle"),
|
|
165
|
-
timeout_ms: z.number().int().positive().optional().default(3e4),
|
|
251
|
+
handler: RepositoryRelativePathSchema,
|
|
252
|
+
method: z.string().regex(IDENTIFIER_RE, "Handler method must be a valid identifier").refine((method) => !UNSAFE_RECORD_KEYS.has(method), "Handler method must be a valid identifier").optional().default("handle"),
|
|
253
|
+
timeout_ms: z.number().int().positive().max(1800 * 1e3).optional().default(3e4),
|
|
166
254
|
description: z.string().optional()
|
|
167
255
|
});
|
|
168
256
|
const SkillInstallSchema = z.object({
|
|
169
|
-
path:
|
|
257
|
+
path: RepositoryRelativePathSchema.optional(),
|
|
170
258
|
clawhub: z.string().min(1, "ClawHub skill slug must not be empty").optional()
|
|
171
|
-
}).refine((data) => data.path
|
|
259
|
+
}).refine((data) => Number(data.path !== void 0) + Number(data.clawhub !== void 0) === 1, { message: "Skill must have exactly one of path or clawhub slug" });
|
|
172
260
|
const PluginInstallSchema = z.object({ package: z.string().min(1, "Plugin package name must not be empty") });
|
|
173
261
|
/** Per-runtime install overrides */
|
|
174
262
|
const RuntimeInstallSchema = z.object({
|
|
@@ -177,19 +265,19 @@ const RuntimeInstallSchema = z.object({
|
|
|
177
265
|
config: z.record(z.string(), z.unknown()).optional()
|
|
178
266
|
});
|
|
179
267
|
/** Runtime key: lowercase alphanumeric + hyphens */
|
|
180
|
-
const RuntimeKeySchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Runtime key must be lowercase alphanumeric with hyphens");
|
|
268
|
+
const RuntimeKeySchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Runtime key must be lowercase alphanumeric with hyphens").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "Runtime key is reserved");
|
|
181
269
|
const InstallTargetsSchema = z.object({
|
|
182
270
|
skills: z.array(SkillInstallSchema).optional().default([]),
|
|
183
271
|
plugins: z.array(PluginInstallSchema).optional().default([]),
|
|
184
272
|
runtimes: z.record(RuntimeKeySchema, RuntimeInstallSchema).optional()
|
|
185
273
|
});
|
|
186
274
|
const IntegrationHooksSchema = z.object({
|
|
187
|
-
pre_install:
|
|
188
|
-
post_install:
|
|
189
|
-
post_activate:
|
|
190
|
-
pre_uninstall:
|
|
191
|
-
post_uninstall:
|
|
192
|
-
health_check:
|
|
275
|
+
pre_install: RepositoryRelativePathSchema.optional(),
|
|
276
|
+
post_install: RepositoryRelativePathSchema.optional(),
|
|
277
|
+
post_activate: RepositoryRelativePathSchema.optional(),
|
|
278
|
+
pre_uninstall: RepositoryRelativePathSchema.optional(),
|
|
279
|
+
post_uninstall: RepositoryRelativePathSchema.optional(),
|
|
280
|
+
health_check: RepositoryRelativePathSchema.optional()
|
|
193
281
|
});
|
|
194
282
|
const IntegrationPricingPlanSchema = z.object({
|
|
195
283
|
name: z.string().min(1),
|
|
@@ -222,13 +310,13 @@ const IntegrationAuthorSchema = z.union([z.string().min(1, "Author must not be e
|
|
|
222
310
|
})]);
|
|
223
311
|
const SemverSchema = z.string().regex(/^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/, "Must be a valid semver version (e.g. 1.0.0)");
|
|
224
312
|
const IntegrationManifestSchema = z.object({
|
|
225
|
-
id: z.string().min(1, "Integration id must not be empty").regex(
|
|
313
|
+
id: z.string().min(1, "Integration id must not be empty").regex(INTEGRATION_ID_RE, "id must be lowercase alphanumeric with hyphens").refine((id) => !UNSAFE_RECORD_KEYS.has(id), "Integration id is reserved"),
|
|
226
314
|
name: z.string().min(1, "Integration name must not be empty"),
|
|
227
315
|
version: SemverSchema,
|
|
228
316
|
description: z.string().min(1, "Description must not be empty"),
|
|
229
317
|
author: IntegrationAuthorSchema,
|
|
230
318
|
license: z.string().default("MIT"),
|
|
231
|
-
depends_on: z.array(z.string()).optional().default([]),
|
|
319
|
+
depends_on: z.array(z.string().regex(INTEGRATION_ID_RE, "Dependency must be a valid integration id").refine((id) => !UNSAFE_RECORD_KEYS.has(id), "Dependency id is reserved")).optional().default([]),
|
|
232
320
|
min_gateway_version: SemverSchema.optional().default("0.1.0"),
|
|
233
321
|
installs: InstallTargetsSchema.optional().default({
|
|
234
322
|
skills: [],
|
|
@@ -242,7 +330,7 @@ const IntegrationManifestSchema = z.object({
|
|
|
242
330
|
requires_connection: z.array(z.string().min(1)).optional(),
|
|
243
331
|
channel_type: z.string().min(1).optional(),
|
|
244
332
|
expected_credentials: z.array(CredentialFieldSpecSchema).optional(),
|
|
245
|
-
repository:
|
|
333
|
+
repository: HttpsUrlSchema.optional(),
|
|
246
334
|
supported_agents: z.array(RuntimeKeySchema).optional(),
|
|
247
335
|
supported_scopes: z.array(z.enum([
|
|
248
336
|
"agent",
|
|
@@ -255,14 +343,85 @@ const IntegrationManifestSchema = z.object({
|
|
|
255
343
|
pricing: IntegrationPricingSchema.optional(),
|
|
256
344
|
preview_images: z.array(z.string()).optional(),
|
|
257
345
|
features: z.array(z.string()).optional()
|
|
346
|
+
}).superRefine((manifest, ctx) => {
|
|
347
|
+
addDuplicateIssues(manifest.depends_on, ctx, ["depends_on"], "dependency");
|
|
348
|
+
addDuplicateIssues(manifest.config_schema.map((field) => field.key), ctx, ["config_schema"], "config key");
|
|
349
|
+
addDuplicateIssues(manifest.commands.map((command) => command.name), ctx, ["commands"], "command name");
|
|
350
|
+
addDuplicateIssues(manifest.mcp_servers.map((server) => server.id), ctx, ["mcp_servers"], "MCP server id");
|
|
351
|
+
addDuplicateIssues(manifest.capabilities, ctx, ["capabilities"], "capability");
|
|
352
|
+
addDuplicateIssues(manifest.requires_connection ?? [], ctx, ["requires_connection"], "connection requirement");
|
|
353
|
+
addDuplicateIssues(manifest.supported_agents ?? [], ctx, ["supported_agents"], "supported agent");
|
|
354
|
+
addDuplicateIssues(manifest.supported_scopes, ctx, ["supported_scopes"], "supported scope");
|
|
355
|
+
if (manifest.depends_on.includes(manifest.id)) ctx.addIssue({
|
|
356
|
+
code: "custom",
|
|
357
|
+
path: ["depends_on"],
|
|
358
|
+
message: "An integration cannot depend on itself"
|
|
359
|
+
});
|
|
360
|
+
const configKeys = new Set(manifest.config_schema.map((field) => field.key));
|
|
361
|
+
for (const [index, field] of manifest.config_schema.entries()) {
|
|
362
|
+
if (!field.depends_on_field) continue;
|
|
363
|
+
const dependencyKey = typeof field.depends_on_field === "string" ? field.depends_on_field : field.depends_on_field.key;
|
|
364
|
+
if (!configKeys.has(dependencyKey)) ctx.addIssue({
|
|
365
|
+
code: "custom",
|
|
366
|
+
path: [
|
|
367
|
+
"config_schema",
|
|
368
|
+
index,
|
|
369
|
+
"depends_on_field"
|
|
370
|
+
],
|
|
371
|
+
message: `Config dependency does not exist: ${dependencyKey}`
|
|
372
|
+
});
|
|
373
|
+
else if (dependencyKey === field.key) ctx.addIssue({
|
|
374
|
+
code: "custom",
|
|
375
|
+
path: [
|
|
376
|
+
"config_schema",
|
|
377
|
+
index,
|
|
378
|
+
"depends_on_field"
|
|
379
|
+
],
|
|
380
|
+
message: "A config field cannot depend on itself"
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
if (manifest.expected_credentials) {
|
|
384
|
+
addDuplicateIssues(manifest.expected_credentials.map((field) => field.key), ctx, ["expected_credentials"], "credential key");
|
|
385
|
+
if (!manifest.requires_connection?.includes("custom")) ctx.addIssue({
|
|
386
|
+
code: "custom",
|
|
387
|
+
path: ["expected_credentials"],
|
|
388
|
+
message: "expected_credentials requires requires_connection to include custom"
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const validateInstallDuplicates = (install, path) => {
|
|
392
|
+
addDuplicateIssues(install.plugins.map((plugin) => plugin.package), ctx, [...path, "plugins"], "plugin package");
|
|
393
|
+
addDuplicateIssues(install.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`), ctx, [...path, "skills"], "skill source");
|
|
394
|
+
};
|
|
395
|
+
validateInstallDuplicates(manifest.installs, ["installs"]);
|
|
396
|
+
for (const [runtime, install] of Object.entries(manifest.installs.runtimes ?? {})) {
|
|
397
|
+
validateInstallDuplicates(install, [
|
|
398
|
+
"installs",
|
|
399
|
+
"runtimes",
|
|
400
|
+
runtime
|
|
401
|
+
]);
|
|
402
|
+
addDuplicateIssues([...manifest.installs.plugins.map((plugin) => plugin.package), ...install.plugins.map((plugin) => plugin.package)], ctx, [
|
|
403
|
+
"installs",
|
|
404
|
+
"runtimes",
|
|
405
|
+
runtime,
|
|
406
|
+
"plugins"
|
|
407
|
+
], "effective plugin package");
|
|
408
|
+
addDuplicateIssues([...manifest.installs.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`), ...install.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`)], ctx, [
|
|
409
|
+
"installs",
|
|
410
|
+
"runtimes",
|
|
411
|
+
runtime,
|
|
412
|
+
"skills"
|
|
413
|
+
], "effective skill source");
|
|
414
|
+
}
|
|
258
415
|
});
|
|
259
416
|
/**
|
|
260
417
|
* Validate user-provided config values against a manifest's config_schema.
|
|
261
418
|
* Returns a Zod schema dynamically built from the manifest's config_schema.
|
|
262
419
|
*/
|
|
263
420
|
function buildConfigValidationSchema(configSchema) {
|
|
264
|
-
const shape =
|
|
421
|
+
const shape = Object.create(null);
|
|
265
422
|
for (const field of configSchema) {
|
|
423
|
+
if (!IDENTIFIER_RE.test(field.key) || UNSAFE_RECORD_KEYS.has(field.key)) throw new Error(`Invalid config schema key: ${field.key}`);
|
|
424
|
+
if (Object.hasOwn(shape, field.key)) throw new Error(`Duplicate config schema key: ${field.key}`);
|
|
266
425
|
let fieldSchema;
|
|
267
426
|
switch (field.type) {
|
|
268
427
|
case "secret":
|
|
@@ -300,7 +459,7 @@ function buildConfigValidationSchema(configSchema) {
|
|
|
300
459
|
if (!field.required) fieldSchema = fieldSchema.optional();
|
|
301
460
|
shape[field.key] = fieldSchema;
|
|
302
461
|
}
|
|
303
|
-
return z.object(shape);
|
|
462
|
+
return z.object(shape).strict();
|
|
304
463
|
}
|
|
305
464
|
//#endregion
|
|
306
465
|
//#region src/parser.ts
|
|
@@ -408,11 +567,23 @@ function isRelativePath(path) {
|
|
|
408
567
|
*/
|
|
409
568
|
function resolveAssetUrl(assetPath, repoUrl, commitHash, subdir) {
|
|
410
569
|
if (!isRelativePath(assetPath)) return assetPath;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
570
|
+
let repository;
|
|
571
|
+
try {
|
|
572
|
+
repository = new URL(repoUrl);
|
|
573
|
+
} catch {
|
|
574
|
+
return assetPath;
|
|
575
|
+
}
|
|
576
|
+
const repoParts = repository.pathname.split("/").filter(Boolean);
|
|
577
|
+
const owner = repoParts[0];
|
|
578
|
+
const repo = repoParts[1]?.replace(/\.git$/, "");
|
|
579
|
+
if (!["http:", "https:"].includes(repository.protocol) || repository.hostname.toLowerCase() !== "github.com" || repository.username !== "" || repository.password !== "" || repository.port !== "" || repository.search !== "" || repository.hash !== "" || repoParts.length !== 2 || !owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) return assetPath;
|
|
580
|
+
const portableSubdir = subdir.replaceAll("\\", "/");
|
|
581
|
+
const portableAssetPath = assetPath.replaceAll("\\", "/");
|
|
582
|
+
if (portableSubdir.startsWith("/") || portableAssetPath.startsWith("/") || /^[A-Za-z]:\//.test(portableSubdir) || /^[A-Za-z]:\//.test(portableAssetPath)) return assetPath;
|
|
583
|
+
const fullPath = posix.normalize(posix.join(portableSubdir, portableAssetPath));
|
|
584
|
+
if (fullPath === "." || fullPath === ".." || fullPath.startsWith("../")) return assetPath;
|
|
585
|
+
const encodedPath = fullPath.split("/").map(encodeURIComponent).join("/");
|
|
586
|
+
return `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(commitHash)}/${encodedPath}`;
|
|
416
587
|
}
|
|
417
588
|
//#endregion
|
|
418
589
|
//#region src/interpolation.ts
|
|
@@ -432,14 +603,33 @@ const TEMPLATE_RE = /\{\{integration\.([a-z0-9-]+)\.([a-z0-9_]+)\}\}/g;
|
|
|
432
603
|
*/
|
|
433
604
|
function interpolateConfig(config, ctx) {
|
|
434
605
|
const result = {};
|
|
435
|
-
for (const [key, value] of Object.entries(config)) if (typeof value === "string")
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
606
|
+
for (const [key, value] of Object.entries(config)) if (typeof value === "string") {
|
|
607
|
+
const interpolated = value.replace(TEMPLATE_RE, (_match, integrationId, field) => {
|
|
608
|
+
if (!Object.hasOwn(ctx.integrations, integrationId)) return _match;
|
|
609
|
+
const integrationConfig = ctx.integrations[integrationId];
|
|
610
|
+
if (!integrationConfig || !Object.hasOwn(integrationConfig, field)) return _match;
|
|
611
|
+
const resolved = integrationConfig[field];
|
|
612
|
+
if (resolved == null) return _match;
|
|
613
|
+
if (typeof resolved === "string") return resolved;
|
|
614
|
+
try {
|
|
615
|
+
const serialized = JSON.stringify(resolved);
|
|
616
|
+
return typeof serialized === "string" ? serialized : _match;
|
|
617
|
+
} catch {
|
|
618
|
+
return _match;
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
Object.defineProperty(result, key, {
|
|
622
|
+
configurable: true,
|
|
623
|
+
enumerable: true,
|
|
624
|
+
value: interpolated,
|
|
625
|
+
writable: true
|
|
626
|
+
});
|
|
627
|
+
} else Object.defineProperty(result, key, {
|
|
628
|
+
configurable: true,
|
|
629
|
+
enumerable: true,
|
|
630
|
+
value,
|
|
631
|
+
writable: true
|
|
441
632
|
});
|
|
442
|
-
else result[key] = value;
|
|
443
633
|
return result;
|
|
444
634
|
}
|
|
445
635
|
/**
|
|
@@ -461,12 +651,38 @@ function extractTemplateReferences(config) {
|
|
|
461
651
|
}
|
|
462
652
|
//#endregion
|
|
463
653
|
//#region src/migration.ts
|
|
654
|
+
function schemaByKey(fields, label) {
|
|
655
|
+
const result = /* @__PURE__ */ new Map();
|
|
656
|
+
for (const field of fields) {
|
|
657
|
+
if (result.has(field.key)) throw new Error(`Duplicate ${label} config schema key: ${field.key}`);
|
|
658
|
+
result.set(field.key, field);
|
|
659
|
+
}
|
|
660
|
+
return result;
|
|
661
|
+
}
|
|
662
|
+
function setOwn(target, key, value) {
|
|
663
|
+
Object.defineProperty(target, key, {
|
|
664
|
+
configurable: true,
|
|
665
|
+
enumerable: true,
|
|
666
|
+
value,
|
|
667
|
+
writable: true
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
function isValidFieldValue(field, value) {
|
|
671
|
+
try {
|
|
672
|
+
return buildConfigValidationSchema([{
|
|
673
|
+
...field,
|
|
674
|
+
required: true
|
|
675
|
+
}]).safeParse({ [field.key]: value }).success;
|
|
676
|
+
} catch {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
464
680
|
/**
|
|
465
681
|
* Diff two config schemas to determine what changed between versions.
|
|
466
682
|
*/
|
|
467
683
|
function diffConfigSchemas(oldSchema, newSchema) {
|
|
468
|
-
const oldMap =
|
|
469
|
-
const newMap =
|
|
684
|
+
const oldMap = schemaByKey(oldSchema, "old");
|
|
685
|
+
const newMap = schemaByKey(newSchema, "new");
|
|
470
686
|
const added = [];
|
|
471
687
|
const removed = [];
|
|
472
688
|
const changed = [];
|
|
@@ -501,12 +717,20 @@ function diffConfigSchemas(oldSchema, newSchema) {
|
|
|
501
717
|
function migrateConfig(currentConfig, diff) {
|
|
502
718
|
const config = {};
|
|
503
719
|
const warnings = [];
|
|
504
|
-
for (const field of diff.unchanged) if (field.key
|
|
720
|
+
for (const field of diff.unchanged) if (Object.hasOwn(currentConfig, field.key)) setOwn(config, field.key, currentConfig[field.key]);
|
|
505
721
|
for (const change of diff.changed) if (change.breaking) {
|
|
506
|
-
if (change.newSchema.default !== void 0) config
|
|
722
|
+
if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
|
|
507
723
|
warnings.push(`Field "${change.field}" type changed from "${change.oldSchema.type}" to "${change.newSchema.type}" — value reset`);
|
|
508
|
-
} else if (
|
|
509
|
-
|
|
724
|
+
} else if (Object.hasOwn(currentConfig, change.field)) {
|
|
725
|
+
const currentValue = currentConfig[change.field];
|
|
726
|
+
if (isValidFieldValue(change.newSchema, currentValue)) setOwn(config, change.field, currentValue);
|
|
727
|
+
else {
|
|
728
|
+
if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
|
|
729
|
+
warnings.push(`Field "${change.field}" no longer accepts its existing value — value reset`);
|
|
730
|
+
}
|
|
731
|
+
} else if (change.newSchema.required) if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
|
|
732
|
+
else warnings.push(`Field "${change.field}" is now required and has no default — user input needed`);
|
|
733
|
+
for (const field of diff.added) if (field.default !== void 0) setOwn(config, field.key, field.default);
|
|
510
734
|
else if (field.required) warnings.push(`New required field "${field.key}" has no default — user input needed`);
|
|
511
735
|
return {
|
|
512
736
|
config,
|