You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
title: Running an OpenClaw Discord Bot inside an OpenShell Sandbox with Podman
2
+
title: Running AI Agents inside OpenShell Sandboxes with Rootless Podman
3
3
---
4
4
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.
6
6
7
7
[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.
8
8
@@ -15,8 +15,8 @@ I haven't seen anyone else running this exact combination, so I thought I would
15
15
There are three pieces:
16
16
17
17
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.
20
20
21
21
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.
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.
120
120
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.
--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.
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.
122
214
123
-
The image is a two-layer build. First, the NemoClaw base from source:
Then a config layer that installs the Discord plugin, bootstraps the OpenClaw config, and pre-configures the Discord channel:
231
+
### Config layer
140
232
141
233
```dockerfile
142
234
FROM localhost/nemoclaw-discord:latest
143
235
144
236
USER root
145
237
ENV HOME=/sandbox
146
238
239
+
# Disable managed proxy during build (points at sandbox proxy that doesn't exist yet)
147
240
RUN node -e ' \
148
241
const fs = require("fs"); \
149
242
const p = "/sandbox/.openclaw/openclaw.json"; \
@@ -165,6 +258,7 @@ RUN openclaw doctor --fix 2>/dev/null; \
165
258
openclaw models set openrouter/anthropic/claude-sonnet-5 && \
166
259
openclaw config set agents.defaults.memorySearch.enabled false
167
260
261
+
# Pre-configure Discord channel and owner
168
262
RUN node -e ' \
169
263
const fs = require("fs"); \
170
264
const p = "/sandbox/.openclaw/openclaw.json"; \
@@ -200,124 +294,103 @@ WORKDIR /sandbox
200
294
201
295
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.
202
296
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.
204
298
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.
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.
218
313
219
-
## The network policy
314
+
## Hermes Agent
220
315
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.
Then a config layer using Hermes's native `config set` commands:
236
329
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
249
332
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
265
335
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
277
346
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
279
349
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
281
352
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
283
354
284
-
```bash
285
-
openshell policy set clankr --policy policy.yaml
355
+
USER sandbox
356
+
WORKDIR /sandbox
286
357
```
287
358
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.
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
304
377
305
378
After about 30 seconds, the bot comes online on Discord. You can verify from the proxy logs:
HTTP:POST[INFO] ALLOWED POST http://openrouter.ai:443/api/v1/chat/completions[policy:openrouter engine:l7]
317
390
HTTP:POST[INFO] ALLOWED POST http://discord.com:443/api/v10/channels/.../messages[policy:discord_api engine:l7]
318
391
```
319
392
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.
321
394
322
395
## Gotchas
323
396
@@ -328,7 +401,9 @@ A few things I ran into that aren't obvious:
328
401
- **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.
329
402
- **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.
330
403
- **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.
331
406
332
407
## Source
333
408
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