Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b5f067a
refactor(messaging): migrate channel lifecycle hooks
sandl99 Jun 12, 2026
b99378a
refactor(messaging): finish manifest channel migration
sandl99 Jun 12, 2026
067ad60
fix(messaging): address manifest hook review feedback
sandl99 Jun 12, 2026
f761d0b
refactor(messaging): simplify channel hook manifests
sandl99 Jun 13, 2026
34ab674
Merge remote-tracking branch 'origin/main' into refactor/messaging-ch…
sandl99 Jun 13, 2026
0de062b
refactor(messaging): colocate wechat host qr login
sandl99 Jun 13, 2026
59cf532
fix(messaging): address metadata review comments
sandl99 Jun 13, 2026
5c71c1f
refactor(messaging): compact persisted plan state
sandl99 Jun 13, 2026
f37ff32
Merge remote-tracking branch 'origin/main' into refactor/messaging-ch…
sandl99 Jun 14, 2026
cbe09ef
fix(messaging): preserve persisted channel plan fields
sandl99 Jun 14, 2026
72ccad9
fix(messaging): bake runtime setup artifact
sandl99 Jun 14, 2026
c02fedb
test(e2e): align WhatsApp preload guard assertion
sandl99 Jun 14, 2026
f7c0b68
Merge branch 'main' into refactor/messaging-channel-manifest-stragglers
sandl99 Jun 14, 2026
75075b0
refactor(messaging): move Slack deny feedback into runtime preload
sandl99 Jun 14, 2026
e497815
refactor(messaging): install plugins from package plan
sandl99 Jun 14, 2026
a465123
fix(messaging): validate plan object array entries
sandl99 Jun 14, 2026
3b46a32
Merge remote-tracking branch 'origin/refactor/messaging-channel-manif…
sandl99 Jun 14, 2026
848c9ec
refactor(messaging): move runtime preload sources to ts
sandl99 Jun 14, 2026
9fc3141
fix(messaging): patch Slack ESM helper imports
sandl99 Jun 15, 2026
f7ba9f7
fix(messaging): type channel diagnostics preloads
sandl99 Jun 15, 2026
c535a6e
fix(messaging): compile runtime preloads for sandbox image
sandl99 Jun 15, 2026
9b85f89
fix(messaging): compile runtime preloads narrowly
sandl99 Jun 15, 2026
754b5a2
refactor(messaging): finish channel manifest cleanup
sandl99 Jun 15, 2026
5e0648a
docs(troubleshooting): restore channel examples
sandl99 Jun 15, 2026
81356c1
fix(messaging): address manifest cleanup review
sandl99 Jun 15, 2026
df7c432
merge(main): resolve messaging manifest cleanup conflicts
sandl99 Jun 15, 2026
2a79a4c
Merge branch 'main' into refactor/messaging-channel-manifest-cleanup
sandl99 Jun 15, 2026
42e149a
refactor(messaging): drop legacy OpenClaw channel restore list
sandl99 Jun 15, 2026
9305d09
Merge remote-tracking branch 'origin/refactor/messaging-channel-manif…
sandl99 Jun 15, 2026
6b76e17
fix(messaging): declare OpenClaw channel ownership in manifests
sandl99 Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions src/commands/credentials/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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} <sandbox> channels remove <telegram|discord|slack>\` to retire`,
` Use \`${CLI_NAME} <sandbox> channels remove <channel>\` to retire`,
" the integration (it tears down the bridge provider and rebuilds the sandbox),",
` or \`${CLI_NAME} <sandbox> channels stop <…>\` to pause it without clearing tokens.`,
]);
Expand Down
1 change: 1 addition & 0 deletions src/lib/messaging/channels/discord/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export const discordManifest = {
],
runtime: {
openclaw: {
channelName: "discord",
visibility: {
configKeys: ["discord"],
logPatterns: ["discord"],
Expand Down
2 changes: 2 additions & 0 deletions src/lib/messaging/channels/manifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions src/lib/messaging/channels/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import {
getMessagingPolicyPresetValidationWarnings,
getMessagingProviderSuffixesByChannel,
listAvailableMessagingChannelIds,
listMessagingChannelsWithoutCredentials,
listMessagingConfigEnvKeys,
listMessagingPackageInstallSpecs,
listMessagingProviderNamesForChannel,
listOpenClawManagedChannelNames,
listOpenClawRuntimeChannelMetadata,
listRequiredCreateTimeMessagingPolicyPresetNames,
} from "./metadata";
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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]),
Expand Down Expand Up @@ -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[] = [
{
Expand All @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/messaging/channels/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -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[] {
Expand Down
1 change: 1 addition & 0 deletions src/lib/messaging/channels/slack/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export const slackManifest = {
],
runtime: {
openclaw: {
channelName: "slack",
visibility: {
configKeys: ["slack"],
logPatterns: ["slack"],
Expand Down
1 change: 1 addition & 0 deletions src/lib/messaging/channels/telegram/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export const telegramManifest = {
],
runtime: {
openclaw: {
channelName: "telegram",
visibility: {
configKeys: ["telegram"],
logPatterns: ["telegram"],
Expand Down
1 change: 1 addition & 0 deletions src/lib/messaging/channels/wechat/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const wechatManifest = {
],
runtime: {
openclaw: {
channelName: "openclaw-weixin",
visibility: {
configKeys: ["openclaw-weixin"],
logPatterns: ["wechat", "openclaw-weixin"],
Expand Down
1 change: 1 addition & 0 deletions src/lib/messaging/channels/whatsapp/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const whatsappManifest = {
],
runtime: {
openclaw: {
channelName: "whatsapp",
visibility: {
configKeys: ["whatsapp"],
logPatterns: ["whatsapp"],
Expand Down
15 changes: 14 additions & 1 deletion src/lib/messaging/manifest/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<MessagingAgentId, ChannelRuntimeSpec>>;
readonly runtime?: ChannelRuntimeByAgentSpec;
readonly agentPackages?: readonly ChannelAgentPackageSpec[];
readonly state: ChannelStateSpec;
readonly hooks: readonly ChannelHookSpec[];
Expand Down Expand Up @@ -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<Record<MessagingAgentId, ChannelRuntimeSpec>> {
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;
Expand Down
23 changes: 8 additions & 15 deletions src/lib/messaging/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand Down
12 changes: 11 additions & 1 deletion src/lib/state/openclaw-config-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } } },
},
},
Expand All @@ -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<string, unknown> }).channels.telegram).toBeUndefined();
expect((merged as { channels: Record<string, unknown> }).channels.whatsapp).toBeUndefined();
expect(
(merged as { channels: Record<string, unknown> }).channels["openclaw-weixin"],
).toBeUndefined();
});

it("preserves backup provider and plugin entries when current entry maps are absent", () => {
Expand Down
5 changes: 4 additions & 1 deletion src/lib/state/openclaw-config-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"],
/**
Expand Down
5 changes: 3 additions & 2 deletions src/lib/verify-deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -128,7 +129,7 @@ function defaultSleep(ms: number): Promise<void> {
// 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
Expand Down Expand Up @@ -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];
Expand Down
2 changes: 2 additions & 0 deletions test/credentials-cli-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <channel>");
expect(output.stderr).not.toContain("channels remove <discord");
});

it("explains provider-name usage when reset receives an env var name", async () => {
Expand Down
Loading