PRD 0045: Workspace Porting Plan #149
@@ -7,6 +7,7 @@ command, default image, and prompt/auth behavior.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -136,9 +137,11 @@ def agent_provision_plan(
|
||||
auth_token: str = "",
|
||||
forward_host_credentials: bool = False,
|
||||
host_env: dict[str, str] | None = None,
|
||||
trusted_project_path: str = "",
|
||||
) -> AgentProvisionPlan:
|
||||
runtime = runtime_for(template)
|
||||
resolved_guest_env = dict(guest_env or {})
|
||||
trusted_path = trusted_project_path or guest_home
|
||||
env_vars: dict[str, str] = {}
|
||||
provisioned_env: dict[str, str] = {}
|
||||
dirs: list[AgentProvisionDir] = []
|
||||
@@ -156,8 +159,9 @@ def agent_provision_plan(
|
||||
dirs.append(AgentProvisionDir(auth_dir))
|
||||
config_path = f"{auth_dir}/config.toml"
|
||||
config_file = state_dir / "codex-config.toml"
|
||||
toml_path = trusted_path.replace("\\", "\\\\").replace('"', '\\"')
|
||||
config_file.write_text(
|
||||
f'[projects."{guest_home}"]\n'
|
||||
f'[projects."{toml_path}"]\n'
|
||||
'trust_level = "trusted"\n'
|
||||
)
|
||||
config_file.chmod(0o600)
|
||||
@@ -202,6 +206,19 @@ def agent_provision_plan(
|
||||
if template == PROVIDER_CLAUDE:
|
||||
env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
|
||||
env_vars["DISABLE_ERROR_REPORTING"] = "1"
|
||||
claude_config = state_dir / "claude.json"
|
||||
claude_projects = {
|
||||
guest_home: {"hasTrustDialogAccepted": True},
|
||||
}
|
||||
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
|
||||
claude_config.write_text(json.dumps({
|
||||
"hasCompletedOnboarding": True,
|
||||
"theme": "dark",
|
||||
"bypassPermissionsModeAccepted": True,
|
||||
"projects": claude_projects,
|
||||
}, indent=2) + "\n")
|
||||
claude_config.chmod(0o600)
|
||||
files.append(AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"))
|
||||
egress_routes.append(EgressRoute(
|
||||
host="api.anthropic.com",
|
||||
auth_scheme="Bearer" if auth_token else "",
|
||||
|
||||
@@ -46,6 +46,7 @@ from ..log import die, info
|
||||
from ..manifest import GitEntry, Manifest
|
||||
from ..supervise import SupervisePlan
|
||||
from ..util import expand_tilde
|
||||
from ..workspace import WorkspacePlan
|
||||
from .print_util import print_multi, visible_agent_env_names
|
||||
from .util import host_skill_dir
|
||||
|
||||
@@ -79,6 +80,7 @@ class BottlePlan(ABC):
|
||||
egress_plan: EgressPlan
|
||||
supervise_plan: SupervisePlan | None
|
||||
agent_provision: AgentProvisionPlan
|
||||
workspace_plan: WorkspacePlan
|
||||
|
||||
def print(self, *, remote_control: bool) -> None:
|
||||
"""Render the y/N preflight summary to stderr."""
|
||||
@@ -320,7 +322,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
decide whether to add provider-specific prompt args to the agent's
|
||||
argv.
|
||||
|
||||
Default orchestration: ca → prompt → skills → git →
|
||||
Default orchestration: ca → prompt → skills → workspace → git →
|
||||
supervise. CA install runs first so the agent's trust store
|
||||
is rebuilt before anything inside the agent makes a TLS call.
|
||||
Subclasses typically don't override this; they implement the
|
||||
@@ -335,6 +337,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
prompt_path = self.provision_prompt(plan, target)
|
||||
self.provision_provider_auth(plan, target)
|
||||
self.provision_skills(plan, target)
|
||||
self.provision_workspace(plan, target)
|
||||
self.provision_git(plan, target)
|
||||
self.provision_supervise(plan, target)
|
||||
return prompt_path
|
||||
@@ -365,6 +368,11 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
"""Copy the agent's named skills from the host into the
|
||||
running bottle. No-op when the agent has no skills."""
|
||||
|
||||
def provision_workspace(self, plan: PlanT, target: str) -> None:
|
||||
"""Copy the operator workspace into the running bottle when
|
||||
the backend cannot bake it into the agent image. Default is
|
||||
no-op for backends like Docker that handle this before launch."""
|
||||
|
||||
@abstractmethod
|
||||
def provision_git(self, plan: PlanT, target: str) -> None:
|
||||
"""Copy the host's cwd `.git` directory into the running
|
||||
|
||||
@@ -101,7 +101,7 @@ def launch(
|
||||
)
|
||||
if plan.derived_image:
|
||||
docker_mod.build_image_with_cwd(
|
||||
plan.derived_image, plan.image, plan.spec.user_cwd
|
||||
plan.derived_image, plan.image, plan.workspace_plan
|
||||
)
|
||||
|
||||
# Networks: compose-managed. The names are derived
|
||||
|
||||
@@ -22,6 +22,7 @@ from ...git_gate import GitGate
|
||||
from ...log import die
|
||||
from ...pipelock import PipelockProxy
|
||||
from ...supervise import Supervise
|
||||
from ...workspace import workspace_plan as resolve_workspace_plan
|
||||
from .. import BottleSpec
|
||||
from . import util as docker_mod
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
@@ -62,6 +63,8 @@ def resolve_plan(
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
provider = bottle.agent_provider
|
||||
provider_runtime = runtime_for(provider.template)
|
||||
guest_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
||||
|
||||
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
|
||||
# mints a random-suffixed identity (so parallel runs of the same
|
||||
@@ -177,10 +180,11 @@ def resolve_plan(
|
||||
template=provider.template,
|
||||
dockerfile=dockerfile_path,
|
||||
state_dir=agent_dir,
|
||||
guest_home=os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node"),
|
||||
guest_home=guest_home,
|
||||
forward_host_credentials=provider.forward_host_credentials,
|
||||
auth_token=provider.auth_token,
|
||||
host_env=dict(os.environ),
|
||||
trusted_project_path=workspace_plan.workdir,
|
||||
)
|
||||
guest_env = dict(agent_provision.guest_env)
|
||||
for key, val in agent_provision.env_vars.items():
|
||||
@@ -245,6 +249,7 @@ def resolve_plan(
|
||||
supervise_plan=supervise_plan,
|
||||
use_runsc=use_runsc,
|
||||
agent_provision=agent_provision,
|
||||
workspace_plan=workspace_plan,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Three concerns, all about git in the agent:
|
||||
|
||||
1. If --cwd was passed AND the host cwd has a .git, copy that .git
|
||||
into /home/node/workspace/.git so the agent operates on the
|
||||
into the planned guest workspace so the agent operates on the
|
||||
user's repo.
|
||||
2. If the bottle declares `git` entries (PRD 0008), write a
|
||||
~/.gitconfig with insteadOf rules so every git operation
|
||||
@@ -20,7 +20,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
||||
from ....log import info
|
||||
@@ -40,19 +39,22 @@ def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
|
||||
"""If --cwd was set and the host cwd has a .git directory, copy
|
||||
it into /home/node/workspace/.git and fix ownership. No-op
|
||||
otherwise."""
|
||||
if not (plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir()):
|
||||
workspace = plan.workspace_plan
|
||||
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
|
||||
return
|
||||
container = target
|
||||
info(f"copying {plan.spec.user_cwd}/.git -> {container}:/home/node/workspace/.git")
|
||||
guest_workspace_git = f"{workspace.guest_path}/.git"
|
||||
host_git = str(workspace.host_path / ".git")
|
||||
info(f"copying {host_git} -> {container}:{guest_workspace_git}")
|
||||
subprocess.run(
|
||||
["docker", "cp", f"{plan.spec.user_cwd}/.git", f"{container}:/home/node/workspace/.git"],
|
||||
["docker", "cp", host_git, f"{container}:{guest_workspace_git}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "exec", "-u", "0", container,
|
||||
"chown", "-R", "node:node", "/home/node/workspace/.git",
|
||||
"chown", "-R", workspace.owner, guest_workspace_git,
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
|
||||
@@ -7,9 +7,11 @@ from __future__ import annotations
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Iterable, Iterator
|
||||
|
||||
from ...log import die, info
|
||||
from ...workspace import WorkspacePlan
|
||||
|
||||
|
||||
# Cap on the suffix the container-name conflict logic will try before
|
||||
@@ -116,35 +118,39 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
_TRUST_DIALOG_NODE_SCRIPT = (
|
||||
'const fs=require("fs"),p=process.env.HOME+"/.claude.json",'
|
||||
'c=JSON.parse(fs.readFileSync(p,"utf8"));'
|
||||
'c.projects=c.projects||{};'
|
||||
'c.projects[process.env.HOME+"/workspace"]={hasTrustDialogAccepted:true};'
|
||||
'fs.writeFileSync(p,JSON.stringify(c,null,2));'
|
||||
)
|
||||
|
||||
|
||||
def build_image_with_cwd(derived: str, base: str, cwd: str) -> None:
|
||||
"""Build a thin derived image that copies <cwd> into
|
||||
/home/node/workspace and adds a trust-dialog entry for it."""
|
||||
def build_image_with_cwd(
|
||||
derived: str,
|
||||
base: str,
|
||||
workspace: WorkspacePlan,
|
||||
) -> None:
|
||||
"""Build a thin derived image that copies the workspace into
|
||||
the plan's guest path and sets the plan's workdir."""
|
||||
import os
|
||||
|
||||
cwd = str(workspace.host_path)
|
||||
if not os.path.isdir(cwd):
|
||||
die(f"cwd not found at {cwd}")
|
||||
info(f"building image {derived} from {base} with {cwd} -> /home/node/workspace")
|
||||
dockerfile = (
|
||||
f"FROM {base}\n"
|
||||
f"COPY --chown=node:node . /home/node/workspace\n"
|
||||
f"RUN node -e '{_TRUST_DIALOG_NODE_SCRIPT}'\n"
|
||||
f"WORKDIR /home/node/workspace\n"
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", derived, "-f", "-", cwd],
|
||||
input=dockerfile,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
info(f"building image {derived} from {base} with {cwd} -> {workspace.guest_path}")
|
||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-cwd.") as tmp:
|
||||
context_dir = os.path.join(tmp, "context")
|
||||
staged_workspace = os.path.join(context_dir, "workspace")
|
||||
shutil.copytree(
|
||||
cwd,
|
||||
staged_workspace,
|
||||
symlinks=True,
|
||||
ignore=shutil.ignore_patterns(".git"),
|
||||
)
|
||||
dockerfile = (
|
||||
f"FROM {base}\n"
|
||||
f"COPY --chown=node:node workspace/. {workspace.guest_path}\n"
|
||||
f"WORKDIR {workspace.workdir}\n"
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", derived, "-f", "-", context_dir],
|
||||
input=dockerfile,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def image_id(ref: str) -> str:
|
||||
|
||||
@@ -22,6 +22,7 @@ from .provision import prompt as _prompt
|
||||
from .provision import provider_auth as _provider_auth
|
||||
from .provision import skills as _skills
|
||||
from .provision import supervise as _supervise
|
||||
from .provision import workspace as _workspace
|
||||
|
||||
|
||||
class SmolmachinesBottleBackend(
|
||||
@@ -72,6 +73,11 @@ class SmolmachinesBottleBackend(
|
||||
) -> None:
|
||||
_skills.provision_skills(plan, target)
|
||||
|
||||
def provision_workspace(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> None:
|
||||
_workspace.provision_workspace(plan, target)
|
||||
|
||||
def provision_git(
|
||||
self, plan: SmolmachinesBottlePlan, target: str
|
||||
) -> None:
|
||||
|
||||
@@ -32,6 +32,7 @@ from ...env import resolve_env
|
||||
from ...git_gate import GitGate
|
||||
from ...pipelock import PipelockProxy
|
||||
from ...supervise import Supervise
|
||||
from ...workspace import workspace_plan as resolve_workspace_plan
|
||||
from .bottle_plan import SmolmachinesBottlePlan
|
||||
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
||||
|
||||
@@ -60,6 +61,8 @@ def resolve_plan(
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
provider = bottle.agent_provider
|
||||
provider_runtime = runtime_for(provider.template)
|
||||
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node")
|
||||
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
||||
|
||||
slug = spec.identity or bottle_identity(spec.agent_name)
|
||||
|
||||
@@ -130,11 +133,12 @@ def resolve_plan(
|
||||
template=provider.template,
|
||||
dockerfile=agent_dockerfile_path,
|
||||
state_dir=agent_dir,
|
||||
guest_home=os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node"),
|
||||
guest_home=guest_home,
|
||||
guest_env=guest_env,
|
||||
forward_host_credentials=provider.forward_host_credentials,
|
||||
auth_token=provider.auth_token,
|
||||
host_env=dict(os.environ),
|
||||
trusted_project_path=workspace_plan.workdir,
|
||||
)
|
||||
merged_guest_env = dict(agent_provision.guest_env)
|
||||
for key, val in agent_provision.env_vars.items():
|
||||
@@ -181,6 +185,7 @@ def resolve_plan(
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
agent_provision=agent_provision,
|
||||
workspace_plan=workspace_plan,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Three concerns, all about git in the agent:
|
||||
|
||||
1. If --cwd was passed AND the host cwd has a .git, copy that
|
||||
.git into /home/node/workspace/.git so the agent operates on
|
||||
.git into the planned guest workspace so the agent operates on
|
||||
the user's repo.
|
||||
2. If the bottle declares `git` entries (PRD 0008), write a
|
||||
~/.gitconfig with insteadOf rules so every git operation
|
||||
@@ -58,20 +58,22 @@ def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
"""If --cwd was set and the host cwd has a .git directory, copy
|
||||
it into <guest_home>/workspace/.git and fix ownership. No-op
|
||||
otherwise."""
|
||||
if not (plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir()):
|
||||
workspace = plan.workspace_plan
|
||||
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
|
||||
return
|
||||
guest_workspace_git = f"{_guest_home()}/workspace/.git"
|
||||
info(f"copying {plan.spec.user_cwd}/.git -> {target}:{guest_workspace_git}")
|
||||
guest_workspace_git = f"{workspace.guest_path}/.git"
|
||||
host_git = str(workspace.host_path / ".git")
|
||||
info(f"copying {host_git} -> {target}:{guest_workspace_git}")
|
||||
# mkdir -p the workspace dir so `machine cp` lands the .git
|
||||
# directly there even on first-time bottles.
|
||||
_smolvm.machine_exec(target, ["mkdir", "-p", f"{_guest_home()}/workspace"])
|
||||
_smolvm.machine_exec(target, ["mkdir", "-p", workspace.guest_path])
|
||||
_smolvm.machine_cp(
|
||||
f"{plan.spec.user_cwd}/.git", f"{target}:{guest_workspace_git}",
|
||||
host_git, f"{target}:{guest_workspace_git}",
|
||||
)
|
||||
# `machine cp` lands files as root; the agent runs as node so
|
||||
# the workspace tree must be chowned over.
|
||||
_smolvm.machine_exec(
|
||||
target, ["chown", "-R", "node:node", guest_workspace_git],
|
||||
target, ["chown", "-R", workspace.owner, guest_workspace_git],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Copy the operator workspace into a smolmachines guest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
|
||||
from ....log import info
|
||||
from .. import smolvm as _smolvm
|
||||
from ..bottle_plan import SmolmachinesBottlePlan
|
||||
|
||||
|
||||
def provision_workspace(plan: SmolmachinesBottlePlan, target: str) -> None:
|
||||
"""Copy host cwd contents to the planned guest workspace."""
|
||||
workspace = plan.workspace_plan
|
||||
if not (workspace.enabled and workspace.copy_contents):
|
||||
return
|
||||
|
||||
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
|
||||
guest_path_q = shlex.quote(workspace.guest_path)
|
||||
guest_parent_q = shlex.quote(guest_parent)
|
||||
owner_q = shlex.quote(workspace.owner)
|
||||
mode_q = shlex.quote(workspace.mode)
|
||||
info(f"copying {workspace.host_path} -> {target}:{workspace.guest_path}")
|
||||
_smolvm.machine_exec(
|
||||
target,
|
||||
["sh", "-c", f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}"],
|
||||
)
|
||||
_smolvm.machine_cp(str(workspace.host_path), f"{target}:{workspace.guest_path}")
|
||||
_smolvm.machine_exec(
|
||||
target,
|
||||
[
|
||||
"sh", "-c",
|
||||
f"chown -R {owner_q} {guest_path_q} && "
|
||||
f"chmod {mode_q} {guest_path_q}",
|
||||
],
|
||||
)
|
||||
@@ -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",
|
||||
state_dir=Path(tmp),
|
||||
)
|
||||
config = Path(tmp, "codex-config.toml").read_text()
|
||||
self.assertEqual("codex", plan.template)
|
||||
self.assertEqual("codex", plan.command)
|
||||
self.assertEqual("read_prompt_file", plan.prompt_mode)
|
||||
@@ -45,6 +46,18 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
("/home/node/.codex/config.toml",),
|
||||
tuple(f.guest_path for f in plan.files),
|
||||
)
|
||||
self.assertIn('[projects."/home/node"]', config)
|
||||
|
||||
def test_codex_trusts_requested_project_path(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
agent_provision_plan(
|
||||
template="codex",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
trusted_project_path="/home/node/workspace",
|
||||
)
|
||||
config = Path(tmp, "codex-config.toml").read_text()
|
||||
self.assertIn('[projects."/home/node/workspace"]', config)
|
||||
|
||||
def test_codex_forward_host_credentials_adds_auth_and_verify(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
@@ -79,6 +92,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
state_dir=Path(tmp),
|
||||
auth_token="BOT_BOTTLE_CLAUDE_OAUTH_TOKEN",
|
||||
)
|
||||
claude_config = json.loads(Path(tmp, "claude.json").read_text())
|
||||
self.assertEqual(1, len(plan.egress_routes))
|
||||
route = plan.egress_routes[0]
|
||||
self.assertEqual("api.anthropic.com", route.host)
|
||||
@@ -89,6 +103,20 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
self.assertEqual("1", plan.env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"])
|
||||
self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"])
|
||||
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names)
|
||||
self.assertIn("/home/node", claude_config["projects"])
|
||||
self.assertIn("/home/node/.claude.json", {f.guest_path for f in plan.files})
|
||||
|
||||
def test_claude_trusts_requested_project_path(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
agent_provision_plan(
|
||||
template="claude",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
trusted_project_path="/home/node/workspace",
|
||||
)
|
||||
config = json.loads(Path(tmp, "claude.json").read_text())
|
||||
self.assertIn("/home/node", config["projects"])
|
||||
self.assertIn("/home/node/workspace", config["projects"])
|
||||
|
||||
def test_codex_forward_host_credentials_populates_egress_routes(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
|
||||
@@ -33,6 +33,7 @@ from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.pipelock import PipelockProxyPlan
|
||||
from bot_bottle.supervise import SupervisePlan
|
||||
from bot_bottle.workspace import workspace_plan
|
||||
|
||||
|
||||
SLUG = "demo-abc12"
|
||||
@@ -163,8 +164,9 @@ def _plan(
|
||||
roles=(),
|
||||
),)
|
||||
|
||||
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
||||
return DockerBottlePlan(
|
||||
spec=_spec(supervise=supervise, with_git=with_git, with_egress=with_egress),
|
||||
spec=spec,
|
||||
stage_dir=STAGE,
|
||||
slug=SLUG,
|
||||
container_name=f"bot-bottle-{SLUG}",
|
||||
@@ -189,6 +191,7 @@ def _plan(
|
||||
dockerfile="",
|
||||
guest_env={},
|
||||
),
|
||||
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -21,9 +21,12 @@ from bot_bottle.egress import EgressPlan
|
||||
from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.pipelock import PipelockProxyPlan
|
||||
from bot_bottle.workspace import workspace_plan
|
||||
|
||||
|
||||
def _plan(*, git_user: dict | None = None,
|
||||
copy_cwd: bool = False,
|
||||
user_cwd: str = "/tmp/x",
|
||||
stage_dir: Path | None = None) -> DockerBottlePlan:
|
||||
bottle_json: dict = {}
|
||||
if git_user is not None:
|
||||
@@ -34,7 +37,7 @@ def _plan(*, git_user: dict | None = None,
|
||||
})
|
||||
spec = BottleSpec(
|
||||
manifest=manifest, agent_name="demo",
|
||||
copy_cwd=False, user_cwd="/tmp/x",
|
||||
copy_cwd=copy_cwd, user_cwd=user_cwd,
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
@@ -75,6 +78,7 @@ def _plan(*, git_user: dict | None = None,
|
||||
dockerfile="",
|
||||
guest_env={},
|
||||
),
|
||||
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
|
||||
)
|
||||
|
||||
|
||||
@@ -106,6 +110,28 @@ class TestProvisionGitUser(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual([], _git_config_calls(run))
|
||||
|
||||
def test_copies_cwd_git_to_workspace_plan_path(self):
|
||||
cwd = self.stage / "cwd"
|
||||
(cwd / ".git").mkdir(parents=True)
|
||||
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
|
||||
with patch.object(_git.subprocess, "run") as run:
|
||||
_git._provision_cwd_git(plan, "bot-bottle-demo-abc12")
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"docker", "cp", f"{cwd}/.git",
|
||||
"bot-bottle-demo-abc12:/home/node/workspace/.git",
|
||||
],
|
||||
run.call_args_list[0].args[0],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
||||
"chown", "-R", "node:node", "/home/node/workspace/.git",
|
||||
],
|
||||
run.call_args_list[1].args[0],
|
||||
)
|
||||
|
||||
def test_sets_name_and_email(self):
|
||||
plan = _plan(
|
||||
git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"},
|
||||
|
||||
@@ -18,6 +18,7 @@ from bot_bottle.egress import EgressPlan
|
||||
from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.pipelock import PipelockProxyPlan
|
||||
from bot_bottle.workspace import workspace_plan
|
||||
|
||||
|
||||
def _plan(
|
||||
@@ -29,13 +30,14 @@ def _plan(
|
||||
"bottles": {"dev": {"agent_provider": {"template": "codex"}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
spec = BottleSpec(
|
||||
manifest=manifest,
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd="/tmp/x",
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
spec=BottleSpec(
|
||||
manifest=manifest,
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd="/tmp/x",
|
||||
),
|
||||
spec=spec,
|
||||
stage_dir=Path("/tmp/stage"),
|
||||
slug="demo-abc12",
|
||||
container_name="bot-bottle-demo-abc12",
|
||||
@@ -69,6 +71,7 @@ def _plan(
|
||||
agent_provision=_agent_provision(
|
||||
agent_provider_template, codex_auth_file=codex_auth_file,
|
||||
),
|
||||
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@ integration smoke."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.docker import util as docker_mod
|
||||
from bot_bottle.workspace import WorkspacePlan
|
||||
|
||||
|
||||
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
|
||||
@@ -67,5 +70,60 @@ class TestSave(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestBuildImageWithCwd(unittest.TestCase):
|
||||
def test_uses_workspace_plan_paths(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-docker-cwd.") as tmp:
|
||||
workspace = WorkspacePlan(
|
||||
enabled=True,
|
||||
host_path=Path(tmp),
|
||||
guest_home="/guest/home",
|
||||
guest_path="/guest/home/workspace",
|
||||
workdir="/guest/home/workspace",
|
||||
)
|
||||
with patch.object(docker_mod.subprocess, "run") as run:
|
||||
docker_mod.build_image_with_cwd("derived:tag", "base:tag", workspace)
|
||||
|
||||
argv = run.call_args.args[0]
|
||||
dockerfile = run.call_args.kwargs["input"]
|
||||
self.assertEqual(["docker", "build", "-t", "derived:tag", "-f", "-"], argv[:6])
|
||||
self.assertTrue(argv[6].endswith("/context"))
|
||||
self.assertIn("FROM base:tag\n", dockerfile)
|
||||
self.assertIn(
|
||||
"COPY --chown=node:node workspace/. /guest/home/workspace\n",
|
||||
dockerfile,
|
||||
)
|
||||
self.assertIn("WORKDIR /guest/home/workspace\n", dockerfile)
|
||||
|
||||
def test_staged_context_includes_hidden_files_but_not_git_dir(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-docker-cwd.") as tmp:
|
||||
root = Path(tmp)
|
||||
(root / ".gitignore").write_text("*.pyc\n")
|
||||
(root / ".dockerignore").write_text(".gitignore\n")
|
||||
(root / ".env.example").write_text("SAFE=1\n")
|
||||
(root / ".git").mkdir()
|
||||
(root / ".git" / "config").write_text("[core]\n")
|
||||
workspace = WorkspacePlan(
|
||||
enabled=True,
|
||||
host_path=root,
|
||||
guest_home="/guest/home",
|
||||
guest_path="/guest/home/workspace",
|
||||
workdir="/guest/home/workspace",
|
||||
)
|
||||
|
||||
def inspect_context(*args, **kwargs):
|
||||
context = Path(args[0][-1])
|
||||
staged = context / "workspace"
|
||||
self.assertTrue((staged / ".gitignore").is_file())
|
||||
self.assertTrue((staged / ".dockerignore").is_file())
|
||||
self.assertTrue((staged / ".env.example").is_file())
|
||||
self.assertFalse((staged / ".git").exists())
|
||||
return _ok()
|
||||
|
||||
with patch.object(
|
||||
docker_mod.subprocess, "run", side_effect=inspect_context,
|
||||
):
|
||||
docker_mod.build_image_with_cwd("derived:tag", "base:tag", workspace)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -21,6 +21,7 @@ from bot_bottle.egress import EgressPlan, EgressRoute
|
||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.pipelock import PipelockProxyPlan
|
||||
from bot_bottle.workspace import workspace_plan
|
||||
|
||||
|
||||
def _manifest() -> Manifest:
|
||||
@@ -109,6 +110,7 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
|
||||
egress_plan=_egress_plan(tmp),
|
||||
supervise_plan=None,
|
||||
agent_provision=_agent_provision(),
|
||||
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
|
||||
slug="test-00001",
|
||||
container_name="bot-bottle-test-00001",
|
||||
container_name_pinned=False,
|
||||
@@ -133,6 +135,7 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
|
||||
egress_plan=_egress_plan(tmp),
|
||||
supervise_plan=None,
|
||||
agent_provision=_agent_provision(),
|
||||
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
|
||||
slug="test-00001",
|
||||
bundle_subnet="10.99.0.0/24",
|
||||
bundle_gateway="10.99.0.1",
|
||||
|
||||
@@ -30,6 +30,7 @@ from bot_bottle.backend.smolmachines.provision import (
|
||||
provider_auth as _provider_auth,
|
||||
skills as _skills,
|
||||
supervise as _supervise,
|
||||
workspace as _workspace,
|
||||
)
|
||||
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
|
||||
from bot_bottle.backend.smolmachines.smolvm import SmolvmRunResult
|
||||
@@ -38,6 +39,7 @@ from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||
from bot_bottle.manifest import GitEntry, Manifest
|
||||
from bot_bottle.pipelock import PipelockProxyPlan
|
||||
from bot_bottle.supervise import SupervisePlan
|
||||
from bot_bottle.workspace import workspace_plan
|
||||
|
||||
|
||||
def _remote_host(g: GitEntry) -> str:
|
||||
@@ -144,6 +146,7 @@ def _plan(
|
||||
codex_auth_file=codex_auth_file,
|
||||
guest_env=dict(guest_env or {}),
|
||||
),
|
||||
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
|
||||
)
|
||||
|
||||
|
||||
@@ -846,6 +849,55 @@ class TestProvisionGitUser(unittest.TestCase):
|
||||
self.assertEqual(["user.email", "bot@example.com"], calls[0][0][7:])
|
||||
|
||||
|
||||
class TestProvisionWorkspace(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-workspace.")
|
||||
self.stage = Path(self._tmp.name)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_noop_when_copy_cwd_false(self):
|
||||
plan = _plan(copy_cwd=False, stage_dir=self.stage)
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_cp"
|
||||
) as cp, patch(
|
||||
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_workspace.provision_workspace(plan, "bot-bottle-demo-abc12")
|
||||
cp.assert_not_called()
|
||||
ex.assert_not_called()
|
||||
|
||||
def test_copies_workspace_to_plan_path_and_chowns(self):
|
||||
cwd = self.stage / "cwd"
|
||||
cwd.mkdir()
|
||||
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
|
||||
with patch(
|
||||
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_cp"
|
||||
) as cp, patch(
|
||||
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_exec"
|
||||
) as ex:
|
||||
_workspace.provision_workspace(plan, "bot-bottle-demo-abc12")
|
||||
|
||||
cp.assert_called_once_with(
|
||||
str(cwd),
|
||||
"bot-bottle-demo-abc12:/home/node/workspace",
|
||||
)
|
||||
argvs = [c.args[1] for c in ex.call_args_list]
|
||||
self.assertIn(
|
||||
["sh", "-c", "rm -rf /home/node/workspace && mkdir -p /home/node"],
|
||||
argvs,
|
||||
)
|
||||
self.assertIn(
|
||||
[
|
||||
"sh", "-c",
|
||||
"chown -R node:node /home/node/workspace && "
|
||||
"chmod 755 /home/node/workspace",
|
||||
],
|
||||
argvs,
|
||||
)
|
||||
|
||||
|
||||
class TestProvisionSupervise(unittest.TestCase):
|
||||
def test_noop_when_supervise_not_enabled(self):
|
||||
with patch(
|
||||
|
||||
@@ -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