pi setup

Host

Credentials

How model credentials get into the sandbox, which one wins, and how to revoke or replace each credential (spec FR-008, FR-008b, FR-025). No credential is ever stored in a git repo or in the image.

1. Default: log in once inside the container#

bash
pi-sandbox start ~/pi/ws/private/sandbox-test
# inside pi:
/login

pi writes the result to ~/.pi/agent/auth.json inside the pi-agent-state volume. The login persists across sessions and rebuilds and is shared by both domains (state.md). The summary line shows credentials=stored-login.

2. Alternative: inject an API key from the Keychain for one session#

Store the key once (you are prompted for the value, so it never lands in shell history):

bash
security add-generic-password -s pi-sandbox -a anthropic-api-key -U -w

Or let a password manager provide it: set ANTHROPIC_API_KEY_CMD in .env, for example ANTHROPIC_API_KEY_CMD="op read op://Private/anthropic/credential" or ANTHROPIC_API_KEY_CMD="bw get password anthropic-api-key". When it is set, the wrapper runs it on the Mac instead of reading the Keychain; a failing or empty command stops with exit 20.

With Bitwarden (bw on the PATH, a *_CMD in .env containing bw , interactive terminal), start and shell check bw status (which honours an exported BW_SESSION); if the vault is locked or the BW_SESSION is stale, bw unlock --raw asks for the master password once and the session key is kept in BW_SESSION for that run only. Nothing is stored. Without a terminal nothing is asked and a locked vault fails as before.

Start with the flag:

bash
pi-sandbox start ~/pi/ws/private/sandbox-test --key-from-keychain

The wrapper reads the item on the host and passes it as ANTHROPIC_API_KEY through its environment (declared in compose.yaml; never on the command line, never in a file). The key lives only in that container’s process environment and disappears when the session ends. The summary line shows credentials=keychain. A locked keychain or a missing item stops the start with exit code 20; there is no silent fallback.

Telegram bot token (pi-telegram)#

The pi-config package @llblab/pi-telegram (/telegram-setup, /telegram-connect, see pi-config docs/config-reference.md) lets you follow and steer a session through a Telegram bot. The bot token is a secret; your user id is not and goes into ~/.pi/agent/telegram.json (profiles.default.allowedUserId), which otherwise holds only the reference $TELEGRAM_BOT_TOKEN.

  1. Create the bot with @BotFather and keep the token in a password manager.
  2. Set a command that prints it in .env, for example TELEGRAM_BOT_TOKEN_CMD="op read op://Private/pi-telegram/token" or TELEGRAM_BOT_TOKEN_CMD="bw get password pi-telegram". .env holds the command, never the token.
  3. Start a session as usual. At every start and shell the wrapper runs the command on the Mac and passes the output into that session as TELEGRAM_BOT_TOKEN (environment only, never argv or a file).

Rules: optional (unset = no token, the bridge’s Telegram part stays off); no Keychain fallback; a failing or empty command stops with exit 22; a TELEGRAM_BOT_TOKEN already set on the host is discarded. The agent can read the token during the session, like the API key above; use a bot dedicated to this and revoke it with /revoke in @BotFather if exposed.

Other providers: model proxy proxy#

pi-provider-model-proxy adds the provider model-proxy. Run /login model-proxy inside the sandbox and choose Sign in with an API key: the proxy URL and key go to auth.json in the state volume, like the default login. Browser SSO with a 127.0.0.1 callback does not work in the sandbox (no published ports); use the API key or the proxy’s verification-code SSO flow. /logout model-proxy removes the local credential; revoke the key on the proxy.

Cloud CLIs: stackit, hcloud, gh#

The image has the STACKIT CLI (stackit), the Hetzner Cloud CLI (hcloud) and the GitHub CLI (gh) for every domain. Their configuration and sign-in live in the domain’s volume (~/.config/stackit, ~/.config/hcloud and ~/.config/gh are links into pi-domain-state-<domain>), so a sign-in made in an work session is never visible in private sessions and the other way round. Sign in once per domain, inside a session (pi-sandbox shell <project> or ! in pi):

  • STACKIT: stackit auth activate-service-account --service-account-token <token> (or --service-account-key-path with a key file you copy into the project for the moment and delete afterwards). stackit auth login does not work in the sandbox: it waits for a browser callback on ports 8000–8020 inside the container, and no ports are published. Set the default project with stackit config set --project-id <id>. stackit auth logout removes the sign-in.
  • Hetzner Cloud: hcloud context create <name> and paste an API token of the project (read only where that is enough). hcloud context delete <name> removes it.
  • GitHub: gh auth login --with-token and paste a fine-grained token (read-only access to the repositories you need) on stdin; gh auth login without --with-token does not work in the sandbox (browser flow). gh auth logout removes it. Without a sign-in gh reads public repos only.

Revoke the service account or API token (or the GitHub token) in the provider’s console when you no longer need it.

Cluster CLIs: kubectl, talosctl#

The image has kubectl and talosctl (and yamllint) for every domain. Like the cloud CLIs, their configuration lives in the domain’s volume: ~/.kube and ~/.talos are links into pi-domain-state-<domain> (cli/kube, cli/talos), so a kubeconfig placed in an work session is never visible in private sessions. The tools use their default locations (~/.kube/config, ~/.talos/config), so no environment variable is needed. Set up once per domain, inside a session (pi-sandbox shell <project> or ! in pi):

  • kubectl: copy a kubeconfig to ~/.kube/config (chmod 600), or create contexts with kubectl config set-cluster|set-credentials|set-context. Several contexts in one file are fine; the agent uses kubectl --context <name>. Prefer a context with a read-only ServiceAccount or role. Exec-plugin credentials (oidc-login etc.) do not work: the plugin binaries are not in the image and a browser callback cannot reach the container; use a token or certificate.
  • talosctl: copy a talosconfig to ~/.talos/config (chmod 600) or run talosctl config merge <file>. Prefer a role of os:reader where that is enough.

This kubeconfig is shared by every session of the domain. For per-customer or per-project clusters use a level kube.json instead: the session then reaches only that cluster, through a read-only proxy sidecar that holds the kubeconfig, read on the Mac at every start; the agent never sees it (config.md).

Delete the file (rm ~/.kube/config) to sign out. Copy the config into the project only for the moment and delete it afterwards; do not commit it.

SSH keys for servers#

Keys for logging in to customer or project servers are configured per level in ssh.json and read on the Mac at every start, like the kubeconfig; only an ssh-agent sidecar holds them and pi can use but not read them (config.md). Use a dedicated key per server or customer without a passphrase, and revoke it on the server (authorized_keys) when it is no longer needed. Your own ~/.ssh and SSH agent are never used.

Hermit toolsets#

Repos that use Hermit (bin/hermit shims) install their pinned tools on first use. The download cache is HERMIT_STATE_DIR, set by the entrypoint to the domain volume (cli/hermit), so a tool fetched in one domain is not reused in another.

The image pins Hermit itself: HERMIT_VERSION with the SHA-256 of its release download, and the package index (cashapp/hermit-packages) as a GitHub archive at a fixed commit, in /opt/hermit-packages. The entrypoint seeds the state dir with that binary and turns self-update off, so the stub never fetches stable from GitHub. The index is read from the image only by the session’s toolsets (config.md). A repo’s own hermit.hcl can name other manifest sources, and Hermit then syncs those from their HEAD; the image cannot pin them. Treat a Hermit-managed repo like its scripts, since its tools run with the session’s rights, and be stricter with customer repos. The baked kubectl, talosctl and others stay as the fallback for repos without Hermit.

Forge tokens for workspace cloning#

pi-sandbox workspace clone|refresh reads a forge token per domain and host on the Mac (WS_<ID>_<HOST_ID>_TOKEN_CMD in .env, else the Keychain item ws-<domain>-<host>-token) and passes it only to the workspace container; sessions never get it. Read-only repository access is enough. Details: workspace.md.

Push from sessions#

Sessions can push to the forge when you give a domain a push credential for the git host of the project (pi-config spec 011). It is off by default; without a credential there is no agent, no token, no hook and no GIT_SSH_COMMAND in the session. It is set up like the workspace (ghorg) settings: per domain and git host, and the remote form chooses the kind of credential. The host is the first folder of the project path (ws/<domain>/<host>/<group>/…/<repo>, the layout pi-sandbox workspace clone creates); a project without a host folder never pushes. <ID> is the domain name in uppercase (- → _), <HOST_ID> the host in uppercase (. and - → _).

Remote formSetting in .envCredential
ssh (default)WS_<ID>_<HOST_ID>_PUSH_KEY=<path> or WS_<ID>_<HOST_ID>_PUSH_KEY_CMD="<command>"an unencrypted private key: a file, or a command that prints it
httpsWS_<ID>_<HOST_ID>_PUSH_TOKEN_CMD="<command>", else the Keychain item ws-<domain>-<host>-push-token; optional WS_<ID>_<HOST_ID>_PUSH_USERan access token (user oauth2, x-access-token on GitHub, or the setting)

The remote form is WS_<ID>_<HOST_ID>_REMOTE (this domain) or WS_<HOST_ID>_REMOTE (all domains), default ssh; the same setting workspace clone uses for the remote URLs. A credential of the other kind is ignored with a warning. Example for the work domain on gitlab.com:

bash
WS_WORK_GITLAB_COM_PUSH_KEY=~/.ssh/pi-work-gitlab                    # remote ssh
# or
WS_WORK_GITLAB_COM_REMOTE=https
WS_WORK_GITLAB_COM_PUSH_TOKEN_CMD="op read op://Private/pi-gitlab/token"

Set-up (once per domain and host, on the Mac)

  1. Create a dedicated credential, never your personal one. For ssh a passphrase-less key: ssh-keygen -t ed25519 -N '' -C pi-sandbox-work-gitlab -f ~/.ssh/pi-work-gitlab. For https an access token with only the scope to push (write:repository on Codeberg, write_repository on GitLab, a fine-grained token on GitHub).
  2. Give the forge the least access that works: a machine user that is a collaborator with write access only on the repositories sessions may push to, or one read-write deploy key per repository. Customer domains must not share a credential with other domains.
  3. Turn on server-side branch protection for the default branch of every such repository (Codeberg/Forgejo: Settings → Branches; GitLab and GitHub the same): nobody may push directly, changes arrive by pull request. This is the real guarantee; the in-session guard below is defence in depth.
  4. Set the variables in .env. Hosts other than codeberg.org, github.com and gitlab.com (host keys pinned in the image and checked against what the forges publish) need their public host keys: pi-sandbox known-hosts add <host> (checks the fingerprints with you and stores them for every session, see below) or a known_hosts-format file in WS_<HOST_ID>_KNOWN_HOSTS_FILE (only for ssh push to that host).
  5. Start a session in a project below ws/<domain>/<host>/: the summary line ends with push=ssh(<host>) or push=https(<host>).

What happens. The wrapper reads the credential on the Mac (exit 60 if it cannot, 61 if it is not usable; with ssh-keygen on the host it also proves a key loads without a passphrase) and passes it to this session only, with PUSH_MODE and PUSH_HOST. Inside:

  • ssh: the entrypoint loads the key into an ssh-agent in the session tmpfs and unsets the variable, so pi and its tools never inherit it. git uses the agent with strict host-key checking, no ssh config file and no agent forwarding; https:// and ssh:// push URLs of the host are rewritten to git@<host>:.
  • https: the token goes to a 0600 file in the session tmpfs and the variable is unset. A git credential helper (git-credential-push) serves it only for https, the project’s own host and its own repository path (<group>/<repo>), never for another repository or host. git@<host>: and ssh:// push URLs are rewritten to https://; git never prompts.
  • Both: nothing is written into any repository’s .git: git is configured through environment variables, and core.hooksPath points to a global hook shim that runs the repository’s own hooks first (prek and friends keep working) and then applies the pre-push guard: no push to the remote’s default branch or to main, master, trunk, develop, dev, production, prod, stable, release* (replace the list with PUSH_PROTECTED_BRANCHES), no force or non-fast-forward push, no deleting or moving remote refs or tags. The session context tells the agent that push is on and what it may do.

What this does not protect. The agent controls git inside the session and can bypass a client hook; and the credential is readable by the agent (the entrypoint’s /proc/<pid>/environ still holds it after it is unset, and the ssh-agent socket can sign). Anyone who can run commands in the session can push anywhere the credential is allowed to; the https helper limits the token’s automatic use to the project’s repository, not what the token itself may do. Hence: dedicated credential, least access, branch protection on the forge. Details and the residual risk: security.md.

Revoke. Remove the key or token on the forge and delete the file or Keychain item; unset the WS_*_PUSH_* lines to turn push off.

Precedence and switching#

pi uses a stored login before an environment key. So:

SituationResult
No flagStored login is used (or pi asks you to /login). Any ANTHROPIC_API_KEY in your host shell is discarded.
--key-from-keychain, no stored loginInjected key is used.
--key-from-keychain and a stored loginStart refuses with exit 21: stored login in agent state takes precedence over injected key; run /logout first or start without –key-from-keychain.

To switch from the stored login to Keychain injection: start normally, run /logout, then use --key-from-keychain from then on. To switch back: start without the flag and /login.

Revoke and replace#

CredentialReplaceRevoke
Stored login (auth.json in the volume)/logout, then /login/logout in pi; or delete the file: pi-sandbox shell <project> → rm ~/.pi/agent/auth.json; then revoke the session or key at the provider
Keychain API keysecurity add-generic-password -s pi-sandbox -a anthropic-api-key -U -wRevoke the key in the provider’s console, then security delete-generic-password -s pi-sandbox -a anthropic-api-key
Memory sync credentials (brain-<store>-token)see memory.mdsee memory.md
Everything in the agent state—docker volume rm pi-agent-state — deletes the login, all session history, trust decisions and installed packages (state.md)

Remaining risk#

The kubeconfig, talosconfig, STACKIT, Hetzner Cloud and GitHub sign-ins are stored in the domain volume and readable by the agent in every session of that domain, like auth.json; give those credentials only the rights the agent’s tasks need.

An injected key is readable by the agent for the duration of the session (it is in the process environment). A stored login is readable by the agent in every session (it is in the agent state). Both are accepted in v1 and listed in security.md.

SSH host keys for every session#

pi-sandbox known-hosts add <host> makes git’s ssh in every session trust that host, with or without push (submodules, other forges, self-hosted servers). It scans the host on the Mac, shows the fingerprints and stores them in known_hosts.d/<host> only after you confirmed them; with --fingerprint SHA256:… (from the server admin or the forge) it stores only matching keys and asks nothing. A scan alone proves nothing: a man in the middle answers it too, so compare the fingerprints with a source you trust. list and remove manage the entries.

Every start passes all entries as PI_KNOWN_HOSTS_B64 (public data, no secret). The entrypoint merges them with the pinned keys of codeberg.org, github.com and gitlab.com into the session’s known_hosts and sets GIT_SSH_COMMAND for strict checking (no ssh config, no agent unless push sets one). Plain ssh typed by the agent uses only the image’s pinned keys. Changes apply to the next session; entries are never trusted on first use.