feat(workspace): port cwd across backends

This commit is contained in:
2026-06-02 17:01:19 +00:00
parent 6150497b47
commit d5fcbe53ef
10 changed files with 229 additions and 31 deletions
+1 -1
View File
@@ -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
+8 -6
View File
@@ -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,
+12 -16
View File
@@ -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 <cwd> 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],
@@ -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:
@@ -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 <guest_home>/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],
)
@@ -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}",
],
)
+25 -1
View File
@@ -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"},
+24
View File
@@ -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()
+50
View File
@@ -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(
+58
View File
@@ -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()