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 from __future__ import annotations
import json
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -136,9 +137,11 @@ def agent_provision_plan(
auth_token: str = "", auth_token: str = "",
forward_host_credentials: bool = False, forward_host_credentials: bool = False,
host_env: dict[str, str] | None = None, host_env: dict[str, str] | None = None,
trusted_project_path: str = "",
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
runtime = runtime_for(template) runtime = runtime_for(template)
resolved_guest_env = dict(guest_env or {}) resolved_guest_env = dict(guest_env or {})
trusted_path = trusted_project_path or guest_home
env_vars: dict[str, str] = {} env_vars: dict[str, str] = {}
provisioned_env: dict[str, str] = {} provisioned_env: dict[str, str] = {}
dirs: list[AgentProvisionDir] = [] dirs: list[AgentProvisionDir] = []
@@ -156,8 +159,9 @@ def agent_provision_plan(
dirs.append(AgentProvisionDir(auth_dir)) dirs.append(AgentProvisionDir(auth_dir))
config_path = f"{auth_dir}/config.toml" config_path = f"{auth_dir}/config.toml"
config_file = state_dir / "codex-config.toml" config_file = state_dir / "codex-config.toml"
toml_path = trusted_path.replace("\\", "\\\\").replace('"', '\\"')
config_file.write_text( config_file.write_text(
f'[projects."{guest_home}"]\n' f'[projects."{toml_path}"]\n'
'trust_level = "trusted"\n' 'trust_level = "trusted"\n'
) )
config_file.chmod(0o600) config_file.chmod(0o600)
@@ -202,6 +206,19 @@ def agent_provision_plan(
if template == PROVIDER_CLAUDE: if template == PROVIDER_CLAUDE:
env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
env_vars["DISABLE_ERROR_REPORTING"] = "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( egress_routes.append(EgressRoute(
host="api.anthropic.com", host="api.anthropic.com",
auth_scheme="Bearer" if auth_token else "", 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 ..manifest import GitEntry, Manifest
from ..supervise import SupervisePlan from ..supervise import SupervisePlan
from ..util import expand_tilde from ..util import expand_tilde
from ..workspace import WorkspacePlan
from .print_util import print_multi, visible_agent_env_names from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir from .util import host_skill_dir
@@ -79,6 +80,7 @@ class BottlePlan(ABC):
egress_plan: EgressPlan egress_plan: EgressPlan
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan agent_provision: AgentProvisionPlan
workspace_plan: WorkspacePlan
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr.""" """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 decide whether to add provider-specific prompt args to the agent's
argv. 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 supervise. CA install runs first so the agent's trust store
is rebuilt before anything inside the agent makes a TLS call. is rebuilt before anything inside the agent makes a TLS call.
Subclasses typically don't override this; they implement the 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) prompt_path = self.provision_prompt(plan, target)
self.provision_provider_auth(plan, target) self.provision_provider_auth(plan, target)
self.provision_skills(plan, target) self.provision_skills(plan, target)
self.provision_workspace(plan, target)
self.provision_git(plan, target) self.provision_git(plan, target)
self.provision_supervise(plan, target) self.provision_supervise(plan, target)
return prompt_path return prompt_path
@@ -365,6 +368,11 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Copy the agent's named skills from the host into the """Copy the agent's named skills from the host into the
running bottle. No-op when the agent has no skills.""" 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 @abstractmethod
def provision_git(self, plan: PlanT, target: str) -> None: def provision_git(self, plan: PlanT, target: str) -> None:
"""Copy the host's cwd `.git` directory into the running """Copy the host's cwd `.git` directory into the running
+1 -1
View File
@@ -101,7 +101,7 @@ def launch(
) )
if plan.derived_image: if plan.derived_image:
docker_mod.build_image_with_cwd( 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 # 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 ...log import die
from ...pipelock import PipelockProxy from ...pipelock import PipelockProxy
from ...supervise import Supervise from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan
from .. import BottleSpec from .. import BottleSpec
from . import util as docker_mod from . import util as docker_mod
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
@@ -62,6 +63,8 @@ def resolve_plan(
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template) 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` # PRD 0016 follow-up: identity, not bare slug. A fresh `start`
# mints a random-suffixed identity (so parallel runs of the same # mints a random-suffixed identity (so parallel runs of the same
@@ -177,10 +180,11 @@ def resolve_plan(
template=provider.template, template=provider.template,
dockerfile=dockerfile_path, dockerfile=dockerfile_path,
state_dir=agent_dir, 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, forward_host_credentials=provider.forward_host_credentials,
auth_token=provider.auth_token, auth_token=provider.auth_token,
host_env=dict(os.environ), host_env=dict(os.environ),
trusted_project_path=workspace_plan.workdir,
) )
guest_env = dict(agent_provision.guest_env) guest_env = dict(agent_provision.guest_env)
for key, val in agent_provision.env_vars.items(): for key, val in agent_provision.env_vars.items():
@@ -245,6 +249,7 @@ def resolve_plan(
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
use_runsc=use_runsc, use_runsc=use_runsc,
agent_provision=agent_provision, agent_provision=agent_provision,
workspace_plan=workspace_plan,
) )
+8 -6
View File
@@ -3,7 +3,7 @@
Three concerns, all about git in the agent: Three concerns, all about git in the agent:
1. If --cwd was passed AND the host cwd has a .git, copy that .git 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. user's repo.
2. If the bottle declares `git` entries (PRD 0008), write a 2. If the bottle declares `git` entries (PRD 0008), write a
~/.gitconfig with insteadOf rules so every git operation ~/.gitconfig with insteadOf rules so every git operation
@@ -20,7 +20,6 @@ from __future__ import annotations
import os import os
import subprocess import subprocess
from pathlib import Path
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
from ....log import info 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 """If --cwd was set and the host cwd has a .git directory, copy
it into /home/node/workspace/.git and fix ownership. No-op it into /home/node/workspace/.git and fix ownership. No-op
otherwise.""" 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 return
container = target 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( 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, stdout=subprocess.DEVNULL,
check=True, check=True,
) )
subprocess.run( subprocess.run(
[ [
"docker", "exec", "-u", "0", container, "docker", "exec", "-u", "0", container,
"chown", "-R", "node:node", "/home/node/workspace/.git", "chown", "-R", workspace.owner, guest_workspace_git,
], ],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
check=True, check=True,
+23 -17
View File
@@ -7,9 +7,11 @@ from __future__ import annotations
import re import re
import shutil import shutil
import subprocess import subprocess
import tempfile
from typing import Iterable, Iterator from typing import Iterable, Iterator
from ...log import die, info from ...log import die, info
from ...workspace import WorkspacePlan
# Cap on the suffix the container-name conflict logic will try before # Cap on the suffix the container-name conflict logic will try before
@@ -116,31 +118,35 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
subprocess.run(args, check=True) subprocess.run(args, check=True)
_TRUST_DIALOG_NODE_SCRIPT = ( def build_image_with_cwd(
'const fs=require("fs"),p=process.env.HOME+"/.claude.json",' derived: str,
'c=JSON.parse(fs.readFileSync(p,"utf8"));' base: str,
'c.projects=c.projects||{};' workspace: WorkspacePlan,
'c.projects[process.env.HOME+"/workspace"]={hasTrustDialogAccepted:true};' ) -> None:
'fs.writeFileSync(p,JSON.stringify(c,null,2));' """Build a thin derived image that copies the workspace into
) the plan's guest path and sets the plan's workdir."""
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."""
import os import os
cwd = str(workspace.host_path)
if not os.path.isdir(cwd): if not os.path.isdir(cwd):
die(f"cwd not found at {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}")
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 = ( dockerfile = (
f"FROM {base}\n" f"FROM {base}\n"
f"COPY --chown=node:node . /home/node/workspace\n" f"COPY --chown=node:node workspace/. {workspace.guest_path}\n"
f"RUN node -e '{_TRUST_DIALOG_NODE_SCRIPT}'\n" f"WORKDIR {workspace.workdir}\n"
f"WORKDIR /home/node/workspace\n"
) )
subprocess.run( subprocess.run(
["docker", "build", "-t", derived, "-f", "-", cwd], ["docker", "build", "-t", derived, "-f", "-", context_dir],
input=dockerfile, input=dockerfile,
text=True, text=True,
check=True, check=True,
@@ -22,6 +22,7 @@ from .provision import prompt as _prompt
from .provision import provider_auth as _provider_auth from .provision import provider_auth as _provider_auth
from .provision import skills as _skills from .provision import skills as _skills
from .provision import supervise as _supervise from .provision import supervise as _supervise
from .provision import workspace as _workspace
class SmolmachinesBottleBackend( class SmolmachinesBottleBackend(
@@ -72,6 +73,11 @@ class SmolmachinesBottleBackend(
) -> None: ) -> None:
_skills.provision_skills(plan, target) _skills.provision_skills(plan, target)
def provision_workspace(
self, plan: SmolmachinesBottlePlan, target: str
) -> None:
_workspace.provision_workspace(plan, target)
def provision_git( def provision_git(
self, plan: SmolmachinesBottlePlan, target: str self, plan: SmolmachinesBottlePlan, target: str
) -> None: ) -> None:
+6 -1
View File
@@ -32,6 +32,7 @@ from ...env import resolve_env
from ...git_gate import GitGate from ...git_gate import GitGate
from ...pipelock import PipelockProxy from ...pipelock import PipelockProxy
from ...supervise import Supervise from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
from .util import smolmachines_bundle_subnet, smolmachines_preflight from .util import smolmachines_bundle_subnet, smolmachines_preflight
@@ -60,6 +61,8 @@ def resolve_plan(
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template) 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) slug = spec.identity or bottle_identity(spec.agent_name)
@@ -130,11 +133,12 @@ def resolve_plan(
template=provider.template, template=provider.template,
dockerfile=agent_dockerfile_path, dockerfile=agent_dockerfile_path,
state_dir=agent_dir, state_dir=agent_dir,
guest_home=os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node"), guest_home=guest_home,
guest_env=guest_env, guest_env=guest_env,
forward_host_credentials=provider.forward_host_credentials, forward_host_credentials=provider.forward_host_credentials,
auth_token=provider.auth_token, auth_token=provider.auth_token,
host_env=dict(os.environ), host_env=dict(os.environ),
trusted_project_path=workspace_plan.workdir,
) )
merged_guest_env = dict(agent_provision.guest_env) merged_guest_env = dict(agent_provision.guest_env)
for key, val in agent_provision.env_vars.items(): for key, val in agent_provision.env_vars.items():
@@ -181,6 +185,7 @@ def resolve_plan(
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
agent_provision=agent_provision, agent_provision=agent_provision,
workspace_plan=workspace_plan,
) )
@@ -4,7 +4,7 @@
Three concerns, all about git in the agent: Three concerns, all about git in the agent:
1. If --cwd was passed AND the host cwd has a .git, copy that 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. the user's repo.
2. If the bottle declares `git` entries (PRD 0008), write a 2. If the bottle declares `git` entries (PRD 0008), write a
~/.gitconfig with insteadOf rules so every git operation ~/.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 """If --cwd was set and the host cwd has a .git directory, copy
it into <guest_home>/workspace/.git and fix ownership. No-op it into <guest_home>/workspace/.git and fix ownership. No-op
otherwise.""" 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 return
guest_workspace_git = f"{_guest_home()}/workspace/.git" guest_workspace_git = f"{workspace.guest_path}/.git"
info(f"copying {plan.spec.user_cwd}/.git -> {target}:{guest_workspace_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 # mkdir -p the workspace dir so `machine cp` lands the .git
# directly there even on first-time bottles. # 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( _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 # `machine cp` lands files as root; the agent runs as node so
# the workspace tree must be chowned over. # the workspace tree must be chowned over.
_smolvm.machine_exec( _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", dockerfile="/tmp/Dockerfile.codex",
state_dir=Path(tmp), state_dir=Path(tmp),
) )
config = Path(tmp, "codex-config.toml").read_text()
self.assertEqual("codex", plan.template) self.assertEqual("codex", plan.template)
self.assertEqual("codex", plan.command) self.assertEqual("codex", plan.command)
self.assertEqual("read_prompt_file", plan.prompt_mode) self.assertEqual("read_prompt_file", plan.prompt_mode)
@@ -45,6 +46,18 @@ class TestAgentProviderRuntime(unittest.TestCase):
("/home/node/.codex/config.toml",), ("/home/node/.codex/config.toml",),
tuple(f.guest_path for f in plan.files), 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): def test_codex_forward_host_credentials_adds_auth_and_verify(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
@@ -79,6 +92,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
state_dir=Path(tmp), state_dir=Path(tmp),
auth_token="BOT_BOTTLE_CLAUDE_OAUTH_TOKEN", auth_token="BOT_BOTTLE_CLAUDE_OAUTH_TOKEN",
) )
claude_config = json.loads(Path(tmp, "claude.json").read_text())
self.assertEqual(1, len(plan.egress_routes)) self.assertEqual(1, len(plan.egress_routes))
route = plan.egress_routes[0] route = plan.egress_routes[0]
self.assertEqual("api.anthropic.com", route.host) 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["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"])
self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"]) self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"])
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names) 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): def test_codex_forward_host_credentials_populates_egress_routes(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: 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.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
SLUG = "demo-abc12" SLUG = "demo-abc12"
@@ -163,8 +164,9 @@ def _plan(
roles=(), roles=(),
),) ),)
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
return DockerBottlePlan( return DockerBottlePlan(
spec=_spec(supervise=supervise, with_git=with_git, with_egress=with_egress), spec=spec,
stage_dir=STAGE, stage_dir=STAGE,
slug=SLUG, slug=SLUG,
container_name=f"bot-bottle-{SLUG}", container_name=f"bot-bottle-{SLUG}",
@@ -189,6 +191,7 @@ def _plan(
dockerfile="", dockerfile="",
guest_env={}, 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.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan
def _plan(*, git_user: dict | None = None, def _plan(*, git_user: dict | None = None,
copy_cwd: bool = False,
user_cwd: str = "/tmp/x",
stage_dir: Path | None = None) -> DockerBottlePlan: stage_dir: Path | None = None) -> DockerBottlePlan:
bottle_json: dict = {} bottle_json: dict = {}
if git_user is not None: if git_user is not None:
@@ -34,7 +37,7 @@ def _plan(*, git_user: dict | None = None,
}) })
spec = BottleSpec( spec = BottleSpec(
manifest=manifest, agent_name="demo", manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x", copy_cwd=copy_cwd, user_cwd=user_cwd,
) )
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
@@ -75,6 +78,7 @@ def _plan(*, git_user: dict | None = None,
dockerfile="", dockerfile="",
guest_env={}, 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)) 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): def test_sets_name_and_email(self):
plan = _plan( plan = _plan(
git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"}, 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.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan
def _plan( def _plan(
@@ -29,13 +30,14 @@ def _plan(
"bottles": {"dev": {"agent_provider": {"template": "codex"}}}, "bottles": {"dev": {"agent_provider": {"template": "codex"}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
return DockerBottlePlan( spec = BottleSpec(
spec=BottleSpec(
manifest=manifest, manifest=manifest,
agent_name="demo", agent_name="demo",
copy_cwd=False, copy_cwd=False,
user_cwd="/tmp/x", user_cwd="/tmp/x",
), )
return DockerBottlePlan(
spec=spec,
stage_dir=Path("/tmp/stage"), stage_dir=Path("/tmp/stage"),
slug="demo-abc12", slug="demo-abc12",
container_name="bot-bottle-demo-abc12", container_name="bot-bottle-demo-abc12",
@@ -69,6 +71,7 @@ def _plan(
agent_provision=_agent_provision( agent_provision=_agent_provision(
agent_provider_template, codex_auth_file=codex_auth_file, 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 from __future__ import annotations
import subprocess import subprocess
import tempfile
import unittest import unittest
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle.backend.docker import util as docker_mod from bot_bottle.backend.docker import util as docker_mod
from bot_bottle.workspace import WorkspacePlan
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: 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__": if __name__ == "__main__":
unittest.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.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan
def _manifest() -> Manifest: def _manifest() -> Manifest:
@@ -109,6 +110,7 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
egress_plan=_egress_plan(tmp), egress_plan=_egress_plan(tmp),
supervise_plan=None, supervise_plan=None,
agent_provision=_agent_provision(), agent_provision=_agent_provision(),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
slug="test-00001", slug="test-00001",
container_name="bot-bottle-test-00001", container_name="bot-bottle-test-00001",
container_name_pinned=False, container_name_pinned=False,
@@ -133,6 +135,7 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
egress_plan=_egress_plan(tmp), egress_plan=_egress_plan(tmp),
supervise_plan=None, supervise_plan=None,
agent_provision=_agent_provision(), agent_provision=_agent_provision(),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
slug="test-00001", slug="test-00001",
bundle_subnet="10.99.0.0/24", bundle_subnet="10.99.0.0/24",
bundle_gateway="10.99.0.1", 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, provider_auth as _provider_auth,
skills as _skills, skills as _skills,
supervise as _supervise, supervise as _supervise,
workspace as _workspace,
) )
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.smolmachines.smolvm import SmolvmRunResult 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.manifest import GitEntry, Manifest
from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
def _remote_host(g: GitEntry) -> str: def _remote_host(g: GitEntry) -> str:
@@ -144,6 +146,7 @@ def _plan(
codex_auth_file=codex_auth_file, codex_auth_file=codex_auth_file,
guest_env=dict(guest_env or {}), 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:]) 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): class TestProvisionSupervise(unittest.TestCase):
def test_noop_when_supervise_not_enabled(self): def test_noop_when_supervise_not_enabled(self):
with patch( 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()