diff --git a/README.md b/README.md
index 9fed781..52722dc 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/containers/paude/Dockerfile b/containers/paude/Dockerfile
index 0ca7de6..e2041bb 100644
--- a/containers/paude/Dockerfile
+++ b/containers/paude/Dockerfile
@@ -19,7 +19,6 @@ RUN dnf install -y dnf-plugins-core && \
python3.12 \
python3.12-pip \
procps-ng \
- tmux \
which \
coreutils \
findutils \
@@ -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
@@ -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.
diff --git a/containers/paude/entrypoint-lib-config.sh b/containers/paude/entrypoint-lib-config.sh
index af09c4a..2141e46 100644
--- a/containers/paude/entrypoint-lib-config.sh
+++ b/containers/paude/entrypoint-lib-config.sh
@@ -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/
-> /pvc/
+# 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
diff --git a/containers/paude/entrypoint-lib-credentials.sh b/containers/paude/entrypoint-lib-credentials.sh
index 9335763..b99d760 100644
--- a/containers/paude/entrypoint-lib-credentials.sh
+++ b/containers/paude/entrypoint-lib-credentials.sh
@@ -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
diff --git a/containers/paude/entrypoint-session.sh b/containers/paude/entrypoint-session.sh
index f3a521e..5b97169 100644
--- a/containers/paude/entrypoint-session.sh
+++ b/containers/paude/entrypoint-session.sh
@@ -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
@@ -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
@@ -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"
@@ -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"
diff --git a/containers/proxy/Dockerfile b/containers/proxy/Dockerfile
index 55696e1..0697600 100644
--- a/containers/proxy/Dockerfile
+++ b/containers/proxy/Dockerfile
@@ -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 && \
diff --git a/src/paude/agents/__init__.py b/src/paude/agents/__init__.py
index 622ae59..aaab478 100644
--- a/src/paude/agents/__init__.py
+++ b/src/paude/agents/__init__.py
@@ -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
@@ -13,6 +14,7 @@
"AgentConfig",
"ClaudeAgent",
"CursorAgent",
+ "GascityAgent",
"GeminiAgent",
"OpenClawAgent",
"get_agent",
@@ -22,6 +24,7 @@
_REGISTRY: dict[str, type] = {
"claude": ClaudeAgent,
"cursor": CursorAgent,
+ "gascity": GascityAgent,
"gemini": GeminiAgent,
"openclaw": OpenClawAgent,
}
diff --git a/src/paude/agents/base.py b/src/paude/agents/base.py
index cff079b..5543fc9 100644
--- a/src/paude/agents/base.py
+++ b/src/paude/agents/base.py
@@ -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."""
diff --git a/src/paude/agents/claude.py b/src/paude/agents/claude.py
index 2f3f1cb..fcf6aad 100644
--- a/src/paude/agents/claude.py
+++ b/src/paude/agents/claude.py
@@ -8,6 +8,7 @@
AgentConfig,
build_environment_from_config,
build_provider_credentials,
+ claude_trust_script,
pipefail_install_lines,
)
@@ -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
diff --git a/src/paude/agents/gascity.py b/src/paude/agents/gascity.py
new file mode 100644
index 0000000..718035b
--- /dev/null
+++ b/src/paude/agents/gascity.py
@@ -0,0 +1,138 @@
+"""Gas City multi-agent orchestration agent implementation."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from paude.agents.base import (
+ AgentConfig,
+ build_environment_from_config,
+ build_provider_credentials,
+ claude_trust_script,
+ gemini_trust_script,
+ pipefail_install_lines,
+)
+
+GC_VERSION = "1.1.0"
+DOLT_VERSION = "1.88.0"
+BD_VERSION = "1.0.4"
+
+_CLAUDE_INSTALL_SCRIPT = "curl -fsSL https://claude.ai/install.sh | bash"
+
+_CLAUDE_CONFIG = AgentConfig(
+ name="claude",
+ display_name="Claude Code",
+ process_name="claude",
+ session_name="claude",
+ install_script=_CLAUDE_INSTALL_SCRIPT,
+)
+
+
+class GascityAgent:
+ """Gas City agent — composite agent with gc, Claude Code, and Gemini CLI."""
+
+ def __init__(self, provider: str | None = None) -> None:
+ creds = build_provider_credentials("gascity", provider)
+ creds.extra_env_vars["NODE_USE_ENV_PROXY"] = "1"
+ creds.extra_env_vars["BD_DOLT_AUTO_COMMIT"] = "off"
+ creds.extra_env_vars["BD_EXPORT_AUTO"] = "false"
+ self._config = AgentConfig(
+ name="gascity",
+ display_name="Gas City",
+ process_name="gc",
+ session_name="gascity",
+ install_script="echo 'gc pre-installed at build time'",
+ env_vars=creds.extra_env_vars,
+ passthrough_env_vars=creds.passthrough_env_vars,
+ secret_env_vars=creds.secret_env_vars,
+ passthrough_env_prefixes=creds.passthrough_env_prefixes,
+ config_dir_name=".gascity",
+ config_file_name=None,
+ yolo_flag=None,
+ clear_command=None,
+ extra_domain_aliases=[
+ "gascity",
+ "claude",
+ "gemini",
+ "nodejs",
+ ],
+ provider=creds.resolved_provider_name,
+ )
+
+ @property
+ def config(self) -> AgentConfig:
+ return self._config
+
+ def dockerfile_install_lines(self, container_home: str) -> list[str]:
+ install_dir = f"{container_home}/.local/bin"
+
+ claude_lines = pipefail_install_lines(
+ _CLAUDE_CONFIG,
+ container_home,
+ )
+ claude_lines[1] += f" && rm -f {container_home}/.claude.json"
+
+ lines = [
+ "",
+ "# --- Gas City composite agent install ---",
+ "",
+ "# Install Node.js, Gemini CLI, and flock",
+ "USER root",
+ "RUN dnf install -y nodejs npm util-linux lsof && dnf clean all",
+ "",
+ "# Install Gemini CLI and patch OTEL proxy",
+ "RUN npm install -g @google/gemini-cli"
+ " && /usr/local/bin/patch-gemini-otel-proxy.sh"
+ " --force 2>&1",
+ "",
+ "# Install Claude Code",
+ "USER paude",
+ f"WORKDIR {container_home}",
+ *claude_lines,
+ "",
+ "# Install dolt, bd (beads), and gc (Gas City)",
+ f"RUN mkdir -p {install_dir} && "
+ f"D={install_dir} && "
+ "ARCH=$(uname -m) && "
+ 'case "$ARCH" in '
+ 'x86_64) BIN_ARCH="amd64" ;; '
+ 'aarch64) BIN_ARCH="arm64" ;; '
+ '*) echo "Unsupported: $ARCH" && exit 1 ;; '
+ "esac && "
+ 'curl -fsSL "https://github.com/dolthub/dolt'
+ f"/releases/download/v{DOLT_VERSION}"
+ '/dolt-linux-${BIN_ARCH}.tar.gz"'
+ " | tar xz --strip-components=2"
+ " -C $D dolt-linux-${BIN_ARCH}/bin/dolt && "
+ 'curl -fsSL "https://github.com/gastownhall'
+ f"/beads/releases/download/v{BD_VERSION}"
+ f'/beads_{BD_VERSION}_linux_${{BIN_ARCH}}.tar.gz"'
+ " | tar xz -C $D bd && "
+ 'curl -fsSL "https://github.com/gastownhall'
+ f"/gascity/releases/download/v{GC_VERSION}"
+ f"/gascity_{GC_VERSION}_linux_${{BIN_ARCH}}"
+ '.tar.gz" | tar xz -C $D gc && '
+ "$D/dolt config --global --set metrics.disabled true && "
+ f"chmod -R g+rwX {container_home}/.dolt",
+ "",
+ f'ENV PATH="{install_dir}:$PATH"',
+ ]
+ return lines
+
+ def apply_sandbox_config(
+ self, home: str, workspace: str, args: str, *, yolo: bool = False
+ ) -> str:
+ return (
+ "#!/bin/bash\n"
+ + claude_trust_script(home, workspace)
+ + gemini_trust_script(home, workspace)
+ )
+
+ def launch_command(self, args: str) -> str:
+ return "bash"
+
+ def host_config_mounts(self, home: Path) -> list[str]:
+ return []
+
+ def build_environment(self) -> dict[str, str]:
+ return build_environment_from_config(self._config)
diff --git a/src/paude/agents/gemini.py b/src/paude/agents/gemini.py
index 3668ff5..7595211 100644
--- a/src/paude/agents/gemini.py
+++ b/src/paude/agents/gemini.py
@@ -8,6 +8,7 @@
AgentConfig,
build_environment_from_config,
build_provider_credentials,
+ gemini_trust_script,
)
@@ -50,10 +51,10 @@ def dockerfile_install_lines(self, container_home: str) -> list[str]:
"USER root",
"RUN dnf install -y nodejs npm && dnf clean all",
"",
- "# Install Gemini CLI",
- "RUN npm install -g @google/gemini-cli",
- "# Patch OTEL SDK to route exports through HTTP proxy (httpAgentOptions)",
- "RUN /usr/local/bin/patch-gemini-otel-proxy.sh --force 2>&1",
+ "# Install Gemini CLI and patch OTEL proxy",
+ "RUN npm install -g @google/gemini-cli"
+ " && /usr/local/bin/patch-gemini-otel-proxy.sh"
+ " --force 2>&1",
"",
"# Set up home directory",
"USER paude",
@@ -64,19 +65,7 @@ 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:
- return f"""\
-#!/bin/bash
-# Pre-trust the workspace folder so Gemini doesn't prompt on every connect
-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
-"""
+ return "#!/bin/bash\n" + gemini_trust_script(home, workspace)
def launch_command(self, args: str) -> str:
if args:
diff --git a/src/paude/cli/create.py b/src/paude/cli/create.py
index 1b5118f..4c09657 100644
--- a/src/paude/cli/create.py
+++ b/src/paude/cli/create.py
@@ -118,7 +118,7 @@ def session_create(
str | None,
typer.Option(
"--agent",
- help="Agent to use: claude (default), cursor, gemini, openclaw.",
+ help="Agent to use: claude (default), cursor, gascity, gemini, openclaw.",
),
] = None,
provider: Annotated[
diff --git a/src/paude/cli/help.py b/src/paude/cli/help.py
index b1e3f8c..1a1e94b 100644
--- a/src/paude/cli/help.py
+++ b/src/paude/cli/help.py
@@ -158,6 +158,7 @@ class HelpSection:
rows=(
("--agent claude", "Claude Code (default)"),
("--agent cursor", "Cursor CLI"),
+ ("--agent gascity", "Gas City (multi-agent orchestration)"),
("--agent gemini", "Gemini CLI"),
("--agent openclaw", "OpenClaw (web UI on port 18789)"),
("", ""),
diff --git a/src/paude/config/dockerfile.py b/src/paude/config/dockerfile.py
index 7153945..dc9b5cc 100644
--- a/src/paude/config/dockerfile.py
+++ b/src/paude/config/dockerfile.py
@@ -7,6 +7,8 @@
from paude.config.models import PaudeConfig
from paude.constants import CONTAINER_ENTRYPOINT, CONTAINER_HOME
+TMUX_VERSION = "3.5a"
+
if TYPE_CHECKING:
from paude.agents.base import Agent
@@ -123,13 +125,13 @@ def generate_workspace_dockerfile(
tar gzip xz unzip zip; \\
elif command -v dnf >/dev/null 2>&1; then \\
dnf install -y --allowerasing \\
- git curl ca-certificates bash tmux glibc-langpack-en socat \\
+ git curl ca-certificates bash glibc-langpack-en socat \\
which coreutils findutils grep sed gawk diffutils less file \\
tar gzip xz unzip zip && \\
dnf clean all; \\
elif command -v yum >/dev/null 2>&1; then \\
yum install -y --allowerasing \\
- git curl ca-certificates bash tmux glibc-langpack-en socat \\
+ git curl ca-certificates bash glibc-langpack-en socat \\
which coreutils findutils grep sed gawk diffutils less file \\
tar gzip xz unzip zip && \\
yum clean all; \\
@@ -137,6 +139,26 @@ def generate_workspace_dockerfile(
echo "Warning: Unknown package manager, git may not be available" >&2; \\
fi""")
+ # Build tmux from source on dnf/yum distros (CentOS Stream 10 / RHEL 10
+ # distro tmux has a capture-pane -p segfault that crashes the tmux server)
+ lines.append("")
+ lines.append(f"""ARG TMUX_VERSION={TMUX_VERSION}
+RUN if command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then \\
+ PKG_MGR=$(command -v dnf || command -v yum) && \\
+ $PKG_MGR install -y gcc make 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 && \\
+ $PKG_MGR remove -y gcc libevent-devel ncurses-devel bison && \\
+ $PKG_MGR clean all && \\
+ tmux -V; \\
+ fi""")
+
lines.append("")
lines.append("""# Install tini init process for zombie reaping
ARG TINI_VERSION=v0.19.0
diff --git a/src/paude/providers/agent_providers.py b/src/paude/providers/agent_providers.py
index b66b889..8f4bb6d 100644
--- a/src/paude/providers/agent_providers.py
+++ b/src/paude/providers/agent_providers.py
@@ -46,6 +46,11 @@ class AgentProviderConfig:
"cursor": {
"cursor": AgentProviderConfig(),
},
+ "gascity": {
+ "vertex": AgentProviderConfig(
+ extra_env_vars={"CLAUDE_CODE_USE_VERTEX": "1"},
+ ),
+ },
"gemini": {
"google": AgentProviderConfig(),
},
@@ -54,6 +59,7 @@ class AgentProviderConfig:
# Default provider for each agent (used when --provider is not specified).
DEFAULT_PROVIDER: dict[str, str] = {
"claude": "vertex",
+ "gascity": "vertex",
"openclaw": "vertex",
"cursor": "cursor",
"gemini": "google",
diff --git a/tests/test_agents.py b/tests/test_agents.py
index 4d5b400..20defaa 100644
--- a/tests/test_agents.py
+++ b/tests/test_agents.py
@@ -16,6 +16,7 @@
)
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
@@ -44,13 +45,17 @@ def test_get_agent_gemini(self) -> None:
agent = get_agent("gemini")
assert isinstance(agent, GeminiAgent)
+ def test_get_agent_gascity(self) -> None:
+ agent = get_agent("gascity")
+ assert isinstance(agent, GascityAgent)
+
def test_get_agent_openclaw(self) -> None:
agent = get_agent("openclaw")
assert isinstance(agent, OpenClawAgent)
def test_get_agent_error_lists_available(self) -> None:
with pytest.raises(
- ValueError, match="Available: claude, cursor, gemini, openclaw"
+ ValueError, match="Available: claude, cursor, gascity, gemini, openclaw"
):
get_agent("bad")
@@ -58,6 +63,7 @@ def test_list_agents(self) -> None:
agents = list_agents()
assert "claude" in agents
assert "cursor" in agents
+ assert "gascity" in agents
assert "gemini" in agents
assert "openclaw" in agents
assert agents == sorted(agents)
diff --git a/tests/test_entrypoint_seed_copy.py b/tests/test_entrypoint_seed_copy.py
index 6049ab1..e689c3e 100644
--- a/tests/test_entrypoint_seed_copy.py
+++ b/tests/test_entrypoint_seed_copy.py
@@ -28,6 +28,7 @@
ENTRYPOINT_LIB_INSTALL_PATH = (
Path(__file__).parent.parent / "containers" / "paude" / "entrypoint-lib-install.sh"
)
+DOCKERFILE_PATH = Path(__file__).parent.parent / "containers" / "paude" / "Dockerfile"
def _read_all_entrypoint_files() -> str:
@@ -102,6 +103,36 @@ def test_entrypoint_has_selinux_remediation(self) -> None:
)
+class TestNssWrapperContract:
+ """Contract tests verifying nss_wrapper setup for OpenShift arbitrary UIDs."""
+
+ def test_dockerfile_installs_nss_wrapper(self) -> None:
+ content = DOCKERFILE_PATH.read_text()
+ assert "nss_wrapper" in content, (
+ "Dockerfile must install nss_wrapper for OpenShift arbitrary UID support"
+ )
+
+ def test_entrypoint_activates_nss_wrapper(self) -> None:
+ content = ENTRYPOINT_PATH.read_text()
+ assert "NSS_WRAPPER_PASSWD" in content, (
+ "entrypoint-session.sh must set NSS_WRAPPER_PASSWD for nss_wrapper"
+ )
+ assert "libnss_wrapper" in content, (
+ "entrypoint-session.sh must LD_PRELOAD libnss_wrapper.so"
+ )
+
+ def test_nss_wrapper_before_home_setup(self) -> None:
+ content = ENTRYPOINT_PATH.read_text()
+ nss_pos = content.find("NSS_WRAPPER_PASSWD")
+ home_pos = content.find('if [[ -z "$HOME" || "$HOME" == "/" ]]')
+ assert nss_pos != -1, "NSS_WRAPPER_PASSWD must exist in entrypoint"
+ assert home_pos != -1, "HOME setup block must exist in entrypoint"
+ assert nss_pos < home_pos, (
+ "nss_wrapper setup must appear before HOME setup so that "
+ "user.Current() and os.UserHomeDir() see the correct passwd entry"
+ )
+
+
def _build_gemini_sandbox_script(
home_dir: str,
workspace: str,
@@ -437,44 +468,64 @@ def test_term_exported_before_first_tmux(self) -> None:
# ---------------------------------------------------------------------------
-# Helper for persist_agent_config tests
+# Helpers for persist_config_dir / persist_agent_config tests
# ---------------------------------------------------------------------------
-def _persist_bash_function(pvc_dir: str) -> str:
- """Return the persist_agent_config() bash function body for test scripts."""
+def _persist_config_dir_bash_function(pvc_dir: str) -> str:
+ """Return the persist_config_dir() bash function body for test scripts."""
return textwrap.dedent(f"""\
- persist_agent_config() {{
- if [[ ! -d "{pvc_dir}" ]]; then
+ persist_config_dir() {{
+ local dir_name="$1"
+ if [[ ! -d "{pvc_dir}" ]]; then return 0; fi
+
+ local pvc_dir="{pvc_dir}/$dir_name"
+ local home_dir="$HOME/$dir_name"
+
+ if [[ ! -d "$home_dir" ]] && [[ ! -L "$home_dir" ]] && [[ ! -d "$pvc_dir" ]]; then
return 0
fi
- local pvc_config_dir="{pvc_dir}/$AGENT_CONFIG_DIR"
- local home_config_dir="$HOME/$AGENT_CONFIG_DIR"
-
- mkdir -p "$pvc_config_dir" 2>/dev/null || true
- chmod g+rwX "$pvc_config_dir" 2>/dev/null || true
- chcon -R --reference="{pvc_dir}" "$pvc_config_dir" 2>/dev/null || true
+ mkdir -p "$pvc_dir" 2>/dev/null || true
+ chmod g+rwX "$pvc_dir" 2>/dev/null || true
+ chcon -R --reference="{pvc_dir}" "$pvc_dir" 2>/dev/null || true
- if [[ -d "$home_config_dir" ]] && [[ ! -L "$home_config_dir" ]]; then
- cp -Rp "$home_config_dir/." "$pvc_config_dir/" 2>/dev/null || true
- rm -rf "$home_config_dir"
+ 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
+ echo "persist_config_dir: cannot replace $home_dir with symlink; using PVC copy at $pvc_dir" >&2
+ fi
fi
+ }}
+ """)
+
- if [[ ! -L "$home_config_dir" ]]; then
- rm -rf "$home_config_dir" 2>/dev/null || true
- ln -sf "$pvc_config_dir" "$home_config_dir"
+def _persist_bash_function(pvc_dir: str) -> str:
+ """Return persist_config_dir + persist_agent_config for test scripts."""
+ config_dir_fn = _persist_config_dir_bash_function(pvc_dir)
+ return config_dir_fn + textwrap.dedent(f"""\
+ persist_agent_config() {{
+ if [[ ! -d "{pvc_dir}" ]]; then
+ return 0
fi
+ mkdir -p "{pvc_dir}/$AGENT_CONFIG_DIR" 2>/dev/null || true
+ persist_config_dir "$AGENT_CONFIG_DIR"
+
if [[ -n "$AGENT_CONFIG_FILE" ]]; then
local pvc_config_file="{pvc_dir}/$AGENT_CONFIG_FILE"
local home_config_file="$HOME/$AGENT_CONFIG_FILE"
if [[ -f "$home_config_file" ]] && [[ ! -L "$home_config_file" ]]; then
if [[ ! -f "$pvc_config_file" ]]; then
- cp -Rp "$home_config_file" "$pvc_config_file" 2>/dev/null || true
+ 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
if [[ ! -f "$pvc_config_file" ]]; then
@@ -483,8 +534,7 @@ def _persist_bash_function(pvc_dir: str) -> str:
chmod g+rw "$pvc_config_file" 2>/dev/null || true
chcon --reference="{pvc_dir}" "$pvc_config_file" 2>/dev/null || true
- 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
@@ -704,6 +754,178 @@ def test_sandbox_config_python_uses_cp_for_claude_json(self) -> None:
)
+def _build_persist_config_dir_script(
+ home_dir: str,
+ pvc_dir: str,
+ dir_name: str,
+) -> str:
+ """Build a script that exercises persist_config_dir()."""
+ config_dir_fn = _persist_config_dir_bash_function(pvc_dir)
+ return textwrap.dedent(f"""\
+ #!/bin/bash
+ set -e
+ export HOME="{home_dir}"
+
+ {config_dir_fn}
+ persist_config_dir {dir_name}
+ """)
+
+
+class TestPersistConfigDir:
+ """Tests for persist_config_dir() — generic dotdir PVC persistence."""
+
+ def test_persists_image_baked_dolt_config(self, tmp_path: Path) -> None:
+ """First start: copies image-baked ~/.dolt to PVC and symlinks."""
+ home = tmp_path / "home"
+ home.mkdir()
+ pvc = tmp_path / "pvc"
+ pvc.mkdir()
+
+ dolt_dir = home / ".dolt"
+ dolt_dir.mkdir()
+ (dolt_dir / "config_global.json").write_text('{"metrics.disabled":true}')
+
+ script = _build_persist_config_dir_script(str(home), str(pvc), ".dolt")
+ result = _run_script(script)
+ assert result.returncode == 0, result.stderr
+
+ assert (home / ".dolt").is_symlink()
+ assert (home / ".dolt").resolve() == (pvc / ".dolt").resolve()
+ assert (pvc / ".dolt" / "config_global.json").read_text() == (
+ '{"metrics.disabled":true}'
+ )
+
+ def test_preserves_pvc_state_on_reconnect(self, tmp_path: Path) -> None:
+ """Reconnect: PVC has existing dolt data, symlink preserved."""
+ home = tmp_path / "home"
+ home.mkdir()
+ pvc = tmp_path / "pvc"
+ pvc.mkdir()
+
+ # Simulate existing PVC state
+ pvc_dolt = pvc / ".dolt"
+ pvc_dolt.mkdir()
+ (pvc_dolt / "config_global.json").write_text('{"user.name":"agent"}')
+
+ script = _build_persist_config_dir_script(str(home), str(pvc), ".dolt")
+ result = _run_script(script)
+ assert result.returncode == 0, result.stderr
+
+ assert (home / ".dolt").is_symlink()
+ assert (pvc / ".dolt" / "config_global.json").read_text() == (
+ '{"user.name":"agent"}'
+ )
+
+ def test_idempotent(self, tmp_path: Path) -> None:
+ """Running twice produces the same result."""
+ home = tmp_path / "home"
+ home.mkdir()
+ pvc = tmp_path / "pvc"
+ pvc.mkdir()
+ (home / ".dolt").mkdir()
+ (home / ".dolt" / "config_global.json").write_text("{}")
+
+ script = _build_persist_config_dir_script(str(home), str(pvc), ".dolt")
+ _run_script(script)
+ (home / ".dolt" / "config_global.json").write_text('{"modified":true}')
+ result = _run_script(script)
+ assert result.returncode == 0, result.stderr
+
+ assert (home / ".dolt").is_symlink()
+ assert (pvc / ".dolt" / "config_global.json").read_text() == '{"modified":true}'
+
+ def test_noop_when_dir_does_not_exist(self, tmp_path: Path) -> None:
+ """No-op when neither HOME nor PVC has the directory."""
+ home = tmp_path / "home"
+ home.mkdir()
+ pvc = tmp_path / "pvc"
+ pvc.mkdir()
+
+ script = _build_persist_config_dir_script(str(home), str(pvc), ".dolt")
+ result = _run_script(script)
+ assert result.returncode == 0, result.stderr
+
+ assert not (home / ".dolt").exists()
+ assert not (pvc / ".dolt").exists()
+
+ def test_no_crash_when_home_dir_not_removable(self, tmp_path: Path) -> None:
+ """If rm -rf fails (e.g. OpenShift overlay), copies to PVC without crashing."""
+ home = tmp_path / "home"
+ home.mkdir()
+ pvc = tmp_path / "pvc"
+ pvc.mkdir()
+
+ dolt_dir = home / ".dolt"
+ dolt_dir.mkdir()
+ (dolt_dir / "config_global.json").write_text('{"metrics.disabled":true}')
+
+ config_dir_fn = _persist_config_dir_bash_function(str(pvc))
+ script = textwrap.dedent(f"""\
+ #!/bin/bash
+ set -e
+ export HOME="{home}"
+
+ {config_dir_fn}
+
+ # Stub rm to simulate OpenShift overlay permission denied
+ rm() {{ return 1; }}
+ export -f rm
+
+ persist_config_dir .dolt
+ """)
+ result = _run_script(script)
+ assert result.returncode == 0, result.stderr
+
+ # Config was copied to PVC even though rm failed
+ assert (pvc / ".dolt" / "config_global.json").read_text() == (
+ '{"metrics.disabled":true}'
+ )
+ # Home dir is still a real directory (not a symlink)
+ assert (home / ".dolt").is_dir()
+ assert not (home / ".dolt").is_symlink()
+
+ def test_noop_without_pvc(self, tmp_path: Path) -> None:
+ """No-op when /pvc doesn't exist (non-persistent setup)."""
+ home = tmp_path / "home"
+ home.mkdir()
+ (home / ".dolt").mkdir()
+
+ script = _build_persist_config_dir_script(
+ str(home), str(tmp_path / "nonexistent"), ".dolt"
+ )
+ result = _run_script(script)
+ assert result.returncode == 0, result.stderr
+
+ assert (home / ".dolt").is_dir()
+ assert not (home / ".dolt").is_symlink()
+
+
+class TestPersistConfigDirContract:
+ """Contract tests for persist_config_dir in the real entrypoint."""
+
+ def test_entrypoint_has_persist_config_dir_function(self) -> None:
+ content = ENTRYPOINT_LIB_CONFIG_PATH.read_text()
+ assert "persist_config_dir()" in content, (
+ "entrypoint-lib-config.sh must define persist_config_dir()"
+ )
+
+ def test_entrypoint_calls_persist_config_dir_for_dolt(self) -> None:
+ content = ENTRYPOINT_PATH.read_text()
+ assert "persist_config_dir .dolt" in content, (
+ "entrypoint-session.sh must call persist_config_dir .dolt"
+ )
+
+ def test_persist_config_dir_called_after_persist_agent_config(self) -> None:
+ content = ENTRYPOINT_PATH.read_text()
+ agent_pos = content.find("\npersist_agent_config\n")
+ dolt_pos = content.find("\npersist_config_dir .dolt\n")
+ assert agent_pos != -1
+ assert dolt_pos != -1
+ assert agent_pos < dolt_pos, (
+ "persist_config_dir .dolt must be called after persist_agent_config"
+ )
+
+
class TestCursorSandboxConfig:
"""Tests for Cursor agent sandbox config generation and execution."""
diff --git a/tests/test_gascity.py b/tests/test_gascity.py
new file mode 100644
index 0000000..d96f940
--- /dev/null
+++ b/tests/test_gascity.py
@@ -0,0 +1,239 @@
+"""Tests for the Gas City multi-agent orchestration agent."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import patch
+
+from paude.agents.gascity import BD_VERSION, DOLT_VERSION, GC_VERSION, GascityAgent
+
+
+class TestGascityAgentConfig:
+ """Tests for GascityAgent configuration values."""
+
+ def test_name(self) -> None:
+ assert GascityAgent().config.name == "gascity"
+
+ def test_display_name(self) -> None:
+ assert GascityAgent().config.display_name == "Gas City"
+
+ def test_process_name(self) -> None:
+ assert GascityAgent().config.process_name == "gc"
+
+ def test_session_name(self) -> None:
+ assert GascityAgent().config.session_name == "gascity"
+
+ def test_config_dir_name(self) -> None:
+ assert GascityAgent().config.config_dir_name == ".gascity"
+
+ def test_config_file_name_is_none(self) -> None:
+ assert GascityAgent().config.config_file_name is None
+
+ def test_yolo_flag_is_none(self) -> None:
+ assert GascityAgent().config.yolo_flag is None
+
+ def test_clear_command_is_none(self) -> None:
+ assert GascityAgent().config.clear_command is None
+
+ def test_env_vars(self) -> None:
+ cfg = GascityAgent().config
+ assert cfg.env_vars == {
+ "CLAUDE_CODE_USE_VERTEX": "1",
+ "NODE_USE_ENV_PROXY": "1",
+ "BD_DOLT_AUTO_COMMIT": "off",
+ "BD_EXPORT_AUTO": "false",
+ }
+
+ def test_passthrough_vars(self) -> None:
+ cfg = GascityAgent().config
+ assert "ANTHROPIC_VERTEX_PROJECT_ID" in cfg.passthrough_env_vars
+ assert "GOOGLE_CLOUD_PROJECT" in cfg.passthrough_env_vars
+
+ def test_passthrough_prefixes(self) -> None:
+ cfg = GascityAgent().config
+ assert "CLOUDSDK_AUTH_" in cfg.passthrough_env_prefixes
+
+ def test_extra_domain_aliases(self) -> None:
+ cfg = GascityAgent().config
+ assert "gascity" in cfg.extra_domain_aliases
+ assert "claude" in cfg.extra_domain_aliases
+ assert "gemini" in cfg.extra_domain_aliases
+ assert "nodejs" in cfg.extra_domain_aliases
+
+ def test_exposed_ports_empty(self) -> None:
+ assert GascityAgent().config.exposed_ports == []
+
+ def test_default_base_image_is_none(self) -> None:
+ assert GascityAgent().config.default_base_image is None
+
+ def test_activity_files_empty(self) -> None:
+ assert GascityAgent().config.activity_files == []
+
+ def test_install_script_is_noop(self) -> None:
+ cfg = GascityAgent().config
+ assert "pre-installed" in cfg.install_script
+
+
+class TestGascityAgentDockerfile:
+ """Tests for GascityAgent.dockerfile_install_lines."""
+
+ def test_returns_list(self) -> None:
+ lines = GascityAgent().dockerfile_install_lines("/home/paude")
+ assert isinstance(lines, list)
+ assert len(lines) > 0
+
+ def test_contains_nodejs(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "nodejs" in text
+
+ def test_contains_npm(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "npm" in text
+
+ def test_contains_gemini_cli(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "@google/gemini-cli" in text
+
+ def test_contains_gemini_otel_patch(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "patch-gemini-otel-proxy.sh" in text
+
+ def test_contains_claude_install(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "claude.ai/install.sh" in text
+
+ def test_contains_claude_binary_check(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "test -x" in text
+ assert "claude" in text
+
+ def test_contains_dolt(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "dolthub/dolt" in text
+ assert DOLT_VERSION in text
+
+ def test_contains_bd(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "gastownhall/beads" in text
+ assert BD_VERSION in text
+
+ def test_contains_gc(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "gastownhall/gascity" in text
+ assert GC_VERSION in text
+
+ def test_contains_flock(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "util-linux" in text
+
+ def test_contains_lsof(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "lsof" in text
+
+ def test_disables_dolt_metrics(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "metrics.disabled" in text
+ assert "dolt config --global --set" in text
+
+ def test_dolt_config_dir_group_writable(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "chmod -R g+rwX /home/paude/.dolt" in text
+
+ def test_arch_detection(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "uname -m" in text
+ assert "amd64" in text
+ assert "arm64" in text
+
+ def test_pipefail_shell(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "pipefail" in text
+
+ def test_sets_path(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/home/paude"))
+ assert "/home/paude/.local/bin" in text
+
+ def test_uses_container_home(self) -> None:
+ text = "\n".join(GascityAgent().dockerfile_install_lines("/custom/home"))
+ assert "/custom/home" in text
+
+
+class TestGascityAgentLaunchCommand:
+ """Tests for GascityAgent.launch_command."""
+
+ def test_no_args(self) -> None:
+ assert GascityAgent().launch_command("") == "bash"
+
+ def test_with_args(self) -> None:
+ assert GascityAgent().launch_command("--foo") == "bash"
+
+
+class TestGascityAgentHostConfigMounts:
+ """Tests for GascityAgent.host_config_mounts."""
+
+ def test_empty(self, tmp_path: Path) -> None:
+ mounts = GascityAgent().host_config_mounts(tmp_path)
+ assert mounts == []
+
+
+class TestGascityAgentBuildEnvironment:
+ """Tests for GascityAgent.build_environment."""
+
+ def test_includes_static_env_vars(self) -> None:
+ with patch.dict("os.environ", {}, clear=True):
+ env = GascityAgent().build_environment()
+ assert env == {
+ "CLAUDE_CODE_USE_VERTEX": "1",
+ "NODE_USE_ENV_PROXY": "1",
+ "BD_DOLT_AUTO_COMMIT": "off",
+ "BD_EXPORT_AUTO": "false",
+ }
+
+ def test_passes_through_vertex_vars(self) -> None:
+ with patch.dict(
+ "os.environ",
+ {"ANTHROPIC_VERTEX_PROJECT_ID": "proj-1", "UNRELATED": "x"},
+ clear=True,
+ ):
+ env = GascityAgent().build_environment()
+ assert env["ANTHROPIC_VERTEX_PROJECT_ID"] == "proj-1"
+ assert env["CLAUDE_CODE_USE_VERTEX"] == "1"
+
+ def test_passes_through_prefix_vars(self) -> None:
+ test_val = "abc" # noqa: S105
+ with patch.dict(
+ "os.environ",
+ {"CLOUDSDK_AUTH_TOKEN": test_val},
+ clear=True,
+ ):
+ env = GascityAgent().build_environment()
+ assert env["CLOUDSDK_AUTH_TOKEN"] == test_val
+
+
+class TestGascityAgentSandboxConfig:
+ """Tests for GascityAgent.apply_sandbox_config."""
+
+ def test_returns_bash_script(self) -> None:
+ script = GascityAgent().apply_sandbox_config("/home/paude", "/workspace", "")
+ assert script.startswith("#!/bin/bash")
+
+ def test_contains_claude_trust(self) -> None:
+ script = GascityAgent().apply_sandbox_config("/home/paude", "/workspace", "")
+ assert "hasCompletedOnboarding" in script
+ assert "hasTrustDialogAccepted" in script
+
+ def test_contains_gemini_trust(self) -> None:
+ script = GascityAgent().apply_sandbox_config("/home/paude", "/workspace", "")
+ assert "trustedFolders.json" in script
+ assert "TRUST_FOLDER" in script
+
+ def test_contains_workspace(self) -> None:
+ script = GascityAgent().apply_sandbox_config(
+ "/home/paude", "/pvc/workspace", ""
+ )
+ assert "/pvc/workspace" in script
+
+ def test_home_path_parameterized(self) -> None:
+ script = GascityAgent().apply_sandbox_config("/custom/home", "/workspace", "")
+ assert "/custom/home/.claude.json" in script
+ assert "/custom/home/.gemini" in script
diff --git a/tests/test_providers.py b/tests/test_providers.py
index 58f3aa4..d2ad7a6 100644
--- a/tests/test_providers.py
+++ b/tests/test_providers.py
@@ -102,6 +102,15 @@ def test_resolve_cursor_cursor(self) -> None:
provider, _ = resolve_agent_provider("cursor", "cursor")
assert provider.name == "cursor"
+ def test_resolve_gascity_vertex(self) -> None:
+ provider, agent_cfg = resolve_agent_provider("gascity", "vertex")
+ assert provider.name == "vertex"
+ assert agent_cfg.extra_env_vars.get("CLAUDE_CODE_USE_VERTEX") == "1"
+
+ def test_resolve_gascity_default(self) -> None:
+ provider, _ = resolve_agent_provider("gascity")
+ assert provider.name == "vertex"
+
def test_resolve_gemini_google(self) -> None:
provider, _ = resolve_agent_provider("gemini", "google")
assert provider.name == "google"
@@ -135,6 +144,9 @@ def test_openclaw_default_is_vertex(self) -> None:
def test_cursor_default_is_cursor(self) -> None:
assert DEFAULT_PROVIDER["cursor"] == "cursor"
+ def test_gascity_default_is_vertex(self) -> None:
+ assert DEFAULT_PROVIDER["gascity"] == "vertex"
+
def test_gemini_default_is_google(self) -> None:
assert DEFAULT_PROVIDER["gemini"] == "google"
@@ -162,6 +174,10 @@ def test_openclaw_providers(self) -> None:
assert "openai" in providers
assert "anthropic" in providers
+ def test_gascity_providers(self) -> None:
+ providers = supported_providers("gascity")
+ assert "vertex" in providers
+
def test_unknown_agent_returns_empty(self) -> None:
assert supported_providers("nonexistent") == []