@alfe.ai/integration-manifest 0.3.3 → 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 +12 -7
- package/dist/index.js +271 -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
|
|
@@ -15,7 +16,7 @@ import { z } from "zod";
|
|
|
15
16
|
* caught at runtime when the integrations service tries to resolve the
|
|
16
17
|
* provider and gets nothing back.
|
|
17
18
|
*/
|
|
18
|
-
declare const KNOWN_CONNECT_PROVIDER_IDS: readonly ["atlassian", "bluesky", "ctrader", "custom", "discord", "github", "google", "microsoft", "mobile", "myob", "notion", "salesforce", "slack", "xero"];
|
|
19
|
+
declare const KNOWN_CONNECT_PROVIDER_IDS: readonly ["atlassian", "bluesky", "ctrader", "custom", "discord", "github", "google", "microsoft", "mobile", "myob", "notion", "salesforce", "shopify", "slack", "x", "xero"];
|
|
19
20
|
type KnownConnectProviderId = (typeof KNOWN_CONNECT_PROVIDER_IDS)[number];
|
|
20
21
|
/**
|
|
21
22
|
* `custom` is a special provider id: it doesn't correspond to a fixed
|
|
@@ -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
|
|
@@ -35,7 +79,9 @@ const KNOWN_CONNECT_PROVIDER_IDS = [
|
|
|
35
79
|
"myob",
|
|
36
80
|
"notion",
|
|
37
81
|
"salesforce",
|
|
82
|
+
"shopify",
|
|
38
83
|
"slack",
|
|
84
|
+
"x",
|
|
39
85
|
"xero"
|
|
40
86
|
];
|
|
41
87
|
/**
|
|
@@ -85,7 +131,7 @@ const SelectOptionSchema = z.object({
|
|
|
85
131
|
label: z.string().min(1)
|
|
86
132
|
});
|
|
87
133
|
const ConfigSchemaFieldSchema = z.object({
|
|
88
|
-
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"),
|
|
89
135
|
type: ConfigFieldTypeSchema,
|
|
90
136
|
label: z.string().min(1, "Config label must not be empty"),
|
|
91
137
|
description: z.string().optional(),
|
|
@@ -93,15 +139,18 @@ const ConfigSchemaFieldSchema = z.object({
|
|
|
93
139
|
default: z.union([
|
|
94
140
|
z.string(),
|
|
95
141
|
z.number(),
|
|
96
|
-
z.boolean()
|
|
142
|
+
z.boolean(),
|
|
143
|
+
z.array(z.string())
|
|
97
144
|
]).optional(),
|
|
98
145
|
editable: z.enum(["admin", "agent"]).optional().default("admin"),
|
|
99
146
|
hidden: z.boolean().optional(),
|
|
100
|
-
options: z.array(z.string()).optional(),
|
|
147
|
+
options: z.array(z.string().min(1, "Enum option must not be empty")).optional(),
|
|
101
148
|
select_options: z.array(SelectOptionSchema).optional(),
|
|
102
149
|
oauth_provider: z.string().optional(),
|
|
103
|
-
|
|
104
|
-
|
|
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"),
|
|
105
154
|
value: z.union([
|
|
106
155
|
z.string(),
|
|
107
156
|
z.number(),
|
|
@@ -118,7 +167,47 @@ const ConfigSchemaFieldSchema = z.object({
|
|
|
118
167
|
}, { message: "Select/multi_select fields must have at least one option in select_options" }).refine((field) => {
|
|
119
168
|
if (field.type === "oauth_connect") return field.oauth_provider !== void 0 && field.oauth_provider.length > 0;
|
|
120
169
|
return true;
|
|
121
|
-
}, { 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
|
+
});
|
|
122
211
|
/**
|
|
123
212
|
* A field a manifest's owner expects the user to fill in when creating
|
|
124
213
|
* a Custom Connection. Used ONLY by manifests authored against the
|
|
@@ -139,7 +228,7 @@ const CredentialFieldTypeSchema = z.enum([
|
|
|
139
228
|
"boolean"
|
|
140
229
|
]);
|
|
141
230
|
const CredentialFieldSpecSchema = z.object({
|
|
142
|
-
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"),
|
|
143
232
|
type: CredentialFieldTypeSchema,
|
|
144
233
|
label: z.string().min(1, "Credential field label must not be empty"),
|
|
145
234
|
description: z.string().optional(),
|
|
@@ -148,25 +237,25 @@ const CredentialFieldSpecSchema = z.object({
|
|
|
148
237
|
pattern: z.string().max(256).optional()
|
|
149
238
|
});
|
|
150
239
|
const McpServerDeclarationSchema = z.object({
|
|
151
|
-
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"),
|
|
152
241
|
command: z.string().min(1, "MCP server command must not be empty"),
|
|
153
242
|
args: z.array(z.string()).optional().default([]),
|
|
154
|
-
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(),
|
|
155
244
|
cwd: z.string().optional(),
|
|
156
245
|
requires_credentials: z.string().min(1).optional(),
|
|
157
246
|
hook_managed: z.boolean().optional().default(false)
|
|
158
247
|
});
|
|
159
248
|
const CommandDeclarationSchema = z.object({
|
|
160
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\")"),
|
|
161
|
-
handler:
|
|
162
|
-
method: z.string().optional().default("handle"),
|
|
163
|
-
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),
|
|
164
253
|
description: z.string().optional()
|
|
165
254
|
});
|
|
166
255
|
const SkillInstallSchema = z.object({
|
|
167
|
-
path:
|
|
256
|
+
path: RepositoryRelativePathSchema.optional(),
|
|
168
257
|
clawhub: z.string().min(1, "ClawHub skill slug must not be empty").optional()
|
|
169
|
-
}).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" });
|
|
170
259
|
const PluginInstallSchema = z.object({ package: z.string().min(1, "Plugin package name must not be empty") });
|
|
171
260
|
/** Per-runtime install overrides */
|
|
172
261
|
const RuntimeInstallSchema = z.object({
|
|
@@ -175,19 +264,19 @@ const RuntimeInstallSchema = z.object({
|
|
|
175
264
|
config: z.record(z.string(), z.unknown()).optional()
|
|
176
265
|
});
|
|
177
266
|
/** Runtime key: lowercase alphanumeric + hyphens */
|
|
178
|
-
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");
|
|
179
268
|
const InstallTargetsSchema = z.object({
|
|
180
269
|
skills: z.array(SkillInstallSchema).optional().default([]),
|
|
181
270
|
plugins: z.array(PluginInstallSchema).optional().default([]),
|
|
182
271
|
runtimes: z.record(RuntimeKeySchema, RuntimeInstallSchema).optional()
|
|
183
272
|
});
|
|
184
273
|
const IntegrationHooksSchema = z.object({
|
|
185
|
-
pre_install:
|
|
186
|
-
post_install:
|
|
187
|
-
post_activate:
|
|
188
|
-
pre_uninstall:
|
|
189
|
-
post_uninstall:
|
|
190
|
-
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()
|
|
191
280
|
});
|
|
192
281
|
const IntegrationPricingPlanSchema = z.object({
|
|
193
282
|
name: z.string().min(1),
|
|
@@ -220,13 +309,13 @@ const IntegrationAuthorSchema = z.union([z.string().min(1, "Author must not be e
|
|
|
220
309
|
})]);
|
|
221
310
|
const SemverSchema = z.string().regex(/^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/, "Must be a valid semver version (e.g. 1.0.0)");
|
|
222
311
|
const IntegrationManifestSchema = z.object({
|
|
223
|
-
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"),
|
|
224
313
|
name: z.string().min(1, "Integration name must not be empty"),
|
|
225
314
|
version: SemverSchema,
|
|
226
315
|
description: z.string().min(1, "Description must not be empty"),
|
|
227
316
|
author: IntegrationAuthorSchema,
|
|
228
317
|
license: z.string().default("MIT"),
|
|
229
|
-
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([]),
|
|
230
319
|
min_gateway_version: SemverSchema.optional().default("0.1.0"),
|
|
231
320
|
installs: InstallTargetsSchema.optional().default({
|
|
232
321
|
skills: [],
|
|
@@ -240,7 +329,7 @@ const IntegrationManifestSchema = z.object({
|
|
|
240
329
|
requires_connection: z.array(z.string().min(1)).optional(),
|
|
241
330
|
channel_type: z.string().min(1).optional(),
|
|
242
331
|
expected_credentials: z.array(CredentialFieldSpecSchema).optional(),
|
|
243
|
-
repository:
|
|
332
|
+
repository: HttpsUrlSchema.optional(),
|
|
244
333
|
supported_agents: z.array(RuntimeKeySchema).optional(),
|
|
245
334
|
supported_scopes: z.array(z.enum([
|
|
246
335
|
"agent",
|
|
@@ -253,14 +342,85 @@ const IntegrationManifestSchema = z.object({
|
|
|
253
342
|
pricing: IntegrationPricingSchema.optional(),
|
|
254
343
|
preview_images: z.array(z.string()).optional(),
|
|
255
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
|
+
}
|
|
256
414
|
});
|
|
257
415
|
/**
|
|
258
416
|
* Validate user-provided config values against a manifest's config_schema.
|
|
259
417
|
* Returns a Zod schema dynamically built from the manifest's config_schema.
|
|
260
418
|
*/
|
|
261
419
|
function buildConfigValidationSchema(configSchema) {
|
|
262
|
-
const shape =
|
|
420
|
+
const shape = Object.create(null);
|
|
263
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}`);
|
|
264
424
|
let fieldSchema;
|
|
265
425
|
switch (field.type) {
|
|
266
426
|
case "secret":
|
|
@@ -298,7 +458,7 @@ function buildConfigValidationSchema(configSchema) {
|
|
|
298
458
|
if (!field.required) fieldSchema = fieldSchema.optional();
|
|
299
459
|
shape[field.key] = fieldSchema;
|
|
300
460
|
}
|
|
301
|
-
return z.object(shape);
|
|
461
|
+
return z.object(shape).strict();
|
|
302
462
|
}
|
|
303
463
|
//#endregion
|
|
304
464
|
//#region src/parser.ts
|
|
@@ -406,11 +566,23 @@ function isRelativePath(path) {
|
|
|
406
566
|
*/
|
|
407
567
|
function resolveAssetUrl(assetPath, repoUrl, commitHash, subdir) {
|
|
408
568
|
if (!isRelativePath(assetPath)) return assetPath;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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}`;
|
|
414
586
|
}
|
|
415
587
|
//#endregion
|
|
416
588
|
//#region src/interpolation.ts
|
|
@@ -430,14 +602,33 @@ const TEMPLATE_RE = /\{\{integration\.([a-z0-9-]+)\.([a-z0-9_]+)\}\}/g;
|
|
|
430
602
|
*/
|
|
431
603
|
function interpolateConfig(config, ctx) {
|
|
432
604
|
const result = {};
|
|
433
|
-
for (const [key, value] of Object.entries(config)) if (typeof value === "string")
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
439
631
|
});
|
|
440
|
-
else result[key] = value;
|
|
441
632
|
return result;
|
|
442
633
|
}
|
|
443
634
|
/**
|
|
@@ -459,12 +650,38 @@ function extractTemplateReferences(config) {
|
|
|
459
650
|
}
|
|
460
651
|
//#endregion
|
|
461
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
|
+
}
|
|
462
679
|
/**
|
|
463
680
|
* Diff two config schemas to determine what changed between versions.
|
|
464
681
|
*/
|
|
465
682
|
function diffConfigSchemas(oldSchema, newSchema) {
|
|
466
|
-
const oldMap =
|
|
467
|
-
const newMap =
|
|
683
|
+
const oldMap = schemaByKey(oldSchema, "old");
|
|
684
|
+
const newMap = schemaByKey(newSchema, "new");
|
|
468
685
|
const added = [];
|
|
469
686
|
const removed = [];
|
|
470
687
|
const changed = [];
|
|
@@ -499,12 +716,20 @@ function diffConfigSchemas(oldSchema, newSchema) {
|
|
|
499
716
|
function migrateConfig(currentConfig, diff) {
|
|
500
717
|
const config = {};
|
|
501
718
|
const warnings = [];
|
|
502
|
-
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]);
|
|
503
720
|
for (const change of diff.changed) if (change.breaking) {
|
|
504
|
-
if (change.newSchema.default !== void 0) config
|
|
721
|
+
if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
|
|
505
722
|
warnings.push(`Field "${change.field}" type changed from "${change.oldSchema.type}" to "${change.newSchema.type}" — value reset`);
|
|
506
|
-
} else if (
|
|
507
|
-
|
|
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);
|
|
508
733
|
else if (field.required) warnings.push(`New required field "${field.key}" has no default — user input needed`);
|
|
509
734
|
return {
|
|
510
735
|
config,
|