From f38810447f366ce49e69c2357d51310729e0de1b Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 22 May 2026 18:23:42 +0700 Subject: [PATCH 1/2] feat(messaging): add manifest compiler Signed-off-by: San Dang --- .../compiler/engines/agent-render-engine.ts | 41 ++ .../compiler/engines/build-step-engine.ts | 29 ++ .../engines/credential-binding-engine.ts | 33 ++ .../compiler/engines/health-check-engine.ts | 17 + .../compiler/engines/policy-resolver.ts | 67 +++ .../compiler/engines/state-update-engine.ts | 24 + .../messaging/compiler/engines/template.ts | 54 ++ src/lib/messaging/compiler/index.ts | 5 + .../compiler/manifest-compiler.test.ts | 480 ++++++++++++++++++ .../messaging/compiler/manifest-compiler.ts | 267 ++++++++++ src/lib/messaging/compiler/types.ts | 24 + src/lib/messaging/index.ts | 1 + src/lib/messaging/manifest/types.test.ts | 88 ++-- src/lib/messaging/manifest/types.ts | 127 ++++- 14 files changed, 1199 insertions(+), 58 deletions(-) create mode 100644 src/lib/messaging/compiler/engines/agent-render-engine.ts create mode 100644 src/lib/messaging/compiler/engines/build-step-engine.ts create mode 100644 src/lib/messaging/compiler/engines/credential-binding-engine.ts create mode 100644 src/lib/messaging/compiler/engines/health-check-engine.ts create mode 100644 src/lib/messaging/compiler/engines/policy-resolver.ts create mode 100644 src/lib/messaging/compiler/engines/state-update-engine.ts create mode 100644 src/lib/messaging/compiler/engines/template.ts create mode 100644 src/lib/messaging/compiler/index.ts create mode 100644 src/lib/messaging/compiler/manifest-compiler.test.ts create mode 100644 src/lib/messaging/compiler/manifest-compiler.ts create mode 100644 src/lib/messaging/compiler/types.ts diff --git a/src/lib/messaging/compiler/engines/agent-render-engine.ts b/src/lib/messaging/compiler/engines/agent-render-engine.ts new file mode 100644 index 0000000000..50ca883ede --- /dev/null +++ b/src/lib/messaging/compiler/engines/agent-render-engine.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelManifest, + SandboxMessagingAgentRenderPlan, + SandboxMessagingEnvLinesRenderPlan, + SandboxMessagingJsonRenderPlan, +} from "../../manifest"; +import type { ManifestCompilerContext } from "../types"; +import { resolveCredentialTemplatesInLines, resolveCredentialTemplatesInValue } from "./template"; + +export function planAgentRender( + manifest: ChannelManifest, + context: ManifestCompilerContext, +): SandboxMessagingAgentRenderPlan[] { + return manifest.render + .filter((render) => render.agent === context.agent) + .map((render) => { + if (render.kind === "json-fragment") { + return { + channelId: manifest.id, + renderId: render.id, + kind: "json-fragment", + agent: render.agent, + target: render.target, + path: render.fragment.path, + value: resolveCredentialTemplatesInValue(render.fragment.value, manifest.credentials), + } satisfies SandboxMessagingJsonRenderPlan; + } + + return { + channelId: manifest.id, + renderId: render.id, + kind: "env-lines", + agent: render.agent, + target: render.target, + lines: resolveCredentialTemplatesInLines(render.lines, manifest.credentials), + } satisfies SandboxMessagingEnvLinesRenderPlan; + }); +} diff --git a/src/lib/messaging/compiler/engines/build-step-engine.ts b/src/lib/messaging/compiler/engines/build-step-engine.ts new file mode 100644 index 0000000000..2a111751d5 --- /dev/null +++ b/src/lib/messaging/compiler/engines/build-step-engine.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelHookOutputSpec, + ChannelManifest, + SandboxMessagingBuildStepPlan, +} from "../../manifest"; + +export function planBuildSteps(manifest: ChannelManifest): SandboxMessagingBuildStepPlan[] { + return manifest.hooks.flatMap((hook) => + (hook.outputs ?? []) + .filter(isBuildStepOutput) + .map((output) => ({ + channelId: manifest.id, + kind: output.kind, + hookId: hook.id, + handler: hook.handler, + outputId: output.id, + required: output.required === true, + })), + ); +} + +function isBuildStepOutput( + output: ChannelHookOutputSpec, +): output is ChannelHookOutputSpec & { readonly kind: "build-arg" | "build-file" } { + return output.kind === "build-arg" || output.kind === "build-file"; +} diff --git a/src/lib/messaging/compiler/engines/credential-binding-engine.ts b/src/lib/messaging/compiler/engines/credential-binding-engine.ts new file mode 100644 index 0000000000..523f94ea2a --- /dev/null +++ b/src/lib/messaging/compiler/engines/credential-binding-engine.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelManifest, + SandboxMessagingCredentialBindingPlan, + SandboxMessagingInputReference, +} from "../../manifest"; +import type { ManifestCompilerContext } from "../types"; +import { resolveSandboxNameTemplate } from "./template"; + +export function planCredentialBindings( + manifest: ChannelManifest, + context: ManifestCompilerContext, + inputs: readonly SandboxMessagingInputReference[], +): SandboxMessagingCredentialBindingPlan[] { + return manifest.credentials.map((credential) => { + const sourceInput = inputs.find((input) => input.inputId === credential.sourceInput); + + return { + channelId: manifest.id, + credentialId: credential.id, + sourceInput: credential.sourceInput, + providerName: resolveSandboxNameTemplate(credential.providerName, context.sandboxName), + providerEnvKey: credential.providerEnvKey, + placeholder: credential.placeholder, + credentialAvailable: + sourceInput?.credentialAvailable === true || + context.credentialAvailability?.[credential.id] === true || + context.credentialAvailability?.[`${manifest.id}.${credential.id}`] === true, + }; + }); +} diff --git a/src/lib/messaging/compiler/engines/health-check-engine.ts b/src/lib/messaging/compiler/engines/health-check-engine.ts new file mode 100644 index 0000000000..d19952efbf --- /dev/null +++ b/src/lib/messaging/compiler/engines/health-check-engine.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest, SandboxMessagingHealthCheckPlan } from "../../manifest"; + +export function planHealthChecks(manifest: ChannelManifest): SandboxMessagingHealthCheckPlan[] { + return [ + { + channelId: manifest.id, + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: manifest.hooks + .filter((hook) => hook.phase === "health-check") + .map((hook) => hook.id), + }, + ]; +} diff --git a/src/lib/messaging/compiler/engines/policy-resolver.ts b/src/lib/messaging/compiler/engines/policy-resolver.ts new file mode 100644 index 0000000000..4f178fb319 --- /dev/null +++ b/src/lib/messaging/compiler/engines/policy-resolver.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelManifest, + MessagingAgentId, + SandboxMessagingNetworkPolicyEntryPlan, + SandboxMessagingNetworkPolicyPlan, +} from "../../manifest"; +import type { ManifestCompilerContext } from "../types"; + +const BUILTIN_POLICY_KEYS: Readonly> = { + telegram: ["telegram_bot"], + discord: ["discord"], + slack: ["slack"], + wechat: ["wechat_bridge"], + whatsapp: ["whatsapp"], +}; + +const AGENT_POLICY_KEY_ALIASES: Readonly< + Record>> +> = { + openclaw: {}, + hermes: { + wechat: ["wechat_bridge"], + }, +}; + +export function planNetworkPolicy( + manifests: readonly ChannelManifest[], + context: ManifestCompilerContext, +): SandboxMessagingNetworkPolicyPlan { + const entries = manifests.flatMap((manifest) => planManifestPolicyEntries(manifest, context)); + return { + presets: unique(entries.map((entry) => entry.presetName)), + entries, + }; +} + +function planManifestPolicyEntries( + manifest: ChannelManifest, + context: ManifestCompilerContext, +): SandboxMessagingNetworkPolicyEntryPlan[] { + return (manifest.policyPresets ?? []).map((presetName) => { + const agentAlias = AGENT_POLICY_KEY_ALIASES[context.agent][presetName]; + if (agentAlias) { + return { + channelId: manifest.id, + presetName, + policyKeys: agentAlias, + source: "agent-alias", + }; + } + + const builtinKeys = BUILTIN_POLICY_KEYS[presetName]; + return { + channelId: manifest.id, + presetName, + policyKeys: builtinKeys ?? [presetName], + source: builtinKeys ? "builtin" : "manifest", + }; + }); +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/src/lib/messaging/compiler/engines/state-update-engine.ts b/src/lib/messaging/compiler/engines/state-update-engine.ts new file mode 100644 index 0000000000..7d9adeb90c --- /dev/null +++ b/src/lib/messaging/compiler/engines/state-update-engine.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest, SandboxMessagingStateUpdatePlan } from "../../manifest"; + +export function planStateUpdates(manifest: ChannelManifest): SandboxMessagingStateUpdatePlan[] { + const persistUpdates = Object.entries(manifest.state.persist ?? {}).map( + ([stateKey, inputIds]) => ({ + channelId: manifest.id, + kind: "persist-inputs" as const, + stateKey, + inputIds, + }), + ); + + const hydrationUpdates = (manifest.state.rebuildHydration ?? []).map((hydration) => ({ + channelId: manifest.id, + kind: "rebuild-hydration" as const, + statePath: hydration.statePath, + env: hydration.env, + })); + + return [...persistUpdates, ...hydrationUpdates]; +} diff --git a/src/lib/messaging/compiler/engines/template.ts b/src/lib/messaging/compiler/engines/template.ts new file mode 100644 index 0000000000..daf77d7d1f --- /dev/null +++ b/src/lib/messaging/compiler/engines/template.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelCredentialSpec, + MessagingSerializableValue, + MessagingTemplateString, +} from "../../manifest"; + +const CREDENTIAL_PLACEHOLDER_PATTERN = + /\{\{\s*credential\.([A-Za-z0-9_-]+)\.placeholder\s*\}\}/g; + +export function resolveSandboxNameTemplate( + value: MessagingTemplateString, + sandboxName: string, +): MessagingTemplateString { + return value.replaceAll("{sandboxName}", sandboxName); +} + +export function resolveCredentialTemplatesInValue( + value: MessagingSerializableValue, + credentials: readonly ChannelCredentialSpec[], +): MessagingSerializableValue { + if (typeof value === "string") return resolveCredentialTemplatesInString(value, credentials); + if (Array.isArray(value)) { + return value.map((entry) => resolveCredentialTemplatesInValue(entry, credentials)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + resolveCredentialTemplatesInValue(entry, credentials), + ]), + ); + } + return value; +} + +export function resolveCredentialTemplatesInLines( + lines: readonly MessagingTemplateString[], + credentials: readonly ChannelCredentialSpec[], +): MessagingTemplateString[] { + return lines.map((line) => resolveCredentialTemplatesInString(line, credentials)); +} + +function resolveCredentialTemplatesInString( + value: MessagingTemplateString, + credentials: readonly ChannelCredentialSpec[], +): MessagingTemplateString { + return value.replace(CREDENTIAL_PLACEHOLDER_PATTERN, (match, credentialId: string) => { + const credential = credentials.find((entry) => entry.id === credentialId); + return credential?.placeholder ?? match; + }); +} diff --git a/src/lib/messaging/compiler/index.ts b/src/lib/messaging/compiler/index.ts new file mode 100644 index 0000000000..6ca6b7529d --- /dev/null +++ b/src/lib/messaging/compiler/index.ts @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from "./manifest-compiler"; +export type * from "./types"; diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts new file mode 100644 index 0000000000..0728af68eb --- /dev/null +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -0,0 +1,480 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { createBuiltInChannelManifestRegistry } from "../channels"; +import { FAKE_WECHAT_HOOK_REGISTRATIONS } from "../channels/wechat/hooks/fakes"; +import { MessagingHookRegistry } from "../hooks"; +import { FAKE_COMMON_HOOK_REGISTRATIONS } from "../hooks/common"; +import { + ChannelManifestRegistry, + type ChannelManifest, + type SandboxMessagingPlan, +} from "../manifest"; +import { ManifestCompiler } from "./manifest-compiler"; + +const ALL_CHANNELS = ["telegram", "discord", "wechat", "slack", "whatsapp"] as const; + +function compiler(): ManifestCompiler { + return new ManifestCompiler( + createBuiltInChannelManifestRegistry(), + new MessagingHookRegistry([ + ...FAKE_COMMON_HOOK_REGISTRATIONS, + ...FAKE_WECHAT_HOOK_REGISTRATIONS, + ]), + ); +} + +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 []; +} + +async function withEnv( + values: Readonly>, + run: () => Promise, +): Promise { + const previous = Object.fromEntries( + Object.keys(values).map((key) => [key, process.env[key]]), + ); + try { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return await run(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +describe("ManifestCompiler", () => { + it("compiles built-in manifests into a deterministic OpenClaw plan", async () => { + const plan = await compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "create", + isInteractive: true, + selectedChannels: ["slack", "telegram", "wechat", "discord", "whatsapp"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + DISCORD_BOT_TOKEN: true, + WECHAT_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }); + + expect(plan.channels.map((channel) => channel.channelId)).toEqual(ALL_CHANNELS); + expect(plan.channels.every((channel) => channel.active)).toBe(true); + expect(plan.credentialBindings.map((binding) => binding.providerName)).toEqual([ + "demo-telegram-bridge", + "demo-discord-bridge", + "demo-wechat-bridge", + "demo-slack-bridge", + "demo-slack-app", + ]); + expect(plan.credentialBindings.map((binding) => binding.placeholder)).toEqual([ + "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + "openshell:resolve:env:DISCORD_BOT_TOKEN", + "openshell:resolve:env:WECHAT_BOT_TOKEN", + "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + ]); + expect(plan.networkPolicy.entries).toEqual([ + { + channelId: "telegram", + presetName: "telegram", + policyKeys: ["telegram_bot"], + source: "builtin", + }, + { + channelId: "discord", + presetName: "discord", + policyKeys: ["discord"], + source: "builtin", + }, + { + channelId: "wechat", + presetName: "wechat", + policyKeys: ["wechat_bridge"], + source: "builtin", + }, + { + channelId: "slack", + presetName: "slack", + policyKeys: ["slack"], + source: "builtin", + }, + { + channelId: "whatsapp", + presetName: "whatsapp", + policyKeys: ["whatsapp"], + source: "builtin", + }, + ]); + expect(plan.agentRender.map((render) => `${render.channelId}:${render.kind}`)).toEqual([ + "telegram:json-fragment", + "telegram:json-fragment", + "discord:json-fragment", + "discord:json-fragment", + "slack:json-fragment", + "whatsapp:json-fragment", + ]); + expect(JSON.stringify(plan.agentRender)).toContain( + "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + ); + expect(plan.buildSteps).toEqual([ + { + channelId: "wechat", + kind: "build-file", + hookId: "wechat-seed-openclaw-account", + handler: "wechat.seedOpenClawAccount", + outputId: "openclawWeixinAccountsIndex", + required: true, + }, + { + channelId: "wechat", + kind: "build-file", + hookId: "wechat-seed-openclaw-account", + handler: "wechat.seedOpenClawAccount", + outputId: "openclawWeixinAccountFile", + required: true, + }, + { + channelId: "wechat", + kind: "build-file", + hookId: "wechat-seed-openclaw-account", + handler: "wechat.seedOpenClawAccount", + outputId: "openclawConfigPatch", + required: true, + }, + ]); + expect(plan.stateUpdates).toContainEqual({ + channelId: "wechat", + kind: "rebuild-hydration", + statePath: "wechatConfig.accountId", + env: "WECHAT_ACCOUNT_ID", + }); + expect(plan.healthChecks).toHaveLength(ALL_CHANNELS.length); + expect(plan.healthChecks.every((check) => check.requiredBefore === "lifecycle-success")).toBe( + true, + ); + }); + + it("compiles Hermes render and WeChat agent policy alias intent", async () => { + const plan = await compiler().compile({ + sandboxName: "demo", + agent: "hermes", + workflow: "rebuild", + isInteractive: false, + selectedChannels: ALL_CHANNELS, + }); + + expect(plan.networkPolicy.entries.find((entry) => entry.channelId === "wechat")).toEqual({ + channelId: "wechat", + presetName: "wechat", + policyKeys: ["wechat_bridge"], + source: "agent-alias", + }); + expect(plan.agentRender.map((render) => `${render.channelId}:${render.target}`)).toEqual([ + "telegram:~/.hermes/.env", + "telegram:~/.hermes/config.yaml", + "discord:~/.hermes/.env", + "discord:~/.hermes/config.yaml", + "wechat:~/.hermes/.env", + "slack:~/.hermes/.env", + "whatsapp:~/.hermes/.env", + ]); + expect(JSON.stringify(plan.agentRender)).toContain( + "WEIXIN_TOKEN=openshell:resolve:env:WECHAT_BOT_TOKEN", + ); + expect( + plan.channels + .find((channel) => channel.channelId === "wechat") + ?.inputs.find((input) => input.inputId === "accountId"), + ).not.toHaveProperty("value"); + }); + + it("runs enrollment hooks before returning the final channel input plan", async () => { + const plan = await compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "create", + isInteractive: true, + selectedChannels: ["wechat", "telegram"], + }); + + const telegram = plan.channels.find((channel) => channel.channelId === "telegram"); + const wechat = plan.channels.find((channel) => channel.channelId === "wechat"); + + expect(telegram?.inputs.find((input) => input.inputId === "botToken")).toMatchObject({ + kind: "secret", + credentialAvailable: true, + }); + expect(wechat?.inputs.find((input) => input.inputId === "botToken")).toMatchObject({ + kind: "secret", + credentialAvailable: true, + }); + expect(wechat?.inputs.find((input) => input.inputId === "accountId")).toMatchObject({ + kind: "config", + value: "fake-wechat-account", + }); + expect(wechat?.inputs.find((input) => input.inputId === "baseUrl")).toMatchObject({ + kind: "config", + value: "https://ilinkai.wechat.example", + }); + }); + + it("skips token-paste and QR enrollment hooks for non-interactive create plans", async () => { + const hooks = new MessagingHookRegistry([ + { + id: "common.tokenPaste", + handler: () => { + throw new Error("token-paste hook should not run"); + }, + }, + ]); + const plan = await new ManifestCompiler( + createBuiltInChannelManifestRegistry(), + hooks, + ).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "create", + isInteractive: false, + selectedChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + }, + }); + + expect(plan.channels[0]?.inputs.find((input) => input.inputId === "botToken")).toMatchObject({ + kind: "secret", + credentialAvailable: true, + }); + }); + + it("reads input values from env keys before returning non-interactive plans", async () => { + await withEnv( + { + TELEGRAM_BOT_TOKEN: "123456:raw-telegram-token", + TELEGRAM_ALLOWED_IDS: "123456789", + }, + async () => { + const plan = await compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "create", + isInteractive: false, + selectedChannels: ["telegram"], + }); + + expect(plan.channels[0]?.inputs.find((input) => input.inputId === "botToken")).toMatchObject({ + kind: "secret", + credentialAvailable: true, + }); + expect(plan.channels[0]?.inputs.find((input) => input.inputId === "allowedIds")).toMatchObject({ + kind: "config", + value: "123456789", + }); + expect(JSON.stringify(plan)).not.toContain("123456:raw-telegram-token"); + }, + ); + }); + + it("keeps compiled plans serializable, deterministic, and secret-free", async () => { + const context = { + sandboxName: "demo", + agent: "openclaw", + workflow: "create", + isInteractive: false, + selectedChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + }, + } as const; + const first = await compiler().compile(context); + const second = await compiler().compile(context); + const serialized = JSON.stringify(first); + + expect(second).toEqual(first); + expect(jsonRoundTrip(first)).toEqual(first); + expect(findFunctionPaths(first)).toEqual([]); + expect(serialized).toContain("openshell:resolve:env:TELEGRAM_BOT_TOKEN"); + expect(serialized).not.toContain("123456:raw-telegram-token"); + expect(Object.keys(first)).toEqual([ + "schemaVersion", + "sandboxName", + "agent", + "workflow", + "channels", + "credentialBindings", + "networkPolicy", + "agentRender", + "buildSteps", + "stateUpdates", + "healthChecks", + ] satisfies Array); + }); + + it("records disabled configured channels without planning side effects for them", async () => { + const plan = await compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "stop", + isInteractive: false, + selectedChannels: [], + configuredChannels: ["telegram"], + disabledChannels: ["telegram"], + }); + + expect(plan.channels).toHaveLength(1); + expect(plan.channels[0]).toMatchObject({ + channelId: "telegram", + active: false, + configured: true, + disabled: true, + }); + expect(plan.credentialBindings).toEqual([]); + expect(plan.networkPolicy.entries).toEqual([]); + expect(plan.agentRender).toEqual([]); + expect(plan.buildSteps).toEqual([]); + expect(plan.stateUpdates).toEqual([]); + expect(plan.healthChecks).toEqual([]); + }); + + it("compiles a non-built-in channel manifest through the same generic path", async () => { + const customManifest = { + schemaVersion: 1, + id: "matrix", + displayName: "Matrix", + supportedAgents: ["openclaw"], + auth: { + mode: "token-paste", + }, + inputs: [ + { + id: "accessToken", + kind: "secret", + required: true, + envKey: "MATRIX_ACCESS_TOKEN", + }, + { + id: "roomId", + kind: "config", + required: true, + envKey: "MATRIX_ROOM_ID", + }, + ], + credentials: [ + { + id: "matrixAccessToken", + sourceInput: "accessToken", + providerName: "{sandboxName}-matrix-bridge", + providerEnvKey: "MATRIX_ACCESS_TOKEN", + placeholder: "openshell:resolve:env:MATRIX_ACCESS_TOKEN", + }, + ], + policyPresets: ["matrix"], + render: [], + state: {}, + hooks: [ + { + id: "matrix-enroll", + phase: "enroll", + handler: "matrix.enroll", + outputs: [ + { + id: "accessToken", + kind: "secret", + required: true, + }, + { + id: "roomId", + kind: "config", + required: true, + }, + ], + }, + ], + } as const satisfies ChannelManifest; + const hooks = new MessagingHookRegistry([ + { + id: "matrix.enroll", + handler: () => ({ + outputs: { + accessToken: { + kind: "secret", + value: "raw-matrix-token", + }, + roomId: { + kind: "config", + value: "!room:example.com", + }, + }, + }), + }, + ]); + const plan = await new ManifestCompiler( + new ChannelManifestRegistry([customManifest]), + hooks, + ).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "create", + isInteractive: true, + selectedChannels: ["matrix"], + }); + + expect(plan.channels.map((channel) => channel.channelId)).toEqual(["matrix"]); + expect(plan.channels[0]?.inputs).toContainEqual( + expect.objectContaining({ + inputId: "accessToken", + credentialAvailable: true, + }), + ); + expect(plan.channels[0]?.inputs).toContainEqual( + expect.objectContaining({ + inputId: "roomId", + value: "!room:example.com", + }), + ); + expect(plan.credentialBindings[0]).toMatchObject({ + channelId: "matrix", + providerName: "demo-matrix-bridge", + credentialAvailable: true, + }); + expect(plan.networkPolicy.entries).toEqual([ + { + channelId: "matrix", + presetName: "matrix", + policyKeys: ["matrix"], + source: "manifest", + }, + ]); + expect(JSON.stringify(plan)).not.toContain("raw-matrix-token"); + }); +}); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts new file mode 100644 index 0000000000..9fa09a97ac --- /dev/null +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -0,0 +1,267 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelInputSpec, + ChannelManifest, + ChannelManifestRegistry, + MessagingChannelId, + MessagingStatePath, + MessagingSerializableValue, + SandboxMessagingChannelPlan, + SandboxMessagingHookReferencePlan, + SandboxMessagingInputReference, + SandboxMessagingPlan, +} from "../manifest"; +import { MessagingHookRegistry, runMessagingHook } from "../hooks"; +import type { MessagingHookInputMap, MessagingHookOutputMap } from "../hooks"; +import { planAgentRender } from "./engines/agent-render-engine"; +import { planBuildSteps } from "./engines/build-step-engine"; +import { planCredentialBindings } from "./engines/credential-binding-engine"; +import { planHealthChecks } from "./engines/health-check-engine"; +import { planNetworkPolicy } from "./engines/policy-resolver"; +import { planStateUpdates } from "./engines/state-update-engine"; +import type { ManifestCompilerContext } from "./types"; + +export class ManifestCompiler { + constructor( + private readonly registry: ChannelManifestRegistry, + private readonly hooks = new MessagingHookRegistry(), + ) {} + + async compile(context: ManifestCompilerContext): Promise { + const manifests = this.resolveRequestedManifests(context); + const channels = []; + for (const manifest of manifests) { + channels.push(await this.compileChannel(manifest, context)); + } + const inputRegistry = new Map( + channels.map((channel) => [channel.channelId, channel.inputs] as const), + ); + const activeManifests = manifests.filter((manifest) => + isChannelActive(manifest.id, context), + ); + + return { + schemaVersion: 1, + sandboxName: context.sandboxName, + agent: context.agent, + workflow: context.workflow, + channels, + credentialBindings: activeManifests.flatMap((manifest) => + planCredentialBindings(manifest, context, inputRegistry.get(manifest.id) ?? []), + ), + networkPolicy: planNetworkPolicy(activeManifests, context), + agentRender: activeManifests.flatMap((manifest) => planAgentRender(manifest, context)), + buildSteps: activeManifests.flatMap((manifest) => planBuildSteps(manifest)), + stateUpdates: activeManifests.flatMap((manifest) => planStateUpdates(manifest)), + healthChecks: activeManifests.flatMap((manifest) => planHealthChecks(manifest)), + }; + } + + private resolveRequestedManifests(context: ManifestCompilerContext): ChannelManifest[] { + const requestedIds = new Set([ + ...context.selectedChannels, + ...(context.configuredChannels ?? []), + ]); + const supportedIds = + context.supportedChannelIds && context.supportedChannelIds.length > 0 + ? new Set(context.supportedChannelIds) + : null; + + const manifests = this.registry + .list() + .filter((manifest) => requestedIds.has(manifest.id)) + .filter((manifest) => manifest.supportedAgents.includes(context.agent)) + .filter((manifest) => !supportedIds || supportedIds.has(manifest.id)); + + const foundIds = new Set(manifests.map((manifest) => manifest.id)); + const missingIds = [...requestedIds].filter((channelId) => !foundIds.has(channelId)); + if (missingIds.length > 0) { + throw new Error(`Missing messaging channel manifest(s): ${missingIds.join(", ")}`); + } + + return manifests; + } + + private async compileChannel( + manifest: ChannelManifest, + context: ManifestCompilerContext, + ): Promise { + const selected = context.selectedChannels.includes(manifest.id); + const configured = context.configuredChannels?.includes(manifest.id) ?? false; + const disabled = context.disabledChannels?.includes(manifest.id) ?? false; + const active = !disabled && (selected || configured); + + return { + channelId: manifest.id, + displayName: manifest.displayName, + authMode: manifest.auth.mode, + active, + selected, + configured, + disabled, + inputs: await resolveChannelInputs(manifest, context, this.hooks, { + runEnrollment: active && context.workflow === "create" && context.isInteractive, + }), + hooks: manifest.hooks.map((hook) => cloneHookReference(manifest.id, hook)), + }; + } +} + +function isChannelActive( + channelId: MessagingChannelId, + context: ManifestCompilerContext, +): boolean { + if (context.disabledChannels?.includes(channelId)) return false; + return ( + context.selectedChannels.includes(channelId) || + (context.configuredChannels ?? []).includes(channelId) + ); +} + +function cloneHookReference( + channelId: MessagingChannelId, + hook: ChannelManifest["hooks"][number], +): SandboxMessagingHookReferencePlan { + return { + channelId, + id: hook.id, + phase: hook.phase, + handler: hook.handler, + inputs: hook.inputs ? [...hook.inputs] : undefined, + outputs: hook.outputs?.map((output) => ({ ...output })), + onFailure: hook.onFailure, + }; +} + +async function resolveChannelInputs( + manifest: ChannelManifest, + context: ManifestCompilerContext, + hooks: MessagingHookRegistry, + options: { readonly runEnrollment: boolean }, +): Promise { + let inputs = manifest.inputs.map((input) => resolveChannelInput(manifest, input, context)); + const enrollmentHooks = options.runEnrollment + ? manifest.hooks.filter((hook) => hook.phase === "enroll") + : []; + + if (enrollmentHooks.length === 0) { + return applyCredentialAvailability(manifest, inputs, context); + } + + for (const hook of enrollmentHooks) { + const result = await runMessagingHook(hook, hooks, { + channelId: manifest.id, + inputs: toHookInputMap(inputs), + }); + inputs = applyCredentialAvailability( + manifest, + mergeEnrollmentOutputs(inputs, result.outputs), + context, + ); + } + + return inputs; +} + +function resolveChannelInput( + manifest: ChannelManifest, + input: ChannelInputSpec, + context: ManifestCompilerContext, +): SandboxMessagingInputReference { + const base = inputReferenceBase(manifest, input); + const envValue = readInputEnvValue(input); + if (envValue !== undefined) { + return input.kind === "secret" + ? { ...base, credentialAvailable: true } + : { ...base, value: envValue }; + } + + return { + ...base, + }; +} + +function inputReferenceBase( + manifest: ChannelManifest, + input: ChannelInputSpec, +): Omit { + const statePath = readInputStatePath(input); + + return { + channelId: manifest.id, + inputId: input.id, + kind: input.kind, + required: input.required, + sourceEnv: input.envKey, + ...(statePath ? { statePath } : {}), + }; +} + +function readInputEnvValue(input: ChannelInputSpec): MessagingSerializableValue | undefined { + if (!input.envKey) return undefined; + const value = process.env[input.envKey]; + return value && value.length > 0 ? value : undefined; +} + +function readInputStatePath(input: ChannelInputSpec): MessagingStatePath | undefined { + return input.kind === "config" ? input.statePath : undefined; +} + +function isCredentialAvailable( + manifest: ChannelManifest, + input: SandboxMessagingInputReference, + context: ManifestCompilerContext, +): boolean { + const availability = context.credentialAvailability ?? {}; + const keys = [input.inputId, `${manifest.id}.${input.inputId}`, input.sourceEnv].filter( + (key): key is string => typeof key === "string" && key.length > 0, + ); + + return keys.some((key) => availability[key] === true); +} + +function applyCredentialAvailability( + manifest: ChannelManifest, + inputs: readonly SandboxMessagingInputReference[], + context: ManifestCompilerContext, +): SandboxMessagingInputReference[] { + return inputs.map((input) => { + if (input.kind !== "secret") return input; + return { + ...input, + credentialAvailable: + input.credentialAvailable === true || isCredentialAvailable(manifest, input, context), + }; + }); +} + +function toHookInputMap( + inputs: readonly SandboxMessagingInputReference[], +): MessagingHookInputMap { + const entries: Array<[string, MessagingSerializableValue]> = []; + for (const input of inputs) { + if (input.value === undefined) continue; + entries.push([input.inputId, input.value]); + if (input.statePath) entries.push([input.statePath, input.value]); + } + return Object.fromEntries(entries); +} + +function mergeEnrollmentOutputs( + inputs: readonly SandboxMessagingInputReference[], + outputs: MessagingHookOutputMap, +): SandboxMessagingInputReference[] { + return inputs.map((input) => { + const output = outputs[input.inputId]; + if (!output) return input; + if (output.kind === "secret") { + return { ...input, credentialAvailable: true }; + } + if (output.kind === "config") { + return input.value === undefined ? { ...input, value: output.value } : input; + } + return input; + }); +} diff --git a/src/lib/messaging/compiler/types.ts b/src/lib/messaging/compiler/types.ts new file mode 100644 index 0000000000..3dbd030c6d --- /dev/null +++ b/src/lib/messaging/compiler/types.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingAgentId, + MessagingChannelId, + MessagingCompilerWorkflow, +} from "../manifest"; + +/** Credential availability lookup by env key, channel.input id, or credential id. */ +export type MessagingCompilerCredentialAvailability = Readonly>; + +/** Compiler inputs; values here must not contain raw secret material. */ +export interface ManifestCompilerContext { + readonly sandboxName: string; + readonly agent: MessagingAgentId; + readonly workflow: MessagingCompilerWorkflow; + readonly isInteractive: boolean; + readonly selectedChannels: readonly MessagingChannelId[]; + readonly configuredChannels?: readonly MessagingChannelId[]; + readonly disabledChannels?: readonly MessagingChannelId[]; + readonly supportedChannelIds?: readonly MessagingChannelId[]; + readonly credentialAvailability?: MessagingCompilerCredentialAvailability; +} diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts index ab993fe4c3..0bb132c5af 100644 --- a/src/lib/messaging/index.ts +++ b/src/lib/messaging/index.ts @@ -2,5 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 export * from "./channels"; +export * from "./compiler"; export * from "./hooks"; export * from "./manifest"; diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index d25c737428..c028e791da 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -60,7 +60,6 @@ const telegramManifest = { required: false, envKey: "TELEGRAM_REQUIRE_MENTION", validValues: ["0", "1"], - defaultValue: "1", statePath: "telegramConfig.requireMention", }, ], @@ -188,19 +187,29 @@ const wechatHookManifest = { const telegramPlan = { schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "create", channels: [ { channelId: "telegram", displayName: "Telegram", + authMode: "token-paste", active: true, + selected: true, + configured: false, + disabled: false, inputs: [ { + channelId: "telegram", inputId: "botToken", kind: "secret", required: true, sourceEnv: "TELEGRAM_BOT_TOKEN", + credentialAvailable: true, }, { + channelId: "telegram", inputId: "allowedIds", kind: "config", required: false, @@ -208,38 +217,55 @@ const telegramPlan = { 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: [], }, ], + credentialBindings: [ + { + channelId: "telegram", + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "demo-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + }, + ], + networkPolicy: { + presets: ["telegram"], + entries: [ + { + channelId: "telegram", + presetName: "telegram", + policyKeys: ["telegram_bot"], + source: "builtin", + }, + ], + }, + agentRender: [ + { + channelId: "telegram", + renderId: "telegram-openclaw", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + path: "channels.telegram.accounts.default", + value: { + botToken: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + enabled: true, + }, + }, + ], + buildSteps: [], + stateUpdates: [], + healthChecks: [ + { + channelId: "telegram", + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: [], + }, + ], } as const satisfies SandboxMessagingPlan; function jsonRoundTrip(value: T): T { @@ -301,7 +327,7 @@ describe("messaging manifest type contracts", () => { 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"); + expect(parsed.credentialBindings[0]).not.toHaveProperty("value"); }); it("uses hook handler references instead of function-valued fields", () => { diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index f397e9859f..0449e32b2b 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -71,15 +71,13 @@ interface ChannelInputBaseSpec { /** 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. */ +/** Non-secret input metadata that may 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. */ @@ -164,85 +162,160 @@ export interface ChannelHookOutputSpec { /** Serializable compiled plan for all selected messaging channels. */ export interface SandboxMessagingPlan { readonly schemaVersion: 1; + readonly sandboxName: string; + readonly agent: MessagingAgentId; + readonly workflow: MessagingCompilerWorkflow; readonly channels: readonly SandboxMessagingChannelPlan[]; + readonly credentialBindings: readonly SandboxMessagingCredentialBindingPlan[]; + readonly networkPolicy: SandboxMessagingNetworkPolicyPlan; + readonly agentRender: readonly SandboxMessagingAgentRenderPlan[]; + readonly buildSteps: readonly SandboxMessagingBuildStepPlan[]; + readonly stateUpdates: readonly SandboxMessagingStateUpdatePlan[]; + readonly healthChecks: readonly SandboxMessagingHealthCheckPlan[]; } -/** Compiled plan for one selected channel. */ +/** Workflow that requested a compiled messaging plan. */ +export type MessagingCompilerWorkflow = "create" | "rebuild" | "start" | "stop"; + +/** Compiled metadata for one requested channel. */ export interface SandboxMessagingChannelPlan { readonly channelId: MessagingChannelId; readonly displayName: string; + readonly authMode: ChannelAuthMode; readonly active: boolean; + readonly selected: boolean; + readonly configured: boolean; + readonly disabled: 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 channelId: MessagingChannelId; readonly inputId: string; readonly kind: "secret" | "config"; readonly required: boolean; readonly sourceEnv?: string; readonly statePath?: MessagingStatePath; + readonly credentialAvailable?: boolean; + readonly value?: MessagingSerializableValue; } /** Plan entry describing an OpenShell provider/env binding to create or attach. */ export interface SandboxMessagingCredentialBindingPlan { + readonly channelId: MessagingChannelId; readonly credentialId: string; readonly sourceInput: string; readonly providerName: MessagingTemplateString; readonly providerEnvKey: string; readonly placeholder: MessagingTemplateString; + readonly credentialAvailable: boolean; +} + +/** Network policy presets and concrete policy keys required by active channels. */ +export interface SandboxMessagingNetworkPolicyPlan { + readonly presets: readonly string[]; + readonly entries: readonly SandboxMessagingNetworkPolicyEntryPlan[]; +} + +/** One active channel's requested policy preset and resolved policy keys. */ +export interface SandboxMessagingNetworkPolicyEntryPlan { + readonly channelId: MessagingChannelId; + readonly presetName: string; + readonly policyKeys: readonly string[]; + readonly source: "builtin" | "agent-alias" | "manifest"; } /** Compiled render output for supported target formats. */ -export type SandboxMessagingRenderFragmentPlan = - | SandboxMessagingJsonRenderFragmentPlan - | SandboxMessagingEnvLinesRenderFragmentPlan; +export type SandboxMessagingAgentRenderPlan = + | SandboxMessagingJsonRenderPlan + | SandboxMessagingEnvLinesRenderPlan; + +/** Compatibility alias for older phase-1 tests and callers. */ +export type SandboxMessagingRenderFragmentPlan = SandboxMessagingAgentRenderPlan; /** Shared metadata for compiled render outputs. */ -interface SandboxMessagingRenderFragmentBasePlan { +interface SandboxMessagingAgentRenderBasePlan { + readonly channelId: MessagingChannelId; + readonly renderId?: string; readonly agent: MessagingAgentId; readonly target: string; } /** Compiled JSON fragment ready for an applier/render engine. */ -export interface SandboxMessagingJsonRenderFragmentPlan - extends SandboxMessagingRenderFragmentBasePlan { +export interface SandboxMessagingJsonRenderPlan + extends SandboxMessagingAgentRenderBasePlan { 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 { +export interface SandboxMessagingEnvLinesRenderPlan + extends SandboxMessagingAgentRenderBasePlan { readonly kind: "env-lines"; readonly lines: readonly MessagingTemplateString[]; } /** Build-time input the applier may pass into sandbox create/rebuild. */ -export type SandboxMessagingBuildInputPlan = - | SandboxMessagingBuildArgPlan - | SandboxMessagingBuildFilePlan; +export type SandboxMessagingBuildStepPlan = + | SandboxMessagingBuildArgStepPlan + | SandboxMessagingBuildFileStepPlan; + +/** Compatibility alias for older phase-1 tests and callers. */ +export type SandboxMessagingBuildInputPlan = SandboxMessagingBuildStepPlan; /** Docker/build argument planned for sandbox create or rebuild. */ -export interface SandboxMessagingBuildArgPlan { +export interface SandboxMessagingBuildArgStepPlan { + readonly channelId: MessagingChannelId; readonly kind: "build-arg"; - readonly name: string; - readonly valueTemplate: MessagingTemplateString; + readonly hookId: string; + readonly handler: string; + readonly outputId: string; + readonly required: boolean; } /** File planned for the sandbox build context, optionally sourced from a hook. */ -export interface SandboxMessagingBuildFilePlan { +export interface SandboxMessagingBuildFileStepPlan { + readonly channelId: MessagingChannelId; readonly kind: "build-file"; - readonly path: string; - readonly contentTemplate?: MessagingTemplateString; - readonly sourceHookOutput?: string; + readonly hookId: string; + readonly handler: string; + readonly outputId: string; + readonly required: boolean; } /** Hook reference carried into a compiled plan. */ -export type SandboxMessagingHookReferencePlan = ChannelHookSpec; +export interface SandboxMessagingHookReferencePlan extends ChannelHookSpec { + readonly channelId: MessagingChannelId; +} + +/** Planned state persistence or rebuild hydration produced from channel manifests. */ +export type SandboxMessagingStateUpdatePlan = + | SandboxMessagingPersistInputsStateUpdatePlan + | SandboxMessagingRebuildHydrationStateUpdatePlan; + +/** State input persistence planned for later workflow integration. */ +export interface SandboxMessagingPersistInputsStateUpdatePlan { + readonly channelId: MessagingChannelId; + readonly kind: "persist-inputs"; + readonly stateKey: string; + readonly inputIds: readonly string[]; +} + +/** Rebuild-time state hydration planned for later build integration. */ +export interface SandboxMessagingRebuildHydrationStateUpdatePlan { + readonly channelId: MessagingChannelId; + readonly kind: "rebuild-hydration"; + readonly statePath: MessagingStatePath; + readonly env: string; +} + +/** Health gates that must run before a lifecycle can report success. */ +export interface SandboxMessagingHealthCheckPlan { + readonly channelId: MessagingChannelId; + readonly phase: "health-check"; + readonly requiredBefore: "lifecycle-success"; + readonly hookIds: readonly string[]; +} From c9c545b5f71f78455b2942eccd802f6d7e397852 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 25 May 2026 14:04:11 +0700 Subject: [PATCH 2/2] feat(messaging): add workflow planner (#4076) ## Summary Adds the phase-1 messaging workflow planner for onboard, channel add/remove/start/stop, and rebuild flows. The planner computes configured, active, and disabled channel state before delegating to the manifest compiler, keeping #3995 architecture-only and out of production CLI paths. ## Related Issue Fixes #3995 ## Changes - Add `MessagingWorkflowPlanner` with pure `planOnboard`, `planAddChannel`, `planRemoveChannel`, `planStartChannel`, `planStopChannel`, and `planRebuild` methods. - Preserve stopped-but-configured channels, remove channels from configured and disabled state on remove, and carry registry/session snapshots through rebuild planning. - Refine `MessagingCompilerWorkflow` to the planner workflows and restrict enrollment hooks to selected onboard/add-channel plans. - Add workflow planner tests for lifecycle state transitions, deterministic unsupported-channel reporting, rebuild preservation, secret-free serialization, and avoiding re-enrollment of existing configured channels. ## Type of Change - [x] Code change (feature, bug fix, or refactor) - [ ] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Verification - [ ] `npx prek run --all-files` passes - [ ] `npm test` passes - [x] Tests added or updated for new or changed behavior - [x] No secrets, API keys, or credentials committed - [ ] Docs updated for user-facing behavior changes - [ ] `make docs` builds without warnings (doc changes only) - [ ] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) Additional verification performed: - `npm test -- --project cli src/lib/messaging` passes. - `npm run typecheck:cli` passes. - `npm run lint -- src/lib/messaging` passes with the existing unrelated warning in `src/lib/onboard/child-exit-tracker.test.ts`. - `npm run source-shape:check` passes. - `git diff --check` passes. - Commit and push hooks were bypassed because the full hook path is currently blocked by unrelated CLI doctor/debug/snapshot failures on this stack. --- Signed-off-by: San Dang --------- Signed-off-by: San Dang --- src/lib/messaging/applier/index.ts | 5 + .../messaging/applier/setup-applier.test.ts | 405 ++++++++++ src/lib/messaging/applier/setup-applier.ts | 743 ++++++++++++++++++ src/lib/messaging/applier/types.ts | 98 +++ src/lib/messaging/channels/manifests.test.ts | 31 +- .../channels/telegram/hooks/fakes.test.ts | 34 + .../channels/telegram/hooks/fakes.ts | 21 + .../messaging/channels/telegram/manifest.ts | 7 + .../channels/wechat/hooks/fakes.test.ts | 7 + .../messaging/channels/wechat/hooks/fakes.ts | 18 +- src/lib/messaging/channels/wechat/manifest.ts | 1 + .../compiler/engines/agent-render-engine.ts | 18 +- .../compiler/engines/build-step-engine.ts | 15 +- .../messaging/compiler/engines/template.ts | 34 + src/lib/messaging/compiler/index.ts | 1 + .../compiler/manifest-compiler.test.ts | 72 +- .../messaging/compiler/manifest-compiler.ts | 179 ++++- .../compiler/workflow-planner.test.ts | 315 ++++++++ .../messaging/compiler/workflow-planner.ts | 244 ++++++ src/lib/messaging/index.ts | 1 + src/lib/messaging/manifest/types.test.ts | 3 +- src/lib/messaging/manifest/types.ts | 16 +- 22 files changed, 2192 insertions(+), 76 deletions(-) create mode 100644 src/lib/messaging/applier/index.ts create mode 100644 src/lib/messaging/applier/setup-applier.test.ts create mode 100644 src/lib/messaging/applier/setup-applier.ts create mode 100644 src/lib/messaging/applier/types.ts create mode 100644 src/lib/messaging/channels/telegram/hooks/fakes.test.ts create mode 100644 src/lib/messaging/channels/telegram/hooks/fakes.ts create mode 100644 src/lib/messaging/compiler/workflow-planner.test.ts create mode 100644 src/lib/messaging/compiler/workflow-planner.ts diff --git a/src/lib/messaging/applier/index.ts b/src/lib/messaging/applier/index.ts new file mode 100644 index 0000000000..19a1e91b68 --- /dev/null +++ b/src/lib/messaging/applier/index.ts @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from "./setup-applier"; +export type * from "./types"; diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts new file mode 100644 index 0000000000..05d32f795f --- /dev/null +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -0,0 +1,405 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { createBuiltInChannelManifestRegistry } from "../channels"; +import { FAKE_TELEGRAM_HOOK_REGISTRATIONS } from "../channels/telegram/hooks/fakes"; +import { FAKE_WECHAT_HOOK_REGISTRATIONS } from "../channels/wechat/hooks/fakes"; +import { MessagingWorkflowPlanner } from "../compiler"; +import { MessagingHookRegistry, runMessagingHook } from "../hooks"; +import { FAKE_COMMON_HOOK_REGISTRATIONS } from "../hooks/common"; +import type { ChannelHookSpec } from "../manifest"; +import type { SandboxMessagingPlan } from "../manifest"; +import { MessagingSetupApplier } from "./setup-applier"; +import { MESSAGING_SETUP_APPLIER_ENV_KEY, type MessagingOpenShellRunner } from "./types"; + +async function withEnv( + values: Readonly>, + run: () => Promise, +): Promise { + const previous = Object.fromEntries( + Object.keys(values).map((key) => [key, process.env[key]]), + ); + try { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return await run(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +function planner(): MessagingWorkflowPlanner { + return new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + new MessagingHookRegistry([ + ...FAKE_COMMON_HOOK_REGISTRATIONS, + ...FAKE_TELEGRAM_HOOK_REGISTRATIONS, + ...FAKE_WECHAT_HOOK_REGISTRATIONS, + ]), + ); +} + +async function planOnboard( + env: Readonly>, + selectedChannels: readonly string[], +): Promise { + return withEnv(env, () => + planner().planOnboard({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + selectedChannels, + }), + ); +} + +describe("MessagingSetupApplier", () => { + it("stores a serializable SandboxMessagingPlan in env without rejecting repeated aliases", async () => { + const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const repeated = { value: "same" }; + const planWithAlias = { + ...plan, + agentRender: [ + { + channelId: "telegram", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + path: "x", + value: [repeated, repeated], + templateRefs: [], + }, + ], + } satisfies SandboxMessagingPlan; + const env: NodeJS.ProcessEnv = {}; + + MessagingSetupApplier.writePlanToEnv(planWithAlias, { env }); + + const decoded = MessagingSetupApplier.readPlanFromEnv({ env }); + expect(env[MESSAGING_SETUP_APPLIER_ENV_KEY]).toBeTruthy(); + expect(decoded?.sandboxName).toBe("demo"); + expect(decoded?.agentRender[0]).toMatchObject({ + channelId: "telegram", + kind: "json-fragment", + }); + + const cyclic = { ...plan } as Record; + cyclic.self = cyclic; + expect(() => MessagingSetupApplier.encodePlan(cyclic as never)).toThrow(/cycle/); + }); + + it("lists hook requests by phase without executing hook implementations", async () => { + const plan = await planOnboard({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); + + expect(MessagingSetupApplier.listHookRequests(plan, "enroll")).toEqual([ + expect.objectContaining({ + sandboxName: "demo", + channelId: "wechat", + hookId: "wechat-host-qr", + phase: "enroll", + handler: "wechat.ilinkLogin", + }), + ]); + expect(MessagingSetupApplier.listHookRequests(plan, "post-agent-install")).toEqual([ + expect.objectContaining({ + sandboxName: "demo", + channelId: "wechat", + hookId: "wechat-seed-openclaw-account", + phase: "post-agent-install", + handler: "wechat.seedOpenClawAccount", + }), + ]); + }); + + it("upserts OpenShell generic providers from plan credential bindings", async () => { + const plan = await planOnboard( + { + TELEGRAM_BOT_TOKEN: "123456:telegram-token", + SLACK_BOT_TOKEN: "xoxb-slack-token", + SLACK_APP_TOKEN: "xapp-slack-token", + }, + ["telegram", "slack"], + ); + const calls: Array<{ + args: readonly string[]; + env?: Readonly>; + }> = []; + const runOpenshell: MessagingOpenShellRunner = (args, options) => { + calls.push({ args, env: options?.env }); + if (args[0] === "provider" && args[1] === "get") { + return { status: args[2] === "demo-slack-bridge" ? 0 : 1 }; + } + return { status: 0 }; + }; + + const result = MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: { + TELEGRAM_BOT_TOKEN: "123456:telegram-token", + SLACK_BOT_TOKEN: "xoxb-slack-token", + SLACK_APP_TOKEN: "xapp-slack-token", + }, + runOpenshell, + }); + + expect(calls.map((call) => call.args)).toEqual([ + ["provider", "get", "demo-telegram-bridge"], + [ + "provider", + "create", + "--name", + "demo-telegram-bridge", + "--type", + "generic", + "--credential", + "TELEGRAM_BOT_TOKEN", + ], + ["provider", "get", "demo-slack-bridge"], + ["provider", "update", "demo-slack-bridge", "--credential", "SLACK_BOT_TOKEN"], + ["provider", "get", "demo-slack-app"], + [ + "provider", + "create", + "--name", + "demo-slack-app", + "--type", + "generic", + "--credential", + "SLACK_APP_TOKEN", + ], + ]); + expect(calls[1]?.env).toEqual({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }); + expect(result.upserted.map((entry) => `${entry.action}:${entry.providerName}`)).toEqual([ + "create:demo-telegram-bridge", + "update:demo-slack-bridge", + "create:demo-slack-app", + ]); + expect(result.sandboxCreateProviderArgs).toEqual([ + "--provider", + "demo-telegram-bridge", + "--provider", + "demo-slack-bridge", + "--provider", + "demo-slack-app", + ]); + expect(JSON.stringify(result)).not.toContain("telegram-token"); + expect(JSON.stringify(result)).not.toContain("slack-token"); + }); + + it("redacts OpenShell provider failure output", async () => { + const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "tokensecretvalue" }, [ + "telegram", + ]); + const runOpenshell: MessagingOpenShellRunner = (args) => { + if (args[0] === "provider" && args[1] === "get") { + return { status: 1 }; + } + return { + status: 1, + stderr: "provider rejected TELEGRAM_BOT_TOKEN=tokensecretvalue", + }; + }; + + let message = ""; + try { + MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: { TELEGRAM_BOT_TOKEN: "tokensecretvalue" }, + runOpenshell, + }); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toContain("TELEGRAM_BOT_TOKEN=toke"); + expect(message).not.toContain("tokensecretvalue"); + }); + + it("applies agent config render plans into sandbox files through OpenShell", async () => { + const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const files: Record = { + "/sandbox/.openclaw/openclaw.json": JSON.stringify({ + agents: { + list: ["default"], + }, + }), + }; + const calls: Array<{ args: readonly string[]; input?: string }> = []; + const runOpenshell: MessagingOpenShellRunner = (args, options) => { + calls.push({ args, input: options?.input }); + const target = String(args.at(-1)); + if (args.includes("cat") && !options?.input) { + return { status: files[target] === undefined ? 1 : 0, stdout: files[target] ?? "" }; + } + if (options?.input !== undefined) { + files[target] = options.input; + return { status: 0 }; + } + return { status: 1 }; + }; + + const result = await MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { + runOpenshell, + }); + + expect(calls.map((call) => call.args)).toEqual([ + [ + "sandbox", + "exec", + "--name", + "demo", + "--", + "cat", + "/sandbox/.openclaw/openclaw.json", + ], + [ + "sandbox", + "exec", + "--name", + "demo", + "--", + "sh", + "-c", + 'mkdir -p "$(dirname "$1")" && cat > "$1"', + "sh", + "/sandbox/.openclaw/openclaw.json", + ], + ]); + expect(calls[1]?.input).toBeTruthy(); + const openclawConfig = JSON.parse(files["/sandbox/.openclaw/openclaw.json"] ?? "{}"); + expect(openclawConfig.agents.list).toEqual(["default"]); + expect(openclawConfig.channels.telegram.accounts.default).toMatchObject({ + botToken: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + enabled: true, + groupPolicy: "open", + }); + expect(openclawConfig.channels.telegram.groups["*"]).toEqual({ + requireMention: "{{telegramConfig.requireMention}}", + }); + expect(result.appliedTargets).toEqual(["/sandbox/.openclaw/openclaw.json"]); + expect(result.appliedHooks).toEqual([]); + expect(result.unresolvedTemplateRefs).toEqual( + expect.arrayContaining(["proxyUrl", "telegramConfig.requireMention"]), + ); + }); + + it("runs post-install hook implementations and writes their build-file outputs", async () => { + const plan = await planOnboard( + { + WECHAT_ACCOUNT_ID: "wechat-account", + WECHAT_BASE_URL: "https://ilinkai.wechat.example", + WECHAT_USER_ID: "wechat-user", + }, + ["wechat"], + ); + const registry = new MessagingHookRegistry(FAKE_WECHAT_HOOK_REGISTRATIONS); + const files: Record = { + "/sandbox/.openclaw/openclaw.json": JSON.stringify({ + plugins: { + entries: { + acpx: { + enabled: false, + }, + }, + }, + }), + }; + + const result = await MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { + runOpenshell: (args, options) => { + const command = String(args[7] ?? ""); + const target = + options?.input !== undefined && command.includes("chmod") + ? String(args.at(-2)) + : String(args.at(-1)); + if (args.includes("cat") && options?.input === undefined) { + return { status: files[target] === undefined ? 1 : 0, stdout: files[target] ?? "" }; + } + if (options?.input !== undefined) { + files[target] = options.input; + return { status: 0 }; + } + return { status: 1 }; + }, + runHook: (request) => { + const hook = { + id: request.hookId, + phase: request.phase, + handler: request.handler, + inputs: request.inputKeys, + outputs: request.outputs, + onFailure: request.onFailure, + } satisfies ChannelHookSpec; + return runMessagingHook(hook, registry, { + channelId: request.channelId, + inputs: request.inputs, + }); + }, + }); + + expect(JSON.parse(files["/sandbox/.openclaw/openclaw-weixin/accounts.json"] ?? "[]")).toEqual( + ["wechat-account"], + ); + expect( + JSON.parse( + files["/sandbox/.openclaw/openclaw-weixin/accounts/wechat-account.json"] ?? "{}", + ), + ).toMatchObject({ + token: "openshell:resolve:env:WECHAT_BOT_TOKEN", + baseUrl: "https://ilinkai.wechat.example", + userId: "wechat-user", + }); + const openclawConfig = JSON.parse(files["/sandbox/.openclaw/openclaw.json"] ?? "{}"); + expect(openclawConfig.plugins.entries.acpx.enabled).toBe(false); + expect(openclawConfig.plugins.entries["openclaw-weixin"].enabled).toBe(true); + expect(openclawConfig.plugins.installs["openclaw-weixin"].spec).toBe( + "@tencent-weixin/openclaw-weixin@2.4.2", + ); + expect(openclawConfig.plugins.load.paths).toEqual([ + "/sandbox/.openclaw/extensions/openclaw-weixin", + ]); + expect(openclawConfig.channels["openclaw-weixin"].accounts["wechat-account"]).toEqual({ + enabled: true, + }); + expect(result.appliedTargets).toEqual([ + "/sandbox/.openclaw/openclaw-weixin/accounts.json", + "/sandbox/.openclaw/openclaw-weixin/accounts/wechat-account.json", + "/sandbox/.openclaw/openclaw.json", + ]); + expect(result.appliedHooks).toEqual(["wechat:wechat-seed-openclaw-account"]); + }); + + it("applies policy presets directly from the serializable plan", async () => { + const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const policyCalls: string[][] = []; + + const result = MessagingSetupApplier.applyPolicyAtOpenShell(plan, { + applyPresets: (sandboxName, presetNames) => { + policyCalls.push([sandboxName, ...presetNames]); + return true; + }, + }); + + expect(policyCalls).toEqual([["demo", "telegram"]]); + expect(result).toEqual({ + appliedPresets: ["telegram"], + }); + }); +}); diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts new file mode 100644 index 0000000000..7a4ab6341b --- /dev/null +++ b/src/lib/messaging/applier/setup-applier.ts @@ -0,0 +1,743 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Buffer } from "node:buffer"; +import YAML from "yaml"; + +import { redact } from "../../security/redact"; +import type { + ChannelHookPhase, + MessagingAgentId, + MessagingSerializableValue, + SandboxMessagingAgentRenderPlan, + SandboxMessagingChannelPlan, + SandboxMessagingCredentialBindingPlan, + SandboxMessagingEnvLinesRenderPlan, + SandboxMessagingJsonRenderPlan, + SandboxMessagingPlan, +} from "../manifest"; +import type { MessagingHookOutputMap } from "../hooks"; +import { + MESSAGING_SETUP_APPLIER_ENV_KEY, + type MessagingCredentialApplyOptions, + type MessagingCredentialApplyResult, + type MessagingHookApplyRequest, + type MessagingHookApplyRunner, + type MessagingOpenShellRunner, + type MessagingPolicyApplyOptions, + type MessagingPolicyApplyResult, + type MessagingSetupEnvOptions, +} from "./types"; + +type MessagingCredentialApplyEntry = MessagingCredentialApplyResult["upserted"][number]; +type MessagingCredentialReuseEntry = MessagingCredentialApplyResult["reused"][number]; +type MessagingMissingCredentialEntry = MessagingCredentialApplyResult["missing"][number]; +type MessagingCredentialBindingLike = Pick< + SandboxMessagingCredentialBindingPlan, + "channelId" | "credentialId" | "providerName" | "providerEnvKey" +>; + +const AGENT_CONFIG_HOOK_PHASES = new Set([ + "apply", + "post-agent-install", +]); + +export class MessagingSetupApplier { + static encodePlan(plan: SandboxMessagingPlan): string { + assertSandboxMessagingPlan(plan); + assertJsonSerializable(plan); + return Buffer.from(JSON.stringify(plan), "utf8").toString("base64"); + } + + static decodePlan(encoded: string): SandboxMessagingPlan { + const raw = Buffer.from(encoded, "base64").toString("utf8"); + const parsed = JSON.parse(raw) as unknown; + assertSandboxMessagingPlan(parsed); + return parsed; + } + + static writePlanToEnv( + plan: SandboxMessagingPlan, + options: MessagingSetupEnvOptions = {}, + ): void { + const env = options.env ?? process.env; + env[options.envKey ?? MESSAGING_SETUP_APPLIER_ENV_KEY] = this.encodePlan(plan); + } + + static readPlanFromEnv(options: MessagingSetupEnvOptions = {}): SandboxMessagingPlan | null { + const env = options.env ?? process.env; + const value = env[options.envKey ?? MESSAGING_SETUP_APPLIER_ENV_KEY]; + return value ? this.decodePlan(value) : null; + } + + static requirePlanFromEnv(options: MessagingSetupEnvOptions = {}): SandboxMessagingPlan { + const plan = this.readPlanFromEnv(options); + if (!plan) { + throw new Error(`${options.envKey ?? MESSAGING_SETUP_APPLIER_ENV_KEY} is not set.`); + } + return plan; + } + + static clearPlanEnv(options: MessagingSetupEnvOptions = {}): void { + const env = options.env ?? process.env; + delete env[options.envKey ?? MESSAGING_SETUP_APPLIER_ENV_KEY]; + } + + static listHookRequests( + plan: SandboxMessagingPlan, + phase?: ChannelHookPhase, + ): MessagingHookApplyRequest[] { + assertSandboxMessagingPlan(plan); + return plan.channels.flatMap((channel) => + channel.hooks + .filter((hook) => !phase || hook.phase === phase) + .map((hook) => toHookApplyRequest(plan, channel, hook)), + ); + } + + static async applyAgentConfigAtOpenShell( + plan: SandboxMessagingPlan, + options: { + readonly runOpenshell: MessagingOpenShellRunner; + readonly runHook?: MessagingHookApplyRunner; + }, + ): Promise<{ + readonly appliedTargets: readonly string[]; + readonly appliedHooks: readonly string[]; + readonly unresolvedTemplateRefs: readonly string[]; + }> { + assertSandboxMessagingPlan(plan); + const hookRequests = hookRequestsForPhases(plan, AGENT_CONFIG_HOOK_PHASES); + if (hookRequests.length > 0 && !options.runHook) { + throw new Error("Messaging agent config hooks require a hook runner."); + } + + const appliedHooks: string[] = []; + const appliedTargets: string[] = []; + for (const request of hookRequests.filter((hook) => hook.phase === "apply")) { + await runApplyHook(request, options.runHook, plan, options.runOpenshell, { + appliedHooks, + appliedTargets, + }); + } + + for (const [target, render] of groupRenderByTarget(plan.agentRender)) { + const resolvedTarget = resolveSandboxAgentConfigTarget(target, plan.agent); + const kind = render[0]?.kind; + if (!kind) continue; + if (render.some((entry) => entry.kind !== kind)) { + throw new Error(`Cannot apply mixed messaging render kinds to ${target}.`); + } + const existing = readSandboxFile(plan.sandboxName, resolvedTarget, options.runOpenshell); + const contents = + kind === "json-fragment" + ? applyJsonFragments( + existing, + render.filter(isJsonRender), + resolvedTarget, + ) + : applyEnvLines(existing, render.filter(isEnvLinesRender)); + writeSandboxFile(plan.sandboxName, resolvedTarget, contents, options.runOpenshell); + appliedTargets.push(resolvedTarget); + } + + for (const request of hookRequests.filter((hook) => hook.phase === "post-agent-install")) { + await runApplyHook(request, options.runHook, plan, options.runOpenshell, { + appliedHooks, + appliedTargets, + }); + } + + return { + appliedTargets: uniqueStrings(appliedTargets), + appliedHooks, + unresolvedTemplateRefs: uniqueStrings( + plan.agentRender.flatMap((render) => render.templateRefs), + ), + }; + } + + static applyCredentialsAtOpenShell( + plan: SandboxMessagingPlan, + options: MessagingCredentialApplyOptions, + ): MessagingCredentialApplyResult { + assertSandboxMessagingPlan(plan); + const env = options.env ?? process.env; + const runOpenshell = options.runOpenshell; + const upserted: MessagingCredentialApplyEntry[] = []; + const reused: MessagingCredentialReuseEntry[] = []; + const missing: MessagingMissingCredentialEntry[] = []; + + for (const binding of plan.credentialBindings) { + const credential = readCredentialEnv(env, binding.providerEnvKey); + if (!credential) { + if (providerExistsInGateway(binding.providerName, runOpenshell)) { + reused.push(toReuseEntry(binding)); + } else { + missing.push(toMissingEntry(binding)); + } + continue; + } + + const action = providerExistsInGateway(binding.providerName, runOpenshell) + ? "update" + : "create"; + const result = runOpenshell( + buildProviderArgs(action, binding.providerName, binding.providerEnvKey), + { + ignoreError: true, + env: { [binding.providerEnvKey]: credential }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const status = result.status ?? 0; + if (status !== 0) { + throw new Error( + `Failed to ${action} messaging provider '${binding.providerName}': ${compactOutput(result)}`, + ); + } + upserted.push({ + channelId: binding.channelId, + credentialId: binding.credentialId, + providerName: binding.providerName, + envKey: binding.providerEnvKey, + action, + }); + } + + const providerNames = uniqueStrings([ + ...upserted.map((entry) => entry.providerName), + ...reused.map((entry) => entry.providerName), + ]); + + return { + upserted, + reused, + missing, + providerNames, + sandboxCreateProviderArgs: providerNames.flatMap((providerName) => [ + "--provider", + providerName, + ]), + }; + } + + static applyPolicyAtOpenShell( + plan: SandboxMessagingPlan, + options: MessagingPolicyApplyOptions, + ): MessagingPolicyApplyResult { + assertSandboxMessagingPlan(plan); + const activePresets = uniqueStrings(plan.networkPolicy.presets); + if (activePresets.length > 0 && !options.applyPresets(plan.sandboxName, activePresets)) { + throw new Error(`Failed to apply messaging policy preset(s): ${activePresets.join(", ")}`); + } + + return { + appliedPresets: activePresets, + }; + } +} + +function hookRequestsForPhases( + plan: SandboxMessagingPlan, + phases: ReadonlySet, +): MessagingHookApplyRequest[] { + return plan.channels.flatMap((channel) => + channel.hooks + .filter((hook) => phases.has(hook.phase)) + .map((hook) => toHookApplyRequest(plan, channel, hook)), + ); +} + +function toHookApplyRequest( + plan: SandboxMessagingPlan, + channel: SandboxMessagingChannelPlan, + hook: SandboxMessagingChannelPlan["hooks"][number], +): MessagingHookApplyRequest { + const inputs = buildHookInputMap(plan, channel); + const selectedInputs = hook.inputs + ? Object.fromEntries( + hook.inputs + .filter((inputKey) => Object.hasOwn(inputs, inputKey)) + .map((inputKey) => [inputKey, inputs[inputKey] as MessagingSerializableValue]), + ) + : inputs; + + return { + sandboxName: plan.sandboxName, + agent: plan.agent, + channelId: channel.channelId, + hookId: hook.id, + phase: hook.phase, + handler: hook.handler, + inputKeys: hook.inputs, + inputs: selectedInputs, + outputs: hook.outputs, + onFailure: hook.onFailure, + }; +} + +function buildHookInputMap( + plan: SandboxMessagingPlan, + channel: SandboxMessagingChannelPlan, +): Record { + const inputs: Record = {}; + for (const input of channel.inputs) { + if (input.value === undefined) continue; + inputs[input.inputId] = input.value; + if (input.statePath) inputs[input.statePath] = input.value; + } + for (const credential of plan.credentialBindings) { + if (credential.channelId !== channel.channelId) continue; + inputs[`credential.${credential.credentialId}.placeholder`] = credential.placeholder; + } + return inputs; +} + +async function runApplyHook( + request: MessagingHookApplyRequest, + runner: MessagingHookApplyRunner | undefined, + plan: SandboxMessagingPlan, + runOpenshell: MessagingOpenShellRunner, + applied: { + readonly appliedHooks: string[]; + readonly appliedTargets: string[]; + }, +): Promise { + if (!runner) return; + try { + const result = await runner(request); + applied.appliedHooks.push(`${request.channelId}:${request.hookId}`); + if (result?.outputs) { + applied.appliedTargets.push( + ...applyHookBuildFileOutputs(plan, result.outputs, runOpenshell), + ); + } + } catch (error) { + if (request.onFailure === "skip-channel") return; + throw error; + } +} + +function assertSandboxMessagingPlan(value: unknown): asserts value is SandboxMessagingPlan { + if ( + !isObject(value) || + value.schemaVersion !== 1 || + typeof value.sandboxName !== "string" || + typeof value.agent !== "string" || + typeof value.workflow !== "string" || + !Array.isArray(value.channels) || + !Array.isArray(value.credentialBindings) || + !isObject(value.networkPolicy) || + !Array.isArray(value.agentRender) || + !Array.isArray(value.buildSteps) || + !Array.isArray(value.stateUpdates) || + !Array.isArray(value.healthChecks) + ) { + throw new Error("Expected a serializable SandboxMessagingPlan."); + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertJsonSerializable( + value: unknown, + path = "$", + visiting: Set = new Set(), +): void { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "undefined" + ) { + return; + } + if (Array.isArray(value)) { + assertAcyclicObject(value, path, visiting, () => { + value.forEach((entry, index) => assertJsonSerializable(entry, `${path}[${index}]`, visiting)); + }); + return; + } + if (typeof value === "object" && value !== null) { + assertAcyclicObject(value, path, visiting, () => { + for (const [key, entry] of Object.entries(value)) { + assertJsonSerializable(entry, `${path}.${key}`, visiting); + } + }); + return; + } + throw new Error(`Messaging setup plan is not JSON-serializable at ${path}.`); +} + +function assertAcyclicObject( + value: object, + path: string, + visiting: Set, + visit: () => void, +): void { + if (visiting.has(value)) { + throw new Error(`Messaging setup plan contains a cycle at ${path}.`); + } + visiting.add(value); + try { + visit(); + } finally { + visiting.delete(value); + } +} + +function groupRenderByTarget( + render: readonly SandboxMessagingAgentRenderPlan[], +): ReadonlyMap { + const groups = new Map(); + for (const entry of render) { + const group = groups.get(entry.target) ?? []; + group.push(entry); + groups.set(entry.target, group); + } + return groups; +} + +function isJsonRender( + render: SandboxMessagingAgentRenderPlan, +): render is SandboxMessagingJsonRenderPlan { + return render.kind === "json-fragment"; +} + +function isEnvLinesRender( + render: SandboxMessagingAgentRenderPlan, +): render is SandboxMessagingEnvLinesRenderPlan { + return render.kind === "env-lines"; +} + +function applyJsonFragments( + existing: string | undefined, + render: readonly SandboxMessagingJsonRenderPlan[], + target: string, +): string { + const format = target.endsWith(".yaml") || target.endsWith(".yml") ? "yaml" : "json"; + const root = parseStructuredConfig(existing, target, format); + for (const entry of render) { + setJsonPath(root, entry.path, entry.value); + } + return format === "yaml" ? YAML.stringify(root) : `${JSON.stringify(root, null, 2)}\n`; +} + +function parseStructuredConfig( + existing: string | undefined, + target: string, + format: "json" | "yaml", +): Record { + if (!existing || existing.trim().length === 0) return {}; + const parsed = format === "yaml" ? YAML.parse(existing) : (JSON.parse(existing) as unknown); + if (!isObject(parsed)) { + throw new Error(`Messaging agent config target ${target} must contain an object.`); + } + return parsed as Record; +} + +function setJsonPath( + root: Record, + path: string, + value: MessagingSerializableValue, +): void { + const segments = path.split(".").filter(Boolean); + if (segments.length === 0) { + throw new Error("Messaging render path must not be empty."); + } + let cursor: Record = root; + for (const segment of segments.slice(0, -1)) { + const next = cursor[segment]; + if (!isObject(next)) { + const created: Record = {}; + cursor[segment] = created; + cursor = created; + } else { + cursor = next as Record; + } + } + cursor[segments[segments.length - 1] as string] = value; +} + +function applyEnvLines( + existing: string | undefined, + render: readonly SandboxMessagingEnvLinesRenderPlan[], +): string { + const desired = new Map(); + const rawDesiredLines: string[] = []; + for (const entry of render) { + for (const line of entry.lines) { + const key = readEnvLineKey(line); + if (key) { + desired.set(key, line); + } else { + rawDesiredLines.push(line); + } + } + } + + const written = new Set(); + const output = (existing ?? "") + .split(/\n/) + .filter((line, index, lines) => line.length > 0 || index < lines.length - 1) + .map((line) => { + const key = readEnvLineKey(line); + if (!key || !desired.has(key)) return line; + written.add(key); + return desired.get(key) as string; + }); + + for (const [key, line] of desired) { + if (!written.has(key)) output.push(line); + } + output.push(...rawDesiredLines); + return output.length > 0 ? `${output.join("\n")}\n` : ""; +} + +function readEnvLineKey(line: string): string | null { + const index = line.indexOf("="); + if (index <= 0) return null; + const key = line.slice(0, index).trim(); + return key.length > 0 ? key : null; +} + +function applyHookBuildFileOutputs( + plan: SandboxMessagingPlan, + outputs: MessagingHookOutputMap, + runOpenshell: MessagingOpenShellRunner, +): string[] { + const appliedTargets: string[] = []; + for (const output of Object.values(outputs)) { + if (output.kind !== "build-file") continue; + const file = readHookBuildFile(output.value); + const target = resolveHookBuildFileTarget(file.path, plan.agent); + const contents = + file.merge !== undefined + ? applyStructuredMerge( + readSandboxFile(plan.sandboxName, target, runOpenshell), + file.merge, + target, + ) + : serializeHookBuildFileContent(file.content, target); + writeSandboxFile(plan.sandboxName, target, contents, runOpenshell, file.mode); + appliedTargets.push(target); + } + return appliedTargets; +} + +function readHookBuildFile(value: MessagingSerializableValue): { + readonly path: string; + readonly mode?: string; + readonly content?: MessagingSerializableValue; + readonly merge?: MessagingSerializableValue; +} { + if (!isObject(value) || typeof value.path !== "string" || value.path.trim().length === 0) { + throw new Error("Messaging build-file hook output must include a non-empty path."); + } + const file = value as Record; + const path = value.path; + const mode = value.mode; + if (file.content === undefined && file.merge === undefined) { + throw new Error(`Messaging build-file '${path}' must include content or merge.`); + } + if (mode !== undefined && typeof mode !== "string") { + throw new Error(`Messaging build-file '${path}' mode must be a string.`); + } + return { + path, + mode, + content: file.content, + merge: file.merge, + }; +} + +function applyStructuredMerge( + existing: string | undefined, + patch: MessagingSerializableValue, + target: string, +): string { + if (!isObject(patch)) { + throw new Error(`Messaging build-file merge for ${target} must be an object.`); + } + const format = target.endsWith(".yaml") || target.endsWith(".yml") ? "yaml" : "json"; + const root = parseStructuredConfig(existing, target, format); + mergeObjects(root, patch); + return format === "yaml" ? YAML.stringify(root) : `${JSON.stringify(root, null, 2)}\n`; +} + +function mergeObjects( + target: Record, + patch: Record, +): void { + for (const [key, value] of Object.entries(patch)) { + const existing = target[key]; + if (isObject(existing) && isObject(value)) { + mergeObjects( + existing as Record, + value as Record, + ); + continue; + } + target[key] = value; + } +} + +function serializeHookBuildFileContent( + content: MessagingSerializableValue | undefined, + target: string, +): string { + if (content === undefined) return ""; + if (typeof content === "string") return content.endsWith("\n") ? content : `${content}\n`; + if (target.endsWith(".yaml") || target.endsWith(".yml")) return YAML.stringify(content); + return `${JSON.stringify(content, null, 2)}\n`; +} + +function resolveHookBuildFileTarget(path: string, agent: MessagingAgentId): string { + if (path.startsWith("/")) return path; + if (path === "openclaw.json") return resolveSandboxAgentConfigTarget(path, "openclaw"); + if (path === "config.yaml" && agent === "hermes") { + return resolveSandboxAgentConfigTarget("~/.hermes/config.yaml", agent); + } + if (path === ".env" && agent === "hermes") { + return resolveSandboxAgentConfigTarget("~/.hermes/.env", agent); + } + if (agent === "openclaw") return `/sandbox/.openclaw/${path}`; + if (agent === "hermes") return `/sandbox/.hermes/${path}`; + throw new Error(`Cannot resolve messaging build-file target '${path}' for ${agent}.`); +} + +function resolveSandboxAgentConfigTarget(target: string, agent: MessagingAgentId): string { + if (target.startsWith("/")) return target; + if (agent === "openclaw" && target === "openclaw.json") { + return "/sandbox/.openclaw/openclaw.json"; + } + if (target.startsWith("~/.openclaw/")) { + return `/sandbox/.openclaw/${target.slice("~/.openclaw/".length)}`; + } + if (target.startsWith("~/.hermes/")) { + return `/sandbox/.hermes/${target.slice("~/.hermes/".length)}`; + } + throw new Error(`Cannot resolve messaging agent config target '${target}' for ${agent}.`); +} + +function readSandboxFile( + sandboxName: string, + target: string, + runOpenshell: MessagingOpenShellRunner, +): string | undefined { + const result = runOpenshell( + ["sandbox", "exec", "--name", sandboxName, "--", "cat", target], + { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const status = result.status ?? 0; + return status === 0 ? String(result.stdout ?? "") : undefined; +} + +function writeSandboxFile( + sandboxName: string, + target: string, + contents: string, + runOpenshell: MessagingOpenShellRunner, + mode?: string, +): void { + const result = runOpenshell( + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "sh", + "-c", + mode + ? 'mkdir -p "$(dirname "$1")" && cat > "$1" && chmod "$2" "$1"' + : 'mkdir -p "$(dirname "$1")" && cat > "$1"', + "sh", + target, + ...(mode ? [mode] : []), + ], + { + input: contents, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const status = result.status ?? 0; + if (status !== 0) { + throw new Error( + `Failed to apply messaging agent config '${target}': ${compactOutput(result)}`, + ); + } +} + +function readCredentialEnv(env: NodeJS.ProcessEnv, envKey: string): string | null { + const raw = env[envKey]; + if (typeof raw !== "string") return null; + const normalized = raw.replace(/\r/g, "").trim(); + return normalized || null; +} + +function providerExistsInGateway( + providerName: string, + runOpenshell: MessagingOpenShellRunner, +): boolean { + const result = runOpenshell(["provider", "get", providerName], { + ignoreError: true, + stdio: ["ignore", "ignore", "ignore"], + }); + return (result.status ?? 0) === 0; +} + +function buildProviderArgs( + action: "create" | "update", + providerName: string, + credentialEnv: string, +): string[] { + return action === "create" + ? [ + "provider", + "create", + "--name", + providerName, + "--type", + "generic", + "--credential", + credentialEnv, + ] + : ["provider", "update", providerName, "--credential", credentialEnv]; +} + +function toReuseEntry(binding: MessagingCredentialBindingLike): MessagingCredentialReuseEntry { + return { + channelId: binding.channelId, + credentialId: binding.credentialId, + providerName: binding.providerName, + envKey: binding.providerEnvKey, + }; +} + +function toMissingEntry(binding: MessagingCredentialBindingLike): MessagingMissingCredentialEntry { + return { + channelId: binding.channelId, + credentialId: binding.credentialId, + providerName: binding.providerName, + envKey: binding.providerEnvKey, + }; +} + +function compactOutput(result: { readonly stdout?: unknown; readonly stderr?: unknown }): string { + const output = redact(`${String(result.stderr ?? "")}${String(result.stdout ?? "")}`) + .replace(/\r/g, "") + .trim(); + return output || "OpenShell command failed."; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} diff --git a/src/lib/messaging/applier/types.ts b/src/lib/messaging/applier/types.ts new file mode 100644 index 0000000000..96847394e0 --- /dev/null +++ b/src/lib/messaging/applier/types.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelHookFailureMode, + ChannelHookOutputSpec, + ChannelHookPhase, + MessagingAgentId, + MessagingChannelId, + SandboxMessagingHookReferencePlan, + SandboxMessagingPlan, +} from "../manifest"; +import type { MessagingHookInputMap, MessagingHookOutputMap, MessagingHookRunResult } from "../hooks"; + +export const MESSAGING_SETUP_APPLIER_ENV_KEY = "NEMOCLAW_MESSAGING_PLAN_B64"; + +export interface MessagingSetupEnvOptions { + readonly env?: NodeJS.ProcessEnv; + readonly envKey?: string; +} + +export interface MessagingHookApplyRequest { + readonly sandboxName: string; + readonly agent: MessagingAgentId; + readonly channelId: MessagingChannelId; + readonly hookId: string; + readonly phase: ChannelHookPhase; + readonly handler: string; + readonly inputKeys?: readonly string[]; + readonly inputs: MessagingHookInputMap; + readonly outputs?: readonly ChannelHookOutputSpec[]; + readonly onFailure?: ChannelHookFailureMode; +} + +export type MessagingHookApplyRunner = ( + request: MessagingHookApplyRequest, +) => + | void + | MessagingHookRunResult + | { readonly outputs?: MessagingHookOutputMap } + | Promise; + +export interface MessagingOpenShellRunOptions { + readonly ignoreError?: boolean; + readonly env?: Readonly>; + readonly input?: string; + readonly stdio?: readonly unknown[]; +} + +export interface MessagingOpenShellRunResult { + readonly status?: number | null; + readonly stdout?: unknown; + readonly stderr?: unknown; +} + +export type MessagingOpenShellRunner = ( + args: readonly string[], + options?: MessagingOpenShellRunOptions, +) => MessagingOpenShellRunResult; + +export interface MessagingCredentialApplyOptions extends MessagingSetupEnvOptions { + readonly runOpenshell: MessagingOpenShellRunner; +} + +export interface MessagingCredentialApplyResult { + readonly upserted: readonly { + readonly channelId: MessagingChannelId; + readonly credentialId: string; + readonly providerName: string; + readonly envKey: string; + readonly action: "create" | "update"; + }[]; + readonly reused: readonly { + readonly channelId: MessagingChannelId; + readonly credentialId: string; + readonly providerName: string; + readonly envKey: string; + }[]; + readonly missing: readonly { + readonly channelId: MessagingChannelId; + readonly credentialId: string; + readonly providerName: string; + readonly envKey: string; + }[]; + readonly providerNames: readonly string[]; + readonly sandboxCreateProviderArgs: readonly string[]; +} + +export interface MessagingPolicyApplyOptions { + readonly applyPresets: (sandboxName: string, presetNames: string[]) => boolean; +} + +export interface MessagingPolicyApplyResult { + readonly appliedPresets: readonly string[]; +} + +export type MessagingSerializablePlan = SandboxMessagingPlan; +export type MessagingSerializableHook = SandboxMessagingHookReferencePlan; diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 5704200885..e13c93b631 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -37,19 +37,17 @@ function renderJson(manifest: ChannelManifest): string { } function expectTokenPasteEnrollHook(manifest: ChannelManifest, outputIds: readonly string[]): void { - expect(manifest.hooks).toEqual([ - { - id: `${manifest.id}-token-paste`, - phase: "enroll", - handler: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, - outputs: outputIds.map((id) => ({ - id, - kind: "secret", - required: true, - })), - onFailure: "skip-channel", - }, - ]); + expect(manifest.hooks).toContainEqual({ + id: `${manifest.id}-token-paste`, + phase: "enroll", + handler: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + outputs: outputIds.map((id) => ({ + id, + kind: "secret", + required: true, + })), + onFailure: "skip-channel", + }); } describe("built-in channel manifests", () => { @@ -151,6 +149,13 @@ describe("built-in channel manifests", () => { expect(renderJson(telegramManifest)).toContain("channels.telegram.groups"); expect(renderJson(telegramManifest)).toContain("telegramConfig.requireMention"); expectTokenPasteEnrollHook(telegramManifest, ["botToken"]); + expect(telegramManifest.hooks).toContainEqual({ + id: "telegram-reachability", + phase: "reachability-check", + handler: "telegram.getMeReachability", + inputs: ["botToken"], + onFailure: "abort", + }); }); it("declares Discord guild and allowlist render intent for both agents", () => { diff --git a/src/lib/messaging/channels/telegram/hooks/fakes.test.ts b/src/lib/messaging/channels/telegram/hooks/fakes.test.ts new file mode 100644 index 0000000000..26595f9e62 --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/fakes.test.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; +import { telegramManifest } from "../manifest"; +import { + FAKE_TELEGRAM_HOOK_REGISTRATIONS, + TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, +} from "./fakes"; + +describe("Telegram fake hook implementations", () => { + it("declares the reachability hook without exposing the token in outputs", async () => { + const registry = new MessagingHookRegistry(FAKE_TELEGRAM_HOOK_REGISTRATIONS); + const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); + + if (!hook) throw new Error("missing Telegram reachability hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "telegram", + inputs: { + botToken: "123456:telegram-token", + }, + }), + ).resolves.toEqual({ + hookId: "telegram-reachability", + handlerId: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + phase: "reachability-check", + outputs: {}, + }); + }); +}); diff --git a/src/lib/messaging/channels/telegram/hooks/fakes.ts b/src/lib/messaging/channels/telegram/hooks/fakes.ts new file mode 100644 index 0000000000..be72c6385b --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/fakes.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks"; + +export const TELEGRAM_GET_ME_REACHABILITY_HOOK_ID = "telegram.getMeReachability"; + +export const fakeTelegramGetMeReachabilityHook: MessagingHookHandler = (context) => { + const token = context.inputs?.botToken; + if (typeof token !== "string" || token.length === 0) { + throw new Error("Telegram reachability check requires botToken."); + } + return {}; +}; + +export const FAKE_TELEGRAM_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = [ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: fakeTelegramGetMeReachabilityHook, + }, +] as const; diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index a322c9a090..0aa9906acd 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -145,5 +145,12 @@ export const telegramManifest = { ], onFailure: "skip-channel", }, + { + id: "telegram-reachability", + phase: "reachability-check", + handler: "telegram.getMeReachability", + inputs: ["botToken"], + onFailure: "abort", + }, ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/wechat/hooks/fakes.test.ts b/src/lib/messaging/channels/wechat/hooks/fakes.test.ts index ba25417532..428e9f7fac 100644 --- a/src/lib/messaging/channels/wechat/hooks/fakes.test.ts +++ b/src/lib/messaging/channels/wechat/hooks/fakes.test.ts @@ -95,6 +95,13 @@ describe("WeChat fake hook implementations", () => { value: { path: "openclaw.json", merge: { + plugins: { + entries: { + "openclaw-weixin": { + enabled: true, + }, + }, + }, channels: { "openclaw-weixin": { accounts: { diff --git a/src/lib/messaging/channels/wechat/hooks/fakes.ts b/src/lib/messaging/channels/wechat/hooks/fakes.ts index d7a856b0ca..e7876f3bcc 100644 --- a/src/lib/messaging/channels/wechat/hooks/fakes.ts +++ b/src/lib/messaging/channels/wechat/hooks/fakes.ts @@ -15,6 +15,9 @@ const FAKE_WECHAT_BASE_URL = "https://ilinkai.wechat.example"; const FAKE_WECHAT_USER_ID = "fake-wechat-user"; const FAKE_WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN"; const FAKE_WECHAT_SAVED_AT = "2026-01-01T00:00:00.000Z"; +const WECHAT_PLUGIN_ID = "openclaw-weixin"; +const WECHAT_PLUGIN_INSTALL_PATH = "/sandbox/.openclaw/extensions/openclaw-weixin"; +const WECHAT_PLUGIN_SPEC = "@tencent-weixin/openclaw-weixin@2.4.2"; export const fakeWechatIlinkLoginHook: MessagingHookHandler = () => ({ outputs: { @@ -80,14 +83,25 @@ export const fakeWechatSeedOpenClawAccountHook: MessagingHookHandler = (context) path: "openclaw.json", merge: { plugins: { + installs: { + [WECHAT_PLUGIN_ID]: { + source: "npm", + spec: WECHAT_PLUGIN_SPEC, + installPath: WECHAT_PLUGIN_INSTALL_PATH, + }, + }, + load: { + paths: [WECHAT_PLUGIN_INSTALL_PATH], + }, entries: { - "openclaw-weixin": { + [WECHAT_PLUGIN_ID]: { enabled: true, }, }, }, channels: { - "openclaw-weixin": { + [WECHAT_PLUGIN_ID]: { + channelConfigUpdatedAt: FAKE_WECHAT_SAVED_AT, accounts: { [accountId]: { enabled: true, diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index 203e09c81a..6fb465983d 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -135,6 +135,7 @@ export const wechatManifest = { id: "wechat-seed-openclaw-account", phase: "post-agent-install", handler: "wechat.seedOpenClawAccount", + agents: ["openclaw"], inputs: [ "wechatConfig.accountId", "wechatConfig.baseUrl", diff --git a/src/lib/messaging/compiler/engines/agent-render-engine.ts b/src/lib/messaging/compiler/engines/agent-render-engine.ts index 50ca883ede..5e58126811 100644 --- a/src/lib/messaging/compiler/engines/agent-render-engine.ts +++ b/src/lib/messaging/compiler/engines/agent-render-engine.ts @@ -8,7 +8,12 @@ import type { SandboxMessagingJsonRenderPlan, } from "../../manifest"; import type { ManifestCompilerContext } from "../types"; -import { resolveCredentialTemplatesInLines, resolveCredentialTemplatesInValue } from "./template"; +import { + collectTemplateReferencesInLines, + collectTemplateReferencesInValue, + resolveCredentialTemplatesInLines, + resolveCredentialTemplatesInValue, +} from "./template"; export function planAgentRender( manifest: ChannelManifest, @@ -18,6 +23,10 @@ export function planAgentRender( .filter((render) => render.agent === context.agent) .map((render) => { if (render.kind === "json-fragment") { + const value = resolveCredentialTemplatesInValue( + render.fragment.value, + manifest.credentials, + ); return { channelId: manifest.id, renderId: render.id, @@ -25,17 +34,20 @@ export function planAgentRender( agent: render.agent, target: render.target, path: render.fragment.path, - value: resolveCredentialTemplatesInValue(render.fragment.value, manifest.credentials), + value, + templateRefs: collectTemplateReferencesInValue(value), } satisfies SandboxMessagingJsonRenderPlan; } + const lines = resolveCredentialTemplatesInLines(render.lines, manifest.credentials); return { channelId: manifest.id, renderId: render.id, kind: "env-lines", agent: render.agent, target: render.target, - lines: resolveCredentialTemplatesInLines(render.lines, manifest.credentials), + lines, + templateRefs: collectTemplateReferencesInLines(lines), } satisfies SandboxMessagingEnvLinesRenderPlan; }); } diff --git a/src/lib/messaging/compiler/engines/build-step-engine.ts b/src/lib/messaging/compiler/engines/build-step-engine.ts index 2a111751d5..29ec2dcb69 100644 --- a/src/lib/messaging/compiler/engines/build-step-engine.ts +++ b/src/lib/messaging/compiler/engines/build-step-engine.ts @@ -4,12 +4,17 @@ import type { ChannelHookOutputSpec, ChannelManifest, + MessagingAgentId, SandboxMessagingBuildStepPlan, } from "../../manifest"; -export function planBuildSteps(manifest: ChannelManifest): SandboxMessagingBuildStepPlan[] { - return manifest.hooks.flatMap((hook) => - (hook.outputs ?? []) +export function planBuildSteps( + manifest: ChannelManifest, + agent: MessagingAgentId, +): SandboxMessagingBuildStepPlan[] { + return manifest.hooks.flatMap((hook) => { + if (hook.agents && !hook.agents.includes(agent)) return []; + return (hook.outputs ?? []) .filter(isBuildStepOutput) .map((output) => ({ channelId: manifest.id, @@ -18,8 +23,8 @@ export function planBuildSteps(manifest: ChannelManifest): SandboxMessagingBuild handler: hook.handler, outputId: output.id, required: output.required === true, - })), - ); + })); + }); } function isBuildStepOutput( diff --git a/src/lib/messaging/compiler/engines/template.ts b/src/lib/messaging/compiler/engines/template.ts index daf77d7d1f..1400029c81 100644 --- a/src/lib/messaging/compiler/engines/template.ts +++ b/src/lib/messaging/compiler/engines/template.ts @@ -9,6 +9,7 @@ import type { const CREDENTIAL_PLACEHOLDER_PATTERN = /\{\{\s*credential\.([A-Za-z0-9_-]+)\.placeholder\s*\}\}/g; +const TEMPLATE_REFERENCE_PATTERN = /\{\{\s*([^}]+?)\s*\}\}/g; export function resolveSandboxNameTemplate( value: MessagingTemplateString, @@ -43,6 +44,27 @@ export function resolveCredentialTemplatesInLines( return lines.map((line) => resolveCredentialTemplatesInString(line, credentials)); } +export function collectTemplateReferencesInValue( + value: MessagingSerializableValue, +): string[] { + if (typeof value === "string") return collectTemplateReferencesInString(value); + if (Array.isArray(value)) { + return unique(value.flatMap((entry) => collectTemplateReferencesInValue(entry))); + } + if (value && typeof value === "object") { + return unique( + Object.values(value).flatMap((entry) => collectTemplateReferencesInValue(entry)), + ); + } + return []; +} + +export function collectTemplateReferencesInLines( + lines: readonly MessagingTemplateString[], +): string[] { + return unique(lines.flatMap((line) => collectTemplateReferencesInString(line))); +} + function resolveCredentialTemplatesInString( value: MessagingTemplateString, credentials: readonly ChannelCredentialSpec[], @@ -52,3 +74,15 @@ function resolveCredentialTemplatesInString( return credential?.placeholder ?? match; }); } + +function collectTemplateReferencesInString(value: MessagingTemplateString): string[] { + return unique( + [...value.matchAll(TEMPLATE_REFERENCE_PATTERN)] + .map((match) => match[1]?.trim()) + .filter((reference): reference is string => typeof reference === "string" && reference.length > 0), + ); +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/src/lib/messaging/compiler/index.ts b/src/lib/messaging/compiler/index.ts index 6ca6b7529d..ae24e2779a 100644 --- a/src/lib/messaging/compiler/index.ts +++ b/src/lib/messaging/compiler/index.ts @@ -2,4 +2,5 @@ // SPDX-License-Identifier: Apache-2.0 export * from "./manifest-compiler"; +export * from "./workflow-planner"; export type * from "./types"; diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 0728af68eb..3f5482f03d 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { createBuiltInChannelManifestRegistry } from "../channels"; +import { FAKE_TELEGRAM_HOOK_REGISTRATIONS } from "../channels/telegram/hooks/fakes"; import { FAKE_WECHAT_HOOK_REGISTRATIONS } from "../channels/wechat/hooks/fakes"; import { MessagingHookRegistry } from "../hooks"; import { FAKE_COMMON_HOOK_REGISTRATIONS } from "../hooks/common"; @@ -21,6 +22,7 @@ function compiler(): ManifestCompiler { createBuiltInChannelManifestRegistry(), new MessagingHookRegistry([ ...FAKE_COMMON_HOOK_REGISTRATIONS, + ...FAKE_TELEGRAM_HOOK_REGISTRATIONS, ...FAKE_WECHAT_HOOK_REGISTRATIONS, ]), ); @@ -75,7 +77,7 @@ describe("ManifestCompiler", () => { const plan = await compiler().compile({ sandboxName: "demo", agent: "openclaw", - workflow: "create", + workflow: "onboard", isInteractive: true, selectedChannels: ["slack", "telegram", "wechat", "discord", "whatsapp"], credentialAvailability: { @@ -182,6 +184,11 @@ describe("ManifestCompiler", () => { expect(plan.healthChecks.every((check) => check.requiredBefore === "lifecycle-success")).toBe( true, ); + expect( + plan.agentRender.find( + (render) => render.channelId === "telegram" && render.kind === "json-fragment", + )?.templateRefs, + ).toEqual(expect.arrayContaining(["proxyUrl", "allowedIds.telegram.values"])); }); it("compiles Hermes render and WeChat agent policy alias intent", async () => { @@ -211,6 +218,7 @@ describe("ManifestCompiler", () => { expect(JSON.stringify(plan.agentRender)).toContain( "WEIXIN_TOKEN=openshell:resolve:env:WECHAT_BOT_TOKEN", ); + expect(plan.buildSteps).toEqual([]); expect( plan.channels .find((channel) => channel.channelId === "wechat") @@ -222,7 +230,7 @@ describe("ManifestCompiler", () => { const plan = await compiler().compile({ sandboxName: "demo", agent: "openclaw", - workflow: "create", + workflow: "onboard", isInteractive: true, selectedChannels: ["wechat", "telegram"], }); @@ -263,7 +271,7 @@ describe("ManifestCompiler", () => { ).compile({ sandboxName: "demo", agent: "openclaw", - workflow: "create", + workflow: "onboard", isInteractive: false, selectedChannels: ["telegram"], credentialAvailability: { @@ -287,7 +295,7 @@ describe("ManifestCompiler", () => { const plan = await compiler().compile({ sandboxName: "demo", agent: "openclaw", - workflow: "create", + workflow: "onboard", isInteractive: false, selectedChannels: ["telegram"], }); @@ -309,7 +317,7 @@ describe("ManifestCompiler", () => { const context = { sandboxName: "demo", agent: "openclaw", - workflow: "create", + workflow: "onboard", isInteractive: false, selectedChannels: ["telegram"], credentialAvailability: { @@ -344,7 +352,7 @@ describe("ManifestCompiler", () => { const plan = await compiler().compile({ sandboxName: "demo", agent: "openclaw", - workflow: "stop", + workflow: "stop-channel", isInteractive: false, selectedChannels: [], configuredChannels: ["telegram"], @@ -367,6 +375,7 @@ describe("ManifestCompiler", () => { }); it("compiles a non-built-in channel manifest through the same generic path", async () => { + const hookCalls: string[] = []; const customManifest = { schemaVersion: 1, id: "matrix", @@ -419,23 +428,40 @@ describe("ManifestCompiler", () => { }, ], }, + { + id: "matrix-host-probe", + phase: "reachability-check", + handler: "matrix.probeHost", + inputs: ["roomId"], + onFailure: "abort", + }, ], } as const satisfies ChannelManifest; const hooks = new MessagingHookRegistry([ { id: "matrix.enroll", - handler: () => ({ - outputs: { - accessToken: { - kind: "secret", - value: "raw-matrix-token", - }, - roomId: { - kind: "config", - value: "!room:example.com", + handler: () => { + hookCalls.push("enroll"); + return { + outputs: { + accessToken: { + kind: "secret", + value: "raw-matrix-token", + }, + roomId: { + kind: "config", + value: "!room:example.com", + }, }, - }, - }), + }; + }, + }, + { + id: "matrix.probeHost", + handler: (context) => { + hookCalls.push(`reachability:${String(context.inputs?.roomId)}`); + return {}; + }, }, ]); const plan = await new ManifestCompiler( @@ -444,7 +470,7 @@ describe("ManifestCompiler", () => { ).compile({ sandboxName: "demo", agent: "openclaw", - workflow: "create", + workflow: "onboard", isInteractive: true, selectedChannels: ["matrix"], }); @@ -475,6 +501,16 @@ describe("ManifestCompiler", () => { source: "manifest", }, ]); + expect(plan.channels[0]?.hooks).toContainEqual( + expect.objectContaining({ + phase: "reachability-check", + handler: "matrix.probeHost", + }), + ); + expect(hookCalls).toEqual([ + "enroll", + "reachability:!room:example.com", + ]); expect(JSON.stringify(plan)).not.toContain("raw-matrix-token"); }); }); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 9fa09a97ac..d4f2d8615c 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { + ChannelHookSpec, ChannelInputSpec, ChannelManifest, ChannelManifestRegistry, @@ -14,7 +15,11 @@ import type { SandboxMessagingPlan, } from "../manifest"; import { MessagingHookRegistry, runMessagingHook } from "../hooks"; -import type { MessagingHookInputMap, MessagingHookOutputMap } from "../hooks"; +import type { + MessagingHookInputMap, + MessagingHookOutputMap, + MessagingHookRunResult, +} from "../hooks"; import { planAgentRender } from "./engines/agent-render-engine"; import { planBuildSteps } from "./engines/build-step-engine"; import { planCredentialBindings } from "./engines/credential-binding-engine"; @@ -30,7 +35,7 @@ export class ManifestCompiler { ) {} async compile(context: ManifestCompilerContext): Promise { - const manifests = this.resolveRequestedManifests(context); + const manifests = this.resolveManifests(requestedChannelIds(context), context); const channels = []; for (const manifest of manifests) { channels.push(await this.compileChannel(manifest, context)); @@ -41,6 +46,18 @@ export class ManifestCompiler { const activeManifests = manifests.filter((manifest) => isChannelActive(manifest.id, context), ); + const credentialBindings = activeManifests.flatMap((manifest) => + planCredentialBindings(manifest, context, inputRegistry.get(manifest.id) ?? []), + ); + const networkPolicy = planNetworkPolicy(activeManifests, context); + const agentRender = activeManifests.flatMap((manifest) => + planAgentRender(manifest, context), + ); + const buildSteps = activeManifests.flatMap((manifest) => + planBuildSteps(manifest, context.agent), + ); + const stateUpdates = activeManifests.flatMap((manifest) => planStateUpdates(manifest)); + const healthChecks = activeManifests.flatMap((manifest) => planHealthChecks(manifest)); return { schemaVersion: 1, @@ -48,22 +65,20 @@ export class ManifestCompiler { agent: context.agent, workflow: context.workflow, channels, - credentialBindings: activeManifests.flatMap((manifest) => - planCredentialBindings(manifest, context, inputRegistry.get(manifest.id) ?? []), - ), - networkPolicy: planNetworkPolicy(activeManifests, context), - agentRender: activeManifests.flatMap((manifest) => planAgentRender(manifest, context)), - buildSteps: activeManifests.flatMap((manifest) => planBuildSteps(manifest)), - stateUpdates: activeManifests.flatMap((manifest) => planStateUpdates(manifest)), - healthChecks: activeManifests.flatMap((manifest) => planHealthChecks(manifest)), + credentialBindings, + networkPolicy, + agentRender, + buildSteps, + stateUpdates, + healthChecks, }; } - private resolveRequestedManifests(context: ManifestCompilerContext): ChannelManifest[] { - const requestedIds = new Set([ - ...context.selectedChannels, - ...(context.configuredChannels ?? []), - ]); + private resolveManifests( + channelIds: readonly MessagingChannelId[], + context: ManifestCompilerContext, + ): ChannelManifest[] { + const requestedIds = new Set(channelIds); const supportedIds = context.supportedChannelIds && context.supportedChannelIds.length > 0 ? new Set(context.supportedChannelIds) @@ -102,13 +117,33 @@ export class ManifestCompiler { configured, disabled, inputs: await resolveChannelInputs(manifest, context, this.hooks, { - runEnrollment: active && context.workflow === "create" && context.isInteractive, + runEnrollment: + selected && active && isEnrollmentWorkflow(context.workflow) && context.isInteractive, + runEnrollmentChecks: selected && active && isEnrollmentWorkflow(context.workflow), }), - hooks: manifest.hooks.map((hook) => cloneHookReference(manifest.id, hook)), + hooks: manifest.hooks + .filter((hook) => isHookForAgent(hook, context.agent)) + .map((hook) => cloneHookReference(manifest.id, hook)), }; } } +function isHookForAgent(hook: ChannelHookSpec, agent: ManifestCompilerContext["agent"]): boolean { + return !hook.agents || hook.agents.includes(agent); +} + +function requestedChannelIds(context: ManifestCompilerContext): MessagingChannelId[] { + return uniqueChannels([...context.selectedChannels, ...(context.configuredChannels ?? [])]); +} + +function uniqueChannels(channelIds: readonly MessagingChannelId[]): MessagingChannelId[] { + return [...new Set(channelIds)]; +} + +function isEnrollmentWorkflow(workflow: ManifestCompilerContext["workflow"]): boolean { + return workflow === "onboard" || workflow === "add-channel"; +} + function isChannelActive( channelId: MessagingChannelId, context: ManifestCompilerContext, @@ -129,6 +164,7 @@ function cloneHookReference( id: hook.id, phase: hook.phase, handler: hook.handler, + agents: hook.agents ? [...hook.agents] : undefined, inputs: hook.inputs ? [...hook.inputs] : undefined, outputs: hook.outputs?.map((output) => ({ ...output })), onFailure: hook.onFailure, @@ -139,22 +175,21 @@ async function resolveChannelInputs( manifest: ChannelManifest, context: ManifestCompilerContext, hooks: MessagingHookRegistry, - options: { readonly runEnrollment: boolean }, + options: { readonly runEnrollment: boolean; readonly runEnrollmentChecks: boolean }, ): Promise { let inputs = manifest.inputs.map((input) => resolveChannelInput(manifest, input, context)); + let hookInputs = buildCompilerHookInputs(manifest, inputs); + inputs = applyCredentialAvailability(manifest, inputs, context); const enrollmentHooks = options.runEnrollment - ? manifest.hooks.filter((hook) => hook.phase === "enroll") + ? manifest.hooks + .filter((hook) => isHookForAgent(hook, context.agent)) + .filter((hook) => hook.phase === "enroll") : []; - if (enrollmentHooks.length === 0) { - return applyCredentialAvailability(manifest, inputs, context); - } - for (const hook of enrollmentHooks) { - const result = await runMessagingHook(hook, hooks, { - channelId: manifest.id, - inputs: toHookInputMap(inputs), - }); + const result = await runCompilerHook(manifest, hook, hooks, hookInputs); + if (!result) continue; + hookInputs = mergeHookOutputsIntoInputs(manifest, hookInputs, result.outputs); inputs = applyCredentialAvailability( manifest, mergeEnrollmentOutputs(inputs, result.outputs), @@ -162,9 +197,35 @@ async function resolveChannelInputs( ); } + if (options.runEnrollmentChecks && hasRequiredInputsAvailable(manifest, inputs)) { + for (const hook of manifest.hooks + .filter((entry) => isHookForAgent(entry, context.agent)) + .filter((entry) => entry.phase === "reachability-check") + .filter((entry) => hasDeclaredHookInputs(hookInputs, entry))) { + await runCompilerHook(manifest, hook, hooks, hookInputs); + } + } + return inputs; } +async function runCompilerHook( + manifest: ChannelManifest, + hook: ChannelHookSpec, + hooks: MessagingHookRegistry, + inputs: MessagingHookInputMap, +): Promise { + try { + return await runMessagingHook(hook, hooks, { + channelId: manifest.id, + inputs: selectDeclaredHookInputs(hook, inputs), + }); + } catch (error) { + if (hook.onFailure === "skip-channel") return null; + throw error; + } +} + function resolveChannelInput( manifest: ChannelManifest, input: ChannelInputSpec, @@ -237,18 +298,72 @@ function applyCredentialAvailability( }); } -function toHookInputMap( +function hasRequiredInputsAvailable( + manifest: ChannelManifest, + inputs: readonly SandboxMessagingInputReference[], +): boolean { + const byId = new Map(inputs.map((input) => [input.inputId, input])); + return manifest.inputs.every((input) => { + if (!input.required) return true; + const resolved = byId.get(input.id); + if (!resolved) return false; + return resolved.kind === "secret" + ? resolved.credentialAvailable === true + : resolved.value !== undefined; + }); +} + +function buildCompilerHookInputs( + manifest: ChannelManifest, inputs: readonly SandboxMessagingInputReference[], -): MessagingHookInputMap { +): Record { + const inputSpecs = new Map(manifest.inputs.map((input) => [input.id, input])); const entries: Array<[string, MessagingSerializableValue]> = []; for (const input of inputs) { - if (input.value === undefined) continue; - entries.push([input.inputId, input.value]); - if (input.statePath) entries.push([input.statePath, input.value]); + const spec = inputSpecs.get(input.inputId); + const value = input.value ?? (spec ? readInputEnvValue(spec) : undefined); + if (value === undefined) continue; + entries.push([input.inputId, value]); + if (input.statePath) entries.push([input.statePath, value]); } return Object.fromEntries(entries); } +function mergeHookOutputsIntoInputs( + manifest: ChannelManifest, + inputs: Record, + outputs: MessagingHookOutputMap, +): Record { + const next = { ...inputs }; + const inputSpecs = new Map(manifest.inputs.map((input) => [input.id, input])); + for (const [outputId, output] of Object.entries(outputs)) { + if (output.kind !== "secret" && output.kind !== "config") continue; + next[outputId] = output.value; + const statePath = inputSpecs.get(outputId)?.statePath; + if (statePath) next[statePath] = output.value; + } + return next; +} + +function hasDeclaredHookInputs( + inputs: MessagingHookInputMap, + hook: ChannelHookSpec, +): boolean { + return (hook.inputs ?? []).every((inputKey) => Object.hasOwn(inputs, inputKey)); +} + +function selectDeclaredHookInputs( + hook: ChannelHookSpec, + inputs: MessagingHookInputMap, +): MessagingHookInputMap | undefined { + if (!hook.inputs || hook.inputs.length === 0) return undefined; + return Object.fromEntries( + hook.inputs + .filter((inputKey) => Object.hasOwn(inputs, inputKey)) + .map((inputKey) => [inputKey, inputs[inputKey] as MessagingSerializableValue]), + ); +} + function mergeEnrollmentOutputs( inputs: readonly SandboxMessagingInputReference[], outputs: MessagingHookOutputMap, diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts new file mode 100644 index 0000000000..3fb501ad59 --- /dev/null +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -0,0 +1,315 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { createBuiltInChannelManifestRegistry } from "../channels"; +import { FAKE_TELEGRAM_HOOK_REGISTRATIONS } from "../channels/telegram/hooks/fakes"; +import { FAKE_WECHAT_HOOK_REGISTRATIONS } from "../channels/wechat/hooks/fakes"; +import { MessagingHookRegistry } from "../hooks"; +import { FAKE_COMMON_HOOK_REGISTRATIONS } from "../hooks/common"; +import { MessagingWorkflowPlanner } from "./workflow-planner"; + +function planner(): MessagingWorkflowPlanner { + return new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + new MessagingHookRegistry([ + ...FAKE_COMMON_HOOK_REGISTRATIONS, + ...FAKE_TELEGRAM_HOOK_REGISTRATIONS, + ...FAKE_WECHAT_HOOK_REGISTRATIONS, + ]), + ); +} + +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 []; +} + +async function withEnv( + values: Readonly>, + run: () => Promise, +): Promise { + const previous = Object.fromEntries( + Object.keys(values).map((key) => [key, process.env[key]]), + ); + try { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return await run(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +describe("MessagingWorkflowPlanner", () => { + it("plans onboard as selected, configured, active channels with enrollment inputs", async () => { + const plan = await planner().planOnboard({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: true, + selectedChannels: ["wechat", "telegram"], + }); + + expect(plan.workflow).toBe("onboard"); + expect(plan.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + "wechat", + ]); + expect(plan.channels).toEqual([ + expect.objectContaining({ + channelId: "telegram", + active: true, + selected: true, + configured: true, + disabled: false, + }), + expect.objectContaining({ + channelId: "wechat", + active: true, + selected: true, + configured: true, + disabled: false, + }), + ]); + expect( + plan.channels + .find((channel) => channel.channelId === "wechat") + ?.inputs.find((input) => input.inputId === "accountId"), + ).toMatchObject({ + kind: "config", + value: "fake-wechat-account", + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ + "telegram", + "wechat", + ]); + }); + + it("plans add-channel as a configured active target and clears stale disabled state", async () => { + const plan = await planner().planAddChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: true, + channelId: "slack", + configuredChannels: ["telegram"], + disabledChannels: ["telegram", "slack"], + }); + + expect(plan.workflow).toBe("add-channel"); + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + configured: true, + disabled: true, + active: false, + selected: false, + }); + expect(plan.channels.find((channel) => channel.channelId === "slack")).toMatchObject({ + configured: true, + disabled: false, + active: true, + selected: true, + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["slack"]); + }); + + it("runs add-channel enrollment only for the selected channel", async () => { + const hooks = new MessagingHookRegistry([ + { + id: "common.tokenPaste", + handler: (context) => { + if (context.channelId === "telegram") { + throw new Error("existing channels should not re-enroll"); + } + const outputs: Record = {}; + for (const output of context.outputDeclarations ?? []) { + if (output.kind === "secret") { + outputs[output.id] = { + kind: "secret", + value: `fake-${context.channelId}-${output.id}`, + }; + } + } + return { outputs }; + }, + }, + ]); + const plan = await new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + hooks, + ).planAddChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: true, + channelId: "slack", + configuredChannels: ["telegram"], + }); + + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + active: true, + selected: false, + }); + expect( + plan.channels + .find((channel) => channel.channelId === "slack") + ?.inputs.filter((input) => input.kind === "secret") + .every((input) => input.credentialAvailable === true), + ).toBe(true); + }); + + it("plans stop-channel by keeping configured state and disabling only that channel", async () => { + const plan = await planner().planStopChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + channelId: "telegram", + configuredChannels: ["telegram", "slack"], + }); + + expect(plan.workflow).toBe("stop-channel"); + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + configured: true, + disabled: true, + active: false, + selected: true, + }); + expect(plan.channels.find((channel) => channel.channelId === "slack")).toMatchObject({ + configured: true, + disabled: false, + active: true, + selected: false, + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["slack"]); + expect( + plan.credentialBindings.some((binding) => binding.channelId === "telegram"), + ).toBe(false); + }); + + it("plans start-channel by preserving configured state and making the channel active", async () => { + const plan = await planner().planStartChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + channelId: "telegram", + configuredChannels: ["telegram", "slack"], + disabledChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }); + + expect(plan.workflow).toBe("start-channel"); + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + configured: true, + disabled: false, + active: true, + selected: true, + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ + "telegram", + "slack", + ]); + }); + + it("plans remove-channel by deleting configured and disabled state", async () => { + const plan = await planner().planRemoveChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + channelId: "telegram", + configuredChannels: ["telegram", "wechat", "slack"], + disabledChannels: ["telegram", "wechat"], + }); + + expect(plan.workflow).toBe("remove-channel"); + expect(plan.channels.map((channel) => channel.channelId)).toEqual(["wechat", "slack"]); + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toBeUndefined(); + expect(plan.channels.find((channel) => channel.channelId === "wechat")).toMatchObject({ + configured: true, + disabled: true, + active: false, + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["slack"]); + }); + + it("plans rebuild from configured and disabled registry snapshots", async () => { + const plan = await planner().planRebuild({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + configuredChannels: ["telegram", "discord", "wechat"], + disabledChannels: ["discord"], + }); + + expect(plan.workflow).toBe("rebuild"); + expect(plan.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + "discord", + "wechat", + ]); + expect(plan.channels.find((channel) => channel.channelId === "discord")).toMatchObject({ + configured: true, + disabled: true, + active: false, + selected: false, + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ + "telegram", + "wechat", + ]); + }); + + it("reports unsupported channels deterministically before compiling", async () => { + await expect( + planner().planOnboard({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + selectedChannels: ["slack", "discord"], + supportedChannelIds: ["telegram"], + }), + ).rejects.toThrow("Unsupported messaging channel(s) for openclaw: discord, slack"); + }); + + it("returns serializable, secret-free plans suitable for dry-run and shadow output", async () => { + await withEnv( + { + TELEGRAM_BOT_TOKEN: "123456:raw-telegram-token", + }, + async () => { + const plan = await planner().planAddChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + channelId: "telegram", + }); + const serialized = JSON.stringify(plan); + + expect(JSON.parse(serialized)).toEqual(plan); + expect(findFunctionPaths(plan)).toEqual([]); + expect(serialized).toContain("openshell:resolve:env:TELEGRAM_BOT_TOKEN"); + expect(serialized).not.toContain("123456:raw-telegram-token"); + }, + ); + }); +}); diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts new file mode 100644 index 0000000000..d10b6ba6f2 --- /dev/null +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { MessagingHookRegistry } from "../hooks"; +import type { + ChannelManifestRegistry, + MessagingAgentId, + MessagingChannelId, + MessagingCompilerWorkflow, + SandboxMessagingPlan, +} from "../manifest"; +import { ManifestCompiler } from "./manifest-compiler"; +import type { + ManifestCompilerContext, + MessagingCompilerCredentialAvailability, +} from "./types"; + +export interface MessagingWorkflowPlannerBaseContext { + readonly sandboxName: string; + readonly agent: MessagingAgentId; + readonly isInteractive: boolean; + readonly configuredChannels?: readonly MessagingChannelId[]; + readonly disabledChannels?: readonly MessagingChannelId[]; + readonly supportedChannelIds?: readonly MessagingChannelId[]; + readonly credentialAvailability?: MessagingCompilerCredentialAvailability; +} + +export interface MessagingWorkflowPlannerOnboardContext + extends MessagingWorkflowPlannerBaseContext { + readonly selectedChannels: readonly MessagingChannelId[]; +} + +export interface MessagingWorkflowPlannerChannelContext + extends MessagingWorkflowPlannerBaseContext { + readonly channelId: MessagingChannelId; +} + +export class MessagingWorkflowPlanner { + private readonly compiler: ManifestCompiler; + + constructor( + private readonly registry: ChannelManifestRegistry, + hooks = new MessagingHookRegistry(), + ) { + this.compiler = new ManifestCompiler(registry, hooks); + } + + async planOnboard( + context: MessagingWorkflowPlannerOnboardContext, + ): Promise { + const selectedChannels = uniqueChannels(context.selectedChannels); + this.assertSupportedChannels(selectedChannels, context); + + return this.compileWorkflow(context, { + workflow: "onboard", + selectedChannels, + configuredChannels: selectedChannels, + disabledChannels: [], + }); + } + + async planAddChannel( + context: MessagingWorkflowPlannerChannelContext, + ): Promise { + const configuredChannels = addChannels(context.configuredChannels, [context.channelId]); + const disabledChannels = removeChannels( + onlyConfiguredChannels(context.disabledChannels, configuredChannels), + [context.channelId], + ); + this.assertSupportedChannels([...configuredChannels, context.channelId], context); + + return this.compileWorkflow(context, { + workflow: "add-channel", + selectedChannels: [context.channelId], + configuredChannels, + disabledChannels, + }); + } + + async planRemoveChannel( + context: MessagingWorkflowPlannerChannelContext, + ): Promise { + const configuredChannels = removeChannels(context.configuredChannels, [context.channelId]); + const disabledChannels = removeChannels( + onlyConfiguredChannels(context.disabledChannels, configuredChannels), + [context.channelId], + ); + this.assertSupportedChannels([...configuredChannels, context.channelId], context); + + return this.compileWorkflow(context, { + workflow: "remove-channel", + selectedChannels: [], + configuredChannels, + disabledChannels, + }); + } + + async planStartChannel( + context: MessagingWorkflowPlannerChannelContext, + ): Promise { + const configuredChannels = uniqueChannels(context.configuredChannels); + const selectedChannels = configuredChannels.includes(context.channelId) + ? [context.channelId] + : []; + const disabledChannels = removeChannels( + onlyConfiguredChannels(context.disabledChannels, configuredChannels), + [context.channelId], + ); + this.assertSupportedChannels([...configuredChannels, context.channelId], context); + + return this.compileWorkflow(context, { + workflow: "start-channel", + selectedChannels, + configuredChannels, + disabledChannels, + }); + } + + async planStopChannel( + context: MessagingWorkflowPlannerChannelContext, + ): Promise { + const configuredChannels = uniqueChannels(context.configuredChannels); + const selectedChannels = configuredChannels.includes(context.channelId) + ? [context.channelId] + : []; + const disabledChannels = configuredChannels.includes(context.channelId) + ? addChannels(onlyConfiguredChannels(context.disabledChannels, configuredChannels), [ + context.channelId, + ]) + : onlyConfiguredChannels(context.disabledChannels, configuredChannels); + this.assertSupportedChannels([...configuredChannels, context.channelId], context); + + return this.compileWorkflow(context, { + workflow: "stop-channel", + selectedChannels, + configuredChannels, + disabledChannels, + }); + } + + async planRebuild( + context: MessagingWorkflowPlannerBaseContext, + ): Promise { + const configuredChannels = uniqueChannels(context.configuredChannels); + const disabledChannels = onlyConfiguredChannels(context.disabledChannels, configuredChannels); + this.assertSupportedChannels(configuredChannels, context); + + return this.compileWorkflow(context, { + workflow: "rebuild", + selectedChannels: [], + configuredChannels, + disabledChannels, + }); + } + + private compileWorkflow( + context: MessagingWorkflowPlannerBaseContext, + workflow: { + readonly workflow: MessagingCompilerWorkflow; + readonly selectedChannels: readonly MessagingChannelId[]; + readonly configuredChannels: readonly MessagingChannelId[]; + readonly disabledChannels: readonly MessagingChannelId[]; + }, + ): Promise { + const compilerContext: ManifestCompilerContext = { + sandboxName: context.sandboxName, + agent: context.agent, + isInteractive: context.isInteractive, + workflow: workflow.workflow, + selectedChannels: workflow.selectedChannels, + configuredChannels: workflow.configuredChannels, + disabledChannels: workflow.disabledChannels, + supportedChannelIds: context.supportedChannelIds, + credentialAvailability: context.credentialAvailability, + }; + return this.compiler.compile(compilerContext); + } + + private assertSupportedChannels( + channelIds: readonly MessagingChannelId[], + context: Pick< + MessagingWorkflowPlannerBaseContext, + "agent" | "supportedChannelIds" + >, + ): void { + const supportedIds = new Set(this.supportedChannelIds(context)); + const unsupportedIds = uniqueChannels(channelIds) + .filter((channelId) => !supportedIds.has(channelId)) + .sort(); + + if (unsupportedIds.length > 0) { + throw new Error( + `Unsupported messaging channel(s) for ${context.agent}: ${unsupportedIds.join(", ")}`, + ); + } + } + + private supportedChannelIds( + context: Pick< + MessagingWorkflowPlannerBaseContext, + "agent" | "supportedChannelIds" + >, + ): MessagingChannelId[] { + const supportedFilter = + context.supportedChannelIds && context.supportedChannelIds.length > 0 + ? new Set(context.supportedChannelIds) + : null; + + return this.registry + .list() + .filter((manifest) => manifest.supportedAgents.includes(context.agent)) + .filter((manifest) => !supportedFilter || supportedFilter.has(manifest.id)) + .map((manifest) => manifest.id); + } +} + +function uniqueChannels( + channelIds: readonly MessagingChannelId[] | undefined, +): MessagingChannelId[] { + return [...new Set(channelIds ?? [])]; +} + +function addChannels( + current: readonly MessagingChannelId[] | undefined, + additions: readonly MessagingChannelId[], +): MessagingChannelId[] { + return uniqueChannels([...(current ?? []), ...additions]); +} + +function removeChannels( + current: readonly MessagingChannelId[] | undefined, + removals: readonly MessagingChannelId[], +): MessagingChannelId[] { + const remove = new Set(removals); + return uniqueChannels(current).filter((channelId) => !remove.has(channelId)); +} + +function onlyConfiguredChannels( + channelIds: readonly MessagingChannelId[] | undefined, + configuredChannels: readonly MessagingChannelId[], +): MessagingChannelId[] { + const configured = new Set(configuredChannels); + return uniqueChannels(channelIds).filter((channelId) => configured.has(channelId)); +} diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts index 0bb132c5af..0f2c562802 100644 --- a/src/lib/messaging/index.ts +++ b/src/lib/messaging/index.ts @@ -4,4 +4,5 @@ export * from "./channels"; export * from "./compiler"; export * from "./hooks"; +export * from "./applier"; export * from "./manifest"; diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index c028e791da..b948f13182 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -189,7 +189,7 @@ const telegramPlan = { schemaVersion: 1, sandboxName: "demo", agent: "openclaw", - workflow: "create", + workflow: "onboard", channels: [ { channelId: "telegram", @@ -254,6 +254,7 @@ const telegramPlan = { botToken: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", enabled: true, }, + templateRefs: [], }, ], buildSteps: [], diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 0449e32b2b..5d27160856 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -135,9 +135,12 @@ export interface ChannelRebuildHydrationSpec { /** Lifecycle phase where a referenced hook may run. */ export type ChannelHookPhase = | "enroll" + | "reachability-check" | "apply" | "post-agent-install" - | "health-check"; + | "health-check" + | "diagnostic" + | "status"; /** How the planner/applier should treat a hook failure. */ export type ChannelHookFailureMode = "abort" | "skip-channel"; @@ -147,6 +150,7 @@ export interface ChannelHookSpec { readonly id: string; readonly phase: ChannelHookPhase; readonly handler: string; + readonly agents?: readonly MessagingAgentId[]; readonly inputs?: readonly string[]; readonly outputs?: readonly ChannelHookOutputSpec[]; readonly onFailure?: ChannelHookFailureMode; @@ -175,7 +179,13 @@ export interface SandboxMessagingPlan { } /** Workflow that requested a compiled messaging plan. */ -export type MessagingCompilerWorkflow = "create" | "rebuild" | "start" | "stop"; +export type MessagingCompilerWorkflow = + | "onboard" + | "add-channel" + | "remove-channel" + | "start-channel" + | "stop-channel" + | "rebuild"; /** Compiled metadata for one requested channel. */ export interface SandboxMessagingChannelPlan { @@ -249,6 +259,7 @@ export interface SandboxMessagingJsonRenderPlan readonly kind: "json-fragment"; readonly path: MessagingStatePath; readonly value: MessagingSerializableValue; + readonly templateRefs: readonly string[]; } /** Compiled env-file lines ready for an applier/render engine. */ @@ -256,6 +267,7 @@ export interface SandboxMessagingEnvLinesRenderPlan extends SandboxMessagingAgentRenderBasePlan { readonly kind: "env-lines"; readonly lines: readonly MessagingTemplateString[]; + readonly templateRefs: readonly string[]; } /** Build-time input the applier may pass into sandbox create/rebuild. */