diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index c610116..28f380d 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -7,6 +7,7 @@ command, default image, and prompt/auth behavior. from __future__ import annotations +import json import os from dataclasses import dataclass, field from pathlib import Path @@ -136,9 +137,11 @@ def agent_provision_plan( auth_token: str = "", forward_host_credentials: bool = False, host_env: dict[str, str] | None = None, + trusted_project_path: str = "", ) -> AgentProvisionPlan: runtime = runtime_for(template) resolved_guest_env = dict(guest_env or {}) + trusted_path = trusted_project_path or guest_home env_vars: dict[str, str] = {} provisioned_env: dict[str, str] = {} dirs: list[AgentProvisionDir] = [] @@ -156,8 +159,9 @@ def agent_provision_plan( dirs.append(AgentProvisionDir(auth_dir)) config_path = f"{auth_dir}/config.toml" config_file = state_dir / "codex-config.toml" + toml_path = trusted_path.replace("\\", "\\\\").replace('"', '\\"') config_file.write_text( - f'[projects."{guest_home}"]\n' + f'[projects."{toml_path}"]\n' 'trust_level = "trusted"\n' ) config_file.chmod(0o600) @@ -202,6 +206,19 @@ def agent_provision_plan( if template == PROVIDER_CLAUDE: env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" env_vars["DISABLE_ERROR_REPORTING"] = "1" + claude_config = state_dir / "claude.json" + claude_projects = { + guest_home: {"hasTrustDialogAccepted": True}, + } + claude_projects[trusted_path] = {"hasTrustDialogAccepted": True} + claude_config.write_text(json.dumps({ + "hasCompletedOnboarding": True, + "theme": "dark", + "bypassPermissionsModeAccepted": True, + "projects": claude_projects, + }, indent=2) + "\n") + claude_config.chmod(0o600) + files.append(AgentProvisionFile(claude_config, f"{guest_home}/.claude.json")) egress_routes.append(EgressRoute( host="api.anthropic.com", auth_scheme="Bearer" if auth_token else "", 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/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/prepare.py b/bot_bottle/backend/docker/prepare.py index 20de072..da34d22 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,10 +180,11 @@ 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), + trusted_project_path=workspace_plan.workdir, ) guest_env = dict(agent_provision.guest_env) for key, val in agent_provision.env_vars.items(): @@ -245,6 +249,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/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..af955f5 100644 --- a/bot_bottle/backend/docker/util.py +++ b/bot_bottle/backend/docker/util.py @@ -7,9 +7,11 @@ from __future__ import annotations import re import shutil import subprocess +import tempfile 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,35 +118,39 @@ 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") - 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" - ) - subprocess.run( - ["docker", "build", "-t", derived, "-f", "-", cwd], - input=dockerfile, - text=True, - check=True, - ) + info(f"building image {derived} from {base} with {cwd} -> {workspace.guest_path}") + with tempfile.TemporaryDirectory(prefix="bot-bottle-cwd.") as tmp: + context_dir = os.path.join(tmp, "context") + staged_workspace = os.path.join(context_dir, "workspace") + shutil.copytree( + cwd, + staged_workspace, + symlinks=True, + ignore=shutil.ignore_patterns(".git"), + ) + dockerfile = ( + f"FROM {base}\n" + f"COPY --chown=node:node workspace/. {workspace.guest_path}\n" + f"WORKDIR {workspace.workdir}\n" + ) + subprocess.run( + ["docker", "build", "-t", derived, "-f", "-", context_dir], + input=dockerfile, + text=True, + check=True, + ) def image_id(ref: str) -> str: 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/prepare.py b/bot_bottle/backend/smolmachines/prepare.py index 3f29c0c..53a6c42 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,11 +133,12 @@ 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, host_env=dict(os.environ), + trusted_project_path=workspace_plan.workdir, ) merged_guest_env = dict(agent_provision.guest_env) for key, val in agent_provision.env_vars.items(): @@ -181,6 +185,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/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/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/docs/prds/0045-workspace-porting-plan.md b/docs/prds/0045-workspace-porting-plan.md new file mode 100644 index 0000000..c9e7f09 --- /dev/null +++ b/docs/prds/0045-workspace-porting-plan.md @@ -0,0 +1,167 @@ +# PRD 0045: Workspace Porting Plan + +- **Status:** Active +- **Author:** didericis-codex +- **Created:** 2026-06-02 +- **Issue:** #116 + +## Summary + +Add a backend-neutral `WorkspacePlan` that describes how the operator's current +workspace is represented inside a bottle. Docker and smolmachines should both +use this plan for workspace path, working directory, content copy, `.git` copy, +ownership, and provider trust configuration instead of rediscovering +`/home/node/workspace` in separate launch and provisioning code paths. + +## Problem + +The current `--cwd` behavior is spread across backend-specific code: + +- Docker builds a derived image that copies the host cwd to + `/home/node/workspace`, sets that as `WORKDIR`, and patches Claude trust in + the generated Dockerfile. +- Docker git provisioning separately copies `.git` into + `/home/node/workspace/.git`. +- smolmachines git provisioning reconstructs `/workspace/.git`, but + does not copy the full working tree. +- Codex provider setup trusts `guest_home`, not the copied workspace path. + +These details create backend drift and make provider-specific workspace fixes +easy to hard-code in the wrong layer. + +## Goals / Success Criteria + +- `BottleSpec` remains the CLI intent shape (`copy_cwd`, `user_cwd`), while a + resolved `WorkspacePlan` carries the backend-neutral guest workspace contract. +- `BottlePlan` exposes `workspace_plan` so shared and backend-specific + provisioning paths consume one resolved object. +- The default in-bottle workspace path remains `/home/node/workspace` when + `--cwd` is enabled. +- Docker uses `WorkspacePlan` when building the derived cwd image and when + provisioning cwd `.git` state. +- smolmachines copies the host cwd contents into the same logical workspace + path and uses `WorkspacePlan` when provisioning cwd `.git` state. +- Provider trust configuration is written for the workspace path when `--cwd` + is enabled, and for the guest home when `--cwd` is disabled. +- Unit tests cover plan resolution, provider trust path selection, Docker + derived image rendering, and both backends' `.git` copy targets. + +## Non-goals + +- No new user-facing flags for custom workspace paths. +- No manifest schema changes. +- No redesign of git-gate or `bottle.git` entries. +- No switch from Docker image-copy to bind-mount. +- No unrelated provider auth changes. + +## Scope + +In scope: + +- Add a small workspace planning module. +- Add `workspace_plan` to `BottlePlan` and populate it in Docker and + smolmachines prepare paths. +- Thread the trusted project path into provider provisioning. +- Replace hard-coded `/home/node/workspace` cwd copy and `.git` copy sites with + `WorkspacePlan` values. +- Copy full host cwd contents for smolmachines `--cwd` parity. +- Update focused unit tests. + +Out of scope: + +- Integration tests that launch real Docker containers or smolmachines VMs. +- Path customization in the bottle manifest or CLI. +- Runtime synchronization after bottle launch; this remains a launch-time copy. + +## Design + +Add `bot_bottle/workspace.py`: + +```python +@dataclass(frozen=True) +class WorkspacePlan: + enabled: bool + host_path: Path + guest_home: str + guest_path: str + workdir: str + owner: str = "node:node" + mode: str = "755" + copy_contents: bool = True + copy_git: bool = True + has_host_git_dir: bool = False +``` + +`workspace_plan(spec, guest_home)` resolves: + +- `enabled` from `spec.copy_cwd`. +- `host_path` from `spec.user_cwd`. +- `guest_path` as `/workspace` when enabled, else `guest_home`. +- `workdir` as `guest_path` when enabled, else `guest_home`. +- `has_host_git_dir` from `/.git`. + +Backends resolve this in `prepare` using their existing guest-home knobs: + +- Docker: `BOT_BOTTLE_CONTAINER_HOME`, default `/home/node`. +- smolmachines: `BOT_BOTTLE_GUEST_HOME`, default `/home/node`. + +`BottlePlan` carries the result so launch, git provisioning, and provider +provisioning stop consulting `spec.copy_cwd` and hard-coded paths directly. + +### Docker + +Keep the current derived-image transport. Change +`build_image_with_cwd(derived, base, cwd)` to accept a `WorkspacePlan` or +explicit guest path/workdir fields, then render: + +- `COPY --chown=node:node . ` +- `WORKDIR ` + +Claude trust should move out of the generated cwd Dockerfile and into provider +provisioning so Docker and smolmachines share the same provider trust behavior. + +### smolmachines + +Copy host cwd contents into `workspace_plan.guest_path` during provisioning or +VM initialization, then chown the resulting workspace to `node:node`. Continue +to copy `.git` through the existing smolvm transport, but target +`/.git`. + +This intentionally closes the current parity gap where smolmachines receives +repo metadata without the working tree. + +### Provider Trust + +Extend provider planning with a `trusted_project_path` argument. Callers pass +`workspace_plan.workdir`. + +Codex writes: + +```toml +[projects.""] +trust_level = "trusted" +``` + +Claude writes or updates `.claude.json` so `projects` includes +`trusted_project_path` with `hasTrustDialogAccepted: true`. This provisioning +belongs in `AgentProvisionPlan` so both backends apply it through their existing +provider file-copy primitives. + +## Testing Strategy + +- Unit-test `workspace_plan()` for enabled and disabled cwd, guest-home + overrides, and `.git` detection. +- Unit-test Docker cwd image rendering to prove it uses the plan's guest path + and workdir. +- Unit-test provider planning for Codex and Claude trusted project paths. +- Unit-test Docker and smolmachines git provisioning targets using mocked copy + and exec primitives. +- Unit-test smolmachines workspace content copy target and ownership command. + +Run: + +- `python3 -m unittest discover -s tests/unit` + +## Open Questions + +None. diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index 3f90db5..216e3f2 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -31,6 +31,7 @@ class TestAgentProviderRuntime(unittest.TestCase): dockerfile="/tmp/Dockerfile.codex", state_dir=Path(tmp), ) + config = Path(tmp, "codex-config.toml").read_text() self.assertEqual("codex", plan.template) self.assertEqual("codex", plan.command) self.assertEqual("read_prompt_file", plan.prompt_mode) @@ -45,6 +46,18 @@ class TestAgentProviderRuntime(unittest.TestCase): ("/home/node/.codex/config.toml",), tuple(f.guest_path for f in plan.files), ) + self.assertIn('[projects."/home/node"]', config) + + def test_codex_trusts_requested_project_path(self): + with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: + agent_provision_plan( + template="codex", + dockerfile="", + state_dir=Path(tmp), + trusted_project_path="/home/node/workspace", + ) + config = Path(tmp, "codex-config.toml").read_text() + self.assertIn('[projects."/home/node/workspace"]', config) def test_codex_forward_host_credentials_adds_auth_and_verify(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: @@ -79,6 +92,7 @@ class TestAgentProviderRuntime(unittest.TestCase): state_dir=Path(tmp), auth_token="BOT_BOTTLE_CLAUDE_OAUTH_TOKEN", ) + claude_config = json.loads(Path(tmp, "claude.json").read_text()) self.assertEqual(1, len(plan.egress_routes)) route = plan.egress_routes[0] self.assertEqual("api.anthropic.com", route.host) @@ -89,6 +103,20 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertEqual("1", plan.env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"]) self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"]) self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names) + self.assertIn("/home/node", claude_config["projects"]) + self.assertIn("/home/node/.claude.json", {f.guest_path for f in plan.files}) + + def test_claude_trusts_requested_project_path(self): + with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: + agent_provision_plan( + template="claude", + dockerfile="", + state_dir=Path(tmp), + trusted_project_path="/home/node/workspace", + ) + config = json.loads(Path(tmp, "claude.json").read_text()) + self.assertIn("/home/node", config["projects"]) + self.assertIn("/home/node/workspace", config["projects"]) def test_codex_forward_host_credentials_populates_egress_routes(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: 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..763e4c1 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -21,9 +21,12 @@ 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, + copy_cwd: bool = False, + user_cwd: str = "/tmp/x", stage_dir: Path | None = None) -> DockerBottlePlan: bottle_json: dict = {} if git_user is not None: @@ -34,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, @@ -75,6 +78,7 @@ def _plan(*, git_user: dict | None = None, dockerfile="", guest_env={}, ), + workspace_plan=workspace_plan(spec, guest_home="/home/node"), ) @@ -106,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_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_docker_util_image.py b/tests/unit/test_docker_util_image.py index 78b92a5..941bebf 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,60 @@ 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", "-"], argv[:6]) + self.assertTrue(argv[6].endswith("/context")) + self.assertIn("FROM base:tag\n", dockerfile) + self.assertIn( + "COPY --chown=node:node workspace/. /guest/home/workspace\n", + dockerfile, + ) + self.assertIn("WORKDIR /guest/home/workspace\n", dockerfile) + + def test_staged_context_includes_hidden_files_but_not_git_dir(self): + with tempfile.TemporaryDirectory(prefix="bb-docker-cwd.") as tmp: + root = Path(tmp) + (root / ".gitignore").write_text("*.pyc\n") + (root / ".dockerignore").write_text(".gitignore\n") + (root / ".env.example").write_text("SAFE=1\n") + (root / ".git").mkdir() + (root / ".git" / "config").write_text("[core]\n") + workspace = WorkspacePlan( + enabled=True, + host_path=root, + guest_home="/guest/home", + guest_path="/guest/home/workspace", + workdir="/guest/home/workspace", + ) + + def inspect_context(*args, **kwargs): + context = Path(args[0][-1]) + staged = context / "workspace" + self.assertTrue((staged / ".gitignore").is_file()) + self.assertTrue((staged / ".dockerignore").is_file()) + self.assertTrue((staged / ".env.example").is_file()) + self.assertFalse((staged / ".git").exists()) + return _ok() + + with patch.object( + docker_mod.subprocess, "run", side_effect=inspect_context, + ): + docker_mod.build_image_with_cwd("derived:tag", "base:tag", workspace) + + if __name__ == "__main__": unittest.main() 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..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 @@ -38,6 +39,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 +146,7 @@ def _plan( codex_auth_file=codex_auth_file, guest_env=dict(guest_env or {}), ), + workspace_plan=workspace_plan(spec, guest_home="/home/node"), ) @@ -846,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()