From 5308d53288b3ed16b691fa1421f65419851736fe Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 2 Jun 2026 16:56:57 +0000 Subject: [PATCH] feat(workspace): add shared workspace plan --- bot_bottle/backend/__init__.py | 10 +++- bot_bottle/backend/docker/prepare.py | 6 ++- bot_bottle/backend/smolmachines/prepare.py | 6 ++- bot_bottle/workspace.py | 52 +++++++++++++++++++ tests/unit/test_compose.py | 5 +- tests/unit/test_docker_provision_git_user.py | 2 + .../test_docker_provision_provider_auth.py | 15 +++--- tests/unit/test_plan_print_parity.py | 3 ++ tests/unit/test_smolmachines_provision.py | 2 + 9 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 bot_bottle/workspace.py diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index b519246..869f0df 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -46,6 +46,7 @@ from ..log import die, info from ..manifest import GitEntry, Manifest from ..supervise import SupervisePlan from ..util import expand_tilde +from ..workspace import WorkspacePlan from .print_util import print_multi, visible_agent_env_names from .util import host_skill_dir @@ -79,6 +80,7 @@ class BottlePlan(ABC): egress_plan: EgressPlan supervise_plan: SupervisePlan | None agent_provision: AgentProvisionPlan + workspace_plan: WorkspacePlan def print(self, *, remote_control: bool) -> None: """Render the y/N preflight summary to stderr.""" @@ -320,7 +322,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): decide whether to add provider-specific prompt args to the agent's argv. - Default orchestration: ca → prompt → skills → git → + Default orchestration: ca → prompt → skills → workspace → git → supervise. CA install runs first so the agent's trust store is rebuilt before anything inside the agent makes a TLS call. Subclasses typically don't override this; they implement the @@ -335,6 +337,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): prompt_path = self.provision_prompt(plan, target) self.provision_provider_auth(plan, target) self.provision_skills(plan, target) + self.provision_workspace(plan, target) self.provision_git(plan, target) self.provision_supervise(plan, target) return prompt_path @@ -365,6 +368,11 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): """Copy the agent's named skills from the host into the running bottle. No-op when the agent has no skills.""" + def provision_workspace(self, plan: PlanT, target: str) -> 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.""" + @abstractmethod def provision_git(self, plan: PlanT, target: str) -> None: """Copy the host's cwd `.git` directory into the running diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py index 20de072..dca847e 100644 --- a/bot_bottle/backend/docker/prepare.py +++ b/bot_bottle/backend/docker/prepare.py @@ -22,6 +22,7 @@ from ...git_gate import GitGate from ...log import die from ...pipelock import PipelockProxy from ...supervise import Supervise +from ...workspace import workspace_plan as resolve_workspace_plan from .. import BottleSpec from . import util as docker_mod from .bottle_plan import DockerBottlePlan @@ -62,6 +63,8 @@ def resolve_plan( bottle = manifest.bottle_for(spec.agent_name) provider = bottle.agent_provider provider_runtime = runtime_for(provider.template) + guest_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node") + workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home) # PRD 0016 follow-up: identity, not bare slug. A fresh `start` # mints a random-suffixed identity (so parallel runs of the same @@ -177,7 +180,7 @@ def resolve_plan( template=provider.template, dockerfile=dockerfile_path, state_dir=agent_dir, - guest_home=os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node"), + guest_home=guest_home, forward_host_credentials=provider.forward_host_credentials, auth_token=provider.auth_token, host_env=dict(os.environ), @@ -245,6 +248,7 @@ def resolve_plan( supervise_plan=supervise_plan, use_runsc=use_runsc, agent_provision=agent_provision, + workspace_plan=workspace_plan, ) diff --git a/bot_bottle/backend/smolmachines/prepare.py b/bot_bottle/backend/smolmachines/prepare.py index 3f29c0c..2c6393a 100644 --- a/bot_bottle/backend/smolmachines/prepare.py +++ b/bot_bottle/backend/smolmachines/prepare.py @@ -32,6 +32,7 @@ from ...env import resolve_env from ...git_gate import GitGate from ...pipelock import PipelockProxy from ...supervise import Supervise +from ...workspace import workspace_plan as resolve_workspace_plan from .bottle_plan import SmolmachinesBottlePlan from .util import smolmachines_bundle_subnet, smolmachines_preflight @@ -60,6 +61,8 @@ def resolve_plan( bottle = manifest.bottle_for(spec.agent_name) provider = bottle.agent_provider provider_runtime = runtime_for(provider.template) + guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node") + workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home) slug = spec.identity or bottle_identity(spec.agent_name) @@ -130,7 +133,7 @@ def resolve_plan( template=provider.template, dockerfile=agent_dockerfile_path, state_dir=agent_dir, - guest_home=os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node"), + guest_home=guest_home, guest_env=guest_env, forward_host_credentials=provider.forward_host_credentials, auth_token=provider.auth_token, @@ -181,6 +184,7 @@ def resolve_plan( egress_plan=egress_plan, supervise_plan=supervise_plan, agent_provision=agent_provision, + workspace_plan=workspace_plan, ) diff --git a/bot_bottle/workspace.py b/bot_bottle/workspace.py new file mode 100644 index 0000000..a762175 --- /dev/null +++ b/bot_bottle/workspace.py @@ -0,0 +1,52 @@ +"""Backend-neutral plan for porting the operator workspace.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + + +WORKSPACE_DIRNAME = "workspace" +DEFAULT_WORKSPACE_OWNER = "node:node" +DEFAULT_WORKSPACE_MODE = "755" + + +class WorkspaceSpec(Protocol): + copy_cwd: bool + user_cwd: str + + +@dataclass(frozen=True) +class WorkspacePlan: + """Resolved workspace contract shared by all bottle backends.""" + + enabled: bool + host_path: Path + guest_home: str + guest_path: str + workdir: str + owner: str = DEFAULT_WORKSPACE_OWNER + mode: str = DEFAULT_WORKSPACE_MODE + copy_contents: bool = True + copy_git: bool = True + has_host_git_dir: bool = False + + +def workspace_plan(spec: WorkspaceSpec, *, guest_home: str) -> WorkspacePlan: + """Resolve the in-bottle workspace path from CLI intent.""" + host_path = Path(spec.user_cwd).expanduser() + if spec.copy_cwd: + guest_path = f"{guest_home.rstrip('/')}/{WORKSPACE_DIRNAME}" + workdir = guest_path + else: + guest_path = guest_home + workdir = guest_home + return WorkspacePlan( + enabled=spec.copy_cwd, + host_path=host_path, + guest_home=guest_home, + guest_path=guest_path, + workdir=workdir, + has_host_git_dir=(host_path / ".git").is_dir(), + ) diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 18c1789..95f0a5f 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -33,6 +33,7 @@ from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import Manifest from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.supervise import SupervisePlan +from bot_bottle.workspace import workspace_plan SLUG = "demo-abc12" @@ -163,8 +164,9 @@ def _plan( roles=(), ),) + spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress) return DockerBottlePlan( - spec=_spec(supervise=supervise, with_git=with_git, with_egress=with_egress), + spec=spec, stage_dir=STAGE, slug=SLUG, container_name=f"bot-bottle-{SLUG}", @@ -189,6 +191,7 @@ def _plan( dockerfile="", guest_env={}, ), + workspace_plan=workspace_plan(spec, guest_home="/home/node"), ) diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index 6b9f6fd..63fb4e7 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -21,6 +21,7 @@ from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest from bot_bottle.pipelock import PipelockProxyPlan +from bot_bottle.workspace import workspace_plan def _plan(*, git_user: dict | None = None, @@ -75,6 +76,7 @@ def _plan(*, git_user: dict | None = None, dockerfile="", guest_env={}, ), + workspace_plan=workspace_plan(spec, guest_home="/home/node"), ) diff --git a/tests/unit/test_docker_provision_provider_auth.py b/tests/unit/test_docker_provision_provider_auth.py index ee74964..e041086 100644 --- a/tests/unit/test_docker_provision_provider_auth.py +++ b/tests/unit/test_docker_provision_provider_auth.py @@ -18,6 +18,7 @@ from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest from bot_bottle.pipelock import PipelockProxyPlan +from bot_bottle.workspace import workspace_plan def _plan( @@ -29,13 +30,14 @@ def _plan( "bottles": {"dev": {"agent_provider": {"template": "codex"}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) + spec = BottleSpec( + manifest=manifest, + agent_name="demo", + copy_cwd=False, + user_cwd="/tmp/x", + ) return DockerBottlePlan( - spec=BottleSpec( - manifest=manifest, - agent_name="demo", - copy_cwd=False, - user_cwd="/tmp/x", - ), + spec=spec, stage_dir=Path("/tmp/stage"), slug="demo-abc12", container_name="bot-bottle-demo-abc12", @@ -69,6 +71,7 @@ def _plan( agent_provision=_agent_provision( agent_provider_template, codex_auth_file=codex_auth_file, ), + workspace_plan=workspace_plan(spec, guest_home="/home/node"), ) diff --git a/tests/unit/test_plan_print_parity.py b/tests/unit/test_plan_print_parity.py index 2e76361..5dcb1fd 100644 --- a/tests/unit/test_plan_print_parity.py +++ b/tests/unit/test_plan_print_parity.py @@ -21,6 +21,7 @@ from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import Manifest from bot_bottle.pipelock import PipelockProxyPlan +from bot_bottle.workspace import workspace_plan def _manifest() -> Manifest: @@ -109,6 +110,7 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan: egress_plan=_egress_plan(tmp), supervise_plan=None, agent_provision=_agent_provision(), + workspace_plan=workspace_plan(spec, guest_home="/home/node"), slug="test-00001", container_name="bot-bottle-test-00001", container_name_pinned=False, @@ -133,6 +135,7 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan: egress_plan=_egress_plan(tmp), supervise_plan=None, agent_provision=_agent_provision(), + workspace_plan=workspace_plan(spec, guest_home="/home/node"), slug="test-00001", bundle_subnet="10.99.0.0/24", bundle_gateway="10.99.0.1", diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index b32d20f..866ed43 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -38,6 +38,7 @@ from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import GitEntry, Manifest from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.supervise import SupervisePlan +from bot_bottle.workspace import workspace_plan def _remote_host(g: GitEntry) -> str: @@ -144,6 +145,7 @@ def _plan( codex_auth_file=codex_auth_file, guest_env=dict(guest_env or {}), ), + workspace_plan=workspace_plan(spec, guest_home="/home/node"), )