@alfe.ai/integration-manifest 0.3.4 → 0.3.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.
- package/README.md +14 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.js +269 -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
|
|
@@ -79,7 +80,7 @@ declare const ConfigSchemaFieldSchema: z.ZodObject<{
|
|
|
79
80
|
label: z.ZodString;
|
|
80
81
|
description: z.ZodOptional<z.ZodString>;
|
|
81
82
|
required: z.ZodDefault<z.ZodBoolean>;
|
|
82
|
-
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
|
|
83
|
+
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>;
|
|
83
84
|
editable: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
|
|
84
85
|
admin: "admin";
|
|
85
86
|
agent: "agent";
|
|
@@ -91,6 +92,8 @@ declare const ConfigSchemaFieldSchema: z.ZodObject<{
|
|
|
91
92
|
label: z.ZodString;
|
|
92
93
|
}, z.core.$strip>>>;
|
|
93
94
|
oauth_provider: z.ZodOptional<z.ZodString>;
|
|
95
|
+
oauth_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
96
|
+
oauth_integration_id: z.ZodOptional<z.ZodString>;
|
|
94
97
|
depends_on_field: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
95
98
|
key: z.ZodString;
|
|
96
99
|
value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
|
|
@@ -240,7 +243,7 @@ declare const IntegrationManifestSchema: z.ZodObject<{
|
|
|
240
243
|
label: z.ZodString;
|
|
241
244
|
description: z.ZodOptional<z.ZodString>;
|
|
242
245
|
required: z.ZodDefault<z.ZodBoolean>;
|
|
243
|
-
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
|
|
246
|
+
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>;
|
|
244
247
|
editable: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
|
|
245
248
|
admin: "admin";
|
|
246
249
|
agent: "agent";
|
|
@@ -252,6 +255,8 @@ declare const IntegrationManifestSchema: z.ZodObject<{
|
|
|
252
255
|
label: z.ZodString;
|
|
253
256
|
}, z.core.$strip>>>;
|
|
254
257
|
oauth_provider: z.ZodOptional<z.ZodString>;
|
|
258
|
+
oauth_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
259
|
+
oauth_integration_id: z.ZodOptional<z.ZodString>;
|
|
255
260
|
depends_on_field: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
256
261
|
key: z.ZodString;
|
|
257
262
|
value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
|
|
@@ -355,7 +360,7 @@ interface ConfigSchemaField {
|
|
|
355
360
|
label: string;
|
|
356
361
|
description?: string;
|
|
357
362
|
required: boolean;
|
|
358
|
-
default?: string | number | boolean;
|
|
363
|
+
default?: string | number | boolean | string[];
|
|
359
364
|
/** Who can mutate this field at runtime. Default: 'admin' */
|
|
360
365
|
editable: 'admin' | 'agent';
|
|
361
366
|
/** Only used when type === 'enum' */
|
|
@@ -516,7 +521,7 @@ interface IntegrationManifest {
|
|
|
516
521
|
* Default: ['agent'] (per-agent only).
|
|
517
522
|
* 'org' means it can be installed at the org level and cascades to all agents.
|
|
518
523
|
*/
|
|
519
|
-
supported_scopes?: ('agent' | 'org')[];
|
|
524
|
+
supported_scopes?: ('agent' | 'org' | 'team' | 'project')[];
|
|
520
525
|
/**
|
|
521
526
|
* Connect provider ids whose credentials this integration resolves at
|
|
522
527
|
* 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
|
|
@@ -87,7 +131,7 @@ const SelectOptionSchema = z.object({
|
|
|
87
131
|
label: z.string().min(1)
|
|
88
132
|
});
|
|
89
133
|
const ConfigSchemaFieldSchema = z.object({
|
|
90
|
-
key: z.string().min(1, "Config key must not be empty"),
|
|
134
|
+
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
135
|
type: ConfigFieldTypeSchema,
|
|
92
136
|
label: z.string().min(1, "Config label must not be empty"),
|
|
93
137
|
description: z.string().optional(),
|
|
@@ -95,15 +139,18 @@ const ConfigSchemaFieldSchema = z.object({
|
|
|
95
139
|
default: z.union([
|
|
96
140
|
z.string(),
|
|
97
141
|
z.number(),
|
|
98
|
-
z.boolean()
|
|
142
|
+
z.boolean(),
|
|
143
|
+
z.array(z.string())
|
|
99
144
|
]).optional(),
|
|
100
145
|
editable: z.enum(["admin", "agent"]).optional().default("admin"),
|
|
101
146
|
hidden: z.boolean().optional(),
|
|
102
|
-
options: z.array(z.string()).optional(),
|
|
147
|
+
options: z.array(z.string().min(1, "Enum option must not be empty")).optional(),
|
|
103
148
|
select_options: z.array(SelectOptionSchema).optional(),
|
|
104
149
|
oauth_provider: z.string().optional(),
|
|
105
|
-
|
|
106
|
-
|
|
150
|
+
oauth_scopes: z.array(z.string().min(1, "OAuth scope must not be empty")).optional(),
|
|
151
|
+
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(),
|
|
152
|
+
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({
|
|
153
|
+
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
154
|
value: z.union([
|
|
108
155
|
z.string(),
|
|
109
156
|
z.number(),
|
|
@@ -120,7 +167,47 @@ const ConfigSchemaFieldSchema = z.object({
|
|
|
120
167
|
}, { message: "Select/multi_select fields must have at least one option in select_options" }).refine((field) => {
|
|
121
168
|
if (field.type === "oauth_connect") return field.oauth_provider !== void 0 && field.oauth_provider.length > 0;
|
|
122
169
|
return true;
|
|
123
|
-
}, { message: "oauth_connect fields must specify an oauth_provider" })
|
|
170
|
+
}, { message: "oauth_connect fields must specify an oauth_provider" }).superRefine((field, ctx) => {
|
|
171
|
+
if (field.options) addDuplicateIssues(field.options, ctx, ["options"], "enum option");
|
|
172
|
+
if (field.select_options) addDuplicateIssues(field.select_options.map((option) => option.value), ctx, ["select_options"], "select option value");
|
|
173
|
+
if (field.oauth_scopes) addDuplicateIssues(field.oauth_scopes, ctx, ["oauth_scopes"], "OAuth scope");
|
|
174
|
+
if (field.type !== "oauth_connect" && (field.oauth_scopes || field.oauth_integration_id)) ctx.addIssue({
|
|
175
|
+
code: "custom",
|
|
176
|
+
path: ["type"],
|
|
177
|
+
message: "OAuth scope and integration metadata is only valid on oauth_connect fields"
|
|
178
|
+
});
|
|
179
|
+
if (field.default === void 0) return;
|
|
180
|
+
let valid = false;
|
|
181
|
+
switch (field.type) {
|
|
182
|
+
case "string":
|
|
183
|
+
valid = typeof field.default === "string";
|
|
184
|
+
break;
|
|
185
|
+
case "secret":
|
|
186
|
+
case "oauth_connect":
|
|
187
|
+
valid = false;
|
|
188
|
+
break;
|
|
189
|
+
case "number":
|
|
190
|
+
valid = typeof field.default === "number";
|
|
191
|
+
break;
|
|
192
|
+
case "boolean":
|
|
193
|
+
valid = typeof field.default === "boolean";
|
|
194
|
+
break;
|
|
195
|
+
case "enum":
|
|
196
|
+
valid = typeof field.default === "string" && (field.options?.includes(field.default) ?? false);
|
|
197
|
+
break;
|
|
198
|
+
case "select":
|
|
199
|
+
valid = typeof field.default === "string" && (field.select_options?.some((option) => option.value === field.default) ?? false);
|
|
200
|
+
break;
|
|
201
|
+
case "multi_select":
|
|
202
|
+
valid = Array.isArray(field.default) && field.default.every((value) => field.select_options?.some((option) => option.value === value));
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
if (!valid) ctx.addIssue({
|
|
206
|
+
code: "custom",
|
|
207
|
+
path: ["default"],
|
|
208
|
+
message: `Default value is invalid for config field type ${field.type}`
|
|
209
|
+
});
|
|
210
|
+
});
|
|
124
211
|
/**
|
|
125
212
|
* A field a manifest's owner expects the user to fill in when creating
|
|
126
213
|
* a Custom Connection. Used ONLY by manifests authored against the
|
|
@@ -141,7 +228,7 @@ const CredentialFieldTypeSchema = z.enum([
|
|
|
141
228
|
"boolean"
|
|
142
229
|
]);
|
|
143
230
|
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)"),
|
|
231
|
+
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
232
|
type: CredentialFieldTypeSchema,
|
|
146
233
|
label: z.string().min(1, "Credential field label must not be empty"),
|
|
147
234
|
description: z.string().optional(),
|
|
@@ -150,25 +237,25 @@ const CredentialFieldSpecSchema = z.object({
|
|
|
150
237
|
pattern: z.string().max(256).optional()
|
|
151
238
|
});
|
|
152
239
|
const McpServerDeclarationSchema = z.object({
|
|
153
|
-
id: z.string().min(1, "MCP server id must not be empty").regex(
|
|
240
|
+
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
241
|
command: z.string().min(1, "MCP server command must not be empty"),
|
|
155
242
|
args: z.array(z.string()).optional().default([]),
|
|
156
|
-
env: z.record(z.string(), z.string()).optional(),
|
|
243
|
+
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
244
|
cwd: z.string().optional(),
|
|
158
245
|
requires_credentials: z.string().min(1).optional(),
|
|
159
246
|
hook_managed: z.boolean().optional().default(false)
|
|
160
247
|
});
|
|
161
248
|
const CommandDeclarationSchema = z.object({
|
|
162
249
|
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),
|
|
250
|
+
handler: RepositoryRelativePathSchema,
|
|
251
|
+
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"),
|
|
252
|
+
timeout_ms: z.number().int().positive().max(1800 * 1e3).optional().default(3e4),
|
|
166
253
|
description: z.string().optional()
|
|
167
254
|
});
|
|
168
255
|
const SkillInstallSchema = z.object({
|
|
169
|
-
path:
|
|
256
|
+
path: RepositoryRelativePathSchema.optional(),
|
|
170
257
|
clawhub: z.string().min(1, "ClawHub skill slug must not be empty").optional()
|
|
171
|
-
}).refine((data) => data.path
|
|
258
|
+
}).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
259
|
const PluginInstallSchema = z.object({ package: z.string().min(1, "Plugin package name must not be empty") });
|
|
173
260
|
/** Per-runtime install overrides */
|
|
174
261
|
const RuntimeInstallSchema = z.object({
|
|
@@ -177,19 +264,19 @@ const RuntimeInstallSchema = z.object({
|
|
|
177
264
|
config: z.record(z.string(), z.unknown()).optional()
|
|
178
265
|
});
|
|
179
266
|
/** 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");
|
|
267
|
+
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
268
|
const InstallTargetsSchema = z.object({
|
|
182
269
|
skills: z.array(SkillInstallSchema).optional().default([]),
|
|
183
270
|
plugins: z.array(PluginInstallSchema).optional().default([]),
|
|
184
271
|
runtimes: z.record(RuntimeKeySchema, RuntimeInstallSchema).optional()
|
|
185
272
|
});
|
|
186
273
|
const IntegrationHooksSchema = z.object({
|
|
187
|
-
pre_install:
|
|
188
|
-
post_install:
|
|
189
|
-
post_activate:
|
|
190
|
-
pre_uninstall:
|
|
191
|
-
post_uninstall:
|
|
192
|
-
health_check:
|
|
274
|
+
pre_install: RepositoryRelativePathSchema.optional(),
|
|
275
|
+
post_install: RepositoryRelativePathSchema.optional(),
|
|
276
|
+
post_activate: RepositoryRelativePathSchema.optional(),
|
|
277
|
+
pre_uninstall: RepositoryRelativePathSchema.optional(),
|
|
278
|
+
post_uninstall: RepositoryRelativePathSchema.optional(),
|
|
279
|
+
health_check: RepositoryRelativePathSchema.optional()
|
|
193
280
|
});
|
|
194
281
|
const IntegrationPricingPlanSchema = z.object({
|
|
195
282
|
name: z.string().min(1),
|
|
@@ -222,13 +309,13 @@ const IntegrationAuthorSchema = z.union([z.string().min(1, "Author must not be e
|
|
|
222
309
|
})]);
|
|
223
310
|
const SemverSchema = z.string().regex(/^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/, "Must be a valid semver version (e.g. 1.0.0)");
|
|
224
311
|
const IntegrationManifestSchema = z.object({
|
|
225
|
-
id: z.string().min(1, "Integration id must not be empty").regex(
|
|
312
|
+
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
313
|
name: z.string().min(1, "Integration name must not be empty"),
|
|
227
314
|
version: SemverSchema,
|
|
228
315
|
description: z.string().min(1, "Description must not be empty"),
|
|
229
316
|
author: IntegrationAuthorSchema,
|
|
230
317
|
license: z.string().default("MIT"),
|
|
231
|
-
depends_on: z.array(z.string()).optional().default([]),
|
|
318
|
+
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
319
|
min_gateway_version: SemverSchema.optional().default("0.1.0"),
|
|
233
320
|
installs: InstallTargetsSchema.optional().default({
|
|
234
321
|
skills: [],
|
|
@@ -242,7 +329,7 @@ const IntegrationManifestSchema = z.object({
|
|
|
242
329
|
requires_connection: z.array(z.string().min(1)).optional(),
|
|
243
330
|
channel_type: z.string().min(1).optional(),
|
|
244
331
|
expected_credentials: z.array(CredentialFieldSpecSchema).optional(),
|
|
245
|
-
repository:
|
|
332
|
+
repository: HttpsUrlSchema.optional(),
|
|
246
333
|
supported_agents: z.array(RuntimeKeySchema).optional(),
|
|
247
334
|
supported_scopes: z.array(z.enum([
|
|
248
335
|
"agent",
|
|
@@ -255,14 +342,85 @@ const IntegrationManifestSchema = z.object({
|
|
|
255
342
|
pricing: IntegrationPricingSchema.optional(),
|
|
256
343
|
preview_images: z.array(z.string()).optional(),
|
|
257
344
|
features: z.array(z.string()).optional()
|
|
345
|
+
}).superRefine((manifest, ctx) => {
|
|
346
|
+
addDuplicateIssues(manifest.depends_on, ctx, ["depends_on"], "dependency");
|
|
347
|
+
addDuplicateIssues(manifest.config_schema.map((field) => field.key), ctx, ["config_schema"], "config key");
|
|
348
|
+
addDuplicateIssues(manifest.commands.map((command) => command.name), ctx, ["commands"], "command name");
|
|
349
|
+
addDuplicateIssues(manifest.mcp_servers.map((server) => server.id), ctx, ["mcp_servers"], "MCP server id");
|
|
350
|
+
addDuplicateIssues(manifest.capabilities, ctx, ["capabilities"], "capability");
|
|
351
|
+
addDuplicateIssues(manifest.requires_connection ?? [], ctx, ["requires_connection"], "connection requirement");
|
|
352
|
+
addDuplicateIssues(manifest.supported_agents ?? [], ctx, ["supported_agents"], "supported agent");
|
|
353
|
+
addDuplicateIssues(manifest.supported_scopes, ctx, ["supported_scopes"], "supported scope");
|
|
354
|
+
if (manifest.depends_on.includes(manifest.id)) ctx.addIssue({
|
|
355
|
+
code: "custom",
|
|
356
|
+
path: ["depends_on"],
|
|
357
|
+
message: "An integration cannot depend on itself"
|
|
358
|
+
});
|
|
359
|
+
const configKeys = new Set(manifest.config_schema.map((field) => field.key));
|
|
360
|
+
for (const [index, field] of manifest.config_schema.entries()) {
|
|
361
|
+
if (!field.depends_on_field) continue;
|
|
362
|
+
const dependencyKey = typeof field.depends_on_field === "string" ? field.depends_on_field : field.depends_on_field.key;
|
|
363
|
+
if (!configKeys.has(dependencyKey)) ctx.addIssue({
|
|
364
|
+
code: "custom",
|
|
365
|
+
path: [
|
|
366
|
+
"config_schema",
|
|
367
|
+
index,
|
|
368
|
+
"depends_on_field"
|
|
369
|
+
],
|
|
370
|
+
message: `Config dependency does not exist: ${dependencyKey}`
|
|
371
|
+
});
|
|
372
|
+
else if (dependencyKey === field.key) ctx.addIssue({
|
|
373
|
+
code: "custom",
|
|
374
|
+
path: [
|
|
375
|
+
"config_schema",
|
|
376
|
+
index,
|
|
377
|
+
"depends_on_field"
|
|
378
|
+
],
|
|
379
|
+
message: "A config field cannot depend on itself"
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
if (manifest.expected_credentials) {
|
|
383
|
+
addDuplicateIssues(manifest.expected_credentials.map((field) => field.key), ctx, ["expected_credentials"], "credential key");
|
|
384
|
+
if (!manifest.requires_connection?.includes("custom")) ctx.addIssue({
|
|
385
|
+
code: "custom",
|
|
386
|
+
path: ["expected_credentials"],
|
|
387
|
+
message: "expected_credentials requires requires_connection to include custom"
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const validateInstallDuplicates = (install, path) => {
|
|
391
|
+
addDuplicateIssues(install.plugins.map((plugin) => plugin.package), ctx, [...path, "plugins"], "plugin package");
|
|
392
|
+
addDuplicateIssues(install.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`), ctx, [...path, "skills"], "skill source");
|
|
393
|
+
};
|
|
394
|
+
validateInstallDuplicates(manifest.installs, ["installs"]);
|
|
395
|
+
for (const [runtime, install] of Object.entries(manifest.installs.runtimes ?? {})) {
|
|
396
|
+
validateInstallDuplicates(install, [
|
|
397
|
+
"installs",
|
|
398
|
+
"runtimes",
|
|
399
|
+
runtime
|
|
400
|
+
]);
|
|
401
|
+
addDuplicateIssues([...manifest.installs.plugins.map((plugin) => plugin.package), ...install.plugins.map((plugin) => plugin.package)], ctx, [
|
|
402
|
+
"installs",
|
|
403
|
+
"runtimes",
|
|
404
|
+
runtime,
|
|
405
|
+
"plugins"
|
|
406
|
+
], "effective plugin package");
|
|
407
|
+
addDuplicateIssues([...manifest.installs.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`), ...install.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`)], ctx, [
|
|
408
|
+
"installs",
|
|
409
|
+
"runtimes",
|
|
410
|
+
runtime,
|
|
411
|
+
"skills"
|
|
412
|
+
], "effective skill source");
|
|
413
|
+
}
|
|
258
414
|
});
|
|
259
415
|
/**
|
|
260
416
|
* Validate user-provided config values against a manifest's config_schema.
|
|
261
417
|
* Returns a Zod schema dynamically built from the manifest's config_schema.
|
|
262
418
|
*/
|
|
263
419
|
function buildConfigValidationSchema(configSchema) {
|
|
264
|
-
const shape =
|
|
420
|
+
const shape = Object.create(null);
|
|
265
421
|
for (const field of configSchema) {
|
|
422
|
+
if (!IDENTIFIER_RE.test(field.key) || UNSAFE_RECORD_KEYS.has(field.key)) throw new Error(`Invalid config schema key: ${field.key}`);
|
|
423
|
+
if (Object.hasOwn(shape, field.key)) throw new Error(`Duplicate config schema key: ${field.key}`);
|
|
266
424
|
let fieldSchema;
|
|
267
425
|
switch (field.type) {
|
|
268
426
|
case "secret":
|
|
@@ -300,7 +458,7 @@ function buildConfigValidationSchema(configSchema) {
|
|
|
300
458
|
if (!field.required) fieldSchema = fieldSchema.optional();
|
|
301
459
|
shape[field.key] = fieldSchema;
|
|
302
460
|
}
|
|
303
|
-
return z.object(shape);
|
|
461
|
+
return z.object(shape).strict();
|
|
304
462
|
}
|
|
305
463
|
//#endregion
|
|
306
464
|
//#region src/parser.ts
|
|
@@ -408,11 +566,23 @@ function isRelativePath(path) {
|
|
|
408
566
|
*/
|
|
409
567
|
function resolveAssetUrl(assetPath, repoUrl, commitHash, subdir) {
|
|
410
568
|
if (!isRelativePath(assetPath)) return assetPath;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
569
|
+
let repository;
|
|
570
|
+
try {
|
|
571
|
+
repository = new URL(repoUrl);
|
|
572
|
+
} catch {
|
|
573
|
+
return assetPath;
|
|
574
|
+
}
|
|
575
|
+
const repoParts = repository.pathname.split("/").filter(Boolean);
|
|
576
|
+
const owner = repoParts[0];
|
|
577
|
+
const repo = repoParts[1]?.replace(/\.git$/, "");
|
|
578
|
+
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;
|
|
579
|
+
const portableSubdir = subdir.replaceAll("\\", "/");
|
|
580
|
+
const portableAssetPath = assetPath.replaceAll("\\", "/");
|
|
581
|
+
if (portableSubdir.startsWith("/") || portableAssetPath.startsWith("/") || /^[A-Za-z]:\//.test(portableSubdir) || /^[A-Za-z]:\//.test(portableAssetPath)) return assetPath;
|
|
582
|
+
const fullPath = posix.normalize(posix.join(portableSubdir, portableAssetPath));
|
|
583
|
+
if (fullPath === "." || fullPath === ".." || fullPath.startsWith("../")) return assetPath;
|
|
584
|
+
const encodedPath = fullPath.split("/").map(encodeURIComponent).join("/");
|
|
585
|
+
return `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(commitHash)}/${encodedPath}`;
|
|
416
586
|
}
|
|
417
587
|
//#endregion
|
|
418
588
|
//#region src/interpolation.ts
|
|
@@ -432,14 +602,33 @@ const TEMPLATE_RE = /\{\{integration\.([a-z0-9-]+)\.([a-z0-9_]+)\}\}/g;
|
|
|
432
602
|
*/
|
|
433
603
|
function interpolateConfig(config, ctx) {
|
|
434
604
|
const result = {};
|
|
435
|
-
for (const [key, value] of Object.entries(config)) if (typeof value === "string")
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
605
|
+
for (const [key, value] of Object.entries(config)) if (typeof value === "string") {
|
|
606
|
+
const interpolated = value.replace(TEMPLATE_RE, (_match, integrationId, field) => {
|
|
607
|
+
if (!Object.hasOwn(ctx.integrations, integrationId)) return _match;
|
|
608
|
+
const integrationConfig = ctx.integrations[integrationId];
|
|
609
|
+
if (!integrationConfig || !Object.hasOwn(integrationConfig, field)) return _match;
|
|
610
|
+
const resolved = integrationConfig[field];
|
|
611
|
+
if (resolved == null) return _match;
|
|
612
|
+
if (typeof resolved === "string") return resolved;
|
|
613
|
+
try {
|
|
614
|
+
const serialized = JSON.stringify(resolved);
|
|
615
|
+
return typeof serialized === "string" ? serialized : _match;
|
|
616
|
+
} catch {
|
|
617
|
+
return _match;
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
Object.defineProperty(result, key, {
|
|
621
|
+
configurable: true,
|
|
622
|
+
enumerable: true,
|
|
623
|
+
value: interpolated,
|
|
624
|
+
writable: true
|
|
625
|
+
});
|
|
626
|
+
} else Object.defineProperty(result, key, {
|
|
627
|
+
configurable: true,
|
|
628
|
+
enumerable: true,
|
|
629
|
+
value,
|
|
630
|
+
writable: true
|
|
441
631
|
});
|
|
442
|
-
else result[key] = value;
|
|
443
632
|
return result;
|
|
444
633
|
}
|
|
445
634
|
/**
|
|
@@ -461,12 +650,38 @@ function extractTemplateReferences(config) {
|
|
|
461
650
|
}
|
|
462
651
|
//#endregion
|
|
463
652
|
//#region src/migration.ts
|
|
653
|
+
function schemaByKey(fields, label) {
|
|
654
|
+
const result = /* @__PURE__ */ new Map();
|
|
655
|
+
for (const field of fields) {
|
|
656
|
+
if (result.has(field.key)) throw new Error(`Duplicate ${label} config schema key: ${field.key}`);
|
|
657
|
+
result.set(field.key, field);
|
|
658
|
+
}
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
661
|
+
function setOwn(target, key, value) {
|
|
662
|
+
Object.defineProperty(target, key, {
|
|
663
|
+
configurable: true,
|
|
664
|
+
enumerable: true,
|
|
665
|
+
value,
|
|
666
|
+
writable: true
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
function isValidFieldValue(field, value) {
|
|
670
|
+
try {
|
|
671
|
+
return buildConfigValidationSchema([{
|
|
672
|
+
...field,
|
|
673
|
+
required: true
|
|
674
|
+
}]).safeParse({ [field.key]: value }).success;
|
|
675
|
+
} catch {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
464
679
|
/**
|
|
465
680
|
* Diff two config schemas to determine what changed between versions.
|
|
466
681
|
*/
|
|
467
682
|
function diffConfigSchemas(oldSchema, newSchema) {
|
|
468
|
-
const oldMap =
|
|
469
|
-
const newMap =
|
|
683
|
+
const oldMap = schemaByKey(oldSchema, "old");
|
|
684
|
+
const newMap = schemaByKey(newSchema, "new");
|
|
470
685
|
const added = [];
|
|
471
686
|
const removed = [];
|
|
472
687
|
const changed = [];
|
|
@@ -501,12 +716,20 @@ function diffConfigSchemas(oldSchema, newSchema) {
|
|
|
501
716
|
function migrateConfig(currentConfig, diff) {
|
|
502
717
|
const config = {};
|
|
503
718
|
const warnings = [];
|
|
504
|
-
for (const field of diff.unchanged) if (field.key
|
|
719
|
+
for (const field of diff.unchanged) if (Object.hasOwn(currentConfig, field.key)) setOwn(config, field.key, currentConfig[field.key]);
|
|
505
720
|
for (const change of diff.changed) if (change.breaking) {
|
|
506
|
-
if (change.newSchema.default !== void 0) config
|
|
721
|
+
if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
|
|
507
722
|
warnings.push(`Field "${change.field}" type changed from "${change.oldSchema.type}" to "${change.newSchema.type}" — value reset`);
|
|
508
|
-
} else if (
|
|
509
|
-
|
|
723
|
+
} else if (Object.hasOwn(currentConfig, change.field)) {
|
|
724
|
+
const currentValue = currentConfig[change.field];
|
|
725
|
+
if (isValidFieldValue(change.newSchema, currentValue)) setOwn(config, change.field, currentValue);
|
|
726
|
+
else {
|
|
727
|
+
if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
|
|
728
|
+
warnings.push(`Field "${change.field}" no longer accepts its existing value — value reset`);
|
|
729
|
+
}
|
|
730
|
+
} else if (change.newSchema.required) if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
|
|
731
|
+
else warnings.push(`Field "${change.field}" is now required and has no default — user input needed`);
|
|
732
|
+
for (const field of diff.added) if (field.default !== void 0) setOwn(config, field.key, field.default);
|
|
510
733
|
else if (field.required) warnings.push(`New required field "${field.key}" has no default — user input needed`);
|
|
511
734
|
return {
|
|
512
735
|
config,
|