PRD 0045: Workspace Porting Plan #149

Merged
didericis merged 6 commits from prd-0045-workspace-plan into main 2026-06-02 13:18:49 -04:00
20 changed files with 588 additions and 51 deletions
+18 -1
View File
@@ -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 "",
+9 -1
View File
@@ -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
+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
+6 -1
View File
@@ -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,
)
+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,
+31 -25
View File
@@ -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 <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")
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:
@@ -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:
+6 -1
View File
@@ -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,
)
@@ -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}",
],
)
+52
View File
@@ -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(),
)
+167
View File
@@ -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 `<guest_home>/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 `<guest_home>/workspace` when enabled, else `guest_home`.
- `workdir` as `guest_path` when enabled, else `guest_home`.
- `has_host_git_dir` from `<host_path>/.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 . <workspace_plan.guest_path>`
- `WORKDIR <workspace_plan.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
`<workspace_plan.guest_path>/.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."<trusted_project_path>"]
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.
+28
View File
@@ -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:
+4 -1
View File
@@ -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"),
)
+27 -1
View File
@@ -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"},
@@ -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"),
)
+58
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,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()
+3
View File
@@ -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",
+52
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
@@ -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(
+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()