From ed98c92f1d3ea06f4285403b0a5f35d7fe31728e Mon Sep 17 00:00:00 2001 From: Ben Browning Date: Mon, 11 May 2026 20:14:34 +0000 Subject: [PATCH 1/3] Add Gas City agent for multi-agent orchestration Introduce the Gas City agent, which runs a Dolt-backed SQL database alongside the coding agent for multi-agent data workflows. Includes agent definition, CLI integration, provider registration, Dockerfile customizations for Dolt and tmux, and comprehensive test coverage. Co-Authored-By: Claude Opus 4.6 --- README.md | 1 + src/paude/agents/__init__.py | 3 + src/paude/agents/base.py | 36 ++++ src/paude/agents/claude.py | 29 +--- src/paude/agents/gascity.py | 134 +++++++++++++++ src/paude/agents/gemini.py | 23 +-- src/paude/cli/create.py | 2 +- src/paude/cli/help.py | 1 + src/paude/providers/agent_providers.py | 6 + tests/test_agents.py | 8 +- tests/test_gascity.py | 226 +++++++++++++++++++++++++ tests/test_providers.py | 16 ++ 12 files changed, 444 insertions(+), 41 deletions(-) create mode 100644 src/paude/agents/gascity.py create mode 100644 tests/test_gascity.py 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/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..38be635 --- /dev/null +++ b/src/paude/agents/gascity.py @@ -0,0 +1,134 @@ +"""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" + 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', + "", + 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/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_gascity.py b/tests/test_gascity.py new file mode 100644 index 0000000..ec123a5 --- /dev/null +++ b/tests/test_gascity.py @@ -0,0 +1,226 @@ +"""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", + } + + 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_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", + } + + 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") == [] From 9aec08300551757d9945e03bdcfdcf5a724c61c2 Mon Sep 17 00:00:00 2001 From: Ben Browning Date: Mon, 11 May 2026 20:14:39 +0000 Subject: [PATCH 2/3] Fix container image and OpenShift compatibility for Gas City Fix supervisor startup, passwd/home directory, gitconfig persistence, and proxy stability under OpenShift's arbitrary UID model. Build tmux 3.5a from source to fix capture-pane segfault. Update paude-proxy to cd70ced. Co-Authored-By: Claude Opus 4.6 --- containers/paude/Dockerfile | 22 +++++++- .../paude/entrypoint-lib-credentials.sh | 15 ++++-- containers/paude/entrypoint-session.sh | 52 +++++++++++++++++-- containers/proxy/Dockerfile | 2 +- src/paude/config/dockerfile.py | 26 +++++++++- tests/test_entrypoint_seed_copy.py | 31 +++++++++++ 6 files changed, 134 insertions(+), 14 deletions(-) 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-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..ed3f6ca 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 @@ -65,6 +100,13 @@ setup_credentials persist_agent_config 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 +211,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/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/tests/test_entrypoint_seed_copy.py b/tests/test_entrypoint_seed_copy.py index 6049ab1..e4cc1f3 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, From ed3eae22a7948e87126f3fef03ca766b1459f2db Mon Sep 17 00:00:00 2001 From: Ben Browning Date: Mon, 11 May 2026 20:14:44 +0000 Subject: [PATCH 3/3] Tune Gas City Dolt configuration and persistence Optimize Dolt performance by disabling metrics and auto-commit. Persist Dolt global config to PVC to survive container restarts, with overlay filesystem crash-loop protection. Make ~/.dolt group-writable for OpenShift arbitrary UIDs and disable beads auto-export. Co-Authored-By: Claude Opus 4.6 --- containers/paude/entrypoint-lib-config.sh | 64 +++--- containers/paude/entrypoint-session.sh | 1 + src/paude/agents/gascity.py | 6 +- tests/test_entrypoint_seed_copy.py | 233 ++++++++++++++++++++-- tests/test_gascity.py | 13 ++ 5 files changed, 269 insertions(+), 48 deletions(-) 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-session.sh b/containers/paude/entrypoint-session.sh index ed3f6ca..5b97169 100644 --- a/containers/paude/entrypoint-session.sh +++ b/containers/paude/entrypoint-session.sh @@ -98,6 +98,7 @@ 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) diff --git a/src/paude/agents/gascity.py b/src/paude/agents/gascity.py index 38be635..718035b 100644 --- a/src/paude/agents/gascity.py +++ b/src/paude/agents/gascity.py @@ -34,6 +34,8 @@ class GascityAgent: 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", @@ -109,7 +111,9 @@ def dockerfile_install_lines(self, container_home: str) -> list[str]: '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', + '.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"', ] diff --git a/tests/test_entrypoint_seed_copy.py b/tests/test_entrypoint_seed_copy.py index e4cc1f3..e689c3e 100644 --- a/tests/test_entrypoint_seed_copy.py +++ b/tests/test_entrypoint_seed_copy.py @@ -468,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 @@ -514,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 @@ -735,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 index ec123a5..d96f940 100644 --- a/tests/test_gascity.py +++ b/tests/test_gascity.py @@ -40,6 +40,8 @@ def test_env_vars(self) -> None: 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: @@ -128,6 +130,15 @@ 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 @@ -174,6 +185,8 @@ def test_includes_static_env_vars(self) -> None: 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: