PRD 0045: Workspace Porting Plan #149
@@ -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 "",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user