Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion agents/hermes/config/hermes-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function buildHermesConfig(settings: HermesBuildSettings): Record<string,
// API server — internal port only.
// Hermes binds to 127.0.0.1 regardless of config (upstream bug).
// socat in start.sh forwards 0.0.0.0:8642 -> 127.0.0.1:18642.
config.platforms = {
const platforms: Record<string, unknown> = {
api_server: {
enabled: true,
extra: {
Expand All @@ -109,5 +109,11 @@ export function buildHermesConfig(settings: HermesBuildSettings): Record<string,
},
};

if (settings.messaging.enabledChannels.has("slack")) {
platforms.slack = { enabled: true };
}

config.platforms = platforms;

return config;
}
1 change: 1 addition & 0 deletions test/e2e-scenario/manifests/hermes-nvidia-slack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ spec:
credentialRefs:
- NVIDIA_API_KEY
- SLACK_BOT_TOKEN
- SLACK_APP_TOKEN
2 changes: 1 addition & 1 deletion test/e2e-scenario/scenarios/scenarios/baseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ const canonicalScenarioInputs: CanonicalScenarioInput[] = [
environment: ubuntuRepoDocker("cloud-nvidia-hermes-slack"),
expectedStateId: "cloud-hermes-ready",
suiteIds: ["smoke"],
requiredSecrets: ["NVIDIA_API_KEY", "SLACK_BOT_TOKEN"],
requiredSecrets: ["NVIDIA_API_KEY", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"],
},
{
id: "ubuntu-repo-cloud-openclaw-resume",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ case "${provider}" in
*) e2e_fail "expected-state.messaging.slack.provider-state expected slack provider, got ${provider}" ;;
esac
e2e_messaging_assert_provider_attached
if [[ "$(e2e_context_get E2E_AGENT)" == "openclaw" ]]; then
agent="$(e2e_context_get E2E_AGENT)"
if [[ "${agent}" == "openclaw" ]]; then
if [[ -n "${E2E_DRY_RUN:-}" ]]; then
e2e_pass "expected-state.messaging.slack.openclaw-enabled dry-run"
e2e_pass "expected-state.messaging.slack.runtime-discovery dry-run"
Expand Down Expand Up @@ -50,4 +51,101 @@ except Exception as exc:
e2e_pass "expected-state.messaging.slack.runtime-discovery OpenClaw reports Slack installed and configured"
fi
fi
if [[ "${agent}" == "hermes" ]]; then
# This scenario asserts the static enablement contract Hermes' gateway uses
# to start its Slack adapter:
# 1) config.yaml carries platforms.slack.enabled=true so the gateway
# instantiates the Slack platform at boot. Without it, Hermes runs only
# api_server and slack_bolt never starts.
# 2) gateway.log shows the Slack adapter completed Socket Mode connection
# and the Bolt app reached the running state.
# 3) SLACK_ALLOWED_CHANNELS, when configured, is present in .env so the
# allowlist values reach the adapter's environment.
if [[ -n "${E2E_DRY_RUN:-}" ]]; then
e2e_pass "expected-state.messaging.slack.hermes-platforms-enabled dry-run"
e2e_pass "expected-state.messaging.slack.hermes-allowed-channels-scoped dry-run"
e2e_pass "expected-state.messaging.slack.hermes-gateway-running dry-run"
else
sandbox_name="$(e2e_context_get E2E_SANDBOX_NAME)"
# The Hermes venv is the same Python that loads config.yaml at runtime, so
# PyYAML is guaranteed there even when the host runner ships a minimal
# python3. Parsing inside the sandbox removes the awk fallback path.
platforms_state="$(openshell sandbox exec --name "${sandbox_name}" -- /opt/hermes/.venv/bin/python -c '
import sys
import yaml

try:
with open("/sandbox/.hermes/config.yaml", "r", encoding="utf-8") as fh:
cfg = yaml.safe_load(fh) or {}
except FileNotFoundError:
print("missing-config")
sys.exit(0)
except Exception as exc:
print("error %s" % exc)
sys.exit(0)
platforms = cfg.get("platforms") or {}
slack = platforms.get("slack") or {}
if isinstance(slack, dict) and slack.get("enabled") is True:
print("yes")
else:
print("no slack=%r" % (slack,))
' 2>/dev/null || true)"
case "${platforms_state}" in
yes)
e2e_pass "expected-state.messaging.slack.hermes-platforms-enabled platforms.slack.enabled true in config.yaml"
;;
missing-config)
e2e_fail "expected-state.messaging.slack.hermes-platforms-enabled /sandbox/.hermes/config.yaml not found"
;;
*)
e2e_fail "expected-state.messaging.slack.hermes-platforms-enabled platforms.slack.enabled not true (${platforms_state})"
;;
esac

env_state="$(openshell sandbox exec --name "${sandbox_name}" -- sh -c 'grep -E "^SLACK_ALLOWED_CHANNELS=" /sandbox/.hermes/.env 2>/dev/null | head -n1' 2>/dev/null || true)"
case "${env_state}" in
SLACK_ALLOWED_CHANNELS=*[!\ ]*)
e2e_pass "expected-state.messaging.slack.hermes-allowed-channels-scoped allowlist present in .env"
;;
"")
e2e_pass "expected-state.messaging.slack.hermes-allowed-channels-scoped no channel allowlist requested (open scope)"
;;
*)
e2e_fail "expected-state.messaging.slack.hermes-allowed-channels-scoped malformed SLACK_ALLOWED_CHANNELS entry"
;;
esac

# Hermes ships two surfaces that carry the gateway boot trace:
# - /sandbox/.hermes/logs/gateway.log: Hermes' own structured logger.
# - <tmpdir>/gateway.log: stdout captured by agents/hermes/start.sh:862,910
# when `hermes gateway run` is supervised by the entrypoint.
# Tail both; either is acceptable evidence the Slack platform booted.
tmp_dir=/tmp
gateway_log_basename=gateway.log
gateway_log=""
for log_path in "/sandbox/.hermes/logs/${gateway_log_basename}" "${tmp_dir}/${gateway_log_basename}"; do
chunk="$(openshell sandbox exec --name "${sandbox_name}" -- sh -c "tail -n 200 ${log_path} 2>/dev/null || true" 2>/dev/null || true)"
if [[ -n "${chunk}" ]]; then
if [[ -n "${gateway_log}" ]]; then
gateway_log="${gateway_log}"$'\n'"${chunk}"
else
gateway_log="${chunk}"
fi
fi
done
if [[ -z "${gateway_log}" ]]; then
e2e_fail "expected-state.messaging.slack.hermes-gateway-running could not read gateway log from sandbox or entrypoint surface"
fi
if printf '%s\n' "${gateway_log}" | grep -qE '\[Slack\] Socket Mode connected|✓ slack connected|slack_bolt\.AsyncApp.*Bolt app is running'; then
e2e_pass "expected-state.messaging.slack.hermes-gateway-running gateway booted slack platform"
else
sanitized_tail="$(printf '%s\n' "${gateway_log}" | tail -n 20 | sed -E \
-e 's/xox[bpaors]-[A-Za-z0-9-]+/<redacted-slack-token>/g' \
-e 's/xapp-[A-Za-z0-9-]+/<redacted-slack-app-token>/g' \
-e 's/[Tt][0-9A-Z]{8,}/<redacted-team-id>/g' \
-e 's/[UCWBDG][0-9A-Z]{8,}/<redacted-slack-id>/g')"
e2e_fail "expected-state.messaging.slack.hermes-gateway-running gateway log shows slack platform never started (sanitized tail: ${sanitized_tail})"
fi
fi
fi
e2e_pass "expected-state.messaging.slack.provider-state ${provider} provider state configured"
12 changes: 9 additions & 3 deletions test/e2e/test-hermes-slack-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,14 @@ config_text = Path("/sandbox/.hermes/config.yaml").read_text(encoding="utf-8")
cfg = yaml.safe_load(config_text) or {}
errors = []
platforms = cfg.get("platforms")
if isinstance(platforms, dict) and "slack" in platforms:
errors.append("platforms.slack present")
if not isinstance(platforms, dict):
errors.append("platforms map missing or not a mapping")
else:
slack = platforms.get("slack")
if not isinstance(slack, dict):
errors.append("platforms.slack missing or not a mapping")
elif slack.get("enabled") is not True:
errors.append(f"platforms.slack.enabled is not true ({slack!r})")
if "SLACK_BOT_TOKEN" in config_text or "SLACK_APP_TOKEN" in config_text:
errors.append("config.yaml contains Slack token env keys")
if errors:
Expand All @@ -350,7 +356,7 @@ PY
)

if [ "$config_probe" = "OK" ]; then
pass "config.yaml has no generic platforms.slack block or Slack token keys"
pass "config.yaml enables platforms.slack and contains no Slack token keys"
else
fail "config.yaml check failed: ${config_probe:0:400}"
fi
Expand Down
22 changes: 20 additions & 2 deletions test/generate-hermes-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ describe("agents/hermes/generate-config.ts", () => {
expect(envFile).not.toContain("DISCORD_ALLOWED_USERS=");
});

it("does not emit generic platforms blocks for Telegram or Slack messaging tokens", () => {
it("enables Slack under platforms and keeps Telegram top-level only when messaging tokens are configured", () => {
const { config, envFile } = runConfigScript({
NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson(["telegram", "slack"]),
NEMOCLAW_MESSAGING_ALLOWED_IDS_B64: encodeJson({
Expand All @@ -253,7 +253,7 @@ describe("agents/hermes/generate-config.ts", () => {

expect(config.telegram).toEqual({ require_mention: true });
expect(config.platforms.telegram).toBeUndefined();
expect(config.platforms.slack).toBeUndefined();
expect(config.platforms.slack).toEqual({ enabled: true });
expect(envFile).toContain("TELEGRAM_BOT_TOKEN=openshell:resolve:env:TELEGRAM_BOT_TOKEN\n");
expect(envFile).toContain("TELEGRAM_ALLOWED_USERS=123456789\n");
expect(envFile).toContain(
Expand All @@ -268,6 +268,24 @@ describe("agents/hermes/generate-config.ts", () => {
expect(envFile).toContain("SLACK_ALLOWED_CHANNELS=C012AB3CD,C987ZY6XW\n");
});

it("omits platforms.slack when Slack channel is not enabled", () => {
const { config } = runConfigScript({
NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson([]),
});

expect(config.platforms.slack).toBeUndefined();
expect(Object.keys(config.platforms)).toEqual(["api_server"]);
});

it("enables Slack under platforms even when the slack token allowlist is empty", () => {
const { config } = runConfigScript({
NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson(["slack"]),
});

expect(config.platforms.slack).toEqual({ enabled: true });
expect(config.platforms.api_server.enabled).toBe(true);
});

it("bridges captured WeChat metadata to Hermes' WEIXIN_* env contract", () => {
// Hermes' adapter reads WEIXIN_TOKEN + WEIXIN_ACCOUNT_ID (plus optional
// WEIXIN_BASE_URL, WEIXIN_ALLOWED_USERS) per
Expand Down
Loading