@capybara-hq/kernel 0.1.0-rc.15 → 0.1.0-rc.16

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.
@@ -188,7 +188,7 @@ export declare function headsToResolve(entries: readonly MarketplaceEntry[], imp
188
188
  /** The host versions (an entry's `minKernel` is judged against them). */
189
189
  export declare const HOST_VERSIONS: {
190
190
  readonly kernel: string;
191
- readonly sdk: "0.1.0-rc.11";
191
+ readonly sdk: "0.1.0-rc.12";
192
192
  };
193
193
  /** Whether this deployment satisfies the entry's `minKernel`. */
194
194
  export declare const entryCompatible: (e: Pick<MarketplaceEntry, "minKernel">) => boolean;
@@ -9,6 +9,10 @@ export declare const ChannelCreateSchema: z.ZodObject<{
9
9
  anonymous: "anonymous";
10
10
  bind: "bind";
11
11
  }>>;
12
+ inboundMode: z.ZodOptional<z.ZodEnum<{
13
+ long_connection: "long_connection";
14
+ webhook: "webhook";
15
+ }>>;
12
16
  }, z.core.$strict>;
13
17
  export declare const ChannelPatchSchema: z.ZodObject<{
14
18
  name: z.ZodOptional<z.ZodString>;
@@ -8,6 +8,8 @@ export const ChannelCreateSchema = z
8
8
  agentId: z.string().min(1).max(100),
9
9
  config: Config,
10
10
  unboundPolicy: Policy.optional(),
11
+ // default long_connection; webhook needs plugin support and channels.webhookBaseUrl (409)
12
+ inboundMode: z.enum(["long_connection", "webhook"]).optional(),
11
13
  })
12
14
  .strict();
13
15
  export const ChannelPatchSchema = z
@@ -7,7 +7,7 @@ import { PluginIcons } from "../extensions/plugin-icons.js";
7
7
  import { type Principal } from "../identity/principal.js";
8
8
  import { type OrgContext } from "../orgs/org.guard.js";
9
9
  import { ChannelCreateSchema as CreateBody, ChannelGuideAckSchema as GuideAckBody, ChannelPatchSchema as PatchBody } from "./channels.schemas.js";
10
- import { ChannelConnections, type ChannelConnectionView } from "./connections.service.js";
10
+ import { ChannelConnections, type ChannelConnectionView, type ChannelInboundInfo } from "./connections.service.js";
11
11
  /**
12
12
  * A connection as the org's pages show it: its plugin's display name (null when it has none) and
13
13
  * the URL of its plugin's icon on the org's catalog route (null without one, or when the org may
@@ -36,13 +36,15 @@ export declare class ChannelConnectionsController {
36
36
  channels: ChannelConnectionPage[];
37
37
  }>;
38
38
  /**
39
- * The connection form's JSON Schema, and where the plugin's setup guide is (`guide`, null when
40
- * the plugin has none the org may see): 新建渠道 shows it once the plugin is picked.
39
+ * The connection form's JSON Schema, where the plugin's setup guide is (`guide`, null when the
40
+ * plugin has none the org may see): 新建渠道 shows it once the plugin is picked; and whether its
41
+ * connections can use webhook inbound (`inbound`, channel inbound mode fields spec §4).
41
42
  */
42
43
  schema(org: OrgContext, plugin: string): Promise<{
43
44
  plugin: string;
44
45
  schema: JsonSchema;
45
46
  guide: Omit<SetupGuideRef, "version"> | null;
47
+ inbound: ChannelInboundInfo;
46
48
  }>;
47
49
  create(principal: Principal, org: OrgContext, body: z.infer<typeof CreateBody>): Promise<{
48
50
  channel: ChannelConnectionPage;
@@ -16,7 +16,7 @@ import { createZodDto } from "../infra/validation/create-zod-dto.js";
16
16
  import { ZBody } from "../infra/validation/zod-params.js";
17
17
  import { CurrentOrg, OrgAction } from "../orgs/org.guard.js";
18
18
  import { ChannelCreateSchema as CreateBody, ChannelGuideAckSchema as GuideAckBody, ChannelPatchSchema as PatchBody, } from "./channels.schemas.js";
19
- import { ChannelConnections } from "./connections.service.js";
19
+ import { ChannelConnections, } from "./connections.service.js";
20
20
  class ChannelCreateBody extends createZodDto(CreateBody, "ChannelCreateBody") {
21
21
  }
22
22
  class ChannelPatchBody extends createZodDto(PatchBody, "ChannelPatchBody") {
@@ -62,13 +62,15 @@ let ChannelConnectionsController = class ChannelConnectionsController {
62
62
  return { channels: await this.withPlugins(org, await this.channels.list(org.orgId)) };
63
63
  }
64
64
  /**
65
- * The connection form's JSON Schema, and where the plugin's setup guide is (`guide`, null when
66
- * the plugin has none the org may see): 新建渠道 shows it once the plugin is picked.
65
+ * The connection form's JSON Schema, where the plugin's setup guide is (`guide`, null when the
66
+ * plugin has none the org may see): 新建渠道 shows it once the plugin is picked; and whether its
67
+ * connections can use webhook inbound (`inbound`, channel inbound mode fields spec §4).
67
68
  */
68
69
  async schema(org, plugin) {
69
70
  const schema = this.channels.jsonSchema(plugin);
71
+ const inbound = this.channels.inboundInfo(plugin);
70
72
  const g = (await this.guides.channelGuides([plugin], org)).get(plugin);
71
- return { plugin, schema, guide: g ? { itemId: g.itemId, guideId: g.guideId } : null };
73
+ return { plugin, schema, guide: g ? { itemId: g.itemId, guideId: g.guideId } : null, inbound };
72
74
  }
73
75
  async create(principal, org, body) {
74
76
  const p = requireUser(principal);
@@ -15,6 +15,11 @@ import { OrgLock } from "../orgs/org-lock.js";
15
15
  import type { OrgRole } from "../orgs/roles.js";
16
16
  import { ChannelJobs } from "./jobs.js";
17
17
  import type { ChannelLocale } from "./phrases.js";
18
+ /**
19
+ * A public https address: https, and the host is not loopback, a private / link-local / reserved
20
+ * IP, `localhost`, `*.localhost`, `*.local`, `*.internal` or a single label (no DNS lookup).
21
+ */
22
+ export declare function isPublicHttpsUrl(url: string): boolean;
18
23
  /** 连接配置里全部密钥字段存成一行:`{ 点路径: 值 }` 的 JSON。 */
19
24
  export declare const connectionSecretKey: (connectionId: string) => string;
20
25
  /**
@@ -82,6 +87,18 @@ export interface CreateChannelInput {
82
87
  agentId: string;
83
88
  config: Record<string, unknown>;
84
89
  unboundPolicy?: UnboundPolicy | undefined;
90
+ /** Default `long_connection`; `webhook` needs plugin support and `channels.webhookBaseUrl`. */
91
+ inboundMode?: InboundMode | undefined;
92
+ }
93
+ /**
94
+ * Whether a channel plugin's connections can use webhook inbound (channel inbound mode fields spec
95
+ * §4): the plugin implements `Channel.webhook`; the kernel accepts webhook mode (a usable
96
+ * `channels.webhookBaseUrl`); and that base URL is a public https address.
97
+ */
98
+ export interface ChannelInboundInfo {
99
+ webhookSupported: boolean;
100
+ webhookAvailable: boolean;
101
+ webhookPublic: boolean;
85
102
  }
86
103
  /** `config` 里的密钥字段:缺省 = 保留原值、`null` = 清除、其他值 = 新值(同组织插件配置)。 */
87
104
  export interface UpdateChannelInput {
@@ -163,6 +180,15 @@ export declare class ChannelConnections implements OnApplicationShutdown {
163
180
  * `channels.webhookBaseUrl` (otherwise there is no URL to give the platform): 409 otherwise.
164
181
  */
165
182
  private assertWebhookAvailable;
183
+ /**
184
+ * The fields the inbound mode requires (`x-ui.inboundModes`, channel inbound mode fields spec
185
+ * §3) must have a value in `full` (the parsed config, stored secrets included): 400
186
+ * `extension.config_invalid` with one issue per missing field otherwise. A plugin without the
187
+ * hint passes.
188
+ */
189
+ private assertModeFields;
190
+ /** Whether the plugin's connections can use webhook inbound (spec §4); `extension.not_found`. */
191
+ inboundInfo(plugin: string): ChannelInboundInfo;
166
192
  private viewOf;
167
193
  private live;
168
194
  list(orgId: string): Promise<ChannelConnectionView[]>;
@@ -8,7 +8,8 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
10
  import { randomUUID } from "node:crypto";
11
- import { CHANNEL_EXTENSION_POINT, } from "@capybara-hq/plugin-sdk";
11
+ import { isIP } from "node:net";
12
+ import { CHANNEL_EXTENSION_POINT, missingInboundModeFields, } from "@capybara-hq/plugin-sdk";
12
13
  import { Inject, Injectable } from "@nestjs/common";
13
14
  import { sql } from "kysely";
14
15
  import { configFields, getPath, secretScalars, secretValues } from "../config/fields.js";
@@ -28,11 +29,35 @@ import { Transactional } from "../infra/database/transactional.decorator.js";
28
29
  import { KernelEvents } from "../infra/events/kernel-events.js";
29
30
  import { KERNEL_BUS, KERNEL_LOGGER, KEYRING, REDIS } from "../infra/tokens.js";
30
31
  import { createRedactor } from "../log.js";
32
+ import { isForbiddenAddress, isLoopbackHost, normalizeHost } from "../net/public-address.js";
31
33
  import { OrgLock } from "../orgs/org-lock.js";
32
34
  import { readOrgSettings } from "../orgs/settings.service.js";
33
35
  import { adaptToFeatures } from "./features.js";
34
36
  import { ChannelJobs } from "./jobs.js";
35
37
  import { webhookBase, webhookUrlOf } from "./webhook-url.js";
38
+ /**
39
+ * A public https address: https, and the host is not loopback, a private / link-local / reserved
40
+ * IP, `localhost`, `*.localhost`, `*.local`, `*.internal` or a single label (no DNS lookup).
41
+ */
42
+ export function isPublicHttpsUrl(url) {
43
+ let u;
44
+ try {
45
+ u = new URL(url);
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ if (u.protocol !== "https:")
51
+ return false;
52
+ const host = normalizeHost(u.hostname);
53
+ if (isLoopbackHost(host))
54
+ return false;
55
+ if (isIP(host) !== 0)
56
+ return !isForbiddenAddress(host);
57
+ if (!host.includes("."))
58
+ return false;
59
+ return ![".localhost", ".local", ".internal"].some((s) => host.endsWith(s));
60
+ }
36
61
  /** 内核 secrets 命名空间(插件名不能取 `kernel`)。 */
37
62
  const SECRETS_NAMESPACE = "kernel";
38
63
  /** 连接配置里全部密钥字段存成一行:`{ 点路径: 值 }` 的 JSON。 */
@@ -305,6 +330,38 @@ let ChannelConnections = class ChannelConnections {
305
330
  throw new ApiError("channel.webhook_unavailable", "Webhook inbound needs channels.webhookBaseUrl to be configured");
306
331
  }
307
332
  }
333
+ /**
334
+ * The fields the inbound mode requires (`x-ui.inboundModes`, channel inbound mode fields spec
335
+ * §3) must have a value in `full` (the parsed config, stored secrets included): 400
336
+ * `extension.config_invalid` with one issue per missing field otherwise. A plugin without the
337
+ * hint passes.
338
+ */
339
+ assertModeFields(plugin, schema, mode, full) {
340
+ const js = this.formSchema(plugin, this.versionOf(plugin), schema)?.schema;
341
+ if (js === undefined)
342
+ return;
343
+ const missing = missingInboundModeFields(js, mode, (field) => {
344
+ const v = getPath(full, field);
345
+ return v !== undefined && v !== null && v !== "";
346
+ });
347
+ if (missing.length === 0)
348
+ return;
349
+ throw new ApiError("extension.config_invalid", `Invalid channel connection config for extension ${plugin}`, missing.map((field) => ({
350
+ path: field,
351
+ segments: [field],
352
+ message: `required in ${mode} mode`,
353
+ })));
354
+ }
355
+ /** Whether the plugin's connections can use webhook inbound (spec §4); `extension.not_found`. */
356
+ inboundInfo(plugin) {
357
+ const webhookSupported = this.channel(plugin).webhook !== undefined;
358
+ const base = this.webhookBase();
359
+ return {
360
+ webhookSupported,
361
+ webhookAvailable: webhookSupported && base !== undefined,
362
+ webhookPublic: webhookSupported && base !== undefined && isPublicHttpsUrl(base),
363
+ };
364
+ }
308
365
  async viewOf(row) {
309
366
  return this.view(row, new Set(Object.keys(await this.readSecrets(row.org_id, row.id))));
310
367
  }
@@ -331,14 +388,16 @@ let ChannelConnections = class ChannelConnections {
331
388
  await this.assertEnabled(orgId, input.plugin);
332
389
  const schema = channel.connectionSchema;
333
390
  const full = this.parse(input.plugin, schema, input.config);
391
+ const mode = input.inboundMode ?? "long_connection";
392
+ if (mode === "webhook")
393
+ this.assertWebhookAvailable(input.plugin);
394
+ this.assertModeFields(input.plugin, schema, mode, full);
334
395
  await this.assertAgent(orgId, input.agentId);
335
396
  const verified = await this.verify(channel, full);
336
397
  const id = randomUUID();
337
398
  const { plain, secrets } = split(schema, full);
338
- // the guide the 新建渠道 page showed: the plugin's current one, for the default mode
339
- const guideAck = await this.guides.currentAck(input.plugin, "channel-connection", {
340
- mode: "long_connection",
341
- });
399
+ // the guide the 新建渠道 page showed: the plugin's current one, for the mode picked there
400
+ const guideAck = await this.guides.currentAck(input.plugin, "channel-connection", { mode });
342
401
  const row = await this.insertIn({
343
402
  orgId,
344
403
  id,
@@ -372,6 +431,7 @@ let ChannelConnections = class ChannelConnections {
372
431
  agent_id: input.agentId,
373
432
  config: JSON.stringify(a.plain),
374
433
  unbound_policy: input.unboundPolicy ?? "bind",
434
+ inbound_mode: input.inboundMode ?? "long_connection",
375
435
  bot_user_id: a.verified.botUserId,
376
436
  tenant_id: a.verified.tenantId,
377
437
  guide_ack: a.guideAck === null ? null : JSON.stringify(a.guideAck),
@@ -385,7 +445,11 @@ let ChannelConnections = class ChannelConnections {
385
445
  orgId,
386
446
  action: "channel.created",
387
447
  target: id,
388
- detail: { plugin: input.plugin, agentId: input.agentId },
448
+ detail: {
449
+ plugin: input.plugin,
450
+ agentId: input.agentId,
451
+ inboundMode: input.inboundMode ?? "long_connection",
452
+ },
389
453
  });
390
454
  return inserted;
391
455
  }
@@ -437,6 +501,15 @@ let ChannelConnections = class ChannelConnections {
437
501
  const modeChanged = patch.inboundMode !== undefined && patch.inboundMode !== current.inbound_mode;
438
502
  if (modeChanged && patch.inboundMode === "webhook")
439
503
  this.assertWebhookAvailable(current.plugin);
504
+ if (rotated || modeChanged) {
505
+ // the fields the effective mode requires (spec §3): the new config, else the stored one
506
+ const schema = this.channel(current.plugin).connectionSchema;
507
+ const full = rotated?.full ?? {
508
+ ...(current.config ?? {}),
509
+ ...(await this.readSecrets(orgId, id)),
510
+ };
511
+ this.assertModeFields(current.plugin, schema, patch.inboundMode ?? current.inbound_mode, full);
512
+ }
440
513
  if (patch.agentId !== undefined)
441
514
  await this.assertAgent(orgId, patch.agentId);
442
515
  // A mode switch sets the platform app up for the new mode: the guide of that mode is the one
@@ -4,8 +4,10 @@ import type { KernelDb } from "../infra/database/kernel-db.js";
4
4
  import type { OrgAction } from "../orgs/roles.js";
5
5
  /** Why a known variable has no value: what the web and the `Platform` tool tell the reader. */
6
6
  export type UnresolvedReason =
7
- /** The target is not saved yet (the webhook URL, a connection name nobody typed). */
7
+ /** The target is not saved yet (the webhook URL). */
8
8
  "not_saved"
9
+ /** No name typed yet on the new target's form (a connection name on 新建渠道). */
10
+ | "no_name"
9
11
  /** `http.publicUrl` is not set (the URLs built from it). */
10
12
  | "public_url"
11
13
  /** `channels.webhookBaseUrl` is not set or unusable. */
@@ -58,7 +58,7 @@ export async function resolvePlace(ctx) {
58
58
  const typed = ctx.query.name?.trim();
59
59
  values.connectionName =
60
60
  connection?.name ?? (typed === undefined || typed === "" ? null : typed);
61
- reasons.connectionName = "not_saved";
61
+ reasons.connectionName = "no_name";
62
62
  if (connection !== undefined) {
63
63
  target = { kind: "channel-connection", id: connection.id };
64
64
  storedMode = connection.inbound_mode;
@@ -12,11 +12,11 @@ import type { ExtensionSource } from "./sources.js";
12
12
  export declare function reviewHash(set: PermissionSet, warnings?: readonly PackageWarning[]): string;
13
13
  /** 权限清单展示用的一组:按类别分组,`sensitive` 标出需要管理员特别留意的类别。 */
14
14
  export interface PermissionGroup {
15
- category: "provides" | "requires" | "optional" | "config" | "tables" | "secrets" | "storage" | "http" | "publicRoutes" | "tools" | "hooks" | "models" | "network" | "ui" | "client";
15
+ category: "provides" | "requires" | "optional" | "config" | "tables" | "secrets" | "storage" | "http" | "publicRoutes" | "tools" | "hooks" | "models" | "network" | "ui" | "components" | "client";
16
16
  /**
17
- * Sensitive category: secrets, public routes, tools, hooks, client code; also the "provides" group
18
- * when it holds one of {@link SENSITIVE_EXTENSION_POINTS}, and the "UI" group when it holds an
19
- * embedded app (embed: the extension's own page in an iframe) or a client component.
17
+ * Sensitive category: secrets, public routes, tools, hooks, client components, client code; also
18
+ * the "provides" group when it holds one of {@link SENSITIVE_EXTENSION_POINTS}, and the "UI" group
19
+ * (views and embeds) when it holds an embedded app (embed: the extension's own page in an iframe).
20
20
  */
21
21
  sensitive: boolean;
22
22
  items: string[];
@@ -38,6 +38,7 @@ const ALWAYS_SENSITIVE = new Set([
38
38
  "publicRoutes",
39
39
  "tools",
40
40
  "hooks",
41
+ "components",
41
42
  "client",
42
43
  ]);
43
44
  const CATEGORY_REASON = {
@@ -45,13 +46,14 @@ const CATEGORY_REASON = {
45
46
  publicRoutes: "public_routes",
46
47
  tools: "tools",
47
48
  hooks: "hooks",
49
+ components: "ui_component",
48
50
  client: "client_code",
49
51
  };
50
52
  /**
51
53
  * Why an item in a category is sensitive (undefined when it is not; shared by the grouped list, the
52
54
  * per-item `added` and the CLI markers): secrets, public routes, tools, hooks and client code are
53
- * always sensitive; {@link SENSITIVE_EXTENSION_POINTS} in "provides"; embedded apps (`embed:<id>`)
54
- * and client components (`component:<id>`) in "UI".
55
+ * always sensitive, and so are client components (their own group, items are the component ids);
56
+ * {@link SENSITIVE_EXTENSION_POINTS} in "provides"; embedded apps (`embed:<id>`) in "UI".
55
57
  */
56
58
  function itemReason(category, item) {
57
59
  // storage 不算敏感:ctx.storage 的键由内核加前缀 `plugins/<插件>/<orgId>/`,插件只能读写自己的前缀
@@ -63,8 +65,6 @@ function itemReason(category, item) {
63
65
  if (category === "ui") {
64
66
  if (item.startsWith("embed:"))
65
67
  return "ui_embed";
66
- if (item.startsWith("component:"))
67
- return "ui_component";
68
68
  return undefined;
69
69
  }
70
70
  return ALWAYS_SENSITIVE.has(category) ? CATEGORY_REASON[category] : undefined;
@@ -89,7 +89,7 @@ function keyItem(key) {
89
89
  ["permissions.hooks:", "hooks", ""],
90
90
  ["permissions.hooks.policy:", "hooks", "policy:"],
91
91
  ["ui.embed:", "ui", "embed:"],
92
- [UI_COMPONENT_KEY_PREFIX, "ui", "component:"],
92
+ [UI_COMPONENT_KEY_PREFIX, "components", ""],
93
93
  ["ui.view:", "ui", "view:"],
94
94
  ];
95
95
  for (const [prefix, category, itemPrefix] of table) {
@@ -162,12 +162,9 @@ export function permissionGroups(set) {
162
162
  { category: "network", items: p.network },
163
163
  {
164
164
  category: "ui",
165
- items: [
166
- ...ui.views.map((v) => `view:${v}`),
167
- ...ui.embeds.map((e) => `embed:${e}`),
168
- ...components.map((c) => `component:${c}`),
169
- ],
165
+ items: [...ui.views.map((v) => `view:${v}`), ...ui.embeds.map((e) => `embed:${e}`)],
170
166
  },
167
+ { category: "components", items: components },
171
168
  { category: "client", items: p.client === true ? [CLIENT_ITEM] : [] },
172
169
  ];
173
170
  return groups
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/kernel",
3
- "version": "0.1.0-rc.15",
3
+ "version": "0.1.0-rc.16",
4
4
  "description": "Capybara kernel: config, database, plugin host and HTTP API of the Capybara agent platform",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -65,7 +65,7 @@
65
65
  "yaml": "^2.9.1",
66
66
  "yauzl": "3.4.0",
67
67
  "zod": "^4.6.7",
68
- "@capybara-hq/plugin-sdk": "^0.1.0-rc.11"
68
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.12"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@langchain/langgraph-checkpoint-validation": "^1.2.0",
@@ -74,7 +74,7 @@
74
74
  "@types/ws": "^8.18.2",
75
75
  "@types/yauzl": "3.4.0",
76
76
  "ws": "^8.22.0",
77
- "@capybara-hq/testing": "0.1.0-rc.14"
77
+ "@capybara-hq/testing": "0.1.0-rc.15"
78
78
  },
79
79
  "scripts": {
80
80
  "build": "tsc -b"