Host ↔ Container Path & Mount Contract
Single source. This file is the one shared contract between
pi-docker-sandbox, which implements it, andpi-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)#
~/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 domainThe 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 path | Container path | Mode | Declared in | When |
|---|---|---|---|---|---|
| 1 | $PI_ROOT/ws/$DOMAIN/$PROJECT | /ws/$DOMAIN/$PROJECT (working directory) | rw | compose.yaml | always |
| 2 | $PI_ROOT/pi-config/agent | /opt/pi-config/agent | ro | compose.yaml | always |
| 3 | $BRAIN_CORE_SRC (= $PI_ROOT/brain/pi-memory-core) | /brain/core | rw | compose.yaml | always (see “Missing stores”) |
| 4 | $BRAIN_DOMAIN_SRC (= $PI_ROOT/brain/pi-memory-$DOMAIN) | /brain/$DOMAIN | rw | compose.yaml | always (see “Missing stores”) |
| 5 | $PI_ROOT/brain/pi-memory-$OTHER_DOMAIN | /brain/$OTHER_DOMAIN | ro | compose.other-domain.yaml | only with --with-domain <name> (or --with-other-domain with two domains) and when present; at most one |
| 6 | named volume pi-agent-state | /home/pi (contains .pi/agent) | rw | compose.yaml | always |
| 9 | named volume pi-domain-state-$DOMAIN | /home/pi/.pi/domain (this domain’s MCP OAuth sign-ins, mcp-auth.json) | rw | compose.yaml | always; created by the wrapper with pi-sessions-$DOMAIN (pi-config spec 007) |
| 8 | named volume pi-sessions-$DOMAIN | /home/pi/.pi/agent/sessions (this domain’s session history) | rw | compose.yaml | always; 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) | ro | compose.yaml | always; staged per start and removed afterwards (pi-config spec 008, config.md) |
| 7 | tmpfs | /tmp, /run/pi-session | rw | compose.yaml | always |
| 11 | per-session volume <project>-s<pid>_pi-ssh (tmpfs driver, uid 1000, mode 0700) | /run/pi-ssh (the ssh-agent socket; no key) | rw | compose.ssh.yaml | only 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~/.sshandSSH_AUTH_SOCK— push does not mount them: the wrapper reads a key file or command (or a token command) named in.envand passes the credential through the environment of that one session (credentials.md)~/.pi/agent/var/run/docker.sock~/.gitconfigand any host git credential helper- the macOS Keychain and password-manager CLIs (secrets are read on the host by
bin/pi-sandboxand 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’spi-domain-state-<other>
Missing stores#
If a brain repo is missing on the host, the wrapper does three things:
- It points that store’s source variable at
pi-docker-sandbox/empty/, a versioned, empty directory. - It mounts that directory with the override
compose.missing-<slot>.yamlas read-only. - It sets
MISSING_STORES=<ids>and printswarning: 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 path | Container path | Mode |
|---|---|---|
$PI_ROOT/brain/pi-memory-core | /brain/core | rw |
$PI_ROOT/brain/pi-memory-$DOMAIN | /brain/$DOMAIN | rw |
| tmpfs | /tmp, /home/pi | rw |
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 path | Container path | Mode |
|---|---|---|
$PI_ROOT/ws/$DOMAIN | /ws/<domain> (the domain’s whole workspace) | rw |
| tmpfs | /tmp, /home/pi | rw |
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)#
| Service | Volume | Container path | Mode | When |
|---|---|---|---|---|
sessions | pi-sessions-$DOMAIN | /home/pi/.pi/agent/sessions | rw | pi-sandbox sessions prune|clear|list, migration |
sessions + compose.sessions-legacy.yaml | pi-agent-state | /state | rw | only the one-time migration when pi-sessions-$DOMAIN is created |
legacy (compose.legacy.yaml) | pi-agent-state | /state | rw | sessions 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 path | Container path | Mode |
|---|---|---|
$PI_ROOT/brain/pi-memory-core (or empty/ if missing) | /brain/core | ro |
$PI_ROOT/brain/pi-memory-$DOMAIN (or empty/ if missing) | /brain/$DOMAIN | ro |
| tmpfs | /tmp, /home/pi | rw |
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 path | Container path | Mode |
|---|---|---|
volume <project>-s<pid>_pi-ssh (tmpfs) | /run/pi-ssh (agent socket) | rw |
| tmpfs | /tmp | rw |
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, defaultprivate work);<path>is one or more folders below the domain, at any depth, e.g.sandbox-testorgit.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#
| Variable | Session | Sync | Source |
|---|---|---|---|
DOMAIN, PROJECT, OTHER_DOMAIN (empty without an opt-in), WITH_OTHER_DOMAIN, MISSING_STORES | ✓ | DOMAIN only | Wrapper (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_TOKEN | non-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_KEY | non-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_KEY | non-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_TOKEN | never | ✓ | .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_RESOURCES | host 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_LEVEL | non-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_TUNNELS | with PI_KUBE_LEVEL | restart, 0, 0 | Wrapper: 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_HOSTS | non-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_KEYS | never (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_KUBECONFIG | never (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_HOST | non-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_TOKEN | only 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_TOKEN | never | – | 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.comandgithub.comfor clones and pulls, and the web search and fetch targets used bypi-web-access. - Residual risk: the agent can reach LAN and homelab services.