From 1619094ce69ad13d61dc0af123019f909d32b09a Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 21 May 2026 18:19:02 +0700 Subject: [PATCH 1/3] feat(messaging): add manifest type contracts Signed-off-by: San Dang --- src/lib/messaging/index.ts | 4 + src/lib/messaging/manifest/index.ts | 4 + src/lib/messaging/manifest/types.test.ts | 340 +++++++++++++++++++++++ src/lib/messaging/manifest/types.ts | 248 +++++++++++++++++ 4 files changed, 596 insertions(+) create mode 100644 src/lib/messaging/index.ts create mode 100644 src/lib/messaging/manifest/index.ts create mode 100644 src/lib/messaging/manifest/types.test.ts create mode 100644 src/lib/messaging/manifest/types.ts diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts new file mode 100644 index 00000000000..71e9f0e50be --- /dev/null +++ b/src/lib/messaging/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type * from "./manifest"; diff --git a/src/lib/messaging/manifest/index.ts b/src/lib/messaging/manifest/index.ts new file mode 100644 index 00000000000..4324a74c9cb --- /dev/null +++ b/src/lib/messaging/manifest/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type * from "./types"; diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts new file mode 100644 index 00000000000..4927be1ca5f --- /dev/null +++ b/src/lib/messaging/manifest/types.test.ts @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { + ChannelManifest, + SandboxMessagingPlan, +} from "../index"; + +type FunctionLike = (...args: never[]) => unknown; + +type FunctionFieldKey = { + [Key in keyof T]-?: Extract, FunctionLike> extends never + ? never + : Key; +}[keyof T]; + +type AssertNever = T; + +type _ManifestFieldsContainNoFunctions = AssertNever>; +type _PlanFieldsContainNoFunctions = AssertNever>; + +const telegramManifest = { + schemaVersion: 1, + id: "telegram", + displayName: "Telegram", + description: "Telegram bot messaging", + supportedAgents: ["openclaw", "hermes"], + auth: { + mode: "token-paste", + }, + inputs: [ + { + id: "botToken", + kind: "secret", + required: true, + envKey: "TELEGRAM_BOT_TOKEN", + prompt: { + label: "Telegram Bot Token", + help: "Create a Telegram bot and copy the token.", + }, + }, + { + id: "allowedIds", + kind: "config", + required: false, + envKey: "TELEGRAM_ALLOWED_IDS", + statePath: "allowedIds.telegram", + prompt: { + label: "Telegram User ID", + }, + }, + { + id: "requireMention", + kind: "config", + required: false, + envKey: "TELEGRAM_REQUIRE_MENTION", + validValues: ["0", "1"], + defaultValue: "1", + statePath: "telegramConfig.requireMention", + }, + ], + credentials: [ + { + id: "telegramBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + }, + ], + policyPresets: ["telegram"], + render: [ + { + id: "telegram-openclaw", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.telegram.accounts.default", + value: { + botToken: "{{credential.telegramBotToken.placeholder}}", + enabled: true, + allowFrom: "{{allowedIds.telegram.csv}}", + }, + }, + }, + { + id: "telegram-hermes", + kind: "env-lines", + agent: "hermes", + target: "~/.hermes/.env", + lines: [ + "TELEGRAM_BOT_TOKEN={{credential.telegramBotToken.placeholder}}", + "TELEGRAM_ALLOWED_USERS={{allowedIds.telegram.csv}}", + ], + }, + ], + state: { + persist: { + allowedIds: ["allowedIds"], + telegramConfig: ["requireMention"], + }, + rebuildHydration: [ + { + statePath: "allowedIds.telegram", + env: "TELEGRAM_ALLOWED_IDS", + }, + { + statePath: "telegramConfig.requireMention", + env: "TELEGRAM_REQUIRE_MENTION", + }, + ], + }, + hooks: [], +} as const satisfies ChannelManifest; + +const wechatHookManifest = { + schemaVersion: 1, + id: "wechat", + displayName: "WeChat", + supportedAgents: ["openclaw", "hermes"], + auth: { + mode: "host-qr", + }, + inputs: [ + { + id: "botToken", + kind: "secret", + required: true, + envKey: "WECHAT_BOT_TOKEN", + }, + { + id: "accountId", + kind: "config", + required: true, + envKey: "WECHAT_ACCOUNT_ID", + statePath: "wechatConfig.accountId", + }, + ], + credentials: [ + { + id: "wechatBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-wechat-bridge", + providerEnvKey: "WECHAT_BOT_TOKEN", + placeholder: "openshell:resolve:env:WECHAT_BOT_TOKEN", + }, + ], + policyPresets: ["wechat"], + render: [], + state: { + persist: { + wechatConfig: ["accountId"], + }, + rebuildHydration: [ + { + statePath: "wechatConfig.accountId", + env: "WECHAT_ACCOUNT_ID", + }, + ], + }, + hooks: [ + { + id: "wechat-host-qr", + phase: "enroll", + handler: "wechat.ilinkLogin", + outputs: [ + { + id: "botToken", + kind: "secret", + required: true, + }, + { + id: "accountId", + kind: "config", + required: true, + }, + ], + onFailure: "skip-channel", + }, + ], +} as const satisfies ChannelManifest; + +const telegramPlan = { + schemaVersion: 1, + channels: [ + { + channelId: "telegram", + displayName: "Telegram", + active: true, + inputs: [ + { + inputId: "botToken", + kind: "secret", + required: true, + sourceEnv: "TELEGRAM_BOT_TOKEN", + }, + { + inputId: "allowedIds", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_ALLOWED_IDS", + statePath: "allowedIds.telegram", + }, + ], + credentialBindings: [ + { + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "demo-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + }, + ], + policyPresets: ["telegram"], + render: [ + { + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + path: "channels.telegram.accounts.default", + value: { + botToken: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + enabled: true, + }, + }, + { + kind: "env-lines", + agent: "hermes", + target: "~/.hermes/.env", + lines: ["TELEGRAM_BOT_TOKEN=openshell:resolve:env:TELEGRAM_BOT_TOKEN"], + }, + ], + buildInputs: [], + hooks: [], + }, + ], +} as const satisfies SandboxMessagingPlan; + +function jsonRoundTrip(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function findFunctionPaths(value: unknown, prefix = "$"): string[] { + if (typeof value === "function") return [prefix]; + if (Array.isArray(value)) { + return value.flatMap((entry, index) => findFunctionPaths(entry, `${prefix}[${index}]`)); + } + if (value && typeof value === "object") { + return Object.entries(value).flatMap(([key, entry]) => findFunctionPaths(entry, `${prefix}.${key}`)); + } + return []; +} + +function collectProductionFiles(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const absolute = path.join(dir, entry); + const stats = statSync(absolute); + if (stats.isDirectory()) return collectProductionFiles(absolute); + if (absolute.endsWith(".ts") && !absolute.endsWith(".test.ts")) return [absolute]; + return []; + }); +} + +function collectModuleSpecifiers(source: string): string[] { + const pattern = + /(?:import|export)\s+(?:type\s+)?(?:[^"']*?\s+from\s+)?["']([^"']+)["']/g; + return [...source.matchAll(pattern)].map((match) => match[1] ?? ""); +} + +describe("messaging manifest type contracts", () => { + it("serializes representative manifests without losing required fields", () => { + const parsedTelegram = jsonRoundTrip(telegramManifest); + const parsedWechat = jsonRoundTrip(wechatHookManifest); + + expect(parsedTelegram).toEqual(telegramManifest); + expect(parsedTelegram.schemaVersion).toBe(1); + expect(parsedTelegram.auth.mode).toBe("token-paste"); + expect(parsedTelegram.inputs.map((input) => input.id)).toEqual([ + "botToken", + "allowedIds", + "requireMention", + ]); + expect(parsedWechat.hooks[0]?.handler).toBe("wechat.ilinkLogin"); + expect(parsedWechat.hooks[0]?.outputs?.map((output) => output.id)).toEqual([ + "botToken", + "accountId", + ]); + }); + + it("serializes plan objects without embedding raw secret values", () => { + const rawSecret = "123456:raw-telegram-token"; + const parsed = jsonRoundTrip(telegramPlan); + const serialized = JSON.stringify(parsed); + + expect(parsed).toEqual(telegramPlan); + expect(serialized).toContain("openshell:resolve:env:TELEGRAM_BOT_TOKEN"); + expect(serialized).not.toContain(rawSecret); + expect(parsed.channels[0]?.credentialBindings[0]).not.toHaveProperty("value"); + }); + + it("uses hook handler references instead of function-valued fields", () => { + expect(findFunctionPaths(telegramManifest)).toEqual([]); + expect(findFunctionPaths(wechatHookManifest)).toEqual([]); + expect(findFunctionPaths(telegramPlan)).toEqual([]); + expect(wechatHookManifest.hooks[0]?.handler).toBe("wechat.ilinkLogin"); + }); + + it("keeps the new production module isolated from side-effect layers", () => { + const moduleRoot = path.join(import.meta.dirname, ".."); + const files = collectProductionFiles(moduleRoot); + const forbiddenFragments = [ + "gateway", + "registry", + "credentials", + "node:fs", + "node:child_process", + "child_process", + "adapters/openshell", + "src/commands", + "lib/actions", + ]; + + for (const file of files) { + const source = readFileSync(file, "utf8"); + const specifiers = collectModuleSpecifiers(source); + for (const fragment of forbiddenFragments) { + expect( + specifiers.some((specifier) => specifier.includes(fragment)), + `${path.relative(moduleRoot, file)} imports ${fragment}`, + ).toBe(false); + } + } + }); +}); diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts new file mode 100644 index 00000000000..f397e9859f0 --- /dev/null +++ b/src/lib/messaging/manifest/types.ts @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** JSON-compatible primitive used by manifests and plans. */ +export type MessagingSerializableScalar = string | number | boolean | null; + +/** Recursive JSON-compatible value; functions and class instances stay out of contracts. */ +export type MessagingSerializableValue = + | MessagingSerializableScalar + | MessagingSerializableObject + | readonly MessagingSerializableValue[]; + +/** JSON-compatible object map used for render fragments and persisted state values. */ +export type MessagingSerializableObject = { + readonly [key: string]: MessagingSerializableValue; +}; + +/** Stable channel identifier, such as "telegram" or "wechat". */ +export type MessagingChannelId = string; + +/** Agent runtimes that messaging manifests can target today. */ +export type MessagingAgentId = "openclaw" | "hermes"; + +/** Dot-separated path into NemoClaw's persisted sandbox or channel state. */ +export type MessagingStatePath = string; + +/** String value that may contain placeholders resolved by a later compiler/applier. */ +export type MessagingTemplateString = string; + +/** Static, serializable declaration for one messaging channel. */ +export interface ChannelManifest { + readonly schemaVersion: 1; + readonly id: MessagingChannelId; + readonly displayName: string; + readonly description?: string; + readonly supportedAgents: readonly MessagingAgentId[]; + readonly auth: ChannelAuthSpec; + readonly inputs: readonly ChannelInputSpec[]; + readonly credentials: readonly ChannelCredentialSpec[]; + /** Built-in policy presets needed when this channel is active. */ + readonly policyPresets?: readonly string[]; + readonly render: readonly ChannelRenderSpec[]; + readonly state: ChannelStateSpec; + readonly hooks: readonly ChannelHookSpec[]; +} + +/** How a channel obtains credential or session material. */ +export type ChannelAuthMode = "none" | "token-paste" | "host-qr" | "in-sandbox-qr"; + +/** Authentication declaration for a channel, without any secret values. */ +export interface ChannelAuthSpec { + readonly mode: ChannelAuthMode; +} + +/** Operator-facing prompt metadata for collecting a manifest input. */ +export interface ChannelInputPromptSpec { + readonly label: string; + readonly help?: string; + readonly placeholder?: string; +} + +/** Shared fields for secret and non-secret manifest inputs. */ +interface ChannelInputBaseSpec { + readonly id: string; + readonly required: boolean; + readonly envKey?: string; + readonly prompt?: ChannelInputPromptSpec; + readonly validValues?: readonly string[]; +} + +/** Secret input metadata; values must be referenced, not stored in manifests or plans. */ +export interface ChannelSecretInputSpec extends ChannelInputBaseSpec { + readonly kind: "secret"; + readonly defaultValue?: never; + readonly statePath?: never; +} + +/** Non-secret input metadata that may default and/or persist into channel state. */ +export interface ChannelConfigInputSpec extends ChannelInputBaseSpec { + readonly kind: "config"; + readonly statePath?: MessagingStatePath; + readonly defaultValue?: MessagingSerializableValue; +} + +/** Manifest input declaration, split so secrets cannot declare defaults or state paths. */ +export type ChannelInputSpec = ChannelSecretInputSpec | ChannelConfigInputSpec; + +/** Provider binding declaration derived from a secret input. */ +export interface ChannelCredentialSpec { + readonly id: string; + readonly sourceInput: string; + readonly providerName: MessagingTemplateString; + readonly providerEnvKey: string; + readonly placeholder: MessagingTemplateString; +} + +/** Manifest render declaration for supported output formats. */ +export type ChannelRenderSpec = ChannelJsonRenderSpec | ChannelEnvLinesRenderSpec; + +/** Shared render target metadata. */ +interface ChannelRenderBaseSpec { + readonly id?: string; + readonly agent: MessagingAgentId; + readonly target: string; +} + +/** JSON fragment a compiler can merge into an agent config file. */ +export interface ChannelJsonRenderSpec extends ChannelRenderBaseSpec { + readonly kind: "json-fragment"; + readonly fragment: ChannelRenderFragmentSpec; +} + +/** Env-file lines a compiler can append or rewrite for an agent. */ +export interface ChannelEnvLinesRenderSpec extends ChannelRenderBaseSpec { + readonly kind: "env-lines"; + readonly lines: readonly MessagingTemplateString[]; +} + +/** JSON path/value pair for one rendered config fragment. */ +export interface ChannelRenderFragmentSpec { + readonly path: MessagingStatePath; + readonly value: MessagingSerializableValue; +} + +/** State persistence and rebuild-hydration rules owned by the channel. */ +export interface ChannelStateSpec { + readonly persist?: Readonly>; + readonly rebuildHydration?: readonly ChannelRebuildHydrationSpec[]; +} + +/** Mapping from persisted state back to an env var during rebuild planning. */ +export interface ChannelRebuildHydrationSpec { + readonly statePath: MessagingStatePath; + readonly env: string; +} + +/** Lifecycle phase where a referenced hook may run. */ +export type ChannelHookPhase = + | "enroll" + | "apply" + | "post-agent-install" + | "health-check"; + +/** How the planner/applier should treat a hook failure. */ +export type ChannelHookFailureMode = "abort" | "skip-channel"; + +/** Declarative hook reference; handler names are resolved by a separate registry. */ +export interface ChannelHookSpec { + readonly id: string; + readonly phase: ChannelHookPhase; + readonly handler: string; + readonly inputs?: readonly string[]; + readonly outputs?: readonly ChannelHookOutputSpec[]; + readonly onFailure?: ChannelHookFailureMode; +} + +/** Output shape a hook promises, without embedding hook implementation details. */ +export interface ChannelHookOutputSpec { + readonly id: string; + readonly kind: "secret" | "config" | "build-arg" | "build-file"; + readonly required?: boolean; +} + +/** Serializable compiled plan for all selected messaging channels. */ +export interface SandboxMessagingPlan { + readonly schemaVersion: 1; + readonly channels: readonly SandboxMessagingChannelPlan[]; +} + +/** Compiled plan for one selected channel. */ +export interface SandboxMessagingChannelPlan { + readonly channelId: MessagingChannelId; + readonly displayName: string; + readonly active: boolean; + readonly inputs: readonly SandboxMessagingInputReference[]; + readonly credentialBindings: readonly SandboxMessagingCredentialBindingPlan[]; + readonly policyPresets: readonly string[]; + readonly render: readonly SandboxMessagingRenderFragmentPlan[]; + readonly buildInputs: readonly SandboxMessagingBuildInputPlan[]; + readonly hooks: readonly SandboxMessagingHookReferencePlan[]; +} + +/** Resolved input metadata carried into the plan without raw secret values. */ +export interface SandboxMessagingInputReference { + readonly inputId: string; + readonly kind: "secret" | "config"; + readonly required: boolean; + readonly sourceEnv?: string; + readonly statePath?: MessagingStatePath; +} + +/** Plan entry describing an OpenShell provider/env binding to create or attach. */ +export interface SandboxMessagingCredentialBindingPlan { + readonly credentialId: string; + readonly sourceInput: string; + readonly providerName: MessagingTemplateString; + readonly providerEnvKey: string; + readonly placeholder: MessagingTemplateString; +} + +/** Compiled render output for supported target formats. */ +export type SandboxMessagingRenderFragmentPlan = + | SandboxMessagingJsonRenderFragmentPlan + | SandboxMessagingEnvLinesRenderFragmentPlan; + +/** Shared metadata for compiled render outputs. */ +interface SandboxMessagingRenderFragmentBasePlan { + readonly agent: MessagingAgentId; + readonly target: string; +} + +/** Compiled JSON fragment ready for an applier/render engine. */ +export interface SandboxMessagingJsonRenderFragmentPlan + extends SandboxMessagingRenderFragmentBasePlan { + readonly kind: "json-fragment"; + readonly path: MessagingStatePath; + readonly value: MessagingSerializableValue; +} + +/** Compiled env-file lines ready for an applier/render engine. */ +export interface SandboxMessagingEnvLinesRenderFragmentPlan + extends SandboxMessagingRenderFragmentBasePlan { + readonly kind: "env-lines"; + readonly lines: readonly MessagingTemplateString[]; +} + +/** Build-time input the applier may pass into sandbox create/rebuild. */ +export type SandboxMessagingBuildInputPlan = + | SandboxMessagingBuildArgPlan + | SandboxMessagingBuildFilePlan; + +/** Docker/build argument planned for sandbox create or rebuild. */ +export interface SandboxMessagingBuildArgPlan { + readonly kind: "build-arg"; + readonly name: string; + readonly valueTemplate: MessagingTemplateString; +} + +/** File planned for the sandbox build context, optionally sourced from a hook. */ +export interface SandboxMessagingBuildFilePlan { + readonly kind: "build-file"; + readonly path: string; + readonly contentTemplate?: MessagingTemplateString; + readonly sourceHookOutput?: string; +} + +/** Hook reference carried into a compiled plan. */ +export type SandboxMessagingHookReferencePlan = ChannelHookSpec; From 17381d2f7f628824cc86a831d6d43cd70456221c Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 21 May 2026 21:06:43 +0700 Subject: [PATCH 2/3] feat(messaging): add manifest registry --- src/lib/messaging/index.ts | 2 +- src/lib/messaging/manifest/index.ts | 1 + src/lib/messaging/manifest/registry.test.ts | 70 +++++++++++++++++++++ src/lib/messaging/manifest/registry.ts | 64 +++++++++++++++++++ src/lib/messaging/manifest/types.test.ts | 2 +- 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 src/lib/messaging/manifest/registry.test.ts create mode 100644 src/lib/messaging/manifest/registry.ts diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts index 71e9f0e50be..188c2a15fcd 100644 --- a/src/lib/messaging/index.ts +++ b/src/lib/messaging/index.ts @@ -1,4 +1,4 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export type * from "./manifest"; +export * from "./manifest"; diff --git a/src/lib/messaging/manifest/index.ts b/src/lib/messaging/manifest/index.ts index 4324a74c9cb..dd0e10f65c4 100644 --- a/src/lib/messaging/manifest/index.ts +++ b/src/lib/messaging/manifest/index.ts @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +export * from "./registry"; export type * from "./types"; diff --git a/src/lib/messaging/manifest/registry.test.ts b/src/lib/messaging/manifest/registry.test.ts new file mode 100644 index 00000000000..1674622c062 --- /dev/null +++ b/src/lib/messaging/manifest/registry.test.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { ChannelManifestRegistry, createChannelManifestRegistry } from "./index"; +import type { ChannelManifest } from "./index"; + +function makeManifest( + id: string, + displayName: string, + supportedAgents: ChannelManifest["supportedAgents"], +): ChannelManifest { + return { + schemaVersion: 1, + id, + displayName, + supportedAgents, + auth: { + mode: "token-paste", + }, + inputs: [], + credentials: [], + policyPresets: [id], + render: [], + state: {}, + hooks: [], + }; +} + +const TELEGRAM_MANIFEST = makeManifest("telegram", "Telegram", ["openclaw", "hermes"]); +const WECHAT_MANIFEST = makeManifest("wechat", "WeChat", ["openclaw"]); + +describe("ChannelManifestRegistry", () => { + it("registers, retrieves, and lists manifests in memory", () => { + const registry = createChannelManifestRegistry(); + + registry.register(TELEGRAM_MANIFEST); + + expect(registry.get("telegram")).toBe(TELEGRAM_MANIFEST); + expect(registry.get(" TELEGRAM ")).toBe(TELEGRAM_MANIFEST); + expect(registry.list()).toEqual([TELEGRAM_MANIFEST]); + }); + + it("rejects duplicate channel ids", () => { + expect( + () => new ChannelManifestRegistry([TELEGRAM_MANIFEST, TELEGRAM_MANIFEST]), + ).toThrow("Duplicate channel manifest id 'telegram'"); + }); + + it("filters available manifests by agent and non-empty platform support lists", () => { + const registry = new ChannelManifestRegistry([TELEGRAM_MANIFEST, WECHAT_MANIFEST]); + + expect(registry.listAvailable().map((manifest) => manifest.id)).toEqual([ + "telegram", + "wechat", + ]); + expect(registry.listAvailable({ agent: "hermes" }).map((manifest) => manifest.id)).toEqual([ + "telegram", + ]); + expect( + registry.listAvailable({ agent: "openclaw", supportedChannelIds: ["wechat"] }).map( + (manifest) => manifest.id, + ), + ).toEqual(["wechat"]); + expect( + registry.listAvailable({ supportedChannelIds: [] }).map((manifest) => manifest.id), + ).toEqual(["telegram", "wechat"]); + }); +}); diff --git a/src/lib/messaging/manifest/registry.ts b/src/lib/messaging/manifest/registry.ts new file mode 100644 index 00000000000..94577da50f2 --- /dev/null +++ b/src/lib/messaging/manifest/registry.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest, MessagingAgentId, MessagingChannelId } from "./types"; + +export interface ChannelManifestAvailabilityContext { + readonly agent?: MessagingAgentId | null; + readonly supportedChannelIds?: readonly MessagingChannelId[] | null; +} + +export class ChannelManifestRegistry { + private readonly manifests = new Map(); + + constructor(manifests: readonly ChannelManifest[] = []) { + for (const manifest of manifests) { + this.register(manifest); + } + } + + register(manifest: ChannelManifest): this { + const id = normalizeChannelId(manifest.id); + if (this.manifests.has(id)) { + throw new Error(`Duplicate channel manifest id '${id}'`); + } + + this.manifests.set(id, manifest); + return this; + } + + get(channelId: MessagingChannelId): ChannelManifest | undefined { + return this.manifests.get(normalizeChannelId(channelId)); + } + + list(): ChannelManifest[] { + return Array.from(this.manifests.values()); + } + + listAvailable(ctx: ChannelManifestAvailabilityContext = {}): ChannelManifest[] { + const supportedChannelIds = + ctx.supportedChannelIds && ctx.supportedChannelIds.length > 0 + ? new Set(ctx.supportedChannelIds.map(normalizeChannelId)) + : null; + + return this.list().filter((manifest) => { + if (ctx.agent && !manifest.supportedAgents.includes(ctx.agent)) { + return false; + } + if (supportedChannelIds && !supportedChannelIds.has(manifest.id)) { + return false; + } + return true; + }); + } +} + +export function createChannelManifestRegistry( + manifests: readonly ChannelManifest[] = [], +): ChannelManifestRegistry { + return new ChannelManifestRegistry(manifests); +} + +function normalizeChannelId(channelId: MessagingChannelId): MessagingChannelId { + return channelId.trim().toLowerCase(); +} diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index 4927be1ca5f..138beddbf86 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -316,7 +316,7 @@ describe("messaging manifest type contracts", () => { const files = collectProductionFiles(moduleRoot); const forbiddenFragments = [ "gateway", - "registry", + "state/registry", "credentials", "node:fs", "node:child_process", From 07975b22a5b85456ed22003a10025d78d9911257 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 21 May 2026 21:21:06 +0700 Subject: [PATCH 3/3] refactor(messaging): keep registry channel ids exact --- src/lib/messaging/manifest/registry.test.ts | 2 +- src/lib/messaging/manifest/registry.ts | 15 +++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/lib/messaging/manifest/registry.test.ts b/src/lib/messaging/manifest/registry.test.ts index 1674622c062..cba1cef477f 100644 --- a/src/lib/messaging/manifest/registry.test.ts +++ b/src/lib/messaging/manifest/registry.test.ts @@ -38,7 +38,7 @@ describe("ChannelManifestRegistry", () => { registry.register(TELEGRAM_MANIFEST); expect(registry.get("telegram")).toBe(TELEGRAM_MANIFEST); - expect(registry.get(" TELEGRAM ")).toBe(TELEGRAM_MANIFEST); + expect(registry.get("TELEGRAM")).toBeUndefined(); expect(registry.list()).toEqual([TELEGRAM_MANIFEST]); }); diff --git a/src/lib/messaging/manifest/registry.ts b/src/lib/messaging/manifest/registry.ts index 94577da50f2..235754ac6a9 100644 --- a/src/lib/messaging/manifest/registry.ts +++ b/src/lib/messaging/manifest/registry.ts @@ -18,17 +18,16 @@ export class ChannelManifestRegistry { } register(manifest: ChannelManifest): this { - const id = normalizeChannelId(manifest.id); - if (this.manifests.has(id)) { - throw new Error(`Duplicate channel manifest id '${id}'`); + if (this.manifests.has(manifest.id)) { + throw new Error(`Duplicate channel manifest id '${manifest.id}'`); } - this.manifests.set(id, manifest); + this.manifests.set(manifest.id, manifest); return this; } get(channelId: MessagingChannelId): ChannelManifest | undefined { - return this.manifests.get(normalizeChannelId(channelId)); + return this.manifests.get(channelId); } list(): ChannelManifest[] { @@ -38,7 +37,7 @@ export class ChannelManifestRegistry { listAvailable(ctx: ChannelManifestAvailabilityContext = {}): ChannelManifest[] { const supportedChannelIds = ctx.supportedChannelIds && ctx.supportedChannelIds.length > 0 - ? new Set(ctx.supportedChannelIds.map(normalizeChannelId)) + ? new Set(ctx.supportedChannelIds) : null; return this.list().filter((manifest) => { @@ -58,7 +57,3 @@ export function createChannelManifestRegistry( ): ChannelManifestRegistry { return new ChannelManifestRegistry(manifests); } - -function normalizeChannelId(channelId: MessagingChannelId): MessagingChannelId { - return channelId.trim().toLowerCase(); -}