pi setup

Session

Host ↔ Container Path & Mount Contract

Single source. This file is the one shared contract between pi-docker-sandbox, which implements it, and pi-config / brain/, which consume it (spec FR-021). Both READMEs link here. Change a mount only together with this table, in the same commit. Design-time origin: pi-config/specs/001-pi-workspace-setup/contracts/path-contract.md.

Host layout (root PI_ROOT, default ~/pi)#

text
~/pi/
├── pi-config/            # agent config repo; only agent/ is mounted
│   └── agent/
├── pi-docker-sandbox/    # container definition + bin/pi-sandbox
├── brain/
│   ├── pi-memory-core/
│   ├── pi-memory-private/
│   ├── pi-memory-work/
│   └── pi-memory-<name>/ # one per further domain in PI_DOMAINS (pi-config spec 005)
├── config/               # pi-config-<domain>/: settings and MCP servers per level (spec 007);
│                         #   read on the host, never mounted
└── ws/                   # not a git repo
    ├── private/<path>/   # any depth, e.g. sandbox-test or git.example.org/group/repo
    ├── work/<path>/       # e.g. <host>/<customer>/<project | meeting notes | docs>
    └── <name>/<path>/    # any further configured domain

The domains are PI_DOMAINS in .env (default private work); pi-sandbox domain add <name> creates ws/<name>/ and brain/pi-memory-<name>/ (commands.md).

PI_ROOT is set in pi-docker-sandbox/.env. The default is derived from the location of pi-docker-sandbox (its parent directory). The wrapper refuses to start if PI_ROOT/pi-config/agent is missing and names the expected path (Edge Cases).

Session container mounts#

#Host pathContainer pathModeDeclared inWhen
1$PI_ROOT/ws/$DOMAIN/$PROJECT/ws/$DOMAIN/$PROJECT (working directory)rwcompose.yamlalways
2$PI_ROOT/pi-config/agent/opt/pi-config/agentrocompose.yamlalways
3$BRAIN_CORE_SRC (= $PI_ROOT/brain/pi-memory-core)/brain/corerwcompose.yamlalways (see “Missing stores”)
4$BRAIN_DOMAIN_SRC (= $PI_ROOT/brain/pi-memory-$DOMAIN)/brain/$DOMAINrwcompose.yamlalways (see “Missing stores”)
5$PI_ROOT/brain/pi-memory-$OTHER_DOMAIN/brain/$OTHER_DOMAINrocompose.other-domain.yamlonly with --with-domain <name> (or --with-other-domain with two domains) and when present; at most one
6named volume pi-agent-state/home/pi (contains .pi/agent)rwcompose.yamlalways
9named volume pi-domain-state-$DOMAIN/home/pi/.pi/domain (this domain’s MCP OAuth sign-ins, mcp-auth.json)rwcompose.yamlalways; created by the wrapper with pi-sessions-$DOMAIN (pi-config spec 007)
8named volume pi-sessions-$DOMAIN/home/pi/.pi/agent/sessions (this domain’s session history)rwcompose.yamlalways; created by the wrapper on the domain’s first use (pi-config spec 006)
10$PI_LAYER_RESOURCES (= pi-docker-sandbox/.run/<run-id>, or pi-docker-sandbox/empty)/opt/pi-layers (this session’s skills and prompt templates, global + levels)rocompose.yamlalways; staged per start and removed afterwards (pi-config spec 008, config.md)
7tmpfs/tmp, /run/pi-sessionrwcompose.yamlalways
11per-session volume <project>-s<pid>_pi-ssh (tmpfs driver, uid 1000, mode 0700)/run/pi-ssh (the ssh-agent socket; no key)rwcompose.ssh.yamlonly when an ssh.json on the path has a host with a usable key; removed with the session (config.md)

The root filesystem is read-only. Nothing else is mounted. These are explicitly forbidden:

  • $HOME
  • ~/.ssh and SSH_AUTH_SOCK — push does not mount them: the wrapper reads a key file or command (or a token command) named in .env and passes the credential through the environment of that one session (credentials.md)
  • ~/.pi/agent
  • /var/run/docker.sock
  • ~/.gitconfig and any host git credential helper
  • the macOS Keychain and password-manager CLIs (secrets are read on the host by bin/pi-sandbox and passed by name only, never mounted)
  • any other ws/ project
  • any other domain’s store without the opt-in, and more than one extra domain
  • any other domain’s session history (pi-sessions-<other>), also with the opt-in
  • any domain configuration store (config/pi-config-<domain>/, read on the host only) and any other domain’s pi-domain-state-<other>

Missing stores#

If a brain repo is missing on the host, the wrapper does three things:

  1. It points that store’s source variable at pi-docker-sandbox/empty/, a versioned, empty directory.
  2. It mounts that directory with the override compose.missing-<slot>.yaml as read-only.
  3. It sets MISSING_STORES=<ids> and prints warning: brain store <id> not found at <path>; starting without it.

The session context marks the store as missing, so the agent does not write there. Nothing is created on the host (Edge Cases). If an opt-in store is missing, it is simply left out, with the same warning.

pi-sandbox selftest uses exactly the session mounts above and checks that /brain holds only core, the session’s domain and the opted-in extra domain. The isolation test is part of the image (/usr/local/lib/pi-sandbox/selftest/), so the self-test adds no mounts.

Sync container mounts (compose.sync.yaml)#

Host pathContainer pathMode
$PI_ROOT/brain/pi-memory-core/brain/corerw
$PI_ROOT/brain/pi-memory-$DOMAIN/brain/$DOMAINrw
tmpfs/tmp, /home/pirw

There is no project, no pi-config mount and no agent state. A missing store makes the sync stop with a message. git runs with hooks disabled and without system or global config; the sync refuses a store whose repo-local .git/config has keys outside an allowlist (FR-019a).

Workspace container mounts (compose.workspace.yaml)#

Host pathContainer pathMode
$PI_ROOT/ws/$DOMAIN/ws/<domain> (the domain’s whole workspace)rw
tmpfs/tmp, /home/pirw

Started only by pi-sandbox workspace clone|refresh (pi-config spec 009, workspace.md). No memory, no pi-config, no configuration store, no agent state, no other domain; the definition check enforces exactly this one bind. Same image and hardening as a session.

Sessions container mounts (compose.sessions.yaml, compose.legacy.yaml)#

ServiceVolumeContainer pathModeWhen
sessionspi-sessions-$DOMAIN/home/pi/.pi/agent/sessionsrwpi-sandbox sessions prune|clear|list, migration
sessions + compose.sessions-legacy.yamlpi-agent-state/staterwonly the one-time migration when pi-sessions-$DOMAIN is created
legacy (compose.legacy.yaml)pi-agent-state/staterwsessions list (legacy row) and sessions clear --legacy

Plus tmpfs /tmp and /home/pi. network_mode: none, no bind mount, no project, no memory, no pi-config, no credentials; same hardening as the other containers. Environment: DOMAIN, PI_DOMAINS, SESSION_RETENTION_DAYS.

Check container mounts (compose.check.yaml)#

Host pathContainer pathMode
$PI_ROOT/brain/pi-memory-core (or empty/ if missing)/brain/corero
$PI_ROOT/brain/pi-memory-$DOMAIN (or empty/ if missing)/brain/$DOMAINro
tmpfs/tmp, /home/pirw

network_mode: none; no credentials, no git identity, no project, no pi-config, no agent state. Environment: DOMAIN, MISSING_STORES only. Started by pi-sandbox memory-check (pi-config spec 003); scripts/check-sandbox-definition.sh fails if it gets a network or a writable mount.

kube-proxy sidecar (compose.yaml service kube-proxy, profile kube)#

No mounts; tmpfs /tmp (holds the kubeconfig). Same image and hardening as the session container (user 1000, read-only, no capabilities, no-new-privileges), 64 pids, 0.5 CPU, 256 MiB. Environment: PI_KUBECONFIG only. Started by the wrapper per session when a level kube.json provides a kubeconfig, in the session’s own compose project <project>-s<pid>, and removed with its network when the session ends (config.md). kubectl proxy listens on its loopback (8002); kube-filter.py serves port 8001 of that project’s network only (no ports:).

ssh-agent sidecar (compose.ssh.yaml service ssh-agent, profile ssh)#

Host pathContainer pathMode
volume <project>-s<pid>_pi-ssh (tmpfs)/run/pi-ssh (agent socket)rw
tmpfs/tmprw

network_mode: none. Same image and hardening as the session container (user 1000, read-only, no capabilities, no-new-privileges), 32 pids, 0.25 CPU, 64 MiB. Environment: PI_SSH_KEYS only. Started by the wrapper per session when SSH hosts have usable keys, in the session’s own compose project, and removed with the volume when the session ends (config.md). scripts/check-sandbox-definition.sh fails if the volume is not tmpfs or the sidecar gets a network.

Path resolution (FR-017)#

The wrapper resolves <project-path> with realpath, which follows every symlink. The result must lie at $PI_ROOT/ws/<domain>/<path> with at least one folder below the domain; PROJECT is that relative <path> (pi-config spec 004). Rules:

  • <domain> is one of the configured domains (PI_DOMAINS, default private work);
  • <path> is one or more folders below the domain, at any depth, e.g. sandbox-test or git.example.org/group/repo (pi-config spec 004); every folder name matches [A-Za-z0-9._-]+ (dots allowed for host names) and none is .git;
  • the project does not need to be a git repository (meeting notes, docs), but a folder inside a git repository that is not its root is refused (…/repo/src), so a subfolder never becomes its own project or memory level;
  • the domain folder itself, and symlinks that land somewhere else (for example ws/private/x → an work repo), are refused.

The domain is always read from the resolved path. Only the project folder is mounted, at /ws/<domain>/<path>; intermediate folders and sibling projects stay invisible (the selftest checks every level). The container label is pi-sandbox.project=<domain>/<path>; the compose project name is pi-<domain>-<name> for one level and pi-<domain>-<sanitised path, ≤ 40 chars>-<cksum of domain/path> for deeper paths, so same-named projects under different hosts never collide.

Environment variables passed into containers#

VariableSessionSyncSource
DOMAIN, PROJECT, OTHER_DOMAIN (empty without an opt-in), WITH_OTHER_DOMAIN, MISSING_STORES✓DOMAIN onlyWrapper (derived from path/flags)
PI_CODING_AGENT_DIR=/home/pi/.pi/agent✓–compose.yaml
PI_SKIP_VERSION_CHECK=1✓–Image
GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, GIT_COMMITTER_NAME, GIT_COMMITTER_EMAIL✓✓.env
TELEGRAM_BOT_TOKENnon-empty when TELEGRAM_BOT_TOKEN_CMD is set in .env–.env command → wrapper environment → declared in compose.yaml (never on argv or in a file)
FIGMA_API_KEYnon-empty when FIGMA_API_KEY_CMD is set in .env–.env command → wrapper environment → declared in compose.yaml (never on argv or in a file)
ANTHROPIC_API_KEYnon-empty only with --key-from-keychain–Keychain → wrapper environment → declared in compose.yaml (never on argv or in a file)
BRAIN_CORE_URL, BRAIN_CORE_TOKENnever✓.env / BRAIN_CORE_TOKEN_CMD or Keychain brain-core-token
BRAIN_DOMAIN_URL, BRAIN_DOMAIN_TOKEN (slots for the synced domain; no variable is named after a domain)never✓the domain’s BRAIN_<ID>_URL in .env / BRAIN_<ID>_TOKEN_CMD or Keychain brain-<name>-token
CREDENTIAL_SOURCE✓–Wrapper (stored-login | keychain)
PI_LAYERED_SETTINGS, PI_LAYERED_MCP✓–Wrapper: merged settings / MCP servers of this path, base64 JSON without secret values (config.md)
PI_CONFIG_LEVELS✓–Wrapper: contributing configuration levels, ;-separated
PI_LAYERED_AGENTS✓–Wrapper: stacked instructions of this path (global + levels), base64, at most 64 KiB (pi-config spec 008)
PI_INSTRUCTION_LEVELS, PI_DOMAIN_INSTRUCTIONS✓–Wrapper: levels that contributed instructions (;-separated); present or missing for the domain’s AGENTS.md
PI_LAYER_RESOURCEShost only–Wrapper: source of mount 10 (staging folder or empty/); not passed into the container
PI_MCP_SECRET_1, PI_MCP_SECRET_2, PI_MCP_SECRET_3, PI_MCP_SECRET_4, PI_MCP_SECRET_5, PI_MCP_SECRET_6, PI_MCP_SECRET_7, PI_MCP_SECRET_8, PI_MCP_SECRET_9, PI_MCP_SECRET_10, PI_MCP_SECRET_11, PI_MCP_SECRET_12, PI_MCP_SECRET_13, PI_MCP_SECRET_14, PI_MCP_SECRET_15, PI_MCP_SECRET_16✓ (only the ones in use)–Wrapper: MCP secrets read on the host for this session (MCP_<ID>_<NAME>_CMD or Keychain mcp-<domain>-<NAME>)
PI_GATEWAY_API_KEY✓ (only with model-proxy.providers.gateway)–Wrapper: key of the Gateway model provider (MCP_<ID>_GATEWAY_API_KEY_CMD, Keychain mcp-<domain>-GATEWAY_API_KEY, else GATEWAY_GLOBAL_TOKEN)
PI_KUBE_LEVELnon-empty only with a kube.json on the project’s path–Wrapper: the level of the nearest kube.json; the entrypoint points KUBECONFIG at the kube-proxy sidecar (no credentials) (config.md)
PI_KUBE_ACCESS, PI_KUBE_SECRETS, PI_KUBE_TUNNELSwith PI_KUBE_LEVELrestart, 0, 0Wrapper: the options of that kube.json; only used to tell the agent what the proxy allows, the kube-proxy sidecar enforces them (config.md)
PI_SSH_HOSTSnon-empty only with SSH hosts on the project’s path–Wrapper: the merged ssh.json hosts with usable keys (alias, target, host keys, public key; base64 JSON); the entrypoint writes the session ssh config (config.md)
PI_SSH_KEYSnever (ssh-agent sidecar only)–Wrapper: their private keys (base64 JSON list, at most 64 KiB), read like MCP secrets; the sidecar loads them into its ssh-agent
PI_KUBECONFIGnever (kube-proxy sidecar only)–Wrapper: that level’s kubeconfig, base64, at most 64 KiB, read like an MCP secret; the sidecar writes it to its tmpfs and unsets it
PUSH_MODE, PUSH_HOSTnon-empty only when the project’s host has a push credential–Wrapper: ssh or https, and the project’s git host (first folder of the project path)
PUSH_SSH_KEY_B64, PUSH_KNOWN_HOSTS_B64, PUSH_HTTPS_USER, PUSH_HTTPS_TOKENonly for the matching PUSH_MODE–Wrapper: the key (base64) read from WS_<ID>_<HOST_ID>_PUSH_KEY/_KEY_CMD, host keys of WS_<HOST_ID>_KNOWN_HOSTS_FILE, or the token and user of WS_<ID>_<HOST_ID>_PUSH_TOKEN_CMD/Keychain; declared in compose.yaml only, never on argv or in a file; unset by the entrypoint once the key is in the session’s ssh-agent / the token in a tmpfs file (credentials.md)
PI_KNOWN_HOSTS_B64✓–Wrapper: public SSH host keys of known_hosts.d/ (pi-sandbox known-hosts), base64; the entrypoint unsets it after writing the session known_hosts. Empty = none
PUSH_PROTECTED_BRANCHES✓–.env: globs for the pre-push guard (empty = built-in list)
WS_HOST, WS_GROUP, WS_TYPE, WS_REMOTE, WS_USER, WS_INCLUDE_ARCHIVED, WS_INCLUDE_FORKS––Wrapper → workspace container only: host, group path, forge type, remote form and options of the run (workspace.md)
WS_TOKENnever–Workspace container only: the forge token from WS_<ID>_<HOST_ID>_TOKEN_CMD or Keychain ws-<domain>-<host>-token
SESSION_RETENTION_DAYS✓–Wrapper: SESSIONS_<ID>_RETENTION_DAYS, else SESSIONS_RETENTION_DAYS, else 30 (pi-config spec 006)

Network#

  • Default bridge network. Outbound traffic is unrestricted (FR-011). There is no ports: entry.
  • The endpoints the agent needs are documented in pi-docker-sandbox/docs/security.md: LLM providers, registry.npmjs.org, gitlab.com and github.com for clones and pulls, and the web search and fetch targets used by pi-web-access.
  • Residual risk: the agent can reach LAN and homelab services.