Configuration can be provided via:
- Command-line arguments (highest precedence)
- Environment variables
- Configuration file
- Built-in defaults (lowest precedence)
Configuration file
Configuration files are discovered in the following order (first match wins):
- Path specified via
--configCLI argument - Path specified via
PODBOT_CONFIG_PATHenvironment variable - The XDG Base Directory Specification configuration directory
(
$XDG_CONFIG_HOME/podbot/config.toml, typically~/.config/podbot/config.toml) ~/.podbot.toml(dotfile in home directory)
If --config is provided, PODBOT_CONFIG_PATH is ignored. If the --config
path does not exist, podbot falls back only to the remaining file-based
discovery locations ($XDG_CONFIG_HOME/podbot/config.toml and
~/.podbot.toml) and does not consult PODBOT_CONFIG_PATH.
Note: GitHub App credentials (app_id, installation_id,
private_key_path) are validated only when GitHub operations are performed.
Commands like podbot ps or podbot stop do not require GitHub configuration.
# Container engine socket (Podman or Docker)
# Unix socket (default for local daemons):
engine_socket = "unix:///run/user/1000/podman/podman.sock"
# TCP endpoint (for remote daemons):
# engine_socket = "tcp://docker.example.com:2375"
# Container image for the sandbox
image = "ghcr.io/example/podbot-sandbox:latest"
[github]
# GitHub App credentials (optional, for private repositories)
app_id = 12345
installation_id = 67890
private_key_path = "/home/user/.config/podbot/github-app.pem"
[sandbox]
# Run the container in privileged mode (less secure, more compatible)
privileged = false
# Mount /dev/fuse for fuse-overlayfs support (required for inner Podman)
mount_dev_fuse = true
# SELinux label handling: "disable_for_container" or "keep_default"
selinux_label_mode = "disable_for_container"
[agent]
# Default agent type: "claude", "codex", or "custom"
kind = "claude"
# Execution mode for the agent: "podbot", "codex_app_server", or "acp"
mode = "podbot"
# Custom launcher command (required when kind = "custom")
command = "opencode"
# Additional launcher arguments (defaults to [])
args = ["acp"]
# Environment variables copied from the host (defaults to [])
env_allowlist = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
[workspace]
# Workspace source: "github_clone" or "host_mount"
source = "github_clone"
# Exact clone destination for github_clone workspaces
base_dir = "/work"
# Host path mounted into the sandbox when source = "host_mount"
host_path = "/abs/path/to/project"
# Container path for the mount; defaults to "/workspace" in host_mount mode
container_path = "/workspace/project"
[creds]
# Copy credentials from the host into the container
copy_claude = true
copy_codex = true
[mcp]
# HTTP bridge reachability strategy for hosted MCP servers
bind_strategy = "host_gateway"
# Idle timeout in seconds for hosted MCP bridges
idle_timeout_secs = 900
# Maximum message size in bytes
max_message_size_bytes = 1048576
# Token issuance policy: "per_workspace" or "per_wire"
auth_token_policy = "per_workspace"
# Allowed-origin policy: "same_origin" or "any"
allowed_origin_policy = "same_origin"
Semantic validation rules:
podbot runaccepts onlyagent.mode = "podbot".podbot hostremains temporarily disabled in this release, even though the configuration model still reservesagent.mode = "codex_app_server"and"acp"for the hosted protocol path.agent.kind = "custom"requires a non-emptyagent.command.- Built-in agent kinds reject
agent.commandandagent.args. workspace.source = "host_mount"requiresworkspace.host_pathand defaultsworkspace.container_pathto"/workspace"when omitted.workspace.source = "github_clone"rejects host-mount-only fields.
For podbot run, --repo must use GitHub owner/name form, such as
leynos/podbot. The --branch flag is required and has no default; Podbot
does not guess the default branch. When workspace.source = "github_clone",
Podbot clones that repository directly into workspace.base_dir inside the
container and treats that path as the workspace root.
Private key file requirements
The private_key_path field must point to a PEM-encoded RSA private key.
GitHub App authentication uses the RS256 algorithm exclusively, so only RSA
keys are supported.
Accepted formats:
- PKCS#1: header
-----BEGIN RSA PRIVATE KEY----- - PKCS#8: header
-----BEGIN PRIVATE KEY-----(must contain an RSA key)
To generate a suitable key:
openssl genrsa -out github-app.pem 2048
Common error messages when loading the key:
| Message | Cause |
|---|---|
| "file is empty" | The key file exists but contains no data. |
| "failed to read file" | The file does not exist or cannot be read. |
| "the file appears to contain an ECDSA key" | An EC key was provided instead of RSA. |
| "the file appears to contain an OpenSSH-format key" | An OpenSSH key was provided; convert with ssh-keygen -p -m pem -f <keyfile>. |
| "invalid RSA private key" | The file contents are not valid PEM-encoded RSA data. |
Credential validation errors
After loading the private key successfully, podbot validates the credentials
against the GitHub API by making an authenticated request to GET /app. If
this validation fails, podbot classifies the failure mode and provides
actionable error messages with remediation hints:
| Message fragment | Cause and remediation |
|---|---|
| "credentials rejected (HTTP 401)" | The private key does not match the App, or the App has been suspended. Verify the App ID and regenerate the private key from the GitHub App settings page. Check for clock skew. |
| "insufficient permissions (HTTP 403)" | The App lacks required permissions. Check the App's permission settings in GitHub. |
| "App not found (HTTP 404)" | The App ID is incorrect or the App has been deleted. Verify that github.app_id is correct. |
| "GitHub API unavailable (HTTP 5xx)" | GitHub is experiencing an outage or maintenance. Check https://www.githubstatus.com for service status. Retry after the service recovers. |
| "failed to validate GitHub App credentials" | A network error occurred or the API returned an unexpected status. Check network connectivity and DNS resolution. Review the detailed error message for the specific cause. |
Installation token acquisition
For workspace.source = "github_clone", podbot acquires a short-lived GitHub
App installation access token on the host before later clone and fetch work.
The token is scoped by the GitHub App installation and expires after about one
hour. Podbot keeps the GitHub App private key on the host; later token-file
work will expose only the short-lived installation token to the sandbox.
Token acquisition uses a refresh buffer so podbot can schedule renewal before expiry. The token string is available to Git credential delivery, but logs and debug output include only non-secret timing metadata such as acquisition time, derived expiry time, refresh time, and buffer duration.
Common token acquisition errors:
"failed to acquire installation token": GitHub rejected token acquisition, the installation ID is wrong, the App installation is unavailable, or the network failed."invalid token expiry buffer": the configured buffer could not be converted for the GitHub client. Use a smaller positive buffer.
Environment variables
All configuration options can be set via environment variables using the
PODBOT_ prefix:
| Variable | Configuration key |
|---|---|
PODBOT_ENGINE_SOCKET |
engine_socket |
PODBOT_IMAGE |
image |
PODBOT_GITHUB_APP_ID |
github.app_id |
PODBOT_GITHUB_INSTALLATION_ID |
github.installation_id |
PODBOT_GITHUB_PRIVATE_KEY_PATH |
github.private_key_path |
PODBOT_SANDBOX_PRIVILEGED |
sandbox.privileged |
PODBOT_SANDBOX_MOUNT_DEV_FUSE |
sandbox.mount_dev_fuse |
PODBOT_SANDBOX_SELINUX_LABEL_MODE |
sandbox.selinux_label_mode |
PODBOT_AGENT_KIND |
agent.kind |
PODBOT_AGENT_MODE |
agent.mode |
PODBOT_AGENT_COMMAND |
agent.command |
PODBOT_AGENT_ARGS |
agent.args |
PODBOT_AGENT_ENV_ALLOWLIST |
agent.env_allowlist |
PODBOT_WORKSPACE_SOURCE |
workspace.source |
PODBOT_WORKSPACE_BASE_DIR |
workspace.base_dir |
PODBOT_WORKSPACE_HOST_PATH |
workspace.host_path |
PODBOT_WORKSPACE_CONTAINER_PATH |
workspace.container_path |
PODBOT_CREDS_COPY_CLAUDE |
creds.copy_claude |
PODBOT_CREDS_COPY_CODEX |
creds.copy_codex |
PODBOT_MCP_BIND_STRATEGY |
mcp.bind_strategy |
PODBOT_MCP_IDLE_TIMEOUT_SECS |
mcp.idle_timeout_secs |
PODBOT_MCP_MAX_MESSAGE_SIZE_BYTES |
mcp.max_message_size_bytes |
PODBOT_MCP_AUTH_TOKEN_POLICY |
mcp.auth_token_policy |
PODBOT_MCP_ALLOWED_ORIGIN_POLICY |
mcp.allowed_origin_policy |
PODBOT_AGENT_ARGS and PODBOT_AGENT_ENV_ALLOWLIST use comma-separated values.
Container engine socket
The socket endpoint for connecting to Docker or Podman is resolved in the following order (first match wins):
--engine-socketCLI argumentengine_socketin configuration filePODBOT_ENGINE_SOCKETenvironment variableDOCKER_HOSTenvironment variableCONTAINER_HOSTenvironment variablePODMAN_HOSTenvironment variable- Platform default (
unix:///var/run/docker.sockon Unix,npipe:////./pipe/docker_engineon Windows)
This allows podbot to integrate with existing Docker and Podman environments
without additional configuration. When DOCKER_HOST or PODMAN_HOST is
already set for container tooling, podbot will automatically use that endpoint.
TCP endpoint support
In addition to Unix sockets and Windows named pipes, podbot supports TCP connections to remote container engines. This is useful when the Docker or Podman daemon is running on a different host or is configured to listen on a TCP port.
Supported TCP endpoint formats:
| Format | Example | Notes |
|---|---|---|
tcp://host:port |
tcp://192.168.1.100:2375 |
Rewritten internally to http:// |
http://host:port |
http://docker.example.com:2375 |
Used directly |
https://host:port |
https://docker.example.com:2376 |
TLS-encrypted connection |
Configuration examples:
Via CLI argument:
podbot run --engine-socket tcp://remotehost:2375 --repo owner/name --branch main
Via environment variable:
export DOCKER_HOST=tcp://192.168.1.100:2375
podbot run --repo owner/name --branch main
Via configuration file:
engine_socket = "tcp://docker.example.com:2375"
TCP-specific troubleshooting:
| Error | Cause | Resolution |
|---|---|---|
failed to connect to container engine: <message> |
TCP endpoint unreachable or daemon not listening | Verify the remote host is reachable and the daemon is configured to listen on the specified port |
container engine health check failed: <message> |
Connection established but daemon did not respond | Verify the daemon is healthy: curl http://remotehost:2375/v1.40/_ping |
container engine health check timed out after 10 seconds |
Network latency or daemon overloaded | Check network connectivity and daemon load |
Security note: TCP connections without TLS (tcp:// and http://)
transmit data unencrypted. Use https:// with TLS certificates for production
environments. Consult the Docker or Podman documentation for configuring TLS.
Engine health check
When connecting to a container engine, podbot performs a health check to verify the engine is responsive. This confirms the engine is operational, not just that the socket is reachable.
Health check behaviour:
- A ping request is sent to the engine after establishing the connection
- The check times out after 10 seconds if the engine does not respond
- If the health check fails, podbot reports a clear error message
Possible error messages:
| Error | Cause |
|---|---|
container engine health check failed: <message> |
The engine did not respond correctly to the ping request |
container engine health check timed out after 10 seconds |
The engine took too long to respond |
Connection error troubleshooting
When podbot cannot connect to the container engine, it provides actionable error messages to help diagnose the issue.
Possible connection errors:
| Error | Cause | Resolution |
|---|---|---|
permission denied accessing container socket: <path> |
User lacks permission to access the Docker/Podman socket | Add user to the docker group: sudo usermod -aG docker $USER && newgrp docker. For Podman, use the rootless socket at /run/user/$UID/podman/podman.sock (where user ID (UID) identifies the account) |
container engine socket not found: <path> |
Socket file does not exist; daemon not running | Start the daemon: Docker: sudo systemctl start docker. Podman: systemctl --user start podman.socket |
failed to connect to container engine: connection refused |
Daemon not accepting connections | Restart the daemon service and check its status |
Common permission scenarios:
- Docker on Linux: By default, the Docker socket (
/var/run/docker.sock) is owned by thedockergroup. Add the current user to this group:
sudo usermod -aG docker $USER
newgrp docker # Apply group membership without logging out
- Rootless Podman: Use the user-level socket instead of the system socket:
# Start the user socket
systemctl --user start podman.socket
# Configure podbot to use it
export PODBOT_ENGINE_SOCKET="unix:///run/user/$(id -u)/podman/podman.sock"
- Podman with sudo: If using the system Podman socket, ensure the socket service is running:
sudo systemctl start podman.socket
Sandbox configuration
The [sandbox] section controls the security and compatibility trade-offs for
the container environment.
| Setting | Default | Description |
|---|---|---|
privileged |
false |
Run container in privileged mode |
mount_dev_fuse |
true |
Mount /dev/fuse for fuse-overlayfs support |
selinux_label_mode |
"disable_for_container" |
SELinux label handling mode |
Minimal mode (default): privileged = false, mount_dev_fuse = true
This is the recommended configuration for most users. It provides:
- Better security isolation by avoiding the privileged flag
- Support for inner Podman via fuse-overlayfs
- Compatibility with most Podman-in-Podman workflows
Privileged mode: privileged = true
Enable privileged mode only when minimal mode does not work for the target environment. Privileged mode:
- Provides maximum compatibility with nested container operations
- Expands the container's attack surface significantly
- Should be avoided unless specifically required
- Ignores
mount_dev_fusebecause the engine grants full device access in privileged mode
Disabling /dev/fuse: mount_dev_fuse = false
The /dev/fuse mount is required for fuse-overlayfs, which enables inner
Podman to function correctly. Disable this only when the agent container does
not need nested container support.
SELinux label mode: selinux_label_mode
Controls how SELinux labels are applied to the container process:
"disable_for_container"(default): AppliesSecurityOpt = ["label=disable"]so rootless nested Podman workflows do not fail under strict SELinux labelling. This is the recommended setting for most environments."keep_default": Leaves SELinux labelling at engine defaults. Use this when the host SELinux policy is already configured to permit nested container operations, or when SELinux enforcement is disabled system-wide.
In privileged mode, this setting is ignored because the engine governs security labelling directly.
Container creation behaviour
When podbot creates a sandbox container, it applies the following host security settings:
privileged = true: setsHostConfig.Privileged = trueand uses engine defaults for capabilities, devices, and SELinux options. Themount_dev_fuseandselinux_label_modesettings are ignored.privileged = falsewith the defaultselinux_label_mode: setsHostConfig.Privileged = falseand appliesSecurityOpt = ["label=disable"].privileged = falseandselinux_label_mode = "keep_default": setsHostConfig.Privileged = falsewithout addingSecurityOpt, leaving SELinux labelling at engine defaults.mount_dev_fuse = true(in non-privileged mode): additionally maps/dev/fuseand addsSYS_ADMINcapability sofuse-overlayfscan run.mount_dev_fuse = false(in non-privileged mode): skips/dev/fusemapping and capability additions.
Container creation requires image to be configured. If it is missing or
whitespace-only, podbot returns:
missing required configuration: image
Podbot resolves this image from layered configuration precedence (--image,
then PODBOT_IMAGE, then file/default values). Validation occurs before the
engine create call, so no container-create request is sent when the resolved
image is empty.
Credential injection behaviour
At sandbox startup, podbot can copy host agent credentials into the container
filesystem using a tar upload to /root.
creds.copy_claude = trueselects~/.claude.creds.copy_codex = trueselects~/.codex.- Selected directories that are missing are skipped.
- If nothing is selected or present, credential injection succeeds as a no-op and no upload request is sent.
- Host-side selection or archive-build failures are reported as
FilesystemError::IoError. - Daemon upload failures are reported as
ContainerError::UploadFailed.
When credentials are uploaded, expected container paths are:
/root/.claudefor Claude credentials./root/.codexfor Codex credentials.
Permission bits from source files and directories are preserved in the uploaded tar entries.
Verification notes:
- Start a sandbox with the desired
copy_claudeandcopy_codexsettings. - Check which directories exist in the container:
podbot exec <container> -- ls -la /root
- Compare permission bits for a representative file between host and
container, for example, with
staton each side.