diff --git a/src/commands/credentials/reset.ts b/src/commands/credentials/reset.ts index aaccf3f4f3..ab8f78c6aa 100644 --- a/src/commands/credentials/reset.ts +++ b/src/commands/credentials/reset.ts @@ -2,12 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { Args } from "@oclif/core"; +import { runOpenshellProviderCommand } from "../../lib/actions/global"; +import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../lib/adapters/openshell/timeouts"; import { CLI_NAME } from "../../lib/cli/branding"; import { yesFlag } from "../../lib/cli/common-flags"; import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; - -import { runOpenshellProviderCommand } from "../../lib/actions/global"; -import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../lib/adapters/openshell/timeouts"; import { isBridgeProviderName, recoverGatewayOrExit } from "../../lib/credentials/command-support"; import { prompt as askPrompt } from "../../lib/credentials/store"; @@ -40,7 +39,7 @@ export default class CredentialsResetCommand extends NemoClawCommand { if (isBridgeProviderName(key)) { this.failWithLines([ ` '${key}' is a per-sandbox messaging bridge, not a credential.`, - ` Use \`${CLI_NAME} channels remove \` to retire`, + ` Use \`${CLI_NAME} channels remove \` to retire`, " the integration (it tears down the bridge provider and rebuilds the sandbox),", ` or \`${CLI_NAME} channels stop <…>\` to pause it without clearing tokens.`, ]); diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 5ed60b46d1..1913f5b27c 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -181,6 +181,7 @@ export const discordManifest = { ], runtime: { openclaw: { + channelName: "discord", visibility: { configKeys: ["discord"], logPatterns: ["discord"], diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index cb772a4488..d12299f297 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -175,7 +175,9 @@ function expectOpenClawRuntimeVisibility( manifest: ChannelManifest, configKeys: readonly string[], logPatterns: readonly string[], + channelName = configKeys[0], ): void { + expect(manifest.runtime?.openclaw?.channelName).toBe(channelName); expect(manifest.runtime?.openclaw?.visibility).toEqual({ configKeys, logPatterns, diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index 98a5319cb8..7eaf2f8d58 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -13,9 +13,11 @@ import { getMessagingPolicyPresetValidationWarnings, getMessagingProviderSuffixesByChannel, listAvailableMessagingChannelIds, + listMessagingChannelsWithoutCredentials, listMessagingConfigEnvKeys, listMessagingPackageInstallSpecs, listMessagingProviderNamesForChannel, + listOpenClawManagedChannelNames, listOpenClawRuntimeChannelMetadata, listRequiredCreateTimeMessagingPolicyPresetNames, } from "./metadata"; @@ -58,6 +60,7 @@ describe("built-in messaging channel metadata", () => { "demo-slack-bridge", "demo-slack-app", ]); + expect(listMessagingChannelsWithoutCredentials()).toEqual(["whatsapp"]); }); it("resolves config env keys and aliases from manifest inputs", () => { @@ -100,6 +103,13 @@ describe("built-in messaging channel metadata", () => { expect(getMessagingPolicyPresetValidationWarnings().discord).toContain( "https://discord.com/api/v10/gateway or validate the configured", ); + expect(listOpenClawManagedChannelNames()).toEqual([ + "telegram", + "discord", + "openclaw-weixin", + "slack", + "whatsapp", + ]); expect( Object.fromEntries( listOpenClawRuntimeChannelMetadata().map((entry) => [entry.channelId, entry.configKeys]), @@ -153,6 +163,51 @@ describe("built-in messaging channel metadata", () => { ]); }); + it("derives OpenClaw managed channel names from explicit runtime metadata", () => { + const manifests: ChannelManifest[] = [ + { + ...manifestWithPreset("matrix", "matrix"), + render: [ + { + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { path: "channels.matrix", value: { enabled: true } }, + }, + { + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { path: "channels.matrix.rooms", value: ["#ops"] }, + }, + { + kind: "json-fragment", + agent: "hermes", + target: "~/.hermes/config.yaml", + fragment: { path: "channels.hermesOnly", value: { enabled: true } }, + }, + { + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { path: "plugins.entries.matrix", value: { enabled: true } }, + }, + ], + runtime: { + openclaw: { + channelName: "matrix-runtime", + visibility: { + configKeys: ["matrix-runtime"], + logPatterns: ["matrix"], + }, + }, + }, + }, + ]; + + expect(listOpenClawManagedChannelNames({ manifests })).toEqual(["matrix-runtime"]); + }); + it("lists package installs from manifest agent package metadata", () => { const manifests: ChannelManifest[] = [ { @@ -171,6 +226,29 @@ describe("built-in messaging channel metadata", () => { expect(listMessagingPackageInstallSpecs({ manifests })[0]?.agents).toEqual(["openclaw"]); expect(listMessagingPackageInstallSpecs({ manifests, agent: "hermes" })).toEqual([]); }); + + it("lists channels that do not declare gateway credentials", () => { + const manifests: ChannelManifest[] = [ + { + ...manifestWithPreset("matrix", "matrix"), + credentials: [ + { + id: "matrixToken", + sourceInput: "token", + providerName: "{sandboxName}-matrix-bridge", + providerEnvKey: "MATRIX_TOKEN", + placeholder: "openshell:resolve:env:MATRIX_TOKEN", + }, + ], + }, + { + ...manifestWithPreset("sessionOnly", "session-only"), + credentials: [], + }, + ]; + + expect(listMessagingChannelsWithoutCredentials({ manifests })).toEqual(["sessionOnly"]); + }); }); function manifestWithPreset(id: string, preset: ChannelPolicyPresetReference): ChannelManifest { diff --git a/src/lib/messaging/channels/metadata.ts b/src/lib/messaging/channels/metadata.ts index 41b9494d8b..62cdb3d28d 100644 --- a/src/lib/messaging/channels/metadata.ts +++ b/src/lib/messaging/channels/metadata.ts @@ -143,6 +143,14 @@ export function listMessagingProviderNamesForChannel( ); } +export function listMessagingChannelsWithoutCredentials( + options: MessagingManifestMetadataOptions = {}, +): string[] { + return selectManifests(options) + .filter((manifest) => manifest.credentials.length === 0) + .map((manifest) => manifest.id); +} + export function listMessagingConfigEnvMetadata( options: MessagingManifestMetadataOptions = {}, ): MessagingConfigEnvMetadata[] { @@ -262,6 +270,16 @@ export function getMessagingPolicyPresetValidationWarnings( return result; } +export function listOpenClawManagedChannelNames( + options: MessagingManifestMetadataOptions = {}, +): string[] { + return uniqueStrings( + selectManifests({ ...options, agent: "openclaw" }).flatMap((manifest) => + manifest.runtime?.openclaw?.channelName ? [manifest.runtime.openclaw.channelName] : [], + ), + ); +} + export function listOpenClawRuntimeChannelMetadata( options: MessagingManifestMetadataOptions = {}, ): OpenClawRuntimeChannelMetadata[] { diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 2403792f29..dd16c109d2 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -149,6 +149,7 @@ export const slackManifest = { ], runtime: { openclaw: { + channelName: "slack", visibility: { configKeys: ["slack"], logPatterns: ["slack"], diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 8b9a1a66cf..3981612c2e 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -160,6 +160,7 @@ export const telegramManifest = { ], runtime: { openclaw: { + channelName: "telegram", visibility: { configKeys: ["telegram"], logPatterns: ["telegram"], diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index 62ebeed145..2f8145bdec 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -109,6 +109,7 @@ export const wechatManifest = { ], runtime: { openclaw: { + channelName: "openclaw-weixin", visibility: { configKeys: ["openclaw-weixin"], logPatterns: ["wechat", "openclaw-weixin"], diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index 85d069dabf..c8bc08cc55 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -87,6 +87,7 @@ export const whatsappManifest = { ], runtime: { openclaw: { + channelName: "whatsapp", visibility: { configKeys: ["whatsapp"], logPatterns: ["whatsapp"], diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 780b536056..1dcac50034 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -42,7 +42,7 @@ export interface ChannelManifest { /** Policy presets needed when this channel is active. */ readonly policyPresets?: readonly ChannelPolicyPresetReference[]; readonly render: readonly ChannelRenderSpec[]; - readonly runtime?: Partial>; + readonly runtime?: ChannelRuntimeByAgentSpec; readonly agentPackages?: readonly ChannelAgentPackageSpec[]; readonly state: ChannelStateSpec; readonly hooks: readonly ChannelHookSpec[]; @@ -143,6 +143,19 @@ export interface ChannelRenderFragmentSpec { readonly value: MessagingSerializableValue; } +/** Agent-runtime metadata consumed by shared runtime setup and diagnostics. */ +export interface ChannelRuntimeByAgentSpec + extends Partial> { + readonly openclaw?: ChannelOpenClawRuntimeSpec; + readonly hermes?: ChannelRuntimeSpec; +} + +/** OpenClaw-specific runtime metadata. */ +export interface ChannelOpenClawRuntimeSpec extends ChannelRuntimeSpec { + /** Key owned under openclaw.json `channels`, when this manifest manages one. */ + readonly channelName?: string; +} + /** Agent-runtime metadata consumed by shared runtime setup and diagnostics. */ export interface ChannelRuntimeSpec { readonly visibility?: ChannelRuntimeVisibilitySpec; diff --git a/src/lib/messaging/persistence.ts b/src/lib/messaging/persistence.ts index f5a6001578..9e5ca17128 100644 --- a/src/lib/messaging/persistence.ts +++ b/src/lib/messaging/persistence.ts @@ -5,7 +5,6 @@ import { createBuiltInChannelManifestRegistry, createBuiltInRenderTemplateResolver, } from "./channels"; -import { buildWechatSeedOpenClawAccountOutputs } from "./channels/wechat/hooks/seed-openclaw-account"; import { planCredentialBindings } from "./compiler/engines/credential-binding-engine"; import { planHealthChecks } from "./compiler/engines/health-check-engine"; import { planNetworkPolicy } from "./compiler/engines/policy-resolver"; @@ -40,6 +39,7 @@ import type { SandboxMessagingRuntimeSetupPlan, } from "./manifest"; import type { MessagingHookInputMap, MessagingHookOutputMap } from "./hooks"; +import { BUILT_IN_MESSAGING_HOOK_REGISTRY, runMessagingHookSync } from "./hooks"; export type PersistedSandboxMessagingInputReference = Pick< SandboxMessagingInputReference, @@ -514,8 +514,7 @@ function hookBuildSteps( ["build-arg", "build-file", "package-install"].includes(output.kind), ); if (outputs.length === 0) return []; - const hookOutputs = - active && channel ? buildKnownHookOutputs(plan, manifest, hook, channel) : {}; + const hookOutputs = active && channel ? buildHookOutputs(plan, manifest, hook, channel) : {}; return outputs.map((output) => ({ channelId: manifest.id, kind: output.kind as "build-arg" | "build-file" | "package-install", @@ -532,22 +531,16 @@ function hookBuildSteps( }); } -function buildKnownHookOutputs( +function buildHookOutputs( plan: SandboxMessagingPlan, - _manifest: ChannelManifest, + manifest: ChannelManifest, hook: ChannelHookSpec, channel: SandboxMessagingChannelPlan, ): MessagingHookOutputMap { - if (hook.handler === "wechat.seedOpenClawAccount") { - try { - return buildWechatSeedOpenClawAccountOutputs( - selectHookInputs(buildHookInputMap(channel, plan.credentialBindings), hook.inputs), - ); - } catch { - return {}; - } - } - return {}; + return runMessagingHookSync(hook, BUILT_IN_MESSAGING_HOOK_REGISTRY, { + channelId: manifest.id, + inputs: selectHookInputs(buildHookInputMap(channel, plan.credentialBindings), hook.inputs), + }).outputs; } function hasFullChannelShape( diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index a9224e0146..7652019284 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -63,6 +63,9 @@ describe("mergeOpenClawRestoredConfig", () => { { channels: { telegram: { accounts: { default: { token: "openshell:resolve:env:v111_TOKEN" } } }, + whatsapp: { accounts: { default: { session: "stale" } } }, + wechat: { accounts: { default: { accountId: "legacy" } } }, + "openclaw-weixin": { accounts: { default: { accountId: "stale-current" } } }, matrix: { accounts: { default: { room: "#ops" } } }, }, }, @@ -71,9 +74,16 @@ describe("mergeOpenClawRestoredConfig", () => { expect(merged).toMatchObject({ gateway: { auth: { token: "fresh-token" } }, - channels: { matrix: { accounts: { default: { room: "#ops" } } } }, + channels: { + wechat: { accounts: { default: { accountId: "legacy" } } }, + matrix: { accounts: { default: { room: "#ops" } } }, + }, }); expect((merged as { channels: Record }).channels.telegram).toBeUndefined(); + expect((merged as { channels: Record }).channels.whatsapp).toBeUndefined(); + expect( + (merged as { channels: Record }).channels["openclaw-weixin"], + ).toBeUndefined(); }); it("preserves backup provider and plugin entries when current entry maps are absent", () => { diff --git a/src/lib/state/openclaw-config-merge.ts b/src/lib/state/openclaw-config-merge.ts index 7fb622b07b..bab09e701a 100644 --- a/src/lib/state/openclaw-config-merge.ts +++ b/src/lib/state/openclaw-config-merge.ts @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { isRecord } from "../core/json-types.js"; +import { listOpenClawManagedChannelNames } from "../messaging/channels/index.js"; + +const MANAGED_OPENCLAW_CHANNEL_NAMES = listOpenClawManagedChannelNames(); /** * Ownership contract for restoring OpenClaw's durable openclaw.json snapshot. @@ -15,7 +18,7 @@ export const OPENCLAW_CONFIG_RESTORE_OWNERSHIP = { /** Fresh rebuild output owns these whole top-level runtime sections. */ runtimeSections: ["gateway", "proxy", "diagnostics"], /** NemoClaw-managed channels reflect current add/remove/start/stop state. */ - managedChannels: ["discord", "slack", "telegram", "whatsapp", "wechat", "openclaw-weixin"], + managedChannels: MANAGED_OPENCLAW_CHANNEL_NAMES, /** Current generated entries win by id; backup-only user entries are kept. */ currentGeneratedEntryMaps: ["plugins.entries"], /** diff --git a/src/lib/verify-deployment.ts b/src/lib/verify-deployment.ts index 974a259a14..8bab1f59ee 100644 --- a/src/lib/verify-deployment.ts +++ b/src/lib/verify-deployment.ts @@ -19,6 +19,7 @@ import type { DashboardDeliveryChain } from "./dashboard/contract"; import { compareChannelSets, type RuntimeChannelStatus } from "./channel-runtime-status"; +import { listMessagingChannelsWithoutCredentials } from "./messaging/channels"; import { getMessagingProviderNamesForChannel } from "./onboard/messaging-reuse"; // ── Types ──────────────────────────────────────────────────────────── @@ -128,7 +129,7 @@ function defaultSleep(ms: number): Promise { // HTTP status codes that indicate the gateway process is alive. // 401 = device auth is enabled but the gateway is running. const GATEWAY_ALIVE_CODES = new Set([200, 401]); -const TOKENLESS_MESSAGING_CHANNELS = new Set(["whatsapp"]); +const CREDENTIALLESS_MESSAGING_CHANNELS = new Set(listMessagingChannelsWithoutCredentials()); // Gateway-failure hint: cover both layers the probe could be failing at. // The probe runs curl inside the sandbox against the in-sandbox OpenClaw @@ -321,7 +322,7 @@ function verifyMessagingBridges( const missingProviders: string[] = []; for (const channel of channels) { const providerNames = getMessagingProviderNamesForChannel(sandboxName, channel); - if (providerNames.length === 0 && TOKENLESS_MESSAGING_CHANNELS.has(channel)) { + if (providerNames.length === 0 && CREDENTIALLESS_MESSAGING_CHANNELS.has(channel)) { continue; } const expectedProviders = providerNames.length > 0 ? providerNames : [channel]; diff --git a/test/credentials-cli-command.test.ts b/test/credentials-cli-command.test.ts index 50e40f7e19..c1fba100d0 100644 --- a/test/credentials-cli-command.test.ts +++ b/test/credentials-cli-command.test.ts @@ -245,6 +245,8 @@ describe("credentials oclif commands", () => { expect(output.stderr).toContain("per-sandbox messaging bridge"); expect(output.stderr).toContain("channels remove"); + expect(output.stderr).toContain("channels remove "); + expect(output.stderr).not.toContain("channels remove {