diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index c0b00dc..006e4eb 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -257,7 +257,7 @@ class AgentProvider(ABC): Default: Debian/node — writes the git-gate insteadOf gitconfig and sets user.name/email as node. Workspace copy runs through BottleBackend.provision_workspace against the running bottle.""" - from .log import info + from .log import die, info # Firecracker exports image rootfs files through an unprivileged host # tar extraction, so image-time ownership of XDG directories is not @@ -266,13 +266,17 @@ class AgentProvider(ABC): # git-gate insteadOf rules below from taking effect. Repair this at # runtime, after every backend's copy/export path has completed. git_xdg_dir = f"{plan.guest_home}/.config/git" - bottle.exec( + repair = bottle.exec( f"mkdir -p {shlex.quote(git_xdg_dir)} && " f"chown -R node:node {shlex.quote(f'{plan.guest_home}/.config')} && " - f"chmod 755 {shlex.quote(f'{plan.guest_home}/.config')} " - f"{shlex.quote(git_xdg_dir)}", + f"chmod -R u+rwX,go+rX {shlex.quote(f'{plan.guest_home}/.config')}", user="root", ) + if repair.returncode != 0: + die( + "git provisioning: could not make the runtime Git config " + f"directory readable: {(repair.stderr or repair.stdout).strip()}" + ) manifest_bottle = plan.manifest.bottle if manifest_bottle.git: @@ -295,11 +299,27 @@ class AgentProvider(ABC): f"{len(manifest_bottle.git)} insteadOf rule(s)" ) bottle.cp_in(str(config_file), guest_gitconfig) - bottle.exec( + permissions = bottle.exec( f"chown node:node {shlex.quote(guest_gitconfig)} && " f"chmod 644 {shlex.quote(guest_gitconfig)}", user="root", ) + if permissions.returncode != 0: + die( + "git provisioning: could not set ownership on " + f"{guest_gitconfig}: " + f"{(permissions.stderr or permissions.stdout).strip()}" + ) + configured = bottle.exec( + "git config --global --get-regexp '^url\\..*\\.insteadof$'", + user="node", + ) + if configured.returncode != 0: + die( + "git provisioning: the runtime user cannot read the " + f"git-gate insteadOf rules from {guest_gitconfig}: " + f"{(configured.stderr or configured.stdout).strip()}" + ) gu = manifest_bottle.git_user if not gu.is_empty(): diff --git a/bot_bottle/backend/firecracker/launch.py b/bot_bottle/backend/firecracker/launch.py index a5efc00..b87b44b 100644 --- a/bot_bottle/backend/firecracker/launch.py +++ b/bot_bottle/backend/firecracker/launch.py @@ -242,6 +242,11 @@ def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str "HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url, "https_proxy": proxy_url, "http_proxy": proxy_url, "NO_PROXY": no_proxy, "no_proxy": no_proxy, + # Rootfs export can leave Git's implicit XDG paths unreadable even + # after the runtime repair. Bypass that discovery and name the + # provisioned global config explicitly so insteadOf can never fall + # through to the credential-bearing upstream URL. + "GIT_CONFIG_GLOBAL": f"{plan.guest_home}/.gitconfig", "NODE_EXTRA_CA_CERTS": AGENT_CA_PATH, "SSL_CERT_FILE": AGENT_CA_BUNDLE, "REQUESTS_CA_BUNDLE": AGENT_CA_BUNDLE, diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index 852822b..c6e8e8d 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -137,7 +137,7 @@ class TestProvisionGitUser(unittest.TestCase): self.assertEqual("root", user) self.assertIn("mkdir -p /home/node/.config/git", script) self.assertIn("chown -R node:node /home/node/.config", script) - self.assertIn("chmod 755 /home/node/.config /home/node/.config/git", script) + self.assertIn("chmod -R u+rwX,go+rX /home/node/.config", script) def test_sets_name_and_email(self): plan = _plan( diff --git a/tests/unit/test_firecracker_backend.py b/tests/unit/test_firecracker_backend.py index e29c972..5da1c77 100644 --- a/tests/unit/test_firecracker_backend.py +++ b/tests/unit/test_firecracker_backend.py @@ -422,10 +422,15 @@ class TestBottlePlanProperties(unittest.TestCase): ap.command = "claude" ap.prompt_mode = "append_file" ap.template = "claude" + ap.guest_home = "/home/node" + ap.guest_env = {} + egress_plan = cast(Any, MagicMock()) + egress_plan.canary = "" + egress_plan.canary_env = "" fields = dict( spec=cast(Any, MagicMock()), manifest=cast(Any, MagicMock()), stage_dir=Path("/stage"), git_gate_plan=cast(Any, MagicMock()), - egress_plan=cast(Any, MagicMock()), supervise_plan=None, + egress_plan=egress_plan, supervise_plan=None, agent_provision=ap, slug="demo-x", forwarded_env={}, ) fields.update(overrides) @@ -451,6 +456,12 @@ class TestBottlePlanProperties(unittest.TestCase): self.assertEqual("10.243.0.0:9420", p.git_gate_insteadof_host) self.assertEqual("http", p.git_gate_insteadof_scheme) + def test_guest_env_pins_global_git_config(self): + from bot_bottle.backend.firecracker.launch import _agent_guest_env + + env = _agent_guest_env(self._plan(), "10.243.0.0") + self.assertEqual("/home/node/.gitconfig", env["GIT_CONFIG_GLOBAL"]) + if __name__ == "__main__": unittest.main()