---
name: installing-clis-on-readonly-home
description: Install and run CLI tools that hardcode state under $HOME when the VPS mounts /root read-only except /root/.hermes (e.g. the Composio CLI). Trigger whenever a CLI install or first run fails with "Read-only file system" or ENOENT writing $HOME/.<tool>, when env-var config-dir overrides are ignored, or when symlinks into /root fail. Also trigger for agent-driven OAuth device flows (composio login --no-wait / --poll) on this VPS.
---

# Installing CLIs on a read-only $HOME

## The environment

This VPS mounts `/root` read-only except `/root/.hermes`. Many CLIs hardcode
state at `$HOME/.<tool>` and offer no working override. Fixes must keep all
writes under `/root/.hermes` and require zero changes to the read-only fs.

## Triage order (cheap → expensive)

1. **Env-var install dir for the binary.** Many installers honor
   `<TOOL>_INSTALL_DIR` / `PREFIX`. E.g. Composio:
   `COMPOSIO_INSTALL_DIR=/root/.hermes/composio bash install.sh`
   Post-install steps may still try to write `$HOME/.<tool>` and print an
   error — check whether the binary itself works before treating that as fatal.
2. **Env-var config dir.** Grep the installed package SOURCE (or node_modules
   dir) for candidates — more reliable than `strings` on a binary for
   interpreted tools:
   `grep -rhoE '<TOOL>_[A-Z_]+' <install-dir>/src/ | sort -u`
   (compiled binary: `strings <bin> | grep -oE '<TOOL>_[A-Z_]+' | sort -u`).
   Then actually TEST each promising var — presence in source ≠ honored.
   (Composio's `COMPOSIO_DIR` is present but ignored; GBrain's `GBRAIN_HOME`
   IS honored — full `~/.gbrain` redirect, no wrapper needed.)
3. **Symlink `$HOME/.<tool>` → writable dir.** Only works if `$HOME` itself is
   writable. On this VPS it fails: `ln: Read-only file system`.
4. **Mount-namespace wrapper (the reliable fallback).** Run the CLI inside
   `unshare -rm` with a fake `$HOME` whose `.<tool>` is a bind mount of a
   persistent dir under `/root/.hermes`. See
   `scripts/homeless-cli-wrapper.sh` — a generic template. A plain
   `mount --bind` over `$HOME/.<tool>` does NOT work (read-only parent
   rejects it); the private-namespace + fake-HOME pattern does.
   `unshare -rm` (user+mount ns) works as root here.

## Verify, don't assume

- After install, run `--version` via the wrapper and confirm state files
  actually landed in the persistent dir (`ls /root/.hermes/<tool>-home`).
- Beware CLIs that exit 0 with empty output when unconfigured (e.g.
  `composio whoami`). Ground truth is the on-disk state files, not exit codes.

## Agent-driven OAuth / device-flow logins (no direct browser control)

Pattern: `tool login --no-wait` prints a URL+key, agent shows URL to user,
agent polls for completion.

- Mint the key, then IMMEDIATELY start the poll as a **background process**
  (notify_on_complete, timeout ~600s) so it's already listening when the user
  finishes the browser flow. Foreground polling blocks the turn and can eat
  the whole key TTL.
- Login keys have SHORT TTLs (Composio: minutes). On "pending login expired",
  delete the pending-session file, mint a fresh key, re-poll — don't retry
  the dead key.
- Warn the user to click the browser flow ALL the way through org/project
  pickers to a success confirmation. Closing the tab early or "skipping" a
  stuck picker leaves the link incomplete even though the page looked done.

## Concrete instances

Composio CLI on this VPS: binary at `/root/.hermes/composio/composio`,
state at `/root/.hermes/composio-home`, deployed wrapper at
`/root/.hermes/bin/composio`. Full runbook with error transcripts:
`references/composio-on-vps.md`.

Bun + GBrain (2026-08-06): installer honors `BUN_INSTALL=/root/.hermes/bun`
(Step 1 works); Bun's *global* package dir separately needs
`BUN_INSTALL_GLOBAL_DIR=/root/.hermes/bun/install/global` or `bun install -g`
still targets `$HOME/.bun`. GBrain's `~/.gbrain` state honored its
`GBRAIN_HOME` env var (Step 2 worked — no wrapper needed; point it under
`/root/.hermes/`). Lesson: for multi-component tools (installer + global
pkg dir + app state), expect a SEPARATE env var per write path, and grep the
installed source for `<TOOL>_[A-Z_]+` when the tool's own docs don't list one —
GBrain's `GBRAIN_HOME` was found by grep, not docs.

## npm packages on read-only root (2026-08-15)

`npm install -g` fails when `/root/.npm` is read-only and npm tries to write `/root/.npmrc`. Two-part fix:

1. Point npm's prefix to a writable dir:
   ```bash
   mkdir -p /root/.hermes/npm-global
   HOME=/tmp/npm-home npm config set prefix /root/.hermes/npm-global
   ```
2. Install with the same HOME override:
   ```bash
   HOME=/tmp/npm-home npm install -g <package>
   ```
3. Add to PATH:
   ```bash
   export PATH="/root/.hermes/npm-global/bin:$PATH"
   ```

Applied to: Claude Code CLI (`@anthropic-ai/claude-code`) → v2.1.197 at `/root/.hermes/npm-global/bin/claude`.

## App-level env-var overrides for data paths (2026-08-15)

Some tools ignore npm prefix and still try to write data under `$HOME/.cache` or `$HOME/.local`. Pattern: set the tool-specific env var at install AND at runtime.

- **Playwright:** `PLAYWRIGHT_BROWSERS_PATH=/root/.hermes/playwright-browsers` (both `playwright install chromium` and every Python invocation)
- **Claude Code:** `ANTHROPIC_API_KEY` env var for auth; no data-path override needed (state goes to `~/.claude/`, which is fine on this VPS)

If a tool's first run fails with `EROFS` or `ENOENT` writing under `$HOME`, grep its docs or source for `<TOOL>_*_PATH` / `<TOOL>_*_DIR` / `<TOOL>_HOME` before resorting to the mount-namespace wrapper.
