Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Run AI coding agents in secure containers. They make commits, you pull them back
|-------|------|--------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `--agent claude` (default) | Supported |
| [Cursor CLI](https://docs.cursor.com/cli) | `--agent cursor` | Supported |
| [Gas City](https://github.com/gastownhall/gascity) | `--agent gascity` | Supported |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `--agent gemini` | Supported |
| [OpenClaw](https://github.com/openclaw/openclaw) | `--agent openclaw` | Supported |

Expand Down
22 changes: 20 additions & 2 deletions containers/paude/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ RUN dnf install -y dnf-plugins-core && \
python3.12 \
python3.12-pip \
procps-ng \
tmux \
which \
coreutils \
findutils \
Expand All @@ -36,10 +35,28 @@ RUN dnf install -y dnf-plugins-core && \
unzip \
zip \
tree \
nss_wrapper \
&& dnf clean all && \
alternatives --install /usr/bin/python python /usr/bin/python3.12 1 && \
alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1

# Build tmux 3.5a from source (CentOS Stream 10 / RHEL 10 distro tmux has
# a capture-pane -p segfault that crashes the tmux server)
ARG TMUX_VERSION=3.5a
RUN dnf install -y gcc libevent-devel ncurses-devel bison && \
curl -fsSL "https://github.com/tmux/tmux/releases/download/${TMUX_VERSION}/tmux-${TMUX_VERSION}.tar.gz" \
-o /tmp/tmux.tar.gz && \
tar -xzf /tmp/tmux.tar.gz -C /tmp && \
cd /tmp/tmux-${TMUX_VERSION} && \
./configure --prefix=/usr/local && \
make -j"$(nproc)" && \
make install && \
rm -rf /tmp/tmux-${TMUX_VERSION} /tmp/tmux.tar.gz && \
dnf remove -y gcc libevent-devel ncurses-devel bison && \
dnf install -y libevent ncurses-libs && \
dnf clean all && \
tmux -V

# Set UTF-8 locale
ENV LANG=en_US.UTF-8
ENV LC_ALL=en_US.UTF-8
Expand Down Expand Up @@ -75,7 +92,8 @@ RUN curl -fsSL https://cli.github.com/packages/rpm/gh-cli.repo -o /etc/yum.repos
RUN useradd -M -d /home/paude -s /bin/bash -g 0 paude && \
umask 0002 && \
mkdir -p /home/paude/.claude /home/paude/.config && \
chown -R paude:0 /home/paude
chown -R paude:0 /home/paude && \
chmod g+w /etc/passwd

# NOTE: Claude Code is NOT installed here due to licensing restrictions.
# It gets installed at user-side build time via a runtime layer.
Expand Down
64 changes: 38 additions & 26 deletions containers/paude/entrypoint-lib-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,70 @@
# Agent config PVC persistence utilities for the paude entrypoint.
# Sourced by entrypoint-session.sh — not run standalone.

# Persist a dotfile directory from $HOME to /pvc.
# Creates symlink: $HOME/<dir> -> /pvc/<dir>
# On first start, copies image-baked contents to PVC; on reconnect, no-op.
persist_config_dir() {
local dir_name="$1"
if [[ ! -d /pvc ]]; then return 0; fi

local pvc_dir="/pvc/$dir_name"
local home_dir="$HOME/$dir_name"

if [[ ! -d "$home_dir" ]] && [[ ! -L "$home_dir" ]] && [[ ! -d "$pvc_dir" ]]; then
return 0
fi

mkdir -p "$pvc_dir" 2>/dev/null || true
chmod g+rwX "$pvc_dir" 2>/dev/null || true
chcon -R --reference=/pvc "$pvc_dir" 2>/dev/null || true

if [[ ! -L "$home_dir" ]]; then
if [[ -d "$home_dir" ]]; then
cp -dR --preserve=mode,timestamps "$home_dir/." "$pvc_dir/" 2>/dev/null || true
rm -rf "$home_dir" 2>/dev/null || true
fi
if [[ ! -e "$home_dir" ]]; then
ln -sf "$pvc_dir" "$home_dir"
else
# Overlay FS may block removal of image-layer dirs on OpenShift.
echo "persist_config_dir: cannot replace $home_dir with symlink; using PVC copy at $pvc_dir" >&2
fi
fi
}

# Persist agent config on the PVC volume so it survives container recreation.
# Creates symlinks: $HOME/$AGENT_CONFIG_DIR -> /pvc/$AGENT_CONFIG_DIR
# $HOME/$AGENT_CONFIG_FILE -> /pvc/$AGENT_CONFIG_FILE
# Follows the same pattern as agent binary persistence at /pvc/.local/bin.
persist_agent_config() {
# Skip if /pvc doesn't exist (non-persistent setup)
if [[ ! -d /pvc ]]; then
return 0
fi

local pvc_config_dir="/pvc/$AGENT_CONFIG_DIR"
local home_config_dir="$HOME/$AGENT_CONFIG_DIR"

# Create PVC directory if it doesn't exist (first start)
mkdir -p "$pvc_config_dir" 2>/dev/null || true
chmod g+rwX "$pvc_config_dir" 2>/dev/null || true
# Fix SELinux context on PVC config dir — earlier versions of cp -a
# preserved the image filesystem context, making the dir inaccessible.
chcon -R --reference=/pvc "$pvc_config_dir" 2>/dev/null || true

# If HOME config dir is a real directory (not symlink), merge into PVC and replace with symlink
if [[ ! -L "$home_config_dir" ]]; then
if [[ -d "$home_config_dir" ]]; then
cp -dR --preserve=mode,timestamps "$home_config_dir/." "$pvc_config_dir/" 2>/dev/null || true
fi
rm -rf "$home_config_dir" 2>/dev/null || true
ln -sf "$pvc_config_dir" "$home_config_dir"
fi
# Agent config dir is always needed, so ensure PVC side exists
# before calling persist_config_dir (which skips absent dirs).
mkdir -p "/pvc/$AGENT_CONFIG_DIR" 2>/dev/null || true
persist_config_dir "$AGENT_CONFIG_DIR"

# Config file (e.g., .claude.json) — symlink to PVC
if [[ -n "$AGENT_CONFIG_FILE" ]]; then
local pvc_config_file="/pvc/$AGENT_CONFIG_FILE"
local home_config_file="$HOME/$AGENT_CONFIG_FILE"

# If HOME config file is a real file (not symlink), move to PVC
if [[ -f "$home_config_file" ]] && [[ ! -L "$home_config_file" ]]; then
if [[ ! -f "$pvc_config_file" ]]; then
cp -dR --preserve=mode,timestamps "$home_config_file" "$pvc_config_file" 2>/dev/null || true
fi
rm -f "$home_config_file"
rm -f "$home_config_file" 2>/dev/null || true
fi

# Create PVC file if it doesn't exist
if [[ ! -f "$pvc_config_file" ]]; then
echo '{}' > "$pvc_config_file" 2>/dev/null || true
fi
chmod g+rw "$pvc_config_file" 2>/dev/null || true
chcon --reference=/pvc "$pvc_config_file" 2>/dev/null || true

# Create symlink
if [[ ! -L "$home_config_file" ]]; then
rm -f "$home_config_file" 2>/dev/null || true
if [[ ! -e "$home_config_file" ]]; then
ln -sf "$pvc_config_file" "$home_config_file"
fi
fi
Expand Down
15 changes: 11 additions & 4 deletions containers/paude/entrypoint-lib-credentials.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,17 @@ setup_credentials() {
ln -sf "$config_path/gcloud" "$HOME/.config/gcloud"
fi

# Set up gitconfig via symlink
if [[ -f "$config_path/gitconfig" ]]; then
rm -f "$HOME/.gitconfig" 2>/dev/null || true
ln -sf "$config_path/gitconfig" "$HOME/.gitconfig"
# Unlike gcloud/gitignore (symlinked read-only), gitconfig must be
# writable — tools like GasCity add entries via `git config --global`.
# Seed once to PVC so additions survive pod restarts. To force a
# re-seed from host config, delete /pvc/.gitconfig before restarting.
if [[ -f "$config_path/gitconfig" ]] && [[ ! -f /pvc/.gitconfig ]]; then
cp -f "$config_path/gitconfig" /pvc/.gitconfig 2>/dev/null || true
chmod 664 /pvc/.gitconfig 2>/dev/null || true
chcon --reference=/pvc /pvc/.gitconfig 2>/dev/null || true
fi
if [[ -f /pvc/.gitconfig ]]; then
export GIT_CONFIG_GLOBAL=/pvc/.gitconfig
fi

# Set up global gitignore via symlink
Expand Down
53 changes: 48 additions & 5 deletions containers/paude/entrypoint-session.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,45 @@ if [[ -f /usr/local/bin/entrypoint-lib-openclaw.sh ]]; then
source /usr/local/bin/entrypoint-lib-openclaw.sh
fi

# OpenShift CRI-O injects home=/ for arbitrary UIDs. Fix /etc/passwd directly
# (made group-writable in Dockerfile) so all programs — including statically
# linked Go binaries that bypass NSS — see the correct home directory.
# nss_wrapper is kept as a fallback for environments where /etc/passwd is
# read-only (e.g. older base images).
_PAUDE_HOME="/home/paude"
_CURRENT_UID=$(id -u)
_PASSWD_HOME=$(getent passwd "$_CURRENT_UID" 2>/dev/null | cut -d: -f6)
if [[ "$_PASSWD_HOME" != "$_PAUDE_HOME" ]]; then
if [[ -n "$_PASSWD_HOME" ]]; then
# UID exists with wrong home (CRI-O injected home=/) — fix it
_SED_EXPR="s|^\([^:]*:[^:]*:${_CURRENT_UID}:[^:]*:[^:]*:\)[^:]*:\(.*\)$|\1${_PAUDE_HOME}:\2|"
if sed -i "$_SED_EXPR" /etc/passwd 2>/dev/null; then
: # /etc/passwd updated in place
else
# Read-only /etc/passwd — fall back to nss_wrapper overlay
sed "$_SED_EXPR" /etc/passwd > /tmp/nss_wrapper_passwd
export LD_PRELOAD="${LD_PRELOAD:+${LD_PRELOAD} }/usr/lib64/libnss_wrapper.so"
export NSS_WRAPPER_PASSWD="/tmp/nss_wrapper_passwd"
export NSS_WRAPPER_GROUP=/etc/group
fi
unset _SED_EXPR
else
# UID not in /etc/passwd at all — append directly or via nss_wrapper
_ENTRY="paude:x:${_CURRENT_UID}:0:paude:${_PAUDE_HOME}:/bin/bash"
if echo "$_ENTRY" >> /etc/passwd 2>/dev/null; then
: # appended to /etc/passwd
else
cp /etc/passwd /tmp/nss_wrapper_passwd
echo "$_ENTRY" >> /tmp/nss_wrapper_passwd
export LD_PRELOAD="${LD_PRELOAD:+${LD_PRELOAD} }/usr/lib64/libnss_wrapper.so"
export NSS_WRAPPER_PASSWD="/tmp/nss_wrapper_passwd"
export NSS_WRAPPER_GROUP=/etc/group
fi
unset _ENTRY
fi
fi
unset _PAUDE_HOME _CURRENT_UID _PASSWD_HOME

# Ensure HOME is set correctly for OpenShift arbitrary UID
# OpenShift runs containers with random UIDs that don't exist in /etc/passwd
# HOME may be unset, empty, or set to "/" which is not writable
Expand All @@ -48,10 +87,6 @@ if [[ -d /pvc ]]; then
chmod g+rwX /pvc 2>/dev/null || true
fi

# Fix git "dubious ownership" error when running as arbitrary UID (OpenShift restricted SCC)
# git config --global creates .gitconfig if it doesn't exist
git config --global --add safe.directory '*' 2>/dev/null || true

# Update CA trust early (before any HTTPS calls like agent install)
# The CA cert is injected by the host after the container starts.
setup_ca_trust
Expand All @@ -63,8 +98,16 @@ setup_ca_trust
wait_for_credentials
setup_credentials
persist_agent_config
persist_config_dir .dolt
wait_for_git

# Fix git "dubious ownership" error when running as arbitrary UID (OpenShift restricted SCC)
# Runs after setup_credentials so it writes to the final (writable) gitconfig.
# Guard against duplicates: gitconfig is PVC-persistent so --add would accumulate entries.
if ! git config --global --get safe.directory '^\*$' &>/dev/null; then
git config --global --add safe.directory '*' 2>/dev/null || true
fi

# Add PVC local bin to PATH (for agent and other tools installed to PVC)
# Also keep home .local/bin for tools installed during image build
export PATH="/pvc/.local/bin:$HOME/.local/bin:$PATH"
Expand Down Expand Up @@ -169,7 +212,7 @@ if tmux -u has-session -t "$AGENT_SESSION_NAME" 2>/dev/null; then
else
echo "Starting new $AGENT_NAME session..."
tmux -u new-session -s "$AGENT_SESSION_NAME" -c "$WORKSPACE" -d "bash -l"
tmux send-keys -t "$AGENT_SESSION_NAME" "export HOME=$HOME PATH='$PATH'" Enter
tmux send-keys -t "$AGENT_SESSION_NAME" "export HOME=$HOME PATH='$PATH'${GIT_CONFIG_GLOBAL:+ GIT_CONFIG_GLOBAL='$GIT_CONFIG_GLOBAL'}" Enter
tmux send-keys -t "$AGENT_SESSION_NAME" "cd $WORKSPACE" Enter
tmux send-keys -t "$AGENT_SESSION_NAME" "clear && $AGENT_LAUNCH_CMD $AGENT_ARGS" Enter
exit_if_headless "started"
Expand Down
2 changes: 1 addition & 1 deletion containers/proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Build stage — compile paude-proxy from source
FROM golang:1.23 AS builder
WORKDIR /app
ARG PAUDE_PROXY_VERSION=e990bd7c854ee6b34d7db9ecb5c3646cd361f9bd
ARG PAUDE_PROXY_VERSION=cd70cedb8ccfeee9728c9b070d667c32ad8c6608
RUN git init . && \
git fetch --depth 1 https://github.com/bbrowning/paude-proxy.git ${PAUDE_PROXY_VERSION} && \
git checkout FETCH_HEAD && \
Expand Down
3 changes: 3 additions & 0 deletions src/paude/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from paude.agents.base import Agent, AgentConfig
from paude.agents.claude import ClaudeAgent
from paude.agents.cursor import CursorAgent
from paude.agents.gascity import GascityAgent
from paude.agents.gemini import GeminiAgent
from paude.agents.openclaw import OpenClawAgent

Expand All @@ -13,6 +14,7 @@
"AgentConfig",
"ClaudeAgent",
"CursorAgent",
"GascityAgent",
"GeminiAgent",
"OpenClawAgent",
"get_agent",
Expand All @@ -22,6 +24,7 @@
_REGISTRY: dict[str, type] = {
"claude": ClaudeAgent,
"cursor": CursorAgent,
"gascity": GascityAgent,
"gemini": GeminiAgent,
"openclaw": OpenClawAgent,
}
Expand Down
36 changes: 36 additions & 0 deletions src/paude/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,42 @@ def pipefail_install_lines(config: AgentConfig, container_home: str) -> list[str
]


def claude_trust_script(home: str, workspace: str) -> str:
"""Generate shell snippet to suppress Claude Code trust/onboarding prompts."""
return f"""\
claude_json="{home}/.claude.json"
if [ -f "$claude_json" ]; then
jq --arg ws "{workspace}" '
.hasCompletedOnboarding = true |
.projects = {{($ws): {{hasTrustDialogAccepted: true}}}}
' "$claude_json" > "${{claude_json}}.tmp" \\
&& cp -f "${{claude_json}}.tmp" "$claude_json" \\
&& rm -f "${{claude_json}}.tmp"
else
jq -n --arg ws "{workspace}" '{{
hasCompletedOnboarding: true,
projects: {{($ws): {{hasTrustDialogAccepted: true}}}}
}}' > "$claude_json"
fi
chmod g+rw "$claude_json" 2>/dev/null || true
"""


def gemini_trust_script(home: str, workspace: str) -> str:
"""Generate shell snippet to pre-trust workspace for Gemini CLI."""
return f"""\
trusted_json="{home}/.gemini/trustedFolders.json"
mkdir -p "{home}/.gemini" 2>/dev/null || true
if [ -f "$trusted_json" ]; then
jq --arg ws "{workspace}" '. + {{($ws): "TRUST_FOLDER"}}' \\
"$trusted_json" > "${{trusted_json}}.tmp" \\
&& mv "${{trusted_json}}.tmp" "$trusted_json"
else
jq -n --arg ws "{workspace}" '{{($ws): "TRUST_FOLDER"}}' > "$trusted_json"
fi
"""


class Agent(Protocol):
"""Protocol for CLI coding agent implementations."""

Expand Down
29 changes: 7 additions & 22 deletions src/paude/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
AgentConfig,
build_environment_from_config,
build_provider_credentials,
claude_trust_script,
pipefail_install_lines,
)

Expand Down Expand Up @@ -67,28 +68,12 @@ def dockerfile_install_lines(self, container_home: str) -> list[str]:
def apply_sandbox_config(
self, home: str, workspace: str, args: str, *, yolo: bool = False
) -> str:
script = f"""\
#!/bin/bash
# Auto-generated sandbox config for Claude Code
claude_json="{home}/.claude.json"
settings_json="{home}/.claude/settings.json"

# Suppress trust prompt and onboarding
if [ -f "$claude_json" ]; then
jq --arg ws "{workspace}" '
.hasCompletedOnboarding = true |
.projects = {{($ws): {{hasTrustDialogAccepted: true}}}}
' "$claude_json" > "${{claude_json}}.tmp" \\
&& cp -f "${{claude_json}}.tmp" "$claude_json" \\
&& rm -f "${{claude_json}}.tmp"
else
jq -n --arg ws "{workspace}" '{{
hasCompletedOnboarding: true,
projects: {{($ws): {{hasTrustDialogAccepted: true}}}}
}}' > "$claude_json"
fi
chmod g+rw "$claude_json" 2>/dev/null || true
"""
script = (
"#!/bin/bash\n"
"# Auto-generated sandbox config for Claude Code\n"
f'settings_json="{home}/.claude/settings.json"\n\n'
+ claude_trust_script(home, workspace)
)
if yolo:
script += f"""
# Suppress bypass permissions warning when yolo mode is enabled
Expand Down
Loading