diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 0289064f64..87cf888c69 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -140,6 +140,12 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=${NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER} \ NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=${NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64} +# Apply messaging agent-install hooks as root so Hermes Python packages can update +# /opt/hermes/.venv before the runtime drops to the sandbox user. +WORKDIR /opt/hermes +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent hermes --phase agent-install + WORKDIR /sandbox USER sandbox @@ -154,10 +160,6 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ # code injection via build-arg interpolation (same concern as OpenClaw C-2). RUN node --experimental-strip-types /opt/nemoclaw-hermes-config/generate-config.ts -# Apply messaging agent-install hooks before Hermes plugin installation. -# hadolint ignore=DL3059 -RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent hermes --phase agent-install - # Install NemoClaw plugin into Hermes # hadolint ignore=DL3059 RUN mkdir -p /sandbox/.hermes/plugins/nemoclaw \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index f8be3ab856..5a2b024de7 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -162,6 +162,8 @@ RUN printf '%s\n' \ # and pty (optional browser TUI bridge). These extras are resolved from the # selected Hermes release's uv.lock via `uv sync --frozen`, so dependency # changes remain tied to HERMES_VERSION/HERMES_TARBALL_SHA256 review. +# Microsoft Teams adapter dependencies are installed by the manifest-driven +# final image when selected. # New Hermes integrations should be installed by the agent workflow when they # are enabled rather than shipped in the base image by default. # Root Node dependencies provide Hermes browser tooling such as agent-browser. diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index c24edc2b67..ae9fc8960d 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -107,6 +107,7 @@ web_auth_env: API_SERVER_KEY # https://hermes-agent.nousresearch.com/docs/user-guide/messaging/weixin. # WhatsApp pairs in the sandbox via `hermes whatsapp`; the selected channel # bakes WHATSAPP_ENABLED/WHATSAPP_MODE into .env and preserves session state. +# Microsoft Teams uses the Bot Framework webhook adapter at /api/messages. messaging_platforms: supported: - telegram @@ -114,6 +115,7 @@ messaging_platforms: - slack - wechat - whatsapp + - teams # Future: signal, matrix, mattermost, email, etc. # Each needs a network policy entry before enabling. diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index 0386ddef63..fa52431a96 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -255,6 +255,88 @@ network_policies: - { path: /usr/bin/python3* } - { path: /opt/hermes/.venv/bin/python } + teams: + name: teams + endpoints: + - host: login.microsoftonline.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: login.botframework.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: api.botframework.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + # The SDK follows Bot Connector serviceUrl values from inbound Teams + # activities, so this host remains method-scoped while Graph/media hosts + # stay read-only. + - host: smba.trafficmanager.net + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - allow: { method: PUT, path: "/**" } + - allow: { method: DELETE, path: "/**" } + - host: graph.microsoft.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - host: teams.microsoft.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: teams.cdn.office.net + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: statics.teams.cdn.office.net + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: "*.sharepoint.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: 1drv.ms + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + binaries: + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3* } + - { path: /opt/hermes/.venv/bin/python } + # WeChat (personal) via Tencent's iLink Bot API. The Hermes adapter uses # HTTP long-polling (no WebSocket). WEIXIN_TOKEN is L7-resolved at egress # from WECHAT_BOT_TOKEN (same credential slot OpenClaw's bridge uses) via diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index 4660213dcd..3ff1099d6c 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -80,6 +80,7 @@ messaging_platforms: - slack - wechat - whatsapp + - teams # ── Inference ─────────────────────────────────────────────────── inference: diff --git a/nemoclaw-blueprint/policies/presets/teams.yaml b/nemoclaw-blueprint/policies/presets/teams.yaml new file mode 100644 index 0000000000..b567e93686 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/teams.yaml @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: teams + description: "Microsoft Teams Bot Framework and Graph API access" + +network_policies: + teams: + name: teams + endpoints: + # Azure AD app credential exchange for the Bot Framework and Graph scopes. + - host: login.microsoftonline.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + # Bot Framework token scope, OpenID metadata, and connector calls. + - host: login.botframework.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: api.botframework.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + # Public Teams Bot Connector service URL used by incoming activities. + # The SDK follows serviceUrl values from inbound activities, and Bot + # Framework connector paths are region/tenant dependent; keep the + # connector host method-scoped while Graph/media hosts stay read-only. + - host: smba.trafficmanager.net + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - allow: { method: PUT, path: "/**" } + - allow: { method: DELETE, path: "/**" } + # Teams plugin media and delegated context helpers use Microsoft Graph. + - host: graph.microsoft.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + # Read-only Teams/Office media surfaces referenced by Teams messages. + - host: teams.microsoft.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: teams.cdn.office.net + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: statics.teams.cdn.office.net + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: "*.sharepoint.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: 1drv.ms + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/node } diff --git a/nemoclaw-blueprint/policies/tiers.yaml b/nemoclaw-blueprint/policies/tiers.yaml index 8f3a0aaba9..85b98e334c 100644 --- a/nemoclaw-blueprint/policies/tiers.yaml +++ b/nemoclaw-blueprint/policies/tiers.yaml @@ -44,5 +44,6 @@ tiers: - { name: telegram, access: read-write } - { name: wechat, access: read-write } - { name: whatsapp, access: read-write } + - { name: teams, access: read-write } - { name: jira, access: read-write } - { name: outlook, access: read-write } diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index bc431720c5..d93f6c8cc2 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -48,7 +48,7 @@ const PROBED_AT = new Date("2026-05-28T04:00:00.000Z"); function fakeAgent(name: "openclaw" | "hermes" = "openclaw"): AgentDefinition { const configDir = name === "openclaw" ? "/sandbox/.openclaw" : "/sandbox/.hermes"; const stateDirs = name === "openclaw" ? ["whatsapp"] : ["platforms"]; - const messagingPlatforms = ["telegram", "discord", "slack", "wechat", "whatsapp"]; + const messagingPlatforms = ["telegram", "discord", "slack", "wechat", "whatsapp", "teams"]; return { name, agentDir: `/fake/${name}`, diff --git a/src/lib/actions/sandbox/messaging-host-forward-lifecycle.ts b/src/lib/actions/sandbox/messaging-host-forward-lifecycle.ts new file mode 100644 index 0000000000..1e720a7ab5 --- /dev/null +++ b/src/lib/actions/sandbox/messaging-host-forward-lifecycle.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + captureOpenshell, + getOpenshellBinary, + runOpenshell, +} from "../../adapters/openshell/runtime"; +import { CLI_NAME } from "../../cli/branding"; +import { sleepSeconds } from "../../core/wait"; +import type { SandboxMessagingPlan } from "../../messaging"; +import { ensureAgentFixedForward } from "../../onboard/agent-fixed-forward"; +import { + ensureMessagingHostForwardIfConfigured, + resolveMessagingHostForward, +} from "../../onboard/messaging-host-forward"; +import { parseForwardList } from "../../state/sandbox-session"; +import { classifyForwardHealthWithReachability, isLocalForwardReachable } from "./forward-health"; + +function captureOpenShellOutput(args: string[], opts: Record = {}): string | null { + const result = captureOpenshell(args, opts as Parameters[1]); + return result.status === 0 ? result.output : null; +} + +function getMessagingForwardHealth(sandboxName: string, port: number): true | false | "occupied" { + const output = captureOpenShellOutput(["forward", "list"], { ignoreError: true }); + if (output === null) return false; + const entries = parseForwardList(output); + const health = classifyForwardHealthWithReachability(entries, sandboxName, String(port), () => + isLocalForwardReachable(port), + ); + if (health === "occupied") { + console.warn( + `! Messaging webhook forward on port ${port} is owned by another sandbox; leaving it unchanged.`, + ); + console.warn(` Free the port, then reconnect: ${CLI_NAME} ${sandboxName} connect`); + return "occupied"; + } + return health; +} + +export function ensureMessagingHostForwardAfterRebuild( + sandboxName: string, + plan: SandboxMessagingPlan | null | undefined, +): boolean { + const forward = resolveMessagingHostForward(plan); + if (!forward) return true; + const health = getMessagingForwardHealth(sandboxName, forward.port); + if (health === true) return true; + if (health === "occupied") return false; + return ensureMessagingHostForwardIfConfigured({ + sandboxName, + plan, + ensureForward: (name, port, label) => + ensureAgentFixedForward( + { + runOpenshell: (args, opts = {}) => + runOpenshell(args, opts as Parameters[1]), + runCaptureOpenshell: captureOpenShellOutput, + openshellArgv: (args) => [getOpenshellBinary(), ...args], + cliName: () => CLI_NAME, + sleep: sleepSeconds, + }, + name, + port, + label, + ), + note: console.log, + }); +} diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index 277f328636..65d621d23e 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -38,17 +38,22 @@ const runtime = D("adapters/openshell/runtime.js"); const gatewayRuntime = D("gateway-runtime-action.js"); const defs = D("agent/defs.js"); const rebuild = D("actions/sandbox/rebuild.js"); +const messagingHostForwardLifecycle = D("actions/sandbox/messaging-host-forward-lifecycle.js"); const processRecovery = D("actions/sandbox/process-recovery.js"); const onboardSession = D("state/onboard-session.js"); const policy = D("policy/index.js"); const { hashCredential } = D("security/credential-hash.js") as { hashCredential: (v: string) => string | null; }; -const { addSandboxChannel } = D("actions/sandbox/policy-channel.js") as { +const { addSandboxChannel, startSandboxChannel } = D("actions/sandbox/policy-channel.js") as { addSandboxChannel: ( name: string, options?: { channel?: string; dryRun?: boolean; force?: boolean }, ) => Promise; + startSandboxChannel: ( + name: string, + options?: { channel?: string; dryRun?: boolean; force?: boolean }, + ) => Promise; }; const TELEGRAM_TOKEN = "123456:AAH-secret-bot-token-value"; @@ -108,6 +113,131 @@ function makeEmptyEntry(name: string): SandboxEntry { return { name } as SandboxEntry; } +function makeTeamsEntry( + name: string, + { disabled = false, port = "3978" }: { disabled?: boolean; port?: string } = {}, +): SandboxEntry { + const active = !disabled; + return { + name, + agent: "openclaw", + messaging: { + schemaVersion: 1, + plan: { + schemaVersion: 1, + sandboxName: name, + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId: "teams", + displayName: "Microsoft Teams", + authMode: "token-paste", + active, + selected: true, + configured: true, + disabled, + inputs: [ + { + channelId: "teams", + inputId: "appId", + kind: "config", + required: true, + sourceEnv: "MSTEAMS_APP_ID", + statePath: "teamsConfig.appId", + value: "teams-app-id", + }, + { + channelId: "teams", + inputId: "clientSecret", + kind: "secret", + required: true, + sourceEnv: "MSTEAMS_APP_PASSWORD", + credentialAvailable: true, + }, + { + channelId: "teams", + inputId: "tenantId", + kind: "config", + required: true, + sourceEnv: "MSTEAMS_TENANT_ID", + statePath: "teamsConfig.tenantId", + value: "teams-tenant-id", + }, + { + channelId: "teams", + inputId: "allowedUsers", + kind: "config", + required: false, + sourceEnv: "TEAMS_ALLOWED_USERS", + statePath: "allowedIds.teams", + value: "", + }, + { + channelId: "teams", + inputId: "webhookPort", + kind: "config", + required: false, + sourceEnv: "MSTEAMS_PORT", + statePath: "teamsConfig.webhookPort", + value: port, + }, + { + channelId: "teams", + inputId: "requireMention", + kind: "config", + required: false, + sourceEnv: "TEAMS_REQUIRE_MENTION", + statePath: "teamsConfig.requireMention", + value: "1", + }, + ], + ...(active + ? { + hostForward: { + channelId: "teams", + port: Number(port), + label: "Microsoft Teams webhook", + }, + } + : {}), + hooks: [], + }, + ], + disabledChannels: disabled ? ["teams"] : [], + credentialBindings: [ + { + channelId: "teams", + credentialId: "teamsClientSecret", + sourceInput: "clientSecret", + providerName: `${name}-teams-bridge`, + providerEnvKey: "MSTEAMS_APP_PASSWORD", + placeholder: "openshell:resolve:env:MSTEAMS_APP_PASSWORD", + credentialAvailable: true, + }, + ], + networkPolicy: { + presets: active ? ["teams"] : [], + entries: active + ? [ + { + channelId: "teams", + presetName: "teams", + policyKeys: ["teams"], + source: "manifest", + }, + ] + : [], + }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }, + }, + } as unknown as SandboxEntry; +} + let spies: MockInstance[]; let logSpy: MockInstance; let errSpy: MockInstance; @@ -119,7 +249,10 @@ let upsertMock: MockInstance; let runOpenshellMock: MockInstance; let applyPresetMock: MockInstance; let getSandboxMock: MockInstance; +let getDisabledChannelsMock: MockInstance; let listSandboxesMock: MockInstance; +let rebuildSandboxMock: MockInstance; +let ensureMessagingHostForwardAfterRebuildMock: MockInstance; function arrangeRegistry(opts: { current: SandboxEntry; others?: SandboxEntry[] }): void { const all = [opts.current, ...(opts.others ?? [])]; @@ -161,6 +294,12 @@ beforeEach(() => { delete process.env.WECHAT_ACCOUNT_ID; delete process.env.WECHAT_BASE_URL; delete process.env.WECHAT_USER_ID; + delete process.env.MSTEAMS_APP_ID; + delete process.env.MSTEAMS_APP_PASSWORD; + delete process.env.MSTEAMS_TENANT_ID; + delete process.env.MSTEAMS_PORT; + delete process.env.TEAMS_ALLOWED_USERS; + delete process.env.TEAMS_REQUIRE_MENTION; logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -170,6 +309,7 @@ beforeEach(() => { // Registry seam. getSandboxMock = vi.spyOn(registry, "getSandbox").mockReturnValue(null); + getDisabledChannelsMock = vi.spyOn(registry, "getDisabledChannels").mockReturnValue([]); listSandboxesMock = vi .spyOn(registry, "listSandboxes") .mockReturnValue({ sandboxes: [], defaultSandbox: null }); @@ -193,7 +333,7 @@ beforeEach(() => { // Agent gate: support every channel. vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "openclaw", - messagingPlatforms: ["telegram", "discord", "slack", "wechat", "whatsapp"], + messagingPlatforms: ["telegram", "discord", "slack", "wechat", "whatsapp", "teams"], }); // Policy seam. addSandboxChannel gates on loadPreset()/parsePresetPolicyKeys() @@ -208,7 +348,10 @@ beforeEach(() => { vi.spyOn(policy, "getAppliedPresets").mockReturnValue([]); // Downstream rebuild is not under test. - vi.spyOn(rebuild, "rebuildSandbox").mockResolvedValue(undefined); + rebuildSandboxMock = vi.spyOn(rebuild, "rebuildSandbox").mockResolvedValue(undefined); + ensureMessagingHostForwardAfterRebuildMock = vi + .spyOn(messagingHostForwardLifecycle, "ensureMessagingHostForwardAfterRebuild") + .mockReturnValue(true); // After a successful interactive add, channel health-check hooks can probe // the sandbox via executeSandboxExecCommand, which calls getOpenshellBinary() @@ -244,6 +387,12 @@ afterEach(() => { delete process.env.WECHAT_ACCOUNT_ID; delete process.env.WECHAT_BASE_URL; delete process.env.WECHAT_USER_ID; + delete process.env.MSTEAMS_APP_ID; + delete process.env.MSTEAMS_APP_PASSWORD; + delete process.env.MSTEAMS_TENANT_ID; + delete process.env.MSTEAMS_PORT; + delete process.env.TEAMS_ALLOWED_USERS; + delete process.env.TEAMS_REQUIRE_MENTION; }); describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { @@ -879,6 +1028,65 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { }); }); +describe("Teams host-forward lifecycle (PRA-2)", () => { + function setTeamsEnv(port = "3978"): void { + process.env.MSTEAMS_APP_ID = "teams-app-id"; + process.env.MSTEAMS_APP_PASSWORD = "teams-client-secret"; + process.env.MSTEAMS_TENANT_ID = "teams-tenant-id"; + process.env.MSTEAMS_PORT = port; + process.env.TEAMS_REQUIRE_MENTION = "1"; + } + + function teamsForwardFromFirstEnsureCall(): unknown { + const plan = ensureMessagingHostForwardAfterRebuildMock.mock.calls[0]?.[1] as + | { channels?: Array<{ channelId?: string; hostForward?: unknown }> } + | undefined; + return plan?.channels?.find((channel) => channel.channelId === "teams")?.hostForward; + } + + it("channels add teams starts the MSTEAMS_PORT host forward after rebuild-now completes", async () => { + setTeamsEnv(); + arrangeRegistry({ current: makeEmptyEntry("alpha") }); + + await addSandboxChannel("alpha", { channel: "teams" }); + + expect(rebuildSandboxMock).toHaveBeenCalledWith("alpha", ["--yes"]); + expect(ensureMessagingHostForwardAfterRebuildMock).toHaveBeenCalledWith( + "alpha", + expect.any(Object), + ); + expect(ensureMessagingHostForwardAfterRebuildMock.mock.invocationCallOrder[0]).toBeGreaterThan( + rebuildSandboxMock.mock.invocationCallOrder[0], + ); + expect(teamsForwardFromFirstEnsureCall()).toEqual({ + channelId: "teams", + port: 3978, + label: "Microsoft Teams webhook", + }); + }); + + it("channels start teams re-establishes the MSTEAMS_PORT host forward after rebuild-now completes", async () => { + arrangeRegistry({ current: makeTeamsEntry("alpha", { disabled: true, port: "3978" }) }); + getDisabledChannelsMock.mockReturnValue(["teams"]); + + await startSandboxChannel("alpha", { channel: "teams" }); + + expect(rebuildSandboxMock).toHaveBeenCalledWith("alpha", ["--yes"]); + expect(ensureMessagingHostForwardAfterRebuildMock).toHaveBeenCalledWith( + "alpha", + expect.any(Object), + ); + expect(ensureMessagingHostForwardAfterRebuildMock.mock.invocationCallOrder[0]).toBeGreaterThan( + rebuildSandboxMock.mock.invocationCallOrder[0], + ); + expect(teamsForwardFromFirstEnsureCall()).toEqual({ + channelId: "teams", + port: 3978, + label: "Microsoft Teams webhook", + }); + }); +}); + function mockBridgeHealthExec(options: { config: unknown; log: string }): void { vi.mocked(processRecovery.executeSandboxExecCommand).mockImplementation( (_sandboxName: string, command: string) => { diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 09aa5bc42b..1b5cd6cf30 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -60,6 +60,7 @@ import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-f import { refreshSandboxPolicyContextFile } from "./policy-context-refresh"; import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; import { rebuildSandbox } from "./rebuild"; +import { ensureMessagingHostForwardAfterRebuild } from "./messaging-host-forward-lifecycle"; type ChannelMutationOptions = { channel?: string; @@ -759,11 +760,15 @@ async function persistManifestChannelDisabledPlan( sandboxName: string, channelId: string, disabled: boolean, -): Promise { +): Promise { const entry = registry.getSandbox(sandboxName); - if (!entry?.messaging?.plan) return false; + if (!entry?.messaging?.plan) return null; const agent = resolveAgentForSandbox(sandboxName); - const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); + const planner = new MessagingWorkflowPlanner( + messagingManifestRegistry, + undefined, + createBuiltInRenderTemplateResolver(), + ); const context = { sandboxName, agent: toMessagingAgentId(agent), @@ -774,7 +779,8 @@ async function persistManifestChannelDisabledPlan( const plan = disabled ? await planner.buildChannelStopPlanFromSandboxEntry(context) : await planner.buildChannelStartPlanFromSandboxEntry(context); - return plan ? MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan) : false; + if (!plan) return null; + return MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan) ? plan : null; } async function persistManifestChannelRemovePlan( @@ -784,7 +790,11 @@ async function persistManifestChannelRemovePlan( const entry = registry.getSandbox(sandboxName); if (!entry) return false; const agent = resolveAgentForSandbox(sandboxName); - const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); + const planner = new MessagingWorkflowPlanner( + messagingManifestRegistry, + undefined, + createBuiltInRenderTemplateResolver(), + ); const plan = await planner.buildChannelRemovePlanFromSandboxEntry({ sandboxName, agent: toMessagingAgentId(agent), @@ -965,7 +975,10 @@ export async function addSandboxChannel( console.log(` ${line}`); } const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); - if (rebuilt) await runMessagingHealthChecksAfterRebuild(sandboxName, plan); + if (rebuilt) { + ensureMessagingHostForwardAfterRebuild(sandboxName, plan); + await runMessagingHealthChecksAfterRebuild(sandboxName, plan); + } return; } @@ -1010,7 +1023,10 @@ export async function addSandboxChannel( } const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); - if (rebuilt) await runMessagingHealthChecksAfterRebuild(sandboxName, plan); + if (rebuilt) { + ensureMessagingHostForwardAfterRebuild(sandboxName, plan); + await runMessagingHealthChecksAfterRebuild(sandboxName, plan); + } } async function rollbackChannelAdd( @@ -1374,13 +1390,17 @@ async function sandboxChannelsSetEnabled( return; } - if (!(await persistManifestChannelDisabledPlan(sandboxName, normalized, disabled))) { + const plan = await persistManifestChannelDisabledPlan(sandboxName, normalized, disabled); + if (!plan) { console.error(` Could not persist messaging plan for '${sandboxName}'.`); process.exit(1); } const state = disabled ? "disabled" : "enabled"; console.log(` ${G}✓${R} Marked ${normalized} ${state} for '${sandboxName}'.`); - await promptAndRebuild(sandboxName, `${verb} '${normalized}'`); + const rebuilt = await promptAndRebuild(sandboxName, `${verb} '${normalized}'`); + if (rebuilt && !disabled) { + ensureMessagingHostForwardAfterRebuild(sandboxName, plan); + } } export async function stopSandboxChannel( diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index 06afdb813a..47bcfb34a0 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -23,6 +23,10 @@ import * as agentRuntime from "../../agent/runtime"; import { G, R } from "../../cli/terminal-style"; import { DASHBOARD_PORT } from "../../core/ports"; import { sleepSeconds, waitUntil } from "../../core/wait"; +import { getActiveMessagingHostForward } from "../../messaging/host-forward"; +import type { SandboxMessagingHostForwardPlan } from "../../messaging/manifest"; +import { hydrateDerivedSandboxMessagingPlanFields } from "../../messaging/persistence"; +import { parseSandboxMessagingPlan } from "../../messaging/plan-validation"; import { ROOT, shellQuote } from "../../runner"; import { createTempSshConfig } from "../../sandbox/temp-ssh-config"; import * as registry from "../../state/registry"; @@ -417,6 +421,17 @@ function recoverDeclaredAgentForwardPorts( return recovered; } +function recoverMessagingHostForward( + sandboxName: string, + { quiet }: { quiet: boolean }, +): boolean | null { + const recovered = ensureMessagingHostForwardHealthy(sandboxName); + if (!quiet && recovered === false) { + console.error(" Messaging webhook port forward could not be re-established."); + } + return recovered; +} + function readNonNegativeNumberEnv(name: string, fallback: number): number { const raw = process.env[name]; if (raw === undefined || raw.trim() === "") return fallback; @@ -544,6 +559,24 @@ function ensureHermesDashboardPortForwardIfEnabled(sandboxName: string): boolean }); } +function getSandboxMessagingHostForward( + sandboxName: string, +): SandboxMessagingHostForwardPlan | null { + const entry = registry.getSandbox(sandboxName); + const parsed = parseSandboxMessagingPlan(entry?.messaging?.plan, { sandboxName }); + const plan = parsed ? hydrateDerivedSandboxMessagingPlanFields(parsed) : null; + return getActiveMessagingHostForward(plan); +} + +function ensureMessagingHostForwardHealthy(sandboxName: string): boolean | null { + const forward = getSandboxMessagingHostForward(sandboxName); + if (!forward) return null; + const health = isSandboxPortForwardHealthy(sandboxName, forward.port); + if (health === true) return true; + if (health === "occupied") return false; + return ensureSandboxPortForwardForPort(sandboxName, forward.port); +} + /** * Re-establish every declared `forward_ports` entry on the active agent * manifest that is not already owned by another recovery helper. The @@ -758,6 +791,7 @@ export function checkAndRecoverSandboxProcesses( } const forwardRecovered = ensureSandboxPortForward(sandboxName); const dashboardForwardRecovered = ensureHermesDashboardPortForwardIfEnabled(sandboxName); + const messagingForwardRecovered = recoverMessagingHostForward(sandboxName, { quiet }); const declaredForwardsRecovered = recoverDeclaredAgentForwardPorts( sandboxName, recoveryPort, @@ -783,6 +817,7 @@ export function checkAndRecoverSandboxProcesses( forwardRecovered || dashboardForwardRecovered === true || dashboardProcessRecovered === true || + messagingForwardRecovered === true || declaredForwardsRecovered === true, }; } @@ -795,6 +830,7 @@ export function checkAndRecoverSandboxProcesses( return { checked: true, wasRunning: true, recovered: false, forwardRecovered: false }; } const dashboardForwardRecovered = ensureHermesDashboardPortForwardIfEnabled(sandboxName); + const messagingForwardRecovered = recoverMessagingHostForward(sandboxName, { quiet }); const declaredForwardsRecovered = recoverDeclaredAgentForwardPorts(sandboxName, recoveryPort, { quiet, }); @@ -805,6 +841,7 @@ export function checkAndRecoverSandboxProcesses( forwardRecovered: dashboardForwardRecovered === true || dashboardProcessRecovered === true || + messagingForwardRecovered === true || declaredForwardsRecovered === true, }; } @@ -836,6 +873,7 @@ export function checkAndRecoverSandboxProcesses( } const forwardRecovered = ensureSandboxPortForward(sandboxName); const dashboardForwardRecovered = ensureHermesDashboardPortForwardIfEnabled(sandboxName); + const messagingForwardRecovered = recoverMessagingHostForward(sandboxName, { quiet }); const declaredForwardsRecovered = recoverDeclaredAgentForwardPorts(sandboxName, recoveryPort, { quiet, }); @@ -859,6 +897,7 @@ export function checkAndRecoverSandboxProcesses( forwardRecovered: forwardRecovered || dashboardForwardRecovered === true || + messagingForwardRecovered === true || declaredForwardsRecovered === true, }; } diff --git a/src/lib/actions/sandbox/rebuild-flow.test.ts b/src/lib/actions/sandbox/rebuild-flow.test.ts index f68c09b90b..f6db00f22d 100644 --- a/src/lib/actions/sandbox/rebuild-flow.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow.test.ts @@ -33,6 +33,7 @@ type RebuildFlowHarness = { backupSandboxStateSpy: MockInstance; errorSpy: MockInstance; executeSandboxCommandSpy: MockInstance; + ensureMessagingHostForwardAfterRebuildSpy: MockInstance; logSpy: MockInstance; onboardSpy: MockInstance; registryUpdateSpy: MockInstance; @@ -68,6 +69,9 @@ function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): Rebuild const nim = requireDist("../../../../dist/lib/inference/nim.js"); const policies = requireDist("../../../../dist/lib/policy/index.js"); const processRecovery = requireDist("../../../../dist/lib/actions/sandbox/process-recovery.js"); + const messagingHostForwardLifecycle = requireDist( + "../../../../dist/lib/actions/sandbox/messaging-host-forward-lifecycle.js", + ); const messaging = requireDist("../../../../dist/lib/messaging/index.js"); const shields = requireDist("../../../../dist/lib/shields/index.js"); @@ -173,6 +177,9 @@ function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): Rebuild const messagingRebuildPlanSpy = vi .spyOn(messaging.MessagingWorkflowPlanner.prototype, "buildRebuildPlanFromSandboxEntry") .mockImplementation(overrides.buildMessagingRebuildPlan ?? (() => null)); + const ensureMessagingHostForwardAfterRebuildSpy = vi + .spyOn(messagingHostForwardLifecycle, "ensureMessagingHostForwardAfterRebuild") + .mockReturnValue(true); errorSpy.mockClear(); logSpy.mockClear(); @@ -184,6 +191,7 @@ function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): Rebuild backupSandboxStateSpy, errorSpy, executeSandboxCommandSpy, + ensureMessagingHostForwardAfterRebuildSpy, logSpy, onboardSpy, registryUpdateSpy, @@ -194,6 +202,76 @@ function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): Rebuild }; } +function makeActiveTeamsMessagingPlan() { + return { + schemaVersion: 1, + sandboxName: "alpha", + agent: "openclaw", + workflow: "rebuild", + channels: [ + { + channelId: "teams", + displayName: "Microsoft Teams", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [ + { + channelId: "teams", + inputId: "appId", + kind: "config", + required: true, + sourceEnv: "MSTEAMS_APP_ID", + statePath: "teamsConfig.appId", + value: "teams-app-id", + }, + { + channelId: "teams", + inputId: "clientSecret", + kind: "secret", + required: true, + sourceEnv: "MSTEAMS_APP_PASSWORD", + credentialAvailable: true, + }, + { + channelId: "teams", + inputId: "tenantId", + kind: "config", + required: true, + sourceEnv: "MSTEAMS_TENANT_ID", + statePath: "teamsConfig.tenantId", + value: "teams-tenant-id", + }, + { + channelId: "teams", + inputId: "webhookPort", + kind: "config", + required: false, + sourceEnv: "MSTEAMS_PORT", + statePath: "teamsConfig.webhookPort", + value: "3978", + }, + ], + hostForward: { + channelId: "teams", + port: 3978, + label: "Microsoft Teams webhook", + }, + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: ["teams"], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + describe("rebuildSandbox flow", () => { beforeEach(() => { delete process.env.NEMOCLAW_SANDBOX_NAME; @@ -269,6 +347,23 @@ describe("rebuildSandbox flow", () => { expect(harness.onboardSpy).not.toHaveBeenCalled(); }); + it("starts the active Teams host forward after a successful rebuild", async () => { + const plan = makeActiveTeamsMessagingPlan(); + const harness = createRebuildFlowHarness({ + applyPreset: () => true, + buildMessagingRebuildPlan: () => plan, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(harness.ensureMessagingHostForwardAfterRebuildSpy).toHaveBeenCalledWith("alpha", plan); + expect( + harness.ensureMessagingHostForwardAfterRebuildSpy.mock.invocationCallOrder[0], + ).toBeGreaterThan(harness.onboardSpy.mock.invocationCallOrder[0]); + }); + it("finishes the rebuild while surfacing incomplete post-restore work", async () => { const harness = createRebuildFlowHarness({ executeSandboxCommand: () => ({ status: 1, stdout: "", stderr: "hash refresh failed" }), diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index dbd6f023ac..bdf05fd4a0 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -48,6 +48,7 @@ import type { } from "../../messaging"; import { createBuiltInChannelManifestRegistry, + createBuiltInRenderTemplateResolver, MessagingSetupApplier, MessagingWorkflowPlanner, toMessagingAgentId, @@ -69,6 +70,7 @@ import { getActiveSandboxSessions, } from "../../state/sandbox-session"; import { removeSandboxRegistryEntry } from "./destroy"; +import { ensureMessagingHostForwardAfterRebuild } from "./messaging-host-forward-lifecycle"; import { executeSandboxCommand } from "./process-recovery"; import { buildRebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out"; import { @@ -231,7 +233,11 @@ async function stageMessagingManifestPlanForRebuild( log: (msg: string) => void, ): Promise { const agent = loadAgent(rebuildAgent || "openclaw"); - const planner = new MessagingWorkflowPlanner(createBuiltInChannelManifestRegistry()); + const planner = new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + undefined, + createBuiltInRenderTemplateResolver(), + ); const plan = await planner.buildRebuildPlanFromSandboxEntry({ sandboxName, agent: toMessagingAgentId(agent), @@ -1001,6 +1007,7 @@ export async function rebuildSandbox( // gateway-side config writes, so the final result is downgraded below. let mutablePermsRepairUnverified = false; let mutableConfigHashRefreshUnverified = false; + let messagingHostForwardUnverified = false; if (agentDef.name === "openclaw") { // openclaw doctor --fix validates and repairs directory structure. // Idempotent and safe — catches structural changes between OpenClaw versions @@ -1093,9 +1100,17 @@ export async function rebuildSandbox( log(`Registry updated: agentVersion=${agentDef.expectedVersion}`); if (!relockShieldsIfNeeded(true)) return bail("Failed to re-apply shields lockdown."); + if (!ensureMessagingHostForwardAfterRebuild(sandboxName, rebuildMessagingPlan)) { + messagingHostForwardUnverified = true; + } console.log(""); - if (restoreSucceeded && !mutablePermsRepairUnverified && !mutableConfigHashRefreshUnverified) { + if ( + restoreSucceeded && + !mutablePermsRepairUnverified && + !mutableConfigHashRefreshUnverified && + !messagingHostForwardUnverified + ) { console.log(` ${G}\u2713${R} Sandbox '${sandboxName}' rebuilt successfully`); if (staleRecovery) { console.log( @@ -1128,6 +1143,11 @@ export async function rebuildSandbox( ` Mutable OpenClaw config hash was not refreshed \u2014 restart the sandbox or re-run \`${CLI_NAME} ${sandboxName} rebuild\` before relying on config integrity checks`, ); } + if (messagingHostForwardUnverified) { + console.log( + ` Messaging webhook forward was not verified \u2014 run \`${CLI_NAME} ${sandboxName} connect\` after resolving the port conflict`, + ); + } } // Stale recovery reset the shields state to mutable (the gone sandbox's lock // seal could not carry over to the fresh image). If lockdown had been enabled, diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 70453cf33e..770d610abc 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -54,6 +54,7 @@ describe("agent definitions", () => { "slack", "wechat", "whatsapp", + "teams", ]); expect(openclaw.inferenceProviderOptions).toEqual([]); // #5027: openclaw.json must be declared as a durable state file so @@ -93,6 +94,7 @@ describe("agent definitions", () => { "slack", "wechat", "whatsapp", + "teams", ]); }); diff --git a/src/lib/inventory/index.test.ts b/src/lib/inventory/index.test.ts index 89a4ba44a0..d77509654e 100644 --- a/src/lib/inventory/index.test.ts +++ b/src/lib/inventory/index.test.ts @@ -573,6 +573,41 @@ describe("inventory commands", () => { ).toBe(true); }); + it("prints a Teams webhook port overlap warning with the port", () => { + const lines: string[] = []; + const findMessagingOverlaps = vi.fn().mockReturnValue([ + { + channel: "teams", + sandboxes: ["alice", "bob"], + reason: "host-forward-port", + port: 3978, + message: + "'{first}' and '{second}' both use Microsoft Teams webhook port {port}; no two active Teams sandboxes can share that local forward.", + }, + ]); + showStatusCommand({ + listSandboxes: () => ({ + sandboxes: [ + { name: "alice", model: "m", messaging: messagingState("alice", ["teams"]) }, + { name: "bob", model: "m", messaging: messagingState("bob", ["teams"]) }, + ], + defaultSandbox: "alice", + }), + getLiveInference: () => null, + showServiceStatus: vi.fn(), + findMessagingOverlaps, + log: (message = "") => lines.push(message), + }); + + expect( + lines.some((line) => + line.includes( + "'alice' and 'bob' both use Microsoft Teams webhook port 3978; no two active Teams sandboxes can share that local forward.", + ), + ), + ).toBe(true); + }); + it("surfaces Hermes gateway log when messaging is degraded", () => { const lines: string[] = []; const checkMessagingBridgeHealth = vi diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index 0859ad590e..e5a8cc84a0 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -89,6 +89,7 @@ export interface MessagingOverlap { sandboxes: [string, string]; reason?: "matching-token" | "unknown-token" | string; message?: string; + port?: number; } export interface GatewayHealth { @@ -483,9 +484,9 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { const overlaps = deps.findMessagingOverlaps(); if (overlaps.length > 0) { log(""); - for (const { channel, sandboxes: pair, reason, message } of overlaps) { + for (const { channel, sandboxes: pair, reason, message, port } of overlaps) { if (message) { - log(` ⚠ ${formatMessagingOverlapMessage(message, channel, pair)}`); + log(` ⚠ ${formatMessagingOverlapMessage(message, channel, pair, { port })}`); continue; } const detail = @@ -541,9 +542,11 @@ function formatMessagingOverlapMessage( template: string, channel: string, pair: readonly [string, string], + values: { readonly port?: number } = {}, ): string { return template .replaceAll("{channel}", channel) .replaceAll("{first}", pair[0]) - .replaceAll("{second}", pair[1]); + .replaceAll("{second}", pair[1]) + .replaceAll("{port}", values.port === undefined ? "" : String(values.port)); } diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index 2d1cb5a05d..a9678dac99 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -22,10 +22,15 @@ describe("messaging channel config", () => { "SLACK_ALLOWED_USERS", "SLACK_ALLOWED_CHANNELS", "WHATSAPP_ALLOWED_IDS", + "TEAMS_ALLOWED_USERS", + "TEAMS_REQUIRE_MENTION", "TELEGRAM_GROUP_POLICY", "WECHAT_ACCOUNT_ID", "WECHAT_BASE_URL", "WECHAT_USER_ID", + "MSTEAMS_APP_ID", + "MSTEAMS_TENANT_ID", + "MSTEAMS_PORT", ]); }); @@ -40,6 +45,11 @@ describe("messaging channel config", () => { DISCORD_REQUIRE_MENTION: "0", SLACK_ALLOWED_USERS: " U01ABC2DEF3, U04GHI5JKL6 ", SLACK_ALLOWED_CHANNELS: " C012AB3CD, C987ZY6XW ", + TEAMS_ALLOWED_USERS: " aad-one, aad-two ", + TEAMS_REQUIRE_MENTION: "1", + MSTEAMS_APP_ID: " teams-app ", + MSTEAMS_TENANT_ID: " teams-tenant ", + MSTEAMS_PORT: "3978", NVIDIA_INFERENCE_API_KEY: "not-channel-config", }), ).toEqual({ @@ -49,6 +59,11 @@ describe("messaging channel config", () => { DISCORD_REQUIRE_MENTION: "0", SLACK_ALLOWED_USERS: "U01ABC2DEF3, U04GHI5JKL6", SLACK_ALLOWED_CHANNELS: "C012AB3CD, C987ZY6XW", + TEAMS_ALLOWED_USERS: "aad-one, aad-two", + TEAMS_REQUIRE_MENTION: "1", + MSTEAMS_APP_ID: "teams-app", + MSTEAMS_TENANT_ID: "teams-tenant", + MSTEAMS_PORT: "3978", }); }); @@ -87,6 +102,16 @@ describe("messaging channel config", () => { expect(env.DISCORD_USER_ID).toBe("1005536447329222676"); }); + it("normalizes Teams port compatibility aliases to canonical channel config", () => { + expect( + readMessagingChannelConfigFromEnv({ + TEAMS_PORT: "3979", + }), + ).toEqual({ + MSTEAMS_PORT: "3979", + }); + }); + it("hydrates missing env values but preserves explicit env overrides", () => { const env: NodeJS.ProcessEnv = { TELEGRAM_ALLOWED_IDS: "env-user", diff --git a/src/lib/messaging/AGENTS.md b/src/lib/messaging/AGENTS.md index cf734c8fa9..5e0f2e58d5 100644 --- a/src/lib/messaging/AGENTS.md +++ b/src/lib/messaging/AGENTS.md @@ -5,7 +5,7 @@ ## Purpose -This package owns NemoClaw's manifest-first messaging architecture. It turns channel declarations for Telegram, Discord, Slack, WeChat, and WhatsApp into a serializable `SandboxMessagingPlan`, then applies that plan during onboard, channel add/remove/start/stop, rebuild, image build, runtime setup, diagnostics, and conflict checks. +This package owns NemoClaw's manifest-first messaging architecture. It turns channel declarations for Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams into a serializable `SandboxMessagingPlan`, then applies that plan during onboard, channel add/remove/start/stop, rebuild, image build, runtime setup, diagnostics, and conflict checks. The design goal is to keep messaging channel behavior out of core onboard/rebuild logic. Add channel-specific behavior to manifests, template resolvers, hooks, runtime assets, and policy metadata first; only change shared engines when the manifest vocabulary cannot express the required behavior. @@ -76,7 +76,7 @@ Use the narrowest test that covers the changed surface: - Build-time render/install behavior: `npx vitest run test/messaging-build-applier.test.ts` - Onboard/channel CLI integration: `npx vitest run test/onboard-messaging.test.ts test/channels-add-preset.test.ts src/lib/onboard/messaging-channel-setup.test.ts` -Mock external messaging APIs. Do not call real Telegram, Discord, Slack, WeChat, WhatsApp, NVIDIA, or OpenShell services from unit tests. +Mock external messaging APIs. Do not call real Telegram, Discord, Slack, WeChat, WhatsApp, Microsoft Teams, NVIDIA, or OpenShell services from unit tests. ## Documentation diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 5adf696145..c3c19cb62c 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -7,6 +7,13 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n import { homedir } from "node:os"; import { dirname, join, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; +import { discordManifest } from "../../channels/discord/manifest.ts"; +import { slackManifest } from "../../channels/slack/manifest.ts"; +import { teamsManifest } from "../../channels/teams/manifest.ts"; +import { telegramManifest } from "../../channels/telegram/manifest.ts"; +import { wechatManifest } from "../../channels/wechat/manifest.ts"; +import { whatsappManifest } from "../../channels/whatsapp/manifest.ts"; +import type { ChannelManifest } from "../../manifest/types.ts"; type Env = Record; type JsonObject = Record; @@ -99,6 +106,7 @@ export type BuildCommandResult = { readonly runtimePlanPath: string; readonly doctorEnv: Record; readonly installSpecs: readonly string[]; + readonly hermesUvPackages: readonly string[]; readonly openclawVersion: string; }; @@ -107,6 +115,25 @@ type OpenClawPluginInstall = { readonly pin: boolean; }; +type HermesUvPackageInstall = { + readonly spec: string; +}; + +const TRUSTED_CHANNEL_MANIFESTS: readonly ChannelManifest[] = [ + telegramManifest, + discordManifest, + wechatManifest, + slackManifest, + whatsappManifest, + teamsManifest, +] as const; + +function isPinnedHermesUvPackageSpec(spec: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9_.-]*(?:\[[A-Za-z0-9][A-Za-z0-9_.-]*(?:,[A-Za-z0-9][A-Za-z0-9_.-]*)*\])?==[A-Za-z0-9][A-Za-z0-9_.!+~-]*$/.test( + spec, + ); +} + export class MessagingBuildApplierError extends Error {} export const DEFAULT_MESSAGING_RUNTIME_PLAN_PATH = @@ -401,6 +428,11 @@ export function collectOpenClawMessagingPluginInstallSpecs( return collectOpenClawMessagingPluginInstalls(plan, env).map((install) => install.spec); } +export function collectHermesMessagingUvPackages(plan: MessagingBuildPlan | null): string[] { + if (plan?.agent !== "hermes") return []; + return collectHermesMessagingUvPackageInstalls(plan).map((install) => install.spec); +} + function collectOpenClawMessagingPluginInstalls( plan: MessagingBuildPlan | null, env: Env, @@ -428,6 +460,62 @@ function collectOpenClawMessagingPluginInstalls( return installs; } +function collectHermesMessagingUvPackageInstalls( + plan: MessagingBuildPlan | null, +): HermesUvPackageInstall[] { + const installs: HermesUvPackageInstall[] = []; + const seen = new Set(); + const trustedSpecs = trustedHermesUvPackageSpecsForPlan(plan); + for (const step of enabledBuildStepsForPhase(plan, "agent-install")) { + if (step.kind !== "package-install") continue; + if (step.value === undefined) { + if (step.required) { + throw new MessagingBuildApplierError( + `Messaging package-install output ${step.outputId} is missing`, + ); + } + continue; + } + const install = readHermesUvPipPackageInstall(step.value, step.outputId); + if (!trustedSpecs.has(install.spec)) { + throw new MessagingBuildApplierError( + `Messaging package-install output ${step.outputId} is not declared by a trusted built-in manifest for active Hermes channels: ${install.spec}`, + ); + } + if (seen.has(install.spec)) continue; + seen.add(install.spec); + installs.push(install); + } + return installs; +} + +/** + * Security boundary: NEMOCLAW_MESSAGING_PLAN_B64 is a derived build artifact, + * not authority to choose root-time Hermes packages. Invalid state: a serialized + * plan contains a hermes-uv-pip package spec absent from the trusted built-in + * manifest for a selected active channel. Source fix: update the channel + * manifest's agentPackages, not the serialized plan/env. Remove this recheck + * only once package installs are no longer serialized or plans are signed and + * attested at the Docker build boundary. + */ +function trustedHermesUvPackageSpecsForPlan(plan: MessagingBuildPlan | null): Set { + const active = new Set(activeChannels(plan)); + const specs = new Set(); + for (const manifest of TRUSTED_CHANNEL_MANIFESTS) { + if (!active.has(manifest.id)) continue; + for (const packageSpec of manifest.agentPackages ?? []) { + if (packageSpec.agent !== "hermes" || packageSpec.manager !== "hermes-uv-pip") continue; + if (!isPinnedHermesUvPackageSpec(packageSpec.spec)) { + throw new MessagingBuildApplierError( + `Trusted manifest ${manifest.id} declares an unsafe Hermes Python package spec: ${packageSpec.spec}`, + ); + } + specs.add(packageSpec.spec); + } + } + return specs; +} + export function openClawDoctorEnvOverrides( plan: MessagingBuildPlan | null, env: Env = process.env, @@ -835,6 +923,35 @@ function readOpenClawPackageInstall( }; } +function readHermesUvPipPackageInstall( + value: MessagingSerializableValue, + outputId: string, +): HermesUvPackageInstall { + if (!isObject(value)) { + throw new MessagingBuildApplierError( + `Messaging package-install output ${outputId} must be an object`, + ); + } + const install = value as JsonObject; + if (install.manager !== "hermes-uv-pip") { + throw new MessagingBuildApplierError( + `Messaging package-install output ${outputId} must use manager 'hermes-uv-pip'`, + ); + } + if (typeof install.spec !== "string" || install.spec.trim().length === 0) { + throw new MessagingBuildApplierError( + `Messaging package-install output ${outputId} must include a Hermes Python package spec`, + ); + } + const spec = install.spec.trim(); + if (!isPinnedHermesUvPackageSpec(spec)) { + throw new MessagingBuildApplierError( + `Messaging package-install output ${outputId} must use a safe exact-pinned Hermes Python package spec`, + ); + } + return { spec }; +} + function resolveOpenClawPackageSpec(spec: string, env: Env): string { const version = (env.OPENCLAW_VERSION || "").trim(); const resolved = spec.replaceAll("{{openclaw.version}}", () => { @@ -1299,6 +1416,10 @@ export function installMessagingPackages(plan: MessagingBuildPlan | null, env: E installOpenClawMessagingPlugins(plan, env); return; } + if (plan.agent === "hermes") { + installHermesMessagingUvPackages(plan, env); + return; + } const packageSteps = enabledBuildStepsForPhase(plan, "agent-install").filter( (step) => step.kind === "package-install", @@ -1310,6 +1431,26 @@ export function installMessagingPackages(plan: MessagingBuildPlan | null, env: E } } +function installHermesMessagingUvPackages(plan: MessagingBuildPlan | null, env: Env): void { + const selectedPackages = collectHermesMessagingUvPackageInstalls(plan).map( + (install) => install.spec, + ); + if (selectedPackages.length === 0) return; + runCommand( + [ + "uv", + "pip", + "install", + "--python", + "/opt/hermes/.venv/bin/python", + "--no-cache", + "--", + ...selectedPackages, + ], + env, + ); +} + export function describeMessagingBuildPhase( plan: MessagingBuildPlan | null, phase: MessagingBuildPhase, @@ -1326,6 +1467,7 @@ export function describeMessagingBuildPhase( doctorEnv: plan?.agent === "openclaw" ? openClawDoctorEnvOverrides(plan, env) : {}, installSpecs: plan?.agent === "openclaw" ? collectOpenClawMessagingPluginInstallSpecs(plan, env) : [], + hermesUvPackages: plan?.agent === "hermes" ? collectHermesMessagingUvPackages(plan) : [], openclawVersion: env.OPENCLAW_VERSION || "", }; } diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts index d86b821b9d..dbd3b16029 100644 --- a/src/lib/messaging/applier/setup-applier.ts +++ b/src/lib/messaging/applier/setup-applier.ts @@ -149,6 +149,7 @@ function assertSandboxMessagingPlan(value: unknown): asserts value is SandboxMes typeof value.agent !== "string" || typeof value.workflow !== "string" || !Array.isArray(value.channels) || + !value.channels.every(isSerializableChannelPlan) || !Array.isArray(value.disabledChannels) || !Array.isArray(value.credentialBindings) || !isObject(value.networkPolicy) || @@ -166,6 +167,21 @@ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isSerializableChannelPlan(value: unknown): boolean { + if (!isObject(value)) return false; + if (!Object.hasOwn(value, "hostForward")) return true; + const hostForward = value.hostForward; + return ( + isObject(hostForward) && + typeof hostForward.channelId === "string" && + typeof hostForward.port === "number" && + Number.isInteger(hostForward.port) && + hostForward.port >= 1 && + hostForward.port <= 65535 && + typeof hostForward.label === "string" + ); +} + function isRuntimeSetup(value: unknown): boolean { if (value === undefined) return true; return ( diff --git a/src/lib/messaging/channels/built-ins.ts b/src/lib/messaging/channels/built-ins.ts index 441e224122..aeb8439114 100644 --- a/src/lib/messaging/channels/built-ins.ts +++ b/src/lib/messaging/channels/built-ins.ts @@ -6,12 +6,14 @@ import { createChannelManifestRegistry } from "../manifest"; import { discordManifest } from "./discord/manifest"; import { slackManifest } from "./slack/manifest"; import { telegramManifest } from "./telegram/manifest"; +import { teamsManifest } from "./teams/manifest"; import { wechatManifest } from "./wechat/manifest"; import { whatsappManifest } from "./whatsapp/manifest"; export { discordManifest } from "./discord/manifest"; export { slackManifest } from "./slack/manifest"; export { telegramManifest } from "./telegram/manifest"; +export { teamsManifest } from "./teams/manifest"; export { wechatManifest } from "./wechat/manifest"; export { whatsappManifest } from "./whatsapp/manifest"; @@ -21,6 +23,7 @@ export const BUILT_IN_CHANNEL_MANIFESTS = [ wechatManifest, slackManifest, whatsappManifest, + teamsManifest, ] as const; export function createBuiltInChannelManifestRegistry(): ChannelManifestRegistry { diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 9654133aab..8d08c248b1 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -21,6 +21,7 @@ import { createBuiltInChannelManifestRegistry, discordManifest, slackManifest, + teamsManifest, telegramManifest, wechatManifest, whatsappManifest, @@ -202,6 +203,7 @@ describe("built-in channel manifests", () => { "wechat", "slack", "whatsapp", + "teams", ]); expect(registry.listAvailable({ agent: "hermes" }).map((manifest) => manifest.id)).toEqual([ "telegram", @@ -209,6 +211,7 @@ describe("built-in channel manifests", () => { "wechat", "slack", "whatsapp", + "teams", ]); }); @@ -238,6 +241,8 @@ describe("built-in channel manifests", () => { "src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.ts", "src/lib/messaging/channels/slack/hooks/validate-credentials.ts", "src/lib/messaging/channels/whatsapp/manifest.ts", + "src/lib/messaging/channels/teams/manifest.ts", + "src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts", "src/lib/messaging/hooks/common/config-prompt.ts", "src/lib/messaging/hooks/common/token-paste.ts", ]; @@ -266,6 +271,7 @@ describe("built-in channel manifests", () => { wechat: wechatManifest, slack: slackManifest, whatsapp: whatsappManifest, + teams: teamsManifest, }; for (const [channelId, manifest] of Object.entries(manifests)) { @@ -287,17 +293,19 @@ describe("built-in channel manifests", () => { expect(findInput(slackManifest, "botToken").prompt).toMatchObject({ label: KNOWN_CHANNELS.slack.label, help: KNOWN_CHANNELS.slack.help, - placeholder: "xoxb-...", }); expect(findInput(slackManifest, "appToken").prompt).toMatchObject({ label: KNOWN_CHANNELS.slack.appTokenLabel, help: KNOWN_CHANNELS.slack.appTokenHelp, - placeholder: "xapp-...", }); expect(findInput(wechatManifest, "botToken").prompt).toEqual({ label: KNOWN_CHANNELS.wechat.label, help: KNOWN_CHANNELS.wechat.help, }); + expect(findInput(teamsManifest, "clientSecret").prompt).toEqual({ + label: KNOWN_CHANNELS.teams.label, + help: KNOWN_CHANNELS.teams.help, + }); }); it("declares Telegram env keys, policy, and OpenClaw/Hermes render intent", () => { @@ -649,4 +657,157 @@ describe("built-in channel manifests", () => { expect(JSON.stringify(whatsappManifest.runtime?.openclaw)).toContain("whatsapp-qr-compact"); expectOpenClawRuntimeVisibility(whatsappManifest, ["whatsapp"], ["whatsapp"]); }); + + it("declares Microsoft Teams Bot Framework config for both agents", () => { + const appId = findInput(teamsManifest, "appId"); + const clientSecret = findInput(teamsManifest, "clientSecret"); + const tenantId = findInput(teamsManifest, "tenantId"); + const allowedUsers = findInput(teamsManifest, "allowedUsers"); + const webhookPort = findInput(teamsManifest, "webhookPort"); + const requireMention = findInput(teamsManifest, "requireMention"); + + expect(() => findInput(teamsManifest, "groupPolicy")).toThrow( + /missing input teams\.groupPolicy/, + ); + expect(getChannelTokenKeys(KNOWN_CHANNELS.teams)).toEqual(["MSTEAMS_APP_PASSWORD"]); + expect(teamsManifest.description).toContain("experimental"); + expect(appId.envKey).toBe("MSTEAMS_APP_ID"); + expect(appId.envAliases).toEqual(["TEAMS_CLIENT_ID"]); + expect(clientSecret.envKey).toBe("MSTEAMS_APP_PASSWORD"); + expect(clientSecret.envAliases).toEqual(["TEAMS_CLIENT_SECRET"]); + expect(clientSecret.statePath).toBeUndefined(); + expect(tenantId.envKey).toBe("MSTEAMS_TENANT_ID"); + expect(tenantId.envAliases).toEqual(["TEAMS_TENANT_ID"]); + expect(allowedUsers.envKey).toBe("TEAMS_ALLOWED_USERS"); + expect(allowedUsers.envAliases).toEqual(["MSTEAMS_ALLOWED_USERS"]); + expect(allowedUsers.required).toBe(false); + expect(webhookPort.envKey).toBe("MSTEAMS_PORT"); + expect(webhookPort.envAliases).toEqual(["TEAMS_PORT"]); + expect(webhookPort).toMatchObject({ kind: "config", defaultValue: "3978" }); + expect(requireMention.envKey).toBe("TEAMS_REQUIRE_MENTION"); + expect(requireMention.validValues).toEqual(["0", "1"]); + expect(requireMention).toMatchObject({ kind: "config", defaultValue: "1" }); + expect(KNOWN_CHANNELS.teams.allowIdsMode).toBe("dm"); + expect(teamsManifest.credentials).toEqual([ + { + id: "teamsClientSecret", + sourceInput: "clientSecret", + providerName: "{sandboxName}-teams-bridge", + providerEnvKey: "MSTEAMS_APP_PASSWORD", + placeholder: "openshell:resolve:env:MSTEAMS_APP_PASSWORD", + primary: true, + }, + ]); + expect(policyPresetNames(teamsManifest)).toEqual(["teams"]); + expect(teamsManifest.hostForward).toEqual({ + port: "{{teamsConfig.webhookPort}}", + label: "Microsoft Teams webhook", + }); + expect(findHook(teamsManifest, "teams-host-forward-port-conflict")).toMatchObject({ + phase: "pre-enable", + handler: "teams.hostForwardPortConflict", + inputs: ["webhookPort"], + onFailure: "abort", + }); + expectConcreteStatusHook( + teamsManifest, + "teams-host-forward-port-status", + "teams.hostForwardPortStatus", + "hostForwardPortOverlaps", + ); + expectEnvRenderLines(teamsManifest, "teams-hermes-env", [ + "TEAMS_CLIENT_ID={{teamsConfig.appId}}", + "TEAMS_CLIENT_SECRET={{credential.teamsClientSecret.placeholder}}", + "TEAMS_TENANT_ID={{teamsConfig.tenantId}}", + "TEAMS_ALLOWED_USERS={{allowedIds.teams.csv}}", + "TEAMS_PORT={{teamsConfig.webhookPort}}", + ]); + expect(renderJson(teamsManifest)).toContain('"path":"channels.msteams"'); + expect(renderJson(teamsManifest)).toContain('"path":"plugins.entries.msteams"'); + expect(renderJson(teamsManifest)).toContain('"path":"platforms.teams"'); + expect(renderJson(teamsManifest)).toContain("credential.teamsClientSecret.placeholder"); + expect(renderJson(teamsManifest)).toContain("teamsConfig.webhookPort"); + expect(renderJson(teamsManifest)).toContain('"groupPolicy":"open"'); + expect(renderJson(teamsManifest)).not.toContain("groupAllowFrom"); + expectTokenPasteEnrollHook(teamsManifest, ["clientSecret"]); + expect(findHook(teamsManifest, "teams-config-prompt")).toMatchObject({ + phase: "enroll", + handler: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + outputs: [ + { + id: "appId", + kind: "config", + required: true, + }, + { + id: "tenantId", + kind: "config", + required: true, + }, + { + id: "allowedUsers", + kind: "config", + }, + { + id: "webhookPort", + kind: "config", + }, + { + id: "requireMention", + kind: "config", + }, + ], + }); + expectOpenClawRuntimeVisibility(teamsManifest, ["msteams"], ["msteams", "teams"], "msteams"); + expect(teamsManifest.agentPackages).toContainEqual({ + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@openclaw/msteams@{{openclaw.version}}", + pin: true, + required: true, + }); + expect(teamsManifest.agentPackages).toContainEqual({ + id: "hermesTeamsAppsPackage", + agent: "hermes", + manager: "hermes-uv-pip", + spec: "microsoft-teams-apps==2.0.13.4", + required: true, + }); + expect(teamsManifest.agentPackages).toContainEqual({ + id: "hermesAiohttpPackage", + agent: "hermes", + manager: "hermes-uv-pip", + spec: "aiohttp==3.14.1", + required: true, + }); + expect(teamsManifest.state).toEqual({ + persist: { + teamsConfig: ["appId", "tenantId", "webhookPort", "requireMention"], + allowedIds: ["allowedUsers"], + }, + rebuildHydration: [ + { + statePath: "teamsConfig.appId", + env: "MSTEAMS_APP_ID", + }, + { + statePath: "teamsConfig.tenantId", + env: "MSTEAMS_TENANT_ID", + }, + { + statePath: "allowedIds.teams", + env: "TEAMS_ALLOWED_USERS", + }, + { + statePath: "teamsConfig.webhookPort", + env: "MSTEAMS_PORT", + }, + { + statePath: "teamsConfig.requireMention", + env: "TEAMS_REQUIRE_MENTION", + }, + ], + }); + }); }); diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index 1e498d9110..ff25fe4c6e 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -30,6 +30,7 @@ describe("built-in messaging channel metadata", () => { "wechat", "slack", "whatsapp", + "teams", ]); expect(listAvailableMessagingChannelIds({ agent: "hermes" })).toEqual([ "telegram", @@ -37,6 +38,7 @@ describe("built-in messaging channel metadata", () => { "wechat", "slack", "whatsapp", + "teams", ]); }); @@ -47,6 +49,7 @@ describe("built-in messaging channel metadata", () => { wechat: ["WECHAT_BOT_TOKEN"], slack: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], whatsapp: [], + teams: ["MSTEAMS_APP_PASSWORD"], }); expect(getMessagingChannelForCredentialEnvKey("SLACK_APP_TOKEN")).toBe("slack"); expect(getMessagingChannelForCredentialEnvKey("WHATSAPP_ALLOWED_IDS")).toBeNull(); @@ -55,6 +58,7 @@ describe("built-in messaging channel metadata", () => { discord: ["-discord-bridge"], wechat: ["-wechat-bridge"], slack: ["-slack-bridge", "-slack-app"], + teams: ["-teams-bridge"], }); expect(listMessagingProviderNamesForChannel("demo", "slack")).toEqual([ "demo-slack-bridge", @@ -78,10 +82,19 @@ describe("built-in messaging channel metadata", () => { "SLACK_ALLOWED_USERS", "SLACK_ALLOWED_CHANNELS", "WHATSAPP_ALLOWED_IDS", + "MSTEAMS_APP_ID", + "MSTEAMS_TENANT_ID", + "TEAMS_ALLOWED_USERS", + "MSTEAMS_PORT", + "TEAMS_REQUIRE_MENTION", ]); expect(getMessagingConfigEnvAliases()).toEqual({ DISCORD_SERVER_ID: ["DISCORD_SERVER_IDS"], DISCORD_USER_ID: ["DISCORD_ALLOWED_IDS"], + MSTEAMS_APP_ID: ["TEAMS_CLIENT_ID"], + MSTEAMS_TENANT_ID: ["TEAMS_TENANT_ID"], + TEAMS_ALLOWED_USERS: ["MSTEAMS_ALLOWED_USERS"], + MSTEAMS_PORT: ["TEAMS_PORT"], }); }); @@ -92,6 +105,7 @@ describe("built-in messaging channel metadata", () => { wechat: ["wechat_bridge"], slack: ["slack"], whatsapp: ["whatsapp"], + teams: ["teams"], }); expect(getMessagingPolicyKeysByChannel({ agent: "hermes" })).toMatchObject({ telegram: ["telegram"], @@ -99,6 +113,7 @@ describe("built-in messaging channel metadata", () => { wechat: ["wechat_bridge"], slack: ["slack"], whatsapp: ["whatsapp"], + teams: ["teams"], }); expect(listRequiredCreateTimeMessagingPolicyPresetNames()).toEqual(["slack"]); expect(getMessagingPolicyPresetValidationWarnings().discord).toContain( @@ -110,6 +125,7 @@ describe("built-in messaging channel metadata", () => { "openclaw-weixin", "slack", "whatsapp", + "msteams", ]); expect( Object.fromEntries( @@ -121,6 +137,7 @@ describe("built-in messaging channel metadata", () => { wechat: ["openclaw-weixin"], slack: ["slack"], whatsapp: ["whatsapp"], + teams: ["msteams"], }); expect( Object.fromEntries( @@ -134,8 +151,24 @@ describe("built-in messaging channel metadata", () => { wechat: "npm:@tencent-weixin/openclaw-weixin@2.4.3", slack: "npm:@openclaw/slack@{{openclaw.version}}", whatsapp: "npm:@openclaw/whatsapp@{{openclaw.version}}", + teams: "npm:@openclaw/msteams@{{openclaw.version}}", }); - expect(listMessagingPackageInstallSpecs({ agent: "hermes" })).toEqual([]); + expect(listMessagingPackageInstallSpecs({ agent: "hermes" })).toEqual([ + { + channelId: "teams", + packageId: "hermesTeamsAppsPackage", + agents: ["hermes"], + manager: "hermes-uv-pip", + spec: "microsoft-teams-apps==2.0.13.4", + }, + { + channelId: "teams", + packageId: "hermesAiohttpPackage", + agents: ["hermes"], + manager: "hermes-uv-pip", + spec: "aiohttp==3.14.1", + }, + ]); }); it("merges duplicate policy preset metadata by preset name", () => { diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index dd16c109d2..000e4c94e7 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -23,7 +23,6 @@ export const slackManifest = { prompt: { label: "Slack Bot Token", help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", - placeholder: "xoxb-...", }, }, { @@ -37,7 +36,6 @@ export const slackManifest = { prompt: { label: "Slack App Token (Socket Mode)", help: "Slack API → Your Apps → Basic Information → App-Level Tokens (xapp-...).", - placeholder: "xapp-...", }, }, { diff --git a/src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.test.ts b/src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.test.ts new file mode 100644 index 0000000000..ead8984843 --- /dev/null +++ b/src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.test.ts @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { makePlan, planEntry } from "../../../../../../test/helpers/messaging-conflict-fixtures"; +import { + MESSAGING_HOOK_CONFLICT_CODE, + MessagingHookRegistry, + runMessagingHook, +} from "../../../hooks"; +import type { + ChannelHookSpec, + MessagingSerializableValue, + SandboxMessagingChannelPlan, +} from "../../../manifest"; +import { + createTeamsHostForwardPortConflictHookRegistration, + createTeamsHostForwardPortStatusHookRegistration, + TEAMS_HOST_FORWARD_PORT_CONFLICT_HOOK_HANDLER_ID, + TEAMS_HOST_FORWARD_PORT_STATUS_HOOK_HANDLER_ID, + TEAMS_HOST_FORWARD_PORT_STATUS_MESSAGE, +} from "./host-forward-port-conflict"; + +const HOOK = { + id: "teams-host-forward-port-conflict", + phase: "pre-enable", + handler: TEAMS_HOST_FORWARD_PORT_CONFLICT_HOOK_HANDLER_ID, + inputs: ["webhookPort"], + onFailure: "abort", +} as const satisfies ChannelHookSpec; +const STATUS_HOOK = { + id: "teams-host-forward-port-status", + phase: "status", + handler: TEAMS_HOST_FORWARD_PORT_STATUS_HOOK_HANDLER_ID, + outputs: [{ id: "hostForwardPortOverlaps", kind: "status" }], +} as const satisfies ChannelHookSpec; + +function teamsChannel(port: number, active = true, disabled = false): SandboxMessagingChannelPlan { + return { + channelId: "teams", + displayName: "Microsoft Teams", + authMode: "token-paste", + active, + selected: true, + configured: true, + disabled, + inputs: [ + { + channelId: "teams", + inputId: "webhookPort", + kind: "config", + required: false, + sourceEnv: "MSTEAMS_PORT", + statePath: "teamsConfig.webhookPort", + value: String(port), + }, + ], + hostForward: { + channelId: "teams", + port, + label: "Microsoft Teams webhook", + }, + hooks: [], + }; +} + +function teamsEntry(name: string, port: number, active = true, disabled = false) { + return planEntry( + name, + makePlan(name, { + channels: [teamsChannel(port, active, disabled)], + disabledChannels: disabled ? ["teams"] : [], + }), + ); +} + +describe("teams.hostForwardPortConflict hook", () => { + it("passes when no active sandbox uses the requested webhook port", async () => { + const registry = new MessagingHookRegistry([ + createTeamsHostForwardPortConflictHookRegistration({ + currentSandbox: "bob", + registryEntries: [teamsEntry("alice", 3977)], + }), + ]); + + await expect( + runMessagingHook(HOOK, registry, { + channelId: "teams", + inputs: { + webhookPort: "3978", + }, + }), + ).resolves.toEqual({ + hookId: "teams-host-forward-port-conflict", + handlerId: TEAMS_HOST_FORWARD_PORT_CONFLICT_HOOK_HANDLER_ID, + phase: "pre-enable", + outputs: {}, + }); + }); + + it("aborts when another active sandbox already uses the webhook port", async () => { + const registry = new MessagingHookRegistry([ + createTeamsHostForwardPortConflictHookRegistration({ + currentSandbox: "bob", + registryEntries: [teamsEntry("alice", 3978)], + }), + ]); + + await expect( + runMessagingHook(HOOK, registry, { + channelId: "teams", + inputs: { + webhookPort: "3978", + }, + }), + ).rejects.toThrow( + "Microsoft Teams webhook port 3978 is already forwarded for sandbox 'alice'; " + + "choose a different MSTEAMS_PORT or stop/remove the other sandbox before enabling Teams.", + ); + await expect( + runMessagingHook(HOOK, registry, { + channelId: "teams", + inputs: { + webhookPort: "3978", + }, + }), + ).rejects.toMatchObject({ + code: MESSAGING_HOOK_CONFLICT_CODE, + }); + }); + + it("accepts serialized applier inputs for registry-scoped checks", async () => { + const registry = new MessagingHookRegistry([ + createTeamsHostForwardPortConflictHookRegistration(), + ]); + const registryEntries = JSON.parse( + JSON.stringify([teamsEntry("alice", 3978)]), + ) as MessagingSerializableValue; + + await expect( + runMessagingHook(HOOK, registry, { + channelId: "teams", + inputs: { + currentSandbox: "bob", + webhookPort: "3978", + registryEntries, + }, + }), + ).rejects.toThrow("Microsoft Teams webhook port 3978 is already forwarded"); + }); + + it("ignores stopped or disabled Teams channels", async () => { + const registry = new MessagingHookRegistry([ + createTeamsHostForwardPortConflictHookRegistration({ + currentSandbox: "bob", + registryEntries: [teamsEntry("alice", 3978, false, true)], + }), + ]); + + await expect( + runMessagingHook(HOOK, registry, { + channelId: "teams", + inputs: { + webhookPort: "3978", + }, + }), + ).resolves.toMatchObject({ + hookId: "teams-host-forward-port-conflict", + outputs: {}, + }); + }); + + it("requires webhook port and registry context when no options are injected", async () => { + const registry = new MessagingHookRegistry([ + createTeamsHostForwardPortConflictHookRegistration(), + ]); + + await expect(runMessagingHook(HOOK, registry, { channelId: "teams" })).rejects.toThrow( + "Microsoft Teams host forward port conflict hook requires webhookPort and registryEntries.", + ); + }); + + it("reports active Teams host forward port overlaps for status", async () => { + const registry = new MessagingHookRegistry([ + createTeamsHostForwardPortStatusHookRegistration({ + registryEntries: [teamsEntry("alice", 3978), teamsEntry("bob", 3978)], + }), + ]); + + await expect( + runMessagingHook(STATUS_HOOK, registry, { + channelId: "teams", + }), + ).resolves.toMatchObject({ + outputs: { + hostForwardPortOverlaps: { + kind: "status", + value: { + type: "messaging-overlaps", + overlaps: [ + { + channel: "teams", + port: 3978, + sandboxes: ["alice", "bob"], + reason: "host-forward-port", + message: TEAMS_HOST_FORWARD_PORT_STATUS_MESSAGE, + }, + ], + }, + }, + }, + }); + }); + + it("emits no status output when active Teams sandboxes use different ports", async () => { + const registry = new MessagingHookRegistry([ + createTeamsHostForwardPortStatusHookRegistration({ + registryEntries: [teamsEntry("alice", 3978), teamsEntry("bob", 3977)], + }), + ]); + + await expect( + runMessagingHook(STATUS_HOOK, registry, { + channelId: "teams", + }), + ).resolves.toMatchObject({ + outputs: {}, + }); + }); +}); diff --git a/src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts b/src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts new file mode 100644 index 0000000000..ee2d113b6d --- /dev/null +++ b/src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getActiveMessagingHostForward } from "../../../host-forward"; +import { MessagingHookConflictError } from "../../../hooks/errors"; +import type { + MessagingHookContext, + MessagingHookHandler, + MessagingHookRegistration, +} from "../../../hooks/types"; +import type { MessagingSerializableValue } from "../../../manifest"; +import { parseSandboxMessagingPlan } from "../../../plan-validation"; + +export const TEAMS_HOST_FORWARD_PORT_CONFLICT_HOOK_HANDLER_ID = "teams.hostForwardPortConflict"; +export const TEAMS_HOST_FORWARD_PORT_STATUS_HOOK_HANDLER_ID = "teams.hostForwardPortStatus"; + +export const TEAMS_HOST_FORWARD_PORT_STATUS_MESSAGE = + "'{first}' and '{second}' both use Microsoft Teams webhook port {port}; no two active Teams sandboxes can share that local forward. Set a different MSTEAMS_PORT or stop/remove one sandbox."; + +export interface TeamsHostForwardPortConflictRegistryEntry { + readonly name: string; + readonly messaging?: { readonly plan?: unknown } | null; +} + +export interface TeamsHostForwardPortConflict { + readonly sandbox: string; + readonly port: number; + readonly label: string; +} + +export interface TeamsHostForwardPortOverlap { + readonly port: number; + readonly sandboxes: [string, string]; +} + +export interface TeamsHostForwardPortConflictHookOptions { + readonly currentSandbox?: string | null | (() => string | null); + readonly registryEntries?: + | readonly TeamsHostForwardPortConflictRegistryEntry[] + | (() => readonly TeamsHostForwardPortConflictRegistryEntry[]); + readonly findConflicts?: ( + currentSandbox: string | null, + port: number, + entries: readonly TeamsHostForwardPortConflictRegistryEntry[], + ) => readonly TeamsHostForwardPortConflict[]; + readonly formatConflict?: (conflict: TeamsHostForwardPortConflict) => string; +} + +export interface TeamsHostForwardPortStatusHookOptions { + readonly registryEntries?: + | readonly TeamsHostForwardPortConflictRegistryEntry[] + | (() => readonly TeamsHostForwardPortConflictRegistryEntry[]); + readonly detectOverlaps?: ( + entries: readonly TeamsHostForwardPortConflictRegistryEntry[], + ) => readonly TeamsHostForwardPortOverlap[]; +} + +export function createTeamsHostForwardPortConflictHook( + options: TeamsHostForwardPortConflictHookOptions = {}, +): MessagingHookHandler { + return (context) => { + if (context.channelId !== "teams") return {}; + + const currentSandbox = resolveCurrentSandbox(context, options); + const port = normalizePort(context.inputs?.webhookPort); + const entries = resolveRegistryEntries(context, options); + if (!port || !entries) { + throw new Error( + "Microsoft Teams host forward port conflict hook requires webhookPort and registryEntries.", + ); + } + + const findConflicts = options.findConflicts ?? findTeamsHostForwardPortConflicts; + const conflicts = findConflicts(currentSandbox, port, entries); + if (conflicts.length === 0) return {}; + + const formatConflict = options.formatConflict ?? formatTeamsHostForwardPortConflictMessage; + throw new MessagingHookConflictError(conflicts.map(formatConflict).join("\n")); + }; +} + +export function createTeamsHostForwardPortStatusHook( + options: TeamsHostForwardPortStatusHookOptions = {}, +): MessagingHookHandler { + return (context) => { + if (context.channelId !== "teams") return {}; + const entries = resolveRegistryEntries(context, options); + if (!entries || entries.length === 0) return {}; + + const detectOverlaps = options.detectOverlaps ?? detectAllTeamsHostForwardPortOverlaps; + const overlaps = detectOverlaps(entries); + if (overlaps.length === 0) return {}; + + return { + outputs: { + hostForwardPortOverlaps: { + kind: "status", + value: { + type: "messaging-overlaps", + overlaps: overlaps.map(({ port, sandboxes }) => ({ + channel: "teams", + port, + sandboxes, + reason: "host-forward-port", + message: TEAMS_HOST_FORWARD_PORT_STATUS_MESSAGE, + })), + }, + }, + }, + }; + }; +} + +export function createTeamsHostForwardPortConflictHookRegistration( + options: TeamsHostForwardPortConflictHookOptions = {}, +): MessagingHookRegistration { + return { + id: TEAMS_HOST_FORWARD_PORT_CONFLICT_HOOK_HANDLER_ID, + handler: createTeamsHostForwardPortConflictHook(options), + }; +} + +export function createTeamsHostForwardPortStatusHookRegistration( + options: TeamsHostForwardPortStatusHookOptions = {}, +): MessagingHookRegistration { + return { + id: TEAMS_HOST_FORWARD_PORT_STATUS_HOOK_HANDLER_ID, + handler: createTeamsHostForwardPortStatusHook(options), + }; +} + +export function findTeamsHostForwardPortConflicts( + currentSandbox: string | null, + port: number, + entries: readonly TeamsHostForwardPortConflictRegistryEntry[], +): TeamsHostForwardPortConflict[] { + return entries.flatMap((entry) => { + if (entry.name === currentSandbox) return []; + const plan = parseSandboxMessagingPlan(entry.messaging?.plan); + const forward = getActiveMessagingHostForward(plan); + if (!forward || forward.port !== port) return []; + return [ + { + sandbox: entry.name, + port: forward.port, + label: forward.label, + }, + ]; + }); +} + +export function detectAllTeamsHostForwardPortOverlaps( + entries: readonly TeamsHostForwardPortConflictRegistryEntry[], +): TeamsHostForwardPortOverlap[] { + const byPort = new Map(); + for (const entry of entries) { + const plan = parseSandboxMessagingPlan(entry.messaging?.plan); + const forward = getActiveMessagingHostForward(plan); + if (!forward || forward.channelId !== "teams") continue; + const names = byPort.get(forward.port) ?? []; + names.push(entry.name); + byPort.set(forward.port, names); + } + + const overlaps: TeamsHostForwardPortOverlap[] = []; + for (const [port, names] of byPort) { + if (names.length < 2) continue; + for (let i = 0; i < names.length; i += 1) { + for (let j = i + 1; j < names.length; j += 1) { + overlaps.push({ port, sandboxes: [names[i], names[j]] }); + } + } + } + return overlaps; +} + +export function formatTeamsHostForwardPortConflictMessage({ + sandbox, + port, +}: TeamsHostForwardPortConflict): string { + return ( + `Microsoft Teams webhook port ${port} is already forwarded for sandbox '${sandbox}'; ` + + "choose a different MSTEAMS_PORT or stop/remove the other sandbox before enabling Teams." + ); +} + +function resolveCurrentSandbox( + context: MessagingHookContext, + options: TeamsHostForwardPortConflictHookOptions, +): string | null { + return ( + normalizeNullableString(context.inputs?.currentSandbox) ?? + resolveNullableOption(options.currentSandbox) + ); +} + +function resolveRegistryEntries( + context: MessagingHookContext, + options: TeamsHostForwardPortConflictHookOptions, +): readonly TeamsHostForwardPortConflictRegistryEntry[] | null { + const inputEntries = parseRegistryEntries(context.inputs?.registryEntries); + if (inputEntries) return inputEntries; + const entries = + typeof options.registryEntries === "function" + ? options.registryEntries() + : options.registryEntries; + return entries ? [...entries] : null; +} + +function parseRegistryEntries( + value: MessagingSerializableValue | undefined, +): readonly TeamsHostForwardPortConflictRegistryEntry[] | null { + if (!Array.isArray(value)) return null; + return value.flatMap((entry) => { + if (!isObject(entry) || typeof entry.name !== "string" || entry.name.length === 0) { + return []; + } + const messaging = isObject(entry.messaging) + ? { plan: (entry.messaging as Record).plan } + : null; + return [ + { + name: entry.name, + messaging, + }, + ]; + }); +} + +function normalizePort(value: unknown): number | null { + const port = typeof value === "number" ? value : Number(String(value ?? "").trim()); + return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null; +} + +function normalizeNullableString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function resolveNullableOption( + value: string | null | (() => string | null) | undefined, +): string | null { + return typeof value === "function" ? value() : (value ?? null); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/messaging/channels/teams/hooks/index.ts b/src/lib/messaging/channels/teams/hooks/index.ts new file mode 100644 index 0000000000..758034afb6 --- /dev/null +++ b/src/lib/messaging/channels/teams/hooks/index.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookRegistration } from "../../../hooks/types"; +import { + createTeamsHostForwardPortConflictHookRegistration, + createTeamsHostForwardPortStatusHookRegistration, + type TeamsHostForwardPortConflictHookOptions, + type TeamsHostForwardPortStatusHookOptions, +} from "./host-forward-port-conflict"; + +export * from "./host-forward-port-conflict"; + +export interface TeamsHookOptions { + readonly hostForwardPortConflict?: TeamsHostForwardPortConflictHookOptions; + readonly hostForwardPortStatus?: TeamsHostForwardPortStatusHookOptions; +} + +export function createTeamsHookRegistrations( + options: TeamsHookOptions = {}, +): readonly MessagingHookRegistration[] { + return [ + createTeamsHostForwardPortConflictHookRegistration( + withoutUndefinedValues(options.hostForwardPortConflict), + ), + createTeamsHostForwardPortStatusHookRegistration( + withoutUndefinedValues(options.hostForwardPortStatus), + ), + ] as const; +} + +function withoutUndefinedValues(options: T | undefined): T { + return Object.fromEntries( + Object.entries(options ?? {}).filter(([, value]) => value !== undefined), + ) as T; +} diff --git a/src/lib/messaging/channels/teams/manifest.ts b/src/lib/messaging/channels/teams/manifest.ts new file mode 100644 index 0000000000..22ee3e378f --- /dev/null +++ b/src/lib/messaging/channels/teams/manifest.ts @@ -0,0 +1,297 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest } from "../../manifest"; + +export const teamsManifest = { + schemaVersion: 1, + id: "teams", + displayName: "Microsoft Teams", + description: "Microsoft Teams bot messaging (experimental)", + enrollmentNotes: [ + "Microsoft Teams requires a public HTTPS webhook endpoint at /api/messages; expose the configured Teams webhook port before installing the Teams app.", + "Use Azure AD object IDs in TEAMS_ALLOWED_USERS so only authorized users can interact with the bot.", + ], + supportedAgents: ["openclaw", "hermes"], + auth: { + mode: "token-paste", + }, + inputs: [ + { + id: "appId", + kind: "config", + required: true, + envKey: "MSTEAMS_APP_ID", + envAliases: ["TEAMS_CLIENT_ID"], + statePath: "teamsConfig.appId", + prompt: { + label: "Microsoft Teams Client ID", + help: "Run `teams app create --endpoint https:///api/messages`, then copy CLIENT_ID.", + }, + }, + { + id: "clientSecret", + kind: "secret", + required: true, + envKey: "MSTEAMS_APP_PASSWORD", + envAliases: ["TEAMS_CLIENT_SECRET"], + prompt: { + label: "Microsoft Teams Client Secret", + help: "Use the CLIENT_SECRET printed by `teams app create`. It is shown once; rotate it in Entra ID if it was lost.", + }, + }, + { + id: "tenantId", + kind: "config", + required: true, + envKey: "MSTEAMS_TENANT_ID", + envAliases: ["TEAMS_TENANT_ID"], + statePath: "teamsConfig.tenantId", + prompt: { + label: "Microsoft Teams Tenant ID", + help: "Use the TENANT_ID printed by `teams app create` or shown by `teams status --verbose`.", + }, + }, + { + id: "allowedUsers", + kind: "config", + required: false, + envKey: "TEAMS_ALLOWED_USERS", + envAliases: ["MSTEAMS_ALLOWED_USERS"], + statePath: "allowedIds.teams", + prompt: { + label: "Microsoft Teams AAD Object IDs (comma-separated allowlist)", + help: "Recommended: run `teams status --verbose` and enter the Azure AD object IDs allowed to use the bot.", + }, + }, + { + id: "webhookPort", + kind: "config", + required: false, + envKey: "MSTEAMS_PORT", + envAliases: ["TEAMS_PORT"], + statePath: "teamsConfig.webhookPort", + defaultValue: "3978", + prompt: { + label: "Microsoft Teams webhook port", + help: "Local bot webhook port to expose publicly. Defaults to 3978 and serves /api/messages.", + }, + }, + { + id: "requireMention", + kind: "config", + required: false, + envKey: "TEAMS_REQUIRE_MENTION", + statePath: "teamsConfig.requireMention", + validValues: ["0", "1"], + defaultValue: "1", + prompt: { + label: "Microsoft Teams mention mode", + help: "Controls OpenClaw group and channel behavior only. Direct messages are unaffected.", + }, + }, + ], + credentials: [ + { + id: "teamsClientSecret", + sourceInput: "clientSecret", + providerName: "{sandboxName}-teams-bridge", + providerEnvKey: "MSTEAMS_APP_PASSWORD", + placeholder: "openshell:resolve:env:MSTEAMS_APP_PASSWORD", + primary: true, + }, + ], + policyPresets: [{ name: "teams", policyKeys: ["teams"] }], + hostForward: { + port: "{{teamsConfig.webhookPort}}", + label: "Microsoft Teams webhook", + }, + render: [ + { + id: "teams-openclaw-channel", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.msteams", + value: { + enabled: true, + appId: "{{teamsConfig.appId}}", + appPassword: "{{credential.teamsClientSecret.placeholder}}", + tenantId: "{{teamsConfig.tenantId}}", + webhook: { + port: "{{teamsConfig.webhookPort}}", + path: "/api/messages", + }, + healthMonitor: { + enabled: false, + }, + dmPolicy: "{{allowedIds.teams.dmPolicy}}", + allowFrom: "{{allowedIds.teams.values}}", + groupPolicy: "open", + requireMention: "{{teamsConfig.requireMention}}", + }, + }, + }, + { + id: "teams-openclaw-plugin", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "plugins.entries.msteams", + value: { + enabled: true, + }, + }, + }, + { + id: "teams-hermes-env", + kind: "env-lines", + agent: "hermes", + target: "~/.hermes/.env", + lines: [ + "TEAMS_CLIENT_ID={{teamsConfig.appId}}", + "TEAMS_CLIENT_SECRET={{credential.teamsClientSecret.placeholder}}", + "TEAMS_TENANT_ID={{teamsConfig.tenantId}}", + "TEAMS_ALLOWED_USERS={{allowedIds.teams.csv}}", + "TEAMS_PORT={{teamsConfig.webhookPort}}", + ], + }, + { + id: "teams-hermes-platform", + kind: "json-fragment", + agent: "hermes", + target: "~/.hermes/config.yaml", + fragment: { + path: "platforms.teams", + value: { + enabled: true, + }, + }, + }, + ], + runtime: { + openclaw: { + channelName: "msteams", + visibility: { + configKeys: ["msteams"], + logPatterns: ["msteams", "teams"], + }, + }, + }, + agentPackages: [ + { + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@openclaw/msteams@{{openclaw.version}}", + pin: true, + required: true, + }, + { + id: "hermesTeamsAppsPackage", + agent: "hermes", + manager: "hermes-uv-pip", + spec: "microsoft-teams-apps==2.0.13.4", + required: true, + }, + { + id: "hermesAiohttpPackage", + agent: "hermes", + manager: "hermes-uv-pip", + spec: "aiohttp==3.14.1", + required: true, + }, + ], + state: { + persist: { + teamsConfig: ["appId", "tenantId", "webhookPort", "requireMention"], + allowedIds: ["allowedUsers"], + }, + rebuildHydration: [ + { + statePath: "teamsConfig.appId", + env: "MSTEAMS_APP_ID", + }, + { + statePath: "teamsConfig.tenantId", + env: "MSTEAMS_TENANT_ID", + }, + { + statePath: "allowedIds.teams", + env: "TEAMS_ALLOWED_USERS", + }, + { + statePath: "teamsConfig.webhookPort", + env: "MSTEAMS_PORT", + }, + { + statePath: "teamsConfig.requireMention", + env: "TEAMS_REQUIRE_MENTION", + }, + ], + }, + hooks: [ + { + id: "teams-host-forward-port-conflict", + phase: "pre-enable", + handler: "teams.hostForwardPortConflict", + inputs: ["webhookPort"], + onFailure: "abort", + }, + { + id: "teams-host-forward-port-status", + phase: "status", + handler: "teams.hostForwardPortStatus", + outputs: [ + { + id: "hostForwardPortOverlaps", + kind: "status", + }, + ], + }, + { + id: "teams-token-paste", + phase: "enroll", + handler: "common.tokenPaste", + outputs: [ + { + id: "clientSecret", + kind: "secret", + required: true, + }, + ], + onFailure: "skip-channel", + }, + { + id: "teams-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + outputs: [ + { + id: "appId", + kind: "config", + required: true, + }, + { + id: "tenantId", + kind: "config", + required: true, + }, + { + id: "allowedUsers", + kind: "config", + }, + { + id: "webhookPort", + kind: "config", + }, + { + id: "requireMention", + kind: "config", + }, + ], + }, + ], +} as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/teams/template-resolver.ts b/src/lib/messaging/channels/teams/template-resolver.ts new file mode 100644 index 0000000000..a08f14838e --- /dev/null +++ b/src/lib/messaging/channels/teams/template-resolver.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { RenderTemplateContext } from "../../compiler/engines/template"; +import { + allowedIds, + type BuiltInRenderTemplateResolver, + nonEmptyArray, + nonEmptyCsv, + nonEmptyString, + parseBoolean, + resolvedRenderTemplateReference, + stateValue, +} from "../template-resolver-utils"; + +const DEFAULT_TEAMS_WEBHOOK_PORT = 3978; + +export const resolveTeamsTemplateReference: BuiltInRenderTemplateResolver = ( + reference, + context, +) => { + switch (reference) { + case "teamsConfig.appId": + return resolvedRenderTemplateReference( + nonEmptyString(stateValue(context, "teamsConfig.appId")), + ); + case "teamsConfig.tenantId": + return resolvedRenderTemplateReference( + nonEmptyString(stateValue(context, "teamsConfig.tenantId")), + ); + case "teamsConfig.webhookPort": + return resolvedRenderTemplateReference(teamsWebhookPort(context)); + case "teamsConfig.requireMention": + return resolvedRenderTemplateReference( + parseBoolean(stateValue(context, "teamsConfig.requireMention")), + ); + default: + break; + } + + const allowedIdsReference = reference.match(/^allowedIds[.]teams[.](values|csv|dmPolicy)$/); + if (!allowedIdsReference?.[1]) return undefined; + const ids = allowedIds(context, "teams"); + switch (allowedIdsReference[1]) { + case "values": + return resolvedRenderTemplateReference(nonEmptyArray(ids)); + case "csv": + return resolvedRenderTemplateReference(nonEmptyCsv(ids)); + case "dmPolicy": + return resolvedRenderTemplateReference(ids.length > 0 ? "allowlist" : undefined); + default: + return undefined; + } +}; + +function teamsWebhookPort(context: RenderTemplateContext): number { + const raw = nonEmptyString(stateValue(context, "teamsConfig.webhookPort")); + if (!raw) return DEFAULT_TEAMS_WEBHOOK_PORT; + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error( + "Microsoft Teams webhook port must be an integer TCP port between 1 and 65535.", + ); + } + return port; +} diff --git a/src/lib/messaging/channels/template-resolver.ts b/src/lib/messaging/channels/template-resolver.ts index 6e93187fe0..11c1190b3b 100644 --- a/src/lib/messaging/channels/template-resolver.ts +++ b/src/lib/messaging/channels/template-resolver.ts @@ -4,6 +4,7 @@ import { resolveDiscordTemplateReference } from "./discord/template-resolver"; import { resolveSlackTemplateReference } from "./slack/template-resolver"; import { resolveTelegramTemplateReference } from "./telegram/template-resolver"; +import { resolveTeamsTemplateReference } from "./teams/template-resolver"; import type { BuiltInRenderTemplateResolver } from "./template-resolver-utils"; import { resolveWechatTemplateReference } from "./wechat/template-resolver"; import { resolveWhatsappTemplateReference } from "./whatsapp/template-resolver"; @@ -14,6 +15,7 @@ const BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS: readonly BuiltInRenderTemplateResol resolveWechatTemplateReference, resolveSlackTemplateReference, resolveWhatsappTemplateReference, + resolveTeamsTemplateReference, ]; export function createBuiltInRenderTemplateResolver(): BuiltInRenderTemplateResolver { diff --git a/src/lib/messaging/compiler/engines/host-forward-engine.ts b/src/lib/messaging/compiler/engines/host-forward-engine.ts new file mode 100644 index 0000000000..91bc8c796f --- /dev/null +++ b/src/lib/messaging/compiler/engines/host-forward-engine.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelManifest, + SandboxMessagingHostForwardPlan, + SandboxMessagingInputReference, +} from "../../manifest"; +import { + isTruthyRenderTemplate, + type RenderTemplateReferenceResolver, + resolveRenderTemplatesInValue, +} from "./template"; + +export function planHostForward( + manifest: ChannelManifest, + inputs: readonly SandboxMessagingInputReference[], + active: boolean, + referenceResolver?: RenderTemplateReferenceResolver, +): SandboxMessagingHostForwardPlan | undefined { + if (!active || !manifest.hostForward) return undefined; + + const context = { inputs, env: process.env, referenceResolver }; + if (!isTruthyRenderTemplate(manifest.hostForward.when, context)) return undefined; + + const portValue = resolveRenderTemplatesInValue(manifest.hostForward.port, context); + const port = normalizeForwardPort(manifest.id, portValue); + return { + channelId: manifest.id, + port, + label: manifest.hostForward.label, + }; +} + +function normalizeForwardPort(channelId: string, value: unknown): number { + const port = typeof value === "number" ? value : Number(String(value ?? "").trim()); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error( + `Channel manifest '${channelId}' declares invalid host forward port '${String(value)}'.`, + ); + } + return port; +} diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 08b20dd93c..a57cfdc550 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -15,14 +15,21 @@ import { } from "../manifest"; import { ManifestCompiler } from "./manifest-compiler"; -const ALL_CHANNELS = ["telegram", "discord", "wechat", "slack", "whatsapp"] as const; +const ALL_CHANNELS = ["telegram", "discord", "wechat", "slack", "whatsapp", "teams"] as const; const TEST_CREDENTIALS: Readonly> = { TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", DISCORD_BOT_TOKEN: "test-discord-token", WECHAT_BOT_TOKEN: "test-wechat-token", SLACK_BOT_TOKEN: "xoxb-test-slack-token", SLACK_APP_TOKEN: "xapp-test-slack-token", + MSTEAMS_APP_PASSWORD: "test-teams-client-secret", }; +const TEST_TEAMS_ENV = { + MSTEAMS_APP_ID: "test-teams-app-id", + MSTEAMS_TENANT_ID: "test-teams-tenant-id", + TEAMS_ALLOWED_USERS: "00000000-0000-0000-0000-000000000001", + MSTEAMS_PORT: "3978", +} as const; const TEST_WECHAT_LOGIN = { token: "test-wechat-token", accountId: "test-wechat-account", @@ -122,20 +129,23 @@ async function withEnv( describe("ManifestCompiler", () => { it("compiles built-in manifests into a deterministic OpenClaw plan", async () => { - const plan = await compiler().compile({ - sandboxName: "demo", - agent: "openclaw", - workflow: "onboard", - isInteractive: true, - configuredChannels: ["slack", "telegram", "wechat", "discord", "whatsapp"], - credentialAvailability: { - TELEGRAM_BOT_TOKEN: true, - DISCORD_BOT_TOKEN: true, - WECHAT_BOT_TOKEN: true, - SLACK_BOT_TOKEN: true, - SLACK_APP_TOKEN: true, - }, - }); + const plan = await withEnv(TEST_TEAMS_ENV, () => + compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: true, + configuredChannels: ["slack", "telegram", "wechat", "discord", "whatsapp", "teams"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + DISCORD_BOT_TOKEN: true, + WECHAT_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + MSTEAMS_APP_PASSWORD: true, + }, + }), + ); expect(plan.channels.map((channel) => channel.channelId)).toEqual(ALL_CHANNELS); expect(plan.channels.every((channel) => channel.active)).toBe(true); @@ -145,6 +155,7 @@ describe("ManifestCompiler", () => { "demo-wechat-bridge", "demo-slack-bridge", "demo-slack-app", + "demo-teams-bridge", ]); expect(plan.credentialBindings.map((binding) => binding.placeholder)).toEqual([ "openshell:resolve:env:TELEGRAM_BOT_TOKEN", @@ -152,6 +163,7 @@ describe("ManifestCompiler", () => { "openshell:resolve:env:WECHAT_BOT_TOKEN", "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + "openshell:resolve:env:MSTEAMS_APP_PASSWORD", ]); expect(plan.networkPolicy.entries).toEqual([ { @@ -184,6 +196,12 @@ describe("ManifestCompiler", () => { policyKeys: ["whatsapp"], source: "manifest", }, + { + channelId: "teams", + presetName: "teams", + policyKeys: ["teams"], + source: "manifest", + }, ]); expect(plan.agentRender.map((render) => `${render.channelId}:${render.renderId}`)).toEqual([ "telegram:telegram-openclaw-channel", @@ -196,6 +214,8 @@ describe("ManifestCompiler", () => { "slack:slack-openclaw-plugin", "whatsapp:whatsapp-openclaw-channel", "whatsapp:whatsapp-openclaw-plugin", + "teams:teams-openclaw-channel", + "teams:teams-openclaw-plugin", ]); expect(plan.agentRender.every((render) => render.handler === "common.staticOutputs")).toBe( true, @@ -250,6 +270,12 @@ describe("ManifestCompiler", () => { outputId: "openclawPluginPackage", required: true, }, + { + channelId: "teams", + kind: "package-install", + outputId: "openclawPluginPackage", + required: true, + }, ]); expect(plan.buildSteps).toEqual( expect.arrayContaining([ @@ -270,6 +296,15 @@ describe("ManifestCompiler", () => { pin: true, }, }), + expect.objectContaining({ + channelId: "teams", + kind: "package-install", + value: { + manager: "openclaw-plugin", + spec: "npm:@openclaw/msteams@{{openclaw.version}}", + pin: true, + }, + }), ]), ); expect(plan.buildSteps.every((step) => step.value !== undefined)).toBe(true); @@ -279,6 +314,12 @@ describe("ManifestCompiler", () => { statePath: "wechatConfig.accountId", env: "WECHAT_ACCOUNT_ID", }); + expect(plan.stateUpdates).toContainEqual({ + channelId: "teams", + kind: "rebuild-hydration", + statePath: "teamsConfig.appId", + env: "MSTEAMS_APP_ID", + }); expect(plan.healthChecks).toEqual([ { channelId: "telegram", @@ -316,6 +357,7 @@ describe("ManifestCompiler", () => { const plan = await withEnv( { WECHAT_ACCOUNT_ID: "test-wechat-account", + ...TEST_TEAMS_ENV, }, () => compiler().compile({ @@ -330,6 +372,7 @@ describe("ManifestCompiler", () => { WECHAT_BOT_TOKEN: true, SLACK_BOT_TOKEN: true, SLACK_APP_TOKEN: true, + MSTEAMS_APP_PASSWORD: true, }, }), ); @@ -340,6 +383,12 @@ describe("ManifestCompiler", () => { policyKeys: ["wechat_bridge"], source: "manifest", }); + expect(plan.networkPolicy.entries.find((entry) => entry.channelId === "teams")).toEqual({ + channelId: "teams", + presetName: "teams", + policyKeys: ["teams"], + source: "manifest", + }); expect(plan.agentRender.map((render) => `${render.channelId}:${render.target}`)).toEqual([ "telegram:~/.hermes/.env", "telegram:~/.hermes/config.yaml", @@ -353,11 +402,37 @@ describe("ManifestCompiler", () => { "slack:~/.hermes/config.yaml", "whatsapp:~/.hermes/.env", "whatsapp:~/.hermes/config.yaml", + "teams:~/.hermes/.env", + "teams:~/.hermes/config.yaml", ]); expect(JSON.stringify(plan.agentRender)).toContain( "WEIXIN_TOKEN=openshell:resolve:env:WECHAT_BOT_TOKEN", ); - expect(plan.buildSteps).toEqual([]); + expect(JSON.stringify(plan.agentRender)).toContain( + "TEAMS_CLIENT_SECRET=openshell:resolve:env:MSTEAMS_APP_PASSWORD", + ); + expect(plan.buildSteps).toEqual([ + { + channelId: "teams", + kind: "package-install", + outputId: "hermesTeamsAppsPackage", + required: true, + value: { + manager: "hermes-uv-pip", + spec: "microsoft-teams-apps==2.0.13.4", + }, + }, + { + channelId: "teams", + kind: "package-install", + outputId: "hermesAiohttpPackage", + required: true, + value: { + manager: "hermes-uv-pip", + spec: "aiohttp==3.14.1", + }, + }, + ]); expect( plan.channels .find((channel) => channel.channelId === "wechat") @@ -397,6 +472,166 @@ describe("ManifestCompiler", () => { } }); + it("rejects unsafe Microsoft Teams Hermes env render values", async () => { + const cases: Array = [ + ["MSTEAMS_APP_ID", "teams-app\nEVIL=1"], + ["MSTEAMS_TENANT_ID", "teams-tenant\nEVIL=1"], + ["TEAMS_ALLOWED_USERS", "user-one\nEVIL=1"], + ]; + + for (const [envKey, value] of cases) { + await expect( + withEnv( + { + ...TEST_TEAMS_ENV, + [envKey]: value, + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "hermes", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["teams"], + credentialAvailability: { + MSTEAMS_APP_PASSWORD: true, + }, + }), + ), + ).rejects.toThrow(/line breaks/); + } + }); + + it("applies Microsoft Teams manifest defaults when optional env keys are unset", async () => { + const plan = await withEnv( + { + MSTEAMS_APP_ID: "test-teams-app-id", + MSTEAMS_TENANT_ID: "test-teams-tenant-id", + TEAMS_ALLOWED_USERS: "00000000-0000-0000-0000-000000000001", + MSTEAMS_PORT: undefined, + TEAMS_PORT: undefined, + TEAMS_REQUIRE_MENTION: undefined, + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["teams"], + credentialAvailability: { + MSTEAMS_APP_PASSWORD: true, + }, + }), + ); + + const teams = plan.channels.find((channel) => channel.channelId === "teams"); + expect(teams?.inputs).toContainEqual( + expect.objectContaining({ + inputId: "webhookPort", + kind: "config", + value: "3978", + }), + ); + expect(teams?.hostForward).toEqual({ + channelId: "teams", + port: 3978, + label: "Microsoft Teams webhook", + }); + expect(teams?.inputs).toContainEqual( + expect.objectContaining({ + inputId: "requireMention", + kind: "config", + value: "1", + }), + ); + expect(JSON.stringify(plan.agentRender)).toContain('"port":3978'); + expect(JSON.stringify(plan.agentRender)).toContain('"groupPolicy":"open"'); + expect(JSON.stringify(plan.agentRender)).not.toContain("groupAllowFrom"); + expect(JSON.stringify(plan.agentRender)).toContain('"requireMention":true'); + }); + + it("keeps Microsoft Teams active when no explicit user allowlist is provided", async () => { + const plan = await withEnv( + { + MSTEAMS_APP_ID: "test-teams-app-id", + MSTEAMS_TENANT_ID: "test-teams-tenant-id", + TEAMS_ALLOWED_USERS: undefined, + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["teams"], + credentialAvailability: { + MSTEAMS_APP_PASSWORD: true, + }, + }), + ); + + expect(plan.channels.find((channel) => channel.channelId === "teams")).toMatchObject({ + active: true, + configured: true, + disabled: false, + }); + expect(JSON.stringify(plan.agentRender)).toContain("channels.msteams"); + expect(JSON.stringify(plan.agentRender)).toContain('"groupPolicy":"open"'); + expect(JSON.stringify(plan.agentRender)).not.toContain("dmPolicy"); + expect(JSON.stringify(plan.agentRender)).not.toContain("allowFrom"); + }); + + it("uses the configured Microsoft Teams webhook port for host forwarding", async () => { + const plan = await withEnv( + { + ...TEST_TEAMS_ENV, + MSTEAMS_PORT: "3977", + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["teams"], + credentialAvailability: { + MSTEAMS_APP_PASSWORD: true, + }, + }), + ); + + const teams = plan.channels.find((channel) => channel.channelId === "teams"); + expect(teams?.hostForward).toEqual({ + channelId: "teams", + port: 3977, + label: "Microsoft Teams webhook", + }); + expect(JSON.stringify(plan.agentRender)).toContain('"port":3977'); + }); + + it("rejects invalid Microsoft Teams webhook ports", async () => { + await expect( + withEnv( + { + ...TEST_TEAMS_ENV, + MSTEAMS_PORT: "70000", + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["teams"], + credentialAvailability: { + MSTEAMS_APP_PASSWORD: true, + }, + }), + ), + ).rejects.toThrow(/Microsoft Teams webhook port/); + }); + it("rejects unsafe WeChat Hermes env render values", async () => { const cases: Array = [ ["WECHAT_ACCOUNT_ID", "wechat-account\nEVIL=1"], @@ -478,8 +713,9 @@ describe("ManifestCompiler", () => { }); expect(plan.disabledChannels).toEqual(["wechat"]); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["wechat"]); - expect(plan.agentRender.map((render) => render.channelId)).toEqual(["wechat", "wechat"]); - expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["wechat"]); + expect(plan.agentRender).toEqual([]); + expect(plan.buildSteps).toEqual([]); + expect(plan.healthChecks).toEqual([]); }); it("runs enrollment hooks before returning the final channel input plan", async () => { @@ -559,7 +795,7 @@ describe("ManifestCompiler", () => { expect(plan.runtimeSetup).toEqual({ nodePreloads: [], envAliases: [], secretScans: [] }); expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual(["telegram"]); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["telegram"]); - expect(plan.agentRender.map((render) => render.channelId)).toEqual(["telegram", "telegram"]); + expect(plan.agentRender).toEqual([]); expect(plan.buildSteps).toEqual([]); expect(plan.stateUpdates.map((entry) => entry.channelId)).toEqual([ "telegram", @@ -568,7 +804,7 @@ describe("ManifestCompiler", () => { "telegram", "telegram", ]); - expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["telegram"]); + expect(plan.healthChecks).toEqual([]); }); it("runs non-interactive enrollment hooks to validate and feed reachability checks", async () => { @@ -976,7 +1212,7 @@ describe("ManifestCompiler", () => { ] satisfies Array); }); - it("records disabled configured channels and leaves applier exclusion to disabledChannels", async () => { + it("records disabled configured channels without side-effect plans", async () => { const plan = await compiler().compile({ sandboxName: "demo", agent: "openclaw", @@ -996,11 +1232,7 @@ describe("ManifestCompiler", () => { expect(plan.disabledChannels).toEqual(["telegram"]); expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual(["telegram"]); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["telegram"]); - expect(plan.agentRender.map((render) => render.channelId)).toEqual([ - "telegram", - "telegram", - "telegram", - ]); + expect(plan.agentRender).toEqual([]); expect(plan.buildSteps).toEqual([]); expect(plan.stateUpdates.map((entry) => entry.channelId)).toEqual([ "telegram", @@ -1009,7 +1241,7 @@ describe("ManifestCompiler", () => { "telegram", "telegram", ]); - expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["telegram"]); + expect(plan.healthChecks).toEqual([]); expect(plan.channels[0]?.hooks.map((hook) => hook.id)).toEqual([ "telegram-token-paste", "telegram-allowlist-aliases", diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 30f7758c23..d44e8b63f4 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -30,6 +30,7 @@ import { planAgentRender } from "./engines/agent-render-engine"; import { planBuildSteps } from "./engines/build-step-engine"; import { planCredentialBindings } from "./engines/credential-binding-engine"; import { planHealthChecks } from "./engines/health-check-engine"; +import { planHostForward } from "./engines/host-forward-engine"; import { planNetworkPolicy } from "./engines/policy-resolver"; import { planRuntimeSetup } from "./engines/runtime-setup-engine"; import { planStateUpdates } from "./engines/state-update-engine"; @@ -63,9 +64,15 @@ export class ManifestCompiler { planCredentialBindings(manifest, context, inputRegistry.get(manifest.id) ?? []), ); const networkPolicy = planNetworkPolicy(manifests, context); + const channelRegistry = new Map( + channels.map((channel) => [channel.channelId, channel] as const), + ); + const activeManifests = manifests.filter( + (manifest) => channelRegistry.get(manifest.id)?.active === true, + ); const agentRender = ( await Promise.all( - manifests.map((manifest) => + activeManifests.map((manifest) => planAgentRender( manifest, context, @@ -76,12 +83,9 @@ export class ManifestCompiler { ), ) ).flat(); - const channelRegistry = new Map( - channels.map((channel) => [channel.channelId, channel] as const), - ); const buildSteps = ( await Promise.all( - manifests.map((manifest) => + activeManifests.map((manifest) => planBuildSteps( manifest, context.agent, @@ -94,7 +98,7 @@ export class ManifestCompiler { ).flat(); const runtimeSetup = planRuntimeSetup(manifests, context.agent, channels); const stateUpdates = manifests.flatMap((manifest) => planStateUpdates(manifest)); - const healthChecks = manifests.flatMap((manifest) => planHealthChecks(manifest)); + const healthChecks = activeManifests.flatMap((manifest) => planHealthChecks(manifest)); return { schemaVersion: 1, @@ -154,6 +158,12 @@ export class ManifestCompiler { }); const requiredInputsAvailable = hasRequiredInputsAvailable(manifest, resolvedInputs.inputs); const active = requestedActive && !resolvedInputs.skipped && requiredInputsAvailable; + const hostForward = planHostForward( + manifest, + resolvedInputs.inputs, + active, + this.renderTemplateResolver, + ); return { channelId: manifest.id, @@ -164,6 +174,7 @@ export class ManifestCompiler { configured: configured && !resolvedInputs.skipped, disabled: disabled || resolvedInputs.skipped || (requestedActive && !requiredInputsAvailable), inputs: resolvedInputs.inputs, + ...(hostForward ? { hostForward } : {}), hooks: requested ? manifest.hooks .filter((hook) => isHookForAgent(hook, context.agent)) diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 04440f3839..5a31d8013e 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -17,6 +17,7 @@ const TEST_CREDENTIALS: Readonly> = { WECHAT_BOT_TOKEN: "test-wechat-token", SLACK_BOT_TOKEN: "xoxb-test-slack-token", SLACK_APP_TOKEN: "xapp-test-slack-token", + MSTEAMS_APP_PASSWORD: "test-teams-client-secret", }; const TEST_WECHAT_LOGIN = { token: "test-wechat-token", @@ -666,6 +667,85 @@ describe("MessagingWorkflowPlanner", () => { ).toBe(true); }); + it("removes Teams host forwarding while the channel is disabled", async () => { + await withEnv( + { + MSTEAMS_APP_ID: "test-teams-app-id", + MSTEAMS_TENANT_ID: "test-teams-tenant-id", + TEAMS_ALLOWED_USERS: "00000000-0000-0000-0000-000000000001", + MSTEAMS_PORT: "3977", + }, + async () => { + const existingPlan = await planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["teams"], + credentialAvailability: { + MSTEAMS_APP_PASSWORD: true, + }, + }); + + expect( + existingPlan.channels.find((channel) => channel.channelId === "teams"), + ).toMatchObject({ + hostForward: { + channelId: "teams", + port: 3977, + label: "Microsoft Teams webhook", + }, + }); + + const stopped = await planner().buildChannelStopPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { + schemaVersion: 1, + plan: existingPlan, + }, + }, + channelId: "teams", + }); + + expect(stopped?.workflow).toBe("stop-channel"); + expect(stopped?.channels.find((channel) => channel.channelId === "teams")).toMatchObject({ + active: false, + disabled: true, + }); + expect( + stopped?.channels.find((channel) => channel.channelId === "teams")?.hostForward, + ).toBeUndefined(); + + const started = await planner().buildChannelStartPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { + schemaVersion: 1, + plan: stopped!, + }, + }, + channelId: "teams", + }); + + expect(started?.workflow).toBe("start-channel"); + expect(started?.channels.find((channel) => channel.channelId === "teams")).toMatchObject({ + active: true, + disabled: false, + hostForward: { + channelId: "teams", + port: 3977, + label: "Microsoft Teams webhook", + }, + }); + }, + ); + }); + it("removes a channel and its dependent plan entries from an existing sandbox entry plan", async () => { const existingPlan = await planner().buildPlan({ sandboxName: "demo", diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index ce901ad1d3..b4b409d532 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -13,6 +13,7 @@ import type { SandboxMessagingPlan, SandboxMessagingRuntimeSetupPlan, } from "../manifest"; +import { planHostForward } from "./engines/host-forward-engine"; import { planRuntimeSetup } from "./engines/runtime-setup-engine"; import type { RenderTemplateReferenceResolver } from "./engines/template"; import { ManifestCompiler } from "./manifest-compiler"; @@ -35,7 +36,7 @@ export class MessagingWorkflowPlanner { constructor( private readonly registry: ChannelManifestRegistry, hooks = new MessagingHookRegistry(), - renderTemplateResolver?: RenderTemplateReferenceResolver, + private readonly renderTemplateResolver?: RenderTemplateReferenceResolver, ) { this.compiler = new ManifestCompiler(registry, hooks, renderTemplateResolver); } @@ -84,9 +85,10 @@ export class MessagingWorkflowPlanner { ): Promise { const plan = await this.planForSandboxEntryMutation(context, "stop-channel"); return plan - ? refreshRuntimeSetup( + ? refreshDerivedPlanFields( setPlanChannelDisabled(plan, context.channelId, true, "stop-channel"), this.registry, + this.renderTemplateResolver, ) : null; } @@ -96,9 +98,10 @@ export class MessagingWorkflowPlanner { ): Promise { const plan = await this.planForSandboxEntryMutation(context, "start-channel"); return plan - ? refreshRuntimeSetup( + ? refreshDerivedPlanFields( setPlanChannelDisabled(plan, context.channelId, false, "start-channel"), this.registry, + this.renderTemplateResolver, ) : null; } @@ -115,13 +118,14 @@ export class MessagingWorkflowPlanner { ): Promise { const existingPlan = readSandboxEntryPlan(context); if (existingPlan) { - return refreshRuntimeSetup( + return refreshDerivedPlanFields( setPlanDisabledChannels( existingPlan, disabledChannelsFromSandboxEntry(context.sandboxEntry, existingPlan), "rebuild", ), this.registry, + this.renderTemplateResolver, ); } return null; @@ -427,17 +431,33 @@ function filterRuntimeSetup( }; } -function refreshRuntimeSetup( +function refreshDerivedPlanFields( plan: SandboxMessagingPlan, registry: ChannelManifestRegistry, + referenceResolver?: RenderTemplateReferenceResolver, ): SandboxMessagingPlan { const manifests = plan.channels.flatMap((channel) => { const manifest = registry.get(channel.channelId); return manifest ? [manifest] : []; }); + const manifestById = new Map(manifests.map((manifest) => [manifest.id, manifest])); + const channels = plan.channels.map((channel) => { + const { hostForward: _oldHostForward, ...channelWithoutHostForward } = channel; + const manifest = manifestById.get(channel.channelId); + const active = + channel.active && !channel.disabled && !plan.disabledChannels.includes(channel.channelId); + const hostForward = manifest + ? planHostForward(manifest, channel.inputs, active, referenceResolver) + : undefined; + return { + ...channelWithoutHostForward, + ...(hostForward ? { hostForward } : {}), + }; + }); return clonePlan({ ...plan, - runtimeSetup: planRuntimeSetup(manifests, plan.agent, plan.channels), + channels, + runtimeSetup: planRuntimeSetup(manifests, plan.agent, channels), }); } diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index 79497f108f..c075044aa7 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -15,6 +15,7 @@ describe("messaging channel diagnostics", () => { "wechat", "slack", "whatsapp", + "teams", ]); expect(specs.find((spec) => spec.channelId === "telegram")).toMatchObject({ policyPresets: ["telegram"], @@ -31,5 +32,9 @@ describe("messaging channel diagnostics", () => { hint: "run `{cli} {sandbox} channels status --channel {channel}` to probe inbound delivery", }), }); + expect(specs.find((spec) => spec.channelId === "teams")).toMatchObject({ + policyPresets: ["teams"], + preferredDefault: false, + }); }); }); diff --git a/src/lib/messaging/hooks/builtins.ts b/src/lib/messaging/hooks/builtins.ts index 0e8ad9e11b..f181b6eabd 100644 --- a/src/lib/messaging/hooks/builtins.ts +++ b/src/lib/messaging/hooks/builtins.ts @@ -4,6 +4,7 @@ import { createDiscordHookRegistrations, type DiscordHookOptions } from "../channels/discord/hooks"; import type { OpenClawBridgeHealthHookOptions } from "../channels/openclaw-bridge-health"; import { createSlackHookRegistrations, type SlackHookOptions } from "../channels/slack/hooks"; +import { createTeamsHookRegistrations, type TeamsHookOptions } from "../channels/teams/hooks"; import { createTelegramHookRegistrations, type TelegramHookOptions, @@ -18,6 +19,7 @@ export interface BuiltInMessagingHookOptions { readonly discord?: DiscordHookOptions; readonly openclawBridgeHealth?: OpenClawBridgeHealthHookOptions; readonly slack?: SlackHookOptions; + readonly teams?: TeamsHookOptions; readonly telegram?: TelegramHookOptions; readonly wechat?: WechatHookOptions; } @@ -33,6 +35,7 @@ export function createBuiltInMessagingHookRegistrations( ...createSlackHookRegistrations( withOpenClawBridgeHealthOptions(options.slack, options.openclawBridgeHealth), ), + ...createTeamsHookRegistrations(options.teams), ...createTelegramHookRegistrations( withOpenClawBridgeHealthOptions(options.telegram, options.openclawBridgeHealth), ), diff --git a/src/lib/messaging/hooks/common/config-prompt.ts b/src/lib/messaging/hooks/common/config-prompt.ts index c15923addc..d37167d8b3 100644 --- a/src/lib/messaging/hooks/common/config-prompt.ts +++ b/src/lib/messaging/hooks/common/config-prompt.ts @@ -24,7 +24,6 @@ export interface ConfigPromptField { readonly label: string; readonly defaultValue?: string; readonly help?: string; - readonly placeholder?: string; readonly emptyValueMessage?: string; readonly validValues?: readonly string[]; readonly format?: RegExp; @@ -128,7 +127,6 @@ export function resolveManifestConfigPromptField( label: input.prompt.label, defaultValue: input.defaultValue, help: input.prompt.help, - placeholder: input.prompt.placeholder, emptyValueMessage: input.prompt.emptyValueMessage, validValues: input.validValues, format: input.formatPattern ? new RegExp(input.formatPattern) : undefined, @@ -209,8 +207,6 @@ function formatConfigPromptQuestion(field: ConfigPromptField): string { const hints: string[] = []; if (field.validValues && field.validValues.length > 0) { hints.push(field.validValues.join("/")); - } else if (field.placeholder) { - hints.push(field.placeholder); } const defaultValue = readDefaultConfigValue(field); if (defaultValue) hints.push(`default: ${defaultValue}`); diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index 835599aecc..2c9d9cf163 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -42,6 +42,8 @@ describe("MessagingHookRegistry", () => { "slack.socketModeGatewayStatus", "slack.openclawBridgeHealth", "slack.validateCredentials", + "teams.hostForwardPortConflict", + "teams.hostForwardPortStatus", "telegram.allowlistAliases", "telegram.openclawBridgeHealth", "telegram.gatewayConflictStatus", diff --git a/src/lib/messaging/host-forward.ts b/src/lib/messaging/host-forward.ts new file mode 100644 index 0000000000..5e12b021e5 --- /dev/null +++ b/src/lib/messaging/host-forward.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingHostForwardPlan, SandboxMessagingPlan } from "./manifest"; + +export function getActiveMessagingHostForward( + plan: SandboxMessagingPlan | null | undefined, +): SandboxMessagingHostForwardPlan | null { + if (!plan) return null; + const disabled = new Set(plan.disabledChannels); + for (const channel of plan.channels) { + if (!channel.active || channel.disabled || disabled.has(channel.channelId)) continue; + if (channel.hostForward) return channel.hostForward; + } + return null; +} diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts index 1eae8e5ca1..b7085dd2a5 100644 --- a/src/lib/messaging/index.ts +++ b/src/lib/messaging/index.ts @@ -6,6 +6,7 @@ export * from "./channels"; export * from "./compiler"; export * from "./diagnostics"; export * from "./hooks"; +export * from "./host-forward"; export * from "./manifest"; export * from "./persistence"; export * from "./utils"; diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 9395e085a9..aa455658ee 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -42,6 +42,7 @@ export interface ChannelManifest { /** Policy presets needed when this channel is active. */ readonly policyPresets?: readonly ChannelPolicyPresetReference[]; readonly render: readonly ChannelRenderSpec[]; + readonly hostForward?: ChannelHostForwardSpec; readonly runtime?: ChannelRuntimeByAgentSpec; readonly agentPackages?: readonly ChannelAgentPackageSpec[]; readonly state: ChannelStateSpec; @@ -72,7 +73,6 @@ export interface ChannelAuthSpec { export interface ChannelInputPromptSpec { readonly label: string; readonly help?: string; - readonly placeholder?: string; readonly emptyValueMessage?: string; } @@ -144,6 +144,13 @@ export interface ChannelRenderFragmentSpec { readonly value: MessagingSerializableValue; } +/** Host-side OpenShell forward needed for inbound channel webhooks. */ +export interface ChannelHostForwardSpec { + readonly port: MessagingTemplateString; + readonly label: string; + readonly when?: MessagingTemplateString; +} + /** Agent-runtime metadata consumed by shared runtime setup and diagnostics. */ export interface ChannelRuntimeByAgentSpec extends Partial> { @@ -196,7 +203,7 @@ export interface ChannelRuntimeSecretScanSpec { readonly exitCode?: number; } -export type ChannelAgentPackageManager = "openclaw-plugin"; +export type ChannelAgentPackageManager = "openclaw-plugin" | "hermes-uv-pip"; /** Agent package/plugin install the sandbox image build should apply. */ export interface ChannelAgentPackageSpec { @@ -300,6 +307,7 @@ export interface SandboxMessagingChannelPlan { readonly configured: boolean; readonly disabled: boolean; readonly inputs: readonly SandboxMessagingInputReference[]; + readonly hostForward?: SandboxMessagingHostForwardPlan; readonly hooks: readonly SandboxMessagingHookReferencePlan[]; } @@ -315,6 +323,13 @@ export interface SandboxMessagingInputReference { readonly value?: MessagingSerializableValue; } +/** Resolved host-side OpenShell forward for an active messaging channel. */ +export interface SandboxMessagingHostForwardPlan { + readonly channelId: MessagingChannelId; + readonly port: number; + readonly label: string; +} + /** Plan entry describing an OpenShell provider/env binding to create or attach. */ export interface SandboxMessagingCredentialBindingPlan { readonly channelId: MessagingChannelId; diff --git a/src/lib/messaging/persistence.ts b/src/lib/messaging/persistence.ts index 9e5ca17128..92bb256e53 100644 --- a/src/lib/messaging/persistence.ts +++ b/src/lib/messaging/persistence.ts @@ -7,6 +7,7 @@ import { } from "./channels"; import { planCredentialBindings } from "./compiler/engines/credential-binding-engine"; import { planHealthChecks } from "./compiler/engines/health-check-engine"; +import { planHostForward } from "./compiler/engines/host-forward-engine"; import { planNetworkPolicy } from "./compiler/engines/policy-resolver"; import { planRuntimeSetup } from "./compiler/engines/runtime-setup-engine"; import { planStateUpdates } from "./compiler/engines/state-update-engine"; @@ -238,6 +239,9 @@ function normalizePersistedChannel( : normalizePersistedInputs(channel, manifest); const active = channel.active ?? (configured && !disabled && requiredInputsAvailable(manifest, inputs)); + const hostForward = manifest + ? planHostForward(manifest, inputs, active && !disabled, createBuiltInRenderTemplateResolver()) + : undefined; return { channelId: channel.channelId, @@ -248,6 +252,7 @@ function normalizePersistedChannel( configured, disabled, inputs, + ...(hostForward ? { hostForward } : {}), hooks: Array.isArray(channel.hooks) ? [...channel.hooks] : [], }; } @@ -398,19 +403,25 @@ function hydrateChannelFromManifest( channel: SandboxMessagingChannelPlan, manifest: ChannelManifest | undefined, ): SandboxMessagingChannelPlan { + const { hostForward: _oldHostForward, ...channelWithoutHostForward } = channel; const disabled = channel.disabled || plan.disabledChannels.includes(channel.channelId); const inputs = hasFullChannelShape(channel) ? normalizeFullInputs(channel.channelId, channel.inputs) : normalizePersistedInputs(channel, manifest); const configured = channel.configured; + const active = channel.active && !disabled; + const hostForward = manifest + ? planHostForward(manifest, inputs, active, createBuiltInRenderTemplateResolver()) + : undefined; return { - ...channel, + ...channelWithoutHostForward, displayName: channel.displayName ?? manifest?.displayName ?? channel.channelId, authMode: channel.authMode ?? manifest?.auth.mode ?? "none", configured, disabled, active: channel.active, inputs, + ...(hostForward ? { hostForward } : {}), hooks: channel.hooks.length > 0 ? channel.hooks diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index 0f4682b6ea..f1c816a199 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -165,6 +165,56 @@ describe("parseSandboxMessagingPlan", () => { expect(parseSandboxMessagingPlan(plan)).toBeNull(); }); + it("accepts and rejects channel host forward plans", () => { + const source = makePlan({ + channels: [ + { + ...makePlan().channels[0], + channelId: "teams", + displayName: "Microsoft Teams", + inputs: [ + { + channelId: "teams", + inputId: "webhookPort", + kind: "config", + required: false, + sourceEnv: "MSTEAMS_PORT", + statePath: "teamsConfig.webhookPort", + value: "3978", + }, + ], + hostForward: { + channelId: "teams", + port: 3978, + label: "Microsoft Teams webhook", + }, + }, + ], + }); + + expect(parseSandboxMessagingPlan(source)?.channels[0]?.hostForward).toEqual({ + channelId: "teams", + port: 3978, + label: "Microsoft Teams webhook", + }); + + for (const hostForward of [ + { channelId: "telegram", port: 0, label: "Telegram webhook" }, + { channelId: "telegram", port: 70000, label: "Telegram webhook" }, + { channelId: "telegram", port: 3978.5, label: "Telegram webhook" }, + { channelId: "telegram", port: "3978", label: "Telegram webhook" }, + { channelId: "telegram", port: 3978 }, + ]) { + const plan = makePlan() as unknown as { channels: Array> }; + plan.channels[0] = { + ...plan.channels[0], + hostForward, + }; + + expect(parseSandboxMessagingPlan(plan), JSON.stringify(hostForward)).toBeNull(); + } + }); + it("rejects malformed object arrays without throwing", () => { for (const field of [ "credentialBindings", diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts index dce275201a..37cba03c51 100644 --- a/src/lib/messaging/plan-validation.ts +++ b/src/lib/messaging/plan-validation.ts @@ -57,6 +57,7 @@ export function parseSandboxMessagingPlan( if (Object.hasOwn(channel, "active") && typeof channel.active !== "boolean") return null; if (Object.hasOwn(channel, "disabled") && typeof channel.disabled !== "boolean") return null; if (Object.hasOwn(channel, "inputs") && !Array.isArray(channel.inputs)) return null; + if (Object.hasOwn(channel, "hostForward") && !isHostForward(channel.hostForward)) return null; if (Object.hasOwn(channel, "hooks") && !Array.isArray(channel.hooks)) return null; if ( Array.isArray(channel.inputs) && @@ -171,6 +172,18 @@ function isOptionalObjectArray(value: Record, key: string): boo return Array.isArray(entries) && entries.every(isObject); } +function isHostForward(value: unknown): boolean { + return ( + isObject(value) && + typeof value.channelId === "string" && + typeof value.port === "number" && + Number.isInteger(value.port) && + value.port >= 1 && + value.port <= 65535 && + typeof value.label === "string" + ); +} + function isRuntimeSetup(value: unknown): boolean { if (value === undefined) return true; return ( diff --git a/src/lib/onboard/agent-dashboard-forward.test.ts b/src/lib/onboard/agent-dashboard-forward.test.ts new file mode 100644 index 0000000000..487ed7b41b --- /dev/null +++ b/src/lib/onboard/agent-dashboard-forward.test.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { ensureAgentDashboardForward } from "./agent-dashboard-forward"; + +describe("ensureAgentDashboardForward", () => { + it("preserves additional host-forward ports during dashboard refresh", () => { + const ensureDashboardForward = vi.fn((_sandboxName, chatUiUrl = "http://127.0.0.1:18789") => { + const parsed = new URL(chatUiUrl); + return Number(parsed.port); + }); + + expect( + ensureAgentDashboardForward({ + sandboxName: "hm", + agent: { + forwardPort: 18789, + forward_ports: [18789, 8642], + }, + ensureDashboardForward, + preserveForwardPorts: [3978], + }), + ).toBe(18789); + + expect(ensureDashboardForward).toHaveBeenNthCalledWith(1, "hm", "http://127.0.0.1:18789", { + preserveSandboxPorts: [18789, 8642, 3978], + }); + expect(ensureDashboardForward).toHaveBeenNthCalledWith(2, "hm", "http://127.0.0.1:8642", { + preserveSandboxPorts: [18789, 8642, 3978], + allowPortReallocation: false, + }); + }); +}); diff --git a/src/lib/onboard/agent-dashboard-forward.ts b/src/lib/onboard/agent-dashboard-forward.ts index 98c7e1aacb..37894c2fde 100644 --- a/src/lib/onboard/agent-dashboard-forward.ts +++ b/src/lib/onboard/agent-dashboard-forward.ts @@ -20,11 +20,16 @@ export type EnsureDashboardForward = ( export type AgentDashboardForwardConfig = NonNullable; +function isValidPort(port: number | null | undefined): port is number { + return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65535; +} + export function ensureAgentDashboardForward(options: { sandboxName: string; agent: AgentDashboardForwardConfig; ensureDashboardForward: EnsureDashboardForward; controlUiPort?: number; + preserveForwardPorts?: readonly (number | null | undefined)[]; warn?: (message: string) => void; }): number { const { @@ -32,6 +37,7 @@ export function ensureAgentDashboardForward(options: { agent, ensureDashboardForward, controlUiPort = DASHBOARD_PORT, + preserveForwardPorts = [], warn = (message: string) => console.warn(message), } = options; if (!shouldManageDashboardForAgent(agent)) { @@ -40,7 +46,9 @@ export function ensureAgentDashboardForward(options: { const declaredPorts = getAgentDeclaredForwardPorts(agent); const agentDashboardPort = getAgentPrimaryForwardPort(agent, controlUiPort); - const preservePorts = [...new Set([agentDashboardPort, ...declaredPorts])]; + const preservePorts = [ + ...new Set([agentDashboardPort, ...declaredPorts, ...preserveForwardPorts]), + ].filter(isValidPort); const actualAgentDashboardPort = ensureDashboardForward( sandboxName, `http://127.0.0.1:${agentDashboardPort}`, diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index bc0d36533e..c0e6b3f4b6 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -32,6 +32,10 @@ import { looksLikeForwardPortConflict, runDetachedForwardStartWithPortReleaseRetries, } from "./forward-start"; +import { + ensureMessagingHostForwardForSandbox, + resolveMessagingHostForwardForSandbox, +} from "./messaging-host-forward"; const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; export const CONTROL_UI_PORT = DASHBOARD_PORT; @@ -239,6 +243,8 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa ): number { const { rollbackSandboxOnFailure, preservedPorts, allowPortReallocation } = normalizeDashboardForwardOptions(options); + const messagingForward = resolveMessagingHostForwardForSandbox(sandboxName); + if (messagingForward) preservedPorts.add(String(messagingForward.port)); const preferredPort = Number(getDashboardForwardPort(chatUiUrl)); const stopForwardForSandbox = createSandboxForwardStopper({ runOpenshell: deps.runOpenshell, @@ -338,6 +344,19 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa ); } } + if (fwdOk && rollbackSandboxOnFailure) { + ensureMessagingHostForwardForSandbox({ + sandboxName, + ensureForward: ensureAgentFixedForward, + note: deps.note, + rollbackOnFailure: { + runOpenshell: deps.runOpenshell, + buildRollbackMessage: buildOrphanedSandboxRollbackMessage, + cliName: deps.cliName, + forwardPortsToStop: [actualPort], + }, + }); + } return actualPort; } @@ -345,7 +364,11 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa sandboxName: string, agent: { forwardPort?: number | null; forward_ports?: number[] | null }, ): number { - return ensureAgentDashboardForwardForAgent({ sandboxName, agent, ensureDashboardForward }); + return ensureAgentDashboardForwardForAgent({ + sandboxName, + agent, + ensureDashboardForward, + }); } function ensureAgentFixedForward(sandboxName: string, port: number, label: string): boolean { diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index 66b29f0cea..bf9bba626f 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -205,6 +205,7 @@ network_policies: " telegram: {}", " discord: {}", " slack: {}", + " teams: {}", " wechat_bridge: {}", "", ].join("\n"), @@ -235,6 +236,7 @@ network_policies: expect(policyNames?.has("discord")).toBe(true); expect(policyNames?.has("telegram")).toBe(false); expect(policyNames?.has("slack")).toBe(false); + expect(policyNames?.has("teams")).toBe(false); expect(policyNames?.has("wechat_bridge")).toBe(false); expect(prepared.cleanup?.()).toBe(true); expect(fs.existsSync(prepared.policyPath)).toBe(false); diff --git a/src/lib/onboard/messaging-host-forward.test.ts b/src/lib/onboard/messaging-host-forward.test.ts new file mode 100644 index 0000000000..4656a3e6ff --- /dev/null +++ b/src/lib/onboard/messaging-host-forward.test.ts @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { + ensureMessagingHostForwardIfConfigured, + resolveMessagingHostForward, +} from "./messaging-host-forward"; + +function makePlan( + channel: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId: "teams", + displayName: "Microsoft Teams", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + hostForward: { + channelId: "teams", + port: 3978, + label: "Microsoft Teams webhook", + }, + ...channel, + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + +function makeCompactTeamsPlan(): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "ms", + agent: "hermes", + workflow: "onboard", + disabledChannels: [], + networkPolicy: { + presets: ["teams"], + entries: [ + { + channelId: "teams", + presetName: "teams", + policyKeys: ["teams"], + source: "manifest", + }, + ], + }, + channels: [ + { + channelId: "teams", + active: true, + configured: true, + disabled: false, + inputs: [ + { inputId: "allowedUsers", value: "user-id" }, + { inputId: "appId", value: "app-id" }, + { inputId: "clientSecret", credentialAvailable: true }, + { inputId: "requireMention", value: "1" }, + { inputId: "tenantId", value: "tenant-id" }, + { inputId: "webhookPort", value: "3978" }, + ], + }, + ], + credentialBindings: [], + } as unknown as SandboxMessagingPlan; +} + +describe("ensureMessagingHostForwardIfConfigured", () => { + it("resolves compact persisted messaging host forwards", () => { + expect(resolveMessagingHostForward(makeCompactTeamsPlan())).toEqual({ + channelId: "teams", + port: 3978, + label: "Microsoft Teams webhook", + }); + }); + + it("fails closed when persisted messaging plans are malformed", () => { + expect( + resolveMessagingHostForward({ + ...makePlan(), + channels: [null], + } as unknown as SandboxMessagingPlan), + ).toBeNull(); + }); + + it("starts the active messaging host forward", () => { + const ensureForward = vi.fn(() => true); + const note = vi.fn(); + + const ok = ensureMessagingHostForwardIfConfigured({ + sandboxName: "demo", + plan: makePlan(), + ensureForward, + note, + }); + + expect(ok).toBe(true); + expect(ensureForward).toHaveBeenCalledWith("demo", 3978, "Microsoft Teams webhook"); + expect(note).toHaveBeenCalledWith( + " ✓ Microsoft Teams webhook forwarded at http://127.0.0.1:3978/", + ); + }); + + it("hydrates compact persisted plans before starting the host forward", () => { + const ensureForward = vi.fn(() => true); + const note = vi.fn(); + + const ok = ensureMessagingHostForwardIfConfigured({ + sandboxName: "ms", + plan: makeCompactTeamsPlan(), + ensureForward, + note, + }); + + expect(ok).toBe(true); + expect(ensureForward).toHaveBeenCalledWith("ms", 3978, "Microsoft Teams webhook"); + expect(note).toHaveBeenCalledWith( + " ✓ Microsoft Teams webhook forwarded at http://127.0.0.1:3978/", + ); + }); + + it("skips disabled messaging channels", () => { + const ensureForward = vi.fn(() => true); + const note = vi.fn(); + + const ok = ensureMessagingHostForwardIfConfigured({ + sandboxName: "demo", + plan: makePlan({ active: false, disabled: true }), + ensureForward, + note, + }); + + expect(ok).toBe(true); + expect(ensureForward).not.toHaveBeenCalled(); + expect(note).not.toHaveBeenCalled(); + }); + + it("returns false when the forward cannot be started", () => { + const ensureForward = vi.fn(() => false); + const note = vi.fn(); + + const ok = ensureMessagingHostForwardIfConfigured({ + sandboxName: "demo", + plan: makePlan(), + ensureForward, + note, + }); + + expect(ok).toBe(false); + expect(note).not.toHaveBeenCalled(); + }); + + it("rolls back and exits when the forward cannot be started", () => { + const ensureForward = vi.fn(() => false); + const runOpenshell = vi.fn((args: string[]) => ({ + status: args[0] === "sandbox" && args[1] === "delete" ? 0 : 0, + })); + const errors: string[] = []; + + expect(() => + ensureMessagingHostForwardIfConfigured({ + sandboxName: "demo", + plan: makePlan(), + ensureForward, + note: vi.fn(), + rollbackOnFailure: { + runOpenshell, + cliName: () => "nemoclaw", + forwardPortsToStop: ["18789", undefined, 3978], + error: (message = "") => errors.push(message), + exit: (code) => { + throw new Error(`process.exit(${code})`); + }, + buildRollbackMessage: (_sandboxName, err, deleteSucceeded) => [ + `rollback:${deleteSucceeded}`, + err instanceof Error ? err.message : String(err), + ], + }, + }), + ).toThrow("process.exit(1)"); + + expect(runOpenshell).toHaveBeenCalledWith(["forward", "stop", "18789", "demo"], { + ignoreError: true, + }); + expect(runOpenshell).toHaveBeenCalledWith(["forward", "stop", "3978", "demo"], { + ignoreError: true, + }); + expect(runOpenshell).toHaveBeenCalledWith(["sandbox", "delete", "demo"], { + ignoreError: true, + }); + expect(runOpenshell).toHaveBeenCalledTimes(3); + expect(errors.join("\n")).toContain("rollback:true"); + expect(errors.join("\n")).toContain( + "Failed to start Microsoft Teams webhook forward on port 3978", + ); + }); +}); diff --git a/src/lib/onboard/messaging-host-forward.ts b/src/lib/onboard/messaging-host-forward.ts new file mode 100644 index 0000000000..6aeaa7331d --- /dev/null +++ b/src/lib/onboard/messaging-host-forward.ts @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + getActiveMessagingHostForward, + MessagingHostStateApplier, + type SandboxMessagingPlan, +} from "../messaging"; +import type { SandboxMessagingHostForwardPlan } from "../messaging/manifest"; +import { hydrateDerivedSandboxMessagingPlanFields } from "../messaging/persistence"; +import { parseSandboxMessagingPlan } from "../messaging/plan-validation"; +import * as registry from "../state/registry"; + +type RunOpenshell = ( + args: string[], + options: { ignoreError: true }, +) => { readonly status?: number | null }; + +export interface MessagingHostForwardRollbackOptions { + readonly runOpenshell: RunOpenshell; + readonly buildRollbackMessage: ( + sandboxName: string, + err: unknown, + deleteSucceeded: boolean, + ) => readonly string[]; + readonly cliName: () => string; + readonly forwardPortsToStop?: readonly (number | string | null | undefined)[]; + readonly error?: (message?: string) => void; + readonly exit?: (code: number) => never; +} + +export function resolveMessagingHostForward( + plan: SandboxMessagingPlan | null | undefined, +): SandboxMessagingHostForwardPlan | null { + const normalizedPlan = plan ? parseSandboxMessagingPlan(plan) : null; + if (!normalizedPlan) return null; + const hydratedPlan = hydrateDerivedSandboxMessagingPlanFields(normalizedPlan); + return getActiveMessagingHostForward(hydratedPlan); +} + +function resolveMessagingPlanForSandbox(sandboxName: string): SandboxMessagingPlan | null { + const envState = MessagingHostStateApplier.readPlanStateFromEnv(); + if (envState?.plan.sandboxName === sandboxName) return envState.plan; + return registry.getSandbox(sandboxName)?.messaging?.plan ?? null; +} + +export function resolveMessagingHostForwardForSandbox( + sandboxName: string, +): SandboxMessagingHostForwardPlan | null { + return resolveMessagingHostForward(resolveMessagingPlanForSandbox(sandboxName)); +} + +export function ensureMessagingHostForwardIfConfigured({ + sandboxName, + plan, + ensureForward, + note, + rollbackOnFailure, +}: { + readonly sandboxName: string; + readonly plan: SandboxMessagingPlan | null | undefined; + readonly ensureForward: (sandboxName: string, port: number, label: string) => boolean; + readonly note: (message: string) => void; + readonly rollbackOnFailure?: MessagingHostForwardRollbackOptions; +}): boolean { + const forward = resolveMessagingHostForward(plan); + if (!forward) return true; + + const ok = ensureForward(sandboxName, forward.port, forward.label); + if (ok) { + note(` ✓ ${forward.label} forwarded at http://127.0.0.1:${forward.port}/`); + } else if (rollbackOnFailure) { + abortMessagingHostForwardFailure({ sandboxName, forward, rollback: rollbackOnFailure }); + } + return ok; +} + +export function ensureMessagingHostForwardForSandbox({ + sandboxName, + ensureForward, + note, + rollbackOnFailure, +}: { + readonly sandboxName: string; + readonly ensureForward: (sandboxName: string, port: number, label: string) => boolean; + readonly note: (message: string) => void; + readonly rollbackOnFailure?: MessagingHostForwardRollbackOptions; +}): boolean { + return ensureMessagingHostForwardIfConfigured({ + sandboxName, + plan: resolveMessagingPlanForSandbox(sandboxName), + ensureForward, + note, + rollbackOnFailure, + }); +} + +function abortMessagingHostForwardFailure({ + sandboxName, + forward, + rollback, +}: { + readonly sandboxName: string; + readonly forward: SandboxMessagingHostForwardPlan; + readonly rollback: MessagingHostForwardRollbackOptions; +}): never { + const portsToStop = new Set(); + for (const port of rollback.forwardPortsToStop ?? []) { + if (port !== null && port !== undefined && String(port).trim() !== "") { + portsToStop.add(String(port)); + } + } + portsToStop.add(String(forward.port)); + + for (const port of portsToStop) { + rollback.runOpenshell(["forward", "stop", port, sandboxName], { ignoreError: true }); + } + const deleteResult = rollback.runOpenshell(["sandbox", "delete", sandboxName], { + ignoreError: true, + }); + const error = new Error( + `Failed to start ${forward.label} forward on port ${forward.port}. Free the port and ` + + `re-run \`${rollback.cliName()} onboard\`, or choose a different messaging channel port.`, + ); + const writeError = rollback.error ?? console.error; + for (const line of rollback.buildRollbackMessage(sandboxName, error, deleteResult.status === 0)) { + writeError(line); + } + const exit = rollback.exit ?? process.exit; + return exit(1); +} diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts index cbc28d332d..ba31de9547 100644 --- a/src/lib/onboard/messaging-prep.test.ts +++ b/src/lib/onboard/messaging-prep.test.ts @@ -144,6 +144,7 @@ describe("prepareCreateSandboxMessaging", () => { expect([...result.messagingTokenDefs.map(({ envKey }) => envKey)].sort()).toEqual([ "DISCORD_BOT_TOKEN", + "MSTEAMS_APP_PASSWORD", "SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN", diff --git a/src/lib/sandbox/channels.test.ts b/src/lib/sandbox/channels.test.ts index 27b3b98b03..5097262af2 100644 --- a/src/lib/sandbox/channels.test.ts +++ b/src/lib/sandbox/channels.test.ts @@ -16,8 +16,15 @@ import { } from "./channels"; describe("sandbox-channels KNOWN_CHANNELS", () => { - it("covers telegram, discord, wechat, slack, and whatsapp", () => { - expect(knownChannelNames()).toEqual(["telegram", "discord", "wechat", "slack", "whatsapp"]); + it("covers telegram, discord, wechat, slack, whatsapp, and teams", () => { + expect(knownChannelNames()).toEqual([ + "telegram", + "discord", + "wechat", + "slack", + "whatsapp", + "teams", + ]); }); it("exposes the primary bot-token env var for token-based channels", () => { @@ -25,6 +32,7 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(getChannelDef("discord")?.envKey).toBe("DISCORD_BOT_TOKEN"); expect(getChannelDef("slack")?.envKey).toBe("SLACK_BOT_TOKEN"); expect(getChannelDef("wechat")?.envKey).toBe("WECHAT_BOT_TOKEN"); + expect(getChannelDef("teams")?.envKey).toBe("MSTEAMS_APP_PASSWORD"); }); it("classifies channels by login method", () => { @@ -40,6 +48,7 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(getChannelDef("telegram")?.loginMethod).toBeUndefined(); expect(getChannelDef("discord")?.loginMethod).toBeUndefined(); expect(getChannelDef("slack")?.loginMethod).toBeUndefined(); + expect(getChannelDef("teams")?.loginMethod).toBeUndefined(); }); it("declares wechat as DM-only with the WECHAT_ALLOWED_IDS env key", () => { @@ -55,6 +64,7 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.whatsapp)).toBe(true); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.wechat)).toBe(false); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.slack)).toBe(false); + expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.teams)).toBe(false); }); it("declares no provider-credential metadata for WhatsApp", () => { @@ -71,6 +81,17 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(getChannelDef("discord")?.appTokenEnvKey).toBeUndefined(); expect(getChannelDef("slack")?.appTokenEnvKey).toBe("SLACK_APP_TOKEN"); expect(getChannelDef("whatsapp")?.appTokenEnvKey).toBeUndefined(); + expect(getChannelDef("teams")?.appTokenEnvKey).toBeUndefined(); + }); + + it("asks for Microsoft Teams AAD object IDs as a comma-separated allowlist", () => { + const teams = getChannelDef("teams"); + expect(teams?.userIdEnvKey).toBe("TEAMS_ALLOWED_USERS"); + expect(teams?.userIdLabel).toBe("Microsoft Teams AAD Object IDs (comma-separated allowlist)"); + expect(teams?.userIdHelp).toContain("Azure AD object IDs"); + expect(teams?.allowIdsMode).toBe("dm"); + expect(teams?.requireMentionEnvKey).toBe("TEAMS_REQUIRE_MENTION"); + expect(teams?.requireMentionHelp).toContain("OpenClaw group and channel behavior"); }); it("asks for Slack human member IDs as a comma-separated allowlist", () => { @@ -107,6 +128,7 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(getChannelDef(" Telegram ")).toBe(KNOWN_CHANNELS.telegram); expect(getChannelDef("DISCORD")).toBe(KNOWN_CHANNELS.discord); expect(getChannelDef(" WhatsApp ")).toBe(KNOWN_CHANNELS.whatsapp); + expect(getChannelDef(" Teams ")).toBe(KNOWN_CHANNELS.teams); }); it("returns undefined for unknown channel names", () => { @@ -119,6 +141,7 @@ describe("sandbox-channels getChannelTokenKeys", () => { it("returns just the primary token key for single-token channels", () => { expect(getChannelTokenKeys(KNOWN_CHANNELS.telegram)).toEqual(["TELEGRAM_BOT_TOKEN"]); expect(getChannelTokenKeys(KNOWN_CHANNELS.discord)).toEqual(["DISCORD_BOT_TOKEN"]); + expect(getChannelTokenKeys(KNOWN_CHANNELS.teams)).toEqual(["MSTEAMS_APP_PASSWORD"]); }); it("returns primary then app token for slack", () => { @@ -146,6 +169,7 @@ describe("sandbox-channels token-shape helpers", () => { expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.wechat)).toBe(false); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.telegram)).toBe(false); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.slack)).toBe(false); + expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.teams)).toBe(false); }); it("channelHasStaticToken narrows to ChannelDef with a defined envKey", () => { @@ -163,11 +187,20 @@ describe("sandbox-channels token-shape helpers", () => { describe("sandbox-channels listChannels", () => { it("materialises an array with the name merged into each entry", () => { const list = listChannels(); - expect(list.map((c) => c.name)).toEqual(["telegram", "discord", "wechat", "slack", "whatsapp"]); + expect(list.map((c) => c.name)).toEqual([ + "telegram", + "discord", + "wechat", + "slack", + "whatsapp", + "teams", + ]); const telegram = list.find((c) => c.name === "telegram"); expect(telegram?.envKey).toBe("TELEGRAM_BOT_TOKEN"); expect(telegram?.allowIdsMode).toBe("dm"); const whatsapp = list.find((c) => c.name === "whatsapp"); expect(whatsapp?.envKey).toBeUndefined(); + const teams = list.find((c) => c.name === "teams"); + expect(teams?.envKey).toBe("MSTEAMS_APP_PASSWORD"); }); }); diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts index 165313a92e..2a739c8a0a 100644 --- a/src/lib/status-command-deps.ts +++ b/src/lib/status-command-deps.ts @@ -247,6 +247,7 @@ function readOverlapOutputs(result: MessagingStatusHookRunResult): MessagingOver sandboxes: entry.sandboxes, ...(typeof entry.reason === "string" ? { reason: entry.reason } : {}), ...(typeof entry.message === "string" ? { message: entry.message } : {}), + ...(typeof entry.port === "number" ? { port: entry.port } : {}), }, ]; }); diff --git a/src/lib/tunnel/services.ts b/src/lib/tunnel/services.ts index c20fd1d9ed..705db4ce19 100644 --- a/src/lib/tunnel/services.ts +++ b/src/lib/tunnel/services.ts @@ -628,7 +628,7 @@ export async function startAll(opts: ServiceOptions = {}): Promise { ensurePidDir(pidDir); - // Messaging (Telegram, Discord, Slack) is now handled natively by OpenClaw + // Messaging channels are handled natively by the agent runtime // inside the sandbox via the OpenShell provider/placeholder/L7-proxy pipeline. // No host-side bridge processes are needed. See: PR #1081. diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 15b1f7bcf7..fb7f248adf 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -307,7 +307,7 @@ const ctx = module.exports; isInteractive: false, configuredChannels: ["slack"], disabledChannels: [], - supportedChannelIds: ["telegram", "discord", "wechat", "slack", "whatsapp"], + supportedChannelIds: ["telegram", "discord", "wechat", "slack", "whatsapp", "teams"], }, ]); }); diff --git a/test/e2e-scenario/live/openclaw-pairing-helpers.ts b/test/e2e-scenario/live/openclaw-pairing-helpers.ts index 4402f8d57b..2595915a7b 100644 --- a/test/e2e-scenario/live/openclaw-pairing-helpers.ts +++ b/test/e2e-scenario/live/openclaw-pairing-helpers.ts @@ -256,7 +256,7 @@ import { pathToFileURL } from "node:url"; function findOpenClawPackageRootFromBinary() { let binary = ""; - try { binary = execFileSync("bash", ["-lc", "type -P openclaw || command -v openclaw"], { encoding: "utf8" }).trim(); } catch { return null; } + try { binary = execFileSync("bash", ["-c", "type -P openclaw || command -v openclaw"], { encoding: "utf8" }).trim(); } catch { return null; } if (!binary) return null; let current = ""; try { current = fs.realpathSync(binary); } catch { return null; } diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 09218d9786..5538068689 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -59,6 +59,18 @@ function wechatConfigB64(overrides: Record = {}): string { ).toString("base64"); } +function teamsConfigB64(overrides: Record = {}): string { + return Buffer.from( + JSON.stringify({ + appId: "test-teams-app-id", + tenantId: "test-teams-tenant-id", + allowedUsers: ["00000000-0000-0000-0000-000000000001"], + webhookPort: "3978", + ...overrides, + }), + ).toString("base64"); +} + function runDryRun(envOverrides: Record = {}) { const env = withLegacyMessagingPlanEnv( { @@ -93,6 +105,14 @@ function parseDryRun(envOverrides: Record = {}) { return JSON.parse(result.stdout); } +function decodePlan(encoded: string): any { + return JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")); +} + +function encodePlan(plan: any): string { + return Buffer.from(JSON.stringify(plan)).toString("base64"); +} + describe("messaging-build-applier.mts: agent-install", () => { it("collects selected messaging plugin install specs", () => { const payload = parseDryRun({ @@ -103,8 +123,10 @@ describe("messaging-build-applier.mts: agent-install", () => { "slack", "whatsapp", "wechat", + "teams", ]), NEMOCLAW_WECHAT_CONFIG_B64: wechatConfigB64(), + NEMOCLAW_TEAMS_CONFIG_B64: teamsConfigB64(), }); expect(payload.installSpecs).toEqual([ @@ -112,9 +134,11 @@ describe("messaging-build-applier.mts: agent-install", () => { "npm:@tencent-weixin/openclaw-weixin@2.4.3", "npm:@openclaw/slack@2026.5.22", "npm:@openclaw/whatsapp@2026.5.22", + "npm:@openclaw/msteams@2026.5.22", ]); expect(payload.doctorEnv).toEqual({ DISCORD_BOT_TOKEN: "openshell:resolve:env:DISCORD_BOT_TOKEN", + MSTEAMS_APP_PASSWORD: "openshell:resolve:env:MSTEAMS_APP_PASSWORD", SLACK_APP_TOKEN: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", SLACK_BOT_TOKEN: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", TELEGRAM_BOT_TOKEN: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", @@ -417,8 +441,10 @@ describe("messaging-build-applier.mts: agent-install", () => { "slack", "whatsapp", "wechat", + "teams", ]), NEMOCLAW_WECHAT_CONFIG_B64: wechatConfigB64(), + NEMOCLAW_TEAMS_CONFIG_B64: teamsConfigB64(), }, "openclaw", ); @@ -446,12 +472,137 @@ describe("messaging-build-applier.mts: agent-install", () => { "plugins|install|npm:@tencent-weixin/openclaw-weixin@2.4.3|--pin|||", "plugins|install|npm:@openclaw/slack@2026.5.22|--pin|||", "plugins|install|npm:@openclaw/whatsapp@2026.5.22|--pin|||", + "plugins|install|npm:@openclaw/msteams@2026.5.22|--pin|||", ]); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); + it("installs Hermes Python packages supplied by the compiled Teams plan", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-teams-packages-")); + const tracePath = path.join(tmp, "uv.trace"); + const fakeUv = path.join(tmp, "uv"); + fs.writeFileSync( + fakeUv, + ["#!/bin/sh", 'printf \'%s\\n\' "$*" >> "$UV_TRACE"', "exit 0", ""].join("\n"), + { mode: 0o755 }, + ); + + try { + const planEnv = withLegacyMessagingPlanEnv( + { + PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, + UV_TRACE: tracePath, + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["teams"]), + NEMOCLAW_TEAMS_CONFIG_B64: teamsConfigB64(), + }, + "hermes", + ); + + const dryRun = spawnSync( + "node", + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "hermes", + "--phase", + "agent-install", + "--dry-run", + ], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: planEnv, + timeout: 10_000, + }, + ); + expect(dryRun.status, dryRun.stderr).toBe(0); + expect(JSON.parse(dryRun.stdout).hermesUvPackages).toEqual([ + "microsoft-teams-apps==2.0.13.4", + "aiohttp==3.14.1", + ]); + + const result = spawnSync( + "node", + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "hermes", + "--phase", + "agent-install", + ], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: planEnv, + timeout: 10_000, + }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(tracePath, "utf-8").trim()).toBe( + "pip install --python /opt/hermes/.venv/bin/python --no-cache -- microsoft-teams-apps==2.0.13.4 aiohttp==3.14.1", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("rejects Hermes Python packages not declared by trusted built-in channel manifests", () => { + const baseEnv = withLegacyMessagingPlanEnv( + { + PATH: process.env.PATH || "/usr/bin:/bin", + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["teams"]), + NEMOCLAW_TEAMS_CONFIG_B64: teamsConfigB64(), + }, + "hermes", + ); + const plan = decodePlan(baseEnv.NEMOCLAW_MESSAGING_PLAN_B64); + plan.buildSteps = [ + ...plan.buildSteps, + { + channelId: "teams", + kind: "package-install", + outputId: "tamperedHermesPackage", + required: true, + value: { + manager: "hermes-uv-pip", + spec: "unexpected-package==1.2.3", + }, + }, + ]; + + const result = spawnSync( + "node", + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "hermes", + "--phase", + "agent-install", + "--dry-run", + ], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { + ...baseEnv, + NEMOCLAW_MESSAGING_PLAN_B64: encodePlan(plan), + }, + timeout: 10_000, + }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("tamperedHermesPackage"); + expect(result.stderr).toContain("not declared by a trusted built-in manifest"); + expect(result.stderr).toContain("unexpected-package==1.2.3"); + }); + it("#4246: messaging post-agent-install render reaches the mocked OpenClaw doctor boundary", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-discord-runtime-contract-")); const tracePath = path.join(tmp, "openclaw.trace"); diff --git a/test/messaging-plan-test-helper.ts b/test/messaging-plan-test-helper.ts index 729abf7832..d40683927e 100644 --- a/test/messaging-plan-test-helper.ts +++ b/test/messaging-plan-test-helper.ts @@ -140,6 +140,13 @@ function legacyMessagingConfigEnv(env: Record): Record>(env, "NEMOCLAW_SLACK_CONFIG_B64", {}); assignCsv(next, "SLACK_ALLOWED_CHANNELS", slackConfig.allowedChannels); + const teamsConfig = decodeJsonEnv>(env, "NEMOCLAW_TEAMS_CONFIG_B64", {}); + assignString(next, "MSTEAMS_APP_ID", teamsConfig.appId); + assignString(next, "MSTEAMS_TENANT_ID", teamsConfig.tenantId); + assignCsv(next, "TEAMS_ALLOWED_USERS", teamsConfig.allowedUsers); + assignString(next, "MSTEAMS_PORT", teamsConfig.webhookPort); + assignMentionMode(next, "TEAMS_REQUIRE_MENTION", teamsConfig.requireMention); + return next; } @@ -234,11 +241,13 @@ function credentialAvailability(): Record { "wechatBotToken", "slackBotToken", "slackAppToken", + "teamsClientSecret", "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "WECHAT_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", + "MSTEAMS_APP_PASSWORD", ]; return Object.fromEntries(keys.map((key) => [key, true])); } diff --git a/test/policies-teams.test.ts b/test/policies-teams.test.ts new file mode 100644 index 0000000000..04aba13e33 --- /dev/null +++ b/test/policies-teams.test.ts @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import * as policies from "../dist/lib/policy"; + +const requireForTest = createRequire(import.meta.url); +const YAML = requireForTest("yaml"); +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "policy", "index.js")); +const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "state", "registry.js")); + +function requirePresetContent(content: string | null): string { + expect(content).toEqual(expect.any(String)); + return content as string; +} + +function parseResultPayload(stdout: string): any { + const marker = "__RESULT__"; + const markerIndex = stdout.indexOf(marker); + expect(markerIndex).toBeGreaterThanOrEqual(0); + return JSON.parse(stdout.slice(markerIndex + marker.length)); +} + +function allowedMethods( + policy: { endpoints: Array<{ host?: string; rules?: Array<{ allow?: { method?: string } }> }> }, + host: string, +): string[] { + return allowedRules(policy, host) + .map((rule) => rule.method) + .filter((method): method is string => typeof method === "string") + .sort(); +} + +function allowedRules( + policy: { + endpoints: Array<{ + host?: string; + rules?: Array<{ allow?: { method?: string; path?: string } }>; + }>; + }, + host: string, +): Array<{ method?: string; path?: string }> { + const endpoint = policy.endpoints.find((entry) => entry.host === host); + expect(endpoint).toBeTruthy(); + return (endpoint?.rules ?? []).map((rule) => rule.allow ?? {}); +} + +describe("Teams policy preset", () => { + it("extracts Microsoft Teams Bot Framework and Graph hosts", () => { + const content = requirePresetContent(policies.loadPreset("teams")); + const hosts = policies.getPresetEndpoints(content); + expect(hosts).toContain("login.microsoftonline.com"); + expect(hosts).toContain("login.botframework.com"); + expect(hosts).toContain("api.botframework.com"); + expect(hosts).toContain("smba.trafficmanager.net"); + expect(hosts).toContain("graph.microsoft.com"); + expect(hosts).toContain("*.sharepoint.com"); + const teamsPolicy = YAML.parse(content).network_policies.teams; + expect(allowedMethods(teamsPolicy, "graph.microsoft.com")).toEqual(["GET"]); + expect(allowedRules(teamsPolicy, "smba.trafficmanager.net")).toEqual([ + { method: "GET", path: "/**" }, + { method: "POST", path: "/**" }, + { method: "PUT", path: "/**" }, + { method: "DELETE", path: "/**" }, + ]); + expect(allowedMethods(teamsPolicy, "teams.microsoft.com")).toEqual(["GET"]); + expect(allowedMethods(teamsPolicy, "teams.cdn.office.net")).toEqual(["GET"]); + expect(allowedMethods(teamsPolicy, "statics.teams.cdn.office.net")).toEqual(["GET"]); + expect(allowedMethods(teamsPolicy, "*.sharepoint.com")).toEqual(["GET"]); + }); + + it("returns Teams validation guidance", () => { + expect(policies.getPresetValidationWarning("teams")).toContain("Microsoft Teams"); + }); + + it("uses agent-specific preset content for Hermes Teams", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-hermes-teams-")); + const fakeOpenshell = path.join(tmpDir, "openshell"); + const policyOut = path.join(tmpDir, "policy.yaml"); + const script = String.raw` +const fs = require("node:fs"); +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +registry.registerSandbox({ name: "hermes-sandbox", agent: "hermes", policies: [] }); +const result = policies.applyPresets("hermes-sandbox", ["teams"]); +process.stdout.write("\n__RESULT__" + JSON.stringify({ + result, + policy: fs.readFileSync(process.env.POLICY_OUT, "utf-8"), + registry: registry.getSandbox("hermes-sandbox"), +})); +`; + fs.writeFileSync( + fakeOpenshell, + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1 $2" = "policy get" ]; then + printf 'Version: 1\nHash: test\n---\nversion: 1\n\nnetwork_policies: {}\n' + exit 0 +fi +if [ "$1 $2" = "policy set" ]; then + policy_file="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--policy" ]; then + policy_file="$2" + break + fi + shift + done + cp "$policy_file" ${JSON.stringify(policyOut)} + printf 'Policy version 2 submitted\nPolicy version 2 loaded\n' + exit 0 +fi +exit 1 +`, + { mode: 0o755 }, + ); + + try { + const result = spawnSync(process.execPath, ["-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_OPENSHELL_BIN: fakeOpenshell, + POLICY_OUT: policyOut, + }, + }); + + expect(result.status).toBe(0); + const payload = parseResultPayload(result.stdout); + const parsed = YAML.parse(payload.policy); + const teamsPolicy = parsed.network_policies.teams; + const binaries = teamsPolicy.binaries.map((entry: { path: string }) => entry.path); + expect(binaries).toContain("/usr/bin/python3*"); + expect(binaries).toContain("/opt/hermes/.venv/bin/python"); + expect(binaries).toContain("/usr/local/bin/hermes"); + expect( + teamsPolicy.endpoints.some( + (endpoint: { host?: string }) => endpoint.host === "smba.trafficmanager.net", + ), + ).toBe(true); + const hosts = teamsPolicy.endpoints.map((endpoint: { host?: string }) => endpoint.host); + expect(hosts).toEqual( + expect.arrayContaining([ + "login.microsoftonline.com", + "login.botframework.com", + "api.botframework.com", + "smba.trafficmanager.net", + "graph.microsoft.com", + "*.sharepoint.com", + ]), + ); + expect(allowedMethods(teamsPolicy, "graph.microsoft.com")).toEqual(["GET"]); + expect(allowedRules(teamsPolicy, "smba.trafficmanager.net")).toEqual([ + { method: "GET", path: "/**" }, + { method: "POST", path: "/**" }, + { method: "PUT", path: "/**" }, + { method: "DELETE", path: "/**" }, + ]); + expect(allowedMethods(teamsPolicy, "teams.microsoft.com")).toEqual(["GET"]); + expect(allowedMethods(teamsPolicy, "*.sharepoint.com")).toEqual(["GET"]); + expect(payload.registry.policies).toEqual(["teams"]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/policies.test.ts b/test/policies.test.ts index a1b23e17be..80741e77d5 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -28,7 +28,6 @@ const SELECT_FROM_LIST_ITEMS = [ { name: "npm", description: "npm and Yarn registry access" }, { name: "pypi", description: "Python Package Index (PyPI) access" }, ]; - type PolicyCall = { type: string; message?: string; @@ -144,6 +143,7 @@ describe("policies", () => { "public-reference", "pypi", "slack", + "teams", "telegram", "weather", "wechat", @@ -169,7 +169,7 @@ describe("policies", () => { }); it("includes /usr/bin/node in communication presets", () => { - for (const preset of ["discord", "slack", "telegram", "whatsapp"]) { + for (const preset of ["discord", "slack", "teams", "telegram", "whatsapp"]) { const content = requirePresetContent(policies.loadPreset(preset)); expect(content).toContain("/usr/local/bin/node"); expect(content).toContain("/usr/bin/node"); diff --git a/test/process-recovery.test.ts b/test/process-recovery.test.ts index 6481656e69..9860eeba18 100644 --- a/test/process-recovery.test.ts +++ b/test/process-recovery.test.ts @@ -51,6 +51,44 @@ function withFakeOpenshellBinary(fn: () => T): T { } } +function compactTeamsMessagingPlan(port = "3978") { + return { + schemaVersion: 1, + sandboxName: "beta", + agent: "openclaw", + workflow: "onboard", + disabledChannels: [], + networkPolicy: { + presets: ["teams"], + entries: [ + { + channelId: "teams", + presetName: "teams", + policyKeys: ["teams"], + source: "manifest", + }, + ], + }, + channels: [ + { + channelId: "teams", + active: true, + configured: true, + disabled: false, + inputs: [ + { inputId: "allowedUsers", value: "00000000-0000-0000-0000-000000000001" }, + { inputId: "appId", value: "test-teams-app-id" }, + { inputId: "clientSecret", credentialAvailable: true }, + { inputId: "requireMention", value: "1" }, + { inputId: "tenantId", value: "test-teams-tenant-id" }, + { inputId: "webhookPort", value: port }, + ], + }, + ], + credentialBindings: [], + }; +} + describe("resolveSandboxDashboardPort", () => { it("uses the recorded OpenClaw dashboard port for multi-sandbox recovery", () => { expect( @@ -380,6 +418,121 @@ beta 127.0.0.1 18789 12345 running`; ).toBe(false); }); + it("checkAndRecoverSandboxProcesses re-establishes an active Teams messaging host forward from a compact plan when the dashboard forward is healthy", () => { + const openshellRuntime = requireDist("../dist/lib/adapters/openshell/runtime.js"); + const agentRuntime = requireDist("../dist/lib/agent/runtime.js"); + const registry = requireDist("../dist/lib/state/registry.js"); + const forwardHealth = requireDist("../dist/lib/actions/sandbox/forward-health.js"); + const childProcess = requireDist("node:child_process"); + const dashboardForward = `SANDBOX BIND PORT PID STATUS +beta 127.0.0.1 18789 12345 running`; + const dashboardAndTeamsForwards = `${dashboardForward} +beta 127.0.0.1 3978 12346 running`; + let teamsForwardStarted = false; + + vi.spyOn(childProcess, "spawnSync").mockReturnValue({ + status: 0, + stdout: "__NEMOCLAW_SANDBOX_EXEC_STARTED__\nRUNNING\n", + stderr: "", + } as never); + vi.spyOn(agentRuntime, "getSessionAgent").mockReturnValue(null); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "beta", + agent: "openclaw", + dashboardPort: 18789, + messaging: { schemaVersion: 1, plan: compactTeamsMessagingPlan() }, + }); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation( + (port: unknown) => Number(port) === 18789 || teamsForwardStarted, + ); + vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ + status: 0, + output: teamsForwardStarted ? dashboardAndTeamsForwards : dashboardForward, + })); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + teamsForwardStarted = + teamsForwardStarted || + (args[0] === "forward" && + args[1] === "start" && + args.includes("--background") && + args.includes("3978") && + args.includes("beta")); + return { status: 0 } as never; + }); + + expect( + withFakeOpenshellBinary(() => checkAndRecoverSandboxProcesses("beta", { quiet: true })), + ).toEqual({ + checked: true, + wasRunning: true, + recovered: false, + forwardRecovered: true, + }); + expect(teamsForwardStarted).toBe(true); + expect(runOpenshell).toHaveBeenCalledWith(["forward", "stop", "3978", "beta"], { + ignoreError: true, + stdio: "ignore", + }); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "3978", "beta"], + { ignoreError: true }, + ); + }); + + it("checkAndRecoverSandboxProcesses reports messaging webhook recovery failure without claiming forwardRecovered", () => { + const openshellRuntime = requireDist("../dist/lib/adapters/openshell/runtime.js"); + const agentRuntime = requireDist("../dist/lib/agent/runtime.js"); + const registry = requireDist("../dist/lib/state/registry.js"); + const forwardHealth = requireDist("../dist/lib/actions/sandbox/forward-health.js"); + const childProcess = requireDist("node:child_process"); + const dashboardForward = `SANDBOX BIND PORT PID STATUS +beta 127.0.0.1 18789 12345 running`; + + vi.spyOn(childProcess, "spawnSync").mockReturnValue({ + status: 0, + stdout: "__NEMOCLAW_SANDBOX_EXEC_STARTED__\nRUNNING\n", + stderr: "", + } as never); + vi.spyOn(agentRuntime, "getSessionAgent").mockReturnValue(null); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "beta", + agent: "openclaw", + dashboardPort: 18789, + messaging: { schemaVersion: 1, plan: compactTeamsMessagingPlan() }, + }); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation( + (port: unknown) => Number(port) === 18789, + ); + vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ + status: 0, + output: dashboardForward, + }); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + return { + status: args[0] === "forward" && args[1] === "start" && args.includes("3978") ? 1 : 0, + } as never; + }); + + expect( + withFakeOpenshellBinary(() => checkAndRecoverSandboxProcesses("beta", { quiet: true })), + ).toEqual({ + checked: true, + wasRunning: true, + recovered: false, + forwardRecovered: false, + }); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "3978", "beta"], + { ignoreError: true }, + ); + }); + it("waits for a recovered sandbox gateway before declaring recovery", () => { const openshellRuntime = requireDist("../dist/lib/adapters/openshell/runtime.js"); const agentRuntime = requireDist("../dist/lib/agent/runtime.js"); diff --git a/test/sandbox-provider-cleanup.test.ts b/test/sandbox-provider-cleanup.test.ts index f301ebd9b3..8e83ac8232 100644 --- a/test/sandbox-provider-cleanup.test.ts +++ b/test/sandbox-provider-cleanup.test.ts @@ -38,6 +38,7 @@ describe("SANDBOX_PROVIDER_SUFFIXES", () => { "wechat-bridge", "slack-bridge", "slack-app", + "teams-bridge", "brave-search", ].sort(), );