diff --git a/bot_bottle/backend/docker/provision/provider_auth.py b/bot_bottle/backend/docker/provision/provider_auth.py index e992e87..7d0478c 100644 --- a/bot_bottle/backend/docker/provision/provider_auth.py +++ b/bot_bottle/backend/docker/provision/provider_auth.py @@ -3,29 +3,72 @@ from __future__ import annotations import os +import shlex import subprocess from ..bottle_plan import DockerBottlePlan -def provision_provider_auth(plan: DockerBottlePlan, target: str) -> None: - """Copy a dummy Codex auth marker when host credentials are - forwarded through egress. +_CODEX_WORKSPACE = "/home/node/workspace" - The file contains no real access or refresh token values; it only + +def provision_provider_auth(plan: DockerBottlePlan, target: str) -> None: + """Prepare Codex home state inside a Docker bottle. + + Every Codex bottle gets a minimal config.toml that trusts the + in-container workspace path. When host credentials are forwarded, + auth.json contains no real access or refresh token values; it only nudges Codex into the same user/device auth branch as the host. """ - if not plan.codex_auth_file: + if plan.agent_provider_template != "codex": return container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node") auth_dir = f"{container_home}/.codex" - auth_path = f"{auth_dir}/auth.json" subprocess.run( ["docker", "exec", "-u", "0", target, "mkdir", "-p", auth_dir], stdout=subprocess.DEVNULL, check=True, ) + subprocess.run( + ["docker", "exec", "-u", "0", target, "chown", "node:node", auth_dir], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", target, "chmod", "700", auth_dir], + stdout=subprocess.DEVNULL, + check=True, + ) + config_path = f"{auth_dir}/config.toml" + config = ( + f'[projects."{_CODEX_WORKSPACE}"]\n' + 'trust_level = "trusted"\n' + ) + subprocess.run( + [ + "docker", "exec", "-u", "0", target, + "sh", "-c", + f"printf %s {shlex.quote(config)} > {shlex.quote(config_path)}", + ], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", target, "chown", "node:node", config_path], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", target, "chmod", "600", config_path], + stdout=subprocess.DEVNULL, + check=True, + ) + + if not plan.codex_auth_file: + return + + auth_path = f"{auth_dir}/auth.json" subprocess.run( ["docker", "cp", str(plan.codex_auth_file), f"{target}:{auth_path}"], stdout=subprocess.DEVNULL, diff --git a/bot_bottle/backend/smolmachines/provision/provider_auth.py b/bot_bottle/backend/smolmachines/provision/provider_auth.py index e71c06f..1d50b5d 100644 --- a/bot_bottle/backend/smolmachines/provision/provider_auth.py +++ b/bot_bottle/backend/smolmachines/provision/provider_auth.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import shlex from ....log import die from .. import smolvm as _smolvm @@ -10,16 +11,18 @@ from ..bottle_plan import SmolmachinesBottlePlan _DEFAULT_GUEST_HOME = "/home/node" +_CODEX_WORKSPACE = "/home/node/workspace" def provision_provider_auth(plan: SmolmachinesBottlePlan, target: str) -> None: - """Copy a dummy Codex auth marker when host credentials are - forwarded through egress. + """Prepare Codex home state inside the smolmachine. - The real host access token remains in the egress bundle env; this - file only selects Codex's user/device auth code path. + Every Codex bottle gets a minimal config.toml that trusts the + in-guest workspace path. When host credentials are forwarded, the + real host access token remains in the egress bundle env; auth.json + only selects Codex's user/device auth code path. """ - if not plan.codex_auth_file: + if plan.agent_provider_template != "codex": return guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME) auth_dir = plan.guest_env.get("CODEX_HOME", f"{guest_home}/.codex") @@ -65,6 +68,29 @@ def provision_provider_auth(plan: SmolmachinesBottlePlan, target: str) -> None: detail = f": {detail}" die(f"codex host credentials: could not reset runtime db files{detail}") + config_path = f"{auth_dir}/config.toml" + config = ( + f'[projects."{_CODEX_WORKSPACE}"]\n' + 'trust_level = "trusted"\n' + ) + result = _smolvm.machine_exec( + target, + [ + "sh", "-c", + f"printf %s {shlex.quote(config)} > {shlex.quote(config_path)}", + ], + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + if detail: + detail = f": {detail}" + die(f"codex host credentials: could not write {config_path}{detail}") + _smolvm.machine_exec(target, ["chown", "node:node", config_path]) + _smolvm.machine_exec(target, ["chmod", "600", config_path]) + + if not plan.codex_auth_file: + return + auth_path = f"{auth_dir}/auth.json" _smolvm.machine_cp(str(plan.codex_auth_file), f"{target}:{auth_path}") _smolvm.machine_exec(target, ["chown", "node:node", auth_path]) diff --git a/tests/unit/test_docker_provision_provider_auth.py b/tests/unit/test_docker_provision_provider_auth.py index 2ac2e70..4541b1b 100644 --- a/tests/unit/test_docker_provision_provider_auth.py +++ b/tests/unit/test_docker_provision_provider_auth.py @@ -15,7 +15,11 @@ from bot_bottle.manifest import Manifest from bot_bottle.pipelock import PipelockProxyPlan -def _plan(*, codex_auth_file: Path | None = None) -> DockerBottlePlan: +def _plan( + *, + codex_auth_file: Path | None = None, + agent_provider_template: str = "codex", +) -> DockerBottlePlan: manifest = Manifest.from_json_obj({ "bottles": {"dev": {"agent_provider": {"template": "codex"}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, @@ -58,18 +62,46 @@ def _plan(*, codex_auth_file: Path | None = None) -> DockerBottlePlan: supervise_plan=None, use_runsc=False, agent_command="codex", - agent_provider_template="codex", + agent_provider_template=agent_provider_template, codex_auth_file=codex_auth_file, ) class TestProvisionProviderAuth(unittest.TestCase): - def test_noop_without_codex_auth_file(self): + def test_noop_for_non_codex_provider(self): + with patch.object(_provider_auth.subprocess, "run") as run: + _provider_auth.provision_provider_auth( + _plan(agent_provider_template="claude"), "bot-bottle-demo-abc12", + ) + self.assertEqual(0, run.call_count) + + def test_codex_provider_trusts_workspace_without_auth_file(self): with patch.object(_provider_auth.subprocess, "run") as run: _provider_auth.provision_provider_auth( _plan(), "bot-bottle-demo-abc12", ) - self.assertEqual(0, run.call_count) + argvs = [call.args[0] for call in run.call_args_list] + self.assertIn( + ["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", + "mkdir", "-p", "/home/node/.codex"], + argvs, + ) + trust_config = next( + a for a in argvs + if a[:6] == ["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", "sh"] + ) + self.assertIn('[projects."/home/node/workspace"]', trust_config[-1]) + self.assertIn('trust_level = "trusted"', trust_config[-1]) + self.assertIn( + ["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", + "chown", "node:node", "/home/node/.codex/config.toml"], + argvs, + ) + self.assertIn( + ["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", + "chmod", "600", "/home/node/.codex/config.toml"], + argvs, + ) def test_copies_dummy_auth_json_to_codex_home(self): with patch.object(_provider_auth.subprocess, "run") as run: @@ -83,6 +115,16 @@ class TestProvisionProviderAuth(unittest.TestCase): "mkdir", "-p", "/home/node/.codex"], argvs, ) + self.assertIn( + ["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", + "chown", "node:node", "/home/node/.codex"], + argvs, + ) + self.assertIn( + ["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", + "chmod", "700", "/home/node/.codex"], + argvs, + ) self.assertIn( ["docker", "cp", "/tmp/codex-auth.json", "bot-bottle-demo-abc12:/home/node/.codex/auth.json"], diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index 7af2035..5aa780f 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -57,6 +57,7 @@ def _plan( agent_git_gate_host: str = "127.0.0.1:55555", agent_supervise_url: str = "http://127.0.0.1:55556/", codex_auth_file: Path | None = None, + agent_provider_template: str = "claude", guest_env: dict[str, str] | None = None, ) -> SmolmachinesBottlePlan: bottle_json: dict = {} @@ -133,6 +134,7 @@ def _plan( agent_git_gate_host=agent_git_gate_host, agent_supervise_url=agent_supervise_url, codex_auth_file=codex_auth_file, + agent_provider_template=agent_provider_template, ) @@ -204,19 +206,45 @@ class TestProvisionProviderAuth(unittest.TestCase): ), ) - def test_noop_without_codex_auth_file(self): + def test_noop_for_non_codex_provider(self): cp_p, ex_p = self._patch() with cp_p as cp, ex_p as ex: _provider_auth.provision_provider_auth(_plan(), "bot-bottle-demo-abc12") self.assertEqual(0, cp.call_count) self.assertEqual(0, ex.call_count) + def test_codex_provider_trusts_workspace_without_auth_file(self): + cp_p, ex_p = self._patch() + with cp_p as cp, ex_p as ex: + ex.return_value = SmolvmRunResult(0, "", "") + _provider_auth.provision_provider_auth( + _plan(agent_provider_template="codex"), + "bot-bottle-demo-abc12", + ) + self.assertEqual(0, cp.call_count) + argv_seen = [call.args[1] for call in ex.call_args_list] + self.assertIn(["mkdir", "-p", "/home/node/.codex"], argv_seen) + trust_config = next( + a for a in argv_seen + if a[:2] == ["sh", "-c"] and "config.toml" in a[2] + ) + self.assertIn('[projects."/home/node/workspace"]', trust_config[2]) + self.assertIn('trust_level = "trusted"', trust_config[2]) + self.assertIn( + ["chown", "node:node", "/home/node/.codex/config.toml"], + argv_seen, + ) + self.assertIn(["chmod", "600", "/home/node/.codex/config.toml"], argv_seen) + def test_copies_dummy_auth_json_to_codex_home(self): cp_p, ex_p = self._patch() with cp_p as cp, ex_p as ex: ex.return_value = SmolvmRunResult(0, "Logged in using ChatGPT\n", "") _provider_auth.provision_provider_auth( - _plan(codex_auth_file=Path("/tmp/codex-auth.json")), + _plan( + agent_provider_template="codex", + codex_auth_file=Path("/tmp/codex-auth.json"), + ), "bot-bottle-demo-abc12", ) cp.assert_called_once_with( @@ -269,6 +297,7 @@ class TestProvisionProviderAuth(unittest.TestCase): ex.return_value = SmolvmRunResult(0, "Logged in using ChatGPT\n", "") _provider_auth.provision_provider_auth( _plan( + agent_provider_template="codex", codex_auth_file=Path("/tmp/codex-auth.json"), guest_env={"CODEX_HOME": "/run/codex-home"}, ), @@ -297,6 +326,7 @@ class TestProvisionProviderAuth(unittest.TestCase): with self.assertRaises(SystemExit): _provider_auth.provision_provider_auth( _plan( + agent_provider_template="codex", codex_auth_file=Path("/tmp/codex-auth.json"), ), "bot-bottle-demo-abc12", @@ -313,6 +343,9 @@ class TestProvisionProviderAuth(unittest.TestCase): SmolvmRunResult(0, "", ""), # chown CODEX_HOME SmolvmRunResult(0, "", ""), # chmod CODEX_HOME SmolvmRunResult(0, "", ""), # reset runtime db files + SmolvmRunResult(0, "", ""), # write config.toml + SmolvmRunResult(0, "", ""), # chown config.toml + SmolvmRunResult(0, "", ""), # chmod config.toml SmolvmRunResult(0, "", ""), # chown auth.json SmolvmRunResult(0, "", ""), # chmod auth.json SmolvmRunResult(1, "Not logged in\n", ""), # login status @@ -320,6 +353,7 @@ class TestProvisionProviderAuth(unittest.TestCase): with self.assertRaises(SystemExit): _provider_auth.provision_provider_auth( _plan( + agent_provider_template="codex", codex_auth_file=Path("/tmp/codex-auth.json"), ), "bot-bottle-demo-abc12",