Skip to content

Commit d27b05e

Browse files
authored
fix: hermes deployment fixes (env block, oauth gzip, mcp mount, branding) (#37)
* fix: hermes deployment fixes (env block, oauth gzip, mcp mount, branding) Six fixes turned up while migrating a production deployment from openclaw to hermes. Each had a tractable root cause and a real symptom that broke the deploy. - proxy/addon: OAuth response handler now decompresses gzip/br/deflate before parsing the token JSON. The old code parsed raw \x1f\x8b bytes as JSON, then panicked on the parse failure and took the flow's response down with it. Reproduced live against auth.openai.com which returns gzip by default. Wrapped the handler in a deferred recover so future malformed responses surface as errors instead of panics, and stripped Content-Encoding after rewrite so the client does not try to gunzip plaintext. - container/types: env injection no longer truncates the agent's env file. Sluice writes its phantom tokens into a fenced "BEGIN/END sluice-managed" block and replaces only that block on each call. Foreign keys (set by `hermes claw migrate`, the agent's own auth flow, or an operator) survive every injection. The `fullReplace` flag is retained for API compatibility but no longer affects file behavior. Keys are sorted for diff-friendly output. - cmd/sluice: MCP /mcp endpoint mounts unconditionally, not only when at least one upstream is registered. With zero upstreams the gateway exposes an empty tool list, which is a valid MCP response. This avoids the chicken-and-egg where an agent's MCP client gives up on a 404 before the operator can register the first upstream. - container/agent_profile: HermesProfile.WireMCPCmd now invokes python3 through a sh wrapper that activates /opt/hermes/.venv when present. PyYAML lives in the venv on the official Hermes Docker image but is not on the system Python path; the previous bare `python3 -c` failed with ModuleNotFoundError on every wire-up. Native installs without the venv use the system python3. - telegram/bot: agent name in approval messages is configurable via SetAgentDisplayName; the OpenClaw label is no longer hardcoded. Sluice's main wires this from --agent profile name at startup, so Hermes deployments now read "Hermes wants to connect to..." in Telegram. - container/types: BuildEnvInjectionScript validates EnvFileRelPath and escapes sed regex metacharacters on the marker comment so future markers can be edited without breaking the script. All six are covered by new unit tests. * chore(deploy): switch repo compose/Caddy to hermes; add bootstrap.sh The OpenClaw production compose lived in compose.yml at the repo root since the original setup. Now that Hermes is the supported agent, the canonical deploy files in this repo target Hermes too. - compose.yml: new sluice (--agent hermes) + tun2proxy + hermes gateway + hermes-dashboard sidecar + caddy front. Sluice image pulled from ghcr.io/nnemirovsky/sluice:latest. Hermes image pinned to nousresearch/hermes-agent:v2026.4.30. HERMES_HOME is overridden to /opt/data/.hermes so paths align with sluice's HermesProfile. - compose.dev.yml: same shape but builds sluice from local source for the dev loop. Drops caddy + dashboard sidecar. - Caddyfile: cert paths moved off provider-specific /etc/cloudflare to standard FHS /etc/ssl/certs/agent.pem and /etc/ssl/private/agent.key, and reverse_proxy now targets the hermes-tun2proxy-1 dashboard port. - bootstrap.sh: one-shot helper that imports an existing OpenClaw home volume into the Hermes home volume via `hermes claw migrate`, then pre-writes mcp_servers.sluice.url into ~/.hermes/config.yaml. Skips cleanly when no legacy volume is found, so it is safe to invoke on fresh installs too. The old OpenClaw compose is gone. Operators still on OpenClaw can recover it from any v0.12.x tag if needed; the migration path forward is bootstrap.sh + the new compose.yml. * fix: address copilot review on PR #37 - proxy/addon: snapshot body + Content-Encoding/Length/Transfer-Encoding before decompressing the OAuth response. Restore the snapshot on any error or panic after the decompress so a partial mid-rewrite cannot send the client plaintext bytes with stripped encoding headers (which, for token responses, would mean real tokens delivered with no phantom swap). The flow now ends in one of two states: fully swapped, or untouched. - telegram/bot: HTML-escape agentDisplayName at render time. SetAgent- DisplayName already filters empty values; the escape adds a second line of defense so a future profile name containing '<', '&', etc cannot break the message structure or inject markup. - container/types: replace `sed '/BEGIN/,/END/d'` with an awk-based conditional delete that runs only when both BEGIN and END markers exist in the file. The old sed range would have happily deleted from BEGIN to end-of-file when END was missing (truncated write, manual edit), nuking foreign keys the marker design promises to preserve. The awk script writes to a sibling temp file and renames in place so a crash mid-rewrite leaves the original env file intact. `bsdSed` is kept in the signature for API compatibility but is no longer consulted (awk is portable across BSD and GNU). - container/types: refresh the ContainerManager.InjectEnvVars doc comment to describe the marker-block reconciliation semantics instead of the old merge-vs-truncate split. - container: rename TestHermesProfile_WireMCPUsesPython to TestHermesProfile_WireMCPUsesVenvWrapper now that the assertion is about the sh wrapper that activates the bundled venv. * fix: address copilot review round 2 on PR #37 - proxy/addon: processAddonOAuthResponse now sets `modified` based on a byte-equality check between the pre-swap and post-swap bodies. A token endpoint that echoes already-phantom tokens (e.g. on a retry after the upstream rotated and we already swapped on the previous attempt) was logging "swapped to phantoms" with metrics bumps even though zero bytes changed. The function now rolls back its decode in the no-op case so the encoding/length headers continue to advertise the original wire form. - Caddyfile: reverse_proxy now targets the docker compose service name `tun2proxy:9119` instead of the project-prefixed container name. The dashboard sidecar shares tun2proxy's namespace, so the service name reaches it on any internal network. Survives renames of the compose project and replica index changes. - bootstrap.sh: typo fix ("read read-only" -> "mounted read-only") and BOOTSTRAP_OVERWRITE env var so a re-run can opt out of the --overwrite flag and let migrate refuse on conflicts. Header comment now describes the actual overwrite semantics instead of claiming the script is unconditionally idempotent. * fix: address copilot review round 3 on PR #37 - container/types: BuildEnvInjectionScript docstring no longer references bsdSed/sed semantics. The implementation switched to awk in round 1 of this PR; the doc comment lagged. Now describes the awk-based block reconciliation accurately. - container/types: env var values are now validated for newlines and NUL bytes via the new validateEnvVarValue helper. The previous doc claimed values were "single-quoted to prevent shell injection", which is true at the shell-command layer but did not stop a value with an embedded newline from splitting one logical entry into two lines of the env file (the second line would either drop or be parsed as a separate KEY=value when sourced). The escape comment now describes the actual quoting model. * fix: ssh proxy exit-status race + copilot round 4 Two findings, one squashed commit because they are interleaved by the copilot review thread on this PR. - proxy/ssh: sshHandleChannel previously called srcChan.CloseWrite from the upstream→agent data-copy goroutine the moment it saw EOF on the upstream channel. That CloseWrite (SSH_MSG_CHANNEL_EOF) raced the request-forwarder goroutine writing exit-status on the same channel. Depending on goroutine schedule, the agent could observe EOF and channel-close BEFORE the exit-status request reached the wire, which surfaces as `session.Wait` returning EOF instead of a clean exit. TestCredential_SSHInjection reproduced this 10-20% of the time locally and one in N runs in CI. Fix: hold the agent-side stdout EOF until all three upstream→agent goroutines (request, data, stderr) have drained, then issue CloseWrite followed by Close. The agent now sees the documented order: data, exit-status, EOF, close. Inputs from the agent (stdin → upstream) still get EOF'd as soon as the agent half-closes, so upstream `cat`-style commands keep terminating correctly. - e2e/credential_test: tightened the test SSH server to send exit-status before half-closing the data side. This matches what well-behaved SSH servers do and removes one cause of the same ordering race on the server side. - container/types: BuildEnvInjectionScriptForProfile now counts non-empty values up front and skips the marker-block emission entirely when nothing in envMap has content. The previous code emitted a stray `BEGIN sluice-managed ... END sluice-managed` pair with no entries when every value was empty (the per-call delete semantic), contradicting the "skip when empty" comment. * fix: address copilot review round 4 on PR #37 - container/types: env file values are now written as `KEY='value'` with single-quoted values via a quoted-tag heredoc instead of `KEY=value` (unquoted). The previous format relied on shell echo quoting which prevented script-level injection but did NOT survive re-sourcing the file. Production compose loads the env file with `set -a; . file; set +a`, so an unquoted value containing `$`, `$()`, backticks, spaces, or globs would have been re-expanded by the shell at source time. Single quotes around values neutralize every shell metacharacter; embedded single quotes are escaped via the standard `'\''` idiom (close, escaped quote, reopen). New round-trip test runs the generated script in a real shell, sources the file, and confirms values like `pa$$word`, `it's`, `back\`tick\`` reach the environment unchanged. - bootstrap.sh: HERMES_UID and HERMES_GID are now honored from env vars (with the upstream-image defaults of 10000/10000) and applied consistently to every chown across all docker run invocations. An operator who pinned non-default UID/GID via compose.yml previously ended up with migrated files owned by 10000:10000 and unreadable to the runtime user. - bootstrap.sh: the venv activation step is now guarded by a `[ -f /opt/hermes/.venv/bin/activate ] && . ...` check that mirrors the runtime wrapper in HermesProfile.WireMCPCmd. A custom Hermes image without the venv now falls through to system python3 and emits a clear error if PyYAML is missing, instead of failing on a non-existent activate script. - Caddyfile: comment now explicitly says the dashboard is reached *via* tun2proxy because the dashboard sidecar uses network_mode: "service:tun2proxy" and has no internal-network identity of its own. Less likely to mislead a reader trying to swap the proxy target. * fix: address copilot review round 5 on PR #37 - container/types: refresh validateEnvVarValue and the BuildEnvInjectionScript docstring to describe the quoted-tag heredoc + KEY='value' format. The previous comments referenced the old echo-based mechanism, and the example escape idiom was rendered with a smart quote that did not match the actual code. - container/types: harden the awk pre-pass that removes the managed block. The old version triggered range-delete behavior whenever both BEGIN and END markers appeared anywhere in the file; if an operator manually shuffled the block (END before BEGIN, or duplicated markers) it could nuke foreign keys after an unmatched BEGIN. The new pass walks the file once, records every well-formed BEGIN..END pair as a contiguous range to delete, then prints every line not in the delete set. Orphan markers stay verbatim so a corrupted file shows what is wrong rather than silently dropping content. - CLAUDE.md: env-block example now uses single-quoted values (KEY1='phantom-value-1') matching the production format, and documents the awk well-formed-pair semantic so operators hand-editing the file know which marker layouts are honored. - CLAUDE.md: the Hermes profile row in the Agent Profiles table now describes the sh wrapper that activates Hermes' bundled venv before exec'ing python3, instead of the old bare `python3 -c <script>` form that no longer matches the code. * fix: address copilot review round 6 on PR #37 - container/types: awkStringEscape no longer doubles backslashes. In shell single-quoted strings, only the apostrophe needs the break-out-and-rejoin escape; every other byte (backslash included) is taken literally. Doubling backslashes would have silently changed the marker text awk sees vs the bytes in the file if EnvBlockBegin or EnvBlockEnd were ever edited to include a backslash, breaking the pre-pass match. - cmd/sluice: phase 3 (MCP wire-up) comments and log messages no longer hard-code openclaw.json / mcp.servers.sluice.url. The comment now describes the profile-specific dispatch (openclaw uses the gateway RPC, hermes patches mcp_servers in ~/.hermes/config.yaml, future profiles plug their own WireMCPCmd), and the success/error logs include the active profile name so an operator reading the tail of `docker compose logs sluice` sees which agent the wiring targeted.
1 parent 9cd1a3e commit d27b05e

16 files changed

Lines changed: 1065 additions & 160 deletions

File tree

CLAUDE.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,31 @@ Profiles abstract per-agent runtime conventions so sluice's container managers s
125125
| Profile | Env file | Reload | MCP wiring |
126126
|---------|----------|--------|------------|
127127
| `openclaw` (default) | `~/.openclaw/.env` | `node -e <gateway_rpc.js> secrets.reload` over the agent's WebSocket gateway | `node -e <gateway_rpc.js> wire-mcp <name> <url>` patches `mcp.servers.<name>` |
128-
| `hermes` | `~/.hermes/.env` | None — Hermes has no documented in-place reload; new env values take effect on next message / restart | `python3 -c <script> <name> <url>` patches `mcp_servers.<name>.url` in `~/.hermes/config.yaml` (idempotent; relies on PyYAML which Hermes already requires) |
128+
| `hermes` | `~/.hermes/.env` | None — Hermes has no documented in-place reload; new env values take effect on next message / restart | `sh -c '[ -f /opt/hermes/.venv/bin/activate ] && . /opt/hermes/.venv/bin/activate; exec python3 -c <script>' <name> <url>` patches `mcp_servers.<name>.url` in `~/.hermes/config.yaml` (idempotent; activates Hermes' bundled venv when present so PyYAML is importable, falls back to system python3 for native installs) |
129129

130130
Adding a new profile is a single edit to `internal/container/agent_profile.go`: register a struct in `builtinProfiles`. All three container backends (Docker, Apple Container, tart) consume the profile through `BuildEnvInjectionScriptForProfile`, `profile.ReloadCmd()`, and `profile.WireMCPCmd()`, so backend code does not need to know about specific agents.
131131

132132
Hermes-specific caveats:
133133

134134
- `ReloadCmd` is nil; `ReloadSecrets` logs a notice and returns nil. New phantom tokens take effect on the next Hermes message or `/reload-mcp` slash command.
135-
- `WireMCPCmd` rewrites `~/.hermes/config.yaml` directly. Hermes picks up the change on its next startup or via `/reload-mcp` from the chat session — sluice cannot trigger that command remotely.
135+
- `WireMCPCmd` rewrites `~/.hermes/config.yaml` directly via a sh wrapper that activates `/opt/hermes/.venv` (so PyYAML is on the import path inside the official Hermes Docker image). For native installs without the venv the activation is a no-op and the system `python3` is used. Hermes picks up the change on its next startup or via `/reload-mcp` from the chat session — sluice cannot trigger that command remotely.
136136
- Hermes' Modal, Daytona, and Vercel Sandbox terminal backends run code on third-party infrastructure that sluice cannot intercept. The local and Docker Hermes backends are the supported targets for sluice's network-layer governance.
137137

138+
### Sluice-managed env block
139+
140+
Sluice writes its phantom tokens into a fenced block inside the agent's env file:
141+
142+
```
143+
# BEGIN sluice-managed (do not edit)
144+
KEY1='phantom-value-1'
145+
KEY2='phantom-value-2'
146+
# END sluice-managed
147+
```
148+
149+
Values are wrapped in single quotes so the file is safe under both shell `source` (`set -a; . file; set +a`) and dotenv parsers. Embedded single quotes inside a value are escaped via the `'\''` idiom (close current quoted run, emit a backslash-escaped literal quote, reopen the quoted run). Validation rejects newlines and NUL bytes; every other byte (`$`, backticks, spaces, glob chars) is safe because the single quoting suppresses all expansion.
150+
151+
Each injection rebuilds the block: existing markers are removed via an awk pre-pass that only deletes well-formed BEGIN..END pairs (an orphan BEGIN with no matching END is left intact so an operator inspecting a corrupted file sees exactly what is wrong), then a fresh block is appended. Anything outside the markers (keys written by `hermes claw migrate`, by the agent's own auth flow, or by an operator) is preserved across both incremental updates and full reconciliation runs. The `fullReplace` flag on `BuildEnvInjectionScript` is retained for API compatibility but no longer affects file behavior — every call reconciles the managed block. Removing a binding's `env_var` simply drops the key from the new block on the next injection.
152+
138153
## MCP Gateway Setup
139154

140155
OpenClaw connects to sluice's MCP gateway via Streamable HTTP. This is a one-time setup per deployment:

Caddyfile

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@
33
}
44

55
:443 {
6-
tls /etc/cloudflare/claw.pem /etc/cloudflare/claw.key
6+
tls /etc/ssl/certs/agent.pem /etc/ssl/private/agent.key
77

8-
reverse_proxy sluice-tun2proxy-1:18789
8+
# Forward to the hermes-dashboard sidecar via tun2proxy. The
9+
# dashboard service uses `network_mode: "service:tun2proxy"`, so
10+
# it shares tun2proxy's network namespace and is reachable on
11+
# tun2proxy's internal-network address (port 9119, where the
12+
# dashboard binds). The hermes-dashboard service name itself has
13+
# no network identity of its own. Using `tun2proxy` (the compose
14+
# service name on the internal network) stays correct regardless
15+
# of the compose project name or replica index.
16+
reverse_proxy tun2proxy:9119
917
}

bootstrap.sh

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#!/usr/bin/env bash
2+
# One-shot bootstrap that imports an existing OpenClaw home volume into
3+
# a fresh Hermes home volume.
4+
#
5+
# Run once after replacing an OpenClaw deployment with the Hermes stack
6+
# in compose.yml. The legacy openclaw-home volume from the previous
7+
# deployment is mounted read-only; the migration target is the new
8+
# hermes-home volume created by compose.yml.
9+
#
10+
# Re-running this script overwrites Hermes-side files for any item that
11+
# would otherwise conflict, because we pass --overwrite to
12+
# `hermes claw migrate`. That is appropriate for the initial migration
13+
# (we want OpenClaw's persona, model config, and provider keys to
14+
# replace the bundled Hermes defaults that the entrypoint copies in on
15+
# first boot) but does mean a re-run will clobber edits the operator
16+
# made on the Hermes side after the first import. Set
17+
# BOOTSTRAP_OVERWRITE=0 to drop the flag for a strictly additive
18+
# re-run; the migrate command will then fail loudly on conflicts so
19+
# they can be reviewed.
20+
#
21+
# Usage:
22+
# bash bootstrap.sh [project-name] [legacy-volume]
23+
#
24+
# project-name docker compose project (defaults to the basename of
25+
# the current directory, e.g. "hermes")
26+
# legacy-volume full name of the openclaw-home volume from the prior
27+
# deployment (defaults to sluice_openclaw-home which is
28+
# the historical default; pass an explicit name if your
29+
# prior project was named differently).
30+
#
31+
# What it does:
32+
# 1. Stage the legacy openclaw home under a path that does not match
33+
# `pgrep -f openclaw` so the migrate command's running-instance
34+
# check does not false-positive on its own argv. Chown to the
35+
# hermes UID so the in-container migrator can read it.
36+
# 2. Run `hermes claw migrate --preset full --migrate-secrets
37+
# --overwrite --no-backup --yes` against the staged source.
38+
# 3. Patch mcp_servers.sluice.url into ~/.hermes/config.yaml so
39+
# Hermes registers sluice as an MCP server. Sluice's
40+
# HermesProfile.WireMCPCmd does this from the running sluice
41+
# container, but having a one-shot pre-write is helpful when
42+
# rebootstrapping a stack from clean.
43+
# 4. Clean up the staging directory.
44+
set -euo pipefail
45+
46+
PROJECT_NAME="${1:-$(basename "$PWD")}"
47+
LEGACY_VOLUME="${2:-sluice_openclaw-home}"
48+
HERMES_VOLUME="${PROJECT_NAME}_hermes-home"
49+
HERMES_IMAGE="${HERMES_IMAGE:-nousresearch/hermes-agent:v2026.4.30}"
50+
BOOTSTRAP_OVERWRITE="${BOOTSTRAP_OVERWRITE:-1}"
51+
52+
# Honor compose.yml's HERMES_UID/HERMES_GID overrides so files chowned
53+
# by this bootstrap stay readable when the operator pinned the runtime
54+
# user to a non-default UID/GID. Defaults match the hermes user baked
55+
# into the upstream image (UID/GID 10000).
56+
HERMES_UID="${HERMES_UID:-10000}"
57+
HERMES_GID="${HERMES_GID:-10000}"
58+
59+
migrate_args=(
60+
--source /opt/data/.hermes/.migration-source
61+
--preset full
62+
--migrate-secrets
63+
--no-backup
64+
--yes
65+
)
66+
if [ "$BOOTSTRAP_OVERWRITE" = "1" ]; then
67+
migrate_args+=(--overwrite)
68+
fi
69+
70+
if ! docker volume inspect "$LEGACY_VOLUME" >/dev/null 2>&1; then
71+
echo "==> legacy volume '$LEGACY_VOLUME' not found; skipping bootstrap (fresh install)"
72+
exit 0
73+
fi
74+
75+
echo "==> ensuring hermes-home volume exists"
76+
docker volume create "$HERMES_VOLUME" >/dev/null
77+
78+
echo "==> staging legacy data (path renamed to avoid pgrep -f openclaw false-positive)"
79+
docker run --rm \
80+
--user 0:0 \
81+
-e HERMES_UID="$HERMES_UID" \
82+
-e HERMES_GID="$HERMES_GID" \
83+
-v "$LEGACY_VOLUME":/legacy:ro \
84+
-v "$HERMES_VOLUME":/opt/data/.hermes \
85+
--entrypoint /bin/bash \
86+
"$HERMES_IMAGE" -c '
87+
set -e
88+
rm -rf /opt/data/.hermes/.migration-source
89+
cp -a /legacy/.openclaw /opt/data/.hermes/.migration-source
90+
chown -R "$HERMES_UID:$HERMES_GID" /opt/data/.hermes/.migration-source
91+
'
92+
93+
echo "==> running hermes claw migrate (overwrite=$BOOTSTRAP_OVERWRITE)"
94+
docker run --rm \
95+
-e HERMES_HOME=/opt/data/.hermes \
96+
-e HERMES_UID="$HERMES_UID" \
97+
-e HERMES_GID="$HERMES_GID" \
98+
-v "$HERMES_VOLUME":/opt/data/.hermes \
99+
"$HERMES_IMAGE" \
100+
claw migrate "${migrate_args[@]}" \
101+
|| echo "migrate exited non-zero (non-fatal warnings or refused on conflicts) -- continuing"
102+
103+
echo "==> patching mcp_servers.sluice.url into config.yaml"
104+
docker run --rm \
105+
-e HERMES_HOME=/opt/data/.hermes \
106+
-e HERMES_UID="$HERMES_UID" \
107+
-e HERMES_GID="$HERMES_GID" \
108+
-v "$HERMES_VOLUME":/opt/data/.hermes \
109+
--entrypoint /bin/bash \
110+
"$HERMES_IMAGE" -c '
111+
set -e
112+
# Activate the bundled venv when present (PyYAML lives there on the
113+
# official Hermes image). Fall back to system python3 otherwise so
114+
# this script also works against custom Hermes builds that omit the
115+
# venv. Mirrors the runtime wrapper in HermesProfile.WireMCPCmd.
116+
if [ -f /opt/hermes/.venv/bin/activate ]; then
117+
. /opt/hermes/.venv/bin/activate
118+
fi
119+
if ! python3 -c "import yaml" 2>/dev/null; then
120+
echo "error: python3 PyYAML is required but not available; install it or use an image that bundles it" >&2
121+
exit 1
122+
fi
123+
python3 - <<PY
124+
import os, yaml
125+
p = "/opt/data/.hermes/config.yaml"
126+
data = {}
127+
if os.path.exists(p):
128+
with open(p) as fh:
129+
data = yaml.safe_load(fh) or {}
130+
servers = data.setdefault("mcp_servers", {})
131+
existing = servers.get("sluice") or {}
132+
if existing.get("url") != "http://sluice:3000/mcp":
133+
existing["url"] = "http://sluice:3000/mcp"
134+
servers["sluice"] = existing
135+
with open(p, "w") as fh:
136+
yaml.safe_dump(data, fh, sort_keys=False)
137+
print("patched", p)
138+
else:
139+
print("already patched")
140+
PY
141+
chown "$HERMES_UID:$HERMES_GID" /opt/data/.hermes/config.yaml
142+
'
143+
144+
echo "==> cleaning up staged migration source"
145+
docker run --rm \
146+
--user 0:0 \
147+
-v "$HERMES_VOLUME":/opt/data/.hermes \
148+
--entrypoint /bin/bash \
149+
"$HERMES_IMAGE" -c 'rm -rf /opt/data/.hermes/.migration-source'
150+
151+
echo "==> bootstrap complete"

cmd/sluice/main.go

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ func main() {
114114
log.Fatalf("invalid --agent value: %v", err)
115115
}
116116
log.Printf("agent profile: %s (env file ~/%s)", agentProfile.Name, agentProfile.EnvFileRelPath)
117+
telegram.SetAgentDisplayName(agentDisplayName(agentProfile.Name))
117118

118119
// Open the SQLite store.
119120
db, err := store.New(*dbPath)
@@ -461,15 +462,19 @@ func main() {
461462
log.Printf("no approval channels configured (ask rules will auto-deny)")
462463
}
463464

464-
// MCP gateway: if upstreams are configured, start the gateway and
465-
// serve it via HTTP on /mcp alongside the API. The mcpHandler local
466-
// is consumed by the startup goroutine below and by the HTTP API
467-
// server that exposes /mcp.
465+
// MCP gateway: always start (even with zero upstreams) so the
466+
// /mcp endpoint is reachable for agents that register sluice as
467+
// their MCP server. With zero upstreams the gateway exposes an
468+
// empty tool list, which is a valid MCP response. This avoids a
469+
// chicken-and-egg problem where the agent's MCP client gives up
470+
// on a 404 before the operator has registered the first upstream.
468471
var mcpHandler http.Handler
469472
upstreamRows, mcpListErr := db.ListMCPUpstreams()
470473
if mcpListErr != nil {
471474
log.Printf("WARNING: failed to list MCP upstreams: %v", mcpListErr)
472-
} else if len(upstreamRows) > 0 {
475+
upstreamRows = nil
476+
}
477+
{
473478
mcpUpstreams := make([]mcp.UpstreamConfig, len(upstreamRows))
474479
for i, r := range upstreamRows {
475480
mcpUpstreams[i] = mcp.UpstreamConfig{
@@ -546,7 +551,10 @@ func main() {
546551
// API and SOCKS5 listeners come up immediately. All phases are no-ops
547552
// outside a container runtime setup.
548553
if containerMgr != nil && db != nil {
549-
hasMCPGateway := mcpHandler != nil
554+
// The MCP gateway always mounts now (even with zero upstreams).
555+
// This constant is left in place so the wire-up phase below
556+
// reads naturally and to make a future opt-out path obvious.
557+
const hasMCPGateway = true
550558
go func() {
551559
// Phase 1: write .env file into the agent container with
552560
// phantom tokens from bindings that declare env_var.
@@ -597,14 +605,19 @@ func main() {
597605
return
598606
}
599607
// Phase 3: wire sluice's MCP gateway URL into the agent's
600-
// openclaw.json config via WebSocket RPC. Idempotent.
608+
// configuration. The exact storage and mechanism is
609+
// profile-specific (openclaw patches openclaw.json via a
610+
// gateway WebSocket RPC; hermes patches mcp_servers in
611+
// ~/.hermes/config.yaml; future profiles plug their own
612+
// AgentProfile.WireMCPCmd). Idempotent: a profile that
613+
// has nothing to do here returns nil and we move on.
601614
// deriveMCPBaseURL already returns a URL ending in /mcp.
602615
mcpURL := deriveMCPBaseURL(*mcpBaseURL, *healthAddr)
603616
wireCtx, wireCancel := context.WithTimeout(context.Background(), 15*time.Second)
604617
if wireErr := containerMgr.WireMCPGateway(wireCtx, "sluice", mcpURL); wireErr != nil {
605-
log.Printf("WARNING: failed to wire MCP gateway into agent config: %v", wireErr)
618+
log.Printf("WARNING: failed to wire MCP gateway into agent config (%s profile): %v", agentProfile.Name, wireErr)
606619
} else {
607-
log.Printf("MCP gateway wired into agent config: mcp.servers.sluice.url=%s", mcpURL)
620+
log.Printf("MCP gateway wired into agent config (%s profile): sluice -> %s", agentProfile.Name, mcpURL)
608621
}
609622
wireCancel()
610623
}()
@@ -913,6 +926,24 @@ func isTartCLIAvailable() bool {
913926
return err == nil
914927
}
915928

929+
// agentDisplayName returns the human-readable label for an agent profile
930+
// name (e.g. "openclaw" -> "OpenClaw", "hermes" -> "Hermes"). Used to
931+
// brand approval messages with the active agent. Falls back to the input
932+
// string with the first letter upper-cased for unknown profiles.
933+
func agentDisplayName(profileName string) string {
934+
switch profileName {
935+
case "openclaw":
936+
return "OpenClaw"
937+
case "hermes":
938+
return "Hermes"
939+
default:
940+
if profileName == "" {
941+
return "agent"
942+
}
943+
return strings.ToUpper(profileName[:1]) + profileName[1:]
944+
}
945+
}
946+
916947
// startAPIServer starts the HTTP server with the generated chi router.
917948
// It serves /healthz (no auth), /api/* (bearer auth), and optionally
918949
// /mcp (MCP Streamable HTTP, no auth) when mcpHandler is non-nil.

compose.dev.yml

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
11
# Development compose file. Builds sluice from source.
22
# Usage: docker compose -f compose.dev.yml up --build
3+
#
4+
# Mirrors compose.yml but builds the sluice image locally and omits the
5+
# caddy reverse-proxy / hermes-dashboard sidecar to keep the dev loop
6+
# fast. See compose.yml for the production stack and the full set of
7+
# inline rationale comments.
38
services:
49
sluice:
510
build: .
611
restart: unless-stopped
12+
command: [
13+
"-listen", "0.0.0.0:1080",
14+
"-health-addr", "0.0.0.0:3000",
15+
"-mcp-base-url", "http://sluice:3000",
16+
"-db", "data/sluice.db",
17+
"-config", "/etc/sluice/config.toml",
18+
"-audit", "/var/log/sluice/audit.jsonl",
19+
"-agent", "hermes",
20+
"-container-name", "hermes",
21+
]
722
environment:
823
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
924
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
10-
- SLUICE_AGENT_CONTAINER=openclaw
25+
- SLUICE_AGENT_CONTAINER=hermes
26+
- SLUICE_AGENT_PROFILE=hermes
1127
group_add:
1228
- "${DOCKER_GID:-0}"
1329
volumes:
@@ -35,7 +51,6 @@ services:
3551
devices:
3652
- /dev/net/tun:/dev/net/tun
3753
command: ["--proxy", "socks5://sluice:1080", "--bypass", "127.0.0.0/8", "--bypass", "::1/128"]
38-
# See compose.yml for the full MCP wiring explanation.
3954
extra_hosts:
4055
- "sluice:172.30.0.2"
4156
healthcheck:
@@ -49,23 +64,23 @@ services:
4964
sluice:
5065
condition: service_healthy
5166

52-
openclaw:
53-
image: ghcr.io/openclaw/openclaw:latest
67+
hermes:
68+
image: nousresearch/hermes-agent:v2026.4.30
5469
restart: unless-stopped
55-
container_name: openclaw
70+
container_name: hermes
5671
network_mode: "service:tun2proxy"
57-
entrypoint: ["/bin/sh", "-c", "[ -f \"$HOME/.openclaw/.env\" ] && set -a && . \"$HOME/.openclaw/.env\" && set +a; exec docker-entrypoint.sh node openclaw.mjs gateway --allow-unconfigured"]
72+
entrypoint: ["/bin/sh", "-c", "[ -f /opt/data/.hermes/.env ] && set -a && . /opt/data/.hermes/.env && set +a; exec /usr/bin/tini -g -- /opt/hermes/docker/entrypoint.sh \"$@\"", "--"]
73+
command: ["gateway", "run"]
5874
environment:
59-
- HOME=/home/node
60-
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN:-}
75+
- HOME=/opt/data
76+
- HERMES_HOME=/opt/data/.hermes
77+
- HERMES_UID=${HERMES_UID:-10000}
78+
- HERMES_GID=${HERMES_GID:-10000}
6179
- SSL_CERT_FILE=/usr/local/share/ca-certificates/sluice/sluice-ca.crt
6280
- REQUESTS_CA_BUNDLE=/usr/local/share/ca-certificates/sluice/sluice-ca.crt
6381
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/sluice/sluice-ca.crt
64-
- NPM_CONFIG_PREFIX=/home/node/.npm-global
65-
- PATH=/home/node/.npm-global/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
66-
- NODE_COMPILE_CACHE=/home/node/.node-compile-cache
6782
volumes:
68-
- openclaw-home:/home/node
83+
- hermes-home:/opt/data/.hermes
6984
- sluice-ca:/usr/local/share/ca-certificates/sluice:ro
7085
depends_on:
7186
tun2proxy:
@@ -84,4 +99,4 @@ volumes:
8499
sluice-vault:
85100
sluice-audit:
86101
sluice-ca:
87-
openclaw-home:
102+
hermes-home:

0 commit comments

Comments
 (0)