From 79dcca180f4bafba500f197e2d93a0ac593ce1fb Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 16 Jun 2026 12:42:41 +0700 Subject: [PATCH 01/11] docs(skills): add messaging channel onboarding guide --- .../SKILL.md | 102 ++++++++++++++++++ .agents/skills/nemoclaw-skills-guide/SKILL.md | 9 +- AGENTS.md | 4 + src/lib/messaging/AGENTS.md | 83 ++++++++++++++ 4 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 .agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md create mode 100644 src/lib/messaging/AGENTS.md diff --git a/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md b/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md new file mode 100644 index 0000000000..df8bc80efa --- /dev/null +++ b/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md @@ -0,0 +1,102 @@ +--- +name: nemoclaw-contributor-onboard-messaging-channel +description: Guide NemoClaw contributors through adding or reviewing a new messaging channel in the manifest-first messaging architecture. Use when onboarding a channel for OpenClaw, Hermes, or both; mapping upstream channel docs and source code into NemoClaw manifests; confirming credentials, plugin/package installs, reachability checks, network policy presets, docs, and tests. Trigger keywords - add messaging channel, onboard messaging channel, new channel, messaging integration, channel manifest, OpenClaw channel, Hermes channel, plugin install, reachability check. +--- + +# Onboard Messaging Channel + +Use this skill to add a messaging channel end to end without leaking channel-specific logic into core NemoClaw code. + +## Intake + +Gather inputs progressively. Do not ask the full intake checklist in one message. +Ask exactly one concise clarification at a time, choosing the earliest unresolved blocker: + +1. If the channel name is missing, ask for the channel name. +2. If target agents are missing, ask whether the channel should support OpenClaw, Hermes, or both. +3. If upstream references are missing, ask for the official docs link and source-of-truth implementation link or path. Include Telegram as the format example: docs `https://docs.openclaw.ai/channels/telegram`, source `https://github.com/openclaw/openclaw/tree/main/extensions/telegram`. +4. After references are available, read them and the local messaging package before asking about credentials, plugin installs, reachability, or network policy. +5. Ask follow-up questions one by one only for details that remain ambiguous after source analysis. + +Use this intake checklist internally while analyzing source: + +- Channel name and target agents: OpenClaw, Hermes, or both. Treat unsupported agents as intentionally out of scope unless the user provides source evidence. +- Official channel documentation link and source-of-truth implementation link or path. Prefer upstream extension/runtime code over README prose when they conflict. +- Required credentials and config inputs: token environment variables, bot or app IDs, user IDs, workspace/guild/group IDs, allowlists, app secrets, webhook secrets, socket-mode/app tokens, QR pairing, callback URLs, or proxy settings. +- Plugin or package install requirements: package name, install manager, version pinning, bundled versus external status, extension ID, and whether the package must be installed during image build. +- Reachability or health evidence: endpoint or command, HTTP method, auth semantics, success response, invalid-credential response, transient-network behavior, and whether tests need a skip env var for fake credentials. +- Network reachability: exact hostnames required at runtime, whether they are agent-only or bridge-only, and whether the policy should be opt-in. + +When asking a follow-up, include the source-derived fact that made the question necessary. Example: "The upstream extension enables a webhook secret, but I do not see whether NemoClaw should prompt for it. Should this be a required input?" + +## Source Analysis + +Before editing, read: + +- Root `AGENTS.md` and `CONTRIBUTING.md`. +- `src/lib/messaging/AGENTS.md`. +- The closest existing channel manifests and tests under `src/lib/messaging/channels/`. +- The upstream docs and source code supplied by the user. + +Compare the new channel to existing patterns: + +- Token plus API reachability: Telegram-style. +- Multiple credentials, socket mode, or channel-owned conflicts: Slack-style. +- Allowlists or scoped IDs: Discord-style. +- QR or pairing flow with runtime status: WeChat or WhatsApp-style. +- Agent-specific plugin install and config render: channels that require external agent extensions. + +When docs and source disagree, implement from source code and note the inference in the final handoff. + +## Implementation Workflow + +Start with the manifest. Add core code only when the manifest vocabulary cannot express a reusable concept. + +1. Add `src/lib/messaging/channels//manifest.ts` with `auth`, `inputs`, `credentials`, `policyPresets`, `render`, `runtime`, `agentPackages`, `state`, and `hooks` as needed. +2. Add `channels//template-resolver.ts` only for derived render values, such as allowlist normalization, booleans, proxy URLs, or agent-specific schema differences. +3. Add hooks under `channels//hooks/` only for enrollment, external reachability checks, QR capture, conflict checks, runtime status, or health probes that cannot be static manifest data. +4. Register the manifest in `channels/built-ins.ts`, template resolver in `channels/template-resolver.ts`, and hook handlers in `hooks/builtins.ts`. +5. Add `nemoclaw-blueprint/policies/presets/.yaml` when the manifest declares a policy preset. Keep messaging-specific egress opt-in unless the project policy says otherwise. +6. Update `agents/openclaw/manifest.yaml` and/or `agents/hermes/manifest.yaml` so supported platforms match the manifest `supportedAgents`. +7. Add agent package install metadata when the channel needs an external agent plugin. For OpenClaw plugin packages, use this shape unless source evidence says otherwise: + + ```ts + agentPackages: [ + { + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@openclaw/@{{openclaw.version}}", + pin: true, + required: true, + }, + ], + ``` + +8. Update docs for user-facing behavior, usually `docs/manage-sandboxes/messaging-channels.mdx`, command references, network policy references, and troubleshooting. + +## Quality Gates + +- Validate the runtime config schema from upstream code. Do not copy another channel's nested config shape blindly without source evidence. +- If `render` enables a plugin entry, confirm the install source exists or document why it is bundled. +- Keep Hermes unsupported when only OpenClaw source support exists, and vice versa. +- Keep channel-specific conditionals out of onboard, rebuild, compiler, applier, and generated-config entrypoints unless the change is a general manifest capability. +- Persist only non-secret state. Plans may contain placeholders, availability flags, and hashes, never raw tokens. +- Mock external APIs in tests. Unit tests must not call real messaging providers. +- Use a skip env var for live reachability hooks when fake credentials are valid for local tests. +- Make policy hostnames exact and scoped to the channel preset. + +## Verification + +Use the narrowest tests that cover the changed behavior: + +```bash +npm run build:cli +npm run typecheck:cli +npx vitest run src/lib/messaging/channels/manifests.test.ts src/lib/messaging/channels/metadata.test.ts src/lib/messaging/compiler/manifest-compiler.test.ts +npx vitest run src/lib/messaging/channels//hooks +npx vitest run test/messaging-build-applier.test.ts +``` + +Add channel-specific config render, hook, policy, and channel add/remove tests when those surfaces change. +Run `npm run docs` for documentation changes and `npx prek run --files ` before handoff. If broad hooks expose unrelated failures, report the failure with the targeted passing evidence. diff --git a/.agents/skills/nemoclaw-skills-guide/SKILL.md b/.agents/skills/nemoclaw-skills-guide/SKILL.md index ea338fe7e5..c264a58148 100644 --- a/.agents/skills/nemoclaw-skills-guide/SKILL.md +++ b/.agents/skills/nemoclaw-skills-guide/SKILL.md @@ -27,10 +27,10 @@ Covers installation, inference configuration, network policy management, monitor For project maintainers. Covers the daily maintainer cadence (morning standup, daytime loop, evening handoff), workflow policy reference, cutting releases, drafting release notes, finding PRs to review, comparing PRs, cross-issue sweeps, triage, normalizing issue and PR title tags, performing security code reviews, and verifying whether stale bug reports still reproduce on the latest release. -### `nemoclaw-contributor-*` (2 skills) +### `nemoclaw-contributor-*` (3 skills) For contributors to the NemoClaw codebase. -Covers creating pull requests that follow the project template and drafting documentation updates from recent commits. +Covers creating pull requests that follow the project template, drafting documentation updates from recent commits, and onboarding new messaging channels. ## Skill Catalog @@ -74,6 +74,7 @@ Covers creating pull requests that follow the project template and drafting docu | Skill | Summary | |-------|---------| | `nemoclaw-contributor-create-pr` | Create GitHub pull requests that follow the NemoClaw PR template, including pre-PR checks, conventional commit titles, and DCO sign-off. | +| `nemoclaw-contributor-onboard-messaging-channel` | Add or review a new messaging channel with manifest-first implementation, upstream source analysis, plugin install confirmation, reachability checks, policies, docs, and tests. | | `nemoclaw-contributor-update-docs` | Scan recent git commits for user-facing changes, draft or update documentation pages, and refresh generated user skills during release prep. | ## Getting Started @@ -89,7 +90,7 @@ Skills are cumulative. Each role includes the skills from the roles above it: | Role | Skills included | Count | Start with | |------|----------------|-------|------------| | User | `nemoclaw-user-*` | 10 | `nemoclaw-user-get-started` | -| Contributor | `nemoclaw-user-*` + `nemoclaw-contributor-*` | 12 | `nemoclaw-user-overview` | -| Maintainer | All skills | 25 | `nemoclaw-maintainer-morning` | +| Contributor | `nemoclaw-user-*` + `nemoclaw-contributor-*` | 13 | `nemoclaw-user-overview` | +| Maintainer | All skills | 26 | `nemoclaw-maintainer-morning` | After identifying the role, present the applicable skills from the Skill Catalog above and recommend the starting skill. diff --git a/AGENTS.md b/AGENTS.md index 1fc7d26750..80254ea35d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,10 @@ This repo ships agent skills under `.agents/skills/`, organized into three audie | `docs/` | MDX/Markdown | User-facing docs (Fern MDX plus legacy MyST source during migration) | | `fern/` | YAML/CSS/SVG | Fern site configuration and shared assets | +Package-specific guides: + +- Messaging architecture and channel migration guidance: [`src/lib/messaging/AGENTS.md`](src/lib/messaging/AGENTS.md) + ## Quick Reference | Task | Command | diff --git a/src/lib/messaging/AGENTS.md b/src/lib/messaging/AGENTS.md new file mode 100644 index 0000000000..cf734c8fa9 --- /dev/null +++ b/src/lib/messaging/AGENTS.md @@ -0,0 +1,83 @@ + + + +# Agent Instructions for `src/lib/messaging` + +## 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. + +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. + +## Data Flow + +1. Channel manifests live in `channels//manifest.ts` and are registered by `channels/built-ins.ts`. +2. `MessagingWorkflowPlanner` selects the right workflow shape for onboard, add, remove, start, stop, or rebuild. +3. `ManifestCompiler` and `compiler/engines/*` compile manifests into a `SandboxMessagingPlan`. +4. `MessagingSetupApplier` serializes the plan through `NEMOCLAW_MESSAGING_PLAN_B64`. +5. `onboard/dockerfile-patch.ts` bakes the plan into the sandbox build. +6. `applier/build/messaging-build-applier.mts` applies agent install, render, post-agent-install build files, and writes the reduced runtime plan artifact. +7. `MessagingHostStateApplier` persists durable plan state under the sandbox registry entry. +8. Rebuild reads the persisted plan, stages a fresh build plan, and reapplies OpenClaw render/post-install hooks after `openclaw doctor` rewrites config. + +## Package Map + +| Path | Ownership | +|---|---| +| `manifest/` | Serializable manifest and plan contracts. Keep these JSON-compatible. | +| `channels/` | Built-in channel manifests, channel metadata helpers, template resolvers, runtime preload assets, and channel hook implementations. | +| `compiler/` | Manifest-to-plan compilation. It may resolve env/config inputs and run enrollment/reachability/build hooks, but should not mutate OpenShell or registry state directly. | +| `hooks/` | Hook contracts, registries, runner validation, common prompt/static-output helpers, and conflict error types. | +| `applier/` | Host/OpenShell side effects: plan env serialization, provider upsert/reuse, policy apply, agent config writes, hook phase execution, conflict detection, registry persistence, and build-time applier. | +| `persistence.ts` | Compact persisted plan shape and hydration from current manifests. | +| `plan-validation.ts` | Defensive parsing for persisted or env-provided plans. | +| `diagnostics.ts` | Manifest-derived channel diagnostics used by status/doctor paths. | +| `utils.ts` | Agent/channel availability and selection helpers. | + +## Core Invariants + +- Manifests and compiled plans are serializable data. Do not put functions, classes, live clients, or raw secret values in them. +- Secret inputs must not declare `statePath`; persisted plans may contain `credentialAvailable`, `credentialHash`, and placeholders, never tokens. +- Hook implementations are resolved by stable handler IDs through `MessagingHookRegistry`. Manifests reference handlers by string; they do not import handler code. +- Hook outputs must match manifest declarations and be JSON-serializable. Add outputs to the manifest before consuming them. +- Channel render/build-file targets must stay inside `/sandbox/.openclaw` or `/sandbox/.hermes`; rely on existing applier validation instead of bypassing it. +- Disabled channels are not active. Always filter effects through `enabledPlanChannels()` or `filterEnabledPlanEntries()` when applying providers, policies, render, hooks, runtime setup, or conflicts. +- Conflict detection has two axes: generic credential-hash overlap in `applier/conflict-detection/` and channel-owned `pre-enable` hooks such as Slack Socket Mode gateway checks. +- Keep transitional compatibility tables derived from manifests. `src/lib/sandbox/channels.ts` intentionally builds legacy CLI metadata from `listBuiltInMessagingChannelManifests()`. + +## Adding or Changing a Channel + +Start with `channels//manifest.ts`. + +1. Declare `auth`, `inputs`, `credentials`, `policyPresets`, `render`, `runtime`, `agentPackages`, `state`, and `hooks` in the manifest. +2. Add template placeholders to `channels//template-resolver.ts` when static render data needs derived values such as allowlists, booleans, proxy URLs, or Hermes/OpenClaw schema differences. +3. Add hook implementations under `channels//hooks/` only for side effects or checks that cannot be represented as static manifest data. +4. Register hook handlers in the channel `hooks/index.ts` and in `hooks/builtins.ts`. +5. Add runtime preload assets under `channels//runtime/` only when the agent runtime needs boot/connect-time shims or diagnostics. +6. Add or update `nemoclaw-blueprint/policies/presets/.yaml` when the manifest declares a channel policy preset. +7. Cover the behavior with manifest/compiler tests plus applier/onboard/channel CLI tests when host effects change. + +## Where Changes Belong + +- New prompt, token, allowlist, provider, policy, render, package install, runtime setup, state hydration, or health-check metadata belongs in a channel manifest. +- Nontrivial render derivation belongs in a channel template resolver. +- Enrollment, external reachability checks, QR capture, channel-specific conflict checks, runtime status, and health probes belong in hooks. +- Provider creation/reuse, policy application, config-file writes, plan env encoding, and registry persistence belong in `applier/`. +- Onboard and `actions/sandbox/policy-channel.ts` should orchestrate planner/applier calls, not grow channel-specific rules. +- Build-time config generation should use the compiled plan and `applier/build/messaging-build-applier.mts`; do not reintroduce channel-specific config rendering in `scripts/generate-openclaw-config.mts` or `agents/hermes/generate-config.ts`. + +## Testing Guide + +Use the narrowest test that covers the changed surface: + +- Manifest shape and plan compilation: `npx vitest run src/lib/messaging/compiler src/lib/messaging/manifest src/lib/messaging/channels` +- Hook behavior: `npx vitest run src/lib/messaging/hooks src/lib/messaging/channels//hooks` +- Host/OpenShell application: `npx vitest run src/lib/messaging/applier` +- 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. + +## Documentation + +User-facing behavior changes usually need docs under `docs/manage-sandboxes/messaging-channels.mdx` or `docs/reference/commands.mdx`. Do not edit generated user skills under `.agents/skills/nemoclaw-user-*/` for normal docs changes. From cd4c69121ecdf0aa7492c6ac8199741b16aadde3 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 16 Jun 2026 15:13:25 +0700 Subject: [PATCH 02/11] docs(skills): address messaging skill review Signed-off-by: San Dang --- .../nemoclaw-contributor-onboard-messaging-channel/SKILL.md | 4 +++- .agents/skills/nemoclaw-skills-guide/SKILL.md | 2 ++ .markdownlint-cli2.yaml | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md b/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md index df8bc80efa..3dd3d8b886 100644 --- a/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md +++ b/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md @@ -1,3 +1,5 @@ + + --- name: nemoclaw-contributor-onboard-messaging-channel description: Guide NemoClaw contributors through adding or reviewing a new messaging channel in the manifest-first messaging architecture. Use when onboarding a channel for OpenClaw, Hermes, or both; mapping upstream channel docs and source code into NemoClaw manifests; confirming credentials, plugin/package installs, reachability checks, network policy presets, docs, and tests. Trigger keywords - add messaging channel, onboard messaging channel, new channel, messaging integration, channel manifest, OpenClaw channel, Hermes channel, plugin install, reachability check. @@ -5,7 +7,7 @@ description: Guide NemoClaw contributors through adding or reviewing a new messa # Onboard Messaging Channel -Use this skill to add a messaging channel end to end without leaking channel-specific logic into core NemoClaw code. +Use this skill to add a messaging channel end-to-end without leaking channel-specific logic into core NemoClaw code. ## Intake diff --git a/.agents/skills/nemoclaw-skills-guide/SKILL.md b/.agents/skills/nemoclaw-skills-guide/SKILL.md index c264a58148..d32e4a29da 100644 --- a/.agents/skills/nemoclaw-skills-guide/SKILL.md +++ b/.agents/skills/nemoclaw-skills-guide/SKILL.md @@ -1,3 +1,5 @@ + + --- name: "nemoclaw-skills-guide" description: "Start here. Introduces what NemoClaw is, what agent skills are available, and which skill to use for a given task. Use when discovering NemoClaw capabilities, choosing the right skill, or orienting in the project. Trigger keywords - skills, capabilities, what can I do, help, guide, index, overview, start here." diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index 4bcf0a209c..56e36db6c3 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -6,6 +6,10 @@ # # All default rules are enabled unless listed below. +# Skills require SPDX comments before YAML frontmatter. Keep the frontmatter +# parser aware of that prefix so Markdown body rules still run normally. +frontMatter: "^(?:\\n\\n\\n?)?---\\n[^]*?\\n---\\n?" + config: default: true From dea51562db8dc75a6395d76a0b6fa96c2e169dc3 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 16 Jun 2026 16:53:38 +0700 Subject: [PATCH 03/11] fix(skills): make frontmatter loadable --- .../nemoclaw-contributor-onboard-messaging-channel/SKILL.md | 2 -- .agents/skills/nemoclaw-skills-guide/SKILL.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md b/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md index 3dd3d8b886..ce5458b996 100644 --- a/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md +++ b/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md @@ -1,5 +1,3 @@ - - --- name: nemoclaw-contributor-onboard-messaging-channel description: Guide NemoClaw contributors through adding or reviewing a new messaging channel in the manifest-first messaging architecture. Use when onboarding a channel for OpenClaw, Hermes, or both; mapping upstream channel docs and source code into NemoClaw manifests; confirming credentials, plugin/package installs, reachability checks, network policy presets, docs, and tests. Trigger keywords - add messaging channel, onboard messaging channel, new channel, messaging integration, channel manifest, OpenClaw channel, Hermes channel, plugin install, reachability check. diff --git a/.agents/skills/nemoclaw-skills-guide/SKILL.md b/.agents/skills/nemoclaw-skills-guide/SKILL.md index d32e4a29da..c264a58148 100644 --- a/.agents/skills/nemoclaw-skills-guide/SKILL.md +++ b/.agents/skills/nemoclaw-skills-guide/SKILL.md @@ -1,5 +1,3 @@ - - --- name: "nemoclaw-skills-guide" description: "Start here. Introduces what NemoClaw is, what agent skills are available, and which skill to use for a given task. Use when discovering NemoClaw capabilities, choosing the right skill, or orienting in the project. Trigger keywords - skills, capabilities, what can I do, help, guide, index, overview, start here." From 9ecbfd49b3f0738743a04e07ca601b8e33179471 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 22 Jun 2026 11:04:40 +0700 Subject: [PATCH 04/11] feat(messaging): onboard Microsoft Teams channel --- agents/hermes/Dockerfile | 10 +- agents/hermes/Dockerfile.base | 2 + agents/hermes/manifest.yaml | 2 + agents/hermes/policy-additions.yaml | 83 +++++ agents/openclaw/manifest.yaml | 1 + docs/_components/StarterPromptButton.tsx | 8 +- docs/about/overview.mdx | 2 +- docs/deployment/brev-web-ui.mdx | 2 +- docs/deployment/deploy-to-remote-gpu.mdx | 2 +- docs/get-started/quickstart.mdx | 4 +- docs/manage-sandboxes/lifecycle.mdx | 2 +- docs/manage-sandboxes/messaging-channels.mdx | 70 +++- docs/manage-sandboxes/runtime-controls.mdx | 4 +- .../customize-network-policy.mdx | 1 + .../integration-policy-examples.mdx | 25 +- docs/reference/architecture.mdx | 2 +- docs/reference/commands-nemohermes.mdx | 23 +- docs/reference/commands.mdx | 31 +- docs/reference/network-policies.mdx | 6 +- docs/reference/troubleshooting.mdx | 12 +- docs/security/best-practices.mdx | 1 + .../policies/presets/teams.yaml | 94 ++++++ nemoclaw-blueprint/policies/tiers.yaml | 1 + .../actions/sandbox/channel-status.test.ts | 2 +- .../sandbox/policy-channel-conflict.test.ts | 2 +- src/lib/actions/sandbox/policy-channel.ts | 12 +- src/lib/actions/sandbox/process-recovery.ts | 37 +++ src/lib/actions/sandbox/rebuild.ts | 7 +- src/lib/agent/defs.test.ts | 2 + src/lib/inventory/index.test.ts | 35 ++ src/lib/inventory/index.ts | 9 +- src/lib/messaging-channel-config.test.ts | 25 ++ src/lib/messaging/AGENTS.md | 4 +- .../applier/build/messaging-build-applier.mts | 86 +++++ src/lib/messaging/applier/setup-applier.ts | 16 + src/lib/messaging/channels/built-ins.ts | 3 + src/lib/messaging/channels/manifests.test.ts | 164 +++++++++- src/lib/messaging/channels/metadata.test.ts | 35 +- src/lib/messaging/channels/slack/manifest.ts | 2 - .../hooks/host-forward-port-conflict.test.ts | 231 ++++++++++++++ .../teams/hooks/host-forward-port-conflict.ts | 247 +++++++++++++++ .../messaging/channels/teams/hooks/index.ts | 36 +++ src/lib/messaging/channels/teams/manifest.ts | 298 ++++++++++++++++++ .../channels/teams/template-resolver.ts | 66 ++++ .../messaging/channels/template-resolver.ts | 2 + .../compiler/engines/host-forward-engine.ts | 43 +++ .../compiler/manifest-compiler.test.ts | 235 +++++++++++++- .../messaging/compiler/manifest-compiler.ts | 8 + .../compiler/workflow-planner.test.ts | 79 +++++ .../messaging/compiler/workflow-planner.ts | 32 +- src/lib/messaging/diagnostics.test.ts | 5 + src/lib/messaging/hooks/builtins.ts | 3 + .../messaging/hooks/common/config-prompt.ts | 4 - src/lib/messaging/hooks/hook-runner.test.ts | 2 + src/lib/messaging/host-forward.ts | 16 + src/lib/messaging/index.ts | 1 + src/lib/messaging/manifest/types.ts | 19 +- src/lib/messaging/persistence.ts | 13 +- src/lib/messaging/plan-validation.test.ts | 50 +++ src/lib/messaging/plan-validation.ts | 13 + .../onboard/agent-dashboard-forward.test.ts | 35 ++ src/lib/onboard/agent-dashboard-forward.ts | 12 +- src/lib/onboard/dashboard.ts | 25 +- src/lib/onboard/initial-policy.test.ts | 2 + .../onboard/messaging-host-forward.test.ts | 207 ++++++++++++ src/lib/onboard/messaging-host-forward.ts | 132 ++++++++ src/lib/onboard/messaging-prep.test.ts | 1 + src/lib/sandbox/channels.test.ts | 39 ++- src/lib/status-command-deps.ts | 1 + src/lib/tunnel/services.ts | 2 +- test/channels-add-preset.test.ts | 2 +- test/messaging-build-applier.test.ts | 91 ++++++ test/messaging-plan-test-helper.ts | 9 + test/policies-teams.test.ts | 133 ++++++++ test/policies.test.ts | 4 +- test/sandbox-provider-cleanup.test.ts | 1 + 76 files changed, 2818 insertions(+), 110 deletions(-) create mode 100644 nemoclaw-blueprint/policies/presets/teams.yaml create mode 100644 src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.test.ts create mode 100644 src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts create mode 100644 src/lib/messaging/channels/teams/hooks/index.ts create mode 100644 src/lib/messaging/channels/teams/manifest.ts create mode 100644 src/lib/messaging/channels/teams/template-resolver.ts create mode 100644 src/lib/messaging/compiler/engines/host-forward-engine.ts create mode 100644 src/lib/messaging/host-forward.ts create mode 100644 src/lib/onboard/agent-dashboard-forward.test.ts create mode 100644 src/lib/onboard/messaging-host-forward.test.ts create mode 100644 src/lib/onboard/messaging-host-forward.ts create mode 100644 test/policies-teams.test.ts 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..3f13baed6f 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -255,6 +255,89 @@ 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: "/**" } + - 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: "/**" } + - allow: { method: POST, path: "/**" } + - allow: { method: PATCH, path: "/**" } + - allow: { method: PUT, path: "/**" } + - allow: { method: DELETE, 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/docs/_components/StarterPromptButton.tsx b/docs/_components/StarterPromptButton.tsx index a7867e5aa3..b375e9e5f8 100644 --- a/docs/_components/StarterPromptButton.tsx +++ b/docs/_components/StarterPromptButton.tsx @@ -111,7 +111,7 @@ If non-interactive mode cannot cover a later prompt, stop before running the int Non-interactive onboarding can skip the interactive messaging-channel picker. After the sandbox is created, ask whether I want to set up messaging as a separate one-question selection. -- First ask: "Do you want to set up a messaging channel now?" with choices: No, Telegram, Discord, Slack, WhatsApp, WeChat (experimental). +- First ask: "Do you want to set up a messaging channel now?" with choices: No, Telegram, Discord, Slack, Microsoft Teams (experimental), WhatsApp, WeChat (experimental). - Configure one channel at a time. If I want another channel, ask again after the current channel finishes. - Run channel commands from the host with \`nemoclaw channels add \`, not from inside the sandbox. - Use \`nemoclaw channels list\` if you need to confirm supported channel names. @@ -125,6 +125,7 @@ Channel credential requirements: | Telegram | \`TELEGRAM_BOT_TOKEN\`; optional \`TELEGRAM_ALLOWED_IDS\`, \`TELEGRAM_REQUIRE_MENTION\`, \`TELEGRAM_GROUP_POLICY\` (OpenClaw only) | | Discord | \`DISCORD_BOT_TOKEN\`; optional \`DISCORD_SERVER_ID\`, \`DISCORD_USER_ID\`, \`DISCORD_REQUIRE_MENTION\` | | Slack | \`SLACK_BOT_TOKEN\`, \`SLACK_APP_TOKEN\`; optional \`SLACK_ALLOWED_USERS\`, \`SLACK_ALLOWED_CHANNELS\` | +| Microsoft Teams | \`MSTEAMS_APP_ID\`, \`MSTEAMS_APP_PASSWORD\`, \`MSTEAMS_TENANT_ID\`; optional \`TEAMS_ALLOWED_USERS\`, \`MSTEAMS_PORT\`; OpenClaw-only \`TEAMS_REQUIRE_MENTION\` | | WhatsApp | No host token; add the channel, rebuild, then complete QR pairing inside the sandbox as documented | | WeChat | Interactive QR scan only; do not use non-interactive mode for WeChat | @@ -145,6 +146,11 @@ NEMOCLAW_NON_INTERACTIVE=1 SLACK_BOT_TOKEN= SLACK_APP_TOKEN= rebuild \`\`\` +\`\`\`shell +NEMOCLAW_NON_INTERACTIVE=1 MSTEAMS_APP_ID= MSTEAMS_APP_PASSWORD= MSTEAMS_TENANT_ID= nemoclaw channels add teams +nemoclaw rebuild +\`\`\` + Use the official NemoClaw Markdown documentation as the source of truth. Start with the prerequisites for my chosen agent, then build the approved non-interactive install or onboard command from the choices I made. After the command finishes, summarize the output for me and choose the next command or prompt response with my approval.`; let resetCopyButtonTimer: ReturnType | null = null; diff --git a/docs/about/overview.mdx b/docs/about/overview.mdx index 689849cde1..9a7842f105 100644 --- a/docs/about/overview.mdx +++ b/docs/about/overview.mdx @@ -38,7 +38,7 @@ NemoClaw provides the following product capabilities. | Agent skills | Packages NemoClaw documentation as user skills so AI coding assistants can guide setup, inference configuration, policy management, monitoring, deployment, security review, and troubleshooting. | | Hardened blueprint | A security-first Dockerfile with capability drops, least-privilege network rules, and declarative policy. | | State management | Safe migration of agent state across machines with credential stripping and integrity verification. | -| Messaging channels | OpenShell-managed processes connect Telegram, Discord, Slack, and similar platforms to the sandboxed agent. NemoClaw configures channels during onboarding; OpenShell supplies the native constructs, credential flow, and runtime supervision. | +| Messaging channels | OpenShell-managed processes connect supported chat platforms such as Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams to the sandboxed agent. NemoClaw configures channels during onboarding; OpenShell supplies the native constructs, credential flow, and runtime supervision. | | Routed inference | Provider-routed model calls through the OpenShell gateway, transparent to the agent. Supports NVIDIA Endpoints, OpenAI, Anthropic, Google Gemini, compatible endpoints, local Ollama, local vLLM, and the Model Router. | | Layered protection | Network, filesystem, process, and inference controls that can be hot-reloaded or locked at creation. | diff --git a/docs/deployment/brev-web-ui.mdx b/docs/deployment/brev-web-ui.mdx index b38a8a5a2c..7de6c344e5 100644 --- a/docs/deployment/brev-web-ui.mdx +++ b/docs/deployment/brev-web-ui.mdx @@ -157,7 +157,7 @@ Check the Brev UI for the current hourly price before leaving the instance runni After your agent is running, explore these related tasks: -- [Set Up Messaging Channels](../manage-sandboxes/messaging-channels) to learn how to connect Telegram, Slack, or Discord. +- [Set Up Messaging Channels](../manage-sandboxes/messaging-channels) to learn how to connect supported messaging channels. - [Switch Inference Providers](../inference/switch-inference-providers) to learn how to change the model provider after setup. - [Monitor Sandbox Activity](../monitoring/monitor-sandbox-activity) to learn how to inspect sandbox health and logs. - [Deploy to a Remote GPU Instance](deploy-to-remote-gpu) to learn how to deploy NemoClaw to a remote GPU instance using the CLI. diff --git a/docs/deployment/deploy-to-remote-gpu.mdx b/docs/deployment/deploy-to-remote-gpu.mdx index 8b828f0e37..37d1f3973d 100644 --- a/docs/deployment/deploy-to-remote-gpu.mdx +++ b/docs/deployment/deploy-to-remote-gpu.mdx @@ -186,6 +186,6 @@ nemoclaw deploy ## Related Topics -- [Set Up Messaging Channels](../manage-sandboxes/messaging-channels) to connect Telegram, Discord, or Slack through OpenShell-managed channel messaging. +- [Set Up Messaging Channels](../manage-sandboxes/messaging-channels) to connect supported messaging channels through OpenShell-managed channel messaging. - [Monitor Sandbox Activity](../monitoring/monitor-sandbox-activity) for sandbox monitoring tools. - [`nemoclaw deploy`](../reference/commands#nemoclaw-deploy) for the full `deploy` command reference. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index f291d6b9e0..dc489e8ecf 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -175,12 +175,12 @@ After you confirm the summary, NemoClaw registers the selected provider with the The wizard then asks whether to enable Brave Web Search. If you enable it, enter a Brave Search API key when prompted. -The wizard also offers messaging channels such as Telegram, Discord, Slack, WeChat, and WhatsApp. +The wizard also offers messaging channels such as Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams. Press a channel number to toggle it, then press Enter to continue. If you leave all channels unselected, pressing Enter skips messaging setup. If you select a channel, NemoClaw validates the token format before it bakes the channel configuration into the sandbox. For example, Slack bot tokens must start with `xoxb-`. -WeChat and WhatsApp are experimental. +WeChat, WhatsApp, and Microsoft Teams are experimental. Review [Messaging Channels](../manage-sandboxes/messaging-channels) before enabling them. ### Choose Network Policy Presets diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index 90b021d704..7351f7579c 100644 --- a/docs/manage-sandboxes/lifecycle.mdx +++ b/docs/manage-sandboxes/lifecycle.mdx @@ -282,7 +282,7 @@ For a full comparison of the two forms, including what they fetch, what they tru ## Related Topics -- [Set Up Messaging Channels](messaging-channels) to connect Telegram, Discord, or Slack. +- [Set Up Messaging Channels](messaging-channels) to connect supported messaging channels. - [Workspace Files](workspace-files) for persistent OpenClaw files inside the sandbox. - [Backup and Restore](backup-restore) for snapshot and restore workflows. - [Monitor Sandbox Activity](../monitoring/monitor-sandbox-activity) for observability tools. diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index 7ab86d9713..f66c72da5e 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -3,9 +3,9 @@ # SPDX-License-Identifier: Apache-2.0 title: "Messaging Channels" sidebar-title: "Set Up Messaging Channels" -description: "Connect Telegram, Discord, Slack, WeChat, or WhatsApp to your sandboxed OpenClaw or Hermes agent using OpenShell-managed channel messaging." -description-agent: "Explains how Telegram, Discord, Slack, WeChat, and WhatsApp reach sandboxed OpenClaw and Hermes agents through OpenShell-managed processes and NemoClaw channel commands. Use when setting up messaging channels, chat interfaces, or integrations without relying on nemoclaw tunnel start for bridges." -keywords: ["nemoclaw messaging channels", "nemoclaw telegram", "nemoclaw discord", "nemoclaw slack", "nemoclaw wechat", "nemoclaw whatsapp", "openshell channel messaging"] +description: "Connect Telegram, Discord, Slack, WeChat, WhatsApp, or Microsoft Teams to your sandboxed OpenClaw or Hermes agent using OpenShell-managed channel messaging." +description-agent: "Explains how Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams reach sandboxed OpenClaw and Hermes agents through OpenShell-managed processes and NemoClaw channel commands. Use when setting up messaging channels, chat interfaces, or integrations without relying on nemoclaw tunnel start for bridges." +keywords: ["nemoclaw messaging channels", "nemoclaw telegram", "nemoclaw discord", "nemoclaw slack", "nemoclaw wechat", "nemoclaw whatsapp", "nemoclaw teams", "openshell channel messaging"] content: type: "how_to" skill: @@ -13,15 +13,17 @@ skill: --- import { AgentOnly } from "../_components/AgentGuide"; -Telegram, Discord, Slack, WeChat, and WhatsApp reach your OpenClaw or Hermes agent through OpenShell-managed processes and gateway constructs. +Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams reach your OpenClaw or Hermes agent through OpenShell-managed processes and gateway constructs. For token-based channels, NemoClaw registers credentials with OpenShell providers. WeChat captures a token through a host-side QR scan during onboarding. WhatsApp pairs inside the sandbox through a QR scan and intentionally stores mutable session state there. +Microsoft Teams uses a Bot Framework webhook at `/api/messages` and stores the app client secret in an OpenShell provider. NemoClaw bakes the selected channel configuration into the sandbox image and keeps runtime delivery under OpenShell control. -WeChat and WhatsApp are experimental. -Both rely on QR-based pairing flows that are more fragile than token-based bots, and the upstream client libraries can change behavior without notice. +WeChat, WhatsApp, and Microsoft Teams are experimental. +WeChat and WhatsApp rely on QR-based pairing flows that are more fragile than token-based bots. +Microsoft Teams depends on public webhook reachability and upstream Bot Framework behavior. Interfaces, defaults, and supported features can change, and NVIDIA does not recommend these channels for production use. @@ -47,7 +49,8 @@ For details, refer to [Commands](../reference/commands). ## Prerequisites - A machine where you can run `$$nemoclaw onboard` (local or remote host that runs the gateway and sandbox). -- A token for each token-based messaging platform you want to enable, a personal WeChat account on your phone for the host-side QR scan during onboarding, or a phone you can use to scan the QR code for WhatsApp pairing. +- A token or client secret for each credential-based messaging platform you want to enable, a personal WeChat account on your phone for the host-side QR scan during onboarding, or a phone you can use to scan the QR code for WhatsApp pairing. +- For Microsoft Teams, a public HTTPS endpoint that forwards to the sandbox's Teams webhook port before installing the Teams app. The default local port is `3978`, and the webhook path is `/api/messages`. - A network policy preset for each enabled channel, or equivalent custom egress rules. ## Channel Requirements @@ -59,6 +62,7 @@ For details, refer to [Commands](../reference/commands). | Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | `SLACK_ALLOWED_USERS` for DM and channel `@mention` user allowlisting, `SLACK_ALLOWED_CHANNELS` for channel ID allowlisting | | WeChat (experimental) | None. Captured through host-side QR scan during `$$nemoclaw onboard` | `WECHAT_ALLOWED_IDS` for DM allowlisting | | WhatsApp (experimental) | None. Pair through QR after rebuild | None | +| Microsoft Teams (experimental) | `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, `MSTEAMS_TENANT_ID` | `TEAMS_ALLOWED_USERS`, `MSTEAMS_PORT`, `TEAMS_REQUIRE_MENTION` | Telegram uses a bot token from [BotFather](https://t.me/BotFather). Open Telegram, send `/newbot` to [@BotFather](https://t.me/BotFather), follow the prompts, and copy the token. @@ -99,6 +103,21 @@ Slack Socket Mode allows one active connection per app-level token. If another sandbox on the same gateway already uses the same Slack app token, onboarding and `channels add slack` warn before continuing in interactive mode and abort in non-interactive mode. Use `--force` only when you intentionally want to move the Slack Socket Mode session to the new sandbox. +Microsoft Teams (experimental) uses the Microsoft Bot Framework webhook flow. +For OpenClaw, NemoClaw installs the upstream `@openclaw/msteams` plugin and renders `channels.msteams` plus `plugins.entries.msteams`. +For Hermes, NemoClaw renders the built-in `platforms.teams` adapter and `.env` entries (`TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_TENANT_ID`, `TEAMS_ALLOWED_USERS`, and `TEAMS_PORT`). +Set `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, and `MSTEAMS_TENANT_ID` from your Teams app registration. +Hermes aliases `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, and `TEAMS_TENANT_ID` are also accepted when scripting setup. +NemoClaw stores only `MSTEAMS_APP_PASSWORD` in the `-teams-bridge` OpenShell provider; app ID, tenant ID, webhook port, mention mode, and allowlist values are non-secret build configuration. +`TEAMS_ALLOWED_USERS` is a comma-separated list of Azure AD object IDs. +Set it for Hermes and OpenClaw direct messages so only authorized users can use those paths. +OpenClaw group chats and channels are rendered with `groupPolicy: "open"` and remain mention-gated by default. +`MSTEAMS_PORT` defaults to `3978`; `TEAMS_PORT` is accepted as a compatibility alias and is the variable rendered into Hermes `.env`. +`TEAMS_REQUIRE_MENTION` controls OpenClaw group and channel behavior only; direct messages are unaffected, and Hermes does not render this setting. +When the Teams channel is active, NemoClaw starts the local OpenShell port forward for `MSTEAMS_PORT` after onboarding, rebuild, or process recovery. +No two active Teams channels can use the same local webhook port; set a different `MSTEAMS_PORT` or stop/remove the other sandbox first. +Your public HTTPS endpoint still needs to forward to `/api/messages` on that host port. + WeChat (experimental) delivers messages over Tencent's iLink gateway through the upstream `@tencent-weixin/openclaw-weixin` plugin installed into WeChat-enabled OpenClaw sandbox images and the built-in Hermes iLink WeChat adapter. The supported mode in this release is **personal WeChat** (`bot_type=3`). WeChat Official Account and WeCom/Enterprise WeChat are not wired up. @@ -135,7 +154,7 @@ Pair only one sandbox per WhatsApp account at a time. ## Enable Channels During Onboarding -When the wizard reaches **Messaging channels**, it lists Telegram, Discord, Slack, WeChat, and WhatsApp. +When the wizard reaches **Messaging channels**, it lists Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams. Press a channel number to toggle it on or off, then press **Enter** when done. If you select no channels, pressing **Enter** skips messaging setup. If a token-based channel token is not already in the environment or credential store, the wizard prompts for it and saves it. @@ -148,6 +167,8 @@ Keep the terminal in the foreground until you see `✓ WeChat login confirmed`. WhatsApp (experimental) uses QR pairing instead of a host-side token, so the wizard does not prompt. It prints pairing instructions and you complete the pairing inside the sandbox after rebuild. NemoClaw also selects the matching network policy preset during policy setup so the channel can reach its provider API. +Microsoft Teams prompts for the client secret plus app ID, tenant ID, optional Azure AD object ID allowlist, webhook port, and OpenClaw mention mode. +Make sure the public webhook endpoint is ready before installing the Teams app. For scripted setup, export the credentials and optional settings for the channels you want to enable before you run onboarding: @@ -160,6 +181,10 @@ export SLACK_BOT_TOKEN= export SLACK_APP_TOKEN= export SLACK_ALLOWED_USERS= export SLACK_ALLOWED_CHANNELS= +export MSTEAMS_APP_ID= +export MSTEAMS_APP_PASSWORD= +export MSTEAMS_TENANT_ID= +export TEAMS_ALLOWED_USERS= ``` This release does not support non-interactive WeChat configuration because the iLink QR handshake requires a human to scan the QR on a paired phone. @@ -190,10 +215,11 @@ $$nemoclaw my-assistant channels add discord $$nemoclaw my-assistant channels add slack $$nemoclaw my-assistant channels add wechat $$nemoclaw my-assistant channels add whatsapp +$$nemoclaw my-assistant channels add teams ``` `channels add` collects whatever each channel needs. -It prompts for Telegram, Discord, and Slack tokens, runs an interactive host-side QR scan for WeChat, and collects nothing for WhatsApp because pairing happens in-sandbox after rebuild. +It prompts for Telegram, Discord, and Slack tokens, prompts for Microsoft Teams Bot Framework credentials and config, runs an interactive host-side QR scan for WeChat, and collects nothing for WhatsApp because pairing happens in-sandbox after rebuild. It registers bridge providers with the OpenShell gateway when it captures tokens, records the channel in the sandbox registry, and asks whether to rebuild immediately. The command accepts mixed-case input such as `Telegram`, then stores and prints the canonical lowercase channel name. `channels add` requires the matching built-in network policy preset YAML to be present. @@ -205,8 +231,8 @@ It flags `gateway-providers` as residual because the in-flight upsert can leave Verify the gateway bridge before relying on the channel. Restore the preset YAML and re-run `$$nemoclaw channels add `. Choose the rebuild so the running sandbox image picks up the new channel. -For Telegram, Discord, and Slack, `channels add` also checks the rebuilt runtime for the selected bridge and reports startup, credential, or missing-plugin warnings before returning. -If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `TELEGRAM_GROUP_POLICY`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, or `SLACK_ALLOWED_CHANNELS`, export them before the rebuild starts. +For Telegram, Discord, Slack, and Teams, `channels add` also checks the rebuilt runtime for the selected bridge and reports startup, credential, or missing-plugin warnings before returning. +If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `TELEGRAM_GROUP_POLICY`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, `SLACK_ALLOWED_CHANNELS`, `TEAMS_ALLOWED_USERS`, `MSTEAMS_PORT`, or `TEAMS_REQUIRE_MENTION`, export them before the rebuild starts. You can omit `TELEGRAM_REQUIRE_MENTION` and `DISCORD_REQUIRE_MENTION` when you want the default mention-only mode. You can omit `TELEGRAM_GROUP_POLICY` when you want OpenClaw Telegram group access to stay open. Telegram Bot API `sendMessage` calls prove outbound delivery from the bot; to test inbound agent replies, send a message from the Telegram client as an allowed user. @@ -220,6 +246,7 @@ $$nemoclaw my-assistant rebuild In non-interactive mode, set the required environment variables before running `channels add`. Optional mention-mode settings that declare defaults are still written when unset. Telegram mention mode defaults to `1`; Discord mention mode defaults to `1` when `DISCORD_SERVER_ID` is set. +Teams webhook port defaults to `MSTEAMS_PORT=3978`, and OpenClaw Teams mention mode defaults to `TEAMS_REQUIRE_MENTION=1`. Missing credentials fail fast, and the command queues the change for a manual rebuild: ```bash @@ -237,6 +264,16 @@ DISCORD_BOT_TOKEN= \ $$nemoclaw my-assistant channels add discord ``` +For Microsoft Teams, include the Bot Framework app settings and make sure the public webhook endpoint is forwarding to `/api/messages`: + +```bash +MSTEAMS_APP_ID= \ + MSTEAMS_APP_PASSWORD= \ + MSTEAMS_TENANT_ID= \ + TEAMS_ALLOWED_USERS= \ + $$nemoclaw my-assistant channels add teams +``` + ### `channels add wechat` `channels add wechat` (experimental) follows the same shape as the other channels with two differences driven by the iLink QR handshake. @@ -269,6 +306,7 @@ To remove a channel and clear its stored credentials, run: ```bash $$nemoclaw my-assistant channels remove telegram $$nemoclaw my-assistant channels remove wechat +$$nemoclaw my-assistant channels remove teams ``` `channels remove wechat` clears the bot token, deletes the `-wechat-bridge` OpenShell provider, and drops `wechat` from the sandbox's enabled-channel set. @@ -293,6 +331,9 @@ $$nemoclaw my-assistant channels start telegram $$nemoclaw my-assistant channels stop wechat $$nemoclaw my-assistant channels start wechat + +$$nemoclaw my-assistant channels stop teams +$$nemoclaw my-assistant channels start teams ``` @@ -304,13 +345,14 @@ For WeChat specifically, `channels stop wechat` followed by a rebuild keeps the A subsequent `channels start wechat` plus rebuild revives the bridge against the same iLink account without a fresh QR scan. The bot token is held by the OpenShell provider across the stop/start cycle. -Telegram, Discord, Slack, and WeChat each allow only one active consumer per channel credential. +Telegram, Discord, Slack, Teams, and WeChat each allow only one active consumer per channel credential. Multiple sandboxes can use the same channel type at the same time when each sandbox uses a distinct bot/app token (or a distinct WeChat iLink bot account). For example, two Telegram sandboxes can DM the same `TELEGRAM_ALLOWED_IDS` account as long as they use different `TELEGRAM_BOT_TOKEN` values. For WeChat, each sandbox must own a distinct iLink `accountId` (bot identity). Running two sandboxes against the same WeChat account causes one of them to lose messages. If you enable a messaging channel and another sandbox already uses the same token, onboarding prompts you to confirm before continuing in interactive mode and exits non-zero in non-interactive mode. For Slack, NemoClaw checks both the bot token and the Socket Mode app token so duplicate Socket Mode sessions do not compete silently. +For Teams, NemoClaw checks the app client secret provider hash. If NemoClaw only has legacy channel metadata and cannot compare credential hashes, it keeps the conservative warning. Re-run `channels add ` with the intended token to refresh the stored non-secret hash. `$$nemoclaw status` reports cross-sandbox overlaps so you can resolve duplicates before messages start dropping. @@ -324,13 +366,13 @@ Use `$$nemoclaw tunnel stop` or its deprecated alias `$$nemoclaw stop` when you Use `$$nemoclaw tunnel stop` when you want to stop host auxiliary services and also ask NemoClaw to stop the Hermes gateway inside the selected sandbox. -Stopping the in-sandbox gateway stops Telegram, Discord, Slack, WeChat, and WhatsApp polling for that sandbox until you restart the sandbox or gateway. +Stopping the in-sandbox gateway stops Telegram, Discord, Slack, WeChat, WhatsApp, and Teams polling or webhook handling for that sandbox until you restart the sandbox or gateway. ## Confirm Delivery After the sandbox is running, send a message to the configured bot or app. If delivery fails, use `openshell term` on the host, check gateway logs, and verify network policy allows the channel API. -Use the matching policy preset (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`) or review [Common Integration Policy Examples](../network-policy/integration-policy-examples). +Use the matching policy preset (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`) or review [Common Integration Policy Examples](../network-policy/integration-policy-examples). ## Tunnel Command diff --git a/docs/manage-sandboxes/runtime-controls.mdx b/docs/manage-sandboxes/runtime-controls.mdx index a50ccdfd83..df2444b838 100644 --- a/docs/manage-sandboxes/runtime-controls.mdx +++ b/docs/manage-sandboxes/runtime-controls.mdx @@ -29,7 +29,7 @@ The table below maps each commonly changed item to the layer that owns it and th | Sub-agent (Hermes / OpenClaw / …) | Re-onboard required (the sub-agent and its workspace are baked at onboard) | `$$nemoclaw onboard --recreate-sandbox` | | Network policy preset (slack, discord, telegram, brave, …) | Runtime. Applies on the next request; rebuild only required if the preset adds bind-mounted secrets | `$$nemoclaw policy-add ` / `policy-remove ` | | Network allow-list (custom hosts) | Runtime. Picks up at next request | `openshell policy set` or interactive approval prompt at the gateway | -| Channel tokens (Slack / Discord / Telegram bot credentials) | Rebuild required (tokens are baked into the sandbox image at onboard so they never leave the host clear-text) | `$$nemoclaw channels add ` then accept the rebuild prompt | +| Channel credentials (Slack, Discord, Telegram, Teams, and other supported channels) | Rebuild required (channel configuration is baked into the sandbox image; secrets stay in OpenShell providers or host-side credential storage) | `$$nemoclaw channels add ` then accept the rebuild prompt | | Channel enable/disable (turn a configured channel off without removing the token) | Rebuild required (`openclaw.json` is the source of truth at runtime, see #3453) | `$$nemoclaw channels stop ` then rebuild | | Dashboard forward port | Runtime. Port is re-resolved on next `connect` | `NEMOCLAW_DASHBOARD_PORT= $$nemoclaw connect` | | Dashboard bind address (loopback compared to all interfaces) | Runtime. Applies on next `connect` | `NEMOCLAW_DASHBOARD_BIND=0.0.0.0 $$nemoclaw connect` (see #3259) | @@ -53,7 +53,7 @@ If a row above conflicts with what you observe, the runtime source of truth insi | Agent runtime (Hermes compared to OpenClaw) | Re-onboard required (the agent and its state layout are baked at onboard) | `$$nemoclaw onboard --recreate-sandbox` or `nemoclaw onboard --agent openclaw --recreate-sandbox` | | Network policy preset (slack, discord, telegram, brave, …) | Runtime. Applies on the next request; rebuild only required if the preset adds bind-mounted secrets | `$$nemoclaw policy-add ` / `policy-remove ` | | Network allow-list (custom hosts) | Runtime. Picks up at next request | `openshell policy set` or interactive approval prompt at the gateway | -| Channel tokens (Slack / Discord / Telegram bot credentials) | Rebuild required (tokens are baked into the sandbox image at onboard so they never leave the host clear-text) | `$$nemoclaw channels add ` then accept the rebuild prompt | +| Channel credentials (Slack, Discord, Telegram, Teams, and other supported channels) | Rebuild required (channel configuration is baked into the sandbox image; secrets stay in OpenShell providers or host-side credential storage) | `$$nemoclaw channels add ` then accept the rebuild prompt | | Channel enable/disable (turn a configured channel off without removing the token) | Rebuild required (`/sandbox/.hermes/.env` and Hermes config are baked at image build time) | `$$nemoclaw channels stop ` then rebuild | | API/dashboard forward port | Runtime. Port is re-resolved on next `connect` | `$$nemoclaw connect` or `openshell forward start` | | Filesystem layout (Landlock zones, read-only mounts, container caps) | **Locked at creation**. No runtime change | Re-onboard with `$$nemoclaw onboard --recreate-sandbox` | diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index 6d03d85711..ce9160fa9e 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -203,6 +203,7 @@ Available presets: | `outlook` | Microsoft 365 and Outlook | | `pypi` | Python Package Index | | `slack` | Slack API and webhooks | +| `teams` | Microsoft Teams Bot Framework and Graph API access (experimental) | | `telegram` | Telegram Bot API | | `wechat` | WeChat (personal) iLink Bot API (experimental) | | `whatsapp` | WhatsApp Web messaging (experimental) | diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index 243dd7acac..bf52ea2175 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -63,6 +63,7 @@ NemoClaw ships maintained policy presets for common services in `nemoclaw-bluepr | Public reference APIs | `public-reference` | | Python Package Index | `pypi` | | Slack messaging | `slack` | +| Microsoft Teams messaging (experimental) | `teams` | | Telegram Bot API | `telegram` | | Weather and geocoding APIs | `weather` | | WeChat (personal) iLink Bot API (experimental) | `wechat` | @@ -126,7 +127,7 @@ If delivery fails, open the TUI and send a test message to the bot: openshell term ``` -The matching preset for each supported messaging channel is the channel name (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`). +The matching preset for each supported messaging channel is the channel name (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`). ## Slack or Discord Messaging @@ -160,6 +161,26 @@ $$nemoclaw my-assistant policy-add slack --yes $$nemoclaw my-assistant policy-add discord --yes ``` +## Microsoft Teams Messaging (Experimental) + +Microsoft Teams needs Bot Framework channel configuration, a public HTTPS webhook, and egress policy. +Set the app credentials, add the channel, rebuild, and apply the `teams` preset: + +```bash +export MSTEAMS_APP_ID= +export MSTEAMS_APP_PASSWORD= +export MSTEAMS_TENANT_ID= +export TEAMS_ALLOWED_USERS= +NEMOCLAW_NON_INTERACTIVE=1 $$nemoclaw my-assistant channels add teams +$$nemoclaw my-assistant rebuild +$$nemoclaw my-assistant policy-add teams --yes +``` + +NemoClaw starts the local OpenShell port forward for the active Teams channel on the configured `MSTEAMS_PORT` value, which defaults to `3978`. +No two active Teams sandboxes can share that local webhook port. +The Teams app must point at a public HTTPS endpoint that forwards to `/api/messages` on that host port. +For Hermes sandboxes, NemoClaw renders the Teams adapter env as `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_TENANT_ID`, `TEAMS_ALLOWED_USERS`, and `TEAMS_PORT`. + ## WeChat or WhatsApp Messaging (Experimental) WeChat and WhatsApp are experimental. @@ -382,5 +403,5 @@ Use `$$nemoclaw my-assistant policy-add` for maintained NemoClaw presets. - [Approve or Deny Agent Network Requests](approve-network-requests) for the interactive OpenShell TUI flow. - [Customize the Sandbox Network Policy](customize-network-policy) for static policy edits and raw OpenShell policy files. -- [Messaging Channels](../manage-sandboxes/messaging-channels) for Telegram, Discord, Slack, WeChat, and WhatsApp channel configuration. +- [Messaging Channels](../manage-sandboxes/messaging-channels) for Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams channel configuration. - [Commands](../reference/commands) for the full `policy-add`, `policy-list`, `policy-remove`, and `channels` command reference. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index 380dedd614..b8c4222285 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -45,7 +45,7 @@ graph LR direction TB GW["Gateway
Credential store
Inference proxy
Policy engine
Device auth
"]:::openshell OSCLI["openshell CLI
provider · sandbox
gateway · policy
"]:::openshell - CHMSG["Channel messaging
OpenShell-managed
Telegram · Discord · Slack
"]:::openshell + CHMSG["Channel messaging
OpenShell-managed
Supported messaging channels
"]:::openshell subgraph SANDBOX["Sandbox Container 🔒"] direction TB diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index f047a41b3d..fab3e68fef 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -563,7 +563,7 @@ The Policy section displays the live enforced policy (fetched via `openshell pol When OpenShell reports an active policy version, the displayed YAML `version` line uses that active version instead of the static schema version. If the sandbox is running an outdated agent version, the output includes an `Update` line with the available version and a `nemohermes rebuild` hint. -When other sandboxes have the same messaging channel enabled (Telegram, Discord, or Slack) with the same bot token, the output includes a cross-sandbox overlap warning so you can resolve the conflict before messages start dropping. +When other sandboxes have a token- or secret-backed messaging channel enabled with the same credential, such as Telegram, Discord, Slack, WeChat, or Microsoft Teams, the output includes a cross-sandbox overlap warning so you can resolve the conflict before messages start dropping. The command also tails `/tmp/gateway.log` inside the default sandbox and flags Telegram `409 Conflict` errors that indicate a duplicate consumer for the bot token. ```bash @@ -844,9 +844,9 @@ nemohermes my-assistant hosts-remove searxng.local ### `nemohermes channels list` -List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`) with a short description. +List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, `teams`) with a short description. The command is a static reference; it does not consult credentials or the running sandbox. -WeChat and WhatsApp are experimental. +WeChat, WhatsApp, and Microsoft Teams are experimental. ```bash nemohermes my-assistant channels list @@ -857,7 +857,8 @@ nemohermes my-assistant channels list Register a messaging channel with the sandbox and rebuild so the image picks up the new channel. Channels fall into three login modes: -- **Token paste** (`telegram`, `discord`, `slack`): the command prompts for any missing token and registers it with the OpenShell gateway. +- **Token paste** (`telegram`, `discord`, `slack`, `teams`): the command prompts for any missing token or client secret and registers it with the OpenShell gateway. + Microsoft Teams also records non-secret app ID, tenant ID, allowlist, and webhook port settings for the Bot Framework webhook at `/api/messages`. - **Host-side QR** (`wechat`, experimental): the command renders an iLink QR code on the host and you scan it from WeChat on your phone. On confirm, NemoClaw captures the bot token, registers it with the OpenShell gateway, and stores non-secret per-account metadata (`WECHAT_ACCOUNT_ID`, `WECHAT_BASE_URL`, `WECHAT_USER_ID`) for the in-sandbox bridge. NemoClaw automatically adds the scanning operator's WeChat user ID to `WECHAT_ALLOWED_IDS`. @@ -876,7 +877,7 @@ With the preset file in place, NemoClaw applies it to the sandbox before the reb When the apply step itself fails after the registry write on a fresh add, NemoClaw attempts to roll back the bridge providers, the `messagingChannels` entry, and any staged environment credentials, then exits without prompting for a rebuild; if any gateway-side step (provider detach or delete) fails the rollback continues and prints a `Rollback could not fully clean ` warning so the operator can clean up manually. When the same failure happens on a re-add of an already-enabled channel, NemoClaw restores the prior `messagingChannels` entry, restores staged environment credentials when available, restores registry credential hashes, and attempts to re-upsert the prior bridge providers, but flags `gateway-providers` as residual because the in-flight upsert may have left the gateway with the new token; verify the gateway bridge before relying on the channel. Restore the preset YAML and re-run `nemohermes channels add `. -For Telegram, Discord, and Slack, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings. +For Telegram, Discord, Slack, and Teams, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings. ```bash nemohermes my-assistant channels add telegram @@ -891,6 +892,12 @@ Slack requires both `SLACK_BOT_TOKEN` (bot user OAuth) and `SLACK_APP_TOKEN` (ap Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. Telegram and Discord mention mode default to `1` when no environment, session, or saved state value exists for that setting. Discord applies that default only when a server ID is configured. + +Microsoft Teams requires `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, and `MSTEAMS_TENANT_ID`; Hermes aliases `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, and `TEAMS_TENANT_ID` are also accepted. +`MSTEAMS_PORT` defaults to `3978`; `TEAMS_PORT` is accepted as a compatibility alias and is the variable rendered into Hermes `.env`. +NemoClaw renders Teams into Hermes as `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_TENANT_ID`, `TEAMS_ALLOWED_USERS`, and `TEAMS_PORT`. +`TEAMS_REQUIRE_MENTION` is accepted for OpenClaw sandboxes only and is not rendered into Hermes. +NemoClaw starts the local OpenShell port forward for the active Teams channel on `MSTEAMS_PORT`; no two active Teams sandboxes can share that port, and the Teams app must point at a public HTTPS endpoint that forwards to `/api/messages` on that host port. When `NEMOCLAW_NON_INTERACTIVE=1` is set, any missing token fails fast and no rebuild prompt is shown — instead, the change is queued and you are told to run `nemohermes rebuild` manually. If you omit the required `` argument, the CLI prints the `channels add ` usage with the supported channel list instead of falling back to top-level help. @@ -899,7 +906,7 @@ If you omit the required `` argument, the CLI prints the `channels add Clear the stored credentials for a messaging channel and rebuild the sandbox so the image drops the channel. Running `remove` for a channel that was never configured is a no-op against the credentials file and still triggers the rebuild prompt. When the bridge provider is attached to a live sandbox, NemoClaw detaches it before deleting the provider from the OpenShell gateway. -If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, or `whatsapp`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone. +If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone. NemoClaw also strips the channel from `session.policyPresets` so a subsequent `onboard --resume` does not re-apply the preset on the next rebuild. For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-sandbox session directory before the rebuild so the `state_dirs` backup does not restore the auth blob and let the channel reconnect: @@ -924,7 +931,7 @@ Host-side removal is the supported path because agent channel config is baked in ### `nemohermes channels stop ` -Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`) without clearing its credentials. +Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`) without clearing its credentials. The channel is marked disabled in the per-sandbox registry, and the sandbox is rebuilt so the onboard step skips registering the bridge with the gateway. The provider stays registered with the OpenShell gateway, so a later `channels start` brings the bridge back without re-entering tokens. @@ -1386,7 +1393,7 @@ For a remote Brev instance, SSH to the instance and run `openshell term` there, Start optional host auxiliary services. This is the cloudflared tunnel when `cloudflared` is installed, which exposes the dashboard with a public URL. -Channel messaging (Telegram, Discord, Slack) is not started here; it is configured during `nemohermes onboard` and runs through OpenShell-managed constructs. +Channel messaging is not started here; it is configured during `nemohermes onboard` and runs through OpenShell-managed constructs. ```bash nemohermes tunnel start diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index eba825c231..a7b843abf6 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -696,7 +696,7 @@ The Policy section displays the live enforced policy (fetched via `openshell pol When OpenShell reports an active policy version, the displayed YAML `version` line uses that active version instead of the static schema version. If the sandbox is running an outdated agent version, the output includes an `Update` line with the available version and a `$$nemoclaw rebuild` hint. -When other sandboxes have the same messaging channel enabled (Telegram, Discord, or Slack) with the same bot token, the output includes a cross-sandbox overlap warning so you can resolve the conflict before messages start dropping. +When other sandboxes have a token- or secret-backed messaging channel enabled with the same credential, such as Telegram, Discord, Slack, WeChat, or Microsoft Teams, the output includes a cross-sandbox overlap warning so you can resolve the conflict before messages start dropping. The command also tails `/tmp/gateway.log` inside the default sandbox and flags Telegram `409 Conflict` errors that indicate a duplicate consumer for the bot token. ```bash @@ -1094,9 +1094,9 @@ $$nemoclaw my-assistant hosts-remove searxng.local ### `$$nemoclaw channels list` -List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`) with a short description. +List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, `teams`) with a short description. The command is a static reference; it does not consult credentials or the running sandbox. -WeChat and WhatsApp are experimental. +WeChat, WhatsApp, and Microsoft Teams are experimental. ```bash $$nemoclaw my-assistant channels list @@ -1107,7 +1107,8 @@ $$nemoclaw my-assistant channels list Register a messaging channel with the sandbox and rebuild so the image picks up the new channel. Channels fall into three login modes: -- **Token paste** (`telegram`, `discord`, `slack`): the command prompts for any missing token and registers it with the OpenShell gateway. +- **Token paste** (`telegram`, `discord`, `slack`, `teams`): the command prompts for any missing token or client secret and registers it with the OpenShell gateway. + Microsoft Teams also records non-secret app ID, tenant ID, allowlist, and webhook port settings for the Bot Framework webhook at `/api/messages`. - **Host-side QR** (`wechat`, experimental): the command renders an iLink QR code on the host and you scan it from WeChat on your phone. On confirm, NemoClaw captures the bot token, registers it with the OpenShell gateway, and stores non-secret per-account metadata (`WECHAT_ACCOUNT_ID`, `WECHAT_BASE_URL`, `WECHAT_USER_ID`) for the in-sandbox bridge. NemoClaw automatically adds the scanning operator's WeChat user ID to `WECHAT_ALLOWED_IDS`. @@ -1126,7 +1127,7 @@ With the preset file in place, NemoClaw applies it to the sandbox before the reb When the apply step itself fails after the registry write on a fresh add, NemoClaw attempts to roll back the bridge providers, the `messagingChannels` entry, and any staged environment credentials, then exits without prompting for a rebuild; if any gateway-side step (provider detach or delete) fails the rollback continues and prints a `Rollback could not fully clean ` warning so the operator can clean up manually. When the same failure happens on a re-add of an already-enabled channel, NemoClaw restores the prior `messagingChannels` entry, restores staged environment credentials when available, restores registry credential hashes, and attempts to re-upsert the prior bridge providers, but flags `gateway-providers` as residual because the in-flight upsert may have left the gateway with the new token; verify the gateway bridge before relying on the channel. Restore the preset YAML and re-run `$$nemoclaw channels add `. -For Telegram, Discord, and Slack, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings. +For Telegram, Discord, Slack, and Teams, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings. ```bash $$nemoclaw my-assistant channels add telegram @@ -1141,6 +1142,20 @@ Slack requires both `SLACK_BOT_TOKEN` (bot user OAuth) and `SLACK_APP_TOKEN` (ap Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. Telegram and Discord mention mode default to `1` when no environment, session, or saved state value exists for that setting. Discord applies that default only when a server ID is configured. + + +Microsoft Teams requires `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, and `MSTEAMS_TENANT_ID`; optional Teams settings come from `TEAMS_ALLOWED_USERS`, `MSTEAMS_PORT`, and `TEAMS_REQUIRE_MENTION`. +`MSTEAMS_PORT` defaults to `3978`, and `TEAMS_REQUIRE_MENTION` defaults to `1`. +NemoClaw renders OpenClaw Teams group chats and channels with `groupPolicy: "open"` by default; `TEAMS_ALLOWED_USERS` restricts direct messages, while group and channel replies stay mention-gated unless you set `TEAMS_REQUIRE_MENTION=0`. +NemoClaw starts the local OpenShell port forward for the active Teams channel on `MSTEAMS_PORT`; no two active Teams sandboxes can share that port, and the Teams app must point at a public HTTPS endpoint that forwards to `/api/messages` on that host port. + + +Microsoft Teams requires `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, and `MSTEAMS_TENANT_ID`; Hermes aliases `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, and `TEAMS_TENANT_ID` are also accepted. +`MSTEAMS_PORT` defaults to `3978`; `TEAMS_PORT` is accepted as a compatibility alias and is the variable rendered into Hermes `.env`. +NemoClaw renders Teams into Hermes as `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_TENANT_ID`, `TEAMS_ALLOWED_USERS`, and `TEAMS_PORT`. +`TEAMS_REQUIRE_MENTION` is accepted for OpenClaw sandboxes only and is not rendered into Hermes. +NemoClaw starts the local OpenShell port forward for the active Teams channel on `MSTEAMS_PORT`; no two active Teams sandboxes can share that port, and the Teams app must point at a public HTTPS endpoint that forwards to `/api/messages` on that host port. + When `NEMOCLAW_NON_INTERACTIVE=1` is set, any missing token fails fast and no rebuild prompt is shown — instead, the change is queued and you are told to run `$$nemoclaw rebuild` manually. If you omit the required `` argument, the CLI prints the `channels add ` usage with the supported channel list instead of falling back to top-level help. @@ -1149,7 +1164,7 @@ If you omit the required `` argument, the CLI prints the `channels add Clear the stored credentials for a messaging channel and rebuild the sandbox so the image drops the channel. Running `remove` for a channel that was never configured is a no-op against the credentials file and still triggers the rebuild prompt. When the bridge provider is attached to a live sandbox, NemoClaw detaches it before deleting the provider from the OpenShell gateway. -If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, or `whatsapp`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone. +If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone. NemoClaw also strips the channel from `session.policyPresets` so a subsequent `onboard --resume` does not re-apply the preset on the next rebuild. For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-sandbox session directory before the rebuild so the `state_dirs` backup does not restore the auth blob and let the channel reconnect: @@ -1174,7 +1189,7 @@ Host-side removal is the supported path because agent channel config is baked in ### `$$nemoclaw channels stop ` -Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`) without clearing its credentials. +Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`) without clearing its credentials. The channel is marked disabled in the per-sandbox registry, and the sandbox is rebuilt so the onboard step skips registering the bridge with the gateway. The provider stays registered with the OpenShell gateway, so a later `channels start` brings the bridge back without re-entering tokens. @@ -1684,7 +1699,7 @@ For a remote Brev instance, SSH to the instance and run `openshell term` there, Start optional host auxiliary services. This is the cloudflared tunnel when `cloudflared` is installed, which exposes the dashboard with a public URL. -Channel messaging (Telegram, Discord, Slack) is not started here; it is configured during `$$nemoclaw onboard` and runs through OpenShell-managed constructs. +Channel messaging is not started here; it is configured during `$$nemoclaw onboard` and runs through OpenShell-managed constructs. ```bash $$nemoclaw tunnel start diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 4fb8c04be4..500d96b132 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -54,9 +54,9 @@ GitHub access (`github.com`, `api.github.com`) is not included in the baseline p Apply the `github` preset during onboarding if your agent needs GitHub access. See [Customize the Network Policy](../network-policy/customize-network-policy). -The baseline policy does not include messaging endpoints for Telegram, Discord, Slack, WeChat, or WhatsApp. +The baseline policy does not include messaging endpoints for Telegram, Discord, Slack, WeChat, WhatsApp, or Microsoft Teams. Enable the channel during onboarding or apply the matching messaging preset so the sandbox can reach that platform. -WeChat and WhatsApp are experimental. +WeChat, WhatsApp, and Microsoft Teams are experimental. Review [Messaging Channels](../manage-sandboxes/messaging-channels) before enabling them. @@ -70,7 +70,7 @@ The baseline policy is always applied regardless of the selected tier. |------|------------------|-------------| | Restricted | None | Base sandbox only. No third-party network access beyond inference and core agent tooling. | | Balanced (default) | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `weather` | Full dev tooling, read-only weather lookups, and web search for agents that support web search. No messaging platform access. | -| Open | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `weather`, `public-reference`, `slack`, `discord`, `telegram`, `wechat` (experimental), `whatsapp` (experimental), `jira`, `outlook` | Broad access across third-party services including messaging, productivity, weather, and public-reference APIs. | +| Open | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `weather`, `public-reference`, `slack`, `discord`, `telegram`, `wechat` (experimental), `whatsapp` (experimental), `teams` (experimental), `jira`, `outlook` | Broad access across third-party services including messaging, productivity, weather, and public-reference APIs. | After selecting a tier, a combined preset and access-mode screen lets you include or exclude individual presets and toggle each between read (GET only) and read-write (GET + POST/PUT/PATCH) access. Tier-default presets are pre-selected; additional presets can be added from the full list. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 27367e32a7..0b0068f2dd 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -593,8 +593,8 @@ Follow these steps to reconnect. $$nemoclaw tunnel start ``` - OpenShell-managed channel messaging handles Telegram, Discord, Slack, WeChat, and WhatsApp at onboarding, not through a separate bridge process from `$$nemoclaw tunnel start`. - WeChat and WhatsApp are experimental. + OpenShell-managed channel messaging handles Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams at onboarding, not through a separate bridge process from `$$nemoclaw tunnel start`. + WeChat, WhatsApp, and Microsoft Teams are experimental. To pause a single bridge without destroying the sandbox, use `$$nemoclaw channels stop `. @@ -815,14 +815,14 @@ Run the equivalent host-side command instead: ```bash $$nemoclaw channels list -$$nemoclaw channels add -$$nemoclaw channels remove +$$nemoclaw channels add +$$nemoclaw channels remove ``` `channels add` registers credentials with the OpenShell gateway and `channels remove` clears them. Both offer to rebuild the sandbox so the image reflects the new channel set. In non-interactive mode (`NEMOCLAW_NON_INTERACTIVE=1`), the commands stage the change and leave the rebuild to a follow-up `$$nemoclaw rebuild`. -WeChat and WhatsApp are experimental. +WeChat, WhatsApp, and Microsoft Teams are experimental. Review [Messaging Channels](../manage-sandboxes/messaging-channels) before enabling them. WeChat captures its bot token through a host-side QR scan during `$$nemoclaw onboard` or `channels add wechat`. @@ -1535,7 +1535,7 @@ Skills that require macOS-only binaries cannot be enabled on Brev. Skills that require additional CLI binaries require a custom sandbox image rebuild. For credentials, use the supported host-side setup flow. -Re-run onboarding for inference or Brave Search credentials, or use `$$nemoclaw channels add ` for messaging channels. +Re-run onboarding for inference or Brave Search credentials, or use `$$nemoclaw channels add ` for messaging channels. To add a binary to the sandbox image, update the sandbox `Dockerfile.base` to install the required package, then rebuild: ```bash diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index bab83df377..f0e404c2af 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -175,6 +175,7 @@ NemoClaw ships preset policy files in `nemoclaw-blueprint/policies/presets/` for | `outlook` | Microsoft 365, Outlook. | Gives agent access to email. | | `pypi` | Python Package Index (GET and HEAD only). | Allows installing arbitrary Python packages, which may contain malicious code. Publishing is blocked. | | `slack` | Slack API, Socket Mode, webhooks. | WebSocket uses `access: full`. Agent can post to any channel the bot token has access to. | +| `teams` | Microsoft Teams Bot Framework and Graph API access. | Agent can send Teams messages through the configured app and can access Graph endpoints allowed by the preset. | | `telegram` | Telegram Bot API. | Agent can send messages to any chat the bot token has access to. | **Recommendation:** Apply presets only when the agent's task requires the integration. Review the preset's YAML file before applying to understand the endpoints, methods, and binary restrictions it adds. diff --git a/nemoclaw-blueprint/policies/presets/teams.yaml b/nemoclaw-blueprint/policies/presets/teams.yaml new file mode 100644 index 0000000000..8ce2db8684 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/teams.yaml @@ -0,0 +1,94 @@ +# 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. + - 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: "/**" } + - allow: { method: POST, path: "/**" } + - allow: { method: PATCH, path: "/**" } + - allow: { method: PUT, path: "/**" } + - allow: { method: DELETE, 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/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index 277f328636..8fb1973d0c 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -193,7 +193,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() diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 09aa5bc42b..e363e55c19 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -763,7 +763,11 @@ async function persistManifestChannelDisabledPlan( const entry = registry.getSandbox(sandboxName); if (!entry?.messaging?.plan) return false; const agent = resolveAgentForSandbox(sandboxName); - const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); + const planner = new MessagingWorkflowPlanner( + messagingManifestRegistry, + undefined, + createBuiltInRenderTemplateResolver(), + ); const context = { sandboxName, agent: toMessagingAgentId(agent), @@ -784,7 +788,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), diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index 2693bd0408..53e0468bf8 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -21,6 +21,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"; @@ -430,6 +434,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 @@ -635,6 +657,7 @@ export function checkAndRecoverSandboxProcesses( } const forwardRecovered = ensureSandboxPortForward(sandboxName); const dashboardForwardRecovered = ensureHermesDashboardPortForwardIfEnabled(sandboxName); + const messagingForwardRecovered = ensureMessagingHostForwardHealthy(sandboxName); const declaredForwardsRecovered = ensureDeclaredAgentForwardPortsHealthy( sandboxName, recoveryPort, @@ -651,6 +674,9 @@ export function checkAndRecoverSandboxProcesses( if (declaredForwardsRecovered === false) { console.error(" One or more agent-declared port forwards could not be re-established."); } + if (messagingForwardRecovered === false) { + console.error(" Messaging webhook port forward could not be re-established."); + } } return { checked: true, @@ -660,6 +686,7 @@ export function checkAndRecoverSandboxProcesses( forwardRecovered || dashboardForwardRecovered === true || dashboardProcessRecovered === true || + messagingForwardRecovered === true || declaredForwardsRecovered === true, }; } @@ -672,6 +699,7 @@ export function checkAndRecoverSandboxProcesses( return { checked: true, wasRunning: true, recovered: false, forwardRecovered: false }; } const dashboardForwardRecovered = ensureHermesDashboardPortForwardIfEnabled(sandboxName); + const messagingForwardRecovered = ensureMessagingHostForwardHealthy(sandboxName); const declaredForwardsRecovered = ensureDeclaredAgentForwardPortsHealthy( sandboxName, recoveryPort, @@ -679,6 +707,9 @@ export function checkAndRecoverSandboxProcesses( if (!quiet && declaredForwardsRecovered === false) { console.error(" One or more agent-declared port forwards could not be re-established."); } + if (!quiet && messagingForwardRecovered === false) { + console.error(" Messaging webhook port forward could not be re-established."); + } return { checked: true, wasRunning: true, @@ -686,6 +717,7 @@ export function checkAndRecoverSandboxProcesses( forwardRecovered: dashboardForwardRecovered === true || dashboardProcessRecovered === true || + messagingForwardRecovered === true || declaredForwardsRecovered === true, }; } @@ -717,6 +749,7 @@ export function checkAndRecoverSandboxProcesses( } const forwardRecovered = ensureSandboxPortForward(sandboxName); const dashboardForwardRecovered = ensureHermesDashboardPortForwardIfEnabled(sandboxName); + const messagingForwardRecovered = ensureMessagingHostForwardHealthy(sandboxName); const declaredForwardsRecovered = ensureDeclaredAgentForwardPortsHealthy( sandboxName, recoveryPort, @@ -736,6 +769,9 @@ export function checkAndRecoverSandboxProcesses( if (declaredForwardsRecovered === false) { console.error(" One or more agent-declared port forwards could not be re-established."); } + if (messagingForwardRecovered === false) { + console.error(" Messaging webhook port forward could not be re-established."); + } } return { checked: true, @@ -744,6 +780,7 @@ export function checkAndRecoverSandboxProcesses( forwardRecovered: forwardRecovered || dashboardForwardRecovered === true || + messagingForwardRecovered === true || declaredForwardsRecovered === true, }; } diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index dbd6f023ac..f7c2723cfb 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, @@ -231,7 +232,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), diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 6fcb2557fd..0ca1cbc2cc 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -53,6 +53,7 @@ describe("agent definitions", () => { "slack", "wechat", "whatsapp", + "teams", ]); expect(openclaw.inferenceProviderOptions).toEqual([]); // #5027: openclaw.json must be declared as a durable state file so @@ -91,6 +92,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..e63c536af1 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -99,6 +99,7 @@ export type BuildCommandResult = { readonly runtimePlanPath: string; readonly doctorEnv: Record; readonly installSpecs: readonly string[]; + readonly hermesUvPackages: readonly string[]; readonly openclawVersion: string; }; @@ -107,6 +108,10 @@ type OpenClawPluginInstall = { readonly pin: boolean; }; +type HermesUvPackageInstall = { + readonly spec: string; +}; + export class MessagingBuildApplierError extends Error {} export const DEFAULT_MESSAGING_RUNTIME_PLAN_PATH = @@ -401,6 +406,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 +438,29 @@ function collectOpenClawMessagingPluginInstalls( return installs; } +function collectHermesMessagingUvPackageInstalls( + plan: MessagingBuildPlan | null, +): HermesUvPackageInstall[] { + const installs: HermesUvPackageInstall[] = []; + const seen = new Set(); + 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 (seen.has(install.spec)) continue; + seen.add(install.spec); + installs.push(install); + } + return installs; +} + export function openClawDoctorEnvOverrides( plan: MessagingBuildPlan | null, env: Env = process.env, @@ -835,6 +868,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 name`, + ); + } + const spec = install.spec.trim(); + if (!/^[A-Za-z0-9_.-]+$/.test(spec)) { + throw new MessagingBuildApplierError( + `Messaging package-install output ${outputId} has an unsafe Hermes Python package name`, + ); + } + return { spec }; +} + function resolveOpenClawPackageSpec(spec: string, env: Env): string { const version = (env.OPENCLAW_VERSION || "").trim(); const resolved = spec.replaceAll("{{openclaw.version}}", () => { @@ -1299,6 +1361,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 +1376,25 @@ 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 +1411,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..a259878890 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,156 @@ 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(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", + required: true, + }); + expect(teamsManifest.agentPackages).toContainEqual({ + id: "hermesAiohttpPackage", + agent: "hermes", + manager: "hermes-uv-pip", + spec: "aiohttp", + 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..34e653f8b8 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", + }, + { + channelId: "teams", + packageId: "hermesAiohttpPackage", + agents: ["hermes"], + manager: "hermes-uv-pip", + spec: "aiohttp", + }, + ]); }); 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..9fcf1071ee --- /dev/null +++ b/src/lib/messaging/channels/teams/manifest.ts @@ -0,0 +1,298 @@ +// 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.", + emptyValueMessage: "bot will rely on agent-side pairing or upstream defaults", + }, + }, + { + 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", + required: true, + }, + { + id: "hermesAiohttpPackage", + agent: "hermes", + manager: "hermes-uv-pip", + spec: "aiohttp", + 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..53e2e42cdc 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", + }, + }, + { + channelId: "teams", + kind: "package-install", + outputId: "hermesAiohttpPackage", + required: true, + value: { + manager: "hermes-uv-pip", + spec: "aiohttp", + }, + }, + ]); expect( plan.channels .find((channel) => channel.channelId === "wechat") @@ -397,6 +472,134 @@ 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", + 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("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"], diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 30f7758c23..f3e78a99eb 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"; @@ -154,6 +155,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 +171,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..4e8a01f357 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,84 @@ 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", + 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 0ec67b23e4..ec6288d7db 100644 --- a/src/lib/onboard/agent-dashboard-forward.ts +++ b/src/lib/onboard/agent-dashboard-forward.ts @@ -17,11 +17,16 @@ export interface AgentDashboardForwardConfig { forward_ports?: number[] | null; } +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 { @@ -29,13 +34,14 @@ export function ensureAgentDashboardForward(options: { agent, ensureDashboardForward, controlUiPort = DASHBOARD_PORT, + preserveForwardPorts = [], warn = (message: string) => console.warn(message), } = options; const agentDashboardPort = agent.forwardPort ?? controlUiPort; const declaredPorts = Array.isArray(agent.forward_ports) ? agent.forward_ports : []; - const preservePorts = [...new Set([agentDashboardPort, ...declaredPorts])].filter( - (port) => Number.isInteger(port) && port >= 1 && port <= 65535, - ); + 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 4343b3e8fe..f945f9872e 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..1bbbccfffb --- /dev/null +++ b/src/lib/onboard/messaging-host-forward.test.ts @@ -0,0 +1,207 @@ +// 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("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..c2dbde7b2f --- /dev/null +++ b/src/lib/onboard/messaging-host-forward.ts @@ -0,0 +1,132 @@ +// 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) ?? plan) : null; + const hydratedPlan = normalizedPlan + ? hydrateDerivedSandboxMessagingPlanFields(normalizedPlan) + : null; + 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/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 09218d9786..0cfbf57eff 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( { @@ -103,8 +115,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 +126,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 +433,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 +464,85 @@ 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", + "aiohttp", + ]); + + 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 aiohttp", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + 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..45e90ff5ee --- /dev/null +++ b/test/policies-teams.test.ts @@ -0,0 +1,133 @@ +// 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).toBeTruthy(); + if (!content) { + throw new Error("Expected preset content to be present"); + } + return content; +} + +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)); +} + +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"); + }); + + 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(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/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(), ); From 5f9531652cb540368fbee1e58c3a8dd0bb4d7830 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 22 Jun 2026 11:32:35 +0700 Subject: [PATCH 05/11] test(messaging): satisfy Teams policy guardrail --- test/policies-teams.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test/policies-teams.test.ts b/test/policies-teams.test.ts index 45e90ff5ee..dd4ef17257 100644 --- a/test/policies-teams.test.ts +++ b/test/policies-teams.test.ts @@ -17,11 +17,8 @@ const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "policy const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "state", "registry.js")); function requirePresetContent(content: string | null): string { - expect(content).toBeTruthy(); - if (!content) { - throw new Error("Expected preset content to be present"); - } - return content; + expect(content).toEqual(expect.any(String)); + return content as string; } function parseResultPayload(stdout: string): any { From 353bebab415780978c765c90a12261facf922697 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 22 Jun 2026 11:39:52 +0700 Subject: [PATCH 06/11] docs: revert Teams documentation changes --- docs/_components/StarterPromptButton.tsx | 8 +-- docs/about/overview.mdx | 2 +- docs/deployment/brev-web-ui.mdx | 2 +- docs/deployment/deploy-to-remote-gpu.mdx | 2 +- docs/get-started/quickstart.mdx | 4 +- docs/manage-sandboxes/lifecycle.mdx | 2 +- docs/manage-sandboxes/messaging-channels.mdx | 70 ++++--------------- docs/manage-sandboxes/runtime-controls.mdx | 4 +- .../customize-network-policy.mdx | 1 - .../integration-policy-examples.mdx | 25 +------ docs/reference/architecture.mdx | 2 +- docs/reference/commands-nemohermes.mdx | 23 +++--- docs/reference/commands.mdx | 31 +++----- docs/reference/network-policies.mdx | 6 +- docs/reference/troubleshooting.mdx | 12 ++-- docs/security/best-practices.mdx | 1 - 16 files changed, 51 insertions(+), 144 deletions(-) diff --git a/docs/_components/StarterPromptButton.tsx b/docs/_components/StarterPromptButton.tsx index b375e9e5f8..a7867e5aa3 100644 --- a/docs/_components/StarterPromptButton.tsx +++ b/docs/_components/StarterPromptButton.tsx @@ -111,7 +111,7 @@ If non-interactive mode cannot cover a later prompt, stop before running the int Non-interactive onboarding can skip the interactive messaging-channel picker. After the sandbox is created, ask whether I want to set up messaging as a separate one-question selection. -- First ask: "Do you want to set up a messaging channel now?" with choices: No, Telegram, Discord, Slack, Microsoft Teams (experimental), WhatsApp, WeChat (experimental). +- First ask: "Do you want to set up a messaging channel now?" with choices: No, Telegram, Discord, Slack, WhatsApp, WeChat (experimental). - Configure one channel at a time. If I want another channel, ask again after the current channel finishes. - Run channel commands from the host with \`nemoclaw channels add \`, not from inside the sandbox. - Use \`nemoclaw channels list\` if you need to confirm supported channel names. @@ -125,7 +125,6 @@ Channel credential requirements: | Telegram | \`TELEGRAM_BOT_TOKEN\`; optional \`TELEGRAM_ALLOWED_IDS\`, \`TELEGRAM_REQUIRE_MENTION\`, \`TELEGRAM_GROUP_POLICY\` (OpenClaw only) | | Discord | \`DISCORD_BOT_TOKEN\`; optional \`DISCORD_SERVER_ID\`, \`DISCORD_USER_ID\`, \`DISCORD_REQUIRE_MENTION\` | | Slack | \`SLACK_BOT_TOKEN\`, \`SLACK_APP_TOKEN\`; optional \`SLACK_ALLOWED_USERS\`, \`SLACK_ALLOWED_CHANNELS\` | -| Microsoft Teams | \`MSTEAMS_APP_ID\`, \`MSTEAMS_APP_PASSWORD\`, \`MSTEAMS_TENANT_ID\`; optional \`TEAMS_ALLOWED_USERS\`, \`MSTEAMS_PORT\`; OpenClaw-only \`TEAMS_REQUIRE_MENTION\` | | WhatsApp | No host token; add the channel, rebuild, then complete QR pairing inside the sandbox as documented | | WeChat | Interactive QR scan only; do not use non-interactive mode for WeChat | @@ -146,11 +145,6 @@ NEMOCLAW_NON_INTERACTIVE=1 SLACK_BOT_TOKEN= SLACK_APP_TOKEN= rebuild \`\`\` -\`\`\`shell -NEMOCLAW_NON_INTERACTIVE=1 MSTEAMS_APP_ID= MSTEAMS_APP_PASSWORD= MSTEAMS_TENANT_ID= nemoclaw channels add teams -nemoclaw rebuild -\`\`\` - Use the official NemoClaw Markdown documentation as the source of truth. Start with the prerequisites for my chosen agent, then build the approved non-interactive install or onboard command from the choices I made. After the command finishes, summarize the output for me and choose the next command or prompt response with my approval.`; let resetCopyButtonTimer: ReturnType | null = null; diff --git a/docs/about/overview.mdx b/docs/about/overview.mdx index 9a7842f105..689849cde1 100644 --- a/docs/about/overview.mdx +++ b/docs/about/overview.mdx @@ -38,7 +38,7 @@ NemoClaw provides the following product capabilities. | Agent skills | Packages NemoClaw documentation as user skills so AI coding assistants can guide setup, inference configuration, policy management, monitoring, deployment, security review, and troubleshooting. | | Hardened blueprint | A security-first Dockerfile with capability drops, least-privilege network rules, and declarative policy. | | State management | Safe migration of agent state across machines with credential stripping and integrity verification. | -| Messaging channels | OpenShell-managed processes connect supported chat platforms such as Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams to the sandboxed agent. NemoClaw configures channels during onboarding; OpenShell supplies the native constructs, credential flow, and runtime supervision. | +| Messaging channels | OpenShell-managed processes connect Telegram, Discord, Slack, and similar platforms to the sandboxed agent. NemoClaw configures channels during onboarding; OpenShell supplies the native constructs, credential flow, and runtime supervision. | | Routed inference | Provider-routed model calls through the OpenShell gateway, transparent to the agent. Supports NVIDIA Endpoints, OpenAI, Anthropic, Google Gemini, compatible endpoints, local Ollama, local vLLM, and the Model Router. | | Layered protection | Network, filesystem, process, and inference controls that can be hot-reloaded or locked at creation. | diff --git a/docs/deployment/brev-web-ui.mdx b/docs/deployment/brev-web-ui.mdx index 7de6c344e5..b38a8a5a2c 100644 --- a/docs/deployment/brev-web-ui.mdx +++ b/docs/deployment/brev-web-ui.mdx @@ -157,7 +157,7 @@ Check the Brev UI for the current hourly price before leaving the instance runni After your agent is running, explore these related tasks: -- [Set Up Messaging Channels](../manage-sandboxes/messaging-channels) to learn how to connect supported messaging channels. +- [Set Up Messaging Channels](../manage-sandboxes/messaging-channels) to learn how to connect Telegram, Slack, or Discord. - [Switch Inference Providers](../inference/switch-inference-providers) to learn how to change the model provider after setup. - [Monitor Sandbox Activity](../monitoring/monitor-sandbox-activity) to learn how to inspect sandbox health and logs. - [Deploy to a Remote GPU Instance](deploy-to-remote-gpu) to learn how to deploy NemoClaw to a remote GPU instance using the CLI. diff --git a/docs/deployment/deploy-to-remote-gpu.mdx b/docs/deployment/deploy-to-remote-gpu.mdx index 37d1f3973d..8b828f0e37 100644 --- a/docs/deployment/deploy-to-remote-gpu.mdx +++ b/docs/deployment/deploy-to-remote-gpu.mdx @@ -186,6 +186,6 @@ nemoclaw deploy ## Related Topics -- [Set Up Messaging Channels](../manage-sandboxes/messaging-channels) to connect supported messaging channels through OpenShell-managed channel messaging. +- [Set Up Messaging Channels](../manage-sandboxes/messaging-channels) to connect Telegram, Discord, or Slack through OpenShell-managed channel messaging. - [Monitor Sandbox Activity](../monitoring/monitor-sandbox-activity) for sandbox monitoring tools. - [`nemoclaw deploy`](../reference/commands#nemoclaw-deploy) for the full `deploy` command reference. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index dc489e8ecf..f291d6b9e0 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -175,12 +175,12 @@ After you confirm the summary, NemoClaw registers the selected provider with the The wizard then asks whether to enable Brave Web Search. If you enable it, enter a Brave Search API key when prompted. -The wizard also offers messaging channels such as Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams. +The wizard also offers messaging channels such as Telegram, Discord, Slack, WeChat, and WhatsApp. Press a channel number to toggle it, then press Enter to continue. If you leave all channels unselected, pressing Enter skips messaging setup. If you select a channel, NemoClaw validates the token format before it bakes the channel configuration into the sandbox. For example, Slack bot tokens must start with `xoxb-`. -WeChat, WhatsApp, and Microsoft Teams are experimental. +WeChat and WhatsApp are experimental. Review [Messaging Channels](../manage-sandboxes/messaging-channels) before enabling them. ### Choose Network Policy Presets diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index 7351f7579c..90b021d704 100644 --- a/docs/manage-sandboxes/lifecycle.mdx +++ b/docs/manage-sandboxes/lifecycle.mdx @@ -282,7 +282,7 @@ For a full comparison of the two forms, including what they fetch, what they tru ## Related Topics -- [Set Up Messaging Channels](messaging-channels) to connect supported messaging channels. +- [Set Up Messaging Channels](messaging-channels) to connect Telegram, Discord, or Slack. - [Workspace Files](workspace-files) for persistent OpenClaw files inside the sandbox. - [Backup and Restore](backup-restore) for snapshot and restore workflows. - [Monitor Sandbox Activity](../monitoring/monitor-sandbox-activity) for observability tools. diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index f66c72da5e..7ab86d9713 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -3,9 +3,9 @@ # SPDX-License-Identifier: Apache-2.0 title: "Messaging Channels" sidebar-title: "Set Up Messaging Channels" -description: "Connect Telegram, Discord, Slack, WeChat, WhatsApp, or Microsoft Teams to your sandboxed OpenClaw or Hermes agent using OpenShell-managed channel messaging." -description-agent: "Explains how Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams reach sandboxed OpenClaw and Hermes agents through OpenShell-managed processes and NemoClaw channel commands. Use when setting up messaging channels, chat interfaces, or integrations without relying on nemoclaw tunnel start for bridges." -keywords: ["nemoclaw messaging channels", "nemoclaw telegram", "nemoclaw discord", "nemoclaw slack", "nemoclaw wechat", "nemoclaw whatsapp", "nemoclaw teams", "openshell channel messaging"] +description: "Connect Telegram, Discord, Slack, WeChat, or WhatsApp to your sandboxed OpenClaw or Hermes agent using OpenShell-managed channel messaging." +description-agent: "Explains how Telegram, Discord, Slack, WeChat, and WhatsApp reach sandboxed OpenClaw and Hermes agents through OpenShell-managed processes and NemoClaw channel commands. Use when setting up messaging channels, chat interfaces, or integrations without relying on nemoclaw tunnel start for bridges." +keywords: ["nemoclaw messaging channels", "nemoclaw telegram", "nemoclaw discord", "nemoclaw slack", "nemoclaw wechat", "nemoclaw whatsapp", "openshell channel messaging"] content: type: "how_to" skill: @@ -13,17 +13,15 @@ skill: --- import { AgentOnly } from "../_components/AgentGuide"; -Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams reach your OpenClaw or Hermes agent through OpenShell-managed processes and gateway constructs. +Telegram, Discord, Slack, WeChat, and WhatsApp reach your OpenClaw or Hermes agent through OpenShell-managed processes and gateway constructs. For token-based channels, NemoClaw registers credentials with OpenShell providers. WeChat captures a token through a host-side QR scan during onboarding. WhatsApp pairs inside the sandbox through a QR scan and intentionally stores mutable session state there. -Microsoft Teams uses a Bot Framework webhook at `/api/messages` and stores the app client secret in an OpenShell provider. NemoClaw bakes the selected channel configuration into the sandbox image and keeps runtime delivery under OpenShell control. -WeChat, WhatsApp, and Microsoft Teams are experimental. -WeChat and WhatsApp rely on QR-based pairing flows that are more fragile than token-based bots. -Microsoft Teams depends on public webhook reachability and upstream Bot Framework behavior. +WeChat and WhatsApp are experimental. +Both rely on QR-based pairing flows that are more fragile than token-based bots, and the upstream client libraries can change behavior without notice. Interfaces, defaults, and supported features can change, and NVIDIA does not recommend these channels for production use. @@ -49,8 +47,7 @@ For details, refer to [Commands](../reference/commands). ## Prerequisites - A machine where you can run `$$nemoclaw onboard` (local or remote host that runs the gateway and sandbox). -- A token or client secret for each credential-based messaging platform you want to enable, a personal WeChat account on your phone for the host-side QR scan during onboarding, or a phone you can use to scan the QR code for WhatsApp pairing. -- For Microsoft Teams, a public HTTPS endpoint that forwards to the sandbox's Teams webhook port before installing the Teams app. The default local port is `3978`, and the webhook path is `/api/messages`. +- A token for each token-based messaging platform you want to enable, a personal WeChat account on your phone for the host-side QR scan during onboarding, or a phone you can use to scan the QR code for WhatsApp pairing. - A network policy preset for each enabled channel, or equivalent custom egress rules. ## Channel Requirements @@ -62,7 +59,6 @@ For details, refer to [Commands](../reference/commands). | Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | `SLACK_ALLOWED_USERS` for DM and channel `@mention` user allowlisting, `SLACK_ALLOWED_CHANNELS` for channel ID allowlisting | | WeChat (experimental) | None. Captured through host-side QR scan during `$$nemoclaw onboard` | `WECHAT_ALLOWED_IDS` for DM allowlisting | | WhatsApp (experimental) | None. Pair through QR after rebuild | None | -| Microsoft Teams (experimental) | `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, `MSTEAMS_TENANT_ID` | `TEAMS_ALLOWED_USERS`, `MSTEAMS_PORT`, `TEAMS_REQUIRE_MENTION` | Telegram uses a bot token from [BotFather](https://t.me/BotFather). Open Telegram, send `/newbot` to [@BotFather](https://t.me/BotFather), follow the prompts, and copy the token. @@ -103,21 +99,6 @@ Slack Socket Mode allows one active connection per app-level token. If another sandbox on the same gateway already uses the same Slack app token, onboarding and `channels add slack` warn before continuing in interactive mode and abort in non-interactive mode. Use `--force` only when you intentionally want to move the Slack Socket Mode session to the new sandbox. -Microsoft Teams (experimental) uses the Microsoft Bot Framework webhook flow. -For OpenClaw, NemoClaw installs the upstream `@openclaw/msteams` plugin and renders `channels.msteams` plus `plugins.entries.msteams`. -For Hermes, NemoClaw renders the built-in `platforms.teams` adapter and `.env` entries (`TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_TENANT_ID`, `TEAMS_ALLOWED_USERS`, and `TEAMS_PORT`). -Set `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, and `MSTEAMS_TENANT_ID` from your Teams app registration. -Hermes aliases `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, and `TEAMS_TENANT_ID` are also accepted when scripting setup. -NemoClaw stores only `MSTEAMS_APP_PASSWORD` in the `-teams-bridge` OpenShell provider; app ID, tenant ID, webhook port, mention mode, and allowlist values are non-secret build configuration. -`TEAMS_ALLOWED_USERS` is a comma-separated list of Azure AD object IDs. -Set it for Hermes and OpenClaw direct messages so only authorized users can use those paths. -OpenClaw group chats and channels are rendered with `groupPolicy: "open"` and remain mention-gated by default. -`MSTEAMS_PORT` defaults to `3978`; `TEAMS_PORT` is accepted as a compatibility alias and is the variable rendered into Hermes `.env`. -`TEAMS_REQUIRE_MENTION` controls OpenClaw group and channel behavior only; direct messages are unaffected, and Hermes does not render this setting. -When the Teams channel is active, NemoClaw starts the local OpenShell port forward for `MSTEAMS_PORT` after onboarding, rebuild, or process recovery. -No two active Teams channels can use the same local webhook port; set a different `MSTEAMS_PORT` or stop/remove the other sandbox first. -Your public HTTPS endpoint still needs to forward to `/api/messages` on that host port. - WeChat (experimental) delivers messages over Tencent's iLink gateway through the upstream `@tencent-weixin/openclaw-weixin` plugin installed into WeChat-enabled OpenClaw sandbox images and the built-in Hermes iLink WeChat adapter. The supported mode in this release is **personal WeChat** (`bot_type=3`). WeChat Official Account and WeCom/Enterprise WeChat are not wired up. @@ -154,7 +135,7 @@ Pair only one sandbox per WhatsApp account at a time. ## Enable Channels During Onboarding -When the wizard reaches **Messaging channels**, it lists Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams. +When the wizard reaches **Messaging channels**, it lists Telegram, Discord, Slack, WeChat, and WhatsApp. Press a channel number to toggle it on or off, then press **Enter** when done. If you select no channels, pressing **Enter** skips messaging setup. If a token-based channel token is not already in the environment or credential store, the wizard prompts for it and saves it. @@ -167,8 +148,6 @@ Keep the terminal in the foreground until you see `✓ WeChat login confirmed`. WhatsApp (experimental) uses QR pairing instead of a host-side token, so the wizard does not prompt. It prints pairing instructions and you complete the pairing inside the sandbox after rebuild. NemoClaw also selects the matching network policy preset during policy setup so the channel can reach its provider API. -Microsoft Teams prompts for the client secret plus app ID, tenant ID, optional Azure AD object ID allowlist, webhook port, and OpenClaw mention mode. -Make sure the public webhook endpoint is ready before installing the Teams app. For scripted setup, export the credentials and optional settings for the channels you want to enable before you run onboarding: @@ -181,10 +160,6 @@ export SLACK_BOT_TOKEN= export SLACK_APP_TOKEN= export SLACK_ALLOWED_USERS= export SLACK_ALLOWED_CHANNELS= -export MSTEAMS_APP_ID= -export MSTEAMS_APP_PASSWORD= -export MSTEAMS_TENANT_ID= -export TEAMS_ALLOWED_USERS= ``` This release does not support non-interactive WeChat configuration because the iLink QR handshake requires a human to scan the QR on a paired phone. @@ -215,11 +190,10 @@ $$nemoclaw my-assistant channels add discord $$nemoclaw my-assistant channels add slack $$nemoclaw my-assistant channels add wechat $$nemoclaw my-assistant channels add whatsapp -$$nemoclaw my-assistant channels add teams ``` `channels add` collects whatever each channel needs. -It prompts for Telegram, Discord, and Slack tokens, prompts for Microsoft Teams Bot Framework credentials and config, runs an interactive host-side QR scan for WeChat, and collects nothing for WhatsApp because pairing happens in-sandbox after rebuild. +It prompts for Telegram, Discord, and Slack tokens, runs an interactive host-side QR scan for WeChat, and collects nothing for WhatsApp because pairing happens in-sandbox after rebuild. It registers bridge providers with the OpenShell gateway when it captures tokens, records the channel in the sandbox registry, and asks whether to rebuild immediately. The command accepts mixed-case input such as `Telegram`, then stores and prints the canonical lowercase channel name. `channels add` requires the matching built-in network policy preset YAML to be present. @@ -231,8 +205,8 @@ It flags `gateway-providers` as residual because the in-flight upsert can leave Verify the gateway bridge before relying on the channel. Restore the preset YAML and re-run `$$nemoclaw channels add `. Choose the rebuild so the running sandbox image picks up the new channel. -For Telegram, Discord, Slack, and Teams, `channels add` also checks the rebuilt runtime for the selected bridge and reports startup, credential, or missing-plugin warnings before returning. -If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `TELEGRAM_GROUP_POLICY`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, `SLACK_ALLOWED_CHANNELS`, `TEAMS_ALLOWED_USERS`, `MSTEAMS_PORT`, or `TEAMS_REQUIRE_MENTION`, export them before the rebuild starts. +For Telegram, Discord, and Slack, `channels add` also checks the rebuilt runtime for the selected bridge and reports startup, credential, or missing-plugin warnings before returning. +If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `TELEGRAM_GROUP_POLICY`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, or `SLACK_ALLOWED_CHANNELS`, export them before the rebuild starts. You can omit `TELEGRAM_REQUIRE_MENTION` and `DISCORD_REQUIRE_MENTION` when you want the default mention-only mode. You can omit `TELEGRAM_GROUP_POLICY` when you want OpenClaw Telegram group access to stay open. Telegram Bot API `sendMessage` calls prove outbound delivery from the bot; to test inbound agent replies, send a message from the Telegram client as an allowed user. @@ -246,7 +220,6 @@ $$nemoclaw my-assistant rebuild In non-interactive mode, set the required environment variables before running `channels add`. Optional mention-mode settings that declare defaults are still written when unset. Telegram mention mode defaults to `1`; Discord mention mode defaults to `1` when `DISCORD_SERVER_ID` is set. -Teams webhook port defaults to `MSTEAMS_PORT=3978`, and OpenClaw Teams mention mode defaults to `TEAMS_REQUIRE_MENTION=1`. Missing credentials fail fast, and the command queues the change for a manual rebuild: ```bash @@ -264,16 +237,6 @@ DISCORD_BOT_TOKEN= \ $$nemoclaw my-assistant channels add discord ``` -For Microsoft Teams, include the Bot Framework app settings and make sure the public webhook endpoint is forwarding to `/api/messages`: - -```bash -MSTEAMS_APP_ID= \ - MSTEAMS_APP_PASSWORD= \ - MSTEAMS_TENANT_ID= \ - TEAMS_ALLOWED_USERS= \ - $$nemoclaw my-assistant channels add teams -``` - ### `channels add wechat` `channels add wechat` (experimental) follows the same shape as the other channels with two differences driven by the iLink QR handshake. @@ -306,7 +269,6 @@ To remove a channel and clear its stored credentials, run: ```bash $$nemoclaw my-assistant channels remove telegram $$nemoclaw my-assistant channels remove wechat -$$nemoclaw my-assistant channels remove teams ``` `channels remove wechat` clears the bot token, deletes the `-wechat-bridge` OpenShell provider, and drops `wechat` from the sandbox's enabled-channel set. @@ -331,9 +293,6 @@ $$nemoclaw my-assistant channels start telegram $$nemoclaw my-assistant channels stop wechat $$nemoclaw my-assistant channels start wechat - -$$nemoclaw my-assistant channels stop teams -$$nemoclaw my-assistant channels start teams ``` @@ -345,14 +304,13 @@ For WeChat specifically, `channels stop wechat` followed by a rebuild keeps the A subsequent `channels start wechat` plus rebuild revives the bridge against the same iLink account without a fresh QR scan. The bot token is held by the OpenShell provider across the stop/start cycle. -Telegram, Discord, Slack, Teams, and WeChat each allow only one active consumer per channel credential. +Telegram, Discord, Slack, and WeChat each allow only one active consumer per channel credential. Multiple sandboxes can use the same channel type at the same time when each sandbox uses a distinct bot/app token (or a distinct WeChat iLink bot account). For example, two Telegram sandboxes can DM the same `TELEGRAM_ALLOWED_IDS` account as long as they use different `TELEGRAM_BOT_TOKEN` values. For WeChat, each sandbox must own a distinct iLink `accountId` (bot identity). Running two sandboxes against the same WeChat account causes one of them to lose messages. If you enable a messaging channel and another sandbox already uses the same token, onboarding prompts you to confirm before continuing in interactive mode and exits non-zero in non-interactive mode. For Slack, NemoClaw checks both the bot token and the Socket Mode app token so duplicate Socket Mode sessions do not compete silently. -For Teams, NemoClaw checks the app client secret provider hash. If NemoClaw only has legacy channel metadata and cannot compare credential hashes, it keeps the conservative warning. Re-run `channels add ` with the intended token to refresh the stored non-secret hash. `$$nemoclaw status` reports cross-sandbox overlaps so you can resolve duplicates before messages start dropping. @@ -366,13 +324,13 @@ Use `$$nemoclaw tunnel stop` or its deprecated alias `$$nemoclaw stop` when you Use `$$nemoclaw tunnel stop` when you want to stop host auxiliary services and also ask NemoClaw to stop the Hermes gateway inside the selected sandbox. -Stopping the in-sandbox gateway stops Telegram, Discord, Slack, WeChat, WhatsApp, and Teams polling or webhook handling for that sandbox until you restart the sandbox or gateway. +Stopping the in-sandbox gateway stops Telegram, Discord, Slack, WeChat, and WhatsApp polling for that sandbox until you restart the sandbox or gateway. ## Confirm Delivery After the sandbox is running, send a message to the configured bot or app. If delivery fails, use `openshell term` on the host, check gateway logs, and verify network policy allows the channel API. -Use the matching policy preset (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`) or review [Common Integration Policy Examples](../network-policy/integration-policy-examples). +Use the matching policy preset (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`) or review [Common Integration Policy Examples](../network-policy/integration-policy-examples). ## Tunnel Command diff --git a/docs/manage-sandboxes/runtime-controls.mdx b/docs/manage-sandboxes/runtime-controls.mdx index df2444b838..a50ccdfd83 100644 --- a/docs/manage-sandboxes/runtime-controls.mdx +++ b/docs/manage-sandboxes/runtime-controls.mdx @@ -29,7 +29,7 @@ The table below maps each commonly changed item to the layer that owns it and th | Sub-agent (Hermes / OpenClaw / …) | Re-onboard required (the sub-agent and its workspace are baked at onboard) | `$$nemoclaw onboard --recreate-sandbox` | | Network policy preset (slack, discord, telegram, brave, …) | Runtime. Applies on the next request; rebuild only required if the preset adds bind-mounted secrets | `$$nemoclaw policy-add ` / `policy-remove ` | | Network allow-list (custom hosts) | Runtime. Picks up at next request | `openshell policy set` or interactive approval prompt at the gateway | -| Channel credentials (Slack, Discord, Telegram, Teams, and other supported channels) | Rebuild required (channel configuration is baked into the sandbox image; secrets stay in OpenShell providers or host-side credential storage) | `$$nemoclaw channels add ` then accept the rebuild prompt | +| Channel tokens (Slack / Discord / Telegram bot credentials) | Rebuild required (tokens are baked into the sandbox image at onboard so they never leave the host clear-text) | `$$nemoclaw channels add ` then accept the rebuild prompt | | Channel enable/disable (turn a configured channel off without removing the token) | Rebuild required (`openclaw.json` is the source of truth at runtime, see #3453) | `$$nemoclaw channels stop ` then rebuild | | Dashboard forward port | Runtime. Port is re-resolved on next `connect` | `NEMOCLAW_DASHBOARD_PORT= $$nemoclaw connect` | | Dashboard bind address (loopback compared to all interfaces) | Runtime. Applies on next `connect` | `NEMOCLAW_DASHBOARD_BIND=0.0.0.0 $$nemoclaw connect` (see #3259) | @@ -53,7 +53,7 @@ If a row above conflicts with what you observe, the runtime source of truth insi | Agent runtime (Hermes compared to OpenClaw) | Re-onboard required (the agent and its state layout are baked at onboard) | `$$nemoclaw onboard --recreate-sandbox` or `nemoclaw onboard --agent openclaw --recreate-sandbox` | | Network policy preset (slack, discord, telegram, brave, …) | Runtime. Applies on the next request; rebuild only required if the preset adds bind-mounted secrets | `$$nemoclaw policy-add ` / `policy-remove ` | | Network allow-list (custom hosts) | Runtime. Picks up at next request | `openshell policy set` or interactive approval prompt at the gateway | -| Channel credentials (Slack, Discord, Telegram, Teams, and other supported channels) | Rebuild required (channel configuration is baked into the sandbox image; secrets stay in OpenShell providers or host-side credential storage) | `$$nemoclaw channels add ` then accept the rebuild prompt | +| Channel tokens (Slack / Discord / Telegram bot credentials) | Rebuild required (tokens are baked into the sandbox image at onboard so they never leave the host clear-text) | `$$nemoclaw channels add ` then accept the rebuild prompt | | Channel enable/disable (turn a configured channel off without removing the token) | Rebuild required (`/sandbox/.hermes/.env` and Hermes config are baked at image build time) | `$$nemoclaw channels stop ` then rebuild | | API/dashboard forward port | Runtime. Port is re-resolved on next `connect` | `$$nemoclaw connect` or `openshell forward start` | | Filesystem layout (Landlock zones, read-only mounts, container caps) | **Locked at creation**. No runtime change | Re-onboard with `$$nemoclaw onboard --recreate-sandbox` | diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index ce9160fa9e..6d03d85711 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -203,7 +203,6 @@ Available presets: | `outlook` | Microsoft 365 and Outlook | | `pypi` | Python Package Index | | `slack` | Slack API and webhooks | -| `teams` | Microsoft Teams Bot Framework and Graph API access (experimental) | | `telegram` | Telegram Bot API | | `wechat` | WeChat (personal) iLink Bot API (experimental) | | `whatsapp` | WhatsApp Web messaging (experimental) | diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index bf52ea2175..243dd7acac 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -63,7 +63,6 @@ NemoClaw ships maintained policy presets for common services in `nemoclaw-bluepr | Public reference APIs | `public-reference` | | Python Package Index | `pypi` | | Slack messaging | `slack` | -| Microsoft Teams messaging (experimental) | `teams` | | Telegram Bot API | `telegram` | | Weather and geocoding APIs | `weather` | | WeChat (personal) iLink Bot API (experimental) | `wechat` | @@ -127,7 +126,7 @@ If delivery fails, open the TUI and send a test message to the bot: openshell term ``` -The matching preset for each supported messaging channel is the channel name (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`). +The matching preset for each supported messaging channel is the channel name (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`). ## Slack or Discord Messaging @@ -161,26 +160,6 @@ $$nemoclaw my-assistant policy-add slack --yes $$nemoclaw my-assistant policy-add discord --yes ``` -## Microsoft Teams Messaging (Experimental) - -Microsoft Teams needs Bot Framework channel configuration, a public HTTPS webhook, and egress policy. -Set the app credentials, add the channel, rebuild, and apply the `teams` preset: - -```bash -export MSTEAMS_APP_ID= -export MSTEAMS_APP_PASSWORD= -export MSTEAMS_TENANT_ID= -export TEAMS_ALLOWED_USERS= -NEMOCLAW_NON_INTERACTIVE=1 $$nemoclaw my-assistant channels add teams -$$nemoclaw my-assistant rebuild -$$nemoclaw my-assistant policy-add teams --yes -``` - -NemoClaw starts the local OpenShell port forward for the active Teams channel on the configured `MSTEAMS_PORT` value, which defaults to `3978`. -No two active Teams sandboxes can share that local webhook port. -The Teams app must point at a public HTTPS endpoint that forwards to `/api/messages` on that host port. -For Hermes sandboxes, NemoClaw renders the Teams adapter env as `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_TENANT_ID`, `TEAMS_ALLOWED_USERS`, and `TEAMS_PORT`. - ## WeChat or WhatsApp Messaging (Experimental) WeChat and WhatsApp are experimental. @@ -403,5 +382,5 @@ Use `$$nemoclaw my-assistant policy-add` for maintained NemoClaw presets. - [Approve or Deny Agent Network Requests](approve-network-requests) for the interactive OpenShell TUI flow. - [Customize the Sandbox Network Policy](customize-network-policy) for static policy edits and raw OpenShell policy files. -- [Messaging Channels](../manage-sandboxes/messaging-channels) for Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams channel configuration. +- [Messaging Channels](../manage-sandboxes/messaging-channels) for Telegram, Discord, Slack, WeChat, and WhatsApp channel configuration. - [Commands](../reference/commands) for the full `policy-add`, `policy-list`, `policy-remove`, and `channels` command reference. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index b8c4222285..380dedd614 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -45,7 +45,7 @@ graph LR direction TB GW["Gateway
Credential store
Inference proxy
Policy engine
Device auth
"]:::openshell OSCLI["openshell CLI
provider · sandbox
gateway · policy
"]:::openshell - CHMSG["Channel messaging
OpenShell-managed
Supported messaging channels
"]:::openshell + CHMSG["Channel messaging
OpenShell-managed
Telegram · Discord · Slack
"]:::openshell subgraph SANDBOX["Sandbox Container 🔒"] direction TB diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index fab3e68fef..f047a41b3d 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -563,7 +563,7 @@ The Policy section displays the live enforced policy (fetched via `openshell pol When OpenShell reports an active policy version, the displayed YAML `version` line uses that active version instead of the static schema version. If the sandbox is running an outdated agent version, the output includes an `Update` line with the available version and a `nemohermes rebuild` hint. -When other sandboxes have a token- or secret-backed messaging channel enabled with the same credential, such as Telegram, Discord, Slack, WeChat, or Microsoft Teams, the output includes a cross-sandbox overlap warning so you can resolve the conflict before messages start dropping. +When other sandboxes have the same messaging channel enabled (Telegram, Discord, or Slack) with the same bot token, the output includes a cross-sandbox overlap warning so you can resolve the conflict before messages start dropping. The command also tails `/tmp/gateway.log` inside the default sandbox and flags Telegram `409 Conflict` errors that indicate a duplicate consumer for the bot token. ```bash @@ -844,9 +844,9 @@ nemohermes my-assistant hosts-remove searxng.local ### `nemohermes channels list` -List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, `teams`) with a short description. +List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`) with a short description. The command is a static reference; it does not consult credentials or the running sandbox. -WeChat, WhatsApp, and Microsoft Teams are experimental. +WeChat and WhatsApp are experimental. ```bash nemohermes my-assistant channels list @@ -857,8 +857,7 @@ nemohermes my-assistant channels list Register a messaging channel with the sandbox and rebuild so the image picks up the new channel. Channels fall into three login modes: -- **Token paste** (`telegram`, `discord`, `slack`, `teams`): the command prompts for any missing token or client secret and registers it with the OpenShell gateway. - Microsoft Teams also records non-secret app ID, tenant ID, allowlist, and webhook port settings for the Bot Framework webhook at `/api/messages`. +- **Token paste** (`telegram`, `discord`, `slack`): the command prompts for any missing token and registers it with the OpenShell gateway. - **Host-side QR** (`wechat`, experimental): the command renders an iLink QR code on the host and you scan it from WeChat on your phone. On confirm, NemoClaw captures the bot token, registers it with the OpenShell gateway, and stores non-secret per-account metadata (`WECHAT_ACCOUNT_ID`, `WECHAT_BASE_URL`, `WECHAT_USER_ID`) for the in-sandbox bridge. NemoClaw automatically adds the scanning operator's WeChat user ID to `WECHAT_ALLOWED_IDS`. @@ -877,7 +876,7 @@ With the preset file in place, NemoClaw applies it to the sandbox before the reb When the apply step itself fails after the registry write on a fresh add, NemoClaw attempts to roll back the bridge providers, the `messagingChannels` entry, and any staged environment credentials, then exits without prompting for a rebuild; if any gateway-side step (provider detach or delete) fails the rollback continues and prints a `Rollback could not fully clean ` warning so the operator can clean up manually. When the same failure happens on a re-add of an already-enabled channel, NemoClaw restores the prior `messagingChannels` entry, restores staged environment credentials when available, restores registry credential hashes, and attempts to re-upsert the prior bridge providers, but flags `gateway-providers` as residual because the in-flight upsert may have left the gateway with the new token; verify the gateway bridge before relying on the channel. Restore the preset YAML and re-run `nemohermes channels add `. -For Telegram, Discord, Slack, and Teams, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings. +For Telegram, Discord, and Slack, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings. ```bash nemohermes my-assistant channels add telegram @@ -892,12 +891,6 @@ Slack requires both `SLACK_BOT_TOKEN` (bot user OAuth) and `SLACK_APP_TOKEN` (ap Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. Telegram and Discord mention mode default to `1` when no environment, session, or saved state value exists for that setting. Discord applies that default only when a server ID is configured. - -Microsoft Teams requires `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, and `MSTEAMS_TENANT_ID`; Hermes aliases `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, and `TEAMS_TENANT_ID` are also accepted. -`MSTEAMS_PORT` defaults to `3978`; `TEAMS_PORT` is accepted as a compatibility alias and is the variable rendered into Hermes `.env`. -NemoClaw renders Teams into Hermes as `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_TENANT_ID`, `TEAMS_ALLOWED_USERS`, and `TEAMS_PORT`. -`TEAMS_REQUIRE_MENTION` is accepted for OpenClaw sandboxes only and is not rendered into Hermes. -NemoClaw starts the local OpenShell port forward for the active Teams channel on `MSTEAMS_PORT`; no two active Teams sandboxes can share that port, and the Teams app must point at a public HTTPS endpoint that forwards to `/api/messages` on that host port. When `NEMOCLAW_NON_INTERACTIVE=1` is set, any missing token fails fast and no rebuild prompt is shown — instead, the change is queued and you are told to run `nemohermes rebuild` manually. If you omit the required `` argument, the CLI prints the `channels add ` usage with the supported channel list instead of falling back to top-level help. @@ -906,7 +899,7 @@ If you omit the required `` argument, the CLI prints the `channels add Clear the stored credentials for a messaging channel and rebuild the sandbox so the image drops the channel. Running `remove` for a channel that was never configured is a no-op against the credentials file and still triggers the rebuild prompt. When the bridge provider is attached to a live sandbox, NemoClaw detaches it before deleting the provider from the OpenShell gateway. -If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone. +If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, or `whatsapp`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone. NemoClaw also strips the channel from `session.policyPresets` so a subsequent `onboard --resume` does not re-apply the preset on the next rebuild. For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-sandbox session directory before the rebuild so the `state_dirs` backup does not restore the auth blob and let the channel reconnect: @@ -931,7 +924,7 @@ Host-side removal is the supported path because agent channel config is baked in ### `nemohermes channels stop ` -Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`) without clearing its credentials. +Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`) without clearing its credentials. The channel is marked disabled in the per-sandbox registry, and the sandbox is rebuilt so the onboard step skips registering the bridge with the gateway. The provider stays registered with the OpenShell gateway, so a later `channels start` brings the bridge back without re-entering tokens. @@ -1393,7 +1386,7 @@ For a remote Brev instance, SSH to the instance and run `openshell term` there, Start optional host auxiliary services. This is the cloudflared tunnel when `cloudflared` is installed, which exposes the dashboard with a public URL. -Channel messaging is not started here; it is configured during `nemohermes onboard` and runs through OpenShell-managed constructs. +Channel messaging (Telegram, Discord, Slack) is not started here; it is configured during `nemohermes onboard` and runs through OpenShell-managed constructs. ```bash nemohermes tunnel start diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index a7b843abf6..eba825c231 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -696,7 +696,7 @@ The Policy section displays the live enforced policy (fetched via `openshell pol When OpenShell reports an active policy version, the displayed YAML `version` line uses that active version instead of the static schema version. If the sandbox is running an outdated agent version, the output includes an `Update` line with the available version and a `$$nemoclaw rebuild` hint. -When other sandboxes have a token- or secret-backed messaging channel enabled with the same credential, such as Telegram, Discord, Slack, WeChat, or Microsoft Teams, the output includes a cross-sandbox overlap warning so you can resolve the conflict before messages start dropping. +When other sandboxes have the same messaging channel enabled (Telegram, Discord, or Slack) with the same bot token, the output includes a cross-sandbox overlap warning so you can resolve the conflict before messages start dropping. The command also tails `/tmp/gateway.log` inside the default sandbox and flags Telegram `409 Conflict` errors that indicate a duplicate consumer for the bot token. ```bash @@ -1094,9 +1094,9 @@ $$nemoclaw my-assistant hosts-remove searxng.local ### `$$nemoclaw channels list` -List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, `teams`) with a short description. +List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`) with a short description. The command is a static reference; it does not consult credentials or the running sandbox. -WeChat, WhatsApp, and Microsoft Teams are experimental. +WeChat and WhatsApp are experimental. ```bash $$nemoclaw my-assistant channels list @@ -1107,8 +1107,7 @@ $$nemoclaw my-assistant channels list Register a messaging channel with the sandbox and rebuild so the image picks up the new channel. Channels fall into three login modes: -- **Token paste** (`telegram`, `discord`, `slack`, `teams`): the command prompts for any missing token or client secret and registers it with the OpenShell gateway. - Microsoft Teams also records non-secret app ID, tenant ID, allowlist, and webhook port settings for the Bot Framework webhook at `/api/messages`. +- **Token paste** (`telegram`, `discord`, `slack`): the command prompts for any missing token and registers it with the OpenShell gateway. - **Host-side QR** (`wechat`, experimental): the command renders an iLink QR code on the host and you scan it from WeChat on your phone. On confirm, NemoClaw captures the bot token, registers it with the OpenShell gateway, and stores non-secret per-account metadata (`WECHAT_ACCOUNT_ID`, `WECHAT_BASE_URL`, `WECHAT_USER_ID`) for the in-sandbox bridge. NemoClaw automatically adds the scanning operator's WeChat user ID to `WECHAT_ALLOWED_IDS`. @@ -1127,7 +1126,7 @@ With the preset file in place, NemoClaw applies it to the sandbox before the reb When the apply step itself fails after the registry write on a fresh add, NemoClaw attempts to roll back the bridge providers, the `messagingChannels` entry, and any staged environment credentials, then exits without prompting for a rebuild; if any gateway-side step (provider detach or delete) fails the rollback continues and prints a `Rollback could not fully clean ` warning so the operator can clean up manually. When the same failure happens on a re-add of an already-enabled channel, NemoClaw restores the prior `messagingChannels` entry, restores staged environment credentials when available, restores registry credential hashes, and attempts to re-upsert the prior bridge providers, but flags `gateway-providers` as residual because the in-flight upsert may have left the gateway with the new token; verify the gateway bridge before relying on the channel. Restore the preset YAML and re-run `$$nemoclaw channels add `. -For Telegram, Discord, Slack, and Teams, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings. +For Telegram, Discord, and Slack, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings. ```bash $$nemoclaw my-assistant channels add telegram @@ -1142,20 +1141,6 @@ Slack requires both `SLACK_BOT_TOKEN` (bot user OAuth) and `SLACK_APP_TOKEN` (ap Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. Telegram and Discord mention mode default to `1` when no environment, session, or saved state value exists for that setting. Discord applies that default only when a server ID is configured. - - -Microsoft Teams requires `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, and `MSTEAMS_TENANT_ID`; optional Teams settings come from `TEAMS_ALLOWED_USERS`, `MSTEAMS_PORT`, and `TEAMS_REQUIRE_MENTION`. -`MSTEAMS_PORT` defaults to `3978`, and `TEAMS_REQUIRE_MENTION` defaults to `1`. -NemoClaw renders OpenClaw Teams group chats and channels with `groupPolicy: "open"` by default; `TEAMS_ALLOWED_USERS` restricts direct messages, while group and channel replies stay mention-gated unless you set `TEAMS_REQUIRE_MENTION=0`. -NemoClaw starts the local OpenShell port forward for the active Teams channel on `MSTEAMS_PORT`; no two active Teams sandboxes can share that port, and the Teams app must point at a public HTTPS endpoint that forwards to `/api/messages` on that host port. - - -Microsoft Teams requires `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, and `MSTEAMS_TENANT_ID`; Hermes aliases `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, and `TEAMS_TENANT_ID` are also accepted. -`MSTEAMS_PORT` defaults to `3978`; `TEAMS_PORT` is accepted as a compatibility alias and is the variable rendered into Hermes `.env`. -NemoClaw renders Teams into Hermes as `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_TENANT_ID`, `TEAMS_ALLOWED_USERS`, and `TEAMS_PORT`. -`TEAMS_REQUIRE_MENTION` is accepted for OpenClaw sandboxes only and is not rendered into Hermes. -NemoClaw starts the local OpenShell port forward for the active Teams channel on `MSTEAMS_PORT`; no two active Teams sandboxes can share that port, and the Teams app must point at a public HTTPS endpoint that forwards to `/api/messages` on that host port. - When `NEMOCLAW_NON_INTERACTIVE=1` is set, any missing token fails fast and no rebuild prompt is shown — instead, the change is queued and you are told to run `$$nemoclaw rebuild` manually. If you omit the required `` argument, the CLI prints the `channels add ` usage with the supported channel list instead of falling back to top-level help. @@ -1164,7 +1149,7 @@ If you omit the required `` argument, the CLI prints the `channels add Clear the stored credentials for a messaging channel and rebuild the sandbox so the image drops the channel. Running `remove` for a channel that was never configured is a no-op against the credentials file and still triggers the rebuild prompt. When the bridge provider is attached to a live sandbox, NemoClaw detaches it before deleting the provider from the OpenShell gateway. -If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone. +If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, or `whatsapp`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone. NemoClaw also strips the channel from `session.policyPresets` so a subsequent `onboard --resume` does not re-apply the preset on the next rebuild. For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-sandbox session directory before the rebuild so the `state_dirs` backup does not restore the auth blob and let the channel reconnect: @@ -1189,7 +1174,7 @@ Host-side removal is the supported path because agent channel config is baked in ### `$$nemoclaw channels stop ` -Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `teams`) without clearing its credentials. +Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`) without clearing its credentials. The channel is marked disabled in the per-sandbox registry, and the sandbox is rebuilt so the onboard step skips registering the bridge with the gateway. The provider stays registered with the OpenShell gateway, so a later `channels start` brings the bridge back without re-entering tokens. @@ -1699,7 +1684,7 @@ For a remote Brev instance, SSH to the instance and run `openshell term` there, Start optional host auxiliary services. This is the cloudflared tunnel when `cloudflared` is installed, which exposes the dashboard with a public URL. -Channel messaging is not started here; it is configured during `$$nemoclaw onboard` and runs through OpenShell-managed constructs. +Channel messaging (Telegram, Discord, Slack) is not started here; it is configured during `$$nemoclaw onboard` and runs through OpenShell-managed constructs. ```bash $$nemoclaw tunnel start diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 500d96b132..4fb8c04be4 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -54,9 +54,9 @@ GitHub access (`github.com`, `api.github.com`) is not included in the baseline p Apply the `github` preset during onboarding if your agent needs GitHub access. See [Customize the Network Policy](../network-policy/customize-network-policy). -The baseline policy does not include messaging endpoints for Telegram, Discord, Slack, WeChat, WhatsApp, or Microsoft Teams. +The baseline policy does not include messaging endpoints for Telegram, Discord, Slack, WeChat, or WhatsApp. Enable the channel during onboarding or apply the matching messaging preset so the sandbox can reach that platform. -WeChat, WhatsApp, and Microsoft Teams are experimental. +WeChat and WhatsApp are experimental. Review [Messaging Channels](../manage-sandboxes/messaging-channels) before enabling them. @@ -70,7 +70,7 @@ The baseline policy is always applied regardless of the selected tier. |------|------------------|-------------| | Restricted | None | Base sandbox only. No third-party network access beyond inference and core agent tooling. | | Balanced (default) | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `weather` | Full dev tooling, read-only weather lookups, and web search for agents that support web search. No messaging platform access. | -| Open | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `weather`, `public-reference`, `slack`, `discord`, `telegram`, `wechat` (experimental), `whatsapp` (experimental), `teams` (experimental), `jira`, `outlook` | Broad access across third-party services including messaging, productivity, weather, and public-reference APIs. | +| Open | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `weather`, `public-reference`, `slack`, `discord`, `telegram`, `wechat` (experimental), `whatsapp` (experimental), `jira`, `outlook` | Broad access across third-party services including messaging, productivity, weather, and public-reference APIs. | After selecting a tier, a combined preset and access-mode screen lets you include or exclude individual presets and toggle each between read (GET only) and read-write (GET + POST/PUT/PATCH) access. Tier-default presets are pre-selected; additional presets can be added from the full list. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 0b0068f2dd..27367e32a7 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -593,8 +593,8 @@ Follow these steps to reconnect. $$nemoclaw tunnel start ``` - OpenShell-managed channel messaging handles Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams at onboarding, not through a separate bridge process from `$$nemoclaw tunnel start`. - WeChat, WhatsApp, and Microsoft Teams are experimental. + OpenShell-managed channel messaging handles Telegram, Discord, Slack, WeChat, and WhatsApp at onboarding, not through a separate bridge process from `$$nemoclaw tunnel start`. + WeChat and WhatsApp are experimental. To pause a single bridge without destroying the sandbox, use `$$nemoclaw channels stop `. @@ -815,14 +815,14 @@ Run the equivalent host-side command instead: ```bash $$nemoclaw channels list -$$nemoclaw channels add -$$nemoclaw channels remove +$$nemoclaw channels add +$$nemoclaw channels remove ``` `channels add` registers credentials with the OpenShell gateway and `channels remove` clears them. Both offer to rebuild the sandbox so the image reflects the new channel set. In non-interactive mode (`NEMOCLAW_NON_INTERACTIVE=1`), the commands stage the change and leave the rebuild to a follow-up `$$nemoclaw rebuild`. -WeChat, WhatsApp, and Microsoft Teams are experimental. +WeChat and WhatsApp are experimental. Review [Messaging Channels](../manage-sandboxes/messaging-channels) before enabling them. WeChat captures its bot token through a host-side QR scan during `$$nemoclaw onboard` or `channels add wechat`. @@ -1535,7 +1535,7 @@ Skills that require macOS-only binaries cannot be enabled on Brev. Skills that require additional CLI binaries require a custom sandbox image rebuild. For credentials, use the supported host-side setup flow. -Re-run onboarding for inference or Brave Search credentials, or use `$$nemoclaw channels add ` for messaging channels. +Re-run onboarding for inference or Brave Search credentials, or use `$$nemoclaw channels add ` for messaging channels. To add a binary to the sandbox image, update the sandbox `Dockerfile.base` to install the required package, then rebuild: ```bash diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index f0e404c2af..bab83df377 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -175,7 +175,6 @@ NemoClaw ships preset policy files in `nemoclaw-blueprint/policies/presets/` for | `outlook` | Microsoft 365, Outlook. | Gives agent access to email. | | `pypi` | Python Package Index (GET and HEAD only). | Allows installing arbitrary Python packages, which may contain malicious code. Publishing is blocked. | | `slack` | Slack API, Socket Mode, webhooks. | WebSocket uses `access: full`. Agent can post to any channel the bot token has access to. | -| `teams` | Microsoft Teams Bot Framework and Graph API access. | Agent can send Teams messages through the configured app and can access Graph endpoints allowed by the preset. | | `telegram` | Telegram Bot API. | Agent can send messages to any chat the bot token has access to. | **Recommendation:** Apply presets only when the agent's task requires the integration. Review the preset's YAML file before applying to understand the endpoints, methods, and binary restrictions it adds. From 03c48d9b0846970e6974662b8a862cbcd581d55c Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 22 Jun 2026 11:52:53 +0700 Subject: [PATCH 07/11] fix(messaging): harden Teams onboarding plan --- agents/hermes/policy-additions.yaml | 3 - .../policies/presets/teams.yaml | 3 - .../applier/build/messaging-build-applier.mts | 13 +- src/lib/messaging/channels/manifests.test.ts | 6 +- src/lib/messaging/channels/metadata.test.ts | 4 +- src/lib/messaging/channels/teams/manifest.ts | 8 +- .../compiler/manifest-compiler.test.ts | 52 ++++-- .../messaging/compiler/manifest-compiler.ts | 15 +- .../compiler/workflow-planner.test.ts | 1 + .../onboard/messaging-host-forward.test.ts | 9 ++ src/lib/onboard/messaging-host-forward.ts | 7 +- test/messaging-build-applier.test.ts | 6 +- test/policies-teams.test.ts | 21 +++ test/process-recovery.test.ts | 153 ++++++++++++++++++ 14 files changed, 258 insertions(+), 43 deletions(-) diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index 3f13baed6f..ed2ce14876 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -300,9 +300,6 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } - - allow: { method: PATCH, path: "/**" } - - allow: { method: PUT, path: "/**" } - - allow: { method: DELETE, path: "/**" } - host: teams.microsoft.com port: 443 protocol: rest diff --git a/nemoclaw-blueprint/policies/presets/teams.yaml b/nemoclaw-blueprint/policies/presets/teams.yaml index 8ce2db8684..0ebbfbb181 100644 --- a/nemoclaw-blueprint/policies/presets/teams.yaml +++ b/nemoclaw-blueprint/policies/presets/teams.yaml @@ -55,9 +55,6 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } - - allow: { method: PATCH, path: "/**" } - - allow: { method: PUT, path: "/**" } - - allow: { method: DELETE, path: "/**" } # Read-only Teams/Office media surfaces referenced by Teams messages. - host: teams.microsoft.com port: 443 diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index e63c536af1..303d486dd4 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -112,6 +112,12 @@ type HermesUvPackageInstall = { readonly spec: string; }; +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 = @@ -885,13 +891,13 @@ function readHermesUvPipPackageInstall( } if (typeof install.spec !== "string" || install.spec.trim().length === 0) { throw new MessagingBuildApplierError( - `Messaging package-install output ${outputId} must include a Hermes Python package name`, + `Messaging package-install output ${outputId} must include a Hermes Python package spec`, ); } const spec = install.spec.trim(); - if (!/^[A-Za-z0-9_.-]+$/.test(spec)) { + if (!isPinnedHermesUvPackageSpec(spec)) { throw new MessagingBuildApplierError( - `Messaging package-install output ${outputId} has an unsafe Hermes Python package name`, + `Messaging package-install output ${outputId} must use a safe exact-pinned Hermes Python package spec`, ); } return { spec }; @@ -1389,6 +1395,7 @@ function installHermesMessagingUvPackages(plan: MessagingBuildPlan | null, env: "--python", "/opt/hermes/.venv/bin/python", "--no-cache", + "--", ...selectedPackages, ], env, diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index a259878890..e7bfc363f7 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -680,6 +680,7 @@ describe("built-in channel manifests", () => { 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(true); expect(webhookPort.envKey).toBe("MSTEAMS_PORT"); expect(webhookPort.envAliases).toEqual(["TEAMS_PORT"]); expect(webhookPort).toMatchObject({ kind: "config", defaultValue: "3978" }); @@ -746,6 +747,7 @@ describe("built-in channel manifests", () => { { id: "allowedUsers", kind: "config", + required: true, }, { id: "webhookPort", @@ -770,14 +772,14 @@ describe("built-in channel manifests", () => { id: "hermesTeamsAppsPackage", agent: "hermes", manager: "hermes-uv-pip", - spec: "microsoft-teams-apps", + spec: "microsoft-teams-apps==2.0.13.4", required: true, }); expect(teamsManifest.agentPackages).toContainEqual({ id: "hermesAiohttpPackage", agent: "hermes", manager: "hermes-uv-pip", - spec: "aiohttp", + spec: "aiohttp==3.14.1", required: true, }); expect(teamsManifest.state).toEqual({ diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index 34e653f8b8..ff25fe4c6e 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -159,14 +159,14 @@ describe("built-in messaging channel metadata", () => { packageId: "hermesTeamsAppsPackage", agents: ["hermes"], manager: "hermes-uv-pip", - spec: "microsoft-teams-apps", + spec: "microsoft-teams-apps==2.0.13.4", }, { channelId: "teams", packageId: "hermesAiohttpPackage", agents: ["hermes"], manager: "hermes-uv-pip", - spec: "aiohttp", + spec: "aiohttp==3.14.1", }, ]); }); diff --git a/src/lib/messaging/channels/teams/manifest.ts b/src/lib/messaging/channels/teams/manifest.ts index 9fcf1071ee..33894b7895 100644 --- a/src/lib/messaging/channels/teams/manifest.ts +++ b/src/lib/messaging/channels/teams/manifest.ts @@ -55,14 +55,13 @@ export const teamsManifest = { { id: "allowedUsers", kind: "config", - required: false, + required: true, 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.", - emptyValueMessage: "bot will rely on agent-side pairing or upstream defaults", }, }, { @@ -194,14 +193,14 @@ export const teamsManifest = { id: "hermesTeamsAppsPackage", agent: "hermes", manager: "hermes-uv-pip", - spec: "microsoft-teams-apps", + spec: "microsoft-teams-apps==2.0.13.4", required: true, }, { id: "hermesAiohttpPackage", agent: "hermes", manager: "hermes-uv-pip", - spec: "aiohttp", + spec: "aiohttp==3.14.1", required: true, }, ], @@ -283,6 +282,7 @@ export const teamsManifest = { { id: "allowedUsers", kind: "config", + required: true, }, { id: "webhookPort", diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 53e2e42cdc..19e2f7bfab 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -419,7 +419,7 @@ describe("ManifestCompiler", () => { required: true, value: { manager: "hermes-uv-pip", - spec: "microsoft-teams-apps", + spec: "microsoft-teams-apps==2.0.13.4", }, }, { @@ -429,7 +429,7 @@ describe("ManifestCompiler", () => { required: true, value: { manager: "hermes-uv-pip", - spec: "aiohttp", + spec: "aiohttp==3.14.1", }, }, ]); @@ -507,6 +507,7 @@ describe("ManifestCompiler", () => { { 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, @@ -550,6 +551,34 @@ describe("ManifestCompiler", () => { expect(JSON.stringify(plan.agentRender)).toContain('"requireMention":true'); }); + it("keeps Microsoft Teams disabled 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: false, + configured: true, + disabled: true, + }); + expect(JSON.stringify(plan.agentRender)).not.toContain("channels.msteams"); + }); + it("uses the configured Microsoft Teams webhook port for host forwarding", async () => { const plan = await withEnv( { @@ -681,8 +710,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 () => { @@ -762,7 +792,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", @@ -771,7 +801,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 () => { @@ -1179,7 +1209,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", @@ -1199,11 +1229,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", @@ -1212,7 +1238,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 f3e78a99eb..d44e8b63f4 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -64,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, @@ -77,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, @@ -95,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, diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 4e8a01f357..5a31d8013e 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -672,6 +672,7 @@ describe("MessagingWorkflowPlanner", () => { { 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 () => { diff --git a/src/lib/onboard/messaging-host-forward.test.ts b/src/lib/onboard/messaging-host-forward.test.ts index 1bbbccfffb..4656a3e6ff 100644 --- a/src/lib/onboard/messaging-host-forward.test.ts +++ b/src/lib/onboard/messaging-host-forward.test.ts @@ -93,6 +93,15 @@ describe("ensureMessagingHostForwardIfConfigured", () => { }); }); + 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(); diff --git a/src/lib/onboard/messaging-host-forward.ts b/src/lib/onboard/messaging-host-forward.ts index c2dbde7b2f..6aeaa7331d 100644 --- a/src/lib/onboard/messaging-host-forward.ts +++ b/src/lib/onboard/messaging-host-forward.ts @@ -32,10 +32,9 @@ export interface MessagingHostForwardRollbackOptions { export function resolveMessagingHostForward( plan: SandboxMessagingPlan | null | undefined, ): SandboxMessagingHostForwardPlan | null { - const normalizedPlan = plan ? (parseSandboxMessagingPlan(plan) ?? plan) : null; - const hydratedPlan = normalizedPlan - ? hydrateDerivedSandboxMessagingPlanFields(normalizedPlan) - : null; + const normalizedPlan = plan ? parseSandboxMessagingPlan(plan) : null; + if (!normalizedPlan) return null; + const hydratedPlan = hydrateDerivedSandboxMessagingPlanFields(normalizedPlan); return getActiveMessagingHostForward(hydratedPlan); } diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 0cfbf57eff..6da1f421c1 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -512,8 +512,8 @@ describe("messaging-build-applier.mts: agent-install", () => { ); expect(dryRun.status, dryRun.stderr).toBe(0); expect(JSON.parse(dryRun.stdout).hermesUvPackages).toEqual([ - "microsoft-teams-apps", - "aiohttp", + "microsoft-teams-apps==2.0.13.4", + "aiohttp==3.14.1", ]); const result = spawnSync( @@ -536,7 +536,7 @@ describe("messaging-build-applier.mts: agent-install", () => { 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 aiohttp", + "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 }); diff --git a/test/policies-teams.test.ts b/test/policies-teams.test.ts index dd4ef17257..9b38da39dd 100644 --- a/test/policies-teams.test.ts +++ b/test/policies-teams.test.ts @@ -28,6 +28,18 @@ function parseResultPayload(stdout: string): any { return JSON.parse(stdout.slice(markerIndex + marker.length)); } +function allowedMethods( + policy: { endpoints: Array<{ host?: string; rules?: Array<{ allow?: { method?: string } }> }> }, + host: string, +): string[] { + const endpoint = policy.endpoints.find((entry) => entry.host === host); + expect(endpoint).toBeTruthy(); + return (endpoint?.rules ?? []) + .map((rule) => rule.allow?.method) + .filter((method): method is string => typeof method === "string") + .sort(); +} + describe("Teams policy preset", () => { it("extracts Microsoft Teams Bot Framework and Graph hosts", () => { const content = requirePresetContent(policies.loadPreset("teams")); @@ -38,6 +50,12 @@ describe("Teams policy preset", () => { 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", "POST"]); + 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", () => { @@ -122,6 +140,9 @@ exit 1 "*.sharepoint.com", ]), ); + expect(allowedMethods(teamsPolicy, "graph.microsoft.com")).toEqual(["GET", "POST"]); + 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/process-recovery.test.ts b/test/process-recovery.test.ts index b79d4b90e4..cf6d833f6f 100644 --- a/test/process-recovery.test.ts +++ b/test/process-recovery.test.ts @@ -39,6 +39,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( @@ -244,6 +282,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"); From 23325a9244119d519a74259e17d3b12bb5db3dca Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 22 Jun 2026 14:31:26 +0700 Subject: [PATCH 08/11] fix(messaging): keep Teams allowlist optional --- src/lib/messaging/channels/manifests.test.ts | 3 +-- src/lib/messaging/channels/teams/manifest.ts | 3 +-- src/lib/messaging/compiler/manifest-compiler.test.ts | 11 +++++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index e7bfc363f7..8d08c248b1 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -680,7 +680,7 @@ describe("built-in channel manifests", () => { 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(true); + expect(allowedUsers.required).toBe(false); expect(webhookPort.envKey).toBe("MSTEAMS_PORT"); expect(webhookPort.envAliases).toEqual(["TEAMS_PORT"]); expect(webhookPort).toMatchObject({ kind: "config", defaultValue: "3978" }); @@ -747,7 +747,6 @@ describe("built-in channel manifests", () => { { id: "allowedUsers", kind: "config", - required: true, }, { id: "webhookPort", diff --git a/src/lib/messaging/channels/teams/manifest.ts b/src/lib/messaging/channels/teams/manifest.ts index 33894b7895..22ee3e378f 100644 --- a/src/lib/messaging/channels/teams/manifest.ts +++ b/src/lib/messaging/channels/teams/manifest.ts @@ -55,7 +55,7 @@ export const teamsManifest = { { id: "allowedUsers", kind: "config", - required: true, + required: false, envKey: "TEAMS_ALLOWED_USERS", envAliases: ["MSTEAMS_ALLOWED_USERS"], statePath: "allowedIds.teams", @@ -282,7 +282,6 @@ export const teamsManifest = { { id: "allowedUsers", kind: "config", - required: true, }, { id: "webhookPort", diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 19e2f7bfab..a57cfdc550 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -551,7 +551,7 @@ describe("ManifestCompiler", () => { expect(JSON.stringify(plan.agentRender)).toContain('"requireMention":true'); }); - it("keeps Microsoft Teams disabled when no explicit user allowlist is provided", async () => { + it("keeps Microsoft Teams active when no explicit user allowlist is provided", async () => { const plan = await withEnv( { MSTEAMS_APP_ID: "test-teams-app-id", @@ -572,11 +572,14 @@ describe("ManifestCompiler", () => { ); expect(plan.channels.find((channel) => channel.channelId === "teams")).toMatchObject({ - active: false, + active: true, configured: true, - disabled: true, + disabled: false, }); - expect(JSON.stringify(plan.agentRender)).not.toContain("channels.msteams"); + 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 () => { From 569a752192dcffbe6d0f950920e19b177c74a2a6 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 23 Jun 2026 11:34:29 +0700 Subject: [PATCH 09/11] fix(messaging): validate Hermes package installs --- .../applier/build/messaging-build-applier.mts | 49 +++++++++++++++ test/messaging-build-applier.test.ts | 60 +++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 303d486dd4..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; @@ -112,6 +119,15 @@ 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, @@ -449,6 +465,7 @@ function collectHermesMessagingUvPackageInstalls( ): 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) { @@ -460,6 +477,11 @@ function collectHermesMessagingUvPackageInstalls( 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); @@ -467,6 +489,33 @@ function collectHermesMessagingUvPackageInstalls( 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, diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 6da1f421c1..5538068689 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -105,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({ @@ -543,6 +551,58 @@ describe("messaging-build-applier.mts: agent-install", () => { } }); + 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"); From 8ab7b004ff2222d4510db327cd94072579d0a06b Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 23 Jun 2026 13:01:26 +0700 Subject: [PATCH 10/11] fix(policy): restrict Teams Graph egress --- agents/hermes/policy-additions.yaml | 4 ++- .../policies/presets/teams.yaml | 4 ++- test/policies-teams.test.ts | 36 +++++++++++++++---- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index ed2ce14876..fa52431a96 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -282,6 +282,9 @@ network_policies: 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 @@ -299,7 +302,6 @@ network_policies: request_body_credential_rewrite: true rules: - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - host: teams.microsoft.com port: 443 protocol: rest diff --git a/nemoclaw-blueprint/policies/presets/teams.yaml b/nemoclaw-blueprint/policies/presets/teams.yaml index 0ebbfbb181..b567e93686 100644 --- a/nemoclaw-blueprint/policies/presets/teams.yaml +++ b/nemoclaw-blueprint/policies/presets/teams.yaml @@ -36,6 +36,9 @@ network_policies: - 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 @@ -54,7 +57,6 @@ network_policies: request_body_credential_rewrite: true rules: - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } # Read-only Teams/Office media surfaces referenced by Teams messages. - host: teams.microsoft.com port: 443 diff --git a/test/policies-teams.test.ts b/test/policies-teams.test.ts index 9b38da39dd..04aba13e33 100644 --- a/test/policies-teams.test.ts +++ b/test/policies-teams.test.ts @@ -32,14 +32,26 @@ function allowedMethods( policy: { endpoints: Array<{ host?: string; rules?: Array<{ allow?: { method?: string } }> }> }, host: string, ): string[] { - const endpoint = policy.endpoints.find((entry) => entry.host === host); - expect(endpoint).toBeTruthy(); - return (endpoint?.rules ?? []) - .map((rule) => rule.allow?.method) + 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")); @@ -51,7 +63,13 @@ describe("Teams policy preset", () => { 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", "POST"]); + 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"]); @@ -140,7 +158,13 @@ exit 1 "*.sharepoint.com", ]), ); - expect(allowedMethods(teamsPolicy, "graph.microsoft.com")).toEqual(["GET", "POST"]); + 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"]); From c130af93a4ca3f99d2dde2b5bc691b23e5913237 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 24 Jun 2026 19:22:52 +0700 Subject: [PATCH 11/11] fix(messaging): guard Teams host forward after rebuild --- .../messaging-host-forward-lifecycle.ts | 70 ++++++ .../sandbox/policy-channel-conflict.test.ts | 212 +++++++++++++++++- src/lib/actions/sandbox/policy-channel.ts | 26 ++- src/lib/actions/sandbox/rebuild-flow.test.ts | 95 ++++++++ src/lib/actions/sandbox/rebuild.ts | 17 +- 5 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 src/lib/actions/sandbox/messaging-host-forward-lifecycle.ts 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 8fb1973d0c..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 }); @@ -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 e363e55c19..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,9 +760,9 @@ 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, @@ -778,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( @@ -973,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; } @@ -1018,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( @@ -1382,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/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 f7c2723cfb..bdf05fd4a0 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -70,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 { @@ -1006,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 @@ -1098,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( @@ -1133,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,