diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 17f1996..2bca283 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -226,24 +226,10 @@ class AgentProvider(ABC): def provision_git(self, bottle: "Bottle", plan: "BottlePlan") -> None: """Configure git inside the agent container. - Default: Debian/node — copies .git when --cwd is set, writes the - git-gate insteadOf gitconfig, sets user.name/email as node. - Override for images that run as a different user or use a - non-standard home directory.""" + 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 - # FIXME: re-enable workspace planning - # workspace = plan.workspace_plan - # if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir: - # guest_workspace_git = f"{workspace.guest_path}/.git" - # host_git = str(workspace.host_path / ".git") - # info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}") - # bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root") - # bottle.cp_in(host_git, guest_workspace_git) - # bottle.exec( - # f"chown -R {shlex.quote(workspace.owner)} " - # f"{shlex.quote(guest_workspace_git)}", - # user="root", - # ) manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) if manifest_bottle.git: diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index f7766f2..28dbc7f 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -32,6 +32,7 @@ manifest does not carry a backend field; the host picks. from __future__ import annotations import os +import shlex import sys from abc import ABC, abstractmethod from contextlib import AbstractContextManager @@ -47,7 +48,7 @@ from ..manifest import ManifestGitEntry, Manifest from ..supervise import SupervisePlan from ..util import expand_tilde from ..env import resolve_env, ResolvedEnv -# from ..workspace import WorkspacePlan +from ..workspace import WorkspacePlan, workspace_plan from .print_util import print_multi, visible_agent_env_names from .util import host_skill_dir @@ -101,7 +102,10 @@ class BottlePlan(ABC): egress_plan: EgressPlan supervise_plan: SupervisePlan | None agent_provision: AgentProvisionPlan - # workspace_plan: WorkspacePlan + + @property + def workspace_plan(self) -> WorkspacePlan: + return workspace_plan(self.spec, guest_home=self.guest_home) def print(self, *, remote_control: bool) -> None: """Render the y/N preflight summary to stderr.""" @@ -293,6 +297,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): manifest_agent_provider = manifest_bottle.agent_provider agent_provider = get_provider(manifest_agent_provider.template) resolved_env = resolve_env(manifest, spec.agent_name) + workspace = workspace_plan(spec, guest_home=agent_provider.guest_home) slug = mint_slug(spec) write_launch_metadata(slug, spec, compose_project="", backend=self.name) @@ -319,7 +324,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): forward_host_credentials=manifest_agent_provider.forward_host_credentials, auth_token=manifest_agent_provider.auth_token, host_env=dict(os.environ), - # trusted_project_path=workspace_plan.workdir, + trusted_project_path=workspace.workdir, label=spec.label, color=spec.color, ) @@ -448,7 +453,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): prompt_path = provider.provision_prompt(plan, bottle) provider.provision(plan, bottle) provider.provision_skills(plan, bottle) - # self.provision_workspace(plan, bottle) + self.provision_workspace(plan, bottle) provider.provision_git(bottle, plan) provider.provision_supervise_mcp( plan, bottle, self.supervise_mcp_url(plan), @@ -456,9 +461,30 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): return prompt_path def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None: - """Copy the operator workspace into the running bottle when - the backend cannot bake it into the agent image. Default is - no-op for backends like Docker that handle this before launch.""" + """Copy the operator workspace into the running bottle. + + This is the only supported workspace-provisioning path: Docker + does not build a derived image containing the current + 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 = shlex.quote(workspace.guest_path) + guest_parent = shlex.quote(guest_parent) + owner = shlex.quote(workspace.owner) + mode = shlex.quote(workspace.mode) + info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}") + bottle.exec( + f"rm -rf {guest_path} && mkdir -p {guest_parent}", + user="root", + ) + bottle.cp_in(str(workspace.host_path), workspace.guest_path) + bottle.exec( + f"chown -R {owner} {guest_path} && chmod {mode} {guest_path}", + user="root", + ) def supervise_mcp_url(self, plan: PlanT) -> str: """Return the agent-side URL of the per-bottle supervise diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index d90edf7..11380d4 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -4,8 +4,8 @@ PRD 0018 chunk 3: each instance is one `docker compose` project. The flow is: - 1. Build the agent's base + derived image (compose builds the - sidecar images via the `build:` directive on first up). + 1. Build the agent image from the provider Dockerfile (compose + builds the sidecar images via the `build:` directive on first up). 2. Mint the per-bottle egress CA (chunk 2 writes it under state//egress/). 3. Populate the inner plans with launch-time fields so the @@ -15,8 +15,8 @@ The flow is: 7. `docker compose up -d` (token + OAuth values flow into the compose subprocess env so `environment: [NAME]` bare-name entries inherit without rendering values into the file). - 8. Provision (CA install, prompt copy, skills, git, supervise - config) — unchanged, uses `docker exec`. + 8. Provision (CA install, prompt copy, skills, workspace, git, + supervise config) — unchanged, uses `docker exec` / `docker cp`. 9. Yield a DockerBottle handle. `exec_agent` runs claude via `docker exec -it` exactly like the pre-compose world. diff --git a/bot_bottle/backend/docker/resolve_plan.py b/bot_bottle/backend/docker/resolve_plan.py index c38cb69..f07ecec 100644 --- a/bot_bottle/backend/docker/resolve_plan.py +++ b/bot_bottle/backend/docker/resolve_plan.py @@ -56,5 +56,4 @@ def resolve_plan( supervise_plan=supervise_plan, use_runsc=use_runsc, agent_provision=agent_provision_plan, - # workspace_plan=workspace_plan, ) diff --git a/bot_bottle/backend/smolmachines/backend.py b/bot_bottle/backend/smolmachines/backend.py index 41e1aca..4cd7fef 100644 --- a/bot_bottle/backend/smolmachines/backend.py +++ b/bot_bottle/backend/smolmachines/backend.py @@ -27,7 +27,6 @@ from . import smolvm as _smolvm from .bottle import SmolmachinesBottle from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan from .bottle_plan import SmolmachinesBottlePlan -# from .provision import workspace as _workspace class SmolmachinesBottleBackend( @@ -82,11 +81,6 @@ class SmolmachinesBottleBackend( with _launch.launch(plan, provision=self.provision) as bottle: yield bottle - # def provision_workspace( - # self, plan: SmolmachinesBottlePlan, bottle: Bottle - # ) -> None: - # _workspace.provision_workspace(plan, bottle) - def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str: """The smolmachines guest reaches the supervise sidecar via a host-published random port the launch step pinned earlier diff --git a/bot_bottle/backend/smolmachines/provision/__init__.py b/bot_bottle/backend/smolmachines/provision/__init__.py index fa581ad..9dd739e 100644 --- a/bot_bottle/backend/smolmachines/provision/__init__.py +++ b/bot_bottle/backend/smolmachines/provision/__init__.py @@ -6,9 +6,7 @@ the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git provisioning also moved to the AgentProvider ABC (with Debian/node defaults); user plugins override them for non-standard images. -The module left in this subpackage handles the remaining backend- -specific step: - - - workspace.py — copy the operator workspace into the guest - (currently commented out — workspace planning is disabled) +No modules remain in this subpackage. Workspace copying now runs +through `BottleBackend.provision_workspace` against the running +bottle for every backend. """ diff --git a/bot_bottle/backend/smolmachines/provision/workspace.py b/bot_bottle/backend/smolmachines/provision/workspace.py deleted file mode 100644 index b25a6df..0000000 --- a/bot_bottle/backend/smolmachines/provision/workspace.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Copy the operator workspace into a smolmachines guest. - -DISABLED — workspace planning is currently commented out at the -BottlePlan level. This module is kept as a placeholder for when -workspace support is re-enabled. -""" - -# from __future__ import annotations -# -# import shlex -# -# from ....log import info -# from ... import Bottle -# from ..bottle_plan import SmolmachinesBottlePlan -# -# -# def provision_workspace(plan: SmolmachinesBottlePlan, bottle: Bottle) -> 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} -> {bottle.name}:{workspace.guest_path}") -# bottle.exec( -# f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}", -# user="root", -# ) -# bottle.cp_in(str(workspace.host_path), workspace.guest_path) -# bottle.exec( -# f"chown -R {owner_q} {guest_path_q} && chmod {mode_q} {guest_path_q}", -# user="root", -# ) diff --git a/bot_bottle/backend/smolmachines/resolve_plan.py b/bot_bottle/backend/smolmachines/resolve_plan.py index ebda2b9..e49eece 100644 --- a/bot_bottle/backend/smolmachines/resolve_plan.py +++ b/bot_bottle/backend/smolmachines/resolve_plan.py @@ -18,8 +18,6 @@ from ...agent_provider import AgentProvisionPlan from ...egress import EgressPlan from ...supervise import SupervisePlan from ...git_gate import GitGatePlan - -# from ...workspace import workspace_plan as resolve_workspace_plan from .bottle_plan import SmolmachinesBottlePlan from .util import smolmachines_bundle_subnet, smolmachines_preflight @@ -79,5 +77,4 @@ def resolve_plan( egress_plan=egress_plan, supervise_plan=supervise_plan, agent_provision=agent_provision_plan, - # workspace_plan=workspace_plan, ) diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 70c7dd2..13f91cd 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -39,7 +39,7 @@ from . import tui def cmd_start(argv: list[str]) -> int: parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True) parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image") + parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle") parser.add_argument("--remote-control", action="store_true") parser.add_argument( "--backend", diff --git a/tests/unit/test_backend_workspace.py b/tests/unit/test_backend_workspace.py new file mode 100644 index 0000000..a9f8443 --- /dev/null +++ b/tests/unit/test_backend_workspace.py @@ -0,0 +1,156 @@ +"""Unit: runtime workspace provisioning. + +Workspace copy is intentionally handled through +`BottleBackend.provision_workspace` against a running bottle. The +Docker derived-image workspace path stays disabled. +""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +from bot_bottle import bottle_state +from bot_bottle import supervise +from bot_bottle.backend import Bottle, BottleSpec, ExecResult +from bot_bottle.backend.docker import DockerBottleBackend +from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend +from bot_bottle.manifest import Manifest + + +def _manifest() -> Manifest: + return Manifest.from_json_obj({ + "bottles": {"dev": {}}, + "agents": { + "demo": { + "bottle": "dev", + "skills": [], + "prompt": "", + }, + }, + }) + + +def _spec(tmp: Path, *, copy_cwd: bool = True, identity: str = "demo-work") -> BottleSpec: + return BottleSpec( + manifest=_manifest(), + agent_name="demo", + copy_cwd=copy_cwd, + user_cwd=str(tmp), + identity=identity, + ) + + +def _bottle() -> MagicMock: + bottle = MagicMock(spec=Bottle) + bottle.name = "bot-bottle-demo-work" + bottle.exec.return_value = ExecResult(returncode=0, stdout="", stderr="") + return bottle + + +class _FakeStateMixin: + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.") + self.tmp = Path(self.tmp_dir.name) + self.root = self.tmp / ".bot-bottle" + self.original_root = supervise.bot_bottle_root + supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment] + + def tearDown(self) -> None: + supervise.bot_bottle_root = self.original_root # type: ignore[assignment] + self.tmp_dir.cleanup() + + +class TestRuntimeWorkspaceProvisioning(_FakeStateMixin, unittest.TestCase): + def test_default_backend_method_copies_workspace_to_running_bottle(self) -> None: + (self.tmp / "src.txt").write_text("hello\n") + (self.tmp / ".git").mkdir() + backend = DockerBottleBackend() + + with ( + patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"), + patch( + "bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available", + return_value=False, + ), + ): + plan = backend.prepare(_spec(self.tmp), self.tmp / "stage") + + bottle = _bottle() + backend.provision_workspace(plan, bottle) + + self.assertEqual( + [ + call( + "rm -rf /home/node/workspace && mkdir -p /home/node", + user="root", + ), + call( + "chown -R node:node /home/node/workspace && " + "chmod 755 /home/node/workspace", + user="root", + ), + ], + bottle.exec.call_args_list, + ) + bottle.cp_in.assert_called_once_with(str(self.tmp), "/home/node/workspace") + + def test_default_backend_method_noops_without_copy_cwd(self) -> None: + backend = DockerBottleBackend() + with ( + patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"), + patch( + "bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available", + return_value=False, + ), + ): + plan = backend.prepare(_spec(self.tmp, copy_cwd=False), self.tmp / "stage") + + bottle = _bottle() + backend.provision_workspace(plan, bottle) + + bottle.exec.assert_not_called() + bottle.cp_in.assert_not_called() + + def test_smolmachines_uses_same_running_bottle_method(self) -> None: + backend = SmolmachinesBottleBackend() + with patch( + "bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight", + ): + plan = backend.prepare( + _spec(self.tmp, identity="demo-smol-work"), + self.tmp / "stage", + ) + + bottle = _bottle() + backend.provision_workspace(plan, bottle) + + bottle.cp_in.assert_called_once_with(str(self.tmp), "/home/node/workspace") + metadata = bottle_state.read_metadata("demo-smol-work") + self.assertIsNotNone(metadata) + self.assertEqual("smolmachines", metadata.backend) + + +class TestWorkspaceTrustPath(_FakeStateMixin, unittest.TestCase): + def test_prepare_trusts_workspace_path_when_copying_cwd(self) -> None: + backend = DockerBottleBackend() + + with ( + patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"), + patch( + "bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available", + return_value=False, + ), + ): + plan = backend.prepare(_spec(self.tmp), self.tmp / "stage") + + claude_config = self.root / "state" / "demo-work" / "agent" / "claude.json" + config = claude_config.read_text() + self.assertIn('"/home/node/workspace"', config) + self.assertEqual("/home/node/workspace", plan.workspace_plan.workdir) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index c661c53..3d4b0cd 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -33,7 +33,6 @@ from bot_bottle.egress import ( from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import Manifest from bot_bottle.supervise import SupervisePlan -# from bot_bottle.workspace import workspace_plan SLUG = "demo-abc12" diff --git a/tests/unit/test_contrib_claude_provider.py b/tests/unit/test_contrib_claude_provider.py index 8e4788d..df37fec 100644 --- a/tests/unit/test_contrib_claude_provider.py +++ b/tests/unit/test_contrib_claude_provider.py @@ -24,7 +24,6 @@ from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest from bot_bottle.supervise import SupervisePlan -# from bot_bottle.workspace import workspace_plan _URL = "http://supervise:9100/" diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index ee77b1c..e0ab6fc 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -25,7 +25,6 @@ from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest from bot_bottle.supervise import SupervisePlan -# from bot_bottle.workspace import workspace_plan _URL = "http://supervise:9100/" diff --git a/tests/unit/test_docker_launch_teardown.py b/tests/unit/test_docker_launch_teardown.py index cff5ccf..58b9c83 100644 --- a/tests/unit/test_docker_launch_teardown.py +++ b/tests/unit/test_docker_launch_teardown.py @@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -# from bot_bottle.workspace import workspace_plan def _manifest() -> Manifest: diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index 33d6917..5513fc5 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -# from bot_bottle.workspace import workspace_plan class _Provider(AgentProvider): @@ -124,10 +123,6 @@ class TestProvisionGitUser(unittest.TestCase): _PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage)) self.assertEqual([], _git_config_exec_calls(bottle)) - # def test_copies_cwd_git_to_workspace_plan_path(self): - # # DISABLED — workspace planning is currently commented out. - # pass - def test_sets_name_and_email(self): plan = _plan( git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"}, diff --git a/tests/unit/test_plan_print_parity.py b/tests/unit/test_plan_print_parity.py index 9e033f9..3001fc3 100644 --- a/tests/unit/test_plan_print_parity.py +++ b/tests/unit/test_plan_print_parity.py @@ -20,7 +20,6 @@ from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import Manifest -# from bot_bottle.workspace import workspace_plan def _manifest() -> Manifest: diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index b920790..86ce6c7 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -35,7 +35,6 @@ from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import ManifestGitEntry, Manifest from bot_bottle.supervise import SupervisePlan -# from bot_bottle.workspace import workspace_plan class _Provider(AgentProvider): @@ -337,9 +336,7 @@ class TestSmolmachinesBottleExec(unittest.TestCase): class TestProvisionGit(unittest.TestCase): - """provision_git dispatches two independent passes (cwd .git - copy + gitconfig insteadOf write); each no-ops on its own - when its condition doesn't hold.""" + """provision_git writes gitconfig insteadOf rules when configured.""" def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") # pylint: disable=consider-using-with @@ -354,14 +351,6 @@ class TestProvisionGit(unittest.TestCase): bottle.cp_in.assert_not_called() bottle.exec.assert_not_called() - # def test_copies_cwd_git_when_copy_cwd_and_git_present(self): - # # DISABLED — workspace planning is currently commented out. - # pass - - # def test_skips_cwd_when_copy_cwd_false(self): - # # DISABLED — workspace planning is currently commented out. - # pass - def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self): # Smolmachines's TSI-allowlisted guest dials git-gate via # smart HTTP at `127.0.0.1:` — the bundle's @@ -481,10 +470,5 @@ class TestProvisionGitUser(unittest.TestCase): self.assertIn("bot@example.com", calls[0][0]) -# class TestProvisionWorkspace(unittest.TestCase): -# # DISABLED — workspace planning / provision_workspace are commented out. -# pass - - if __name__ == "__main__": unittest.main()