Skip to content

Commit 943af0d

Browse files
committed
Expand post to cover both OpenClaw and Hermes agents
Signed-off-by: Shane McDonald <me@shanemcd.com>
1 parent db8e712 commit 943af0d

1 file changed

Lines changed: 158 additions & 83 deletions

File tree

content/posts/10-openshell-openclaw-podman-discord-bot.md

Lines changed: 158 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
---
2-
title: Running an OpenClaw Discord Bot inside an OpenShell Sandbox with Podman
2+
title: Running AI Agents inside OpenShell Sandboxes with Rootless Podman
33
---
44

5-
I've been running AI agents on my workstation for a while now. First it was [[03-ollama-rootless-podman-quadlet|Ollama under rootless Podman]], then Hermes Agent with Vertex AI. Recently I wanted to replace Hermes with [OpenClaw](https://openclaw.ai/) as my Discord bot, but I didn't want to just run it as a bare container. If an AI agent is going to have network access and API credentials, I want something between it and the outside world.
5+
I've been running AI agents on my workstation for a while now. First it was [[03-ollama-rootless-podman-quadlet|Ollama under rootless Podman]], then Hermes Agent with Vertex AI. Recently I wanted to run both [OpenClaw](https://openclaw.ai/) and [Hermes Agent](https://github.com/NousResearch/hermes-agent) as Discord bots, but I didn't want to just run them as bare containers. If an AI agent is going to have network access and API credentials, I want something between it and the outside world.
66

77
[NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) provides exactly that: sandboxed runtimes for AI agents with kernel-level isolation, policy-enforced network egress, and credential injection. The agent never sees your real API keys. The proxy intercepts every outbound connection and evaluates it against a YAML policy before anything leaves the box.
88

@@ -15,8 +15,8 @@ I haven't seen anyone else running this exact combination, so I thought I would
1515
There are three pieces:
1616

1717
1. **OpenShell gateway** runs as a Podman Quadlet systemd service. It manages sandbox lifecycle, mTLS, JWT auth, provider credentials, and network policy.
18-
2. **OpenClaw sandbox** is created by the gateway via the Podman driver. It runs inside an OpenShell supervisor with a network namespace, transparent proxy, Landlock filesystem restrictions, and credential injection.
19-
3. **NemoClaw image** is the container image. It's built from [NVIDIA's NemoClaw](https://github.com/NVIDIA/NemoClaw) repo (which bundles OpenClaw with security hardening and proxy preload scripts), then layered with the Discord plugin and config.
18+
2. **Agent sandboxes** are created by the gateway via the Podman driver. Each runs inside an OpenShell supervisor with a network namespace, transparent proxy, Landlock filesystem restrictions, and credential injection.
19+
3. **NemoClaw images** are the container images. They're built from [NVIDIA's NemoClaw](https://github.com/NVIDIA/NemoClaw) repo (which bundles agents with security hardening and proxy preload scripts), then layered with Discord config.
2020

2121
The gateway and sandboxes sit on the same Podman bridge network (`openshell`) so they can communicate directly. The gateway is published on `0.0.0.0:8080` so I can reach it from other machines on my Tailscale network.
2222

@@ -118,9 +118,101 @@ guest_tls_key = "/home/shanemcd/.local/state/openshell/tls/client/tls.key"
118118

119119
The `guest_tls_*` paths are host filesystem paths because the Podman driver bind-mounts them into sandbox containers. If you put container-internal paths here, the host Podman daemon won't find the files.
120120

121-
## Building the image
121+
## Creating providers
122+
123+
OpenShell providers hold credentials and inject them into sandboxes as opaque placeholders. The agent process never sees the real values. The proxy resolves placeholders at egress time.
124+
125+
```bash
126+
openshell provider create --name openrouter --type openai \
127+
--credential "OPENAI_API_KEY=$(secret-tool lookup service openshell key openrouter-api-key)" \
128+
--credential "OPENROUTER_API_KEY=$(secret-tool lookup service openshell key openrouter-api-key)" \
129+
--config "OPENAI_BASE_URL=https://openrouter.ai/api/v1"
130+
131+
openshell provider create --name discord --type generic \
132+
--credential "DISCORD_BOT_TOKEN=$(secret-tool lookup service openshell key discord-bot-token)"
133+
```
134+
135+
The OpenRouter provider needs both `OPENAI_API_KEY` and `OPENROUTER_API_KEY` because different agents read different env vars. OpenClaw uses `OPENROUTER_API_KEY`. Hermes uses the OpenAI SDK which reads `OPENAI_API_KEY`. Having both means the same provider works for either agent.
136+
137+
## The network policy
138+
139+
This is where OpenShell earns its keep. Every outbound connection from the sandbox goes through the transparent proxy and is evaluated against the policy. If there's no matching rule, the connection is denied with a 403.
140+
141+
```yaml
142+
version: 1
143+
144+
filesystem_policy:
145+
read_only: [/usr, /lib, /etc]
146+
read_write: [/sandbox, /tmp, /dev/null, /dev/urandom]
147+
148+
landlock:
149+
compatibility: best_effort
150+
151+
process:
152+
run_as_user: sandbox
153+
run_as_group: sandbox
154+
155+
network_policies:
156+
discord_gateway:
157+
name: discord-gateway
158+
endpoints:
159+
- host: "*.discord.gg"
160+
port: 443
161+
protocol: websocket
162+
enforcement: enforce
163+
access: full
164+
websocket_credential_rewrite: true
165+
binaries:
166+
- path: /usr/local/bin/node
167+
- path: /usr/bin/python3.13
168+
169+
discord_api:
170+
name: discord-api
171+
endpoints:
172+
- host: discord.com
173+
port: 443
174+
protocol: rest
175+
enforcement: enforce
176+
access: full
177+
- host: discordapp.com
178+
port: 443
179+
protocol: rest
180+
enforcement: enforce
181+
access: full
182+
binaries:
183+
- path: /usr/local/bin/node
184+
- path: /usr/bin/python3.13
185+
186+
openrouter:
187+
name: openrouter
188+
endpoints:
189+
- host: openrouter.ai
190+
port: 443
191+
protocol: rest
192+
enforcement: enforce
193+
access: full
194+
binaries:
195+
- path: /usr/local/bin/node
196+
- path: /usr/bin/python3.13
197+
```
198+
199+
The Discord WebSocket endpoint needs `websocket_credential_rewrite: true` so the proxy can resolve the bot token placeholder in the WebSocket IDENTIFY payload. Without this, Discord rejects the connection with error code 4004 (authentication failed) because it receives the placeholder string instead of the real token.
200+
201+
I've included both `node` and `python3.13` in the binary allowlists so the same policy works for OpenClaw (Node.js) and Hermes (Python). You could split these into separate policy files per agent if you want tighter binary restrictions.
202+
203+
The policy is hot-reloadable. If you need to add a new endpoint, you update the YAML and run:
204+
205+
```bash
206+
openshell policy set <sandbox-name> --policy policy.yaml
207+
```
208+
209+
No sandbox restart needed. The proxy picks up the new rules within seconds.
210+
211+
## OpenClaw
212+
213+
OpenClaw is a Node.js agent. The image is a two-layer build: the NemoClaw base from source, then a config layer with the Discord plugin.
122214

123-
The image is a two-layer build. First, the NemoClaw base from source:
215+
### Building the NemoClaw base
124216

125217
```bash
126218
git clone --depth 1 https://github.com/NVIDIA/NemoClaw.git /tmp/nemoclaw-src
@@ -136,14 +228,15 @@ podman build -t nemoclaw-discord:latest \
136228
/tmp/nemoclaw-src
137229
```
138230

139-
Then a config layer that installs the Discord plugin, bootstraps the OpenClaw config, and pre-configures the Discord channel:
231+
### Config layer
140232

141233
```dockerfile
142234
FROM localhost/nemoclaw-discord:latest
143235
144236
USER root
145237
ENV HOME=/sandbox
146238
239+
# Disable managed proxy during build (points at sandbox proxy that doesn't exist yet)
147240
RUN node -e ' \
148241
const fs = require("fs"); \
149242
const p = "/sandbox/.openclaw/openclaw.json"; \
@@ -165,6 +258,7 @@ RUN openclaw doctor --fix 2>/dev/null; \
165258
openclaw models set openrouter/anthropic/claude-sonnet-5 && \
166259
openclaw config set agents.defaults.memorySearch.enabled false
167260
261+
# Pre-configure Discord channel and owner
168262
RUN node -e ' \
169263
const fs = require("fs"); \
170264
const p = "/sandbox/.openclaw/openclaw.json"; \
@@ -200,124 +294,103 @@ WORKDIR /sandbox
200294

201295
The proxy toggle during the plugin install is necessary because the NemoClaw base image bakes in a managed proxy config pointing at `10.200.0.1:3128` (the OpenShell sandbox proxy). That proxy doesn't exist at build time, so npm would hang trying to route through it.
202296

203-
The owner and allowed users are baked into the image because once the sandbox is running, Landlock filesystem restrictions prevent the OpenClaw CLI from writing to its SQLite database. You can't run `openclaw pairing approve` inside a running sandbox.
297+
The owner and allowed users are baked into the image because once the sandbox is running, Landlock filesystem restrictions prevent the OpenClaw CLI from writing to its SQLite database.
204298

205-
## Creating providers
206-
207-
OpenShell providers hold credentials and inject them into sandboxes as opaque placeholders. The agent process never sees the real values. The proxy resolves placeholders at egress time.
299+
### Creating the sandbox
208300

209301
```bash
210-
openshell provider create --name openrouter --type generic \
211-
--credential "OPENROUTER_API_KEY=$(secret-tool lookup service openshell key openrouter-api-key)"
212-
213-
openshell provider create --name discord --type generic \
214-
--credential "DISCORD_BOT_TOKEN=$(secret-tool lookup service openshell key discord-bot-token)"
302+
openshell sandbox create \
303+
--name clankr \
304+
--from localhost/nemoclaw-discord-configured:latest \
305+
--provider openrouter \
306+
--provider discord \
307+
--policy policy.yaml \
308+
--no-tty \
309+
-- /usr/local/bin/nemoclaw-start
215310
```
216311

217-
One thing that tripped me up: OpenClaw's built-in OpenRouter support expects `OPENROUTER_API_KEY`, not `OPENAI_API_KEY`. If you use the `openai` provider type with `OPENAI_API_KEY`, the credential gets injected but OpenClaw ignores it and you get `401 Missing Authentication header` from OpenRouter.
312+
The `nemoclaw-start` entrypoint is important for OpenClaw. It handles privilege separation, gateway auth token generation, and most critically, it loads a Node.js preload script (`http-proxy-fix.js`) that patches `https.request()` to properly route WebSocket CONNECT tunnels through the sandbox proxy. Without this preload, Node.js tries to resolve DNS directly (which is blocked by nftables in the sandbox network namespace) and you get `EAI_AGAIN` errors on every connection.
218313

219-
## The network policy
314+
## Hermes Agent
220315

221-
This is where OpenShell earns its keep. Every outbound connection from the sandbox goes through the transparent proxy and is evaluated against the policy. If there's no matching rule, the connection is denied with a 403.
316+
Hermes is a Python agent. The setup is simpler because Python's `httpx` (used by the OpenAI SDK) natively respects the transparent proxy without needing preload hacks.
222317

223-
```yaml
224-
version: 1
318+
### Building the image
225319

226-
filesystem_policy:
227-
read_only: [/usr, /lib, /etc]
228-
read_write: [/sandbox, /tmp, /dev/null, /dev/urandom]
320+
The NemoClaw base for Hermes:
229321

230-
landlock:
231-
compatibility: best_effort
322+
```bash
323+
podman build -t nemoclaw-hermes:latest \
324+
-f /tmp/nemoclaw-src/agents/hermes/Dockerfile \
325+
/tmp/nemoclaw-src
326+
```
232327

233-
process:
234-
run_as_user: sandbox
235-
run_as_group: sandbox
328+
Then a config layer using Hermes's native `config set` commands:
236329

237-
network_policies:
238-
discord_gateway:
239-
name: discord-gateway
240-
endpoints:
241-
- host: "*.discord.gg"
242-
port: 443
243-
protocol: websocket
244-
enforcement: enforce
245-
access: full
246-
websocket_credential_rewrite: true
247-
binaries:
248-
- path: /usr/local/bin/node
330+
```dockerfile
331+
FROM localhost/nemoclaw-hermes:latest
249332
250-
discord_api:
251-
name: discord-api
252-
endpoints:
253-
- host: discord.com
254-
port: 443
255-
protocol: rest
256-
enforcement: enforce
257-
access: full
258-
- host: discordapp.com
259-
port: 443
260-
protocol: rest
261-
enforcement: enforce
262-
access: full
263-
binaries:
264-
- path: /usr/local/bin/node
333+
USER root
334+
ENV HOME=/sandbox
265335
266-
openrouter:
267-
name: openrouter
268-
endpoints:
269-
- host: openrouter.ai
270-
port: 443
271-
protocol: rest
272-
enforcement: enforce
273-
access: full
274-
binaries:
275-
- path: /usr/local/bin/node
276-
```
336+
RUN mkdir -p /sandbox/.hermes && \
337+
hermes config set model.default anthropic/claude-sonnet-5 && \
338+
hermes config set model.provider custom && \
339+
hermes config set model.base_url https://openrouter.ai/api/v1 && \
340+
hermes config set providers.custom.api https://openrouter.ai/api/v1 && \
341+
hermes config set providers.custom.default_model anthropic/claude-sonnet-5 && \
342+
hermes config set platforms.discord.enabled true && \
343+
hermes config set discord.require_mention 1 && \
344+
hermes config set discord.auto_thread true && \
345+
hermes config set discord.reactions true
277346
278-
The Discord WebSocket endpoint needs `websocket_credential_rewrite: true` so the proxy can resolve the bot token placeholder in the WebSocket IDENTIFY payload. Without this, Discord rejects the connection with error code 4004 (authentication failed) because it receives the placeholder string instead of the real token.
347+
# Remove api_key fields so the OpenAI SDK falls back to OPENAI_API_KEY env var
348+
RUN sed -i '/api_key:/d' /sandbox/.hermes/config.yaml
279349
280-
The `protocol: rest` endpoints get full L7 inspection. The proxy terminates TLS, checks the request against the policy rules, resolves any credential placeholders in headers or URL paths, and forwards the request upstream. The `protocol: websocket` endpoint gets the WebSocket handshake validated, then switches to raw bidirectional relay after the upgrade.
350+
RUN printf 'DISCORD_ALLOWED_USERS=YOUR_DISCORD_USER_ID\nDISCORD_ALLOW_ALL_USERS=false\n' \
351+
> /sandbox/.hermes/.env
281352
282-
The policy is hot-reloadable. If you need to add a new endpoint (say, the agent wants to fetch something from GitHub), you update the YAML and run:
353+
RUN chown -R sandbox:sandbox /sandbox/.hermes
283354
284-
```bash
285-
openshell policy set clankr --policy policy.yaml
355+
USER sandbox
356+
WORKDIR /sandbox
286357
```
287358

288-
No sandbox restart needed. The proxy picks up the new rules within seconds.
359+
The `sed` to remove `api_key` lines is the key trick. The NemoClaw base bakes in `sk-OPENSHELL-PROXY-REWRITE` as a static placeholder that NemoClaw's own proxy would resolve, but the OpenShell proxy doesn't know about it. With no `api_key` in the config, the OpenAI SDK falls back to the `OPENAI_API_KEY` environment variable, which OpenShell injects as a credential placeholder. The proxy resolves that placeholder in the `Authorization: Bearer` header at egress time.
289360

290-
## Creating the sandbox
361+
### Creating the sandbox
291362

292363
```bash
293364
openshell sandbox create \
294-
--name clankr \
295-
--from localhost/nemoclaw-discord-configured:latest \
365+
--name hermes \
366+
--from localhost/nemoclaw-hermes-configured:latest \
296367
--provider openrouter \
297368
--provider discord \
298369
--policy policy.yaml \
299370
--no-tty \
300-
-- /usr/local/bin/nemoclaw-start
371+
-- hermes gateway run --force
301372
```
302373

303-
The `nemoclaw-start` entrypoint is important. It handles privilege separation, gateway auth token generation, and most critically, it loads a Node.js preload script (`http-proxy-fix.js`) that patches `https.request()` to properly route WebSocket CONNECT tunnels through the sandbox proxy. Without this preload, Node.js tries to resolve DNS directly (which is blocked by nftables in the sandbox network namespace) and you get `EAI_AGAIN` errors on every connection.
374+
Unlike OpenClaw, Hermes can skip the `nemoclaw-start` entrypoint and run `hermes gateway run --force` directly. Python handles the transparent proxy correctly without preload scripts. The tradeoff is losing NemoClaw's privilege separation and config integrity checks, but for a personal Discord bot, the OpenShell sandbox provides more than enough isolation.
375+
376+
## What it looks like running
304377

305378
After about 30 seconds, the bot comes online on Discord. You can verify from the proxy logs:
306379

307380
```bash
308-
openshell logs clankr
381+
openshell logs <sandbox-name>
309382
```
310383

311384
```
312385
NET:OPEN [INFO] ALLOWED /usr/local/bin/node(428) -> discord.com:443 [policy:discord_api engine:opa]
313386
HTTP:GET [INFO] ALLOWED GET http://discord.com:443/api/v10/users/@me [policy:discord_api engine:l7]
314-
NET:UPGRADE [INFO] gateway.discord.gg:443
315-
NET:OTHER [INFO] ALLOWED gateway.discord.gg:443 [policy:discord_gateway engine:l7-websocket]
387+
NET:UPGRADE [INFO] gateway-us-east1-c.discord.gg:443
388+
NET:OTHER [INFO] ALLOWED gateway-us-east1-c.discord.gg:443 [policy:discord_gateway engine:l7-websocket]
316389
HTTP:POST [INFO] ALLOWED POST http://openrouter.ai:443/api/v1/chat/completions [policy:openrouter engine:l7]
317390
HTTP:POST [INFO] ALLOWED POST http://discord.com:443/api/v10/channels/.../messages [policy:discord_api engine:l7]
318391
```
319392
320-
Every connection is logged with the policy rule that allowed it, the engine that evaluated it (OPA for L4, l7 for HTTP, l7-websocket for WebSocket), and the binary that initiated it.
393+
Every connection is logged with the policy rule that allowed it, the engine that evaluated it (OPA for L4, l7 for HTTP, l7-websocket for WebSocket), and the binary that initiated it. Anything not in the policy gets a 403 and an OCSF log entry.
321394
322395
## Gotchas
323396
@@ -328,7 +401,9 @@ A few things I ran into that aren't obvious:
328401
- **OpenRouter models in OpenClaw use the format `openrouter/<author>/<slug>`**, not `openrouter:author/slug`. The colon-separated format causes OpenClaw to treat the model name as a filesystem path and crash with a "Bundled plugin dirName must be a single directory" error.
329402
- **After regenerating TLS certs, you must recreate sandboxes.** Running sandboxes have the old certs mounted and lose connectivity to the gateway. The supervisor can't fetch policy updates, so hot-reload stops working.
330403
- **Wildcard the Discord gateway hostname.** The policy needs `*.discord.gg`, not just `gateway.discord.gg`. Discord uses regional gateways like `gateway-us-east1-c.discord.gg` for reconnections, and the bot goes offline when the proxy denies the regional hostname.
404+
- **Hermes needs `api_key` fields removed from config, not set to empty.** The NemoClaw base bakes in a static proxy placeholder. Empty string makes the OpenAI SDK send an empty Bearer token. Removing the field entirely makes it fall back to the `OPENAI_API_KEY` env var, which OpenShell injects as a resolvable placeholder.
405+
- **One Discord bot token, one consumer.** If you run both agents with the same bot token, only the last one to connect will receive messages. Use a separate bot token per agent if you want both online simultaneously.
331406
332407
## Source
333408
334-
Everything is in my [clankr](https://github.com/shanemcd/clankr) repo under `agents/openclaw/`. The gateway quadlet and config live in my [dotfiles](https://github.com/shanemcd/dotfiles).
409+
Everything is in my [clankr](https://github.com/shanemcd/clankr) repo under `agents/openclaw/` and `agents/hermes/`. The gateway quadlet and config live in my [dotfiles](https://github.com/shanemcd/dotfiles).

0 commit comments

Comments
 (0)