diff --git a/bot_bottle/backend/smolmachines/bottle.py b/bot_bottle/backend/smolmachines/bottle.py index c278937..c496919 100644 --- a/bot_bottle/backend/smolmachines/bottle.py +++ b/bot_bottle/backend/smolmachines/bottle.py @@ -47,10 +47,24 @@ _HOME_FOR = { "root": "/root", } +_DEFAULT_PATH_FOR = { + # Committed smolmachine snapshots are rebuilt from a rootfs tarball and + # lose Docker image ENV metadata. Restore the provider CLI path here so + # resumed Codex bottles can still find the per-user install. + "node": ( + "/home/node/.local/bin:" + "/home/node/.codex/packages/standalone/current/bin:" + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ), + "root": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", +} + def _env_assignments_for(user: str, env: Mapping[str, str]) -> list[str]: home = _HOME_FOR.get(user, f"/home/{user}") out = [f"HOME={home}", f"USER={user}"] + if "PATH" not in env: + out.append(f"PATH={_DEFAULT_PATH_FOR.get(user, _DEFAULT_PATH_FOR['root'])}") for k, v in env.items(): out.append(f"{k}={v}") return out diff --git a/bot_bottle/contrib/codex/agent_provider.py b/bot_bottle/contrib/codex/agent_provider.py index b7afbc5..0a681a5 100644 --- a/bot_bottle/contrib/codex/agent_provider.py +++ b/bot_bottle/contrib/codex/agent_provider.py @@ -34,6 +34,12 @@ if TYPE_CHECKING: _SUPERVISE_MCP_NAME = "supervise" +_CODEX_CLI = "/home/node/.codex/packages/standalone/current/bin/codex" +_CODEX_CLI_PATH = ( + "/home/node/.local/bin:" + "/home/node/.codex/packages/standalone/current/bin:" + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +) def _skills_dir(guest_home: str) -> str: @@ -50,7 +56,7 @@ def _prompt_path(guest_home: str) -> str: _RUNTIME = AgentProviderRuntime( template="codex", - command="codex", + command=_CODEX_CLI, image="bot-bottle-codex:latest", prompt_mode="read_prompt_file", bypass_args=("--dangerously-bypass-approvals-and-sandbox",), @@ -145,7 +151,8 @@ class CodexAgentProvider(AgentProvider): "env", f"HOME={guest_home}", f"CODEX_HOME={auth_dir}", - "codex", "login", "status", + f"PATH={_CODEX_CLI_PATH}", + _CODEX_CLI, "login", "status", ), ( "codex host credentials: dummy auth was copied into the " "guest, but Codex did not accept it" @@ -267,7 +274,7 @@ class CodexAgentProvider(AgentProvider): return info(f"registering supervise MCP server in agent codex config → {supervise_url}") r = bottle.exec( - f"codex mcp add {_SUPERVISE_MCP_NAME} --url " + f"{shlex.quote(_CODEX_CLI)} mcp add {_SUPERVISE_MCP_NAME} --url " f"{shlex.quote(supervise_url)}", user="node", ) diff --git a/bot_bottle/git_gate_render.py b/bot_bottle/git_gate_render.py index ba11926..e333da6 100644 --- a/bot_bottle/git_gate_render.py +++ b/bot_bottle/git_gate_render.py @@ -228,7 +228,7 @@ supervise_gitleaks_allow() { fi proposal_id=$( - GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY' + PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY' import datetime import hashlib import json @@ -236,7 +236,10 @@ import os import sys from pathlib import Path -from bot_bottle import supervise as _sv +try: + import supervise as _sv +except ImportError: + from bot_bottle import supervise as _sv report_path = Path(sys.argv[1]) slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "") @@ -314,10 +317,13 @@ PY echo "git-gate: approve with './cli.py supervise' to continue this push" >&2 waited=0 while [ "$waited" -lt "$timeout" ]; do - status=$(python3 - "$slug" "$proposal_id" <<'PY' + status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$slug" "$proposal_id" <<'PY' import sys -from bot_bottle import supervise as _sv +try: + import supervise as _sv +except ImportError: + from bot_bottle import supervise as _sv slug = sys.argv[1] try: @@ -336,10 +342,13 @@ PY if [ -n "$status" ]; then case "$status" in approved|modified) - python3 - "$slug" "$proposal_id" <<'PY' || true + PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$slug" "$proposal_id" <<'PY' || true import sys -from bot_bottle import supervise as _sv +try: + import supervise as _sv +except ImportError: + from bot_bottle import supervise as _sv _sv.archive_proposal(sys.argv[1], sys.argv[2]) PY diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index efbb758..adf25d2 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -35,7 +35,10 @@ class TestAgentProviderRuntime(unittest.TestCase): ) config = Path(tmp, "codex-config.toml").read_text() self.assertEqual("codex", plan.template) - self.assertEqual("codex", plan.command) + self.assertEqual( + "/home/node/.codex/packages/standalone/current/bin/codex", + plan.command, + ) self.assertEqual("read_prompt_file", plan.prompt_mode) self.assertEqual("/tmp/Dockerfile.codex", plan.dockerfile) self.assertEqual( diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index 3ccfbfb..68932e1 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -261,6 +261,36 @@ class TestCodexProvision(unittest.TestCase): self.assertTrue(any("find" in s and "-delete" in s for s in scripts)) self.assertTrue(any("runuser" in s and "codex login status" in s for s in scripts)) + def test_forwarded_credentials_verify_sets_codex_path(self): + with tempfile.TemporaryDirectory(prefix="bb-codex-auth.") as tmp: + state_dir = Path(tmp) + prompt_file = state_dir / "prompt.txt" + prompt_file.write_text("") + with patch( + "bot_bottle.contrib.codex.agent_provider.codex_host_access_token", + return_value="token", + ), patch( + "bot_bottle.contrib.codex.agent_provider.write_codex_dummy_auth_file", + ): + provision = CodexAgentProvider().provision_plan( + dockerfile="", + state_dir=state_dir, + instance_name="bot-bottle-demo-abc12", + prompt_file=prompt_file, + forward_host_credentials=True, + host_env={}, + ) + + verify_argv = provision.verify[0].argv + self.assertTrue(any( + item.startswith("PATH=/home/node/.local/bin:") + for item in verify_argv + )) + self.assertIn( + "/home/node/.codex/packages/standalone/current/bin/codex", + verify_argv, + ) + def test_dies_when_dir_creation_fails(self): provision = AgentProvisionPlan( template="codex", command="codex", @@ -301,7 +331,8 @@ class TestCodexSuperviseMcp(unittest.TestCase): script = bottle.exec.call_args.args[0] self.assertEqual("node", bottle.exec.call_args.kwargs.get("user")) self.assertEqual( - f"codex mcp add supervise --url {_URL}", + "/home/node/.codex/packages/standalone/current/bin/codex " + f"mcp add supervise --url {_URL}", script, ) diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index 6cc0a99..ef74288 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -217,6 +217,16 @@ class TestHookRender(unittest.TestCase): self.assertIn("supervisor approved # gitleaks:allow", hook) self.assertIn("supervisor rejected # gitleaks:allow", hook) + def test_inline_gitleaks_allow_python_imports_work_in_sidecar_layout(self): + hook = git_gate_render_hook() + # The sidecar image copies supervise.py flat under /app, while + # host-side tests import it through the bot_bottle package. + # Hooks execute from the bare repo directory, so the embedded + # Python must include /app and support both import layouts. + self.assertIn('PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}"', hook) + self.assertIn("import supervise as _sv", hook) + self.assertIn("from bot_bottle import supervise as _sv", hook) + def test_inline_gitleaks_allow_fails_closed_without_supervisor(self): hook = git_gate_render_hook() self.assertIn( diff --git a/tests/unit/test_macos_container_bottle.py b/tests/unit/test_macos_container_bottle.py index 543cd74..28da717 100644 --- a/tests/unit/test_macos_container_bottle.py +++ b/tests/unit/test_macos_container_bottle.py @@ -30,6 +30,19 @@ class TestMacosContainerBottle(unittest.TestCase): argv, ) + def test_agent_argv_accepts_absolute_provider_command(self): + command = "/home/node/.codex/packages/standalone/current/bin/codex" + bottle = MacosContainerBottle( + "bot-bottle-dev-abc", + lambda: None, + None, + agent_command=command, + ) + with patch.dict(bottle_mod.os.environ, {}, clear=True): + argv = bottle.agent_argv(["run"]) + self.assertIn(command, argv) + self.assertNotIn("codex", argv) + def test_agent_argv_includes_workdir(self): bottle = MacosContainerBottle( "bot-bottle-dev-abc", diff --git a/tests/unit/test_smolmachines_bottle.py b/tests/unit/test_smolmachines_bottle.py index 7c98ef2..fcfeee9 100644 --- a/tests/unit/test_smolmachines_bottle.py +++ b/tests/unit/test_smolmachines_bottle.py @@ -79,6 +79,11 @@ class TestClaudeArgvWrapped(unittest.TestCase): "--", "runuser", "-u", "node", "--", "env", "HOME=/home/node", "USER=node", + ( + "PATH=/home/node/.local/bin:" + "/home/node/.codex/packages/standalone/current/bin:" + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ), "claude", ], argv, @@ -127,16 +132,24 @@ class TestClaudeArgvWrapped(unittest.TestCase): self.assertIn("HTTPS_PROXY=http://127.0.0.1:1234", argv) self.assertIn("NO_PROXY=localhost", argv) + def test_guest_env_path_overrides_default_path(self): + argv = _unwrap(_bottle(None, PATH="/custom/bin").agent_argv([])) + self.assertIn("PATH=/custom/bin", argv) + self.assertFalse(any( + item.startswith("PATH=/home/node/.local/bin") + for item in argv + )) + def test_runuser_switch_precedes_claude(self): # The dashboard's `_build_resume_argv_with_fallback` finds # the `claude` token to split exec-framing from the claude # tail. `runuser -u node --` must sit on the prefix side so # the shell wrap inherits the UID switch. argv = _bottle().agent_argv([]) - agent_idx = argv.index("claude") + runuser_idx = argv.index("runuser") self.assertEqual( ["runuser", "-u", "node", "--", "env"], - argv[agent_idx - 7:agent_idx - 2], + argv[runuser_idx:runuser_idx + 5], ) def test_pi_provider_appends_system_prompt_without_print_mode(self):