Skip to main content

Deployment

Use this page after nanoinfra agent -m "Hello!" works locally. Deployment keeps long-running surfaces online: WebUI, chat apps, heartbeat, Dream, cron jobs, and channel connections.

Before You Deploy​

Check these once before Docker, systemd, or LaunchAgent:

CheckWhy it matters
nanoinfra status shows the expected config and workspaceConfirms the process will read the instance you meant to run
nanoinfra agent -m "Hello!" worksProves install, config, provider, model, and workspace writes before adding a service layer
Secrets are in environment variables or protected config filesAPI keys, bot tokens, OAuth state, and chat credentials should not be world-readable
~/.nanoinfra/ or your custom config/workspace path is persistentSessions, memory, channel login state, generated artifacts, and cron jobs live there
Channel access control is intentionalUse allowFrom, pairing, WebSocket token/tokenIssueSecret, or private test channels before exposing the bot
Ports are plannedGateway health defaults to local-only 127.0.0.1:18790. The WebUI and the WebSocket channel default to 8765. nanoinfra serve defaults to 8900
Logs are easy to reachUse docker compose logs, journalctl, LaunchAgent log files, or nanoinfra gateway --verbose while diagnosing startup

Restart the deployed process after editing config.json. Long-running processes read config at startup.

Choose a Runtime​

RuntimeUse it forState locationUseful first command
Docker ComposeRepeatable container runs on Linux servers or workstationsBind-mount ~/.nanoinfra to /home/nanoinfra/.nanoinfradocker compose run --rm nanoinfra-cli agent -m "Hello!"
Docker CLIManual container testing or small one-off hostsBind-mount ~/.nanoinfra to /home/nanoinfra/.nanoinfradocker run --rm -v ~/.nanoinfra:/home/nanoinfra/.nanoinfra ghcr.io/nanoinfraorg/nanoinfra:latest status
systemd user serviceLinux user-level gateway that restarts automaticallyHost user's ~/.nanoinfra unless you pass explicit pathssystemctl --user status nanoinfra-gateway
macOS LaunchAgentmacOS gateway that starts after loginHost user's ~/.nanoinfra unless the plist passes explicit paths`launchctl list

The Process Split​

nanoinfra runs the agent in one process and three helpers beside it. The split decides what a compromised agent can reach. For the policy the executor applies, read capability-gates.md.

ProcessEntry pointWhat it holdsWhat it never holds
Agentnanoinfra gatewaythe model, the tools, the session storea credential value, a transport to a host
Executorpython -m nanoinfra.gates.executorthe credential store, the four transports, the gate, the audit logthe web tools
Fetcherpython -m nanoinfra.gates.fetcherweb_fetch and web_searcha credential, a transport, the right to start a program
MCP hostpython -m nanoinfra.gates.mcp_hostthe stdio MCP servers your config namesa credential, a transport to a host

Each helper listens on one Unix socket. The agent writes one request and reads one reply. A TCP socket would widen an egress policy to include the executor, so the transport is a Unix socket in every case.

The executor listens on two more sockets.

On the operator socket, only an operator answers, and the agent never does. See The Operator Socket.

On the scrub socket, the agent asks for text with the stored credential values removed. The scrub needs those values to find them, so it runs where they already live. Without this socket the agent would decrypt the whole credential store on every turn that persisted anything. The agent sends text and reads text back. It never receives a value.

One request carries many texts. A session file holds one record per message plus one item per tool call. A scrub per text would open hundreds of connections on a single save. The executor still reads the credential store once per request, and caches no result across two requests. So a secret you create during a turn is removed from that same turn.

Who Starts Each Process​

RuntimeExecutorFetcherMCP host
Docker image, started as rootthe entrypoint, on its own accountthe entrypoint, on its own accountthe entrypoint, on its own account
Docker image, started as non-rootthe gateway, on the agent's accountthe gateway, on the agent's accountthe gateway, on the agent's account
pip install plus nanoinfra gatewaythe gateway, on the agent's accountthe gateway, on the agent's accountthe gateway, on the agent's account
systemd user service or LaunchAgentthe gateway, on the agent's accountthe gateway, on the agent's accountthe gateway, on the agent's account
Python SDKthe SDK, on requestnot startednot started

Only a root start can place two processes on two accounts, so the container entrypoint is the supervisor for that image. A gateway start elsewhere still gives the executor a process of its own. That process alone keeps the credential store and the four transports out of the address space that runs the model.

The gateway starts the executor first of the three children, and it stops the executor last. The approvals inbox derives its own socket from the path that this start exports.

The executor start is unconditional, and the other two are not. The gateway starts a fetcher only when tools.web.enable is true. It starts an MCP host only when at least one configured MCP server is a stdio server.

Two facts make the executor different. execute_on_server is a registered tool in every install, so no config value names a switch. A server record can also appear at any moment after boot, with no gateway restart.

NANOINFRA_EXECUTOR_EXTERNAL stops the second start. A container that already placed the executor on its own account keeps that one, because a gateway child would hold the agent's UID instead.

A failed executor start reaches the log and the console, and both name the consequence:

gates: the executor did not start, so every gated action refuses until an executor
answers: <cause>

A deployment without an executor refuses every execute_on_server call as a deployment fault. See troubleshooting.md#capability-gate-problems.

Accounts and Groups in the Container​

The image creates five accounts and five groups.

AccountUIDRunsWhy it is separate
nanoinfra1000the agentit owns the data dir and the writable virtualenv
nanoinfra-exec1001the executorit holds the plaintext credentials and writes the audit log
nanoinfra-fetch1002the fetcheruntrusted web content enters this account
nanoinfra-mcp1003the MCP hostit starts the programs that your MCP config names
nanoinfra-connector1004the connector hostit makes a marketplace connector package's HTTPS request, so the account holding the credential store does not

Two processes under one UID get no separation from the kernel. Either one can trace the other and read its memory. So the account that reads a web page is never the account that decrypts a credential.

GroupMembersGuards
nanoinfra-ipcnanoinfra, nanoinfra-exec/run/nanoinfra-exec
nanoinfra-fetch-ipcnanoinfra, nanoinfra-fetch/run/nanoinfra-fetch
nanoinfra-mcp-ipcnanoinfra, nanoinfra-mcp/run/nanoinfra-mcp
nanoinfra-connector-ipcnanoinfra-exec, nanoinfra-connector/run/nanoinfra-connector
nanoinfra-opnanoinfra/run/nanoinfra-exec/operator

nanoinfra-connector-ipc is the only group the agent is not in. A connector call starts in the executor after the gate answered. So nothing in the process the model steers has a reason to reach that socket.

Each pair needs its own group. A member of nanoinfra-ipc can traverse the executor's socket directory and connect to its socket. The fetcher inside that group could therefore run a command on every inventory host, and that is the one outcome this split prevents. The agent belongs to four of the five groups. No helper belongs to another helper's group.

Each socket directory carries mode 2710, and each socket file carries mode 660:

  • The owner is the helper, so only the helper can create or replace the socket.
  • The group has traverse rights alone. The agent reaches a known socket name, and it cannot list the directory or create anything in it.
  • Other accounts get nothing.
  • The setgid bit keeps the group across a rebind, so the agent keeps access after the helper restarts.

That last line was not true in a container until v2.2.1, and the way it failed is worth knowing if you build your own image. chmod(2) turns off the setgid bit when the file's group is not the caller's, unless the caller holds CAP_FSETID — and it returns success. The published compose file drops all capabilities and adds six. FSETID is not among them. So the entrypoint's chown to a helper group followed by chmod 2710 left every socket directory at 710, silently, with each guard passing. Setting the mode before the chown keeps the bit and needs no capability, because chown does not clear setgid on a directory. If you add capabilities to that list, FSETID is not one you need.

The three socket directories sit under /run rather than under the agent's home. Write rights on a parent directory allow a rename of any entry inside it. A socket directory in the agent's home could therefore be moved aside, and the agent could present its own socket at the expected path. /run is root-owned.

The Operator Socket​

The executor binds a second socket for the answers to a suspended approval. For the rule the answer must satisfy, read capability-gates.md#the-approval-path.

StartDefault path
A gateway or SDK start~/.nanoinfra/run/operator/executor.op.sock
The container entrypoint/run/nanoinfra-exec/operator/executor.op.sock

The path always sits in a private subdirectory beside the execute socket, and the file name carries that socket's own name. NANOINFRA_OPERATOR_SOCKET overrides it.

On a single-account start the executor creates that directory at mode 700, so no other account reaches it. The container needs the group bit instead, and the entrypoint prepares the directory before the executor starts:

PathOwner and mode
/run/nanoinfra-exec/operatornanoinfra-exec:nanoinfra-op, 2710
/run/nanoinfra-exec/operator/executor.op.socknanoinfra-exec:nanoinfra-op, 660

The setgid bit on that directory is the only mechanism this socket has for its group. That is why v2.2.1 mattered more here than for the other three. The executor sets the group itself on the sockets it shares through nanoinfra-ipc, because it belongs to that group. It deliberately does not belong to nanoinfra-op, so for this one it cannot. Root's chown after the bind is racy on a restart: it can land on the previous run's socket file, which the executor then unlinks.

The entrypoint exports NANOINFRA_OPERATOR_SOCKET before the start, so the executor binds the path that root prepared.

The group is nanoinfra-op and never nanoinfra-ipc. The second group would hand the same reach to the fetcher and to the MCP host. Either helper could then approve an action that it asked for.

Only the agent account joins nanoinfra-op. No helper joins it.

An image built before that group exists keeps the directory at mode 700, and it says what the deployment loses:

[entrypoint] warning: no nanoinfra-op group, so no approval can be answered
[entrypoint] warning: an approve decision then waits and refuses

The residual risk, and it is deliberate. The approvals inbox answers on this socket from inside the gateway process, under the agent's account. The filesystem half of the split therefore protects nothing on that one path. Three facts remain:

  • The answer still crosses a process boundary into the executor, and the executor owns the decision.
  • The executor still matches the asserted actor against gates.approvers from git-reviewed config.
  • Inside the gateway process the import graph is the whole protection. A test walks that graph and fails when a tool module reaches the answer path.

A tool that runs arbitrary code in the gateway process defeats the import closure. The approver match is then the last rule that holds.

Paths the Executor Owns​

PathOwner and modePurpose
<workspace>/secretsnanoinfra-exec:nanoinfra-ipc, 2750, records 640the credential store. The agent can list metadata and cannot write a record
~/.nanoinfra/gatesnanoinfra-exec:nanoinfra-ipc, 2750, files 640the gate audit log
~/.nanoinfra and $HOMEroot:nanoinfra-ipc, 1775the sticky parents of the audit log

The audit log is group-readable on purpose. The agent process rebuilds denial latches from it, and the WebUI viewer serves it. At mode 700 the agent could not open a segment, and every latch cleared on each boot. Write access stays with the executor.

The sticky bit on the two parent directories closes a rename. Only the owner of an entry, the owner of the directory, or root may rename an entry in a sticky directory. The agent keeps every entry it creates, and it cannot move the executor's one. The executor also pins the device and inode of its audit root, so a rename it cannot prevent stops the executor rather than the latches.

Per-Process Confinement​

Each helper process starts under a Landlock ruleset. The kernel applies the rules to the child before the exec, and a ruleset survives an exec, so every program the child starts inherits them.

A sandbox complements the privilege split. It never replaces it. The split must hold after a sandbox layer fails, so no rule here weakens a boundary that the accounts and the sockets already keep. Landlock needs no root, no namespace, and no helper program, so it also works on a plain pip install host.

The agent keeps its own bubblewrap wrapper for local shell commands. This layer changes nothing about that.

What Each Policy Bounds​

ProcessThe policy bounds
Executorthe filesystem and the exec surface
Fetcheroutbound TCP ports, plus a filesystem policy that names no workspace path
MCP hostthe exec surface, the write surface, and no workspace path at all

Every role gets the same base:

  • read access to the system directories, and to the interpreter's own paths.
  • read access to a curated list of /etc entries, rather than to the whole directory.
  • read access to config.json.
  • write access to the temp directories.
  • the run directory, with the rights a socket bind needs and no right to create a regular file there.

Each role then adds its own:

  • The executor takes write access to the workspace, to the data directory that holds the audit log, and to its working directory. It takes exec rights on the system bin directories, because the Ansible backend runs ansible and ssh. It reads /etc/ansible and /etc/ssh.
  • The fetcher takes an outbound TCP allowlist of ports 53, 80, and 443, plus the port of a configured proxy. Port 53 covers the TCP fallback of a DNS resolver.
  • The MCP host takes write and exec rights on the toolchain caches in the home directory, such as .npm, .cache, and .bun. npx, uvx, and bunx each write a cache and then run a program out of it.

No role may open a TCP listener. The executor gets no port allowlist on purpose. Contact with the inventory hosts is its whole purpose, so an allowlist there would equal the inventory and restrict nothing.

Two rights depend on the kernel. The TCP rules need Landlock ABI 4. The block on signals and abstract sockets outside the process tree needs ABI 6.

Loud Failure​

A silent sandbox failure is worse than no sandbox. Five outcomes exist, and they differ on purpose:

CaseResult
The kernel reports no Landlock supportthe helper starts, and the log carries a warning
Landlock has no known syscall number on this architecturethe helper starts, and the log carries a warning
The kernel reports an ABI and then rejects the rulesetthe helper refuses to start
The run directory is absentthe helper refuses to start
An optional path is absentthat one rule drops out of the plan

A kernel without Landlock support is a legitimate host, so a refusal there would make the release unusable. A container profile that predates Landlock answers the probe the same way. A dropped optional rule makes the policy tighter and never wider, so images can differ without a warning.

What the Startup Echo Prints​

The gates: line at start ends with the confinement clause. A kernel that supports Landlock reports the ABI it offers, and <abi> below is that number:

confinement: landlock abi <abi> on each helper process, filesystem rules, a tcp port
allowlist for the fetcher, and no tcp listener

Below ABI 4 the same line drops the port clause and reads filesystem rules and no tcp listener. A kernel with no support states the absence just as plainly:

confinement: NOT APPLIED, this kernel reports no landlock support. The three helper
processes keep their own accounts and their own sockets

Each child also logs its own layer. A working layer names the rule count and the controls:

gates: executor confinement: landlock abi <abi>, <n> filesystem rules, no tcp
listener, no signal or abstract socket outside the process tree

A degraded layer names the reason and the boundary that still holds:

gates: fetcher confinement: NOT APPLIED, this kernel reports no landlock support.
The privilege split still holds, and it is the boundary that carries the weight.

In the container the entrypoint starts each helper through the confinement launcher, which reads a role and never a command. A rejected ruleset exits with status 78, and the retry loop stops at once:

[confinement] error: this helper refuses to start unconfined
[entrypoint] error: the executor refuses to start unconfined
[entrypoint] error: every gated action stays refused

Stated Limits​

The module names these limits rather than hides them.

  • The ELF loader holds EXECUTE. The kernel opens the dynamic loader with exec intent, so a rule on the interpreter alone denies the exec. A grant on the loader also lets the loader run any file it can read. The exec surface therefore bounds intent rather than an escape, and the write surface plus the split carry the weight.
  • An ansible-runner projectPath outside the granted roots gets no write grant. ansible-runner writes its artifacts under that path, so a run against it fails. A read grant cannot fix that, and no config key widens the policy.
  • The MCP host cannot read the workspace. A stdio MCP server that must read files needs a directory outside the workspace. That cost buys the property that matters here: the process that starts a program cannot read the credentials the executor decrypts.
  • A write grant carries a read grant. The temp directories therefore become readable to the helper. That is the stated cost of a working tempfile, and the executor needs a private data directory for Ansible.

Helper Restarts​

The entrypoint restarts a helper that exits, after five seconds. Five failed starts in a row stop the retries, and the log says so. A run that lasted a minute counts as a crash rather than a broken start, so it returns the full budget.

The entrypoint waits up to five seconds for each socket. It never blocks the agent, and it never goes quiet:

[entrypoint] warning: no executor socket at /run/nanoinfra-exec/executor.sock after 5s
[entrypoint] warning: gated actions will fail until the executor answers

Environment Variables​

VariableWho sets itWho reads it
NANOINFRA_EXECUTOR_SOCKETthe container entrypoint and the gatewayexecute_on_server, and the approvals inbox
NANOINFRA_EXECUTOR_EXTERNALthe container entrypointthe gateway, which then starts no second executor
NANOINFRA_EXECUTOR_USERyouthe gateway, as the account for its executor child
NANOINFRA_FETCHER_SOCKETthe container entrypoint and the gatewayweb_fetch and web_search
NANOINFRA_FETCHER_EXTERNALthe container entrypointthe gateway, which then starts no second fetcher
NANOINFRA_FETCHER_USERyouthe gateway, as the account for its fetcher child
NANOINFRA_MCP_HOST_SOCKETthe container entrypoint and the gatewaythe agent's MCP client
NANOINFRA_MCP_HOST_EXTERNALthe container entrypointthe gateway, which then starts no second MCP host
NANOINFRA_MCP_HOST_USERyouthe gateway, as the account for its MCP host child
NANOINFRA_OPERATOR_SOCKETthe container entrypoint, or youthe executor, as the path for its operator socket
NANOINFRA_WORKSPACEyouthe container entrypoint, to give the executor the agent's workspace

Set NANOINFRA_WORKSPACE when only config.json names the workspace. A shell cannot read that file, so the entrypoint would otherwise hand the executor the default path. The entrypoint logs the value it resolved.

NANOINFRA_EXECUTOR_USER, NANOINFRA_FETCHER_USER, and NANOINFRA_MCP_HOST_USER name the account for a helper child. A gateway that runs as root honours them, and the log names the child's account and UID. A gateway that runs as an ordinary user cannot honour them:

No privilege to start the fetcher as 'nanoinfra-fetch': euid 1000 is not root. The
fetcher shares the supervisor's uid, so the split is organisational and not
enforced.

An account that does not exist fails the start rather than falls back, because a silent fallback would run the child under the wrong UID. The Python SDK takes an executor_user argument for the same purpose.

NANOINFRA_EXECUTOR_SOCKET names where the executor listens. The gateway exports it before it starts the child, and the container entrypoint exports the path under /run. Without the variable the client opens ~/.nanoinfra/run/executor.sock. The approvals inbox derives the operator socket from the same path unless NANOINFRA_OPERATOR_SOCKET names another one.

What a Non-Root or Single-Account Start Loses​

The entrypoint states each missing property in the log. Never read silence as a guarantee.

A start that is not root cannot place two processes on two accounts:

[entrypoint] warning: this container did not start as root
[entrypoint] warning: the agent and the executor share one uid, so either one can
[entrypoint] warning: ptrace the other and read its memory. The #18 privilege
[entrypoint] warning: split is organisational here, not kernel-enforced.

Such a start still reaches an executor. The gateway starts one under the agent's account, and the log states that the kernel enforces no UID split there:

gates: the executor runs as a separate process under the gateway's own account. The
kernel enforces no uid split here. Set NANOINFRA_EXECUTOR_USER to get one.

The approvals inbox works on that start as well, because the executor binds its operator socket wherever it runs.

An image without the nanoinfra-exec account skips the entrypoint's executor and prints the same warning. An image without nanoinfra-fetch, nanoinfra-mcp or nanoinfra-connector runs that helper under a shared account, and the log names the property that the start lacks:

[entrypoint] warning: no separate nanoinfra-fetch account runs the fetcher
[entrypoint] warning: the fetcher shares the agent's uid, so either one can ptrace
[entrypoint] warning: the other and read its memory. The #19 split is a process
[entrypoint] warning: boundary here, and the kernel does not enforce it.
[entrypoint] warning: the fetcher still holds no credential store and no transport.

A systemd --user unit and a LaunchAgent both run as the account that installed them. Both processes hold one UID there, so the split is organisational on those two managers. A kernel-enforced split needs a system unit with one User= per process, or the container layout.

Residual Risks​

The code states these limits, and each one is deliberate.

  • The virtualenv stays writable by the agent. An enabled channel may install its declared dependencies at start, so /app/.venv belongs to the agent account. The executor imports code from that same virtualenv. The agent account can therefore place code that the executor account later runs. No UID split closes that path. A fully enforced split needs the executor on its own read-only interpreter.
  • The agent can remove its own workspace entries. The workspace belongs to the agent, so the agent can rename or delete <workspace>/secrets. That act costs the executor its credentials, and it never reveals one. Confidentiality holds. Availability does not.
  • An operator with a shell can delete the audit log. root and a shell user can remove the segments. The act is visible, because the segments are then gone. The point of the layout is that the agent account cannot do it.
  • A moved audit root stops the executor. The executor refuses to read or write when its audit root changes identity. A rename then costs availability instead of every latch the log holds.
  • The approvals inbox answers from inside the gateway process. The agent account holds the operator socket. So the file mode protects nothing on that path. The process boundary, the approver match in config, and an import closure carry the weight instead. See The Operator Socket.
  • The sandbox is a second layer and not the boundary. A kernel with no Landlock support runs every helper unconfined. The log says so. The accounts and the sockets still hold. See Per-Process Confinement.
  • SSH host-key verification is disabled. See secrets-and-servers.md.

Docker​

[!TIP] The -v ~/.nanoinfra:/home/nanoinfra/.nanoinfra flag mounts your local config directory into the container, so your config and workspace persist across container restarts. The container runs as the non-root user nanoinfra (UID 1000) and reads config from /home/nanoinfra/.nanoinfra. Always mount your host config directory to /home/nanoinfra/.nanoinfra, not /root/.nanoinfra. If you get Permission denied, fix ownership on the host first: sudo chown -R 1000:1000 ~/.nanoinfra, or pass --user $(id -u):$(id -g) to match your host UID. Podman users can use --userns=keep-id instead.

[!IMPORTANT] The official image is ghcr.io/nanoinfraorg/nanoinfra, published from this repository's Dockerfile on every version tag. Tags are latest, the minor (1.0) and the exact version (1.0.1), for linux/amd64. Pin the exact version in production. Building the Dockerfile yourself stays supported and is what docker compose in the repository does. Docker Hub images under third-party namespaces are not maintained or verified by nanoinfraorg/nanoinfra. Do not mount API keys or bot tokens into them unless you trust the publisher.

[!IMPORTANT] The gateway and WebSocket channel default to host: "127.0.0.1" in config.json (set in nanoinfra/config/schema.py). Docker -p port forwarding cannot reach a container's loopback interface. For the host or LAN to reach the exposed ports, set both binds to 0.0.0.0 in ~/.nanoinfra/config.json before you start the container. To serve the bundled WebUI from Docker, bind the WebSocket channel externally and protect bootstrap with tokenIssueSecret:

{
"gateway": { "host": "0.0.0.0" },
"channels": {
"websocket": {
"host": "0.0.0.0",
"port": 8765,
"tokenIssueSecret": "your-secret-here"
}
}
}

When the WebSocket host is 0.0.0.0, the channel refuses to start unless token, tokenIssueSecret, or a fully configured trustedProxyAuth is also configured. See webui.md#lan-access for details. The gateway health route itself is intentionally minimal and unauthenticated. When the container binds it to 0.0.0.0, publish port 18790 to host loopback only. Place any remotely monitored health endpoint behind a firewall or reverse proxy. If another host must probe it directly, replace 127.0.0.1 in the port mapping with a trusted host interface. Then restrict inbound traffic to the monitoring system.

Identity in Front of the Gateway​

An authenticating proxy in front of the gateway is what makes an audit record name a person rather than a shared token: Identity and SSO.

Docker Compose​

The default image preinstalls WhatsApp dependencies. To bake other enabled channels into an image (recommended for deployments without PyPI access), pass a comma-separated NANOINFRA_CHANNELS build argument:

NANOINFRA_CHANNELS=telegram,slack docker compose build

The image keeps nanoinfra in a virtual environment owned by its built-in non-root runtime user (UID 1000). If an enabled channel was not preinstalled, gateway startup can therefore install its manifest-declared dependencies. Rebuilding with NANOINFRA_CHANNELS keeps that installation reproducible instead of relying on the container's writable layer. If you override the container with a different --user, bake every enabled channel into the image. That UID is not guaranteed write access to the virtual environment.

docker compose run --rm nanoinfra-cli onboard # first-time setup
vim ~/.nanoinfra/config.json # add API keys
docker compose up -d nanoinfra-gateway # start gateway
docker compose run --rm nanoinfra-cli agent -m "Hello!" # run CLI
docker compose logs -f nanoinfra-gateway # view logs
docker compose down # stop

The default Compose file drops all Linux capabilities and keeps Docker's default AppArmor/seccomp profiles enabled. If you explicitly set "tools.exec.sandbox": "bwrap" in ~/.nanoinfra/config.json, add the bwrap override file when starting containers:

docker compose -f docker-compose.yml -f docker-compose.bwrap.yml up -d nanoinfra-gateway
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml run --rm nanoinfra-cli agent -m "Hello!"

The override grants CAP_SYS_ADMIN and disables AppArmor/seccomp confinement for the container so bubblewrap can create its nested namespaces. Use it only when the bwrap sandbox is enabled.

Docker​

# Pull the official image. Pin the exact version in production.
docker pull ghcr.io/nanoinfraorg/nanoinfra:1.0.1

# Or build it yourself, which is what you want if you need an extra or a
# channel's dependencies baked in rather than installed at first start.
docker build -t nanoinfra .
docker build --build-arg NANOINFRA_EXTRAS=bedrock -t nanoinfra .
docker build --build-arg NANOINFRA_CHANNELS=telegram,slack -t nanoinfra .

# Initialize config (first time only)
docker run --rm -it -v ~/.nanoinfra:/home/nanoinfra/.nanoinfra \
ghcr.io/nanoinfraorg/nanoinfra:latest onboard --wizard

# Edit config on host to add API keys
vim ~/.nanoinfra/config.json

# Run the gateway.
#
# `--cap-drop ALL` on its own is not hardening here, it is a downgrade. The
# entrypoint needs five capabilities to place the executor, the fetcher and the
# MCP host on their own accounts. Without them it does not refuse: it warns that
# "the agent and the executor share one uid, so either one can ptrace the other
# and read its memory", and the split becomes organisational rather than
# kernel-enforced. Drop everything, then add back exactly those five.
#
# A uid change clears the permitted set, so no process holds them once it runs.
docker run -d --name nanoinfra \
--cap-drop ALL \
--cap-add SETUID --cap-add SETGID --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \
-v ~/.nanoinfra:/home/nanoinfra/.nanoinfra \
-p 127.0.0.1:18790:18790 -p 127.0.0.1:8765:8765 \
ghcr.io/nanoinfraorg/nanoinfra:latest gateway

# If `tools.exec.sandbox: "bwrap"` is enabled, add the permissions bubblewrap
# needs for nested namespaces. Without them, `bwrap` may exit with
# `clone3: Operation not permitted`.
docker run -d --name nanoinfra \
--cap-drop ALL \
--cap-add SETUID --cap-add SETGID --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \
--cap-add SYS_ADMIN \
--security-opt apparmor=unconfined \
--security-opt seccomp=unconfined \
-v ~/.nanoinfra:/home/nanoinfra/.nanoinfra \
-p 127.0.0.1:18790:18790 -p 127.0.0.1:8765:8765 \
ghcr.io/nanoinfraorg/nanoinfra:latest gateway

# Or run a single command
docker run --rm -v ~/.nanoinfra:/home/nanoinfra/.nanoinfra \
ghcr.io/nanoinfraorg/nanoinfra:latest agent -m "Hello!"
docker run --rm -v ~/.nanoinfra:/home/nanoinfra/.nanoinfra \
ghcr.io/nanoinfraorg/nanoinfra:latest status

Publishing 8765 only reaches the channel if channels.websocket.host is "0.0.0.0" in the config, which the channel refuses without a token. The block above this one has the exact settings.

Linux Service​

Run the gateway as a systemd user service so it starts automatically and restarts on failure.

Preview the generated unit first:

nanoinfra gateway install-service --manager systemd --dry-run

Install, enable, and start it:

nanoinfra gateway install-service --manager systemd

For a custom instance, pass the same config/workspace selector you use to run the gateway:

nanoinfra gateway install-service \
--manager systemd \
--name nanoinfra-telegram \
--config ~/.nanoinfra-telegram/config.json \
--workspace ~/.nanoinfra-telegram/workspace

Common operations:

systemctl --user status nanoinfra-gateway # check status
systemctl --user restart nanoinfra-gateway # restart after config changes
journalctl --user -u nanoinfra-gateway -f # follow logs
nanoinfra gateway uninstall-service --manager systemd

The installer writes ~/.config/systemd/user/nanoinfra-gateway.service, runs systemctl --user daemon-reload, enables the unit, and restarts it. It uses the current Python executable with python -m nanoinfra gateway --foreground, so the service runs in the same environment you used to install nanoinfra.

Note: User services only run while you are logged in. To keep the gateway running after logout, enable lingering:

loginctl enable-linger $USER

macOS LaunchAgent​

Use a LaunchAgent when you want nanoinfra gateway to stay online after you log in, without keeping a terminal open.

Preview the generated plist first:

nanoinfra gateway install-service --manager launchd --dry-run

Install, load, enable, and start it:

nanoinfra gateway install-service --manager launchd

For a custom instance:

nanoinfra gateway install-service \
--manager launchd \
--name nanoinfra-telegram \
--config ~/.nanoinfra-telegram/config.json \
--workspace ~/.nanoinfra-telegram/workspace

Common operations:

launchctl list | grep ai.nanoinfra.gateway
launchctl kickstart -k gui/$(id -u)/ai.nanoinfra.gateway
nanoinfra gateway uninstall-service --manager launchd

The installer writes ~/Library/LaunchAgents/ai.nanoinfra.gateway.plist, uses the current Python executable with python -m nanoinfra gateway --foreground, and writes LaunchAgent logs under ~/.nanoinfra/logs/.

Note: if startup fails with "address already in use", stop the manually started nanoinfra gateway process first.