From d5fcbe53ef746395dd460cf89b2812795dec29e4 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 2 Jun 2026 17:01:19 +0000 Subject: [PATCH] feat(workspace): port cwd across backends --- bot_bottle/backend/docker/launch.py | 2 +- bot_bottle/backend/docker/provision/git.py | 14 +++-- bot_bottle/backend/docker/util.py | 28 ++++----- bot_bottle/backend/smolmachines/backend.py | 6 ++ .../backend/smolmachines/provision/git.py | 16 ++--- .../smolmachines/provision/workspace.py | 36 ++++++++++++ tests/unit/test_docker_provision_git_user.py | 26 ++++++++- tests/unit/test_docker_util_image.py | 24 ++++++++ tests/unit/test_smolmachines_provision.py | 50 ++++++++++++++++ tests/unit/test_workspace.py | 58 +++++++++++++++++++ 10 files changed, 229 insertions(+), 31 deletions(-) create mode 100644 bot_bottle/backend/smolmachines/provision/workspace.py create mode 100644 tests/unit/test_workspace.py diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 9e28a00..800cf42 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -101,7 +101,7 @@ def launch( ) if plan.derived_image: docker_mod.build_image_with_cwd( - plan.derived_image, plan.image, plan.spec.user_cwd + plan.derived_image, plan.image, plan.workspace_plan ) # Networks: compose-managed. The names are derived diff --git a/bot_bottle/backend/docker/provision/git.py b/bot_bottle/backend/docker/provision/git.py index 59e738b..a0c7b22 100644 --- a/bot_bottle/backend/docker/provision/git.py +++ b/bot_bottle/backend/docker/provision/git.py @@ -3,7 +3,7 @@ Three concerns, all about git in the agent: 1. If --cwd was passed AND the host cwd has a .git, copy that .git - into /home/node/workspace/.git so the agent operates on the + into the planned guest workspace so the agent operates on the user's repo. 2. If the bottle declares `git` entries (PRD 0008), write a ~/.gitconfig with insteadOf rules so every git operation @@ -20,7 +20,6 @@ from __future__ import annotations import os import subprocess -from pathlib import Path from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig from ....log import info @@ -40,19 +39,22 @@ def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None: """If --cwd was set and the host cwd has a .git directory, copy it into /home/node/workspace/.git and fix ownership. No-op otherwise.""" - if not (plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir()): + workspace = plan.workspace_plan + if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir): return container = target - info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git") + guest_workspace_git = f"{workspace.guest_path}/.git" + host_git = str(workspace.host_path / ".git") + info(f"copying {host_git} -> {container}:{guest_workspace_git}") subprocess.run( - ["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"], + ["docker", "cp", host_git, f"{container}:{guest_workspace_git}"], stdout=subprocess.DEVNULL, check=True, ) subprocess.run( [ "docker", "exec", "-u", "0", container, - "chown", "-R", "node:node", "/home/node/workspace/.git", + "chown", "-R", workspace.owner, guest_workspace_git, ], stdout=subprocess.DEVNULL, check=True, diff --git a/bot_bottle/backend/docker/util.py b/bot_bottle/backend/docker/util.py index 0854f5b..f9ce729 100644 --- a/bot_bottle/backend/docker/util.py +++ b/bot_bottle/backend/docker/util.py @@ -10,6 +10,7 @@ import subprocess from typing import Iterable, Iterator from ...log import die, info +from ...workspace import WorkspacePlan # Cap on the suffix the container-name conflict logic will try before @@ -116,28 +117,23 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None: subprocess.run(args, check=True) -_TRUST_DIALOG_NODE_SCRIPT = ( - 'const fs=require("fs"),p=process.env.HOME+"/.claude.json",' - 'c=JSON.parse(fs.readFileSync(p,"utf8"));' - 'c.projects=c.projects||{};' - 'c.projects[process.env.HOME+"/workspace"]={hasTrustDialogAccepted:true};' - 'fs.writeFileSync(p,JSON.stringify(c,null,2));' -) - - -def build_image_with_cwd(derived: str, base: str, cwd: str) -> None: - """Build a thin derived image that copies into - /home/node/workspace and adds a trust-dialog entry for it.""" +def build_image_with_cwd( + derived: str, + base: str, + workspace: WorkspacePlan, +) -> None: + """Build a thin derived image that copies the workspace into + the plan's guest path and sets the plan's workdir.""" import os + cwd = str(workspace.host_path) if not os.path.isdir(cwd): die(f"cwd not found at {cwd}") - info(f"building image {derived} from {base} with {cwd} -> /home/node/workspace") + info(f"building image {derived} from {base} with {cwd} -> {workspace.guest_path}") dockerfile = ( f"FROM {base}\n" - f"COPY --chown=node:node . /home/node/workspace\n" - f"RUN node -e '{_TRUST_DIALOG_NODE_SCRIPT}'\n" - f"WORKDIR /home/node/workspace\n" + f"COPY --chown=node:node . {workspace.guest_path}\n" + f"WORKDIR {workspace.workdir}\n" ) subprocess.run( ["docker", "build", "-t", derived, "-f", "-", cwd], diff --git a/bot_bottle/backend/smolmachines/backend.py b/bot_bottle/backend/smolmachines/backend.py index bc3ab65..cfa36a7 100644 --- a/bot_bottle/backend/smolmachines/backend.py +++ b/bot_bottle/backend/smolmachines/backend.py @@ -22,6 +22,7 @@ from .provision import prompt as _prompt from .provision import provider_auth as _provider_auth from .provision import skills as _skills from .provision import supervise as _supervise +from .provision import workspace as _workspace class SmolmachinesBottleBackend( @@ -72,6 +73,11 @@ class SmolmachinesBottleBackend( ) -> None: _skills.provision_skills(plan, target) + def provision_workspace( + self, plan: SmolmachinesBottlePlan, target: str + ) -> None: + _workspace.provision_workspace(plan, target) + def provision_git( self, plan: SmolmachinesBottlePlan, target: str ) -> None: diff --git a/bot_bottle/backend/smolmachines/provision/git.py b/bot_bottle/backend/smolmachines/provision/git.py index 017798d..88d9918 100644 --- a/bot_bottle/backend/smolmachines/provision/git.py +++ b/bot_bottle/backend/smolmachines/provision/git.py @@ -4,7 +4,7 @@ Three concerns, all about git in the agent: 1. If --cwd was passed AND the host cwd has a .git, copy that - .git into /home/node/workspace/.git so the agent operates on + .git into the planned guest workspace so the agent operates on the user's repo. 2. If the bottle declares `git` entries (PRD 0008), write a ~/.gitconfig with insteadOf rules so every git operation @@ -58,20 +58,22 @@ def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None: """If --cwd was set and the host cwd has a .git directory, copy it into /workspace/.git and fix ownership. No-op otherwise.""" - if not (plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir()): + workspace = plan.workspace_plan + if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir): return - guest_workspace_git = f"{_guest_home()}/workspace/.git" - info(f"copying {plan.spec.user_cwd}/.git -> {target}:{guest_workspace_git}") + guest_workspace_git = f"{workspace.guest_path}/.git" + host_git = str(workspace.host_path / ".git") + info(f"copying {host_git} -> {target}:{guest_workspace_git}") # mkdir -p the workspace dir so `machine cp` lands the .git # directly there even on first-time bottles. - _smolvm.machine_exec(target, ["mkdir", "-p", f"{_guest_home()}/workspace"]) + _smolvm.machine_exec(target, ["mkdir", "-p", workspace.guest_path]) _smolvm.machine_cp( - f"{plan.spec.user_cwd}/.git", f"{target}:{guest_workspace_git}", + host_git, f"{target}:{guest_workspace_git}", ) # `machine cp` lands files as root; the agent runs as node so # the workspace tree must be chowned over. _smolvm.machine_exec( - target, ["chown", "-R", "node:node", guest_workspace_git], + target, ["chown", "-R", workspace.owner, guest_workspace_git], ) diff --git a/bot_bottle/backend/smolmachines/provision/workspace.py b/bot_bottle/backend/smolmachines/provision/workspace.py new file mode 100644 index 0000000..8cd2f91 --- /dev/null +++ b/bot_bottle/backend/smolmachines/provision/workspace.py @@ -0,0 +1,36 @@ +"""Copy the operator workspace into a smolmachines guest.""" + +from __future__ import annotations + +import shlex + +from ....log import info +from .. import smolvm as _smolvm +from ..bottle_plan import SmolmachinesBottlePlan + + +def provision_workspace(plan: SmolmachinesBottlePlan, target: str) -> None: + """Copy host cwd contents to the planned guest workspace.""" + workspace = plan.workspace_plan + if not (workspace.enabled and workspace.copy_contents): + return + + guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/" + guest_path_q = shlex.quote(workspace.guest_path) + guest_parent_q = shlex.quote(guest_parent) + owner_q = shlex.quote(workspace.owner) + mode_q = shlex.quote(workspace.mode) + info(f"copying {workspace.host_path} -> {target}:{workspace.guest_path}") + _smolvm.machine_exec( + target, + ["sh", "-c", f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}"], + ) + _smolvm.machine_cp(str(workspace.host_path), f"{target}:{workspace.guest_path}") + _smolvm.machine_exec( + target, + [ + "sh", "-c", + f"chown -R {owner_q} {guest_path_q} && " + f"chmod {mode_q} {guest_path_q}", + ], + ) diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index 63fb4e7..763e4c1 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -25,6 +25,8 @@ from bot_bottle.workspace import workspace_plan def _plan(*, git_user: dict | None = None, + copy_cwd: bool = False, + user_cwd: str = "/tmp/x", stage_dir: Path | None = None) -> DockerBottlePlan: bottle_json: dict = {} if git_user is not None: @@ -35,7 +37,7 @@ def _plan(*, git_user: dict | None = None, }) spec = BottleSpec( manifest=manifest, agent_name="demo", - copy_cwd=False, user_cwd="/tmp/x", + copy_cwd=copy_cwd, user_cwd=user_cwd, ) return DockerBottlePlan( spec=spec, @@ -108,6 +110,28 @@ class TestProvisionGitUser(unittest.TestCase): ) self.assertEqual([], _git_config_calls(run)) + def test_copies_cwd_git_to_workspace_plan_path(self): + cwd = self.stage / "cwd" + (cwd / ".git").mkdir(parents=True) + plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage) + with patch.object(_git.subprocess, "run") as run: + _git._provision_cwd_git(plan, "bot-bottle-demo-abc12") + + self.assertEqual( + [ + "docker", "cp", f"{cwd}/.git", + "bot-bottle-demo-abc12:/home/node/workspace/.git", + ], + run.call_args_list[0].args[0], + ) + self.assertEqual( + [ + "docker", "exec", "-u", "0", "bot-bottle-demo-abc12", + "chown", "-R", "node:node", "/home/node/workspace/.git", + ], + run.call_args_list[1].args[0], + ) + def test_sets_name_and_email(self): plan = _plan( git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"}, diff --git a/tests/unit/test_docker_util_image.py b/tests/unit/test_docker_util_image.py index 78b92a5..a230290 100644 --- a/tests/unit/test_docker_util_image.py +++ b/tests/unit/test_docker_util_image.py @@ -8,10 +8,13 @@ integration smoke.""" from __future__ import annotations import subprocess +import tempfile import unittest +from pathlib import Path from unittest.mock import patch from bot_bottle.backend.docker import util as docker_mod +from bot_bottle.workspace import WorkspacePlan def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: @@ -67,5 +70,26 @@ class TestSave(unittest.TestCase): ) +class TestBuildImageWithCwd(unittest.TestCase): + def test_uses_workspace_plan_paths(self): + with tempfile.TemporaryDirectory(prefix="bb-docker-cwd.") as tmp: + workspace = WorkspacePlan( + enabled=True, + host_path=Path(tmp), + guest_home="/guest/home", + guest_path="/guest/home/workspace", + workdir="/guest/home/workspace", + ) + with patch.object(docker_mod.subprocess, "run") as run: + docker_mod.build_image_with_cwd("derived:tag", "base:tag", workspace) + + argv = run.call_args.args[0] + dockerfile = run.call_args.kwargs["input"] + self.assertEqual(["docker", "build", "-t", "derived:tag", "-f", "-", tmp], argv) + self.assertIn("FROM base:tag\n", dockerfile) + self.assertIn("COPY --chown=node:node . /guest/home/workspace\n", dockerfile) + self.assertIn("WORKDIR /guest/home/workspace\n", dockerfile) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index 866ed43..a6a2707 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -30,6 +30,7 @@ from bot_bottle.backend.smolmachines.provision import ( provider_auth as _provider_auth, skills as _skills, supervise as _supervise, + workspace as _workspace, ) from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec from bot_bottle.backend.smolmachines.smolvm import SmolvmRunResult @@ -848,6 +849,55 @@ class TestProvisionGitUser(unittest.TestCase): self.assertEqual(["user.email", "bot@example.com"], calls[0][0][7:]) +class TestProvisionWorkspace(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-workspace.") + self.stage = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def test_noop_when_copy_cwd_false(self): + plan = _plan(copy_cwd=False, stage_dir=self.stage) + with patch( + "bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_cp" + ) as cp, patch( + "bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_exec" + ) as ex: + _workspace.provision_workspace(plan, "bot-bottle-demo-abc12") + cp.assert_not_called() + ex.assert_not_called() + + def test_copies_workspace_to_plan_path_and_chowns(self): + cwd = self.stage / "cwd" + cwd.mkdir() + plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage) + with patch( + "bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_cp" + ) as cp, patch( + "bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_exec" + ) as ex: + _workspace.provision_workspace(plan, "bot-bottle-demo-abc12") + + cp.assert_called_once_with( + str(cwd), + "bot-bottle-demo-abc12:/home/node/workspace", + ) + argvs = [c.args[1] for c in ex.call_args_list] + self.assertIn( + ["sh", "-c", "rm -rf /home/node/workspace && mkdir -p /home/node"], + argvs, + ) + self.assertIn( + [ + "sh", "-c", + "chown -R node:node /home/node/workspace && " + "chmod 755 /home/node/workspace", + ], + argvs, + ) + + class TestProvisionSupervise(unittest.TestCase): def test_noop_when_supervise_not_enabled(self): with patch( diff --git a/tests/unit/test_workspace.py b/tests/unit/test_workspace.py new file mode 100644 index 0000000..560aed0 --- /dev/null +++ b/tests/unit/test_workspace.py @@ -0,0 +1,58 @@ +"""Unit: backend-neutral workspace planning.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from bot_bottle.backend import BottleSpec +from bot_bottle.manifest import Manifest +from bot_bottle.workspace import workspace_plan + + +def _spec(*, copy_cwd: bool, user_cwd: str) -> BottleSpec: + manifest = Manifest.from_json_obj({ + "bottles": {"dev": {}}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + return BottleSpec( + manifest=manifest, + agent_name="demo", + copy_cwd=copy_cwd, + user_cwd=user_cwd, + ) + + +class TestWorkspacePlan(unittest.TestCase): + def test_disabled_uses_guest_home_as_workdir(self): + plan = workspace_plan( + _spec(copy_cwd=False, user_cwd="/tmp/project"), + guest_home="/home/node", + ) + self.assertFalse(plan.enabled) + self.assertEqual("/home/node", plan.guest_path) + self.assertEqual("/home/node", plan.workdir) + + def test_enabled_uses_workspace_under_guest_home(self): + plan = workspace_plan( + _spec(copy_cwd=True, user_cwd="/tmp/project"), + guest_home="/guest/home", + ) + self.assertTrue(plan.enabled) + self.assertEqual(Path("/tmp/project"), plan.host_path) + self.assertEqual("/guest/home/workspace", plan.guest_path) + self.assertEqual("/guest/home/workspace", plan.workdir) + + def test_detects_host_git_dir(self): + with tempfile.TemporaryDirectory(prefix="bb-workspace.") as tmp: + Path(tmp, ".git").mkdir() + plan = workspace_plan( + _spec(copy_cwd=True, user_cwd=tmp), + guest_home="/home/node", + ) + self.assertTrue(plan.has_host_git_dir) + + +if __name__ == "__main__": + unittest.main()