diff --git a/scripts/install.sh b/scripts/install.sh index 56b1bd826b..6362287588 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -27,7 +27,9 @@ _global_cleanup() { } trap _global_cleanup EXIT -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +_INSTALLER_SOURCE="${BASH_SOURCE[0]:-$0}" +SCRIPT_DIR="$(cd "$(dirname "${_INSTALLER_SOURCE}")" && pwd)" +_INSTALLER_SCRIPT_PATH="${SCRIPT_DIR}/$(basename "${_INSTALLER_SOURCE}")" resolve_repo_root() { local base="${NEMOCLAW_REPO_ROOT:-$SCRIPT_DIR}" @@ -2275,7 +2277,7 @@ ensure_docker() { if installer_non_interactive \ && [ "${NEMOCLAW_DOCKER_GROUP_REACTIVATED:-}" != "1" ] \ && command -v sg >/dev/null 2>&1; then - local self="${BASH_SOURCE[0]:-$0}" + local self="${NEMOCLAW_INSTALLER_STAGED:-${_INSTALLER_SCRIPT_PATH:-${BASH_SOURCE[0]:-$0}}}" if [ -n "$self" ] && [ -f "$self" ]; then info "Reactivating docker group membership via 'sg docker' to continue non-interactive install." export NEMOCLAW_DOCKER_GROUP_REACTIVATED=1 diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index fe13c7751d..323b50ded8 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -40,7 +40,6 @@ const defs = D("agent/defs.js"); const rebuild = D("actions/sandbox/rebuild.js"); const processRecovery = D("actions/sandbox/process-recovery.js"); const onboardSession = D("state/onboard-session.js"); -const slackValidation = D("actions/sandbox/slack-channel-validation.js"); const policy = D("policy/index.js"); const { hashCredential } = D("security/credential-hash.js") as { hashCredential: (v: string) => string | null; @@ -93,6 +92,19 @@ function conflictPromptShown(): boolean { beforeEach(() => { spies = []; delete process.env.NEMOCLAW_NON_INTERACTIVE; + delete process.env.TELEGRAM_BOT_TOKEN; + delete process.env.TELEGRAM_ALLOWED_IDS; + delete process.env.TELEGRAM_REQUIRE_MENTION; + delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_APP_TOKEN; + delete process.env.SLACK_ALLOWED_USERS; + delete process.env.SLACK_ALLOWED_CHANNELS; + delete process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY; + delete process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION; + delete process.env.WECHAT_BOT_TOKEN; + delete process.env.WECHAT_ACCOUNT_ID; + delete process.env.WECHAT_BASE_URL; + delete process.env.WECHAT_USER_ID; logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -151,10 +163,8 @@ beforeEach(() => { vi.spyOn(processRecovery, "executeSandboxExecCommand").mockReturnValue(null); vi.spyOn(processRecovery, "executeSandboxCommand").mockReturnValue(null); - // Slack add runs a credential validation (auth.test-style) before the - // conflict check; that is its own concern and would otherwise probe Slack. - // Stub it to pass so scenario 11 can reach the conflict logic. - vi.spyOn(slackValidation, "validateSlackChannelCredentials").mockReturnValue({ ok: true }); + process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1"; + process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; // onboard-session for the wechat host-qr branch. vi.spyOn(onboardSession, "loadSession").mockReturnValue(null); @@ -165,7 +175,19 @@ afterEach(() => { vi.restoreAllMocks(); for (const s of spies) s.mockRestore(); delete process.env.NEMOCLAW_NON_INTERACTIVE; + delete process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY; + delete process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION; + delete process.env.TELEGRAM_BOT_TOKEN; + delete process.env.TELEGRAM_ALLOWED_IDS; + delete process.env.TELEGRAM_REQUIRE_MENTION; + delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_APP_TOKEN; + delete process.env.SLACK_ALLOWED_USERS; + delete process.env.SLACK_ALLOWED_CHANNELS; + delete process.env.WECHAT_BOT_TOKEN; delete process.env.WECHAT_ACCOUNT_ID; + delete process.env.WECHAT_BASE_URL; + delete process.env.WECHAT_USER_ID; }); describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { @@ -397,8 +419,8 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { }, ], }); - // acquireHostQrChannel short-circuits on a cached token; supply account - // metadata so the wechat cached-token branch does not bail. + // The hook planner skips non-interactive host-QR enrollment, but the + // conflict guard should still see a cached WeChat credential. getCredentialMock.mockImplementation((key: string) => key === "WECHAT_BOT_TOKEN" ? wechatToken : null, ); @@ -535,8 +557,8 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { // Scenario 11 it("slack two-token channel: matching SLACK_BOT_TOKEN hash is detected", async () => { - const slackBot = "test-slack-bot-token"; - const slackApp = "test-slack-app-token"; + const slackBot = "xoxb-test-slack-bot-token"; + const slackApp = "xapp-test-slack-app-token"; const slackBotHash = hashCredential(slackBot) as string; arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index de039760fc..3cfbeec6b7 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -9,6 +9,25 @@ import { type AgentDefinition, loadAgent } from "../../agent/defs"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; import { prompt as askPrompt, getCredential } from "../../credentials/store"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; +import { + type ChannelManifest, + createBuiltInChannelManifestRegistry, + createBuiltInMessagingHookRegistry, + getMessagingManifestAvailabilityContext, + MessagingHostStateApplier, + MessagingSetupApplier, + MessagingWorkflowPlanner, + type SandboxMessagingChannelPlan, + type SandboxMessagingPlan, + toMessagingAgentId, +} from "../../messaging"; +import { + type MessagingChannelConfig, + mergeMessagingChannelConfigs, + normalizeMessagingChannelConfigValue, + resolveMessagingChannelConfigEnvValue, + sanitizeMessagingChannelConfig, +} from "../../messaging-channel-config"; import { hashCredential } from "../../security/credential-hash"; const { isNonInteractive } = require("../../onboard") as { isNonInteractive: () => boolean }; @@ -17,9 +36,6 @@ const onboardProviders = require("../../onboard/providers"); import { filterSetupPolicyPresetsForAgent } from "../../onboard/agent-policy-presets"; import * as policies from "../../policy"; -// Lazy-required: keeps qrcode-terminal + the iLink HTTP client out of the -// import graph for non-host-qr channels-add calls. -const { HOST_QR_LOGIN_HANDLERS } = require("../../host-qr-handlers") as typeof import("../../host-qr-handlers"); const onboardSession = require("../../state/onboard-session") as typeof import("../../state/onboard-session"); import { runOpenshell } from "../../adapters/openshell/runtime"; @@ -28,7 +44,7 @@ import { type PolicyRemoveOptions, parsePolicyAddOptions, } from "../../domain/policy-channel"; -import type { HostQrLoginResult } from "../../host-qr-handlers"; +import { getMessagingToken } from "../../onboard/messaging-token"; import { shellQuote } from "../../runner"; import { type ChannelDef, @@ -36,7 +52,6 @@ import { clearChannelTokens, getChannelDef, getChannelTokenKeys, - KNOWN_CHANNELS, knownChannelNames, persistChannelTokens, } from "../../sandbox/channels"; @@ -47,7 +62,6 @@ import { } from "./gateway-failure-classifier"; import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; import { rebuildSandbox } from "./rebuild"; -import { validateSlackChannelCredentials } from "./slack-channel-validation"; import { printTelegramDirectMessageAllowlistWarning } from "./telegram-channel-bridge-verification"; type ChannelMutationOptions = { @@ -56,6 +70,8 @@ type ChannelMutationOptions = { force?: boolean; }; +const messagingManifestRegistry = createBuiltInChannelManifestRegistry(); + const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; const trueColor = useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); @@ -302,18 +318,28 @@ function resolveAgentForSandbox(sandboxName: string): AgentDefinition { return loadAgent(agentName); } +function knownManifestChannelNames(): string[] { + return messagingManifestRegistry.list().map((manifest) => manifest.id); +} + +function resolveChannelManifest(name: string): ChannelManifest | undefined { + return messagingManifestRegistry.get(name.trim().toLowerCase()); +} + +function availableManifestChannelsForAgent(agent: AgentDefinition): ChannelManifest[] { + return messagingManifestRegistry.listAvailable(getMessagingManifestAvailabilityContext(agent)); +} + function channelSupportedByAgent(channelName: string, agent: AgentDefinition): boolean { - const supported = agent.messagingPlatforms; - return !Array.isArray(supported) || supported.length === 0 || supported.includes(channelName); + return availableManifestChannelsForAgent(agent).some((manifest) => manifest.id === channelName); } export function listSandboxChannels(sandboxName: string) { const agent = resolveAgentForSandbox(sandboxName); console.log(""); console.log(` Known messaging channels for sandbox '${sandboxName}':`); - for (const [name, channel] of Object.entries(KNOWN_CHANNELS)) { - if (!channelSupportedByAgent(name, agent)) continue; - console.log(` ${name} — ${channel.description}`); + for (const manifest of availableManifestChannelsForAgent(agent)) { + console.log(` ${manifest.id} — ${manifest.description ?? manifest.displayName}`); } console.log(""); } @@ -323,8 +349,11 @@ export function listSandboxChannels(sandboxName: string) { // channels-add upsert collides with (i.e. updates) the same provider that // a later rebuild would have created from scratch. function bridgeProviderName(sandboxName: string, channelName: string, envKey: string): string { - if (channelName === "slack" && envKey === "SLACK_APP_TOKEN") { - return `${sandboxName}-slack-app`; + const credential = messagingManifestRegistry + .get(channelName) + ?.credentials.find((entry) => entry.providerEnvKey === envKey); + if (credential) { + return credential.providerName.replaceAll("{sandboxName}", sandboxName); } return `${sandboxName}-${channelName}-bridge`; } @@ -732,152 +761,213 @@ function verifyChannelBridgeAfterRebuild(sandboxName: string, channelName: strin ); } -// Paste-prompt token acquisition for Telegram / Discord / Slack — extracted -// from the original inline loop so `addSandboxChannel` can fork cleanly on -// `loginMethod`. -async function acquirePasteTokens( - channelArg: string, - channel: ChannelDef, - acquired: Record, -): Promise { - const tokenKeys = getChannelTokenKeys(channel); - for (const envKey of tokenKeys) { - const isPrimary = envKey === channel.envKey; - const help = isPrimary ? channel.help : channel.appTokenHelp; - const label = isPrimary ? channel.label : channel.appTokenLabel; - const existing = getCredential(envKey); - if (existing) { - acquired[envKey] = existing; - continue; - } - if (isNonInteractive()) { - console.error(` Missing ${envKey} for channel '${channelArg}'.`); - console.error( - ` Set ${envKey} in the environment or via '${CLI_NAME} credentials' before running in non-interactive mode.`, - ); - process.exit(1); - } - console.log(""); - console.log(` ${help}`); - const token = (await askPrompt(` ${label}: `, { secret: true })).trim(); - if (!token) { - console.error(` Aborted — no value entered for ${envKey}.`); - process.exit(1); - } - acquired[envKey] = token; +async function planSandboxChannelAdd( + sandboxName: string, + channelId: string, + agent: AgentDefinition, +): Promise { + const planner = new MessagingWorkflowPlanner( + messagingManifestRegistry, + createBuiltInMessagingHookRegistry(), + ); + const availableChannels = availableManifestChannelsForAgent(agent); + const supportedChannelIds = availableChannels.map((manifest) => manifest.id); + + hydrateAddChannelEnvFromSession(sandboxName, channelId); + + try { + const plan = await planner.buildChannelAddPlanFromSandboxEntry({ + sandboxName, + agent: toMessagingAgentId(agent), + isInteractive: !isNonInteractive(), + channelId, + sandboxEntry: registry.getSandbox(sandboxName), + supportedChannelIds, + credentialAvailability: buildCredentialAvailability([channelId]), + }); + MessagingSetupApplier.writePlanToEnv(plan); + return plan; + } catch (error) { + console.error(` Failed to plan messaging channel '${channelId}'.`); + console.error(` ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); } } -// Host-QR token acquisition for WeChat (the only channel with -// `loginMethod: "host-qr"` today). Drives the iLink QR handshake on the -// host, captures the bot token and the non-secret per-account metadata -// (accountId, baseUrl, userId), and stashes the metadata where the -// upcoming rebuild can find it: -// - `process.env` — for the in-process rebuild that fires next -// (`promptAndRebuild` → `rebuildSandbox` → -// `onboard --resume` reads WECHAT_ACCOUNT_ID -// etc. via the wechatConfig builder). -// - `session.wechatConfig` — for a deferred rebuild started from a fresh -// process. `rebuildSandbox`'s env-stash reads -// back from here. -async function acquireHostQrChannel( +async function persistManifestChannelDisabledPlan( sandboxName: string, - channelArg: string, - channel: ChannelDef, - acquired: Record, + channelId: string, + disabled: boolean, ): Promise { - const envKey = channel.envKey; - if (!envKey) { - console.error(` Channel '${channelArg}' does not declare a credential environment key.`); - process.exit(1); - } - // Cached-token short-circuit. A sandbox originally onboarded with this - // channel already has the bot token in OpenShell + the per-account - // metadata in session.wechatConfig. Re-running QR would invalidate the - // upstream plugin's existing iLink session; prefer the cache and let - // the rebuild's env-stash re-bake from session. - const cached = getCredential(envKey); - if (cached) { - if (channelArg === "wechat") { - // The rebuild needs accountId/baseUrl/userId to reconstruct the - // upstream plugin's account state file via seed-wechat-accounts.py. - // Restore them from session here so a deferred rebuild (started in a - // fresh process where rebuild.ts hasn't stashed yet) still finds - // them — and bail loudly if the session was cleared. Only honor the - // session entry when it belongs to THIS sandbox, otherwise we'd bake - // another sandbox's WECHAT_* into this image. - const savedSession = onboardSession.loadSession(); - const savedWechat = - savedSession?.sandboxName === sandboxName ? savedSession.wechatConfig ?? null : null; - if (savedWechat?.accountId && !process.env.WECHAT_ACCOUNT_ID) { - process.env.WECHAT_ACCOUNT_ID = savedWechat.accountId; - if (savedWechat.baseUrl) process.env.WECHAT_BASE_URL = savedWechat.baseUrl; - if (savedWechat.userId) process.env.WECHAT_USER_ID = savedWechat.userId; - } - if (!process.env.WECHAT_ACCOUNT_ID) { - console.error(" Cached WeChat token found, but per-account metadata is missing."); - console.error( - ` Run '${CLI_NAME} ${sandboxName} channels remove ${channelArg}' then '${CLI_NAME} ${sandboxName} channels add ${channelArg}' to capture a fresh account via QR.`, - ); - process.exit(1); - } + const entry = registry.getSandbox(sandboxName); + if (!entry) return; + const agent = resolveAgentForSandbox(sandboxName); + const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); + const context = { + sandboxName, + agent: toMessagingAgentId(agent), + channelId, + sandboxEntry: entry, + supportedChannelIds: availableManifestChannelsForAgent(agent).map((manifest) => manifest.id), + }; + const plan = disabled + ? await planner.buildChannelStopPlanFromSandboxEntry(context) + : await planner.buildChannelStartPlanFromSandboxEntry(context); + if (plan) MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); +} + +async function persistManifestChannelRemovePlan( + sandboxName: string, + channelId: string, +): Promise { + const entry = registry.getSandbox(sandboxName); + if (!entry) return; + const agent = resolveAgentForSandbox(sandboxName); + const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); + const plan = await planner.buildChannelRemovePlanFromSandboxEntry({ + sandboxName, + agent: toMessagingAgentId(agent), + channelId, + sandboxEntry: entry, + supportedChannelIds: availableManifestChannelsForAgent(agent).map((manifest) => manifest.id), + }); + if (plan) MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); +} + +function buildCredentialAvailability(channelIds: readonly string[]): Record { + const availability: Record = {}; + for (const channelId of channelIds) { + const manifest = messagingManifestRegistry.get(channelId); + if (!manifest) continue; + for (const input of manifest.inputs) { + if (input.kind !== "secret" || !input.envKey) continue; + if (!getMessagingToken(input.envKey)) continue; + availability[input.id] = true; + availability[`${manifest.id}.${input.id}`] = true; + availability[input.envKey] = true; } - acquired[envKey] = cached; - return; } - if (isNonInteractive()) { - console.error( - ` '${channelArg}' requires an interactive QR login; cannot run in non-interactive mode.`, - ); + return availability; +} + +function collectManifestCredentials(manifest: ChannelManifest): Record { + const acquired: Record = {}; + for (const credential of manifest.credentials) { + const value = getMessagingToken(credential.providerEnvKey); + if (value) acquired[credential.providerEnvKey] = value; + } + return acquired; +} + +function assertAddChannelPlanActive( + sandboxName: string, + manifest: ChannelManifest, + plan: SandboxMessagingPlan, +): SandboxMessagingChannelPlan { + const channelPlan = plan.channels.find((channel) => channel.channelId === manifest.id); + if (channelPlan?.active) return channelPlan; + + const missing = channelPlan?.inputs.filter((input) => input.required && !inputAvailable(input)) ?? []; + if (missing.length > 0) { console.error( - ` Run '${CLI_NAME} ${sandboxName} channels add ${channelArg}' interactively instead.`, + ` Missing required input(s) for channel '${manifest.id}': ${missing + .map(formatMissingInput) + .join(", ")}.`, ); - process.exit(1); + if (manifest.auth.mode === "host-qr" && getMessagingToken(manifest.credentials[0]?.providerEnvKey)) { + console.error( + ` Run '${CLI_NAME} ${sandboxName} channels remove ${manifest.id}' then '${CLI_NAME} ${sandboxName} channels add ${manifest.id}' to capture fresh account metadata.`, + ); + } else if (isNonInteractive()) { + console.error( + ` Set the required environment values or run '${CLI_NAME} ${sandboxName} channels add ${manifest.id}' interactively.`, + ); + } + } else { + console.error(` Channel '${manifest.id}' was skipped during manifest enrollment.`); } - const handler = HOST_QR_LOGIN_HANDLERS[channelArg]; - if (!handler) { - console.error(` No host-qr handler registered for '${channelArg}'.`); - process.exit(1); + process.exit(1); +} + +function inputAvailable(input: SandboxMessagingChannelPlan["inputs"][number]): boolean { + if (input.kind === "secret") return input.credentialAvailable === true; + if (input.value === undefined) return false; + return typeof input.value === "string" ? input.value.trim().length > 0 : true; +} + +function formatMissingInput(input: SandboxMessagingChannelPlan["inputs"][number]): string { + return input.sourceEnv ? `${input.inputId} (${input.sourceEnv})` : input.inputId; +} + +function hydrateAddChannelEnvFromSession(sandboxName: string, channelId: string): void { + if (channelId !== "wechat") return; + const savedSession = safeLoadOnboardSession(); + const savedWechat = + savedSession?.sandboxName === sandboxName ? savedSession.wechatConfig ?? null : null; + if (!savedWechat) return; + if (savedWechat.accountId && !process.env.WECHAT_ACCOUNT_ID) { + process.env.WECHAT_ACCOUNT_ID = savedWechat.accountId; } - console.log(""); - console.log(` ${channel.help}`); - let result: HostQrLoginResult; - try { - result = await handler(); - } catch (err: unknown) { - result = { kind: "error", message: err instanceof Error ? err.message : String(err) }; - } - if (result.kind !== "ok") { - const reason = - result.kind === "timeout" - ? "QR login timed out" - : result.kind === "expired" - ? "QR expired too many times" - : result.kind === "aborted" - ? "login aborted" - : `login failed: ${result.message ?? "unknown error"}`; - console.error(` Aborted — ${reason}.`); - process.exit(1); + if (savedWechat.baseUrl && !process.env.WECHAT_BASE_URL) { + process.env.WECHAT_BASE_URL = savedWechat.baseUrl; } - if (!result.token) { - console.error(" Aborted — host-qr handler returned no token."); - process.exit(1); + if (savedWechat.userId && !process.env.WECHAT_USER_ID) { + process.env.WECHAT_USER_ID = savedWechat.userId; } - acquired[envKey] = result.token; - if (result.extraEnv) { - for (const [key, value] of Object.entries(result.extraEnv)) { - process.env[key] = value; - } +} + +function persistManifestAddState(sandboxName: string, manifest: ChannelManifest): void { + persistManifestMessagingConfig(sandboxName, manifest); + if (manifest.id === "wechat") persistWechatConfigFromEnv(sandboxName); +} + +function persistManifestMessagingConfig(sandboxName: string, manifest: ChannelManifest): void { + const config = readManifestMessagingConfigFromEnv(manifest); + if (!config) return; + + const entry = registry.getSandbox(sandboxName); + const mergedRegistryConfig = mergeMessagingChannelConfigs(entry?.messagingChannelConfig, config); + if (entry && mergedRegistryConfig) { + registry.updateSandbox(sandboxName, { messagingChannelConfig: mergedRegistryConfig }); } - if (channel.userIdEnvKey && result.defaultUserId && !process.env[channel.userIdEnvKey]) { - process.env[channel.userIdEnvKey] = result.defaultUserId; + + const session = safeLoadOnboardSession(); + if (session?.sandboxName !== sandboxName) return; + const mergedSessionConfig = mergeMessagingChannelConfigs(session.messagingChannelConfig, config); + if (!mergedSessionConfig) return; + try { + onboardSession.updateSession((current) => { + current.messagingChannelConfig = mergedSessionConfig; + return current; + }); + } catch { + // Best-effort: registry state still carries the config when available. } - if (channelArg === "wechat" && result.extraEnv) { - const captured = { - accountId: result.extraEnv.WECHAT_ACCOUNT_ID, - baseUrl: result.extraEnv.WECHAT_BASE_URL, - userId: result.extraEnv.WECHAT_USER_ID, - }; +} + +function readManifestMessagingConfigFromEnv(manifest: ChannelManifest): MessagingChannelConfig | null { + const result: MessagingChannelConfig = {}; + for (const input of manifest.inputs) { + if (input.kind !== "config" || !input.envKey) continue; + const resolved = resolveMessagingChannelConfigEnvValue(input.envKey, process.env); + const normalized = + resolved.value ?? + normalizeMessagingChannelConfigValue(input.envKey, process.env[input.envKey]); + if (normalized) result[input.envKey] = normalized; + } + return sanitizeMessagingChannelConfig(result); +} + +function persistWechatConfigFromEnv(sandboxName: string): void { + const captured = { + accountId: normalizeEnvValue(process.env.WECHAT_ACCOUNT_ID), + baseUrl: normalizeEnvValue(process.env.WECHAT_BASE_URL), + userId: normalizeEnvValue(process.env.WECHAT_USER_ID), + }; + if (!captured.accountId && !captured.baseUrl && !captured.userId) return; + const session = safeLoadOnboardSession(); + if (session?.sandboxName !== sandboxName) return; + try { onboardSession.updateSession((current) => { const prior = current.wechatConfig; current.wechatConfig = { @@ -887,9 +977,23 @@ async function acquireHostQrChannel( }; return current; }); + } catch { + // The channel remains usable for an immediate rebuild; deferred rebuilds + // can be recovered by re-running channels add for the same sandbox. } - const suffix = result.summary ? ` (${result.summary})` : ""; - console.log(` ${G}✓${R} ${channelArg} token saved${suffix}.`); +} + +function safeLoadOnboardSession(): ReturnType { + try { + return onboardSession.loadSession(); + } catch { + return null; + } +} + +function normalizeEnvValue(value: string | undefined): string | undefined { + const normalized = value?.replace(/\r/g, "").trim(); + return normalized || undefined; } export async function addSandboxChannel( @@ -901,17 +1005,17 @@ export async function addSandboxChannel( const rawChannelArg = options.channel; if (!rawChannelArg) { console.error(` Usage: ${CLI_NAME} channels add [--dry-run]`); - console.error(` Valid channels: ${knownChannelNames().join(", ")}`); + console.error(` Valid channels: ${knownManifestChannelNames().join(", ")}`); process.exit(1); } - const channel = getChannelDef(rawChannelArg); - if (!channel) { + const manifest = resolveChannelManifest(rawChannelArg); + if (!manifest) { console.error(` Unknown channel '${rawChannelArg}'.`); - console.error(` Valid channels: ${knownChannelNames().join(", ")}`); + console.error(` Valid channels: ${knownManifestChannelNames().join(", ")}`); process.exit(1); } - const canonical = rawChannelArg.trim().toLowerCase(); + const canonical = manifest.id; const agent = resolveAgentForSandbox(sandboxName); if (!channelSupportedByAgent(canonical, agent)) { @@ -942,23 +1046,33 @@ export async function addSandboxChannel( return; } + const plan = await planSandboxChannelAdd(sandboxName, canonical, agent); + const acquired = collectManifestCredentials(manifest); + if (!(await checkChannelAddConflict(sandboxName, canonical, acquired, force))) { + return; // user aborted; nothing registered or widened + } + assertAddChannelPlanActive(sandboxName, manifest, plan); + // QR-paired channels that own their session inside the sandbox have no // host-side credential to acquire; register the bridge now and let the // operator complete pairing after rebuild. - if (channelUsesInSandboxQrPairing(channel)) { + if (manifest.auth.mode === "in-sandbox-qr") { if (!applyChannelPresetIfAvailable(sandboxName, canonical)) { process.exit(1); } await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, {}); + persistManifestAddState(sandboxName, manifest); + MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); console.log(""); - console.log(` ${channel.help}`); + const help = manifest.enrollmentHelp ?? manifest.inputs[0]?.prompt?.help; + if (help) console.log(` ${help}`); console.log( ` ${G}✓${R} Enabled ${canonical} channel. Complete QR pairing from inside the sandbox after rebuild.`, ); // Show post-pair guidance (e.g. the channels status hint for WhatsApp) // here because the in-sandbox QR branch returns before the shared note // loop the non-QR branches use. - for (const line of channel.setupNotes ?? []) { + for (const line of manifest.enrollmentNotes ?? []) { console.log(` ${line}`); } const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); @@ -966,6 +1080,11 @@ export async function addSandboxChannel( return; } + const channelDef = getChannelDef(canonical); + if (!channelDef) { + console.error(` Unknown channel '${canonical}'.`); + process.exit(1); + } const priorEntry = registry.getSandbox(sandboxName); const priorMessagingChannels: string[] = priorEntry?.messagingChannels ? [...priorEntry.messagingChannels] @@ -974,35 +1093,13 @@ export async function addSandboxChannel( const priorHashes: Record = { ...((priorEntry?.providerCredentialHashes as Record) || {}), }; - const channelTokenKeys = getChannelTokenKeys(channel); + const channelTokenKeys = getChannelTokenKeys(channelDef); const priorCreds: Record = {}; for (const key of channelTokenKeys) { const existing = getCredential(key); if (existing != null) priorCreds[key] = existing; } - - const acquired: Record = {}; - if (channel.loginMethod === "host-qr") { - await acquireHostQrChannel(sandboxName, canonical, channel, acquired); - } else { - await acquirePasteTokens(canonical, channel, acquired); - } - - if (canonical === "slack") { - const validation = validateSlackChannelCredentials(channel, acquired); - if (!validation.ok) { - console.error(` ${validation.message}`); - process.exit(1); - } - if (validation.message) { - console.log(` ${YW}⚠${R} ${validation.message}`); - } - } - persistChannelTokens(acquired); - if (!(await checkChannelAddConflict(sandboxName, canonical, acquired, force))) { - return; // user aborted; nothing registered or widened - } // Push to the gateway and update the registry NOW so that answering // "rebuild later" (or running non-interactively) does not silently // discard the change. Pre-fix this was safe because saveCredential() @@ -1012,7 +1109,7 @@ export async function addSandboxChannel( console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`); if (!applyChannelPresetIfAvailable(sandboxName, canonical)) { - await rollbackChannelAdd(sandboxName, channel, canonical, { + await rollbackChannelAdd(sandboxName, channelDef, canonical, { wasAlreadyEnabled, priorMessagingChannels, priorHashes, @@ -1021,6 +1118,9 @@ export async function addSandboxChannel( process.exit(1); } + persistManifestAddState(sandboxName, manifest); + MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); + const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical); } @@ -1327,6 +1427,7 @@ export async function removeSandboxChannel( } removeChannelPresetIfPresent(sandboxName, canonical); + await persistManifestChannelRemovePlan(sandboxName, canonical); // Token-based channels: best-effort tidy of any leftover dir. Token // revocation already prevents the bot from authenticating, so a @@ -1382,6 +1483,7 @@ async function sandboxChannelsSetEnabled( console.error(` Sandbox '${sandboxName}' not found in the registry.`); process.exit(1); } + await persistManifestChannelDisabledPlan(sandboxName, normalized, disabled); const state = disabled ? "disabled" : "enabled"; console.log(` ${G}✓${R} Marked ${normalized} ${state} for '${sandboxName}'.`); await promptAndRebuild(sandboxName, `${verb} '${normalized}'`); diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 6f0d21eec0..a6dfbe5dfd 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -43,6 +43,12 @@ import * as agentRuntime from "../../agent/runtime"; import { RD as _RD, B, D, G, R, YW } from "../../cli/terminal-style"; import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy"; import * as nim from "../../inference/nim"; +import { + createBuiltInChannelManifestRegistry, + MessagingSetupApplier, + MessagingWorkflowPlanner, + toMessagingAgentId, +} from "../../messaging"; import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets"; import { captureSandboxListWithGatewayRecovery, @@ -165,6 +171,33 @@ function preflightHermesProviderCredentials( return false; } +async function stageMessagingManifestPlanForRebuild( + sandboxName: string, + sandboxEntry: registry.SandboxEntry, + rebuildAgent: string | null, + log: (msg: string) => void, +): Promise { + const agent = loadAgent(rebuildAgent || "openclaw"); + const planner = new MessagingWorkflowPlanner(createBuiltInChannelManifestRegistry()); + const plan = await planner.buildRebuildPlanFromSandboxEntry({ + sandboxName, + agent: toMessagingAgentId(agent), + sandboxEntry, + supportedChannelIds: agent.messagingPlatforms, + }); + if (!plan || plan.channels.length === 0) { + MessagingSetupApplier.clearPlanEnv(); + log("Messaging manifest rebuild plan: no configured channels"); + return; + } + MessagingSetupApplier.writePlanToEnv(plan); + log( + `Messaging manifest rebuild plan staged: ${plan.channels + .map((channel) => channel.channelId) + .join(",")}`, + ); +} + /** * Rebuild a live sandbox while preserving registered agent state and policies. * @@ -406,6 +439,21 @@ export async function rebuildSandbox( ); } + try { + await stageMessagingManifestPlanForRebuild(sandboxName, sb, rebuildAgent, log); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(""); + console.error( + ` ${_RD}Rebuild preflight failed:${R} messaging manifest plan could not be staged.`, + ); + console.error(` ${message}`); + console.error(""); + console.error(" Sandbox is untouched — no data was lost."); + bail(message); + return; + } + // Step 1: Ensure sandbox is live for backup log("Checking sandbox liveness: openshell sandbox list"); const liveRecovery = await captureSandboxListWithGatewayRecovery(); diff --git a/src/lib/actions/sandbox/slack-channel-validation.ts b/src/lib/actions/sandbox/slack-channel-validation.ts deleted file mode 100644 index 8502b9c4a2..0000000000 --- a/src/lib/actions/sandbox/slack-channel-validation.ts +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ChannelDef } from "../../sandbox/channels"; -import { - formatSlackValidationFailure, - validateSlackCredentials, -} from "../../onboard/slack-validation"; - -export type SlackChannelCredentialValidationResult = - | { ok: true; message?: string } - | { ok: false; message: string }; - -function isAcquiredTokenFormatValid(channel: ChannelDef, envKey: string, token: string): boolean { - if (envKey === channel.envKey) return !channel.tokenFormat || channel.tokenFormat.test(token); - if (envKey === channel.appTokenEnvKey) { - return !channel.appTokenFormat || channel.appTokenFormat.test(token); - } - return false; -} - -export function validateSlackChannelCredentials( - channel: ChannelDef, - acquired: Record, -): SlackChannelCredentialValidationResult { - if (!channel.envKey || !channel.appTokenEnvKey) { - return { ok: false, message: "Slack channel definition is missing required token keys." }; - } - - const botToken = acquired[channel.envKey]; - const appToken = acquired[channel.appTokenEnvKey]; - if (!botToken || !appToken) { - return { ok: false, message: "Slack requires both SLACK_BOT_TOKEN and SLACK_APP_TOKEN." }; - } - - for (const [envKey, token] of Object.entries(acquired)) { - if (!isAcquiredTokenFormatValid(channel, envKey, token)) { - const hint = - envKey === channel.appTokenEnvKey - ? channel.appTokenFormatHint || "Check the token and try again." - : channel.tokenFormatHint || "Check the token and try again."; - return { ok: false, message: `Invalid ${envKey} format. ${hint}` }; - } - } - - const validation = validateSlackCredentials({ botToken, appToken }); - if (validation.ok) { - return validation.skipped && validation.message - ? { ok: true, message: validation.message } - : { ok: true }; - } - - return { - ok: false, - message: `Slack credential validation failed. ${formatSlackValidationFailure(validation)}`, - }; -} diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index 7672e91f02..9f34712b75 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -28,7 +28,6 @@ describe("messaging channel config", () => { expect( sanitizeMessagingChannelConfig({ TELEGRAM_ALLOWED_IDS: " 123,456 ", - TELEGRAM_AUTHORIZED_CHAT_IDS: "ignored-because-canonical-wins", TELEGRAM_REQUIRE_MENTION: "yes", DISCORD_SERVER_ID: "1491590992753590594", DISCORD_REQUIRE_MENTION: "0", @@ -45,22 +44,18 @@ describe("messaging channel config", () => { }); }); - it("canonicalizes Telegram allowlist aliases from env and persisted config", () => { + it("leaves Telegram compatibility aliases to Telegram enrollment hooks", () => { expect( sanitizeMessagingChannelConfig({ TELEGRAM_AUTHORIZED_CHAT_IDS: " 123, 456 ", }), - ).toEqual({ - TELEGRAM_ALLOWED_IDS: "123, 456", - }); + ).toBeNull(); expect( readMessagingChannelConfigFromEnv({ TELEGRAM_CHAT_ID: "8388960805", }), - ).toEqual({ - TELEGRAM_ALLOWED_IDS: "8388960805", - }); + ).toBeNull(); }); it("hydrates missing env values but preserves explicit env overrides", () => { @@ -86,15 +81,13 @@ describe("messaging channel config", () => { expect(env.DISCORD_REQUIRE_MENTION).toBeUndefined(); }); - it("hydrates Telegram aliases into the canonical env key for downstream build code", () => { + it("does not hydrate Telegram aliases outside the enrollment hook", () => { const env: NodeJS.ProcessEnv = { TELEGRAM_AUTHORIZED_CHAT_IDS: "alias-user", }; - expect(hydrateMessagingChannelConfig(null, env)).toEqual({ - TELEGRAM_ALLOWED_IDS: "alias-user", - }); - expect(env.TELEGRAM_ALLOWED_IDS).toBe("alias-user"); + expect(hydrateMessagingChannelConfig(null, env)).toBeNull(); + expect(env.TELEGRAM_ALLOWED_IDS).toBeUndefined(); }); it("reads effective config from env", () => { diff --git a/src/lib/messaging-channel-config.ts b/src/lib/messaging-channel-config.ts index 2460fd5676..61f5d003c8 100644 --- a/src/lib/messaging-channel-config.ts +++ b/src/lib/messaging-channel-config.ts @@ -6,14 +6,6 @@ import { listChannels } from "./sandbox/channels"; export type MessagingChannelConfig = Record; const channels = listChannels(); -const CONFIG_ALIASES: Record = { - TELEGRAM_ALLOWED_IDS: ["TELEGRAM_AUTHORIZED_CHAT_IDS", "TELEGRAM_CHAT_ID"], -}; -const aliasToCanonicalKey = new Map( - Object.entries(CONFIG_ALIASES).flatMap(([canonical, aliases]) => - aliases.map((alias) => [alias, canonical] as const), - ), -); const requireMentionKeys = new Set( channels .map((channel) => channel.requireMentionEnvKey) @@ -48,14 +40,13 @@ function normalizeValue(value: unknown): string | null { } export function getCanonicalMessagingChannelConfigKey(key: string): string | null { - if (knownConfigKeys.has(key)) return key; - return aliasToCanonicalKey.get(key) ?? null; + return knownConfigKeys.has(key) ? key : null; } export function getMessagingChannelConfigEnvKeys(key: string): readonly string[] { const canonical = getCanonicalMessagingChannelConfigKey(key); if (!canonical) return []; - return [canonical, ...(CONFIG_ALIASES[canonical] ?? [])]; + return [canonical]; } export function normalizeMessagingChannelConfigValue( diff --git a/src/lib/messaging/applier/host-state-applier.test.ts b/src/lib/messaging/applier/host-state-applier.test.ts new file mode 100644 index 0000000000..7a18eb5850 --- /dev/null +++ b/src/lib/messaging/applier/host-state-applier.test.ts @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SandboxMessagingPlan } from "../manifest"; +import { MessagingHostStateApplier } from "./host-state-applier"; +import { MessagingSetupApplier } from "./setup-applier"; +import * as registry from "../../state/registry"; + +vi.mock("../../state/registry", () => { + const sandboxes = new Map>(); + return { + __clear: () => sandboxes.clear(), + __getSandbox: (name: string) => sandboxes.get(name) ?? null, + __setSandbox: (name: string, entry: Record) => + sandboxes.set(name, { ...entry }), + getSandbox: vi.fn((name: string) => sandboxes.get(name) ?? null), + updateSandbox: vi.fn((name: string, updates: Record) => { + const entry = sandboxes.get(name); + if (!entry) return false; + Object.assign(entry, updates); + return true; + }), + }; +}); + +const registryMock = registry as typeof registry & { + __clear(): void; + __getSandbox(name: string): Record | null; + __setSandbox(name: string, entry: Record): void; +}; + +describe("MessagingHostStateApplier", () => { + beforeEach(() => { + registryMock.__clear(); + vi.clearAllMocks(); + }); + + it("builds durable messaging state from the manifest plan env", () => { + const env: NodeJS.ProcessEnv = {}; + const plan = makePlan(["telegram"]); + + MessagingSetupApplier.writePlanToEnv(plan, { env }); + const state = MessagingHostStateApplier.readPlanStateFromEnv({ env }); + + expect(state).toEqual({ + schemaVersion: 1, + plan, + }); + }); + + it("stores only the new messaging state on an existing sandbox entry", () => { + registryMock.__setSandbox("demo", { + name: "demo", + messagingChannels: ["telegram"], + disabledChannels: ["discord"], + }); + const plan = makePlan(["telegram"]); + + const updated = MessagingHostStateApplier.applyPlanToRegistry("demo", plan); + + expect(updated).toBe(true); + expect(registry.updateSandbox).toHaveBeenCalledWith("demo", { + messaging: { + schemaVersion: 1, + plan, + }, + }); + expect(registryMock.__getSandbox("demo")).toMatchObject({ + messagingChannels: ["telegram"], + disabledChannels: ["discord"], + messaging: { + schemaVersion: 1, + plan, + }, + }); + }); + + it("can merge a single-channel add plan into existing messaging state", () => { + registryMock.__setSandbox("demo", { + name: "demo", + messaging: MessagingHostStateApplier.buildStateFromPlan(makePlan(["telegram"])), + }); + + const updated = MessagingHostStateApplier.applyPlanToRegistry( + "demo", + makePlan(["slack"], { + credentialBindings: [ + makeCredentialBinding("slack", "bot"), + makeCredentialBinding("slack", "app"), + ], + }), + { mode: "merge" }, + ); + + expect(updated).toBe(true); + const entry = registryMock.__getSandbox("demo"); + const plan = (entry?.messaging as { plan: SandboxMessagingPlan }).plan; + expect(plan.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + "slack", + ]); + expect(plan.credentialBindings.map((binding) => binding.providerEnvKey)).toEqual([ + "TELEGRAM_BOT_TOKEN", + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + ]); + expect(plan.networkPolicy.presets).toEqual(["slack", "telegram"]); + }); +}); + +function makePlan( + channelIds: readonly string[], + overrides: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "add-channel", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + })), + disabledChannels: [], + credentialBindings: channelIds.map((channelId) => makeCredentialBinding(channelId, "bot")), + networkPolicy: { + presets: [...channelIds], + entries: channelIds.map((channelId) => ({ + channelId, + presetName: channelId, + policyKeys: [channelId], + source: "manifest", + })), + }, + agentRender: channelIds.map((channelId) => ({ + channelId, + agent: "openclaw", + target: "openclaw.json", + kind: "json-fragment", + path: `channels.${channelId}`, + value: { enabled: true }, + templateRefs: [], + })), + buildSteps: [], + stateUpdates: channelIds.map((channelId) => ({ + channelId, + kind: "persist-inputs", + stateKey: channelId, + inputIds: [], + })), + healthChecks: [], + ...overrides, + }; +} + +function makeCredentialBinding( + channelId: string, + credentialId: string, +): SandboxMessagingPlan["credentialBindings"][number] { + const envKey = + channelId === "slack" && credentialId === "app" + ? "SLACK_APP_TOKEN" + : `${channelId.toUpperCase()}_BOT_TOKEN`; + return { + channelId, + credentialId, + sourceInput: credentialId, + providerName: `demo-${channelId}-${credentialId}`, + providerEnvKey: envKey, + placeholder: `\${${envKey}}`, + credentialAvailable: true, + }; +} diff --git a/src/lib/messaging/applier/host-state-applier.ts b/src/lib/messaging/applier/host-state-applier.ts new file mode 100644 index 0000000000..2c9add5d53 --- /dev/null +++ b/src/lib/messaging/applier/host-state-applier.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingPlan } from "../manifest"; +import * as registry from "../../state/registry"; +import { MessagingSetupApplier } from "./setup-applier"; +import type { MessagingSetupEnvOptions } from "./types"; + +export interface MessagingHostStateApplyOptions { + readonly mode?: "replace" | "merge"; +} + +export class MessagingHostStateApplier { + static buildStateFromPlan(plan: SandboxMessagingPlan): registry.SandboxMessagingState { + return { + schemaVersion: 1, + plan: clonePlan(plan), + }; + } + + static readPlanStateFromEnv( + options: MessagingSetupEnvOptions = {}, + ): registry.SandboxMessagingState | undefined { + const plan = MessagingSetupApplier.readPlanFromEnv(options); + return plan ? this.buildStateFromPlan(plan) : undefined; + } + + static applyPlanFromEnv( + sandboxName: string, + options: MessagingSetupEnvOptions & MessagingHostStateApplyOptions = {}, + ): boolean { + const plan = MessagingSetupApplier.readPlanFromEnv(options); + if (!plan) return false; + return this.applyPlanToRegistry(sandboxName, plan, options); + } + + static applyPlanToRegistry( + sandboxName: string, + plan: SandboxMessagingPlan, + options: MessagingHostStateApplyOptions = {}, + ): boolean { + if (plan.sandboxName !== sandboxName) return false; + const entry = registry.getSandbox(sandboxName); + if (!entry) return false; + const nextPlan = + options.mode === "merge" && entry.messaging?.plan + ? mergeSandboxMessagingPlans(entry.messaging.plan, plan) + : clonePlan(plan); + return registry.updateSandbox(sandboxName, { + messaging: { + schemaVersion: 1, + plan: nextPlan, + }, + }); + } +} + +function clonePlan(plan: SandboxMessagingPlan): SandboxMessagingPlan { + return MessagingSetupApplier.decodePlan(MessagingSetupApplier.encodePlan(plan)); +} + +function mergeSandboxMessagingPlans( + existing: SandboxMessagingPlan, + incoming: SandboxMessagingPlan, +): SandboxMessagingPlan { + if ( + existing.schemaVersion !== incoming.schemaVersion || + existing.sandboxName !== incoming.sandboxName || + existing.agent !== incoming.agent + ) { + return clonePlan(incoming); + } + + const incomingChannelIds = new Set(incoming.channels.map((channel) => channel.channelId)); + const mergedChannels = [ + ...existing.channels.filter((channel) => !incomingChannelIds.has(channel.channelId)), + ...incoming.channels, + ]; + const activeIncomingChannels = new Set( + incoming.channels + .filter((channel) => channel.active && !channel.disabled) + .map((channel) => channel.channelId), + ); + const disabledChannels = uniqueStrings([ + ...existing.disabledChannels.filter((channelId) => !activeIncomingChannels.has(channelId)), + ...incoming.disabledChannels, + ]); + const networkEntries = mergeByChannelId( + existing.networkPolicy.entries, + incoming.networkPolicy.entries, + ); + + return clonePlan({ + ...incoming, + channels: mergedChannels, + disabledChannels, + credentialBindings: mergeByChannelId( + existing.credentialBindings, + incoming.credentialBindings, + ), + networkPolicy: { + presets: uniqueStrings(networkEntries.map((entry) => entry.presetName)), + entries: networkEntries, + }, + agentRender: mergeByChannelId(existing.agentRender, incoming.agentRender), + buildSteps: mergeByChannelId(existing.buildSteps, incoming.buildSteps), + stateUpdates: mergeByChannelId(existing.stateUpdates, incoming.stateUpdates), + healthChecks: mergeByChannelId(existing.healthChecks, incoming.healthChecks), + }); +} + +function mergeByChannelId( + existing: readonly T[], + incoming: readonly T[], +): T[] { + const incomingChannelIds = new Set(incoming.map((entry) => entry.channelId)); + return [ + ...existing.filter((entry) => !incomingChannelIds.has(entry.channelId)), + ...incoming, + ]; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)].filter(Boolean).sort(); +} diff --git a/src/lib/messaging/applier/index.ts b/src/lib/messaging/applier/index.ts index 31b80dc3c2..f9e4dcf486 100644 --- a/src/lib/messaging/applier/index.ts +++ b/src/lib/messaging/applier/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export * from "./setup-applier"; +export * from "./host-state-applier"; export * from "./agent-config"; export * from "./openshell-provider"; export * from "./policy"; diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index b86c7fa28c..e795474b8e 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -31,11 +31,15 @@ async function withEnv( values: Readonly>, run: () => Promise, ): Promise { + const scopedValues = { + NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", + ...values, + }; const previous = Object.fromEntries( - Object.keys(values).map((key) => [key, process.env[key]]), + Object.keys(scopedValues).map((key) => [key, process.env[key]]), ); try { - for (const [key, value] of Object.entries(values)) { + for (const [key, value] of Object.entries(scopedValues)) { if (value === undefined) { delete process.env[key]; } else { @@ -59,12 +63,17 @@ function planner(): MessagingWorkflowPlanner { createBuiltInChannelManifestRegistry(), createBuiltInMessagingHookRegistry({ common: { - env: {}, getCredential: (key) => TEST_CREDENTIALS[key] ?? null, saveCredential: () => {}, prompt: async () => "unused", log: () => {}, }, + slack: { + validateCredentials: { + log: () => {}, + validateCredentials: () => ({ ok: true }), + }, + }, telegram: { fetch: async () => ({ ok: true, @@ -79,8 +88,8 @@ function planner(): MessagingWorkflowPlanner { }, wechat: { ilinkLogin: { - env: {}, saveCredential: () => {}, + log: () => {}, runLogin: async () => ({ kind: "timeout", }), @@ -147,7 +156,13 @@ describe("MessagingSetupApplier", () => { }); it("lists hook requests by phase without executing hook implementations", async () => { - const plan = await buildOnboardPlan({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); + const plan = await buildOnboardPlan( + { + WECHAT_BOT_TOKEN: "wechat-token", + WECHAT_ACCOUNT_ID: "wechat-account", + }, + ["wechat"], + ); expect(MessagingSetupApplier.listHookRequests(plan, "enroll")).toEqual([ expect.objectContaining({ @@ -157,6 +172,13 @@ describe("MessagingSetupApplier", () => { phase: "enroll", handler: "wechat.ilinkLogin", }), + expect.objectContaining({ + sandboxName: "demo", + channelId: "wechat", + hookId: "wechat-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + }), ]); expect(MessagingSetupApplier.listHookRequests(plan, "post-agent-install")).toEqual([ expect.objectContaining({ @@ -369,8 +391,14 @@ describe("MessagingSetupApplier", () => { "slack", ]); expect( - MessagingSetupApplier.listHookRequests(plan).map((request) => request.channelId), - ).toEqual(["slack"]); + MessagingSetupApplier.listHookRequests(plan).map( + (request) => `${request.channelId}:${request.hookId}`, + ), + ).toEqual([ + "slack:slack-token-paste", + "slack:slack-config-prompt", + "slack:slack-credential-validation", + ]); const providerCalls: string[][] = []; const credentialResult = MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { @@ -427,6 +455,7 @@ describe("MessagingSetupApplier", () => { it("runs post-install hook implementations and writes their build-file outputs", async () => { const plan = await buildOnboardPlan( { + WECHAT_BOT_TOKEN: "wechat-token", WECHAT_ACCOUNT_ID: "wechat-account", WECHAT_BASE_URL: "https://ilinkai.wechat.example", WECHAT_USER_ID: "wechat-user", @@ -434,33 +463,7 @@ describe("MessagingSetupApplier", () => { ["wechat"], ); const registry = createBuiltInMessagingHookRegistry({ - common: { - env: {}, - getCredential: (key) => TEST_CREDENTIALS[key] ?? null, - saveCredential: () => {}, - prompt: async () => "unused", - log: () => {}, - }, - telegram: { - fetch: async () => ({ - ok: true, - status: 200, - async json() { - return { ok: true }; - }, - async text() { - return ""; - }, - }), - }, wechat: { - ilinkLogin: { - env: {}, - saveCredential: () => {}, - runLogin: async () => ({ - kind: "timeout", - }), - }, seedOpenClawAccount: { now: () => "2026-01-01T00:00:00.000Z", }, @@ -543,7 +546,13 @@ describe("MessagingSetupApplier", () => { }); it("rejects prototype-polluting build-file merge keys", async () => { - const plan = await buildOnboardPlan({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); + const plan = await buildOnboardPlan( + { + WECHAT_BOT_TOKEN: "wechat-token", + WECHAT_ACCOUNT_ID: "wechat-account", + }, + ["wechat"], + ); const files: Record = { "/sandbox/.openclaw/openclaw.json": "{}", }; @@ -654,7 +663,13 @@ describe("MessagingSetupApplier", () => { }); it("rejects unsafe build-file hook output paths and modes", async () => { - const plan = await buildOnboardPlan({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); + const plan = await buildOnboardPlan( + { + WECHAT_BOT_TOKEN: "wechat-token", + WECHAT_ACCOUNT_ID: "wechat-account", + }, + ["wechat"], + ); const runOpenshell: MessagingOpenShellRunner = (args, options) => { if (args.includes("cat") && options?.input === undefined) { return { status: 0, stdout: "{}" }; diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 2daddfde27..212d48db8e 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -32,6 +32,7 @@ export const discordManifest = { prompt: { label: "Discord Server ID (for guild workspace access)", help: "Enable Developer Mode in Discord, then right-click your server and copy the Server ID.", + emptyValueMessage: "guild channels stay disabled", }, }, { @@ -40,6 +41,7 @@ export const discordManifest = { required: false, envKey: "DISCORD_REQUIRE_MENTION", statePath: "discordGuilds.requireMention", + promptWhenInput: "serverId", validValues: ["0", "1"], prompt: { label: "Discord mention mode", @@ -52,9 +54,11 @@ export const discordManifest = { required: false, envKey: "DISCORD_USER_ID", statePath: "discordGuilds.userIds", + promptWhenInput: "serverId", prompt: { label: "Discord User ID (optional guild allowlist)", help: "Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.", + emptyValueMessage: "any member in the configured server can message the bot", }, }, ], @@ -164,5 +168,24 @@ export const discordManifest = { ], onFailure: "skip-channel", }, + { + id: "discord-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + outputs: [ + { + id: "serverId", + kind: "config", + }, + { + id: "requireMention", + kind: "config", + }, + { + id: "userId", + kind: "config", + }, + ], + }, ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 3f3a3c609b..189be24cd1 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -10,7 +10,12 @@ import { buildMessagingEnvLines, } from "../../../../agents/hermes/config/messaging-config.ts"; import { getChannelTokenKeys, KNOWN_CHANNELS, knownChannelNames } from "../../sandbox/channels"; -import { COMMON_TOKEN_PASTE_HOOK_HANDLER_ID } from "../hooks/common"; +import { + COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, +} from "../hooks/common"; +import { SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID } from "./slack/hooks"; +import { TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID } from "./telegram/hooks"; import type { ChannelInputSpec, ChannelManifest, ChannelRenderSpec } from "../manifest"; import { BUILT_IN_CHANNEL_MANIFESTS, @@ -58,6 +63,41 @@ function expectTokenPasteEnrollHook(manifest: ChannelManifest, outputIds: readon }); } +function expectConfigPromptEnrollHook( + manifest: ChannelManifest, + outputIds: readonly string[], +): void { + expect(manifest.hooks).toContainEqual({ + id: `${manifest.id}-config-prompt`, + phase: "enroll", + handler: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + outputs: outputIds.map((id) => ({ + id, + kind: "config", + })), + }); +} + +function expectReachabilityHook(manifest: ChannelManifest, inputIds: readonly string[]): void { + expect(manifest.hooks).toContainEqual({ + id: `${manifest.id}-get-me-reachability`, + phase: "reachability-check", + handler: `${manifest.id}.getMeReachability`, + inputs: inputIds, + onFailure: "skip-channel", + }); +} + +function expectSlackCredentialValidationHook(inputIds: readonly string[]): void { + expect(slackManifest.hooks).toContainEqual({ + id: "slack-credential-validation", + phase: "reachability-check", + handler: SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + inputs: inputIds, + onFailure: "skip-channel", + }); +} + describe("built-in channel manifests", () => { it("registers the phase-1 built-in manifests without consuming them in workflows", () => { const registry = createBuiltInChannelManifestRegistry(); @@ -98,7 +138,9 @@ describe("built-in channel manifests", () => { "src/lib/messaging/channels/wechat/hooks/index.ts", "src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts", "src/lib/messaging/channels/slack/manifest.ts", + "src/lib/messaging/channels/slack/hooks/validate-credentials.ts", "src/lib/messaging/channels/whatsapp/manifest.ts", + "src/lib/messaging/hooks/common/config-prompt.ts", "src/lib/messaging/hooks/common/token-paste.ts", ]; const forbiddenImports = [ @@ -196,12 +238,18 @@ describe("built-in channel manifests", () => { 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", + id: "telegram-allowlist-aliases", + phase: "enroll", + handler: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + outputs: [ + { + id: "allowedIds", + kind: "config", + }, + ], }); + expectConfigPromptEnrollHook(telegramManifest, ["requireMention", "allowedIds"]); + expectReachabilityHook(telegramManifest, ["botToken"]); }); it("declares Discord guild and allowlist render intent for both agents", () => { @@ -255,12 +303,14 @@ describe("built-in channel manifests", () => { expect(renderJson(discordManifest)).toContain("discord.guilds"); expect(renderJson(discordManifest)).toContain("require_mention"); expectTokenPasteEnrollHook(discordManifest, ["botToken"]); + expectConfigPromptEnrollHook(discordManifest, ["serverId", "requireMention", "userId"]); }); it("declares Slack Bolt-compatible placeholders and allowlist render intent", () => { const botToken = findInput(slackManifest, "botToken"); const appToken = findInput(slackManifest, "appToken"); const allowedUsers = findInput(slackManifest, "allowedUsers"); + const allowedChannels = findInput(slackManifest, "allowedChannels"); const hermesLines = buildMessagingEnvLines( new Set(["slack"]), { slack: ["U0123456789"] }, @@ -276,6 +326,13 @@ describe("built-in channel manifests", () => { expect(botToken.envKey).toBe("SLACK_BOT_TOKEN"); expect(appToken.envKey).toBe("SLACK_APP_TOKEN"); expect(allowedUsers.envKey).toBe("SLACK_ALLOWED_USERS"); + expect(allowedChannels.envKey).toBe("SLACK_ALLOWED_CHANNELS"); + expect(allowedChannels.statePath).toBe("slackConfig.allowedChannels"); + expect(allowedChannels.prompt).toEqual({ + label: "Slack Channel IDs (comma-separated allowlist)", + help: "Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.", + emptyValueMessage: "channel @mentions stay unrestricted by channel ID", + }); expect(KNOWN_CHANNELS.slack.allowIdsMode).toBe("dm"); expect(slackManifest.credentials).toEqual([ { @@ -303,6 +360,24 @@ describe("built-in channel manifests", () => { expect(renderJson(slackManifest)).toContain("channels.slack.accounts.default"); expect(renderJson(slackManifest)).toContain("allowedIds.slack.channels"); expectTokenPasteEnrollHook(slackManifest, ["botToken", "appToken"]); + expectConfigPromptEnrollHook(slackManifest, ["allowedUsers", "allowedChannels"]); + expectSlackCredentialValidationHook(["botToken", "appToken"]); + expect(slackManifest.state).toEqual({ + persist: { + allowedIds: ["allowedUsers"], + slackConfig: ["allowedChannels"], + }, + rebuildHydration: [ + { + statePath: "allowedIds.slack", + env: "SLACK_ALLOWED_USERS", + }, + { + statePath: "slackConfig.allowedChannels", + env: "SLACK_ALLOWED_CHANNELS", + }, + ], + }); }); it("declares WeChat host-QR hooks, state hydration, provider binding, and Hermes env intent", () => { @@ -370,10 +445,12 @@ describe("built-in channel manifests", () => { expect(renderJson(wechatManifest)).toContain("credential.wechatBotToken.placeholder"); expect(wechatManifest.hooks.map((hook) => hook.handler)).toEqual([ "wechat.ilinkLogin", + "common.configPrompt", "wechat.seedOpenClawAccount", "wechat.healthCheck", ]); - expect(wechatManifest.hooks[1]?.outputs).toEqual( + expectConfigPromptEnrollHook(wechatManifest, ["allowedIds"]); + expect(wechatManifest.hooks[2]?.outputs).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "openclawWeixinAccountFile", @@ -385,7 +462,7 @@ describe("built-in channel manifests", () => { }), ]), ); - expect(wechatManifest.hooks[2]).toMatchObject({ + expect(wechatManifest.hooks[3]).toMatchObject({ id: "wechat-health-check", phase: "health-check", handler: "wechat.healthCheck", diff --git a/src/lib/onboard/slack-validation.test.ts b/src/lib/messaging/channels/slack/hooks/credential-validation.test.ts similarity index 76% rename from src/lib/onboard/slack-validation.test.ts rename to src/lib/messaging/channels/slack/hooks/credential-validation.test.ts index 8be6024372..f30aa96636 100644 --- a/src/lib/onboard/slack-validation.test.ts +++ b/src/lib/messaging/channels/slack/hooks/credential-validation.test.ts @@ -5,22 +5,20 @@ import fs from "node:fs"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ProbeResult } from "./types"; -import { KNOWN_CHANNELS } from "../sandbox/channels"; +import type { CurlProbeResult } from "../../../../adapters/http/probe"; -vi.mock("../adapters/http/probe", () => ({ +vi.mock("../../../../adapters/http/probe", () => ({ runCurlProbe: vi.fn(), })); -import { runCurlProbe } from "../adapters/http/probe"; +import { runCurlProbe } from "../../../../adapters/http/probe"; import { - filterSlackSelectionByValidation, validateSlackAppToken, validateSlackBotToken, validateSlackCredentials, -} from "./slack-validation"; +} from "./credential-validation"; -function probe(body: string, overrides: Partial = {}): ProbeResult { +function probe(body: string, overrides: Partial = {}): CurlProbeResult { return { ok: true, httpStatus: 200, @@ -189,46 +187,4 @@ describe("Slack token validation", () => { if (!result.ok) expect(result.message).not.toContain(token); }); - it("drops Slack selections when live validation is indeterminate", () => { - process.env.SLACK_BOT_TOKEN = "xoxb-timeout-bot"; - process.env.SLACK_APP_TOKEN = "xapp-timeout-app"; - vi.mocked(runCurlProbe).mockReturnValue( - probe("", { - ok: false, - httpStatus: 0, - curlStatus: 28, - stderr: "operation timed out", - message: "curl failed (exit 28): operation timed out", - }), - ); - const warnings: string[] = []; - - const result = filterSlackSelectionByValidation( - ["telegram", "slack"], - [KNOWN_CHANNELS.slack], - (message) => warnings.push(message), - ); - - expect(result).toEqual(["telegram"]); - expect(warnings.join("\n")).toContain("Slack integration will be disabled"); - expect(warnings.join("\n")).not.toContain("xoxb-timeout-bot"); - }); - - it("keeps Slack selected in explicit skip mode without probing Slack", () => { - process.env.SLACK_BOT_TOKEN = "xoxb-offline-bot"; - process.env.SLACK_APP_TOKEN = "xapp-offline-app"; - process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; - vi.mocked(runCurlProbe).mockReturnValue(probe('{"ok":false,"error":"invalid_auth"}')); - const warnings: string[] = []; - - const result = filterSlackSelectionByValidation( - ["telegram", "slack"], - [KNOWN_CHANNELS.slack], - (message) => warnings.push(message), - ); - - expect(result).toEqual(["telegram", "slack"]); - expect(vi.mocked(runCurlProbe)).not.toHaveBeenCalled(); - expect(warnings.join("\n")).toContain("Live Slack API validation skipped"); - }); }); diff --git a/src/lib/onboard/slack-validation.ts b/src/lib/messaging/channels/slack/hooks/credential-validation.ts similarity index 84% rename from src/lib/onboard/slack-validation.ts rename to src/lib/messaging/channels/slack/hooks/credential-validation.ts index 0158f2c0fe..65d75ad6c7 100644 --- a/src/lib/onboard/slack-validation.ts +++ b/src/lib/messaging/channels/slack/hooks/credential-validation.ts @@ -5,9 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { runCurlProbe, type CurlProbeResult } from "../adapters/http/probe"; -import type { ChannelDef } from "../sandbox/channels"; -import { getValidatedMessagingTokenByEnvKey } from "./messaging-token"; +import { runCurlProbe, type CurlProbeResult } from "../../../../adapters/http/probe"; export type SlackTokenKind = "bot" | "app"; export type SlackValidationFailureKind = "rejected" | "indeterminate"; @@ -238,33 +236,3 @@ export function formatSlackValidationFailure( ): string { return result.message; } - -export function filterSlackSelectionByValidation( - found: string[], - channels: readonly ChannelDef[], - warn: (message: string) => void = console.warn, -): string[] { - if (!found.includes("slack")) return found; - - const botToken = getValidatedMessagingTokenByEnvKey(channels, "SLACK_BOT_TOKEN"); - const appToken = getValidatedMessagingTokenByEnvKey(channels, "SLACK_APP_TOKEN"); - if (!botToken || !appToken) { - warn( - " Slack integration will be disabled for this onboard run because both SLACK_BOT_TOKEN and SLACK_APP_TOKEN are required.", - ); - return found.filter((channel) => channel !== "slack"); - } - - const validation = validateSlackCredentials({ botToken, appToken }); - if (validation.ok) { - if (validation.skipped && validation.message) { - warn(` ${validation.message}`); - } - return found; - } - - warn( - ` Slack integration will be disabled for this onboard run. ${formatSlackValidationFailure(validation)}`, - ); - return found.filter((channel) => channel !== "slack"); -} diff --git a/src/lib/messaging/channels/slack/hooks/index.ts b/src/lib/messaging/channels/slack/hooks/index.ts new file mode 100644 index 0000000000..3047d914a5 --- /dev/null +++ b/src/lib/messaging/channels/slack/hooks/index.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookRegistration } from "../../../hooks/types"; +import { + createSlackValidateCredentialsHookRegistration, + type SlackValidateCredentialsHookOptions, +} from "./validate-credentials"; + +export * from "./credential-validation"; +export * from "./validate-credentials"; + +export interface SlackHookOptions { + readonly validateCredentials?: SlackValidateCredentialsHookOptions; +} + +export function createSlackHookRegistrations( + options: SlackHookOptions = {}, +): readonly MessagingHookRegistration[] { + return [ + createSlackValidateCredentialsHookRegistration( + withoutUndefinedValues(options.validateCredentials), + ), + ] as const; +} + +function withoutUndefinedValues( + options: SlackValidateCredentialsHookOptions | undefined, +): SlackValidateCredentialsHookOptions { + return Object.fromEntries( + Object.entries(options ?? {}).filter(([, value]) => value !== undefined), + ) as SlackValidateCredentialsHookOptions; +} diff --git a/src/lib/messaging/channels/slack/hooks/validate-credentials.test.ts b/src/lib/messaging/channels/slack/hooks/validate-credentials.test.ts new file mode 100644 index 0000000000..773b3ce52c --- /dev/null +++ b/src/lib/messaging/channels/slack/hooks/validate-credentials.test.ts @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { runMessagingHook } from "../../../hooks/hook-runner"; +import { MessagingHookRegistry } from "../../../hooks/registry"; +import { slackManifest } from "../manifest"; +import { + createSlackValidateCredentialsHook, + SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + type SlackValidateCredentialsHookOptions, +} from "./validate-credentials"; + +function registry(options: SlackValidateCredentialsHookOptions): MessagingHookRegistry { + return new MessagingHookRegistry([ + { + id: SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + handler: createSlackValidateCredentialsHook(options), + }, + ]); +} + +function slackValidationHook() { + const hook = slackManifest.hooks.find((entry) => entry.id === "slack-credential-validation"); + if (!hook) throw new Error("missing Slack credential validation hook"); + return hook; +} + +describe("Slack credential validation hook", () => { + it("uses the Slack-specific reachability handler declared by the manifest", () => { + expect(slackValidationHook()).toMatchObject({ + phase: "reachability-check", + handler: SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + inputs: ["botToken", "appToken"], + onFailure: "skip-channel", + }); + }); + + it("validates the collected bot and app tokens without exposing them in outputs", async () => { + const validated: Array<{ readonly botToken: string; readonly appToken: string }> = []; + + await expect( + runMessagingHook( + slackValidationHook(), + registry({ + validateCredentials: (tokens) => { + validated.push(tokens); + return { ok: true }; + }, + log: () => {}, + }), + { + channelId: "slack", + inputs: { + botToken: "xoxb-test-slack-token", + appToken: "xapp-test-slack-token", + }, + }, + ), + ).resolves.toMatchObject({ + handlerId: SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + phase: "reachability-check", + outputs: {}, + }); + expect(validated).toEqual([ + { + botToken: "xoxb-test-slack-token", + appToken: "xapp-test-slack-token", + }, + ]); + }); + + it("logs skip-env validation warnings without failing the hook", async () => { + const logs: string[] = []; + + await runMessagingHook( + slackValidationHook(), + registry({ + validateCredentials: () => ({ + ok: true, + skipped: true, + message: "Live Slack API validation skipped because test.", + }), + log: (message) => logs.push(message), + }), + { + channelId: "slack", + inputs: { + botToken: "xoxb-test-slack-token", + appToken: "xapp-test-slack-token", + }, + }, + ); + + expect(logs.join("\n")).toContain("Live Slack API validation skipped because test."); + }); + + it("skips Slack when the Slack API rejects a credential", async () => { + const logs: string[] = []; + + await expect( + runMessagingHook( + slackValidationHook(), + registry({ + validateCredentials: () => ({ + ok: false, + kind: "rejected", + tokenKind: "app", + credential: "app", + error: "invalid_auth", + httpStatus: 200, + curlStatus: 0, + message: "Slack app token was rejected by Slack API: invalid_auth.", + }), + log: (message) => logs.push(message), + }), + { + channelId: "slack", + inputs: { + botToken: "xoxb-fake-bot-token", + appToken: "xapp-fake-app-token", + }, + }, + ), + ).rejects.toThrow("Slack credential validation failed"); + expect(logs.join("\n")).toContain("Slack app token was rejected by Slack API"); + expect(logs.join("\n")).toContain("Skipped slack (invalid Slack credentials)"); + expect(logs.join("\n")).not.toContain("xoxb-fake-bot-token"); + expect(logs.join("\n")).not.toContain("xapp-fake-app-token"); + }); + + it("skips Slack when Slack API validation is unavailable", async () => { + const logs: string[] = []; + + await expect( + runMessagingHook( + slackValidationHook(), + registry({ + validateCredentials: () => ({ + ok: false, + kind: "indeterminate", + tokenKind: "bot", + credential: "bot", + httpStatus: 0, + curlStatus: 7, + message: "Slack bot token could not be validated because Slack API was unreachable.", + }), + log: (message) => logs.push(message), + }), + { + channelId: "slack", + inputs: { + botToken: "xoxb-fake-bot-token", + appToken: "xapp-fake-app-token", + }, + }, + ), + ).rejects.toThrow("Slack credential validation failed"); + expect(logs.join("\n")).toContain("Slack API validation unavailable"); + }); + + it("requires both Slack hook inputs", async () => { + await expect( + runMessagingHook( + slackValidationHook(), + registry({ validateCredentials: () => ({ ok: true }) }), + { + channelId: "slack", + inputs: { + botToken: "xoxb-test-slack-token", + }, + }, + ), + ).rejects.toThrow("Slack credential validation requires botToken and appToken"); + }); +}); diff --git a/src/lib/messaging/channels/slack/hooks/validate-credentials.ts b/src/lib/messaging/channels/slack/hooks/validate-credentials.ts new file mode 100644 index 0000000000..13a6f054b5 --- /dev/null +++ b/src/lib/messaging/channels/slack/hooks/validate-credentials.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + formatSlackValidationFailure, + validateSlackCredentials, +} from "./credential-validation"; +import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; + +export const SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID = "slack.validateCredentials"; + +export interface SlackValidateCredentialsHookOptions { + readonly validateCredentials?: typeof validateSlackCredentials; + readonly formatValidationFailure?: typeof formatSlackValidationFailure; + readonly log?: (message: string) => void; +} + +export function createSlackValidateCredentialsHook( + options: SlackValidateCredentialsHookOptions = {}, +): MessagingHookHandler { + return async (context) => { + const botToken = normalizeHookToken(context.inputs?.botToken); + const appToken = normalizeHookToken(context.inputs?.appToken); + if (!botToken || !appToken) { + throw new Error("Slack credential validation requires botToken and appToken."); + } + + const validate = options.validateCredentials ?? validateSlackCredentials; + const validation = validate({ botToken, appToken }); + if (validation.ok) { + if (validation.skipped && validation.message) { + (options.log ?? console.log)(` ⚠ ${validation.message}`); + } + return {}; + } + + const log = options.log ?? console.log; + const formatFailure = options.formatValidationFailure ?? formatSlackValidationFailure; + const prefix = validation.kind === "rejected" ? "✗" : "⚠"; + log(` ${prefix} ${formatFailure(validation)}`); + log( + ` Skipped slack (${ + validation.kind === "rejected" + ? "invalid Slack credentials" + : "Slack API validation unavailable" + })`, + ); + throw new Error(`Slack credential validation failed: ${formatFailure(validation)}`); + }; +} + +export function createSlackValidateCredentialsHookRegistration( + options: SlackValidateCredentialsHookOptions = {}, +): MessagingHookRegistration { + return { + id: SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + handler: createSlackValidateCredentialsHook(options), + }; +} + +function normalizeHookToken(value: unknown): string { + return typeof value === "string" ? value.replace(/\r/g, "").trim() : ""; +} diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 25033ccecb..f0564c69aa 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -18,6 +18,8 @@ export const slackManifest = { kind: "secret", required: true, envKey: "SLACK_BOT_TOKEN", + formatPattern: "^xoxb-[A-Za-z0-9_-]+$", + formatHint: "Slack bot tokens start with 'xoxb-' (e.g. xoxb---).", prompt: { label: "Slack Bot Token", help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", @@ -29,6 +31,8 @@ export const slackManifest = { kind: "secret", required: true, envKey: "SLACK_APP_TOKEN", + formatPattern: "^xapp-[A-Za-z0-9_-]+$", + formatHint: "Slack app tokens start with 'xapp-' (e.g. xapp----).", prompt: { label: "Slack App Token (Socket Mode)", help: "Slack API → Your Apps → Basic Information → App-Level Tokens (xapp-...).", @@ -44,6 +48,19 @@ export const slackManifest = { prompt: { label: "Slack Member IDs (comma-separated allowlist)", help: "In Slack, open each allowed human user's profile -> More -> Copy member ID. Enter one or more comma-separated member IDs, not the app or bot user ID. Member IDs look like U01ABC2DEF3.", + emptyValueMessage: "bot will require manual pairing", + }, + }, + { + id: "allowedChannels", + kind: "config", + required: false, + envKey: "SLACK_ALLOWED_CHANNELS", + statePath: "slackConfig.allowedChannels", + prompt: { + label: "Slack Channel IDs (comma-separated allowlist)", + help: "Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.", + emptyValueMessage: "channel @mentions stay unrestricted by channel ID", }, }, ], @@ -101,12 +118,17 @@ export const slackManifest = { state: { persist: { allowedIds: ["allowedUsers"], + slackConfig: ["allowedChannels"], }, rebuildHydration: [ { statePath: "allowedIds.slack", env: "SLACK_ALLOWED_USERS", }, + { + statePath: "slackConfig.allowedChannels", + env: "SLACK_ALLOWED_CHANNELS", + }, ], }, hooks: [ @@ -128,5 +150,27 @@ export const slackManifest = { ], onFailure: "skip-channel", }, + { + id: "slack-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + outputs: [ + { + id: "allowedUsers", + kind: "config", + }, + { + id: "allowedChannels", + kind: "config", + }, + ], + }, + { + id: "slack-credential-validation", + phase: "reachability-check", + handler: "slack.validateCredentials", + inputs: ["botToken", "appToken"], + onFailure: "skip-channel", + }, ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.test.ts b/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.test.ts new file mode 100644 index 0000000000..9486f96367 --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.test.ts @@ -0,0 +1,75 @@ +// 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 type { ChannelHookSpec } from "../../../manifest"; +import { + createTelegramAllowlistAliasesHook, + TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, +} from "./allowlist-aliases"; + +const TELEGRAM_ALLOWLIST_ALIASES_HOOK = { + id: "telegram-allowlist-aliases", + phase: "enroll", + handler: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + outputs: [ + { + id: "allowedIds", + kind: "config", + }, + ], +} as const satisfies ChannelHookSpec; + +describe("Telegram allowlist aliases hook implementation", () => { + it("merges compatibility aliases into canonical TELEGRAM_ALLOWED_IDS", async () => { + const env: NodeJS.ProcessEnv = { + TELEGRAM_ALLOWED_IDS: "111, 222", + TELEGRAM_AUTHORIZED_CHAT_IDS: "333,222", + TELEGRAM_CHAT_ID: "444", + }; + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + handler: createTelegramAllowlistAliasesHook({ env }), + }, + ]); + + await expect( + runMessagingHook(TELEGRAM_ALLOWLIST_ALIASES_HOOK, registry, { + channelId: "telegram", + }), + ).resolves.toMatchObject({ + hookId: "telegram-allowlist-aliases", + handlerId: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + phase: "enroll", + outputs: { + allowedIds: { + kind: "config", + value: "111,222,333,444", + }, + }, + }); + expect(env.TELEGRAM_ALLOWED_IDS).toBe("111,222,333,444"); + }); + + it("does nothing when no canonical or alias allowlist values are present", async () => { + const env: NodeJS.ProcessEnv = {}; + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + handler: createTelegramAllowlistAliasesHook({ env }), + }, + ]); + + await expect( + runMessagingHook(TELEGRAM_ALLOWLIST_ALIASES_HOOK, registry, { + channelId: "telegram", + }), + ).resolves.toMatchObject({ + outputs: {}, + }); + expect(env.TELEGRAM_ALLOWED_IDS).toBeUndefined(); + }); +}); diff --git a/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.ts b/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.ts new file mode 100644 index 0000000000..fa53798723 --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingHookHandler, + MessagingHookOutputMap, + MessagingHookRegistration, +} from "../../../hooks/types"; + +export const TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID = "telegram.allowlistAliases"; + +export const TELEGRAM_ALLOWED_IDS_ENV = "TELEGRAM_ALLOWED_IDS"; +export const TELEGRAM_ALLOWED_IDS_ALIAS_ENVS = [ + "TELEGRAM_AUTHORIZED_CHAT_IDS", + "TELEGRAM_CHAT_ID", +] as const; + +export interface TelegramAllowlistAliasesHookOptions { + readonly env?: NodeJS.ProcessEnv; +} + +export function createTelegramAllowlistAliasesHook( + options: TelegramAllowlistAliasesHookOptions = {}, +): MessagingHookHandler { + return async () => { + const env = options.env ?? process.env; + const value = mergeTelegramAllowlistAliases(env); + const outputs: Record = {}; + if (value) { + outputs.allowedIds = { + kind: "config", + value, + }; + } + return { outputs }; + }; +} + +export function createTelegramAllowlistAliasesHookRegistration( + options: TelegramAllowlistAliasesHookOptions = {}, +): MessagingHookRegistration { + return { + id: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + handler: createTelegramAllowlistAliasesHook(options), + }; +} + +export function mergeTelegramAllowlistAliases( + env: NodeJS.ProcessEnv | Record = process.env, +): string | null { + const values = [ + env[TELEGRAM_ALLOWED_IDS_ENV], + ...TELEGRAM_ALLOWED_IDS_ALIAS_ENVS.map((key) => env[key]), + ]; + const merged = mergeAllowlistValues(values); + if (!merged) return null; + env[TELEGRAM_ALLOWED_IDS_ENV] = merged; + return merged; +} + +function mergeAllowlistValues(values: readonly unknown[]): string | null { + const ids: string[] = []; + const seen = new Set(); + for (const value of values) { + if (typeof value !== "string") continue; + const normalized = value.replace(/[\r\n]/g, "").trim(); + if (!normalized) continue; + for (const entry of normalized.split(",")) { + const id = entry.trim(); + if (!id || seen.has(id)) continue; + ids.push(id); + seen.add(id); + } + } + return ids.length > 0 ? ids.join(",") : null; +} diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts index 44754d53d8..922b535492 100644 --- a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts @@ -4,12 +4,20 @@ import { describe, expect, it } from "vitest"; import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; -import { telegramManifest } from "../manifest"; +import type { ChannelHookSpec } from "../../../manifest"; import { createTelegramGetMeReachabilityHook, TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, } from "./get-me-reachability"; +const TELEGRAM_REACHABILITY_HOOK = { + id: "telegram-reachability", + phase: "reachability-check", + handler: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + inputs: ["botToken"], + onFailure: "skip-channel", +} as const satisfies ChannelHookSpec; + describe("Telegram getMe reachability hook implementation", () => { it("calls Telegram getMe without exposing the token in outputs", async () => { const urls: string[] = []; @@ -34,12 +42,8 @@ describe("Telegram getMe reachability hook implementation", () => { }), }, ]); - const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); - - if (!hook) throw new Error("missing Telegram reachability hook"); - await expect( - runMessagingHook(hook, registry, { + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { channelId: "telegram", inputs: { botToken: "123456:telegram-token", @@ -54,15 +58,17 @@ describe("Telegram getMe reachability hook implementation", () => { expect(urls).toEqual(["https://telegram.test/bot123456:telegram-token/getMe"]); }); - it("fails closed when Telegram rejects the token", async () => { + it("fails so the compiler can skip the channel when Telegram rejects the token", async () => { + const logs: string[] = []; const registry = new MessagingHookRegistry([ { id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, handler: createTelegramGetMeReachabilityHook({ + log: (message) => logs.push(message), fetch: async () => ({ ok: false, - status: 401, - statusText: "Unauthorized", + status: 404, + statusText: "Not Found", async json() { return { ok: false }; }, @@ -73,17 +79,110 @@ describe("Telegram getMe reachability hook implementation", () => { }), }, ]); - const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); + await expect( + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { + channelId: "telegram", + inputs: { + botToken: "bad-token", + }, + }), + ).rejects.toThrow("Telegram bot token was rejected."); + expect(logs).toEqual([ + " ⚠ Bot token was rejected by Telegram — verify the token is correct.", + [ + " Telegram integration will be disabled for this enrollment run because", + "the bot token was rejected by Telegram.", + ].join(" "), + ]); + }); - if (!hook) throw new Error("missing Telegram reachability hook"); + it("fails so the compiler can skip the channel when non-interactive Bot API requests fail", async () => { + const logs: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: createTelegramGetMeReachabilityHook({ + log: (message) => logs.push(message), + fetch: async () => { + throw new Error("network unavailable"); + }, + }), + }, + ]); + await expect( + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { + channelId: "telegram", + isInteractive: false, + inputs: { + botToken: "123456:telegram-token", + }, + }), + ).rejects.toThrow("Telegram reachability check failed: Bot API request failed."); + expect(logs).toEqual([ + [ + " Telegram integration will be disabled for this enrollment run because", + "api.telegram.org is unreachable.", + ].join(" "), + ]); + }); + + it("bounds hung Bot API requests with a timeout", async () => { + const logs: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: createTelegramGetMeReachabilityHook({ + log: (message) => logs.push(message), + timeoutMs: 1, + fetch: async () => new Promise(() => {}), + }), + }, + ]); await expect( - runMessagingHook(hook, registry, { + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { channelId: "telegram", inputs: { - botToken: "bad-token", + botToken: "123456:telegram-token", + }, + }), + ).resolves.toMatchObject({ + hookId: "telegram-reachability", + outputs: {}, + }); + expect(logs).toEqual([ + " ⚠ Telegram reachability check failed: Bot API request failed.", + ]); + }); + + it("honors the explicit skip env without calling Telegram", async () => { + const urls: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: createTelegramGetMeReachabilityHook({ + env: { + NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", + }, + fetch: async (url) => { + urls.push(url); + throw new Error("fetch should not run"); + }, + }), + }, + ]); + + await expect( + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { + channelId: "telegram", + inputs: { + botToken: "123456:telegram-token", }, }), - ).rejects.toThrow("Telegram reachability check failed with HTTP 401 Unauthorized."); + ).resolves.toMatchObject({ + hookId: "telegram-reachability", + outputs: {}, + }); + expect(urls).toEqual([]); }); }); diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts index eb4729582e..43448f6569 100644 --- a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts @@ -3,8 +3,13 @@ import { normalizeCredentialValue } from "../../../../credentials/store"; import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; +import { + createTelegramAllowlistAliasesHookRegistration, + type TelegramAllowlistAliasesHookOptions, +} from "./allowlist-aliases"; export const TELEGRAM_GET_ME_REACHABILITY_HOOK_ID = "telegram.getMeReachability"; +const DEFAULT_TELEGRAM_REACHABILITY_TIMEOUT_MS = 10_000; interface TelegramFetchResponse { readonly ok: boolean; @@ -14,37 +19,64 @@ interface TelegramFetchResponse { text(): Promise; } -type TelegramFetch = (url: string) => Promise; +interface TelegramFetchOptions { + readonly signal?: AbortSignal; +} + +type TelegramFetch = ( + url: string, + options?: TelegramFetchOptions, +) => Promise; -export interface TelegramGetMeReachabilityHookOptions { +export interface TelegramGetMeReachabilityHookOptions extends TelegramAllowlistAliasesHookOptions { readonly fetch?: TelegramFetch; readonly apiBaseUrl?: string; + readonly timeoutMs?: number; + readonly log?: (message: string) => void; } export function createTelegramGetMeReachabilityHook( options: TelegramGetMeReachabilityHookOptions = {}, ): MessagingHookHandler { return async (context) => { + const env = options.env ?? process.env; + if (env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") { + return {}; + } + const rawToken = context.inputs?.botToken; const token = normalizeCredentialValue(typeof rawToken === "string" ? rawToken : ""); if (!token) { throw new Error("Telegram reachability check requires botToken."); } + const log = options.log ?? console.log; + const isInteractive = context.isInteractive !== false; const response = await fetchTelegramGetMe(token, options).catch(() => { - throw new Error("Telegram reachability check failed: Bot API request failed."); + const message = "Telegram reachability check failed: Bot API request failed."; + if (!isInteractive) { + logTelegramDisabled("api.telegram.org is unreachable", log); + throw new Error(message); + } + log(` ⚠ ${message}`); + return null; }); + if (!response) return {}; if (!response.ok) { - throw new Error( - `Telegram reachability check failed with HTTP ${response.status}${ - response.statusText ? ` ${response.statusText}` : "" - }.`, - ); + if (isRejectedTokenResponse(response)) { + logRejectedToken(log); + logTelegramDisabled("the bot token was rejected by Telegram", log); + throw new Error("Telegram bot token was rejected."); + } + logTelegramHttpWarning(response, log); + return {}; } const payload = await readTelegramJson(response); if (!isObject(payload) || payload.ok !== true) { - throw new Error("Telegram reachability check failed: Bot API rejected the token."); + logRejectedToken(log); + logTelegramDisabled("the bot token was rejected by Telegram", log); + throw new Error("Telegram bot token was rejected."); } return {}; @@ -55,6 +87,7 @@ export function createTelegramHookRegistrations( options: TelegramGetMeReachabilityHookOptions = {}, ): readonly MessagingHookRegistration[] { return [ + createTelegramAllowlistAliasesHookRegistration(options), { id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, handler: createTelegramGetMeReachabilityHook(options), @@ -68,14 +101,49 @@ async function fetchTelegramGetMe( ): Promise { const fetchImpl = options.fetch ?? defaultFetch; const baseUrl = (options.apiBaseUrl ?? "https://api.telegram.org").replace(/\/+$/, ""); - return fetchImpl(`${baseUrl}/bot${token}/getMe`); + const timeoutMs = normalizeTimeoutMs(options.timeoutMs); + return fetchWithTimeout(fetchImpl, `${baseUrl}/bot${token}/getMe`, timeoutMs); } -async function defaultFetch(url: string): Promise { +async function defaultFetch( + url: string, + options?: TelegramFetchOptions, +): Promise { if (typeof fetch !== "function") { throw new Error("Telegram reachability check requires global fetch."); } - return fetch(url) as Promise; + return fetch(url, options) as Promise; +} + +function normalizeTimeoutMs(timeoutMs: number | undefined): number { + return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DEFAULT_TELEGRAM_REACHABILITY_TIMEOUT_MS; +} + +async function fetchWithTimeout( + fetchImpl: TelegramFetch, + url: string, + timeoutMs: number, +): Promise { + const controller = typeof AbortController === "function" ? new AbortController() : null; + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + controller?.abort(); + reject(new Error("Telegram reachability check timed out.")); + }, timeoutMs); + timeout.unref?.(); + }); + + try { + return await Promise.race([ + fetchImpl(url, controller ? { signal: controller.signal } : undefined), + timeoutPromise, + ]); + } finally { + if (timeout) clearTimeout(timeout); + } } async function readTelegramJson(response: TelegramFetchResponse): Promise { @@ -89,3 +157,26 @@ async function readTelegramJson(response: TelegramFetchResponse): Promise { return typeof value === "object" && value !== null && !Array.isArray(value); } + +function isRejectedTokenResponse(response: TelegramFetchResponse): boolean { + return response.status === 401 || response.status === 404; +} + +function logRejectedToken(log: (message: string) => void): void { + log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); +} + +function logTelegramDisabled(reason: string, log: (message: string) => void): void { + log(` Telegram integration will be disabled for this enrollment run because ${reason}.`); +} + +function logTelegramHttpWarning( + response: TelegramFetchResponse, + log: (message: string) => void, +): void { + log( + ` ⚠ Telegram API returned HTTP ${response.status}${ + response.statusText ? ` ${response.statusText}` : "" + } — the bot may not work correctly.`, + ); +} diff --git a/src/lib/messaging/channels/telegram/hooks/index.ts b/src/lib/messaging/channels/telegram/hooks/index.ts index bafffe4fcb..604e80d5ca 100644 --- a/src/lib/messaging/channels/telegram/hooks/index.ts +++ b/src/lib/messaging/channels/telegram/hooks/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export * from "./get-me-reachability"; +export * from "./allowlist-aliases"; diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index e5faf9dffc..d0af4167bd 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -8,6 +8,10 @@ export const telegramManifest = { id: "telegram", displayName: "Telegram", description: "Telegram bot messaging", + enrollmentNotes: [ + "For Telegram group chats, disable privacy mode in @BotFather (/setprivacy -> your bot -> Disable).", + "After changing privacy mode, remove and re-add the bot to each group before testing @mentions.", + ], supportedAgents: ["openclaw", "hermes"], auth: { mode: "token-paste", @@ -32,6 +36,7 @@ export const telegramManifest = { prompt: { label: "Telegram User ID (for DM access)", help: "Send /start to @userinfobot on Telegram to get your numeric user ID.", + emptyValueMessage: "bot will require manual pairing", }, }, { @@ -146,11 +151,37 @@ export const telegramManifest = { onFailure: "skip-channel", }, { - id: "telegram-reachability", + id: "telegram-allowlist-aliases", + phase: "enroll", + handler: "telegram.allowlistAliases", + outputs: [ + { + id: "allowedIds", + kind: "config", + }, + ], + }, + { + id: "telegram-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + outputs: [ + { + id: "requireMention", + kind: "config", + }, + { + id: "allowedIds", + kind: "config", + }, + ], + }, + { + id: "telegram-get-me-reachability", phase: "reachability-check", handler: "telegram.getMeReachability", inputs: ["botToken"], - onFailure: "abort", + onFailure: "skip-channel", }, ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts b/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts new file mode 100644 index 0000000000..8b7112b5cf --- /dev/null +++ b/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { saveCredential } from "../../../../credentials/store"; +import { + HOST_QR_LOGIN_HANDLERS, + type HostQrLoginResult, +} from "../../../../host-qr-handlers"; +import { wechatManifest } from "../manifest"; +import type { WechatIlinkLoginHookOptions, WechatLoginResult } from "./ilink-login"; + +export function createDefaultWechatHostQrLoginOptions(): WechatIlinkLoginHookOptions { + return { + saveCredential, + runLogin: createWechatHostQrLoginRunner(), + }; +} + +function createWechatHostQrLoginRunner(): () => Promise { + return async () => { + logEnrollmentHelp(); + const handler = HOST_QR_LOGIN_HANDLERS.wechat; + if (!handler) return { kind: "error", message: "no host-qr handler registered" }; + + let result: HostQrLoginResult; + try { + result = await handler(); + } catch (error) { + result = { kind: "error", message: error instanceof Error ? error.message : String(error) }; + } + + if (result.kind !== "ok") { + return result.kind === "error" + ? { kind: "error", message: result.message } + : { kind: result.kind }; + } + if (!result.token) { + return { kind: "error", message: "host-qr handler returned no token" }; + } + + const accountId = result.extraEnv?.WECHAT_ACCOUNT_ID; + if (!accountId) { + return { kind: "error", message: "host-qr handler returned no WeChat account id" }; + } + + return { + kind: "ok", + summary: result.summary, + credentials: { + token: result.token, + accountId, + baseUrl: result.extraEnv?.WECHAT_BASE_URL, + userId: result.extraEnv?.WECHAT_USER_ID ?? result.defaultUserId, + }, + }; + }; +} + +function logEnrollmentHelp(): void { + const help = wechatManifest.enrollmentHelp ?? wechatManifest.inputs[0]?.prompt?.help; + if (!help) return; + console.log(""); + console.log(` ${help}`); +} diff --git a/src/lib/messaging/channels/wechat/hooks/ilink-login.ts b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts index 77322519f7..73e15ed3a4 100644 --- a/src/lib/messaging/channels/wechat/hooks/ilink-login.ts +++ b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts @@ -16,7 +16,11 @@ export interface WechatLoginCredentials { } export type WechatLoginResult = - | { readonly kind: "ok"; readonly credentials: WechatLoginCredentials } + | { + readonly kind: "ok"; + readonly credentials: WechatLoginCredentials; + readonly summary?: string; + } | { readonly kind: "timeout" } | { readonly kind: "expired"; readonly reason?: string } | { readonly kind: "aborted" } @@ -28,12 +32,20 @@ export interface WechatIlinkLoginHookOptions { readonly env?: NodeJS.ProcessEnv; readonly runLogin?: () => Promise; readonly saveCredential?: (key: string, value: string) => void; + readonly log?: (message: string) => void; } export function createWechatIlinkLoginHook( options: WechatIlinkLoginHookOptions = {}, ): MessagingHookHandler { return async (context) => { + if (context.isInteractive === false) { + (options.log ?? console.log)( + ` Skipped ${context.channelId} (host QR login requires interactive mode)`, + ); + throw new Error("WeChat host QR login requires interactive mode."); + } + const runLogin = options.runLogin; if (!runLogin) { throw new Error( @@ -42,7 +54,9 @@ export function createWechatIlinkLoginHook( } const result = await runLogin(); if (result.kind !== "ok") { - throw new Error(`WeChat host QR login failed: ${wechatFailureReason(result)}.`); + const reason = wechatFailureReason(result); + (options.log ?? console.log)(` Skipped ${context.channelId} (${reason})`); + throw new Error(`WeChat host QR login failed: ${reason}.`); } const env = options.env ?? process.env; @@ -59,6 +73,8 @@ export function createWechatIlinkLoginHook( env.WECHAT_ACCOUNT_ID = accountId; if (baseUrl) env.WECHAT_BASE_URL = baseUrl; if (userId) env.WECHAT_USER_ID = userId; + const suffix = result.summary ? ` (${result.summary})` : ""; + (options.log ?? console.log)(` ✓ ${context.channelId} token saved${suffix}`); const outputs: Record = { botToken: { diff --git a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts index 5e21fb2991..8ed4a14613 100644 --- a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts +++ b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts @@ -41,6 +41,7 @@ describe("WeChat hook implementations", () => { id: WECHAT_ILINK_LOGIN_HOOK_ID, handler: createWechatIlinkLoginHook({ env, + log: () => {}, saveCredential: (key, value) => saved.push({ key, value }), runLogin: async () => ({ kind: "ok", @@ -100,6 +101,7 @@ describe("WeChat hook implementations", () => { id: WECHAT_ILINK_LOGIN_HOOK_ID, handler: createWechatIlinkLoginHook({ env, + log: () => {}, saveCredential: (key, value) => saved.push({ key, value }), runLogin: async () => ({ kind: "timeout" }), }), @@ -166,7 +168,9 @@ describe("WeChat hook implementations", () => { }), }, ]); - const hook = wechatManifest.hooks[1]; + const hook = wechatManifest.hooks.find( + (entry) => entry.id === "wechat-seed-openclaw-account", + ); if (!hook) throw new Error("missing WeChat seed hook"); diff --git a/src/lib/messaging/channels/wechat/hooks/index.ts b/src/lib/messaging/channels/wechat/hooks/index.ts index db09732c15..6a78df8406 100644 --- a/src/lib/messaging/channels/wechat/hooks/index.ts +++ b/src/lib/messaging/channels/wechat/hooks/index.ts @@ -3,6 +3,7 @@ import type { MessagingHookRegistration } from "../../../hooks/types"; import { createWechatHealthCheckHookRegistration } from "./health-check"; +import { createDefaultWechatHostQrLoginOptions } from "./host-qr-login-runtime"; import { createWechatIlinkLoginHookRegistration, type WechatIlinkLoginHookOptions, @@ -24,9 +25,21 @@ export interface WechatHookOptions { export function createWechatHookRegistrations( options: WechatHookOptions = {}, ): readonly MessagingHookRegistration[] { + const ilinkLoginOptions = { + ...createDefaultWechatHostQrLoginOptions(), + ...withoutUndefinedValues(options.ilinkLogin), + }; return [ - createWechatIlinkLoginHookRegistration(options.ilinkLogin), + createWechatIlinkLoginHookRegistration(ilinkLoginOptions), createWechatSeedOpenClawAccountHookRegistration(options.seedOpenClawAccount), createWechatHealthCheckHookRegistration(), ] as const; } + +function withoutUndefinedValues( + options: WechatIlinkLoginHookOptions | undefined, +): WechatIlinkLoginHookOptions { + return Object.fromEntries( + Object.entries(options ?? {}).filter(([, value]) => value !== undefined), + ) as WechatIlinkLoginHookOptions; +} diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index f6c245a123..7c87b38820 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -8,6 +8,8 @@ export const wechatManifest = { id: "wechat", displayName: "WeChat", description: "WeChat (personal) bot messaging", + enrollmentHelp: + "Captured automatically via a host-side QR scan during onboard — pair the bot by scanning the QR with WeChat on your phone (Discover → Scan). DM-only.", supportedAgents: ["openclaw", "hermes"], auth: { mode: "host-qr", @@ -53,6 +55,7 @@ export const wechatManifest = { prompt: { label: "WeChat User ID(s) (DM allowlist)", help: "Optional: restrict who can DM the bot. The WeChat user id of the operator who scanned is added automatically; supply additional ids as a comma-separated list.", + emptyValueMessage: "bot will require manual pairing", }, }, ], @@ -136,6 +139,17 @@ export const wechatManifest = { ], onFailure: "skip-channel", }, + { + id: "wechat-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + outputs: [ + { + id: "allowedIds", + kind: "config", + }, + ], + }, { id: "wechat-seed-openclaw-account", phase: "post-agent-install", diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index 906399f4a2..c6f931c0df 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -8,6 +8,11 @@ export const whatsappManifest = { id: "whatsapp", displayName: "WhatsApp", description: "WhatsApp Web messaging (QR pairing)", + enrollmentHelp: + "WhatsApp Web pairs via QR code scanned with your phone — no host-side token. After the sandbox is running, run `openshell term` and then use `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes to display the QR.", + enrollmentNotes: [ + "After pairing, run `nemoclaw channels status --channel whatsapp` to confirm the bridge is delivering inbound messages — pairing alone does not guarantee inbound delivery (issue #4386).", + ], supportedAgents: ["openclaw", "hermes"], auth: { mode: "in-sandbox-qr", diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 237a0b94bc..82b5bf8c23 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -6,8 +6,8 @@ import { describe, expect, it } from "vitest"; import { createBuiltInChannelManifestRegistry } from "../channels"; import { createBuiltInMessagingHookRegistry, MessagingHookRegistry } from "../hooks"; import { - ChannelManifestRegistry, type ChannelManifest, + ChannelManifestRegistry, type SandboxMessagingPlan, } from "../manifest"; import { ManifestCompiler } from "./manifest-compiler"; @@ -35,9 +35,15 @@ function compiler(): ManifestCompiler { env: {}, getCredential: (key) => TEST_CREDENTIALS[key] ?? null, saveCredential: () => {}, - prompt: async () => "unused", + prompt: async () => "", log: () => {}, }, + slack: { + validateCredentials: { + log: () => {}, + validateCredentials: () => ({ ok: true }), + }, + }, telegram: { fetch: async () => ({ ok: true, @@ -53,6 +59,7 @@ function compiler(): ManifestCompiler { wechat: { ilinkLogin: { env: {}, + log: () => {}, saveCredential: () => {}, runLogin: async () => ({ kind: "ok", @@ -234,13 +241,26 @@ describe("ManifestCompiler", () => { }); it("compiles Hermes render and manifest-owned WeChat policy intent", async () => { - const plan = await compiler().compile({ - sandboxName: "demo", - agent: "hermes", - workflow: "rebuild", - isInteractive: false, - configuredChannels: ALL_CHANNELS, - }); + const plan = await withEnv( + { + WECHAT_ACCOUNT_ID: "test-wechat-account", + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "hermes", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ALL_CHANNELS, + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + DISCORD_BOT_TOKEN: true, + WECHAT_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }), + ); expect(plan.networkPolicy.entries.find((entry) => entry.channelId === "wechat")).toEqual({ channelId: "wechat", @@ -265,7 +285,39 @@ describe("ManifestCompiler", () => { plan.channels .find((channel) => channel.channelId === "wechat") ?.inputs.find((input) => input.inputId === "accountId"), - ).not.toHaveProperty("value"); + ).toMatchObject({ + kind: "config", + value: "test-wechat-account", + }); + }); + + it("does not activate a requested channel while any required manifest input is missing", async () => { + const plan = await withEnv( + { + WECHAT_ACCOUNT_ID: undefined, + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "hermes", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["wechat"], + credentialAvailability: { + WECHAT_BOT_TOKEN: true, + }, + }), + ); + + expect(plan.channels[0]).toMatchObject({ + channelId: "wechat", + active: false, + disabled: true, + }); + expect(plan.disabledChannels).toEqual(["wechat"]); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["wechat"]); + expect(plan.agentRender.map((render) => render.channelId)).toEqual(["wechat"]); + expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["wechat"]); }); it("runs enrollment hooks before returning the final channel input plan", async () => { @@ -334,7 +386,9 @@ describe("ManifestCompiler", () => { expect(plan.disabledChannels).toEqual(["telegram"]); expect(plan.channels[0]?.hooks.map((hook) => hook.id)).toEqual([ "telegram-token-paste", - "telegram-reachability", + "telegram-allowlist-aliases", + "telegram-config-prompt", + "telegram-get-me-reachability", ]); expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual([ "telegram", @@ -356,18 +410,95 @@ describe("ManifestCompiler", () => { expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["telegram"]); }); - it("skips token-paste and QR enrollment hooks for non-interactive create plans", async () => { + it("runs non-interactive enrollment hooks to validate and feed reachability checks", async () => { + const hookCalls: string[] = []; const hooks = new MessagingHookRegistry([ { id: "common.tokenPaste", - handler: () => { - throw new Error("token-paste hook should not run"); + handler: (context) => { + hookCalls.push(`token-paste-input:${String(context.inputs?.botToken)}`); + const token = process.env.TELEGRAM_BOT_TOKEN ?? "missing"; + return { + outputs: { + botToken: { + kind: "secret", + value: token, + }, + }, + }; }, }, + { + id: "common.configPrompt", + handler: () => ({}), + }, + { + id: "telegram.allowlistAliases", + handler: () => ({}), + }, { id: "telegram.getMeReachability", + handler: (context) => { + hookCalls.push(`reachability:${String(context.inputs?.botToken)}`); + return {}; + }, + }, + ]); + const plan = await withEnv( + { + TELEGRAM_BOT_TOKEN: "123456:raw-telegram-token", + }, + () => + new ManifestCompiler(createBuiltInChannelManifestRegistry(), hooks).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + }, + }), + ); + + expect(plan.channels[0]?.inputs.find((input) => input.inputId === "botToken")).toMatchObject({ + kind: "secret", + credentialAvailable: true, + }); + expect(hookCalls).toEqual([ + "token-paste-input:undefined", + "reachability:123456:raw-telegram-token", + ]); + expect(JSON.stringify(plan)).not.toContain("123456:raw-telegram-token"); + }); + + it("disables a channel when a reachability check opts to skip it", async () => { + const hooks = new MessagingHookRegistry([ + { + id: "common.tokenPaste", + handler: () => ({ + outputs: { + botToken: { + kind: "secret", + value: "123456:raw-telegram-token", + }, + }, + }), + }, + { + id: "common.configPrompt", handler: () => ({}), }, + { + id: "telegram.allowlistAliases", + handler: () => ({}), + }, + { + id: "telegram.getMeReachability", + handler: () => { + throw new Error("telegram is unreachable"); + }, + }, ]); const plan = await new ManifestCompiler( createBuiltInChannelManifestRegistry(), @@ -378,15 +509,16 @@ describe("ManifestCompiler", () => { workflow: "onboard", isInteractive: false, configuredChannels: ["telegram"], - credentialAvailability: { - TELEGRAM_BOT_TOKEN: true, - }, }); - expect(plan.channels[0]?.inputs.find((input) => input.inputId === "botToken")).toMatchObject({ - kind: "secret", - credentialAvailable: true, + expect(plan.channels[0]).toMatchObject({ + channelId: "telegram", + active: false, + selected: true, + configured: false, + disabled: true, }); + expect(plan.disabledChannels).toEqual(["telegram"]); }); it("reads input values from env keys before returning non-interactive plans", async () => { @@ -491,7 +623,9 @@ describe("ManifestCompiler", () => { expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["telegram"]); expect(plan.channels[0]?.hooks.map((hook) => hook.id)).toEqual([ "telegram-token-paste", - "telegram-reachability", + "telegram-allowlist-aliases", + "telegram-config-prompt", + "telegram-get-me-reachability", ]); }); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index dd6863236a..30a5a5d30a 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -2,24 +2,26 @@ // SPDX-License-Identifier: Apache-2.0 import type { + MessagingHookInputMap, + MessagingHookOutputMap, + MessagingHookRunResult, +} from "../hooks"; +import { MessagingHookRegistry, runMessagingHook } from "../hooks"; +import type { + ChannelHookOutputSpec, ChannelHookSpec, ChannelInputSpec, ChannelManifest, ChannelManifestRegistry, MessagingChannelId, - MessagingStatePath, MessagingSerializableValue, + MessagingStatePath, SandboxMessagingChannelPlan, SandboxMessagingHookReferencePlan, SandboxMessagingInputReference, SandboxMessagingPlan, } from "../manifest"; -import { MessagingHookRegistry, runMessagingHook } from "../hooks"; -import type { - MessagingHookInputMap, - MessagingHookOutputMap, - MessagingHookRunResult, -} from "../hooks"; +import { resolveMessagingChannelConfigEnvValue } from "../../messaging-channel-config"; import { planAgentRender } from "./engines/agent-render-engine"; import { planBuildSteps } from "./engines/build-step-engine"; import { planCredentialBindings } from "./engines/credential-binding-engine"; @@ -111,13 +113,15 @@ export class ManifestCompiler { const requestedActive = !disabled && requested; const resolvedInputs = await resolveChannelInputs(manifest, context, this.hooks, { runEnrollment: + selected && requestedActive && isEnrollmentWorkflow(context.workflow), + runEnrollmentChecks: selected && requestedActive && - isEnrollmentWorkflow(context.workflow) && - context.isInteractive, - runEnrollmentChecks: selected && requestedActive && isEnrollmentWorkflow(context.workflow), + isEnrollmentWorkflow(context.workflow), + isInteractive: context.isInteractive, }); - const active = requestedActive && !resolvedInputs.skipped; + const requiredInputsAvailable = hasRequiredInputsAvailable(manifest, resolvedInputs.inputs); + const active = requestedActive && !resolvedInputs.skipped && requiredInputsAvailable; return { channelId: manifest.id, @@ -126,7 +130,8 @@ export class ManifestCompiler { active, selected, configured: configured && !resolvedInputs.skipped, - disabled: disabled || resolvedInputs.skipped, + disabled: + disabled || resolvedInputs.skipped || (requestedActive && !requiredInputsAvailable), inputs: resolvedInputs.inputs, hooks: requested ? manifest.hooks @@ -173,14 +178,18 @@ async function resolveChannelInputs( manifest: ChannelManifest, context: ManifestCompilerContext, hooks: MessagingHookRegistry, - options: { readonly runEnrollment: boolean; readonly runEnrollmentChecks: boolean }, + options: { + readonly runEnrollment: boolean; + readonly runEnrollmentChecks: boolean; + readonly isInteractive: boolean; + }, ): Promise<{ readonly inputs: SandboxMessagingInputReference[]; readonly skipped: boolean; }> { let inputs = manifest.inputs.map((input) => resolveChannelInput(manifest, input, context)); - let hookInputs = buildCompilerHookInputs(manifest, inputs); inputs = applyCredentialAvailability(manifest, inputs, context); + let hookInputs = buildCompilerHookInputs(manifest, inputs); const enrollmentHooks = options.runEnrollment ? manifest.hooks .filter((hook) => isHookForAgent(hook, context.agent)) @@ -189,7 +198,14 @@ async function resolveChannelInputs( let skipped = false; for (const hook of enrollmentHooks) { - const result = await runCompilerHook(manifest, hook, hooks, hookInputs); + if (!shouldRunEnrollmentHook(hook, inputs)) continue; + const result = await runCompilerHook( + manifest, + hook, + hooks, + hookInputs, + options.isInteractive, + ); if (!result) { skipped = true; break; @@ -207,7 +223,17 @@ async function resolveChannelInputs( .filter((entry) => isHookForAgent(entry, context.agent)) .filter((entry) => entry.phase === "reachability-check") .filter((entry) => hasDeclaredHookInputs(hookInputs, entry))) { - await runCompilerHook(manifest, hook, hooks, hookInputs); + const result = await runCompilerHook( + manifest, + hook, + hooks, + hookInputs, + options.isInteractive, + ); + if (!result) { + skipped = true; + break; + } } } @@ -219,10 +245,12 @@ async function runCompilerHook( hook: ChannelHookSpec, hooks: MessagingHookRegistry, inputs: MessagingHookInputMap, + isInteractive: boolean, ): Promise { try { return await runMessagingHook(hook, hooks, { channelId: manifest.id, + isInteractive, inputs: selectDeclaredHookInputs(hook, inputs), }); } catch (error) { @@ -267,8 +295,13 @@ function inputReferenceBase( function readInputEnvValue(input: ChannelInputSpec): MessagingSerializableValue | undefined { if (!input.envKey) return undefined; + if (input.kind === "config") { + const resolved = resolveMessagingChannelConfigEnvValue(input.envKey, process.env); + if (resolved.value) return resolved.value; + } const value = process.env[input.envKey]; - return value && value.length > 0 ? value : undefined; + const normalized = value?.replace(/\r/g, "").trim(); + return normalized && normalized.length > 0 ? normalized : undefined; } function readInputStatePath(input: ChannelInputSpec): MessagingStatePath | undefined { @@ -312,12 +345,49 @@ function hasRequiredInputsAvailable( 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; + return isInputReferenceAvailable(resolved); }); } +function isInputReferenceAvailable(input: SandboxMessagingInputReference): boolean { + if (input.kind === "secret") return input.credentialAvailable === true; + if (input.value === undefined) return false; + return typeof input.value === "string" ? input.value.trim().length > 0 : true; +} + +function shouldRunEnrollmentHook( + hook: ChannelHookSpec, + inputs: readonly SandboxMessagingInputReference[], +): boolean { + if (hook.handler.endsWith(".tokenPaste")) return true; + + const outputs = hook.outputs ?? []; + if (outputs.length === 0) return true; + + const requiredOutputs = outputs.filter((output) => output.required); + if (requiredOutputs.length > 0) { + return requiredOutputs.some((output) => !isHookOutputAvailable(output, inputs)); + } + + if (outputs.every((output) => output.kind === "config")) return true; + return outputs.some((output) => !isHookOutputAvailable(output, inputs)); +} + +function isHookOutputAvailable( + output: ChannelHookOutputSpec, + inputs: readonly SandboxMessagingInputReference[], +): boolean { + const input = inputs.find((entry) => entry.inputId === output.id); + if (!input) return false; + if (output.kind === "secret") { + return input.kind === "secret" && input.credentialAvailable === true; + } + if (output.kind === "config") { + return input.kind === "config" && input.value !== undefined; + } + return false; +} + function buildCompilerHookInputs( manifest: ChannelManifest, inputs: readonly SandboxMessagingInputReference[], diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 2ad6252f59..9a07b5f307 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -32,6 +32,12 @@ function planner(): MessagingWorkflowPlanner { prompt: async () => "unused", log: () => {}, }, + slack: { + validateCredentials: { + log: () => {}, + validateCredentials: () => ({ ok: true }), + }, + }, telegram: { fetch: async () => ({ ok: true, @@ -48,6 +54,7 @@ function planner(): MessagingWorkflowPlanner { ilinkLogin: { env: {}, saveCredential: () => {}, + log: () => {}, runLogin: async () => ({ kind: "ok", credentials: TEST_WECHAT_LOGIN, @@ -188,15 +195,29 @@ describe("MessagingWorkflowPlanner", () => { const outputs: Record = {}; for (const output of context.outputDeclarations ?? []) { if (output.kind === "secret") { + const value = + context.channelId === "slack" && output.id === "botToken" + ? "xoxb-test-slack-bot-token" + : context.channelId === "slack" && output.id === "appToken" + ? "xapp-test-slack-app-token" + : `test-${context.channelId}-${output.id}`; outputs[output.id] = { kind: "secret", - value: `test-${context.channelId}-${output.id}`, + value, }; } } return { outputs }; }, }, + { + id: "common.configPrompt", + handler: () => ({ outputs: {} }), + }, + { + id: "slack.validateCredentials", + handler: () => ({}), + }, ]); const plan = await new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), @@ -222,6 +243,60 @@ describe("MessagingWorkflowPlanner", () => { ).toBe(true); }); + it("does not re-run host-QR enrollment when required manifest inputs are already available", async () => { + const hooks = new MessagingHookRegistry([ + { + id: "wechat.ilinkLogin", + handler: () => { + throw new Error("cached host-QR inputs should not re-enroll"); + }, + }, + { + id: "common.configPrompt", + handler: () => ({ outputs: {} }), + }, + { + id: "slack.validateCredentials", + handler: () => ({}), + }, + ]); + + await withEnv( + { + WECHAT_ACCOUNT_ID: "cached-wechat-account", + WECHAT_ALLOWED_IDS: "cached-wechat-user", + }, + async () => { + const plan = await new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + hooks, + ).buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: true, + configuredChannels: ["wechat"], + credentialAvailability: { + WECHAT_BOT_TOKEN: true, + }, + }); + + expect(plan.channels[0]).toMatchObject({ + channelId: "wechat", + active: true, + selected: true, + configured: true, + }); + expect(plan.channels[0]?.inputs).toContainEqual( + expect.objectContaining({ + inputId: "accountId", + value: "cached-wechat-account", + }), + ); + }, + ); + }); + it("records disabled configured channels for stop-channel plans", async () => { const plan = await planner().buildPlan({ sandboxName: "demo", @@ -230,6 +305,10 @@ describe("MessagingWorkflowPlanner", () => { isInteractive: false, configuredChannels: ["telegram", "slack"], disabledChannels: ["telegram"], + credentialAvailability: { + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, }); expect(plan.workflow).toBe("stop-channel"); @@ -291,6 +370,10 @@ describe("MessagingWorkflowPlanner", () => { isInteractive: false, configuredChannels: ["wechat", "slack"], disabledChannels: ["wechat"], + credentialAvailability: { + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, }); expect(plan.workflow).toBe("remove-channel"); @@ -306,14 +389,24 @@ describe("MessagingWorkflowPlanner", () => { }); it("builds rebuild plans from configured and disabled registry snapshots", async () => { - const plan = await planner().buildPlan({ - sandboxName: "demo", - agent: "openclaw", - workflow: "rebuild", - isInteractive: false, - configuredChannels: ["telegram", "discord", "wechat"], - disabledChannels: ["discord"], - }); + const plan = await withEnv( + { + WECHAT_ACCOUNT_ID: "test-wechat-account", + }, + () => + planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["telegram", "discord", "wechat"], + disabledChannels: ["discord"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + WECHAT_BOT_TOKEN: true, + }, + }), + ); expect(plan.workflow).toBe("rebuild"); expect(plan.disabledChannels).toEqual(["discord"]); @@ -335,6 +428,253 @@ describe("MessagingWorkflowPlanner", () => { ]); }); + it("adds one manifest channel into an existing sandbox entry plan", async () => { + const existingPlan = await planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + }, + }); + 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") { + const value = + context.channelId === "slack" && output.id === "botToken" + ? "xoxb-test-slack-bot-token" + : context.channelId === "slack" && output.id === "appToken" + ? "xapp-test-slack-app-token" + : `test-${context.channelId}-${output.id}`; + outputs[output.id] = { + kind: "secret", + value, + }; + } + } + return { outputs }; + }, + }, + { + id: "common.configPrompt", + handler: () => ({ outputs: {} }), + }, + { + id: "slack.validateCredentials", + handler: () => ({}), + }, + ]); + + const plan = await new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + hooks, + ).buildChannelAddPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { + schemaVersion: 1, + plan: existingPlan, + }, + }, + channelId: "slack", + isInteractive: true, + supportedChannelIds: ["telegram", "slack"], + }); + + expect(plan.workflow).toBe("add-channel"); + expect(plan.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + "slack", + ]); + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + active: true, + configured: true, + }); + expect(plan.channels.find((channel) => channel.channelId === "slack")).toMatchObject({ + active: true, + configured: true, + disabled: false, + }); + expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual([ + "telegram", + "slack", + "slack", + ]); + }); + + it("mutates disabled channel state in an existing sandbox entry plan", async () => { + const existingPlan = await planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram", "slack"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }); + + const stopped = await planner().buildChannelStopPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { + schemaVersion: 1, + plan: existingPlan, + }, + }, + channelId: "telegram", + }); + + expect(stopped?.workflow).toBe("stop-channel"); + expect(stopped?.disabledChannels).toEqual(["telegram"]); + expect(stopped?.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + active: false, + disabled: true, + }); + + const started = await planner().buildChannelStartPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { + schemaVersion: 1, + plan: stopped!, + }, + }, + channelId: "telegram", + }); + + expect(started?.workflow).toBe("start-channel"); + expect(started?.disabledChannels).toEqual([]); + expect(started?.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + active: true, + disabled: false, + }); + }); + + it("removes a channel and its dependent plan entries from an existing sandbox entry plan", async () => { + const existingPlan = await planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram", "slack"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }); + + const removed = await planner().buildChannelRemovePlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { + schemaVersion: 1, + plan: existingPlan, + }, + }, + channelId: "telegram", + }); + + expect(removed?.workflow).toBe("remove-channel"); + expect(removed?.channels.map((channel) => channel.channelId)).toEqual(["slack"]); + expect(removed?.disabledChannels).toEqual([]); + expect( + removed?.credentialBindings.some((binding) => binding.channelId === "telegram"), + ).toBe(false); + expect( + removed?.networkPolicy.entries.some((entry) => entry.channelId === "telegram"), + ).toBe(false); + expect(removed?.agentRender.some((entry) => entry.channelId === "telegram")).toBe(false); + }); + + it("rebuilds from stored plan input values when config env is unavailable", async () => { + const existingPlan = await withEnv( + { + TELEGRAM_REQUIRE_MENTION: "1", + }, + () => + planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + }, + }), + ); + + await withEnv( + { + TELEGRAM_REQUIRE_MENTION: undefined, + }, + async () => { + const rebuilt = await planner().buildRebuildPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messagingChannels: ["telegram"], + messaging: { + schemaVersion: 1, + plan: existingPlan, + }, + }, + }); + + expect(rebuilt?.workflow).toBe("rebuild"); + expect( + rebuilt?.channels + .find((channel) => channel.channelId === "telegram") + ?.inputs.find((input) => input.inputId === "requireMention"), + ).toMatchObject({ + value: "1", + }); + expect(rebuilt?.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + active: true, + disabled: false, + }); + }, + ); + }); + + it("does not compile a rebuild plan when the sandbox entry has no stored plan", async () => { + const rebuilt = await planner().buildRebuildPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messagingChannels: ["telegram"], + providerCredentialHashes: { + TELEGRAM_BOT_TOKEN: "sha256:test", + }, + }, + }); + + expect(rebuilt).toBeNull(); + }); + it("reports unsupported channels deterministically before compiling", async () => { await expect( planner().buildPlan({ diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index 8371b2d1ad..853ff39456 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -7,6 +7,7 @@ import type { MessagingAgentId, MessagingChannelId, MessagingCompilerWorkflow, + SandboxMessagingChannelPlan, SandboxMessagingPlan, } from "../manifest"; import { ManifestCompiler } from "./manifest-compiler"; @@ -56,6 +57,65 @@ export class MessagingWorkflowPlanner { return this.compiler.compile(compilerContext); } + async buildChannelAddPlanFromSandboxEntry( + context: MessagingWorkflowPlannerChannelAddContext, + ): Promise { + const existingPlan = readSandboxEntryPlan(context); + const compiledPlan = await this.buildPlan({ + sandboxName: context.sandboxName, + agent: context.agent, + workflow: "add-channel", + isInteractive: context.isInteractive, + configuredChannels: [context.channelId], + disabledChannels: [], + supportedChannelIds: context.supportedChannelIds, + credentialAvailability: mergeAvailability( + credentialAvailabilityFromPlan(existingPlan), + this.credentialAvailabilityFromSandboxEntry( + context.sandboxEntry, + [context.channelId], + ), + context.credentialAvailability, + ), + }); + return existingPlan + ? mergeSandboxMessagingPlans(existingPlan, compiledPlan) + : compiledPlan; + } + + async buildChannelStopPlanFromSandboxEntry( + context: MessagingWorkflowPlannerChannelMutationContext, + ): Promise { + const plan = await this.planForSandboxEntryMutation(context, "stop-channel"); + return plan ? setPlanChannelDisabled(plan, context.channelId, true, "stop-channel") : null; + } + + async buildChannelStartPlanFromSandboxEntry( + context: MessagingWorkflowPlannerChannelMutationContext, + ): Promise { + const plan = await this.planForSandboxEntryMutation(context, "start-channel"); + return plan ? setPlanChannelDisabled(plan, context.channelId, false, "start-channel") : null; + } + + async buildChannelRemovePlanFromSandboxEntry( + context: MessagingWorkflowPlannerChannelMutationContext, + ): Promise { + const plan = await this.planForSandboxEntryMutation(context, "remove-channel"); + return plan ? removePlanChannel(plan, context.channelId, "remove-channel") : null; + } + + async buildRebuildPlanFromSandboxEntry( + context: MessagingWorkflowPlannerSandboxRebuildContext, + ): Promise { + const existingPlan = readSandboxEntryPlan(context); + if (!existingPlan) return null; + return setPlanDisabledChannels( + existingPlan, + disabledChannelsFromSandboxEntry(context.sandboxEntry, existingPlan), + "rebuild", + ); + } + private assertSupportedChannels( channelIds: readonly MessagingChannelId[], context: Pick< @@ -92,8 +152,74 @@ export class MessagingWorkflowPlanner { .filter((manifest) => !supportedFilter || supportedFilter.has(manifest.id)) .map((manifest) => manifest.id); } + + private async planForSandboxEntryMutation( + context: MessagingWorkflowPlannerChannelMutationContext, + workflow: MessagingCompilerWorkflow, + ): Promise { + const existingPlan = readSandboxEntryPlan(context); + if (existingPlan) return { ...clonePlan(existingPlan), workflow }; + return null; + } + + private credentialAvailabilityFromSandboxEntry( + sandboxEntry: MessagingWorkflowPlannerSandboxEntry | null | undefined, + channelIds: readonly MessagingChannelId[], + ): MessagingCompilerCredentialAvailability | undefined { + const hashes = sandboxEntry?.providerCredentialHashes; + if (!hashes || Object.keys(hashes).length === 0) return undefined; + + const availability: Record = {}; + for (const channelId of channelIds) { + const manifest = this.registry.get(channelId); + if (!manifest) continue; + for (const credential of manifest.credentials) { + if (!hashes[credential.providerEnvKey]) continue; + availability[credential.sourceInput] = true; + availability[`${manifest.id}.${credential.sourceInput}`] = true; + availability[credential.id] = true; + availability[`${manifest.id}.${credential.id}`] = true; + availability[credential.providerEnvKey] = true; + } + } + return Object.keys(availability).length > 0 ? availability : undefined; + } +} + +export interface MessagingWorkflowPlannerSandboxEntry { + readonly name: string; + readonly agent?: string | null; + readonly messagingChannels?: readonly MessagingChannelId[] | null; + readonly disabledChannels?: readonly MessagingChannelId[] | null; + readonly providerCredentialHashes?: Readonly> | null; + readonly messaging?: { + readonly schemaVersion: 1; + readonly plan: SandboxMessagingPlan; + } | null; +} + +export interface MessagingWorkflowPlannerSandboxContext { + readonly sandboxName: string; + readonly agent: MessagingAgentId; + readonly sandboxEntry?: MessagingWorkflowPlannerSandboxEntry | null; + readonly supportedChannelIds?: readonly MessagingChannelId[]; + readonly credentialAvailability?: MessagingCompilerCredentialAvailability; } +export interface MessagingWorkflowPlannerChannelAddContext + extends MessagingWorkflowPlannerSandboxContext { + readonly channelId: MessagingChannelId; + readonly isInteractive: boolean; +} + +export interface MessagingWorkflowPlannerChannelMutationContext + extends MessagingWorkflowPlannerSandboxContext { + readonly channelId: MessagingChannelId; +} + +export type MessagingWorkflowPlannerSandboxRebuildContext = + MessagingWorkflowPlannerSandboxContext; + function uniqueChannels( channelIds: readonly MessagingChannelId[] | undefined, ): MessagingChannelId[] { @@ -107,3 +233,235 @@ function onlyConfiguredChannels( const configured = new Set(configuredChannels); return uniqueChannels(channelIds).filter((channelId) => configured.has(channelId)); } + +function readSandboxEntryPlan( + context: Pick< + MessagingWorkflowPlannerSandboxContext, + "agent" | "sandboxEntry" | "sandboxName" + >, +): SandboxMessagingPlan | null { + const plan = context.sandboxEntry?.messaging?.plan; + if ( + !plan || + plan.schemaVersion !== 1 || + plan.sandboxName !== context.sandboxName || + plan.agent !== context.agent + ) { + return null; + } + return clonePlan(plan); +} + +function disabledChannelsFromSandboxEntry( + sandboxEntry: MessagingWorkflowPlannerSandboxEntry | null | undefined, + fallbackPlan: SandboxMessagingPlan | null, +): MessagingChannelId[] { + return uniqueChannels( + Array.isArray(sandboxEntry?.disabledChannels) + ? sandboxEntry.disabledChannels + : fallbackPlan?.disabledChannels ?? [], + ); +} + +function clonePlan(plan: SandboxMessagingPlan): SandboxMessagingPlan { + return JSON.parse(JSON.stringify(plan)) as SandboxMessagingPlan; +} + +function mergeSandboxMessagingPlans( + existing: SandboxMessagingPlan, + incoming: SandboxMessagingPlan, +): SandboxMessagingPlan { + if ( + existing.schemaVersion !== incoming.schemaVersion || + existing.sandboxName !== incoming.sandboxName || + existing.agent !== incoming.agent + ) { + return clonePlan(incoming); + } + + const incomingChannelIds = new Set(incoming.channels.map((channel) => channel.channelId)); + const mergedChannels = [ + ...existing.channels.filter((channel) => !incomingChannelIds.has(channel.channelId)), + ...incoming.channels, + ]; + const activeIncomingChannels = new Set( + incoming.channels + .filter((channel) => channel.active && !channel.disabled) + .map((channel) => channel.channelId), + ); + const disabledChannels = uniqueSortedStrings([ + ...existing.disabledChannels.filter((channelId) => !activeIncomingChannels.has(channelId)), + ...incoming.disabledChannels, + ]); + const networkEntries = mergePlanEntriesByChannel( + existing.networkPolicy.entries, + incoming.networkPolicy.entries, + ); + + return clonePlan({ + ...incoming, + channels: mergedChannels, + disabledChannels, + credentialBindings: mergePlanEntriesByChannel( + existing.credentialBindings, + incoming.credentialBindings, + ), + networkPolicy: { + presets: uniqueSortedStrings(networkEntries.map((entry) => entry.presetName)), + entries: networkEntries, + }, + agentRender: mergePlanEntriesByChannel(existing.agentRender, incoming.agentRender), + buildSteps: mergePlanEntriesByChannel(existing.buildSteps, incoming.buildSteps), + stateUpdates: mergePlanEntriesByChannel(existing.stateUpdates, incoming.stateUpdates), + healthChecks: mergePlanEntriesByChannel(existing.healthChecks, incoming.healthChecks), + }); +} + +function setPlanChannelDisabled( + plan: SandboxMessagingPlan, + channelId: MessagingChannelId, + disabled: boolean, + workflow: MessagingCompilerWorkflow, +): SandboxMessagingPlan { + const nextChannels = plan.channels.map((channel) => { + if (channel.channelId !== channelId) return channel; + const nextChannel = { ...channel, disabled }; + return { + ...nextChannel, + active: !disabled && isChannelPlanStartable(nextChannel), + }; + }); + const configuredIds = new Set(nextChannels.map((channel) => channel.channelId)); + const disabledChannels = disabled + ? uniqueSortedStrings([...plan.disabledChannels, channelId]).filter((id) => + configuredIds.has(id), + ) + : plan.disabledChannels.filter((id) => id !== channelId); + + return clonePlan({ + ...plan, + workflow, + channels: nextChannels, + disabledChannels, + }); +} + +function setPlanDisabledChannels( + plan: SandboxMessagingPlan, + disabledChannelIds: readonly MessagingChannelId[], + workflow: MessagingCompilerWorkflow, +): SandboxMessagingPlan { + const configuredIds = new Set(plan.channels.map((channel) => channel.channelId)); + const disabledChannels = uniqueSortedStrings(disabledChannelIds).filter((id) => + configuredIds.has(id), + ); + const disabledSet = new Set(disabledChannels); + const channels = plan.channels.map((channel) => { + const disabled = disabledSet.has(channel.channelId); + const nextChannel = { ...channel, disabled }; + return { + ...nextChannel, + active: !disabled && isChannelPlanStartable(nextChannel), + }; + }); + + return clonePlan({ + ...plan, + workflow, + channels, + disabledChannels, + }); +} + +function removePlanChannel( + plan: SandboxMessagingPlan, + channelId: MessagingChannelId, + workflow: MessagingCompilerWorkflow, +): SandboxMessagingPlan { + const channels = plan.channels.filter((channel) => channel.channelId !== channelId); + const remainingChannelIds = new Set(channels.map((channel) => channel.channelId)); + const networkEntries = plan.networkPolicy.entries.filter( + (entry) => entry.channelId !== channelId, + ); + const keepEntry = (entry: T) => + entry.channelId !== channelId && remainingChannelIds.has(entry.channelId); + + return clonePlan({ + ...plan, + workflow, + channels, + disabledChannels: plan.disabledChannels.filter( + (id) => id !== channelId && remainingChannelIds.has(id), + ), + credentialBindings: plan.credentialBindings.filter(keepEntry), + networkPolicy: { + presets: uniqueSortedStrings(networkEntries.map((entry) => entry.presetName)), + entries: networkEntries, + }, + agentRender: plan.agentRender.filter(keepEntry), + buildSteps: plan.buildSteps.filter(keepEntry), + stateUpdates: plan.stateUpdates.filter(keepEntry), + healthChecks: plan.healthChecks.filter(keepEntry), + }); +} + +function isChannelPlanStartable(channel: SandboxMessagingChannelPlan): boolean { + if (!channel.configured) return false; + return channel.inputs.every((input) => { + if (!input.required) return true; + if (input.kind === "secret") return input.credentialAvailable === true; + if (input.value === undefined) return false; + return typeof input.value === "string" ? input.value.trim().length > 0 : true; + }); +} + +function mergePlanEntriesByChannel( + existing: readonly T[], + incoming: readonly T[], +): T[] { + const incomingChannelIds = new Set(incoming.map((entry) => entry.channelId)); + return [ + ...existing.filter((entry) => !incomingChannelIds.has(entry.channelId)), + ...incoming, + ]; +} + +function credentialAvailabilityFromPlan( + plan: SandboxMessagingPlan | null, +): MessagingCompilerCredentialAvailability | undefined { + if (!plan) return undefined; + const availability: Record = {}; + for (const channel of plan.channels) { + for (const input of channel.inputs) { + if (input.kind !== "secret" || input.credentialAvailable !== true) continue; + availability[input.inputId] = true; + availability[`${channel.channelId}.${input.inputId}`] = true; + if (input.sourceEnv) availability[input.sourceEnv] = true; + } + } + for (const credential of plan.credentialBindings) { + if (!credential.credentialAvailable) continue; + availability[credential.credentialId] = true; + availability[`${credential.channelId}.${credential.credentialId}`] = true; + availability[credential.sourceInput] = true; + availability[`${credential.channelId}.${credential.sourceInput}`] = true; + availability[credential.providerEnvKey] = true; + } + return Object.keys(availability).length > 0 ? availability : undefined; +} + +function mergeAvailability( + ...sources: Array +): MessagingCompilerCredentialAvailability | undefined { + const merged: Record = {}; + for (const source of sources) { + for (const [key, value] of Object.entries(source ?? {})) { + if (value === true) merged[key] = true; + } + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + +function uniqueSortedStrings(values: readonly string[]): string[] { + return [...new Set(values)].filter(Boolean).sort(); +} diff --git a/src/lib/messaging/hooks/builtins.ts b/src/lib/messaging/hooks/builtins.ts index 0cd04cd3d7..b87d0929c9 100644 --- a/src/lib/messaging/hooks/builtins.ts +++ b/src/lib/messaging/hooks/builtins.ts @@ -1,17 +1,25 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + createSlackHookRegistrations, + type SlackHookOptions, +} from "../channels/slack/hooks"; import { createTelegramHookRegistrations, type TelegramGetMeReachabilityHookOptions, } from "../channels/telegram/hooks"; -import { createWechatHookRegistrations, type WechatHookOptions } from "../channels/wechat/hooks"; -import { createCommonHookRegistrations, type TokenPasteHookOptions } from "./common"; +import { + createWechatHookRegistrations, + type WechatHookOptions, +} from "../channels/wechat/hooks"; +import { createCommonHookRegistrations, type CommonHookOptions } from "./common"; import { MessagingHookRegistry } from "./registry"; import type { MessagingHookRegistration } from "./types"; export interface BuiltInMessagingHookOptions { - readonly common?: TokenPasteHookOptions; + readonly common?: CommonHookOptions; + readonly slack?: SlackHookOptions; readonly telegram?: TelegramGetMeReachabilityHookOptions; readonly wechat?: WechatHookOptions; } @@ -21,6 +29,7 @@ export function createBuiltInMessagingHookRegistrations( ): readonly MessagingHookRegistration[] { return [ ...createCommonHookRegistrations(options.common), + ...createSlackHookRegistrations(options.slack), ...createTelegramHookRegistrations(options.telegram), ...createWechatHookRegistrations(options.wechat), ]; @@ -31,3 +40,5 @@ export function createBuiltInMessagingHookRegistry( ): MessagingHookRegistry { return new MessagingHookRegistry(createBuiltInMessagingHookRegistrations(options)); } + +export const BUILT_IN_MESSAGING_HOOK_REGISTRY = createBuiltInMessagingHookRegistry(); diff --git a/src/lib/messaging/hooks/common/config-prompt.test.ts b/src/lib/messaging/hooks/common/config-prompt.test.ts new file mode 100644 index 0000000000..c8910a2040 --- /dev/null +++ b/src/lib/messaging/hooks/common/config-prompt.test.ts @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { discordManifest, slackManifest, telegramManifest } from "../../channels"; +import { runMessagingHook } from "../hook-runner"; +import { MessagingHookRegistry } from "../registry"; +import { + COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + createConfigPromptHook, +} from "./config-prompt"; + +describe("common config-prompt hook implementation", () => { + it("prompts manifest config outputs in hook declaration order", async () => { + const env: NodeJS.ProcessEnv = {}; + const questions: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook({ + env, + log: () => {}, + prompt: async (question) => { + questions.push(question); + return question.includes("Reply only") ? "n" : "123456789"; + }, + }), + }, + ]); + const hook = telegramManifest.hooks.find( + (entry) => entry.id === "telegram-config-prompt", + ); + + if (!hook) throw new Error("missing Telegram config-prompt hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "telegram", + }), + ).resolves.toMatchObject({ + handlerId: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + outputs: { + requireMention: { + kind: "config", + value: "0", + }, + allowedIds: { + kind: "config", + value: "123456789", + }, + }, + }); + expect(questions).toEqual([ + " Reply only when @mentioned? [Y/n]: ", + " Telegram User ID (for DM access): ", + ]); + expect(env.TELEGRAM_REQUIRE_MENTION).toBe("0"); + expect(env.TELEGRAM_ALLOWED_IDS).toBe("123456789"); + }); + + it("gates dependent prompts on earlier manifest config input values", async () => { + const questions: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook({ + env: {}, + log: () => {}, + prompt: async (question) => { + questions.push(question); + return ""; + }, + }), + }, + ]); + const hook = discordManifest.hooks.find( + (entry) => entry.id === "discord-config-prompt", + ); + + if (!hook) throw new Error("missing Discord config-prompt hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "discord", + }), + ).resolves.toMatchObject({ + outputs: {}, + }); + expect(questions).toEqual([ + " Discord Server ID (for guild workspace access): ", + ]); + }); + + it("prompts Slack user and channel allowlists from the manifest", async () => { + const env: NodeJS.ProcessEnv = {}; + const questions: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook({ + env, + log: () => {}, + prompt: async (question) => { + questions.push(question); + return question.includes("Channel IDs") + ? "C012AB3CD,C987ZY6XW" + : "U01ABC2DEF3"; + }, + }), + }, + ]); + const hook = slackManifest.hooks.find((entry) => entry.id === "slack-config-prompt"); + + if (!hook) throw new Error("missing Slack config-prompt hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "slack", + }), + ).resolves.toMatchObject({ + outputs: { + allowedUsers: { + kind: "config", + value: "U01ABC2DEF3", + }, + allowedChannels: { + kind: "config", + value: "C012AB3CD,C987ZY6XW", + }, + }, + }); + expect(questions).toEqual([ + " Slack Member IDs (comma-separated allowlist): ", + " Slack Channel IDs (comma-separated allowlist): ", + ]); + expect(env.SLACK_ALLOWED_USERS).toBe("U01ABC2DEF3"); + expect(env.SLACK_ALLOWED_CHANNELS).toBe("C012AB3CD,C987ZY6XW"); + }); + + it("logs existing config values without reprompting", async () => { + const logs: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook({ + env: { + TELEGRAM_REQUIRE_MENTION: "1", + TELEGRAM_ALLOWED_IDS: "123456789", + }, + log: (message) => logs.push(message), + prompt: async () => { + throw new Error("existing config should not reprompt"); + }, + }), + }, + ]); + const hook = telegramManifest.hooks.find( + (entry) => entry.id === "telegram-config-prompt", + ); + + if (!hook) throw new Error("missing Telegram config-prompt hook"); + + await runMessagingHook(hook, registry, { + channelId: "telegram", + }); + + expect(logs.join("\n")).toContain("reply mode already set: @mentions only"); + expect(logs.join("\n")).toContain("allowed IDs already set: 123456789"); + }); + + it("records existing config but does not prompt for missing config in non-interactive mode", async () => { + const env: NodeJS.ProcessEnv = { + SLACK_ALLOWED_USERS: "U01ABC2DEF3", + }; + const logs: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook({ + env, + log: (message) => logs.push(message), + prompt: async () => { + throw new Error("non-interactive config hook should not prompt"); + }, + }), + }, + ]); + const hook = slackManifest.hooks.find((entry) => entry.id === "slack-config-prompt"); + + if (!hook) throw new Error("missing Slack config-prompt hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "slack", + isInteractive: false, + }), + ).resolves.toMatchObject({ + outputs: { + allowedUsers: { + kind: "config", + value: "U01ABC2DEF3", + }, + }, + }); + expect(env.SLACK_ALLOWED_CHANNELS).toBeUndefined(); + expect(logs.join("\n")).toContain("allowed IDs already set: U01ABC2DEF3"); + }); +}); diff --git a/src/lib/messaging/hooks/common/config-prompt.ts b/src/lib/messaging/hooks/common/config-prompt.ts new file mode 100644 index 0000000000..1a912251f1 --- /dev/null +++ b/src/lib/messaging/hooks/common/config-prompt.ts @@ -0,0 +1,255 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createBuiltInChannelManifestRegistry } from "../../channels"; +import type { + ChannelConfigInputSpec, + ChannelHookOutputSpec, + ChannelManifest, + MessagingSerializableValue, +} from "../../manifest"; +import { resolveMessagingChannelConfigEnvValue } from "../../../messaging-channel-config"; +import type { + MessagingHookHandler, + MessagingHookInputMap, + MessagingHookOutputMap, + MessagingHookRegistration, +} from "../types"; + +export const COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID = "common.configPrompt"; + +export interface ConfigPromptField { + readonly id: string; + readonly envKey: string; + readonly label: string; + readonly help?: string; + readonly emptyValueMessage?: string; + readonly validValues?: readonly string[]; + readonly promptWhenInput?: string; + readonly statePath?: string; +} + +export interface ConfigPromptHookOptions { + readonly env?: NodeJS.ProcessEnv; + readonly prompt?: (question: string, options?: { readonly secret?: boolean }) => Promise; + readonly log?: (message: string) => void; + readonly resolveField?: ( + channelId: string, + output: ChannelHookOutputSpec, + ) => ConfigPromptField | null; +} + +export function createConfigPromptHook( + options: ConfigPromptHookOptions = {}, +): MessagingHookHandler { + return async (context) => { + const outputs: Record = {}; + const availableInputs: Record = { + ...(context.inputs ?? {}), + }; + + for (const output of context.outputDeclarations ?? []) { + if (output.kind !== "config") continue; + const field = resolveConfigPromptField(context.channelId, output, options); + if (!field) { + throw new Error( + `No config-prompt field registered for ${context.channelId}.${output.id}`, + ); + } + if (field.promptWhenInput && !hasInputValue(availableInputs, field.promptWhenInput)) { + continue; + } + + const existing = readExistingConfigValue(field, availableInputs, options); + if (existing) { + recordConfigValue(field, existing, outputs, availableInputs, options); + logExistingConfigInput(context.channelId, field, existing, options); + continue; + } + + if (context.isInteractive === false) { + continue; + } + + if (field.help) log(options, ` ${field.help}`); + const value = await promptConfigInputValue(field, options); + if (value) { + recordConfigValue(field, value, outputs, availableInputs, options); + logSavedConfigInput(context.channelId, field, value, options); + } else { + logSkippedConfigInput(context.channelId, field, options); + } + } + + return { outputs }; + }; +} + +export function createConfigPromptHookRegistration( + options: ConfigPromptHookOptions = {}, +): MessagingHookRegistration { + return { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook(options), + }; +} + +function resolveConfigPromptField( + channelId: string, + output: ChannelHookOutputSpec, + options: ConfigPromptHookOptions, +): ConfigPromptField | null { + const custom = options.resolveField?.(channelId, output); + if (custom) return custom; + + const manifest = createBuiltInChannelManifestRegistry().get(channelId); + if (!manifest) return null; + return resolveManifestConfigPromptField(manifest, output); +} + +export function resolveManifestConfigPromptField( + manifest: ChannelManifest, + output: ChannelHookOutputSpec, +): ConfigPromptField | null { + const input = manifest.inputs.find( + (entry): entry is ChannelConfigInputSpec => + entry.kind === "config" && entry.id === output.id, + ); + if (!input?.envKey || !input.prompt) return null; + return { + id: input.id, + envKey: input.envKey, + label: input.prompt.label, + help: input.prompt.help, + emptyValueMessage: input.prompt.emptyValueMessage, + validValues: input.validValues, + promptWhenInput: input.promptWhenInput, + statePath: input.statePath, + }; +} + +function readExistingConfigValue( + field: ConfigPromptField, + availableInputs: MessagingHookInputMap, + options: ConfigPromptHookOptions, +): string | null { + const env = options.env ?? process.env; + const envValue = + resolveMessagingChannelConfigEnvValue(field.envKey, env).value ?? env[field.envKey]; + return ( + normalizeConfigValue(field, envValue) ?? + normalizeConfigValue(field, availableInputs[field.id]) ?? + normalizeConfigValue(field, field.statePath ? availableInputs[field.statePath] : undefined) + ); +} + +function recordConfigValue( + field: ConfigPromptField, + value: string, + outputs: Record, + availableInputs: Record, + options: ConfigPromptHookOptions, +): void { + const env = options.env ?? process.env; + env[field.envKey] = value; + outputs[field.id] = { + kind: "config", + value, + }; + availableInputs[field.id] = value; + if (field.statePath) availableInputs[field.statePath] = value; +} + +async function promptConfigInputValue( + field: ConfigPromptField, + options: ConfigPromptHookOptions, +): Promise { + const prompt = options.prompt ?? missingConfigPrompt; + if (isMentionModeInput(field)) { + const answer = (await prompt(" Reply only when @mentioned? [Y/n]: ")).trim().toLowerCase(); + return answer === "n" || answer === "no" ? "0" : "1"; + } + return normalizeConfigValue(field, await prompt(` ${field.label}: `)); +} + +async function missingConfigPrompt(): Promise { + throw new Error( + "Config-prompt hook requires an injected prompt implementation in phase 1.", + ); +} + +function normalizeConfigValue(field: ConfigPromptField, value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.replace(/\r/g, "").trim(); + if (!normalized) return null; + if (field.validValues && !field.validValues.includes(normalized)) return null; + return normalized; +} + +function hasInputValue( + inputs: Record, + inputId: string, +): boolean { + const value = inputs[inputId]; + return typeof value === "string" ? value.trim().length > 0 : value !== undefined; +} + +function logExistingConfigInput( + channelId: string, + field: ConfigPromptField, + value: string, + options: ConfigPromptHookOptions, +): void { + if (isMentionModeInput(field)) { + log(options, ` ✓ ${channelId} — reply mode already set: ${formatMentionMode(value)}`); + return; + } + log(options, ` ✓ ${channelId} — ${configInputNoun(field)} already set: ${value}`); +} + +function logSavedConfigInput( + channelId: string, + field: ConfigPromptField, + value: string, + options: ConfigPromptHookOptions, +): void { + if (isMentionModeInput(field)) { + log(options, ` ✓ ${channelId} reply mode saved: ${formatMentionMode(value)}`); + return; + } + log(options, ` ✓ ${channelId} ${configInputNoun(field)} saved`); +} + +function logSkippedConfigInput( + channelId: string, + field: ConfigPromptField, + options: ConfigPromptHookOptions, +): void { + const reason = field.emptyValueMessage ?? "left unset"; + log(options, ` Skipped ${channelId} ${configInputNoun(field)} (${reason})`); +} + +function configInputNoun(field: ConfigPromptField): string { + if (/channel/i.test(field.id)) return "channel IDs"; + if (/server/i.test(field.id)) return "server ID"; + if (/allowed|user/i.test(field.id)) return "allowed IDs"; + return field.label; +} + +function isMentionModeInput(field: ConfigPromptField): boolean { + return ( + field.validValues?.length === 2 && + field.validValues.includes("0") && + field.validValues.includes("1") + ); +} + +function formatMentionMode(value: string): string { + return value === "0" ? "all messages" : "@mentions only"; +} + +function log(options: ConfigPromptHookOptions, message: string): void { + (options.log ?? console.log)(message); +} + +export const configPromptHook = createConfigPromptHook(); diff --git a/src/lib/messaging/hooks/common/index.ts b/src/lib/messaging/hooks/common/index.ts index b6eee49387..90c1b92f0b 100644 --- a/src/lib/messaging/hooks/common/index.ts +++ b/src/lib/messaging/hooks/common/index.ts @@ -1,4 +1,87 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + createConfigPromptHookRegistration, + type ConfigPromptHookOptions, +} from "./config-prompt"; +import { + createTokenPasteHookRegistration, + type TokenPasteHookOptions, +} from "./token-paste"; +import { getCredential, prompt, saveCredential } from "../../../credentials/store"; +import type { MessagingHookRegistration } from "../types"; + +export interface CommonHookOptions extends TokenPasteHookOptions { + readonly tokenPaste?: TokenPasteHookOptions; + readonly configPrompt?: ConfigPromptHookOptions; +} + +export function createCommonHookRegistrations( + options: CommonHookOptions = {}, +): readonly MessagingHookRegistration[] { + const resolvedOptions = mergeCommonHookOptions(defaultCommonHookOptions(), options); + const tokenPasteOptions = { + ...resolvedOptions, + ...resolvedOptions.tokenPaste, + }; + const configPromptOptions = { + env: resolvedOptions.env, + prompt: resolvedOptions.prompt, + log: resolvedOptions.log, + ...resolvedOptions.configPrompt, + }; + + return [ + createTokenPasteHookRegistration(tokenPasteOptions), + createConfigPromptHookRegistration(configPromptOptions), + ] as const; +} + +export const COMMON_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = + createCommonHookRegistrations(); + +function defaultCommonHookOptions(): CommonHookOptions { + return { + getCredential, + saveCredential, + prompt, + tokenPaste: { + log: logMessage, + }, + configPrompt: { + log: logMessage, + }, + }; +} + +function mergeCommonHookOptions( + defaults: CommonHookOptions, + options: CommonHookOptions, +): CommonHookOptions { + const base = { + ...defaults, + ...options, + }; + const inheritedLog = options.log ? { log: options.log } : {}; + return { + ...base, + tokenPaste: { + ...defaults.tokenPaste, + ...inheritedLog, + ...options.tokenPaste, + }, + configPrompt: { + ...defaults.configPrompt, + ...inheritedLog, + ...options.configPrompt, + }, + }; +} + +function logMessage(message: string): void { + console.log(message); +} + +export * from "./config-prompt"; export * from "./token-paste"; diff --git a/src/lib/messaging/hooks/common/token-paste.test.ts b/src/lib/messaging/hooks/common/token-paste.test.ts index 9c56814451..67ec979214 100644 --- a/src/lib/messaging/hooks/common/token-paste.test.ts +++ b/src/lib/messaging/hooks/common/token-paste.test.ts @@ -3,21 +3,24 @@ import { describe, expect, it } from "vitest"; -import { slackManifest, telegramManifest } from "../../channels"; +import { discordManifest, slackManifest, telegramManifest } from "../../channels"; import { runMessagingHook } from "../hook-runner"; import { MessagingHookRegistry } from "../registry"; import { + COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, COMMON_HOOK_REGISTRATIONS, createTokenPasteHook, -} from "./token-paste"; +} from "./index"; describe("common token-paste hook implementation", () => { it("uses the shared handler id declared by token-paste channel manifests", () => { expect(COMMON_HOOK_REGISTRATIONS.map((registration) => registration.id)).toEqual([ COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, ]); expect(telegramManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); + expect(discordManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); expect(slackManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); }); @@ -71,7 +74,7 @@ describe("common token-paste hook implementation", () => { }); }); - it("shows the multi-token enrollment output shape", async () => { + it("collects Slack bot and app tokens through the shared token-paste hook", async () => { const registry = new MessagingHookRegistry([ { id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, @@ -112,83 +115,4 @@ describe("common token-paste hook implementation", () => { }); }); - it("prompts only for missing token outputs and stages them for provider upsert", async () => { - const env: NodeJS.ProcessEnv = { - SLACK_BOT_TOKEN: "xoxb-existing", - }; - const prompts: Array<{ readonly question: string; readonly secret: boolean }> = []; - const saved: Array<{ readonly key: string; readonly value: string }> = []; - const registry = new MessagingHookRegistry([ - { - id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, - handler: createTokenPasteHook({ - env, - getCredential: () => null, - saveCredential: (key, value) => saved.push({ key, value }), - log: () => {}, - prompt: async (question, options) => { - prompts.push({ question, secret: options?.secret === true }); - return "xapp-prompted"; - }, - }), - }, - ]); - const hook = slackManifest.hooks[0]; - - if (!hook) throw new Error("missing Slack token-paste hook"); - - await expect( - runMessagingHook(hook, registry, { - channelId: "slack", - }), - ).resolves.toMatchObject({ - outputs: { - botToken: { - kind: "secret", - value: "xoxb-existing", - }, - appToken: { - kind: "secret", - value: "xapp-prompted", - }, - }, - }); - expect(prompts).toEqual([ - { - question: " Slack App Token (Socket Mode): ", - secret: true, - }, - ]); - expect(saved).toEqual([ - { key: "SLACK_BOT_TOKEN", value: "xoxb-existing" }, - { key: "SLACK_APP_TOKEN", value: "xapp-prompted" }, - ]); - expect(env.SLACK_APP_TOKEN).toBe("xapp-prompted"); - }); - - it("rejects invalid pasted token formats before staging credentials", async () => { - const saved: Array<{ readonly key: string; readonly value: string }> = []; - const registry = new MessagingHookRegistry([ - { - id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, - handler: createTokenPasteHook({ - env: {}, - getCredential: () => null, - saveCredential: (key, value) => saved.push({ key, value }), - log: () => {}, - prompt: async () => "not-a-slack-token", - }), - }, - ]); - const hook = slackManifest.hooks[0]; - - if (!hook) throw new Error("missing Slack token-paste hook"); - - await expect( - runMessagingHook(hook, registry, { - channelId: "slack", - }), - ).rejects.toThrow("Invalid token format for SLACK_BOT_TOKEN"); - expect(saved).toEqual([]); - }); }); diff --git a/src/lib/messaging/hooks/common/token-paste.ts b/src/lib/messaging/hooks/common/token-paste.ts index fdba275b03..42f870456b 100644 --- a/src/lib/messaging/hooks/common/token-paste.ts +++ b/src/lib/messaging/hooks/common/token-paste.ts @@ -6,8 +6,12 @@ import type { MessagingHookOutputMap, MessagingHookRegistration, } from "../types"; -import { getChannelDef } from "../../../sandbox/channels"; -import type { ChannelHookOutputSpec } from "../../manifest"; +import { createBuiltInChannelManifestRegistry } from "../../channels"; +import type { + ChannelHookOutputSpec, + ChannelManifest, + ChannelSecretInputSpec, +} from "../../manifest"; export const COMMON_TOKEN_PASTE_HOOK_HANDLER_ID = "common.tokenPaste"; @@ -34,6 +38,13 @@ export interface TokenPasteHookOptions { export function createTokenPasteHook(options: TokenPasteHookOptions = {}): MessagingHookHandler { return async (context) => { const outputs: Record = {}; + const manifest = createBuiltInChannelManifestRegistry().get(context.channelId); + const resolvedFields: Array<{ + readonly output: ChannelHookOutputSpec; + readonly field: TokenPasteField; + readonly token: string; + readonly source: "existing" | "prompted"; + }> = []; for (const output of context.outputDeclarations ?? []) { if (output.kind !== "secret") continue; @@ -43,47 +54,94 @@ export function createTokenPasteHook(options: TokenPasteHookOptions = {}): Messa `No token-paste field registered for ${context.channelId}.${output.id}`, ); } - const token = await resolveTokenValue(field, options); + const resolved = await resolveTokenValue( + context.channelId, + output, + field, + options, + context.isInteractive !== false, + ); + resolvedFields.push({ + output, + field, + token: resolved.token, + source: resolved.source, + }); outputs[output.id] = { kind: "secret", - value: token, + value: resolved.token, }; } + for (const resolved of resolvedFields) { + persistTokenValue(resolved.field, resolved.token, resolved.source, options); + logTokenStatus(context.channelId, resolved.output, resolved.source, options); + if (isPrimarySecretOutput(manifest, resolved.output)) { + logEnrollmentNotes(manifest, options); + } + } + return { outputs }; }; } -export function createCommonHookRegistrations( +export function createTokenPasteHookRegistration( options: TokenPasteHookOptions = {}, -): readonly MessagingHookRegistration[] { - return [ - { - id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, - handler: createTokenPasteHook(options), - }, - ] as const; +): MessagingHookRegistration { + return { + id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + handler: createTokenPasteHook(options), + }; } async function resolveTokenValue( + channelId: string, + output: ChannelHookOutputSpec, field: TokenPasteField, options: TokenPasteHookOptions, -): Promise { + isInteractive: boolean, +): Promise<{ readonly token: string; readonly source: "existing" | "prompted" }> { const env = options.env ?? process.env; const readCredential = options.getCredential ?? (() => null); - const writeCredential = options.saveCredential ?? (() => {}); const prompt = options.prompt ?? missingPhaseOnePrompt; const log = options.log ?? ((message: string) => console.log(message)); let token = normalizeCredentialValue(env[field.envKey]) || readCredential(field.envKey); + let source: "existing" | "prompted" = "existing"; + if (token && field.format && !field.format.test(token)) { + log(` ✗ Invalid format. ${field.formatHint || "Check the token and try again."}`); + if (!isInteractive) { + log(formatSkippedInvalidTokenMessage(channelId, output)); + throw new Error( + `Invalid token format for ${field.envKey}. ${ + field.formatHint || "Check the token and try again." + }`, + ); + } + log(` ✗ Invalid existing ${channelId} ${tokenNoun(output)} ignored.`); + token = ""; + } if (!token) { - if (field.help) log(` ${field.help}`); + if (!isInteractive) { + log(formatSkippedNoTokenMessage(channelId, output)); + throw new Error(`No token entered for ${field.envKey}.`); + } + if (field.help) { + log(""); + log(` ${field.help}`); + } token = normalizeCredentialValue(await prompt(` ${field.label}: `, { secret: true })); + source = "prompted"; } if (!token) { + log(formatSkippedNoTokenMessage(channelId, output)); throw new Error(`No token entered for ${field.envKey}.`); } if (field.format && !field.format.test(token)) { + log( + ` ✗ Invalid format. ${field.formatHint || "Check the token and try again."}`, + ); + log(formatSkippedInvalidTokenMessage(channelId, output)); throw new Error( `Invalid token format for ${field.envKey}. ${ field.formatHint || "Check the token and try again." @@ -91,9 +149,21 @@ async function resolveTokenValue( ); } - writeCredential(field.envKey, token); + return { token, source }; +} + +function persistTokenValue( + field: TokenPasteField, + token: string, + source: "existing" | "prompted", + options: TokenPasteHookOptions, +): void { + const env = options.env ?? process.env; env[field.envKey] = token; - return token; + if (source === "prompted") { + const writeCredential = options.saveCredential ?? (() => {}); + writeCredential(field.envKey, token); + } } async function missingPhaseOnePrompt(): Promise { @@ -115,30 +185,90 @@ function resolveTokenPasteField( const custom = options.resolveField?.(channelId, output); if (custom) return custom; - const channel = getChannelDef(channelId); - if (!channel) return null; - if (output.id === "botToken" && "envKey" in channel && channel.envKey) { - return { - envKey: channel.envKey, - label: channel.label, - help: channel.help, - format: channel.tokenFormat, - formatHint: channel.tokenFormatHint, - }; + const manifest = createBuiltInChannelManifestRegistry().get(channelId); + return manifest ? resolveManifestTokenPasteField(manifest, output) : null; +} + +function resolveManifestTokenPasteField( + manifest: ChannelManifest, + output: ChannelHookOutputSpec, +): TokenPasteField | null { + const input = manifest.inputs.find( + (entry): entry is ChannelSecretInputSpec => + entry.kind === "secret" && entry.id === output.id, + ); + if (!input?.envKey) return null; + return { + envKey: input.envKey, + label: input.prompt?.label ?? input.envKey, + help: input.prompt?.help, + format: input.formatPattern ? new RegExp(input.formatPattern) : undefined, + formatHint: input.formatHint, + }; +} + +export const tokenPasteHook = createTokenPasteHook(); + +function logTokenStatus( + channelId: string, + output: ChannelHookOutputSpec, + source: "existing" | "prompted", + options: TokenPasteHookOptions, +): void { + const log = options.log ?? ((message: string) => console.log(message)); + if (source === "existing") { + log( + output.id === "botToken" + ? ` ✓ ${channelId} — already configured` + : ` ✓ ${channelId} ${tokenNoun(output)} — already configured`, + ); + return; } - if (output.id === "appToken" && "appTokenEnvKey" in channel && channel.appTokenEnvKey) { - return { - envKey: channel.appTokenEnvKey, - label: channel.appTokenLabel ?? `${channel.label} App Token`, - help: channel.appTokenHelp, - format: channel.appTokenFormat, - formatHint: channel.appTokenFormatHint, - }; + log(` ✓ ${channelId} ${tokenNoun(output)} saved`); +} + +function logEnrollmentNotes( + manifest: ChannelManifest | undefined, + options: TokenPasteHookOptions, +): void { + const log = options.log ?? ((message: string) => console.log(message)); + for (const line of manifest?.enrollmentNotes ?? []) { + log(` ${line}`); } - return null; } -export const tokenPasteHook = createTokenPasteHook(); +function isPrimarySecretOutput( + manifest: ChannelManifest | undefined, + output: ChannelHookOutputSpec, +): boolean { + return ( + manifest?.inputs.find( + (input): input is ChannelSecretInputSpec => + input.kind === "secret" && input.required && Boolean(input.envKey), + )?.id === output.id + ); +} + +function tokenNoun(output: ChannelHookOutputSpec): string { + return output.id === "appToken" ? "app token" : "token"; +} -export const COMMON_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = - createCommonHookRegistrations(); +function formatSkippedNoTokenMessage( + channelId: string, + output: ChannelHookOutputSpec, +): string { + if (output.id === "appToken") { + return ` Skipped ${channelId} app token (Socket Mode requires both tokens)`; + } + return ` Skipped ${channelId} (no token entered)`; +} + +function formatSkippedInvalidTokenMessage( + channelId: string, + output: ChannelHookOutputSpec, +): string { + if (output.id === "appToken") { + return ` Skipped ${channelId} app token (invalid token format)`; + } + return ` Skipped ${channelId} (invalid token format)`; +} diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index 570a66eb0c..afd70080e1 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -30,36 +30,13 @@ const HOST_QR_HOOK = { describe("MessagingHookRegistry", () => { it("constructs the production built-in hook registry", () => { - const registry = createBuiltInMessagingHookRegistry({ - common: { - prompt: async () => "unused", - }, - telegram: { - fetch: async () => ({ - ok: true, - status: 200, - async json() { - return { ok: true }; - }, - async text() { - return ""; - }, - }), - }, - wechat: { - ilinkLogin: { - runLogin: async () => ({ - kind: "timeout", - }), - }, - seedOpenClawAccount: { - now: () => "2026-01-01T00:00:00.000Z", - }, - }, - }); + const registry = createBuiltInMessagingHookRegistry(); expect(registry.listIds()).toEqual([ "common.tokenPaste", + "common.configPrompt", + "slack.validateCredentials", + "telegram.allowlistAliases", "telegram.getMeReachability", "wechat.ilinkLogin", "wechat.seedOpenClawAccount", diff --git a/src/lib/messaging/hooks/hook-runner.ts b/src/lib/messaging/hooks/hook-runner.ts index dba3052290..e9ca30f33a 100644 --- a/src/lib/messaging/hooks/hook-runner.ts +++ b/src/lib/messaging/hooks/hook-runner.ts @@ -26,6 +26,9 @@ export async function runMessagingHook( channelId: context.channelId, hookId: hook.id, phase: hook.phase, + ...(typeof context.isInteractive === "boolean" + ? { isInteractive: context.isInteractive } + : {}), inputs: context.inputs, outputDeclarations: hook.outputs, }); diff --git a/src/lib/messaging/hooks/types.ts b/src/lib/messaging/hooks/types.ts index a739f1fb14..94b254d563 100644 --- a/src/lib/messaging/hooks/types.ts +++ b/src/lib/messaging/hooks/types.ts @@ -17,6 +17,7 @@ export type MessagingHookInputMap = Readonly string | null; + +export function toMessagingAgentId( + agent: MessagingAgentDescriptor | null | undefined, +): MessagingAgentId { + return agent?.name === "hermes" ? "hermes" : "openclaw"; +} + +export function getMessagingManifestAvailabilityContext( + agent: MessagingAgentDescriptor | null | undefined, +): ChannelManifestAvailabilityContext { + return { + agent: toMessagingAgentId(agent), + supportedChannelIds: + agent?.messagingPlatforms && agent.messagingPlatforms.length > 0 + ? agent.messagingPlatforms + : null, + }; +} + +export function resolveMessagingManifestSeed( + manifests: readonly ChannelManifest[], + existingChannels: readonly string[] | null | undefined, + hasChannelRequiredInputs: (manifest: ChannelManifest) => boolean, + { includeAllExisting = false }: { readonly includeAllExisting?: boolean } = {}, +): string[] { + const seeded = new Set( + manifests.filter(hasChannelRequiredInputs).map((manifest) => manifest.id), + ); + if (!Array.isArray(existingChannels)) return Array.from(seeded); + + const manifestById = new Map(manifests.map((manifest) => [manifest.id, manifest])); + for (const channelId of existingChannels) { + const manifest = manifestById.get(channelId); + if (!manifest) continue; + if (includeAllExisting || manifest.auth.mode === "in-sandbox-qr") { + seeded.add(channelId); + } + } + return Array.from(seeded); +} + +export function hasMessagingManifestRequiredInputs( + manifest: ChannelManifest, + resolveInput: MessagingInputResolver, +): boolean { + const requiredInputs = manifest.inputs.filter((input) => input.required); + if (requiredInputs.length === 0) return false; + return requiredInputs.every((input) => { + if (!input.envKey) return false; + return hasResolvedInputValue(resolveInput(input)); + }); +} + +function hasResolvedInputValue(value: string | null): boolean { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a94a5381c4..771247431b 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -21,10 +21,6 @@ const inferenceInputCapability = require("./onboard/inference-input-capability") const { cleanupTempDir }: typeof import("./onboard/temp-files") = require("./onboard/temp-files"); const { abortNonInteractive }: typeof import("./onboard/non-interactive-abort") = require("./onboard/non-interactive-abort"); const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup"); -const { - TELEGRAM_NETWORK_CURL_CODES, - checkTelegramReachability, -}: typeof import("./onboard/telegram-reachability") = require("./onboard/telegram-reachability"); const { ensureOllamaLoopbackSystemdOverride, }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); @@ -94,8 +90,9 @@ const { toSessionWechatConfig, } = require("./onboard/wechat-config") as typeof import("./onboard/wechat-config"); const { - setupSelectedMessagingChannels, + setupMessagingChannels: setupMessagingChannelsImpl, } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); +const { MessagingHostStateApplier }: typeof import("./messaging") = require("./messaging"); const { clearAgentScopedResumeState, }: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); @@ -529,8 +526,6 @@ import { import { mergePolicyMessagingChannels } from "./onboard/messaging-policy-presets"; import { filterEnabledChannelsByAgent, - getAvailableMessagingChannelsForAgent, - resolveMessagingChannelSeed, resolveQrSelectedChannels, } from "./onboard/messaging-state"; import { getValidatedMessagingToken, getValidatedMessagingTokenByEnvKey } from "./onboard/messaging-token"; @@ -560,7 +555,6 @@ import { type SandboxGpuFlag, } from "./onboard/sandbox-gpu-mode"; import type { SelectionDrift } from "./onboard/selection-drift"; -import { filterSlackSelectionByValidation } from "./onboard/slack-validation"; import { formatOnboardConfigSummary, formatSandboxBuildEstimateNote } from "./onboard/summary"; import type { ModelValidationResult, ValidationFailureLike } from "./onboard/types"; import type { ContainerRuntime } from "./platform"; @@ -703,7 +697,6 @@ const { hydrateCredentialEnv }: typeof import("./onboard/credential-env") = const { summarizeCurlFailure, summarizeProbeFailure, - runCurlProbe, } = httpProbe; const selectOnboardAgent = createSelectOnboardAgent({ @@ -1124,19 +1117,38 @@ function removeDockerDriverGatewayRegistration(): boolean { return destroyResult.status === 0; } +function terminateDockerDriverGatewayProcess(pid: number): boolean { + if (!isPidAlive(pid)) { + return false; + } + + try { + process.kill(pid, "SIGTERM"); + for (let i = 0; i < 10; i += 1) { + if (!isPidAlive(pid)) break; + sleepSeconds(1); + } + if (isPidAlive(pid)) process.kill(pid, "SIGKILL"); + return true; + } catch { + return false; + } +} + function stopDockerDriverGatewayProcess(): boolean { - const result = hostGatewayProcess.stopHostGatewayProcesses( - { - log: console.log, - warn: console.warn, - }, - { - gatewayBin: resolveOpenShellGatewayBinary(), - pidFile: getDockerDriverGatewayPidFile(), - stateDir: getDockerDriverGatewayStateDir(), - }, - ); - return result.stopped.length > 0; + const pid = getDockerDriverGatewayPid(); + if (pid === null || !isPidAlive(pid)) { + clearDockerDriverGatewayRuntimeFiles(); + return false; + } + if (!isDockerDriverGatewayProcess(pid, resolveOpenShellGatewayBinary())) { + clearDockerDriverGatewayRuntimeFiles(); + return false; + } + + const stopped = terminateDockerDriverGatewayProcess(pid); + clearDockerDriverGatewayRuntimeFiles(); + return stopped; } function stopLegacyGatewayClusterContainer(): boolean { @@ -1175,18 +1187,8 @@ function retireLegacyGatewayForDockerDriverUpgrade(): void { function restartDockerDriverGatewayProcessForDrift(pid: number, reason: string): void { console.log(` Existing OpenShell Docker-driver gateway is stale (${reason}); restarting...`); - hostGatewayProcess.stopHostGatewayProcesses( - { - log: console.log, - warn: console.warn, - }, - { - gatewayBin: resolveOpenShellGatewayBinary(), - pidFile: getDockerDriverGatewayPidFile(), - pids: [pid], - stateDir: getDockerDriverGatewayStateDir(), - }, - ); + terminateDockerDriverGatewayProcess(pid); + clearDockerDriverGatewayRuntimeFiles(); } async function refreshDockerDriverGatewayReuseState( @@ -1319,9 +1321,7 @@ function handleFinalGatewayStartFailure({ printError(` # For OpenShell releases that still expose lifecycle commands:`); printError(` openshell gateway destroy -g ${GATEWAY_NAME}`); if (process.platform === "linux") { - printError( - " sudo pkill -f openshell-gateway # if a privileged host gateway process remains", - ); + printError(" sudo pkill -f openshell-gateway # if a privileged host gateway process remains"); } printError( ` docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | xargs -r docker volume rm`, @@ -3771,6 +3771,9 @@ async function createSandbox( const resolvedImageTag = resolveSandboxImageTagFromCreateOutput(createResult.output, buildId); const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig); + const plannedMessagingState = MessagingHostStateApplier.readPlanStateFromEnv(); + const messagingState = + plannedMessagingState?.plan.sandboxName === sandboxName ? plannedMessagingState : undefined; registry.registerSandbox({ name: sandboxName, model: model || null, @@ -3789,6 +3792,7 @@ async function createSandbox( messagingChannels: enabledChannels != null ? [...new Set(enabledChannels)] : activeMessagingChannels, messagingChannelConfig: messagingChannelConfig || undefined, + messaging: messagingState, disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, hermesToolGateways: hermesToolGateways.length > 0 ? [...hermesToolGateways] : undefined, ...onboardHermesDashboard.getHermesDashboardRegistryFields(finalHermesDashboardState), @@ -5318,168 +5322,17 @@ function getRecordedMessagingChannelsForResume( }); } -const telegramReachabilityDeps = { isNonInteractive, note, promptYesNoOrDefault }; - async function setupMessagingChannels( agent: AgentDefinition | null = null, existingChannels: string[] | null = null, + sandboxName: string | null = null, ): Promise { - step(5, 8, "Messaging channels"); - - const availableChannels = getAvailableMessagingChannelsForAgent(MESSAGING_CHANNELS, agent); - const seedFromState = (includeAllExisting = false): string[] => - resolveMessagingChannelSeed( - availableChannels, - existingChannels, - (channel) => Boolean(getValidatedMessagingToken(channel, channel.envKey)), - { includeAllExisting }, - ); - - // Non-interactive: skip prompt, tokens come from env/credentials - if (isNonInteractive() || process.env.NEMOCLAW_NON_INTERACTIVE === "1") { - let found = Array.from(new Set(seedFromState(false))); - if (found.length > 0) { - note(` [non-interactive] Messaging tokens detected: ${found.join(", ")}`); - if (found.includes("telegram")) { - const telegramToken = getValidatedMessagingTokenByEnvKey(MESSAGING_CHANNELS, "TELEGRAM_BOT_TOKEN"); - if (telegramToken) { - const reachability = await checkTelegramReachability(telegramToken, telegramReachabilityDeps); - if (reachability.skipped) found = found.filter((c) => c !== "telegram"); - } - } - found = filterSlackSelectionByValidation(found, MESSAGING_CHANNELS); - } else { - note(" [non-interactive] No messaging tokens configured. Skipping."); - } - return found; - } - - // Single-keypress toggle selector — pre-select channels that already have tokens - // or were recorded for this sandbox (so a rebuild does not silently drop QR-only - // channels that have no host token). - const enabled = new Set(seedFromState(true)); - - const output = process.stderr; - // Lines above the prompt: 1 blank + 1 header + N channels + 1 blank = N + 3 - const linesAbovePrompt = availableChannels.length + 3; - let firstDraw = true; - const showList = () => { - if (!firstDraw) { - // Cursor is at end of prompt line. Move to column 0, go up, clear to end of screen. - output.write(`\r\x1b[${linesAbovePrompt}A\x1b[J`); - } - firstDraw = false; - output.write("\n"); - output.write(" Available messaging channels:\n"); - availableChannels.forEach((ch, i) => { - const marker = enabled.has(ch.name) ? "●" : "○"; - const status = getValidatedMessagingToken(ch, ch.envKey) ? " (configured)" : ""; - output.write(` [${i + 1}] ${marker} ${ch.name} — ${ch.description}${status}\n`); - }); - output.write("\n"); - output.write(` Press 1-${availableChannels.length} to toggle, Enter when done (none selected skips): `); - }; - - showList(); - - await new Promise((resolve, reject) => { - const input = process.stdin; - let rawModeEnabled = false; - let finished = false; - - function cleanup() { - input.removeListener("data", onData); - if (rawModeEnabled && typeof input.setRawMode === "function") { - input.setRawMode(false); - } - // Symmetric with the ref() at the entry; lets the wizard exit - // naturally if this is the last prompt. - if (typeof input.pause === "function") { - input.pause(); - } - if (typeof input.unref === "function") { - input.unref(); - } - } - - function finish(): void { - if (finished) return; - finished = true; - cleanup(); - output.write("\n"); - resolve(); - } - - function onData(chunk: Buffer | string): void { - const text = chunk.toString("utf8"); - for (let i = 0; i < text.length; i += 1) { - const ch = text[i]; - if (ch === "\u0003") { - cleanup(); - reject(Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" })); - process.kill(process.pid, "SIGINT"); - return; - } - if (ch === "\r" || ch === "\n") { - finish(); - return; - } - const num = parseInt(ch, 10); - if (num >= 1 && num <= availableChannels.length) { - const channel = availableChannels[num - 1]; - if (enabled.has(channel.name)) { - enabled.delete(channel.name); - } else { - enabled.add(channel.name); - } - showList(); - } - } - } - - // Re-attach stdin to the event loop. A prior prompt cleanup may have - // unref'd it (sticky), and resume() alone would leave the raw-mode read - // detached from the loop. - if (typeof input.ref === "function") { - input.ref(); - } - input.setEncoding("utf8"); - if (typeof input.resume === "function") { - input.resume(); - } - if (typeof input.setRawMode === "function") { - input.setRawMode(true); - rawModeEnabled = true; - } - input.on("data", onData); + return setupMessagingChannelsImpl(agent, existingChannels, { + step, + note, + isNonInteractive, + sandboxName, }); - - const selected = Array.from(enabled); - if (selected.length === 0) { - console.log(" Skipping messaging channels."); - return []; - } - - await setupSelectedMessagingChannels(selected, enabled, availableChannels); - console.log(""); - - // Channels where the user declined to enter a token were dropped from - // `enabled` inside the per-channel loop, so only channels with credentials - // configured remain in the Set. - - // Preflight: verify Telegram API is reachable from the host before sandbox creation. - // The non-interactive branch above already ran this probe and returned early, - // so this second call only fires on the interactive path — guard explicitly - // to make the no-double-probe invariant visible at the call site. - if (!isNonInteractive() && enabled.has("telegram")) { - const telegramToken = getValidatedMessagingTokenByEnvKey(MESSAGING_CHANNELS, "TELEGRAM_BOT_TOKEN"); - if (telegramToken) { - const reachability = await checkTelegramReachability(telegramToken, telegramReachabilityDeps); - if (reachability.skipped) enabled.delete("telegram"); - } - } - - return Array.from(enabled); } @@ -6793,7 +6646,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { verifyDeployment: async (name, chain) => { const verifyDeploymentModule: typeof import("./verify-deployment") = require("./verify-deployment"); return verifyDeploymentModule.verifyDeployment(name, chain, { - executeSandboxCommand: executeSandboxCommandForVerification, + executeSandboxCommand: (sandbox: string, script: string) => + executeSandboxCommandForVerification(sandbox, script), probeHostPort: (port: number, probePath: string) => { const result = runCapture( ["curl", "-so", "/dev/null", "-w", "%{http_code}", "--max-time", "3", `http://127.0.0.1:${port}${probePath}`], @@ -6804,7 +6658,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }) || null, getMessagingChannels: () => selectedMessagingChannels || [], providerExistsInGateway: (providerName: string) => providerExistsInGateway(providerName), - probeChannelRuntimeStatus: require("./onboard/verify-channel-runtime").buildOnboardChannelRuntimeProbe(agent, name), }); }, formatVerificationDiagnostics: (result) => { @@ -6956,8 +6809,6 @@ module.exports = { fetchGatewayAuthTokenFromSandbox, getProbeAuthMode, getValidationProbeCurlArgs, - checkTelegramReachability, - TELEGRAM_NETWORK_CURL_CODES, verifyCompatibleEndpointSandboxSmoke, resumeProviderShimDeps: { isRoutedInferenceProvider, replaceNamedCredential }, }; diff --git a/src/lib/onboard/host-qr-dispatch.ts b/src/lib/onboard/host-qr-dispatch.ts deleted file mode 100644 index 1d0326372a..0000000000 --- a/src/lib/onboard/host-qr-dispatch.ts +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { saveCredential } from "../credentials/store"; -import { HOST_QR_LOGIN_HANDLERS } from "../host-qr-handlers"; -import type { ChannelDef } from "../sandbox/channels"; - -export interface HostQrDispatchOutcome { - ok: boolean; - summary?: string; - reason?: string; -} - -/** - * Run a channel's host-side QR login handler and apply its token + - * non-secret metadata side effects (credential save, process.env stash, - * DM-allowlist default). Extracted from `setupMessagingChannels` to keep - * `src/lib/onboard.ts` focused on flow rather than per-channel mechanism. - * - * Belt-and-suspenders: handlers may wrap their own body in try/catch, but - * a future handler might not — wrap the await in a real try/catch so any - * throw that escapes before the Promise is returned still becomes a - * structured "error" outcome and the channel is skipped instead of - * crashing onboarding. - */ -export async function dispatchHostQrLogin( - ch: ChannelDef & { name: string }, -): Promise { - const handler = HOST_QR_LOGIN_HANDLERS[ch.name]; - if (!handler) return { ok: false, reason: "no host-qr handler registered" }; - let result: Awaited>; - try { - result = await handler(); - } catch (err: unknown) { - result = { kind: "error", message: err instanceof Error ? err.message : String(err) }; - } - if (result.kind !== "ok") { - const reason = - result.kind === "timeout" - ? "QR login timed out" - : result.kind === "expired" - ? "QR expired too many times" - : result.kind === "aborted" - ? "login aborted" - : `login failed: ${result.message ?? "unknown error"}`; - return { ok: false, reason }; - } - if (result.token && ch.envKey) { - saveCredential(ch.envKey, result.token); - process.env[ch.envKey] = result.token; - } - // Non-secret per-account metadata: the in-sandbox wrapper plugin reads - // these via NEMOCLAW_*_CONFIG_B64 build args, so seed-wechat-accounts.py - // (and equivalents) can pre-seed credentials without re-running the QR - // handshake. See `patchStagedDockerfile`'s `wechatConfig` parameter. - if (result.extraEnv) { - for (const [key, value] of Object.entries(result.extraEnv)) { - process.env[key] = value; - } - } - // Merge the scanned operator's id into the DM allowlist. The channel's - // userIdHelp documents this as "added automatically; supply additional - // ids as a comma-separated list", so an operator-supplied list must not - // displace the scanner — otherwise the person who paired the bot can - // lock themselves out of DM access. Dedupe via Set; preserve the - // existing comma format (no space) the rest of the stack writes. - if (ch.userIdEnvKey && result.defaultUserId) { - const existing = process.env[ch.userIdEnvKey] ?? ""; - const merged = new Set( - existing - .split(",") - .map((v) => v.trim()) - .filter(Boolean), - ); - merged.add(result.defaultUserId); - process.env[ch.userIdEnvKey] = Array.from(merged).join(","); - } - return { ok: true, summary: result.summary }; -} diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index e10b106420..51dbaf2352 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -130,7 +130,7 @@ describe("handleSandboxState", () => { const result = await handleSandboxState(baseOptions(deps)); expect(calls.startStep).toHaveBeenCalledWith("sandbox", { provider: "provider", model: "model" }); - expect(calls.setupMessaging).toHaveBeenCalledWith(null, null); + expect(calls.setupMessaging).toHaveBeenCalledWith(null, ["telegram"], "my-assistant"); expect(calls.promptName).toHaveBeenCalledWith(null); expect(calls.createSandbox).toHaveBeenCalledWith( { type: "nvidia" }, @@ -302,11 +302,17 @@ describe("handleSandboxState", () => { }); it("uses recorded messaging channels on non-interactive resume", async () => { - const { deps, calls } = createDeps({ getRecordedMessagingChannelsForResume: vi.fn(() => ["discord"]) }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["discord"]); + const { deps, calls } = createDeps({ getRecordedMessagingChannelsForResume }); const result = await handleSandboxState({ ...baseOptions(deps), resume: true }); expect(calls.setupMessaging).not.toHaveBeenCalled(); + expect(getRecordedMessagingChannelsForResume).toHaveBeenCalledWith( + true, + expect.any(Object), + "my-assistant", + ); expect(calls.note).toHaveBeenCalledWith(" [non-interactive] Reusing messaging channel configuration: discord"); expect(result.selectedMessagingChannels).toEqual(["discord"]); }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 959f1ce590..26a26c08c8 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -56,7 +56,11 @@ export interface SandboxStateOptions; + setupMessagingChannels( + agent: Agent, + existingChannels: string[] | null, + sandboxName: string, + ): Promise; readMessagingChannelConfigFromEnv(): MessagingChannelConfig | null; promptValidatedSandboxName(agent: Agent): Promise; selectResourceProfileForSandbox(): Promise; @@ -257,6 +261,7 @@ export async function handleSandboxState { @@ -276,7 +281,6 @@ export async function handleSandboxState ({ getCredential: vi.fn(() => null), @@ -17,26 +22,57 @@ vi.mock("../credentials/store", () => ({ saveCredential: vi.fn(), })); -vi.mock("./host-qr-dispatch", () => ({ - dispatchHostQrLogin: vi.fn(), +vi.mock("../host-qr-handlers", () => ({ + HOST_QR_LOGIN_HANDLERS: { + wechat: vi.fn(), + }, })); -vi.mock("./slack-validation", () => ({ +vi.mock("../messaging/channels/slack/hooks/credential-validation", () => ({ formatSlackValidationFailure: vi.fn((result: { message: string }) => result.message), validateSlackCredentials: vi.fn(() => ({ ok: true })), })); const ORIGINAL_ENV = { ...process.env }; +const manifestRegistry = createBuiltInChannelManifestRegistry(); + +function manifests(...channelIds: string[]) { + return channelIds.map((channelId) => { + const manifest = manifestRegistry.get(channelId); + if (!manifest) throw new Error(`missing manifest ${channelId}`); + return manifest; + }); +} + +function stubTelegramReachability(): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + })), + ); +} describe("setupSelectedMessagingChannels", () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV }; vi.clearAllMocks(); + vi.mocked(getCredential).mockReturnValue(null); + vi.mocked(prompt).mockResolvedValue(""); + stubTelegramReachability(); vi.mocked(validateSlackCredentials).mockReturnValue({ ok: true }); }); afterEach(() => { process.env = { ...ORIGINAL_ENV }; + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -52,7 +88,7 @@ describe("setupSelectedMessagingChannels", () => { await setupSelectedMessagingChannels( ["telegram"], new Set(["telegram"]), - [{ name: "telegram", ...KNOWN_CHANNELS.telegram }], + manifests("telegram"), ); const output = logs.join("\n"); @@ -60,11 +96,55 @@ describe("setupSelectedMessagingChannels", () => { expect(output).toContain("/setprivacy -> your bot -> Disable"); expect(output).toContain("remove and re-add the bot to each group"); expect(output).toContain("reply mode already set: @mentions only"); + expect(output.indexOf("✓ telegram — already configured")).toBeLessThan( + output.indexOf("disable privacy mode in @BotFather"), + ); + expect(output.indexOf("disable privacy mode in @BotFather")).toBeLessThan( + output.indexOf("reply mode already set: @mentions only"), + ); + }); + + it("disables Telegram when reachability rejects the token during interactive setup", async () => { + process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-token"; + process.env.TELEGRAM_REQUIRE_MENTION = "1"; + process.env.TELEGRAM_ALLOWED_IDS = "123456789"; + const fetchMock = vi.fn(async () => ({ + ok: false, + status: 404, + statusText: "Not Found", + async json() { + return { ok: false }; + }, + async text() { + return ""; + }, + })); + vi.stubGlobal("fetch", fetchMock); + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + const enabled = new Set(["telegram"]); + + const plan = await setupSelectedMessagingChannels( + ["telegram"], + enabled, + manifests("telegram"), + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(enabled.has("telegram")).toBe(false); + expect(plan?.channels[0]).toMatchObject({ channelId: "telegram", active: false }); + expect( + logs.filter((line) => line.includes("Bot token was rejected by Telegram")), + ).toHaveLength(1); }); - it("accepts Telegram allowlist aliases during channel setup", async () => { + it("accepts Telegram allowlist aliases during manifest channel setup", async () => { process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-token"; - process.env.TELEGRAM_CHAT_ID = "8388960805"; + process.env.TELEGRAM_ALLOWED_IDS = "8388960805"; + process.env.TELEGRAM_AUTHORIZED_CHAT_IDS = "8388960806"; + process.env.TELEGRAM_CHAT_ID = "8388960807"; process.env.TELEGRAM_REQUIRE_MENTION = "0"; const logs: string[] = []; vi.spyOn(console, "log").mockImplementation((message = "") => { @@ -74,63 +154,333 @@ describe("setupSelectedMessagingChannels", () => { await setupSelectedMessagingChannels( ["telegram"], new Set(["telegram"]), - [{ name: "telegram", ...KNOWN_CHANNELS.telegram }], + manifests("telegram"), ); - expect(process.env.TELEGRAM_ALLOWED_IDS).toBe("8388960805"); + expect(process.env.TELEGRAM_ALLOWED_IDS).toBe("8388960805,8388960806,8388960807"); expect(prompt).not.toHaveBeenCalledWith(" Telegram User ID (for DM access): "); - expect(logs.join("\n")).toContain("telegram — allowed IDs already set: 8388960805"); + expect(logs.join("\n")).toContain( + "telegram — allowed IDs already set: 8388960805,8388960806,8388960807", + ); }); - it("#3715 re-prompts instead of accepting an invalid preconfigured Slack bot token", async () => { - process.env.SLACK_BOT_TOKEN = "abcd"; - process.env.SLACK_APP_TOKEN = "xapp-existing"; - vi.mocked(prompt).mockResolvedValueOnce("xoxb-valid-token").mockResolvedValueOnce(""); + it("uses manifest token validation for Slack dual-token enrollment", async () => { const logs: string[] = []; + vi.mocked(prompt).mockResolvedValueOnce("not-a-slack-token"); vi.spyOn(console, "log").mockImplementation((message = "") => { logs.push(String(message)); }); + const enabled = new Set(["slack"]); await setupSelectedMessagingChannels( ["slack"], - new Set(["slack"]), - [{ name: "slack", ...KNOWN_CHANNELS.slack }], + enabled, + manifests("slack"), ); - expect(prompt).toHaveBeenCalledWith(" Slack Bot Token: ", { secret: true }); - expect(saveCredential).toHaveBeenCalledWith("SLACK_BOT_TOKEN", "xoxb-valid-token"); - expect(process.env.SLACK_BOT_TOKEN).toBe("xoxb-valid-token"); - const output = logs.join("\n"); - expect(output).toContain("Invalid existing slack token ignored"); - expect(output).not.toContain("slack — already configured"); + expect(enabled.has("slack")).toBe(false); + expect(saveCredential).not.toHaveBeenCalled(); + expect(logs.join("\n")).toContain("Slack bot tokens start with 'xoxb-'"); + expect(logs.join("\n")).toContain("Skipped slack (invalid token format)"); + expect(logs.join("\n")).not.toContain("enrollment failed"); }); - it("prompts for Slack channel IDs with channel-specific copy", async () => { - process.env.SLACK_BOT_TOKEN = "xoxb-1234-test"; - process.env.SLACK_APP_TOKEN = ["xapp", "1", "A0000", "12345", "test"].join("-"); - process.env.SLACK_ALLOWED_USERS = "U01ABC2DEF3"; - vi.mocked(prompt).mockResolvedValueOnce("C012AB3CD,C987ZY6XW"); + it("reprompts for an invalid existing Slack token during interactive setup", async () => { + process.env.SLACK_BOT_TOKEN = "not-a-slack-token"; + process.env.SLACK_APP_TOKEN = "xapp-existing-token"; const logs: string[] = []; + const questions: string[] = []; + vi.mocked(prompt).mockImplementation(async (question: string) => { + questions.push(question); + if (question.includes("Slack Bot Token")) return "xoxb-recovered-token"; + return ""; + }); vi.spyOn(console, "log").mockImplementation((message = "") => { logs.push(String(message)); }); + const enabled = new Set(["slack"]); - await setupSelectedMessagingChannels( + const plan = await setupSelectedMessagingChannels( ["slack"], - new Set(["slack"]), - [{ name: "slack", ...KNOWN_CHANNELS.slack }], + enabled, + manifests("slack"), ); - expect(process.env.SLACK_ALLOWED_CHANNELS).toBe("C012AB3CD,C987ZY6XW"); - expect(vi.mocked(prompt)).toHaveBeenCalledWith( + expect(enabled.has("slack")).toBe(true); + expect(plan?.channels[0]).toMatchObject({ channelId: "slack", active: true }); + expect(questions).toEqual([ + " Slack Bot Token: ", + " Slack Member IDs (comma-separated allowlist): ", " Slack Channel IDs (comma-separated allowlist): ", + ]); + expect(saveCredential).toHaveBeenCalledWith( + "SLACK_BOT_TOKEN", + "xoxb-recovered-token", ); - const output = logs.join("\n"); - expect(output).toContain("Slack channel IDs"); - expect(output).toContain("channel IDs saved"); + expect(saveCredential).not.toHaveBeenCalledWith( + "SLACK_APP_TOKEN", + "xapp-existing-token", + ); + expect(process.env.SLACK_BOT_TOKEN).toBe("xoxb-recovered-token"); + expect(process.env.SLACK_APP_TOKEN).toBe("xapp-existing-token"); + expect(logs.join("\n")).toContain("Invalid existing slack token ignored"); + expect(logs.join("\n")).not.toContain("Skipped slack (invalid token format)"); }); - it("does not save prompted Slack credentials when Slack API rejects them", async () => { + it("prompts each channel's config before enrolling the next selected channel", async () => { + const questions: string[] = []; + vi.mocked(prompt).mockImplementation(async (question: string) => { + questions.push(question); + if (question.includes("Telegram Bot Token")) return "123456:telegram-token"; + if (question.includes("Reply only")) return "n"; + if (question.includes("Telegram User ID")) return "123456789"; + if (question.includes("Discord Bot Token")) return "discord-token"; + return ""; + }); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await setupSelectedMessagingChannels( + ["telegram", "discord"], + new Set(["telegram", "discord"]), + manifests("telegram", "discord"), + ); + + expect(questions).toEqual([ + " Telegram Bot Token: ", + " Reply only when @mentioned? [Y/n]: ", + " Telegram User ID (for DM access): ", + " Discord Bot Token: ", + " Discord Server ID (for guild workspace access): ", + ]); + expect(process.env.TELEGRAM_REQUIRE_MENTION).toBe("0"); + expect(process.env.TELEGRAM_ALLOWED_IDS).toBe("123456789"); + }); + + it("prompts Discord guild-only config after the manifest server ID input is set", async () => { + process.env.DISCORD_BOT_TOKEN = "discord-token"; + vi.mocked(prompt) + .mockResolvedValueOnce("1491590992753590594") + .mockResolvedValueOnce("n") + .mockResolvedValueOnce("1005536447329222676"); + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + await setupSelectedMessagingChannels( + ["discord"], + new Set(["discord"]), + manifests("discord"), + ); + + expect(process.env.DISCORD_SERVER_ID).toBe("1491590992753590594"); + expect(process.env.DISCORD_REQUIRE_MENTION).toBe("0"); + expect(process.env.DISCORD_USER_ID).toBe("1005536447329222676"); + expect(logs.join("\n")).toContain("discord server ID saved"); + expect(logs.join("\n")).toContain("discord reply mode saved: all messages"); + expect(logs.join("\n")).toContain("discord allowed IDs saved"); + }); + + it("runs WeChat host-QR enrollment through the manifest hook", async () => { + vi.mocked(HOST_QR_LOGIN_HANDLERS.wechat).mockResolvedValue({ + kind: "ok", + token: "wechat-token", + extraEnv: { + WECHAT_ACCOUNT_ID: "wechat-account", + WECHAT_BASE_URL: "https://ilinkai.wechat.example", + WECHAT_USER_ID: "wechat-user", + }, + defaultUserId: "wechat-user", + summary: "account wechat-account", + }); + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + const plan = await setupSelectedMessagingChannels( + ["wechat"], + new Set(["wechat"]), + manifests("wechat"), + ); + + expect(saveCredential).toHaveBeenCalledWith("WECHAT_BOT_TOKEN", "wechat-token"); + expect(process.env.WECHAT_ACCOUNT_ID).toBe("wechat-account"); + expect(process.env.WECHAT_BASE_URL).toBe("https://ilinkai.wechat.example"); + expect(process.env.WECHAT_USER_ID).toBe("wechat-user"); + expect(process.env.WECHAT_ALLOWED_IDS).toBe("wechat-user"); + expect(plan?.channels[0]).toMatchObject({ channelId: "wechat", active: true }); + expect(logs.join("\n")).toContain("wechat token saved (account wechat-account)"); + }); + + it("enrolls tokenless WhatsApp without credential prompts or providers", async () => { + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + const plan = await setupSelectedMessagingChannels( + ["whatsapp"], + new Set(["whatsapp"]), + manifests("whatsapp"), + { agent: { name: "hermes" } }, + ); + + expect(prompt).not.toHaveBeenCalled(); + expect(getCredential).not.toHaveBeenCalled(); + expect(plan?.credentialBindings).toEqual([]); + expect(plan?.channels[0]).toMatchObject({ + channelId: "whatsapp", + authMode: "in-sandbox-qr", + active: true, + }); + expect(logs.join("\n")).toContain("WhatsApp Web pairs via QR code"); + expect(logs.join("\n")).toContain("channels status --channel whatsapp"); + }); + + it("threads the resolved sandbox name into manifest provider bindings", async () => { + process.env.TELEGRAM_BOT_TOKEN = "123456:telegram-token"; + + const plan = await setupSelectedMessagingChannels( + ["telegram"], + new Set(["telegram"]), + manifests("telegram"), + { interactive: false, sandboxName: "actual-sandbox" }, + ); + + expect(plan?.credentialBindings).toContainEqual( + expect.objectContaining({ + channelId: "telegram", + providerName: "actual-sandbox-telegram-bridge", + }), + ); + expect(JSON.stringify(plan)).not.toContain("pending-sandbox"); + expect(MessagingSetupApplier.requirePlanFromEnv()).toEqual(plan); + }); +}); + +describe("setupMessagingChannels", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.clearAllMocks(); + vi.mocked(getCredential).mockReturnValue(null); + vi.mocked(prompt).mockResolvedValue(""); + vi.mocked(validateSlackCredentials).mockReturnValue({ ok: true }); + stubTelegramReachability(); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("orchestrates non-interactive manifest selection with injected onboard deps", async () => { + process.env.TELEGRAM_BOT_TOKEN = "123456:telegram-token"; + process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token"; + process.env.SLACK_APP_TOKEN = "xapp-test-slack-token"; + const steps: string[] = []; + const notes: string[] = []; + + const result = await setupMessagingChannels(null, null, { + step: (current, total, label) => steps.push(`${current}/${total} ${label}`), + note: (message) => notes.push(message), + isNonInteractive: () => true, + }); + + expect(result).toEqual(["telegram", "slack"]); + expect(steps).toEqual(["5/8 Messaging channels"]); + expect(notes).toEqual([ + " [non-interactive] Messaging channel inputs detected: telegram, slack", + ]); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("skips partially configured multi-secret channels in non-interactive mode", async () => { + process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token"; + const notes: string[] = []; + + const result = await setupMessagingChannels(null, null, { + note: (message) => notes.push(message), + isNonInteractive: () => true, + }); + + expect(result).toEqual([]); + expect(notes).toEqual([ + " [non-interactive] No complete messaging channel inputs configured. Skipping.", + ]); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("clears any stale serialized messaging plan when no channels are selected", async () => { + process.env[MESSAGING_SETUP_APPLIER_ENV_KEY] = "stale-plan"; + + const result = await setupMessagingChannels(null, null, { + isNonInteractive: () => true, + }); + + expect(result).toEqual([]); + expect(process.env[MESSAGING_SETUP_APPLIER_ENV_KEY]).toBeUndefined(); + }); + + it("skips channels missing required non-secret inputs in non-interactive mode", async () => { + process.env.WECHAT_BOT_TOKEN = "wechat-token"; + process.env.WECHAT_ACCOUNT_ID = " "; + const notes: string[] = []; + + const result = await setupMessagingChannels(null, null, { + note: (message) => notes.push(message), + isNonInteractive: () => true, + }); + + expect(result).toEqual([]); + expect(notes).toEqual([ + " [non-interactive] No complete messaging channel inputs configured. Skipping.", + ]); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("seeds channels with all required secret and non-secret inputs in non-interactive mode", async () => { + process.env.WECHAT_BOT_TOKEN = "wechat-token"; + process.env.WECHAT_ACCOUNT_ID = "wechat-account"; + const notes: string[] = []; + + const result = await setupMessagingChannels(null, null, { + note: (message) => notes.push(message), + isNonInteractive: () => true, + }); + + expect(result).toEqual(["wechat"]); + expect(notes).toEqual([ + " [non-interactive] Messaging channel inputs detected: wechat", + ]); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("validates detected non-interactive Slack inputs before returning enabled channels", async () => { + process.env.SLACK_BOT_TOKEN = "not-a-slack-token"; + process.env.SLACK_APP_TOKEN = "xapp-existing-token"; + const logs: string[] = []; + const notes: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + const result = await setupMessagingChannels(null, null, { + note: (message) => notes.push(message), + isNonInteractive: () => true, + }); + + expect(result).toEqual([]); + expect(notes).toEqual([ + " [non-interactive] Messaging channel inputs detected: slack", + ]); + expect(logs.join("\n")).toContain("Slack bot tokens start with 'xoxb-'"); + expect(logs.join("\n")).toContain("Skipped slack (invalid token format)"); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("disables Slack when Slack API rejects prompted credentials", async () => { delete process.env.SLACK_BOT_TOKEN; delete process.env.SLACK_APP_TOKEN; vi.mocked(prompt) @@ -155,20 +505,21 @@ describe("setupSelectedMessagingChannels", () => { await setupSelectedMessagingChannels( ["slack"], enabled, - [{ name: "slack", ...KNOWN_CHANNELS.slack }], + manifests("slack"), ); expect(enabled.has("slack")).toBe(false); - expect(saveCredential).not.toHaveBeenCalled(); - expect(process.env.SLACK_BOT_TOKEN).toBeUndefined(); - expect(process.env.SLACK_APP_TOKEN).toBeUndefined(); + expect(saveCredential).toHaveBeenCalledWith("SLACK_BOT_TOKEN", "xoxb-fake-bot-token"); + expect(saveCredential).toHaveBeenCalledWith("SLACK_APP_TOKEN", "xapp-fake-app-token"); + expect(process.env.SLACK_BOT_TOKEN).toBe("xoxb-fake-bot-token"); + expect(process.env.SLACK_APP_TOKEN).toBe("xapp-fake-app-token"); const output = logs.join("\n"); expect(output).toContain("Slack app token was rejected by Slack API"); expect(output).not.toContain("xoxb-fake-bot-token"); expect(output).not.toContain("xapp-fake-app-token"); }); - it("does not save prompted Slack credentials when Slack API validation is indeterminate", async () => { + it("disables Slack when Slack API validation is indeterminate", async () => { delete process.env.SLACK_BOT_TOKEN; delete process.env.SLACK_APP_TOKEN; vi.mocked(prompt) @@ -188,13 +539,14 @@ describe("setupSelectedMessagingChannels", () => { await setupSelectedMessagingChannels( ["slack"], enabled, - [{ name: "slack", ...KNOWN_CHANNELS.slack }], + manifests("slack"), ); expect(enabled.has("slack")).toBe(false); - expect(saveCredential).not.toHaveBeenCalled(); - expect(process.env.SLACK_BOT_TOKEN).toBeUndefined(); - expect(process.env.SLACK_APP_TOKEN).toBeUndefined(); + expect(saveCredential).toHaveBeenCalledWith("SLACK_BOT_TOKEN", "xoxb-timeout-bot-token"); + expect(saveCredential).toHaveBeenCalledWith("SLACK_APP_TOKEN", "xapp-timeout-app-token"); + expect(process.env.SLACK_BOT_TOKEN).toBe("xoxb-timeout-bot-token"); + expect(process.env.SLACK_APP_TOKEN).toBe("xapp-timeout-app-token"); }); it("ignores existing Slack tokens that pass format but fail Slack API validation", async () => { @@ -219,15 +571,15 @@ describe("setupSelectedMessagingChannels", () => { await setupSelectedMessagingChannels( ["slack"], enabled, - [{ name: "slack", ...KNOWN_CHANNELS.slack }], + manifests("slack"), ); expect(enabled.has("slack")).toBe(false); - expect(prompt).not.toHaveBeenCalled(); + expect(prompt).toHaveBeenCalledWith(" Slack Member IDs (comma-separated allowlist): "); + expect(prompt).toHaveBeenCalledWith(" Slack Channel IDs (comma-separated allowlist): "); expect(saveCredential).not.toHaveBeenCalled(); const output = logs.join("\n"); - expect(output).toContain("Invalid existing slack token ignored"); expect(output).toContain("token_revoked"); - expect(output).not.toContain("slack — already configured"); + expect(output).toContain("slack — already configured"); }); }); diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index e0e590188c..c0a3928c7e 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -1,351 +1,331 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { AgentDefinition } from "../agent/defs"; +import { getCredential, normalizeCredentialValue } from "../credentials/store"; import { - normalizeCredentialValue, - prompt, - saveCredential, -} from "../credentials/store"; -import { - normalizeMessagingChannelConfigValue, - resolveMessagingChannelConfigEnvValue, -} from "../messaging-channel-config"; -import { channelHasStaticToken, type ChannelDef } from "../sandbox/channels"; -import { dispatchHostQrLogin } from "./host-qr-dispatch"; -import { - getMessagingToken, - isMessagingTokenFormatValid, -} from "./messaging-token"; -import { - formatSlackValidationFailure, - type SlackTokenKind, - validateSlackCredentials, -} from "./slack-validation"; + type ChannelInputSpec, + type ChannelManifest, + createBuiltInMessagingHookRegistry, + createBuiltInChannelManifestRegistry, + getMessagingManifestAvailabilityContext, + hasMessagingManifestRequiredInputs, + MessagingSetupApplier, + MessagingWorkflowPlanner, + resolveMessagingManifestSeed, + type SandboxMessagingPlan, + toMessagingAgentId, +} from "../messaging"; +import { resolveMessagingChannelConfigEnvValue } from "../messaging-channel-config"; -type ChannelEntry = { name: string } & ChannelDef; - -const getMessagingConfigValue = (envKey: string): string | null => { - const resolved = resolveMessagingChannelConfigEnvValue(envKey, process.env); - if (resolved.value) { - if (!process.env[envKey]) process.env[envKey] = resolved.value; - return resolved.value; - } - return normalizeMessagingChannelConfigValue(envKey, process.env[envKey]); -}; +export interface SetupSelectedMessagingChannelsOptions { + readonly agent?: { readonly name?: string } | null; + readonly sandboxName?: string | null; + readonly interactive?: boolean; +} -function getExistingMessagingToken( - ch: ChannelEntry, - envKey: string | undefined, - label: "token" | "app token", -): string | null { - const token = getMessagingToken(envKey); - if (token && !isMessagingTokenFormatValid(ch, envKey, token)) { - console.log(` ✗ Invalid existing ${ch.name} ${label} ignored.`); - return null; - } - return token; +export interface SetupMessagingChannelsDeps { + readonly step?: (current: number, total: number, label: string) => void; + readonly note?: (message: string) => void; + readonly isNonInteractive?: () => boolean; + readonly sandboxName?: string | null; } -type SlackTokenSlot = { - envKey: string; - kind: SlackTokenKind; - label: "token" | "app token"; - promptLabel: string; - help: string; -}; +const getMessagingToken = (envKey: string): string | null => + normalizeCredentialValue(process.env[envKey]) || getCredential(envKey) || null; -type CollectedSlackToken = { - token: string; - save: boolean; - label: "token" | "app token"; +const getMessagingInputValue = (input: ChannelInputSpec): string | null => { + if (!input.envKey) return null; + if (input.kind === "secret") return getMessagingToken(input.envKey); + const resolved = resolveMessagingChannelConfigEnvValue(input.envKey, process.env); + if (resolved.value) return resolved.value; + return normalizeCredentialValue(process.env[input.envKey]) || null; }; -function skipSlack(enabled: Set, reason: string): null { - console.log(` Skipped slack (${reason})`); - enabled.delete("slack"); - return null; -} +export async function setupMessagingChannels( + agent: AgentDefinition | null = null, + existingChannels: string[] | null = null, + deps: SetupMessagingChannelsDeps = {}, +): Promise { + deps.step?.(5, 8, "Messaging channels"); -async function collectSlackToken( - ch: ChannelEntry, - slot: SlackTokenSlot, - enabled: Set, -): Promise { - const existing = getExistingMessagingToken(ch, slot.envKey, slot.label); - if (existing) { - return { token: existing, save: false, label: slot.label }; - } + const note = deps.note ?? console.log; + const isNonInteractive = + deps.isNonInteractive ?? (() => process.env.NEMOCLAW_NON_INTERACTIVE === "1"); + const manifestRegistry = createBuiltInChannelManifestRegistry(); + const availabilityContext = getMessagingManifestAvailabilityContext(agent); + const availableChannels = manifestRegistry.listAvailable(availabilityContext); + const hasManifestRequiredInputs = (manifest: ChannelManifest) => + hasMessagingManifestRequiredInputs(manifest, getMessagingInputValue); + const seedFromState = (includeAllExisting = false): string[] => + resolveMessagingManifestSeed(availableChannels, existingChannels, hasManifestRequiredInputs, { + includeAllExisting, + }); - console.log(""); - console.log(` ${slot.help}`); - const token = normalizeCredentialValue(await prompt(` ${slot.promptLabel}: `, { secret: true })); - if (!token) { - const reason = - slot.kind === "app" ? "Socket Mode requires both tokens" : "no token entered"; - return skipSlack(enabled, reason); + if (isNonInteractive() || process.env.NEMOCLAW_NON_INTERACTIVE === "1") { + const enabled = new Set(seedFromState(false)); + const found = Array.from(enabled); + if (found.length > 0) { + note(` [non-interactive] Messaging channel inputs detected: ${found.join(", ")}`); + await setupSelectedMessagingChannels(found, enabled, availableChannels, { + agent, + interactive: false, + sandboxName: deps.sandboxName, + }); + } else { + MessagingSetupApplier.clearPlanEnv(); + note(" [non-interactive] No complete messaging channel inputs configured. Skipping."); + } + return Array.from(enabled); } - if (!isMessagingTokenFormatValid(ch, slot.envKey, token)) { - const formatHint = - slot.kind === "app" - ? ch.appTokenFormatHint || "Check the token and try again." - : ch.tokenFormatHint || "Check the token and try again."; - console.log(` ✗ Invalid format. ${formatHint}`); - return skipSlack(enabled, "invalid token format"); - } + const enabled = new Set(seedFromState(true)); + const output = process.stderr; + const linesAbovePrompt = availableChannels.length + 3; + let firstDraw = true; + const showList = () => { + if (!firstDraw) { + output.write(`\r\x1b[${linesAbovePrompt}A\x1b[J`); + } + firstDraw = false; + output.write("\n"); + output.write(" Available messaging channels:\n"); + availableChannels.forEach((manifest, i) => { + const marker = enabled.has(manifest.id) ? "●" : "○"; + const status = hasManifestRequiredInputs(manifest) ? " (configured)" : ""; + output.write( + ` [${i + 1}] ${marker} ${manifest.id} — ${ + manifest.description ?? manifest.displayName + }${status}\n`, + ); + }); + output.write("\n"); + output.write(` Press 1-${availableChannels.length} to toggle, Enter when done: `); + }; - return { token, save: true, label: slot.label }; -} + showList(); + await readMessagingChannelSelection(availableChannels, enabled, showList); -async function setupSlackTokens(ch: ChannelEntry, enabled: Set): Promise { - if (!ch.envKey || !ch.appTokenEnvKey || !ch.appTokenHelp || !ch.appTokenLabel) { - return false; + const selected = Array.from(enabled); + if (selected.length === 0) { + MessagingSetupApplier.clearPlanEnv(); + console.log(" Skipping messaging channels."); + return []; } - const bot = await collectSlackToken( - ch, - { - envKey: ch.envKey, - kind: "bot", - label: "token", - promptLabel: ch.label, - help: ch.help, - }, - enabled, - ); - if (!bot) return false; - - const app = await collectSlackToken( - ch, - { - envKey: ch.appTokenEnvKey, - kind: "app", - label: "app token", - promptLabel: ch.appTokenLabel, - help: ch.appTokenHelp, - }, - enabled, - ); - if (!app) return false; - - const validation = validateSlackCredentials({ botToken: bot.token, appToken: app.token }); - if (!validation.ok) { - if (!bot.save && validation.credential === "bot") { - console.log(` ✗ Invalid existing ${ch.name} ${bot.label} ignored.`); - } - if (!app.save && validation.credential === "app") { - console.log(` ✗ Invalid existing ${ch.name} ${app.label} ignored.`); - } - const prefix = validation.kind === "rejected" ? "✗" : "⚠"; - console.log(` ${prefix} ${formatSlackValidationFailure(validation)}`); - skipSlack( - enabled, - validation.kind === "rejected" - ? "invalid Slack credentials" - : "Slack API validation unavailable", - ); - return false; - } - if (validation.skipped && validation.message) { - console.log(` ⚠ ${validation.message}`); - } + await setupSelectedMessagingChannels(selected, enabled, availableChannels, { + agent, + sandboxName: deps.sandboxName, + }); + console.log(""); - if (bot.save) { - saveCredential(ch.envKey, bot.token); - process.env[ch.envKey] = bot.token; - console.log(` ✓ ${ch.name} token saved`); - } else { - console.log(` ✓ ${ch.name} — already configured`); - } - if (app.save) { - saveCredential(ch.appTokenEnvKey, app.token); - process.env[ch.appTokenEnvKey] = app.token; - console.log(` ✓ ${ch.name} app token saved`); - } else { - console.log(` ✓ ${ch.name} app token — already configured`); - } - return true; + return Array.from(enabled); } /** - * Prompt for token + per-channel config (app token, server ID, mention - * mode, allowlist IDs) for each selected messaging channel. Mutates - * `process.env` for non-secret config and saves credentials via - * `saveCredential`. Channels where the user declined or supplied an - * invalid token are removed from `enabled`. + * Prompt for token + per-channel config for each selected messaging channel. * - * Extracted from `setupMessagingChannels` in onboard.ts so the - * per-channel interactive loop lives outside the top-level entrypoint - * (src/lib/onboard.ts file-growth budget). + * Enrollment now flows through the manifest-first architecture: selected + * built-in manifests are planned with `MessagingWorkflowPlanner`, token paste + * and host-QR acquisition run via registered hooks, and follow-up config prompts + * are driven from manifest input metadata. */ export async function setupSelectedMessagingChannels( selected: readonly string[], enabled: Set, - messagingChannels: readonly ChannelEntry[], -): Promise { - for (const name of selected) { - const ch = messagingChannels.find((c) => c.name === name); - if (!ch) { - console.log(` Unknown channel: ${name}`); + messagingChannels: readonly ChannelManifest[], + options: SetupSelectedMessagingChannelsOptions = {}, +): Promise { + const registry = createBuiltInChannelManifestRegistry(); + const supportedChannelIds = messagingChannels.map((channel) => channel.id); + const selectedChannels = uniqueSelectedChannels(selected, supportedChannelIds, registry); + if (selectedChannels.length === 0) { + MessagingSetupApplier.clearPlanEnv(); + return null; + } + + const agent = toMessagingAgentId(options.agent); + const sandboxName = resolveMessagingSetupSandboxName(options); + const planner = new MessagingWorkflowPlanner(registry, createBuiltInMessagingHookRegistry()); + + if (options.interactive === false) { + const plan = await planner.buildPlan({ + sandboxName, + agent, + workflow: "onboard", + isInteractive: false, + configuredChannels: selectedChannels, + supportedChannelIds, + credentialAvailability: buildCredentialAvailability(registry, selectedChannels), + }); + MessagingSetupApplier.writePlanToEnv(plan); + for (const channel of plan.channels) { + if (!channel.active) enabled.delete(channel.channelId); + } + return plan; + } + + const plan = await planner.buildPlan({ + sandboxName, + agent, + workflow: "onboard", + isInteractive: true, + configuredChannels: selectedChannels, + supportedChannelIds, + credentialAvailability: buildCredentialAvailability(registry, selectedChannels), + }); + MessagingSetupApplier.writePlanToEnv(plan); + + for (const channel of plan.channels) { + if (!channel.active) { + enabled.delete(channel.channelId); continue; } - if (ch.name === "slack" && channelHasStaticToken(ch)) { - const configured = await setupSlackTokens(ch, enabled); - if (!configured) continue; - } else if (channelHasStaticToken(ch) && getExistingMessagingToken(ch, ch.envKey, "token")) { - console.log(` ✓ ${ch.name} — already configured`); - } else if (ch.loginMethod === "host-qr") { - console.log(""); - console.log(` ${ch.help}`); - const outcome = await dispatchHostQrLogin(ch); - if (!outcome.ok) { - console.log(` Skipped ${ch.name} (${outcome.reason})`); - enabled.delete(ch.name); - continue; - } - const suffix = outcome.summary ? ` (${outcome.summary})` : ""; - console.log(` ✓ ${ch.name} token saved${suffix}`); - } else if (ch.loginMethod === "in-sandbox-qr") { - console.log(""); - console.log(` ${ch.help}`); - console.log( - ` ✓ ${ch.name} enabled — complete QR pairing from inside the sandbox after rebuild.`, - ); - // Surface the post-pair diagnostic hint here too — in-sandbox-qr - // channels skipped the shared setupNotes block below by `continue`, - // so users would never see the `channels status` guidance otherwise. - for (const line of ch.setupNotes ?? []) { - console.log(` ${line}`); + const manifest = registry.get(channel.channelId); + if (manifest?.auth.mode === "in-sandbox-qr") printInSandboxQrStatus(manifest); + } + + return plan; +} + +function readMessagingChannelSelection( + availableChannels: readonly ChannelManifest[], + enabled: Set, + showList: () => void, +): Promise { + return new Promise((resolve, reject) => { + const input = process.stdin; + const output = process.stderr; + let rawModeEnabled = false; + let finished = false; + + function cleanup() { + input.removeListener("data", onData); + if (rawModeEnabled && typeof input.setRawMode === "function") { + input.setRawMode(false); } - continue; - } else { - if (!channelHasStaticToken(ch)) continue; - console.log(""); - console.log(` ${ch.help}`); - const token = normalizeCredentialValue(await prompt(` ${ch.label}: `, { secret: true })); - if (token && ch.tokenFormat && !ch.tokenFormat.test(token)) { - console.log( - ` ✗ Invalid format. ${ch.tokenFormatHint || "Check the token and try again."}`, - ); - console.log(` Skipped ${ch.name} (invalid token format)`); - enabled.delete(ch.name); - continue; + if (typeof input.pause === "function") { + input.pause(); } - if (token) { - saveCredential(ch.envKey, token); - process.env[ch.envKey] = token; - console.log(` ✓ ${ch.name} token saved`); - } else { - console.log(` Skipped ${ch.name} (no token entered)`); - enabled.delete(ch.name); - continue; + if (typeof input.unref === "function") { + input.unref(); } } - for (const line of ch.setupNotes ?? []) { - console.log(` ${line}`); + + function finish(): void { + if (finished) return; + finished = true; + cleanup(); + output.write("\n"); + resolve(); } - if (ch.name !== "slack" && ch.appTokenEnvKey) { - const existingAppToken = getExistingMessagingToken(ch, ch.appTokenEnvKey, "app token"); - if (existingAppToken) { - console.log(` ✓ ${ch.name} app token — already configured`); - } else { - console.log(""); - console.log(` ${ch.appTokenHelp}`); - const appToken = normalizeCredentialValue( - await prompt(` ${ch.appTokenLabel}: `, { secret: true }), - ); - if (appToken && ch.appTokenFormat && !ch.appTokenFormat.test(appToken)) { - console.log( - ` ✗ Invalid format. ${ch.appTokenFormatHint || "Check the token and try again."}`, - ); - console.log(` Skipped ${ch.name} app token (invalid token format)`); - enabled.delete(ch.name); - continue; + + function onData(chunk: Buffer | string): void { + const text = chunk.toString("utf8"); + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (ch === "\u0003") { + cleanup(); + reject(Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" })); + process.kill(process.pid, "SIGINT"); + return; } - if (appToken) { - saveCredential(ch.appTokenEnvKey, appToken); - process.env[ch.appTokenEnvKey] = appToken; - console.log(` ✓ ${ch.name} app token saved`); - } else { - console.log(` Skipped ${ch.name} app token (Socket Mode requires both tokens)`); - enabled.delete(ch.name); - continue; + if (ch === "\r" || ch === "\n") { + finish(); + return; } - } - } - if (ch.serverIdEnvKey) { - const existingServerIds = getMessagingConfigValue(ch.serverIdEnvKey) || ""; - if (existingServerIds) { - process.env[ch.serverIdEnvKey] = existingServerIds; - console.log(` ✓ ${ch.name} — server ID already set: ${existingServerIds}`); - } else { - console.log(` ${ch.serverIdHelp}`); - const serverId = (await prompt(` ${ch.serverIdLabel}: `)).trim(); - if (serverId) { - process.env[ch.serverIdEnvKey] = serverId; - console.log(` ✓ ${ch.name} server ID saved`); - } else { - console.log(` Skipped ${ch.name} server ID (guild channels stay disabled)`); + const num = parseInt(ch, 10); + if (num >= 1 && num <= availableChannels.length) { + const channel = availableChannels[num - 1]; + if (enabled.has(channel.id)) { + enabled.delete(channel.id); + } else { + enabled.add(channel.id); + } + showList(); } } } - // Mention-control prompt: fires for any channel that exposes a - // requireMention env key. Discord gates the prompt behind a configured - // server ID (mention control only makes sense in a guild). Telegram - // has no serverIdEnvKey because mention control applies to every group - // the bot is added to, so the prompt always fires there. See #1737. - const requireMentionKey = ch.requireMentionEnvKey; - if (requireMentionKey && (!ch.serverIdEnvKey || Boolean(process.env[ch.serverIdEnvKey]))) { - const existingRequireMention = getMessagingConfigValue(requireMentionKey); - if (existingRequireMention === "0" || existingRequireMention === "1") { - process.env[requireMentionKey] = existingRequireMention; - const mode = existingRequireMention === "0" ? "all messages" : "@mentions only"; - console.log(` ✓ ${ch.name} — reply mode already set: ${mode}`); - } else { - console.log(` ${ch.requireMentionHelp}`); - const answer = (await prompt(" Reply only when @mentioned? [Y/n]: ")).trim().toLowerCase(); - const value = answer === "n" || answer === "no" ? "0" : "1"; - process.env[requireMentionKey] = value; - const mode = value === "0" ? "all messages" : "@mentions only"; - console.log(` ✓ ${ch.name} reply mode saved: ${mode}`); - } + + if (typeof input.ref === "function") { + input.ref(); } - // Prompt for user/sender ID when the channel supports allowlisting - if (ch.userIdEnvKey && (!ch.serverIdEnvKey || process.env[ch.serverIdEnvKey])) { - const existingIds = getMessagingConfigValue(ch.userIdEnvKey) || ""; - if (existingIds) { - process.env[ch.userIdEnvKey] = existingIds; - console.log(` ✓ ${ch.name} — allowed IDs already set: ${existingIds}`); - } else { - console.log(` ${ch.userIdHelp}`); - const userId = (await prompt(` ${ch.userIdLabel}: `)).trim(); - if (userId) { - process.env[ch.userIdEnvKey] = userId; - console.log(` ✓ ${ch.name} allowed IDs saved`); - } else { - const skippedReason = - ch.allowIdsMode === "guild" - ? "any member in the configured server can message the bot" - : "bot will require manual pairing"; - console.log(` Skipped ${ch.name} user ID (${skippedReason})`); - } - } + input.setEncoding("utf8"); + if (typeof input.resume === "function") { + input.resume(); } - if (ch.channelIdEnvKey && (!ch.serverIdEnvKey || process.env[ch.serverIdEnvKey])) { - const existingChannelIds = getMessagingConfigValue(ch.channelIdEnvKey) || ""; - if (existingChannelIds) { - process.env[ch.channelIdEnvKey] = existingChannelIds; - console.log(` ✓ ${ch.name} — channel IDs already set: ${existingChannelIds}`); - } else { - console.log(` ${ch.channelIdHelp}`); - const channelIds = (await prompt(` ${ch.channelIdLabel}: `)).trim(); - if (channelIds) { - process.env[ch.channelIdEnvKey] = channelIds; - console.log(` ✓ ${ch.name} channel IDs saved`); - } else { - console.log(` Skipped ${ch.name} channel IDs (channel @mentions stay disabled)`); - } + if (typeof input.setRawMode === "function") { + input.setRawMode(true); + rawModeEnabled = true; + } + input.on("data", onData); + }); +} + +function uniqueSelectedChannels( + selected: readonly string[], + supportedChannelIds: readonly string[], + registry: ReturnType, +): string[] { + const supported = new Set(supportedChannelIds); + const result: string[] = []; + for (const rawName of selected) { + const name = rawName.trim().toLowerCase(); + if (!supported.has(name) || !registry.get(name)) { + console.log(` Unknown channel: ${rawName}`); + continue; + } + if (!result.includes(name)) result.push(name); + } + return result; +} + +function logEnrollmentHelp(manifest: ChannelManifest): void { + const help = manifest.enrollmentHelp ?? manifest.inputs[0]?.prompt?.help; + if (!help) return; + console.log(""); + console.log(` ${help}`); +} + +function buildCredentialAvailability( + registry: ReturnType, + channelIds: readonly string[], +): Record { + const availability: Record = {}; + for (const channelId of channelIds) { + const manifest = registry.get(channelId); + if (!manifest) continue; + for (const input of manifest.inputs) { + if (input.kind !== "secret" || !input.envKey || !getMessagingToken(input.envKey)) { + continue; } + availability[input.id] = true; + availability[`${manifest.id}.${input.id}`] = true; + availability[input.envKey] = true; } } + return availability; +} + +function printInSandboxQrStatus(manifest: ChannelManifest): void { + logEnrollmentHelp(manifest); + console.log( + ` ✓ ${manifest.id} enabled — complete QR pairing from inside the sandbox after rebuild.`, + ); + for (const line of manifest.enrollmentNotes ?? []) { + console.log(` ${line}`); + } +} + +function resolveMessagingSetupSandboxName(options: SetupSelectedMessagingChannelsOptions): string { + const explicitName = normalizeSandboxName(options.sandboxName); + if (explicitName) return explicitName; + const envName = normalizeSandboxName(process.env.NEMOCLAW_SANDBOX_NAME); + if (envName) return envName; + return options.agent?.name === "hermes" ? "hermes" : "my-assistant"; +} + +function normalizeSandboxName(value: string | null | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; } diff --git a/src/lib/onboard/messaging-config.test.ts b/src/lib/onboard/messaging-config.test.ts index 2ed4c9be20..c38b882281 100644 --- a/src/lib/onboard/messaging-config.test.ts +++ b/src/lib/onboard/messaging-config.test.ts @@ -46,53 +46,6 @@ describe("onboard messaging config", () => { }); }); - it("uses Telegram allowlist aliases when the canonical env key is absent", () => { - const warn = vi.fn(); - - expect( - collectMessagingBuildConfig({ - channels: [{ name: "telegram", userIdEnvKey: "TELEGRAM_ALLOWED_IDS" }], - activeChannelNames: new Set(["telegram"]), - enabledTokenEnvKeys: new Set(), - env: { - TELEGRAM_AUTHORIZED_CHAT_IDS: "8388960805, 8388960806", - }, - discordSnowflakeRe: DISCORD_SNOWFLAKE_RE, - warn, - }), - ).toEqual({ - messagingAllowedIds: { - telegram: ["8388960805", "8388960806"], - }, - discordGuilds: {}, - slackConfig: {}, - }); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("TELEGRAM_AUTHORIZED_CHAT_IDS is treated as TELEGRAM_ALLOWED_IDS"), - ); - }); - - it("prefers TELEGRAM_ALLOWED_IDS over Telegram aliases", () => { - expect( - collectMessagingBuildConfig({ - channels: [{ name: "telegram", userIdEnvKey: "TELEGRAM_ALLOWED_IDS" }], - activeChannelNames: new Set(["telegram"]), - enabledTokenEnvKeys: new Set(), - env: { - TELEGRAM_ALLOWED_IDS: "canonical", - TELEGRAM_CHAT_ID: "alias", - }, - discordSnowflakeRe: DISCORD_SNOWFLAKE_RE, - }), - ).toEqual({ - messagingAllowedIds: { - telegram: ["canonical"], - }, - discordGuilds: {}, - slackConfig: {}, - }); - }); - it("collects Discord guild config and warns on malformed IDs", () => { const warn = vi.fn(); diff --git a/src/lib/onboard/messaging-config.ts b/src/lib/onboard/messaging-config.ts index 287d93a5d5..c18fba5cb1 100644 --- a/src/lib/onboard/messaging-config.ts +++ b/src/lib/onboard/messaging-config.ts @@ -65,11 +65,6 @@ export function collectMessagingBuildConfig({ if (activeChannelNames.has(ch.name) && ch.userIdEnvKey) { const resolved = resolveMessagingChannelConfigEnvValue(ch.userIdEnvKey, env); if (!resolved.value) continue; - if (resolved.sourceKey && resolved.sourceKey !== ch.userIdEnvKey) { - warn( - ` Warning: ${resolved.sourceKey} is treated as ${ch.userIdEnvKey} for ${ch.name} allowlisting; prefer ${ch.userIdEnvKey}.`, - ); - } const ids = parseMessagingConfigList(resolved.value); if (ids.length > 0) messagingAllowedIds[ch.name] = ids; } diff --git a/src/lib/onboard/messaging-state.test.ts b/src/lib/onboard/messaging-state.test.ts index 3c40f57961..2a3c69b2c6 100644 --- a/src/lib/onboard/messaging-state.test.ts +++ b/src/lib/onboard/messaging-state.test.ts @@ -6,8 +6,6 @@ import { describe, expect, it } from "vitest"; import type { AgentDefinition } from "../agent/defs"; import { filterEnabledChannelsByAgent, - getAvailableMessagingChannelsForAgent, - resolveMessagingChannelSeed, resolveQrSelectedChannels, } from "./messaging-state"; @@ -42,20 +40,6 @@ describe("filterEnabledChannelsByAgent", () => { }); }); -describe("getAvailableMessagingChannelsForAgent", () => { - const channels = [{ name: "telegram" }, { name: "whatsapp" }]; - - it("filters channels not in the agent's messagingPlatforms", () => { - expect(getAvailableMessagingChannelsForAgent(channels, agent(["telegram"]))).toEqual([ - { name: "telegram" }, - ]); - }); - - it("returns all channels when the agent has no platform list", () => { - expect(getAvailableMessagingChannelsForAgent(channels, agent([]))).toEqual(channels); - }); -}); - const messagingChannels = [ { name: "telegram", envKey: "TELEGRAM_BOT_TOKEN", description: "", help: "", label: "" }, { @@ -75,41 +59,7 @@ const messagingChannels = [ }, ]; -describe("resolveMessagingChannelSeed", () => { - it("always seeds channels with host-side tokens", () => { - expect( - resolveMessagingChannelSeed( - messagingChannels, - null, - (channel) => channel.name === "telegram", - ), - ).toEqual(["telegram"]); - }); - - it("carries forward only in-sandbox QR channels by default", () => { - expect( - resolveMessagingChannelSeed( - messagingChannels, - ["telegram", "wechat", "whatsapp"], - () => false, - ), - ).toEqual(["whatsapp"]); - }); - - it("can carry forward all existing channels for interactive preselection", () => { - expect( - resolveMessagingChannelSeed( - messagingChannels, - ["telegram", "wechat", "whatsapp"], - () => false, - { includeAllExisting: true }, - ), - ).toEqual(["telegram", "wechat", "whatsapp"]); - }); -}); - describe("resolveQrSelectedChannels", () => { - it("returns only in-sandbox QR-paired channels from the enabled list", () => { expect( resolveQrSelectedChannels(messagingChannels, ["telegram", "wechat", "whatsapp"], new Set()), diff --git a/src/lib/onboard/messaging-state.ts b/src/lib/onboard/messaging-state.ts index bd4d7bf1a8..ab3a19b9ee 100644 --- a/src/lib/onboard/messaging-state.ts +++ b/src/lib/onboard/messaging-state.ts @@ -6,17 +6,6 @@ import { channelUsesInSandboxQrPairing, type ChannelDef } from "../sandbox/chann export type MessagingChannel = { name: string } & ChannelDef; -export function getAvailableMessagingChannelsForAgent( - channels: T[], - agent: AgentDefinition | null = null, -): T[] { - const supportedPlatforms = agent?.messagingPlatforms; - if (supportedPlatforms && supportedPlatforms.length > 0) { - return channels.filter((c) => supportedPlatforms.includes(c.name)); - } - return channels; -} - export function resolveQrSelectedChannels( channels: MessagingChannel[], enabledChannels: string[] | null | undefined, @@ -30,26 +19,6 @@ export function resolveQrSelectedChannels( }); } -export function resolveMessagingChannelSeed( - channels: MessagingChannel[], - existingChannels: string[] | null | undefined, - hasChannelToken: (channel: MessagingChannel) => boolean, - { includeAllExisting = false }: { includeAllExisting?: boolean } = {}, -): string[] { - const seeded = new Set(channels.filter(hasChannelToken).map((channel) => channel.name)); - if (!Array.isArray(existingChannels)) return Array.from(seeded); - - const channelByName = new Map(channels.map((channel) => [channel.name, channel])); - for (const name of existingChannels) { - const channel = channelByName.get(name); - if (!channel) continue; - if (includeAllExisting || channelUsesInSandboxQrPairing(channel)) { - seeded.add(name); - } - } - return Array.from(seeded); -} - export function filterEnabledChannelsByAgent( enabledChannels: T, agent: AgentDefinition | null, diff --git a/src/lib/onboard/telegram-reachability.test.ts b/src/lib/onboard/telegram-reachability.test.ts deleted file mode 100644 index d653970034..0000000000 --- a/src/lib/onboard/telegram-reachability.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// Unit tests for the Telegram reachability + token-validation probe. -// -// Covers the warn-and-skip behavior introduced for #4238: when api.telegram.org -// is unreachable or the bot token is rejected, onboarding should drop the -// optional Telegram integration and continue — not abort. Mirrors the Brave -// optional-component path at src/lib/onboard/web-search-flow.ts. - -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import type { ProbeResult } from "./types"; - -vi.mock("../adapters/http/probe", () => ({ - runCurlProbe: vi.fn(), -})); - -import { runCurlProbe } from "../adapters/http/probe"; -import { checkTelegramReachability, type TelegramReachabilityDeps } from "./telegram-reachability"; - -function probeOk(): ProbeResult { - return { ok: true, httpStatus: 200, curlStatus: 0, body: '{"ok":true}', stderr: "", message: "" }; -} -function probeHttpError(httpStatus: number): ProbeResult { - return { ok: false, httpStatus, curlStatus: 0, body: "", stderr: "", message: "" }; -} -function probeCurlError(curlStatus: number): ProbeResult { - return { ok: false, httpStatus: 0, curlStatus, body: "", stderr: "", message: "" }; -} - -function makeDeps(overrides: Partial = {}): TelegramReachabilityDeps { - return { - isNonInteractive: vi.fn(() => true), - note: vi.fn(), - promptYesNoOrDefault: vi.fn(async () => true), - ...overrides, - }; -} - -beforeEach(() => { - vi.mocked(runCurlProbe).mockReset(); - delete process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY; -}); - -describe("checkTelegramReachability", () => { - it("returns { skipped: false } on HTTP 200 (token valid, network reachable)", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeOk()); - expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: false }); - }); - - it("returns { skipped: true } when curl exits 35 (TLS handshake failure)", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(35)); - expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: true }); - }); - - it("returns { skipped: true } for every curl exit in TELEGRAM_NETWORK_CURL_CODES (non-interactive)", async () => { - for (const code of [6, 7, 28, 35, 52, 56]) { - vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(code)); - expect( - await checkTelegramReachability("123:abc", makeDeps()), - `curlStatus=${code}`, - ).toEqual({ skipped: true }); - } - }); - - it("returns { skipped: true } on HTTP 401 (token rejected by Telegram)", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeHttpError(401)); - expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: true }); - }); - - it("returns { skipped: true } on HTTP 404 (token rejected by Telegram)", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeHttpError(404)); - expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: true }); - }); - - it("returns { skipped: false } and skips the probe when NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1", async () => { - process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1"; - expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: false }); - expect(vi.mocked(runCurlProbe)).not.toHaveBeenCalled(); - }); - - it("prompts 'Disable Telegram?' on interactive network failure and returns { skipped: true } when accepted", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(7)); - const deps = makeDeps({ - isNonInteractive: vi.fn(() => false), - promptYesNoOrDefault: vi.fn(async () => true), - }); - const result = await checkTelegramReachability("123:abc", deps); - expect(result).toEqual({ skipped: true }); - expect(deps.promptYesNoOrDefault).toHaveBeenCalledWith( - expect.stringContaining("Disable Telegram"), - null, - true, - ); - }); - - it("calls process.exit(1) when interactive user declines the prompt", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(7)); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`__test_exit_${code ?? 0}__`); - }) as never); - try { - const deps = makeDeps({ - isNonInteractive: vi.fn(() => false), - promptYesNoOrDefault: vi.fn(async () => false), - }); - await expect(checkTelegramReachability("123:abc", deps)).rejects.toThrow("__test_exit_1__"); - expect(exitSpy).toHaveBeenCalledWith(1); - } finally { - exitSpy.mockRestore(); - } - }); -}); diff --git a/src/lib/onboard/telegram-reachability.ts b/src/lib/onboard/telegram-reachability.ts deleted file mode 100644 index f8c910f4c6..0000000000 --- a/src/lib/onboard/telegram-reachability.ts +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { runCurlProbe } from "../adapters/http/probe"; -import { cliName } from "./branding"; -import { exitOnboardFromPrompt } from "./prompt-helpers"; - -// Curl exit codes that indicate a network-level failure (not a token problem). -// 35 (TLS handshake failure) covers corporate proxies that MITM HTTPS. -export const TELEGRAM_NETWORK_CURL_CODES = new Set([6, 7, 28, 35, 52, 56]); - -export type TelegramReachabilityResult = { skipped: boolean }; - -export interface TelegramReachabilityDeps { - isNonInteractive(): boolean; - note(message: string): void; - promptYesNoOrDefault( - question: string, - envVar: string | null, - defaultIsYes: boolean, - ): Promise; -} - -function announceTelegramSkip(reason: "unreachable" | "invalid-token"): void { - const because = - reason === "unreachable" - ? "api.telegram.org is unreachable" - : "the bot token was rejected by Telegram"; - const recovery = - reason === "unreachable" - ? "once network access is restored" - : "after setting a valid TELEGRAM_BOT_TOKEN"; - console.warn(` Telegram integration will be disabled for this onboard run because ${because}.`); - console.warn( - ` Re-run onboarding (or \`${cliName()} channels add telegram\`) ${recovery}.`, - ); -} - -export async function checkTelegramReachability( - token: string, - deps: TelegramReachabilityDeps, -): Promise { - if (process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") { - deps.note(" [non-interactive] Skipping Telegram reachability probe by request."); - return { skipped: false }; - } - - const result = runCurlProbe([ - "-sS", - "--connect-timeout", - "5", - "--max-time", - "10", - `https://api.telegram.org/bot${token}/getMe`, - ]); - - // HTTP 200 with "ok":true — Telegram is reachable and token is valid. - if (result.ok) return { skipped: false }; - - // HTTP 401 or 404 — Telegram rejected the bot token. The integration cannot - // function with an invalid token, so this is "validation fails" per #4238 and - // takes the same warn-and-skip path as a network failure: drop telegram from - // the active messaging channel set instead of letting onboarding write an - // unusable token into the sandbox/provider config. - if (result.httpStatus === 401 || result.httpStatus === 404) { - console.log(""); - console.log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); - announceTelegramSkip("invalid-token"); - return { skipped: true }; - } - - // Network-level failure — Telegram is unreachable from this host. Treat as - // an optional-integration soft-fail (#4238): warn, drop telegram from the - // active messaging channel set, and let onboarding continue. Matches the - // warn-and-skip pattern Brave uses at src/lib/onboard/web-search-flow.ts. - if (result.curlStatus && TELEGRAM_NETWORK_CURL_CODES.has(result.curlStatus)) { - console.log(""); - console.log(" ⚠ api.telegram.org is not reachable from this host."); - console.log(" Telegram integration requires outbound HTTPS access to api.telegram.org."); - console.log(" This is commonly blocked by corporate network proxies."); - - if (deps.isNonInteractive()) { - announceTelegramSkip("unreachable"); - return { skipped: true }; - } - // Interactive: prompt explicitly asks whether to skip telegram and continue. - // Default Y favors the soft-fail path so an enter-press lines up with the - // optional-integration contract. An explicit N still aborts onboarding — - // the user opted out of both telegram and the workaround. - if ( - await deps.promptYesNoOrDefault( - " Disable Telegram for this run and continue?", - null, - true, - ) - ) { - announceTelegramSkip("unreachable"); - return { skipped: true }; - } - exitOnboardFromPrompt(); - } - - // Unexpected probe failure — warn but don't block. - if (!result.ok && result.httpStatus > 0) { - console.log( - ` ⚠ Telegram API returned HTTP ${result.httpStatus} — the bot may not work correctly.`, - ); - } else if (!result.ok) { - console.log(` ⚠ Telegram reachability probe failed: ${result.message}`); - } - return { skipped: false }; -} diff --git a/src/lib/sandbox/channels.ts b/src/lib/sandbox/channels.ts index 19948a1555..6dfd80912c 100644 --- a/src/lib/sandbox/channels.ts +++ b/src/lib/sandbox/channels.ts @@ -131,9 +131,6 @@ export const KNOWN_CHANNELS: Record = { help: "WhatsApp Web pairs via QR code scanned with your phone — no host-side token. After the sandbox is running, connect to it (e.g. `openshell sandbox connect `) and run `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes. NemoClaw renders the OpenClaw QR in compact (scan-friendly) form and validates the gateway before pairing, so a gateway close (e.g. `1008`) is reported separately from the QR (issue #4522).", label: "WhatsApp", loginMethod: "in-sandbox-qr", - setupNotes: [ - "After pairing, run `nemoclaw channels status --channel whatsapp` to confirm the bridge is delivering inbound messages — pairing alone does not guarantee inbound delivery (issue #4386).", - ], }, }; diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index fc9ce25b18..8b1680bef5 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { isErrnoException } from "../core/errno"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; import type { MessagingChannelConfig } from "../messaging-channel-config"; import { ensureConfigDir, readConfigFile, writeConfigFile } from "./config-io"; @@ -62,6 +63,7 @@ export interface SandboxEntry { providerCredentialHashes?: Record; messagingChannels?: string[]; messagingChannelConfig?: MessagingChannelConfig; + messaging?: SandboxMessagingState; hermesToolGateways?: string[]; hermesDashboardEnabled?: boolean; hermesDashboardPort?: number | null; @@ -77,6 +79,11 @@ export interface SandboxEntry { gatewayPort?: number | null; } +export interface SandboxMessagingState { + schemaVersion: 1; + plan: SandboxMessagingPlan; +} + export interface SandboxRegistry { sandboxes: Record; defaultSandbox: string | null; @@ -256,6 +263,7 @@ export function registerSandbox(entry: SandboxEntry): void { entry.messagingChannelConfig && Object.keys(entry.messagingChannelConfig).length > 0 ? { ...entry.messagingChannelConfig } : undefined, + messaging: cloneSandboxMessagingState(entry.messaging), hermesToolGateways: Array.isArray(entry.hermesToolGateways) && entry.hermesToolGateways.length > 0 ? [...entry.hermesToolGateways] @@ -279,6 +287,16 @@ export function registerSandbox(entry: SandboxEntry): void { }); } +function cloneSandboxMessagingState( + messaging: SandboxMessagingState | undefined, +): SandboxMessagingState | undefined { + if (!messaging || messaging.schemaVersion !== 1) return undefined; + return { + schemaVersion: 1, + plan: JSON.parse(JSON.stringify(messaging.plan)) as SandboxMessagingPlan, + }; +} + export function updateSandbox(name: string, updates: Partial): boolean { return withLock(() => { const data = load(); diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 2659bf3fec..b66369d904 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -31,6 +31,7 @@ function runScript(scriptBody: string, extraEnv: Record = {}): S SLACK_BOT_TOKEN: "xoxb-slack-bot-token-for-test", SLACK_APP_TOKEN: "xapp-slack-app-token-for-test", DISCORD_BOT_TOKEN: "test-discord-token", + NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", ...extraEnv, }, timeout: 15000, @@ -119,6 +120,22 @@ onboardProviders.upsertMessagingProviders = (defs) => { callOrder.push("upsertMessagingProviders"); }; +const workflowPlanner = require(${j("messaging/compiler/workflow-planner.js")}); +const originalBuildPlan = workflowPlanner.MessagingWorkflowPlanner.prototype.buildPlan; +const buildPlanCalls = []; +workflowPlanner.MessagingWorkflowPlanner.prototype.buildPlan = async function(context) { + if (context.workflow === "add-channel") buildPlanCalls.push({ + sandboxName: context.sandboxName, + agent: context.agent, + workflow: context.workflow, + isInteractive: context.isInteractive, + configuredChannels: context.configuredChannels, + disabledChannels: context.disabledChannels, + supportedChannelIds: context.supportedChannelIds, + }); + return originalBuildPlan.call(this, context); +}; + const registry = require(${j("state/registry.js")}); const registryUpdates = []; registry.getSandbox = () => ({ @@ -233,6 +250,7 @@ module.exports = { providerCalls, registryUpdates, sessionUpdates, + buildPlanCalls, savedCredentialKeys, deletedCredentialKeys, credentialSaveCalls, @@ -243,6 +261,40 @@ module.exports = { } describe("channels add applies matching policy preset (issue #3437)", () => { + it("plans channel enrollment through the messaging manifest workflow", () => { + const script = `${buildPreamble()} +const ctx = module.exports; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "slack" }); + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + buildPlanCalls: ctx.buildPlanCalls, + }) + "\\n"); + } catch (err) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker in stdout:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.buildPlanCalls, [ + { + sandboxName: "test-sb", + agent: "openclaw", + workflow: "add-channel", + isInteractive: false, + configuredChannels: ["slack"], + disabledChannels: [], + supportedChannelIds: ["telegram", "discord", "wechat", "slack", "whatsapp"], + }, + ]); + }); + for (const channel of ["telegram", "slack", "discord"]) { it(`applies the '${channel}' preset before triggering rebuild`, () => { const script = `${buildPreamble()} @@ -319,12 +371,30 @@ const ctx = module.exports; assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); assert.deepEqual(payload.providerCalls, [], "WhatsApp must not create host-side providers"); - assert.deepEqual(payload.registryUpdates, [ - { - name: "test-sb", - updates: { messagingChannels: ["whatsapp"], disabledChannels: [] }, - }, - ]); + assert.deepEqual(payload.registryUpdates[0], { + name: "test-sb", + updates: { messagingChannels: ["whatsapp"], disabledChannels: [] }, + }); + const messagingStateUpdate = payload.registryUpdates.find( + (entry: { updates?: { messaging?: { plan?: { channels?: Array<{ channelId?: string }> } } } }) => + entry.updates?.messaging?.plan, + ); + assert.ok( + messagingStateUpdate, + `expected a registry update that stores durable messaging state; got ${JSON.stringify(payload.registryUpdates)}`, + ); + assert.deepEqual( + messagingStateUpdate.updates.messaging.plan.channels.map( + (channel: { channelId: string }) => channel.channelId, + ), + ["whatsapp"], + ); + assert.equal(messagingStateUpdate.updates.messaging.plan.agent, "hermes"); + assert.deepEqual(messagingStateUpdate.updates.messaging.plan.credentialBindings, []); + assert.deepEqual( + payload.registryUpdates.map((entry: { name: string }) => entry.name), + ["test-sb", "test-sb"], + ); assert.deepEqual( payload.appliedCalls, [{ sandboxName: "test-sb", presetName: "whatsapp" }], @@ -1009,7 +1079,7 @@ process.exit = (code) => { ); }); - it("validates Slack credentials before persisting tokens or registering providers", () => { + it("validates Slack credentials before registering providers", () => { const script = `${buildPreamble()} const ctx = module.exports; (async () => { @@ -1055,6 +1125,11 @@ const ctx = module.exports; payload.callOrder.indexOf("saveCredential:SLACK_BOT_TOKEN"), `Slack validation must complete before token persistence; got ${JSON.stringify(payload.callOrder)}`, ); + assert.ok( + payload.callOrder.indexOf("slackProbe:app") < + payload.callOrder.indexOf("upsertMessagingProviders"), + `Slack validation must complete before provider registration; got ${JSON.stringify(payload.callOrder)}`, + ); assert.ok( payload.callOrder.indexOf("saveCredential:SLACK_APP_TOKEN") < payload.callOrder.indexOf("upsertMessagingProviders"), @@ -1114,7 +1189,7 @@ global.__slackBotProbe = { ); }); - it("aborts Slack channel add on rejected Slack API validation before persistence or registration", () => { + it("aborts Slack channel add on rejected Slack API validation before provider registration", () => { const script = `${buildPreamble()} const ctx = module.exports; global.__slackBotProbe = { @@ -1174,7 +1249,7 @@ process.exit = (code) => { ); }); - it("aborts Slack channel add on indeterminate Slack API validation before persistence or registration", () => { + it("aborts Slack channel add on indeterminate Slack API validation before provider registration", () => { const script = `${buildPreamble()} const ctx = module.exports; global.__slackBotProbe = { diff --git a/test/e2e/test-channels-add-remove.sh b/test/e2e/test-channels-add-remove.sh index 57a75463cf..6dbc8a4196 100755 --- a/test/e2e/test-channels-add-remove.sh +++ b/test/e2e/test-channels-add-remove.sh @@ -84,7 +84,26 @@ fi SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-channels-add-remove}" INSTALL_LOG="/tmp/nemoclaw-e2e-install.log" +REGISTRY="$HOME/.nemoclaw/sandboxes.json" TELEGRAM_TOKEN="${TELEGRAM_BOT_TOKEN:-test-fake-telegram-token-add-remove-e2e}" +TELEGRAM_ALLOWED_IDS_VALUE="${TELEGRAM_ALLOWED_IDS:-123456789}" +TELEGRAM_REQUIRE_MENTION_VALUE="${TELEGRAM_REQUIRE_MENTION:-0}" + +is_fake_telegram_token() { + case "${1:-}" in + *fake*) return 0 ;; + *) return 1 ;; + esac +} + +maybe_skip_telegram_reachability_for_fake_token() { + if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ] && is_fake_telegram_token "$TELEGRAM_TOKEN"; then + # This E2E normally uses a fake token to exercise add/remove plumbing, not + # the live Telegram API. Remove once the test has a hermetic fake Telegram API. + export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 + info "Skipping Telegram reachability probe for fake-token E2E" + fi +} # shellcheck source=test/e2e/lib/sandbox-teardown.sh . "$(dirname "${BASH_SOURCE[0]}")/lib/sandbox-teardown.sh" @@ -142,6 +161,105 @@ policy_list_has_preset() { | grep -E "^\s*●\s+${preset}\b" >/dev/null } +assert_host_telegram_config() { + local context="$1" + local output + if output="$(node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, allowedIds, requireMention] = process.argv.slice(1); +const fail = (message) => { + console.error(message); + process.exit(1); +}; +if (!fs.existsSync(registryPath)) fail("registry file not found: " + registryPath); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const entry = registry.sandboxes?.[sandboxName]; +if (!entry) fail("sandbox " + sandboxName + " missing from registry"); +const config = entry.messagingChannelConfig; +if (!config || typeof config !== "object" || Array.isArray(config)) { + fail("messagingChannelConfig missing or not an object"); +} +if (config.TELEGRAM_ALLOWED_IDS !== allowedIds) { + fail("TELEGRAM_ALLOWED_IDS expected " + allowedIds + ", got " + JSON.stringify(config.TELEGRAM_ALLOWED_IDS)); +} +if (config.TELEGRAM_REQUIRE_MENTION !== requireMention) { + fail("TELEGRAM_REQUIRE_MENTION expected " + requireMention + ", got " + JSON.stringify(config.TELEGRAM_REQUIRE_MENTION)); +} +' "$REGISTRY" "$SANDBOX_NAME" "$TELEGRAM_ALLOWED_IDS_VALUE" "$TELEGRAM_REQUIRE_MENTION_VALUE" 2>&1)"; then + pass "host registry messagingChannelConfig persists telegram config ${context}" + else + fail "host registry messagingChannelConfig missing telegram config ${context}: ${output}" + fi +} + +assert_host_telegram_plan() { + local expected="$1" + local context="$2" + local output + if output="$(node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, expected] = process.argv.slice(1); +const fail = (message) => { + console.error(message); + process.exit(1); +}; +if (!fs.existsSync(registryPath)) fail("registry file not found: " + registryPath); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const entry = registry.sandboxes?.[sandboxName]; +if (!entry) fail("sandbox " + sandboxName + " missing from registry"); +const state = entry.messaging; +if (!state || state.schemaVersion !== 1) fail("messaging state missing or schemaVersion != 1"); +const plan = state.plan; +if (!plan || plan.schemaVersion !== 1) fail("messaging.plan missing or schemaVersion != 1"); +if (plan.sandboxName !== sandboxName) { + fail("messaging.plan.sandboxName expected " + sandboxName + ", got " + JSON.stringify(plan.sandboxName)); +} +if (plan.agent !== "openclaw") fail("messaging.plan.agent expected openclaw, got " + JSON.stringify(plan.agent)); +const channels = Array.isArray(plan.channels) ? plan.channels : []; +const channel = channels.find((item) => item?.channelId === "telegram"); +const disabledChannels = Array.isArray(plan.disabledChannels) ? plan.disabledChannels : []; +const credentialBindings = Array.isArray(plan.credentialBindings) ? plan.credentialBindings : []; +const networkEntries = Array.isArray(plan.networkPolicy?.entries) ? plan.networkPolicy.entries : []; +const networkPresets = Array.isArray(plan.networkPolicy?.presets) ? plan.networkPolicy.presets : []; +const agentRender = Array.isArray(plan.agentRender) ? plan.agentRender : []; +if (expected === "active") { + if (!channel) fail("telegram channel missing from messaging.plan.channels"); + if (channel.active !== true) fail("telegram plan active expected true, got " + JSON.stringify(channel.active)); + if (channel.disabled === true) fail("telegram plan disabled unexpectedly true"); + if (!networkPresets.includes("telegram")) fail("telegram missing from messaging.plan.networkPolicy.presets"); + if (!networkEntries.some((entry) => entry?.channelId === "telegram")) { + fail("telegram missing from messaging.plan.networkPolicy.entries"); + } + if (!credentialBindings.some((entry) => entry?.channelId === "telegram" && entry?.providerEnvKey === "TELEGRAM_BOT_TOKEN")) { + fail("telegram TELEGRAM_BOT_TOKEN credential binding missing from messaging.plan"); + } + if (!agentRender.some((entry) => entry?.channelId === "telegram" && entry?.agent === "openclaw")) { + fail("telegram openclaw agent render entry missing from messaging.plan"); + } + if (disabledChannels.includes("telegram")) fail("telegram unexpectedly listed in messaging.plan.disabledChannels"); +} else if (expected === "removed") { + if (channel) fail("telegram still present in messaging.plan.channels"); + if (disabledChannels.includes("telegram")) fail("telegram still present in messaging.plan.disabledChannels"); + if (networkPresets.includes("telegram")) fail("telegram still present in messaging.plan.networkPolicy.presets"); + if (networkEntries.some((entry) => entry?.channelId === "telegram")) { + fail("telegram still present in messaging.plan.networkPolicy.entries"); + } + if (credentialBindings.some((entry) => entry?.channelId === "telegram")) { + fail("telegram credential binding still present in messaging.plan"); + } + if (agentRender.some((entry) => entry?.channelId === "telegram")) { + fail("telegram agent render entry still present in messaging.plan"); + } +} else { + fail("unknown expected plan state: " + expected); +} +' "$REGISTRY" "$SANDBOX_NAME" "$expected" 2>&1)"; then + pass "host registry messaging.plan has telegram ${expected} ${context}" + else + fail "host registry messaging.plan expected telegram ${expected} ${context}: ${output}" + fi +} + # Run rebuild with live tail of the rebuild log so the operator can see # progress. Mirrors the install.sh tail pattern in Phase 1. run_rebuild_with_live_log() { @@ -229,6 +347,8 @@ pass "C1a: Pre-cleanup complete" # messaging tokens and skip the messaging step entirely. This reproduces the # exact entry condition of the #3437 bug (onboard empty -> later channels add). unset TELEGRAM_BOT_TOKEN +unset TELEGRAM_ALLOWED_IDS +unset TELEGRAM_REQUIRE_MENTION export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" export NEMOCLAW_RECREATE_SANDBOX=1 @@ -322,6 +442,9 @@ section "Phase 3: channels add telegram + rebuild" # Now provide the token — this mirrors the real user flow: after onboard, # the operator decides to add a channel and exports the token first. export TELEGRAM_BOT_TOKEN="$TELEGRAM_TOKEN" +export TELEGRAM_ALLOWED_IDS="$TELEGRAM_ALLOWED_IDS_VALUE" +export TELEGRAM_REQUIRE_MENTION="$TELEGRAM_REQUIRE_MENTION_VALUE" +maybe_skip_telegram_reachability_for_fake_token # Gateway-credential reuse gate. Before the fix, the rebuild preflight # aborted with "provider credential not found" when NVIDIA_API_KEY was unset @@ -345,6 +468,8 @@ else fail "C3a: channels add telegram did not register" tail -20 /tmp/nc-add.log 2>/dev/null || true fi +assert_host_telegram_config "after channels add" +assert_host_telegram_plan "active" "after channels add" info "Rebuilding sandbox to apply the add..." if run_rebuild_with_live_log /tmp/nc-rebuild-add.log; then @@ -407,6 +532,9 @@ else fail "C4c: telegram-bridge provider missing in gateway after add+rebuild" fi +assert_host_telegram_config "after add+rebuild" +assert_host_telegram_plan "active" "after add+rebuild" + # C4d: network reachability. With the preset applied, the bridge-style # probe (see telegram_egress_open) should reach Telegram and elicit a # response; without it, the proxy denies the CONNECT. User-facing symptom @@ -439,6 +567,8 @@ else fail "C5a: channels remove telegram did not unregister" tail -20 /tmp/nc-remove.log 2>/dev/null || true fi +assert_host_telegram_config "after channels remove" +assert_host_telegram_plan "removed" "after channels remove" info "Rebuilding sandbox to apply the remove..." if run_rebuild_with_live_log /tmp/nc-rebuild-remove.log; then @@ -484,4 +614,6 @@ else pass "C6c: 'telegram' preset removed from policy list after remove+rebuild" fi +assert_host_telegram_config "after remove+rebuild" + print_summary diff --git a/test/e2e/test-channels-stop-start.sh b/test/e2e/test-channels-stop-start.sh index aeec0697cb..eb6aa6ac08 100755 --- a/test/e2e/test-channels-stop-start.sh +++ b/test/e2e/test-channels-stop-start.sh @@ -319,6 +319,116 @@ assert_disabled_channels() { done } +assert_host_messaging_config() { + local context="$1" + local output msg + if output="$(node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, ...pairs] = process.argv.slice(1); +const fail = (message) => { + console.error(message); + process.exit(1); +}; +if (!fs.existsSync(registryPath)) fail("registry file not found: " + registryPath); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const entry = registry.sandboxes?.[sandboxName]; +if (!entry) fail("sandbox " + sandboxName + " missing from registry"); +const config = entry.messagingChannelConfig; +if (!config || typeof config !== "object" || Array.isArray(config)) { + fail("messagingChannelConfig missing or not an object"); +} +for (let i = 0; i < pairs.length; i += 2) { + const key = pairs[i]; + const expected = pairs[i + 1]; + if (config[key] !== expected) { + fail(key + " expected " + expected + ", got " + JSON.stringify(config[key])); + } +} +' "$REGISTRY" "$ACTIVE_SANDBOX" \ + TELEGRAM_ALLOWED_IDS "$TELEGRAM_ALLOWED_IDS" \ + TELEGRAM_REQUIRE_MENTION "$TELEGRAM_REQUIRE_MENTION" \ + DISCORD_SERVER_ID "$DISCORD_SERVER_ID" \ + DISCORD_USER_ID "$DISCORD_USER_ID" \ + DISCORD_REQUIRE_MENTION "$DISCORD_REQUIRE_MENTION" \ + SLACK_ALLOWED_USERS "$SLACK_ALLOWED_USERS" \ + WECHAT_ALLOWED_IDS "$WECHAT_ALLOWED_IDS" 2>&1)"; then + msg="${ACTIVE_AGENT}: host registry messagingChannelConfig persists channel config ${context}" + pass_msg "$msg" + else + msg="${ACTIVE_AGENT}: host registry messagingChannelConfig missing channel config ${context}: ${output}" + fail_msg "$msg" + fi +} + +assert_host_messaging_plan_state() { + local expected="$1" + local context="$2" + local channel output msg + for channel in "${CHANNELS[@]}"; do + if output="$(node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, agent, channelId, expected] = process.argv.slice(1); +const fail = (message) => { + console.error(message); + process.exit(1); +}; +if (!fs.existsSync(registryPath)) fail("registry file not found: " + registryPath); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const entry = registry.sandboxes?.[sandboxName]; +if (!entry) fail("sandbox " + sandboxName + " missing from registry"); +const state = entry.messaging; +if (!state || state.schemaVersion !== 1) fail("messaging state missing or schemaVersion != 1"); +const plan = state.plan; +if (!plan || plan.schemaVersion !== 1) fail("messaging.plan missing or schemaVersion != 1"); +if (plan.sandboxName !== sandboxName) { + fail("messaging.plan.sandboxName expected " + sandboxName + ", got " + JSON.stringify(plan.sandboxName)); +} +if (plan.agent !== agent) fail("messaging.plan.agent expected " + agent + ", got " + JSON.stringify(plan.agent)); +const channels = Array.isArray(plan.channels) ? plan.channels : []; +const channel = channels.find((item) => item?.channelId === channelId); +if (!channel) fail(channelId + " missing from messaging.plan.channels"); +if (channel.configured !== true) { + fail(channelId + " messaging.plan configured expected true, got " + JSON.stringify(channel.configured)); +} +const disabledChannels = Array.isArray(plan.disabledChannels) ? plan.disabledChannels : []; +if (expected === "active") { + if (channel.active !== true) fail(channelId + " messaging.plan active expected true, got " + JSON.stringify(channel.active)); + if (channel.disabled === true) fail(channelId + " messaging.plan disabled unexpectedly true"); + if (disabledChannels.includes(channelId)) fail(channelId + " unexpectedly listed in messaging.plan.disabledChannels"); +} else if (expected === "disabled") { + if (channel.disabled !== true) fail(channelId + " messaging.plan disabled expected true, got " + JSON.stringify(channel.disabled)); + if (channel.active === true) fail(channelId + " messaging.plan active unexpectedly true"); + if (!disabledChannels.includes(channelId)) fail(channelId + " missing from messaging.plan.disabledChannels"); +} else { + fail("unknown expected plan state: " + expected); +} +const networkEntries = Array.isArray(plan.networkPolicy?.entries) ? plan.networkPolicy.entries : []; +const networkPresets = Array.isArray(plan.networkPolicy?.presets) ? plan.networkPolicy.presets : []; +if (!networkPresets.includes(channelId)) fail(channelId + " missing from messaging.plan.networkPolicy.presets"); +if (!networkEntries.some((entry) => entry?.channelId === channelId)) { + fail(channelId + " missing from messaging.plan.networkPolicy.entries"); +} +const credentialBindings = Array.isArray(plan.credentialBindings) ? plan.credentialBindings : []; +if (channelId !== "whatsapp" && !credentialBindings.some((entry) => entry?.channelId === channelId)) { + fail(channelId + " credential binding missing from messaging.plan"); +} +const agentRender = Array.isArray(plan.agentRender) ? plan.agentRender : []; +const buildSteps = Array.isArray(plan.buildSteps) ? plan.buildSteps : []; +const hasAgentRender = agentRender.some((entry) => entry?.channelId === channelId && entry?.agent === agent); +const hasBuildStep = buildSteps.some((entry) => entry?.channelId === channelId); +if (!hasAgentRender && !hasBuildStep) { + fail(channelId + " " + agent + " render/build-step entry missing from messaging.plan"); +} +' "$REGISTRY" "$ACTIVE_SANDBOX" "$ACTIVE_AGENT" "$channel" "$expected" 2>&1)"; then + msg="${ACTIVE_AGENT}/${channel}: host registry messaging.plan has channel ${expected} ${context}" + pass_msg "$msg" + else + msg="${ACTIVE_AGENT}/${channel}: host registry messaging.plan expected ${expected} ${context}: ${output}" + fail_msg "$msg" + fi + done +} + assert_provider_records_exist() { local context="$1" local channel provider msg @@ -432,11 +542,14 @@ install_for_active_agent() { export NEMOCLAW_RECREATE_SANDBOX=1 export NEMOCLAW_FRESH=1 - if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ] && is_fake_telegram_token "$TELEGRAM_BOT_TOKEN"; then - # This E2E normally uses fake tokens to exercise config plumbing, not the - # live Telegram API. Remove once onboard has a hermetic fake Telegram API. - export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 - info "Skipping onboarding Telegram reachability probe for fake-token E2E" + if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ]; then + if is_fake_telegram_token "${TELEGRAM_BOT_TOKEN:-}"; then + export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 + info "Skipping onboarding Telegram reachability probe for fake-token E2E" + elif ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then + export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 + info "api.telegram.org unreachable from host; setting NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1" + fi fi if [ -z "${NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION:-}" ] \ && { is_fake_slack_token "$SLACK_BOT_TOKEN" || is_fake_slack_token "$SLACK_APP_TOKEN"; }; then @@ -621,12 +734,16 @@ run_agent_scenario() { assert_all_config_channels "present" "at baseline" assert_registry_channels "present" "at baseline" assert_disabled_channels "absent" "at baseline" + assert_host_messaging_config "at baseline" + assert_host_messaging_plan_state "active" "at baseline" for channel in "${CHANNELS[@]}"; do assert_policy_preset_active "$channel" "active" "at baseline" done section "${agent}: channels stop all + rebuild" stop_all_channels + assert_host_messaging_config "after channels stop" + assert_host_messaging_plan_state "disabled" "after channels stop" run_rebuild "stop-all" section "${agent}: verify stopped state" @@ -634,9 +751,13 @@ run_agent_scenario() { assert_registry_channels "present" "after stop" assert_disabled_channels "present" "after stop" assert_provider_records_exist "after stop" + assert_host_messaging_config "after stop+rebuild" + assert_host_messaging_plan_state "disabled" "after stop+rebuild" section "${agent}: channels start all + rebuild" start_all_channels + assert_host_messaging_config "after channels start" + assert_host_messaging_plan_state "active" "after channels start" run_rebuild "start-all" section "${agent}: verify restarted state" @@ -644,6 +765,8 @@ run_agent_scenario() { assert_registry_channels "present" "after start" assert_disabled_channels "absent" "after start" assert_provider_records_exist "after start" + assert_host_messaging_config "after start+rebuild" + assert_host_messaging_plan_state "active" "after start+rebuild" } section "Phase 0: Prerequisites" diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 02792e036b..2d751cd1e5 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -677,14 +677,15 @@ if openshell --version >/dev/null 2>&1; then fi pass "Pre-cleanup complete" -if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ] \ - && [ -z "${TELEGRAM_BOT_TOKEN_REAL:-}" ] \ - && [[ "$TELEGRAM_TOKEN" == *fake* ]]; then - # This E2E normally uses fake tokens to exercise config plumbing, not the - # live Telegram API. Keep real-token runs on the onboard validation path. - # Remove once onboard has a hermetic fake Telegram API. - export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 - info "Skipping onboarding Telegram reachability probe for fake-token E2E" +if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ]; then + if [ -z "${TELEGRAM_BOT_TOKEN_REAL:-}" ] && [[ "$TELEGRAM_TOKEN" == *fake* ]]; then + export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 + info "Skipping onboarding Telegram reachability probe for fake-token E2E" + elif [ -z "${TELEGRAM_BOT_TOKEN_REAL:-}" ] \ + && ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then + export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 + info "Host cannot reach api.telegram.org; skipping manifest Telegram reachability check" + fi fi if [ -z "${NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION:-}" ] \ && [ -z "${SLACK_BOT_TOKEN_REAL:-}" ] \ diff --git a/test/install-docker-group-reexec.test.ts b/test/install-docker-group-reexec.test.ts index 934835d906..0bfa52dc4e 100644 --- a/test/install-docker-group-reexec.test.ts +++ b/test/install-docker-group-reexec.test.ts @@ -103,6 +103,7 @@ function runEnsureDocker(env: Record, installerArgs: string[]): warn() { :; } error() { return 1; } is_wsl_host() { return 1; } + uname() { printf 'Linux\n'; } verify_downloaded_script() { :; } _NEMOCLAW_INSTALLER_ARGS=(${argsArrayLiteral}) diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 3cce1579ee..687d874e14 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -112,18 +112,24 @@ childProcess.spawn = (...args) => { return child; }; -const { createSandbox } = require(${onboardPath}); +const { createSandbox, setupMessagingChannels } = require(${onboardPath}); (async () => { process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1"; process.env.DISCORD_BOT_TOKEN = "test-discord-token-value"; process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value"; process.env.SLACK_APP_TOKEN = "xapp-test-slack-app-token-value"; process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-telegram-token"; process.env.KUBECONFIG = "/tmp/host-kubeconfig"; process.env.SSH_AUTH_SOCK = "/tmp/host-ssh-agent.sock"; + await setupMessagingChannels(null, null, "my-assistant"); const sandboxName = await createSandbox(null, "gpt-5.4"); - console.log(JSON.stringify({ sandboxName, commands })); + console.log(JSON.stringify({ + sandboxName, + commands, + messagingPlanEnv: process.env.NEMOCLAW_MESSAGING_PLAN_B64, + })); })().catch((error) => { console.error(error); process.exit(1); @@ -205,6 +211,19 @@ const { createSandbox } = require(${onboardPath}); assert.doesNotMatch(createCommand.command, /xapp-test-slack-app-token-value/); assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); assert.doesNotMatch(createCommand.command, /SLACK_APP_TOKEN=/); + assert.doesNotMatch(createCommand.command, /NEMOCLAW_MESSAGING_PLAN_B64=/); + + assert.ok(payload.messagingPlanEnv, "expected serialized messaging plan in host process env"); + const messagingPlan = JSON.parse( + Buffer.from(payload.messagingPlanEnv, "base64").toString("utf8"), + ); + assert.equal(messagingPlan.sandboxName, "my-assistant"); + assert.deepEqual( + messagingPlan.channels.map((channel: { channelId: string }) => channel.channelId).sort(), + ["discord", "slack", "telegram"].sort(), + ); + assert.doesNotMatch(JSON.stringify(messagingPlan), /test-discord-token-value/); + assert.doesNotMatch(JSON.stringify(messagingPlan), /123456:ABC-test-telegram-token/); // Verify blocked credentials are NOT in the sandbox spawn environment assert.ok(createCommand.env, "expected env to be captured from spawn call"); @@ -1244,6 +1263,7 @@ const { createSandbox } = require(${onboardPath}); process.env.OPENSHELL_GATEWAY = "nemoclaw"; process.env.DISCORD_BOT_TOKEN = "test-discord-token"; process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token"; + process.env.SLACK_APP_TOKEN = "xapp-test-slack-token"; const sandboxName = await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); console.log(JSON.stringify({ sandboxName, commands })); })().catch((error) => { @@ -1581,7 +1601,6 @@ const { createSandbox } = require(${onboardPath}); const scriptPath = path.join(tmpDir, "messaging-noninteractive.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); - const httpProbePath = JSON.stringify(path.join(repoRoot, "dist", "lib", "adapters", "http", "probe.js")); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { @@ -1594,24 +1613,14 @@ const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, "") runner.run = () => ({ status: 0 }); runner.runCapture = () => ""; -// Stub the Telegram reachability probe so this test doesn't make a real network -// call — on networks where api.telegram.org is blocked, the non-interactive -// preflight would otherwise abort the test. -const httpProbe = require(${httpProbePath}); -httpProbe.runCurlProbe = (argv) => { - const url = String(argv[argv.length - 1] || ""); - if (url.includes("slack.com/api/")) { - throw new Error("Slack live auth probe should be skipped in this offline test"); - } - return { - ok: true, - httpStatus: 200, - curlStatus: 0, - body: '{"ok":true,"result":{"id":1,"is_bot":true}}', - stderr: "", - message: "", - }; -}; +// Stub the manifest-driven Telegram reachability hook so this test does not +// make a real network call. +global.fetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ ok: true, result: { id: 1, is_bot: true } }), + text: async () => "", +}); const { setupMessagingChannels } = require(${onboardPath}); @@ -1768,6 +1777,7 @@ const { setupMessagingChannels } = require(${onboardPath}); delete process.env.TELEGRAM_BOT_TOKEN; delete process.env.DISCORD_BOT_TOKEN; delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_APP_TOKEN; const result = await setupMessagingChannels(); console.log(JSON.stringify(result)); })().catch((error) => { @@ -1788,6 +1798,7 @@ const { setupMessagingChannels } = require(${onboardPath}); TELEGRAM_BOT_TOKEN: "", DISCORD_BOT_TOKEN: "", SLACK_BOT_TOKEN: "", + SLACK_APP_TOKEN: "", }, });