feat(workspace): add shared workspace plan

This commit is contained in:
2026-06-02 16:56:57 +00:00
parent d01f4b6613
commit 5308d53288
9 changed files with 91 additions and 10 deletions
+9 -1
View File
@@ -46,6 +46,7 @@ from ..log import die, info
from ..manifest import GitEntry, Manifest from ..manifest import GitEntry, Manifest
from ..supervise import SupervisePlan from ..supervise import SupervisePlan
from ..util import expand_tilde from ..util import expand_tilde
from ..workspace import WorkspacePlan
from .print_util import print_multi, visible_agent_env_names from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir from .util import host_skill_dir
@@ -79,6 +80,7 @@ class BottlePlan(ABC):
egress_plan: EgressPlan egress_plan: EgressPlan
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan agent_provision: AgentProvisionPlan
workspace_plan: WorkspacePlan
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr.""" """Render the y/N preflight summary to stderr."""
@@ -320,7 +322,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
decide whether to add provider-specific prompt args to the agent's decide whether to add provider-specific prompt args to the agent's
argv. argv.
Default orchestration: ca → prompt → skills → git → Default orchestration: ca → prompt → skills → workspace → git →
supervise. CA install runs first so the agent's trust store supervise. CA install runs first so the agent's trust store
is rebuilt before anything inside the agent makes a TLS call. is rebuilt before anything inside the agent makes a TLS call.
Subclasses typically don't override this; they implement the Subclasses typically don't override this; they implement the
@@ -335,6 +337,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
prompt_path = self.provision_prompt(plan, target) prompt_path = self.provision_prompt(plan, target)
self.provision_provider_auth(plan, target) self.provision_provider_auth(plan, target)
self.provision_skills(plan, target) self.provision_skills(plan, target)
self.provision_workspace(plan, target)
self.provision_git(plan, target) self.provision_git(plan, target)
self.provision_supervise(plan, target) self.provision_supervise(plan, target)
return prompt_path return prompt_path
@@ -365,6 +368,11 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Copy the agent's named skills from the host into the """Copy the agent's named skills from the host into the
running bottle. No-op when the agent has no skills.""" running bottle. No-op when the agent has no skills."""
def provision_workspace(self, plan: PlanT, target: str) -> None:
"""Copy the operator workspace into the running bottle when
the backend cannot bake it into the agent image. Default is
no-op for backends like Docker that handle this before launch."""
@abstractmethod @abstractmethod
def provision_git(self, plan: PlanT, target: str) -> None: def provision_git(self, plan: PlanT, target: str) -> None:
"""Copy the host's cwd `.git` directory into the running """Copy the host's cwd `.git` directory into the running
+5 -1
View File
@@ -22,6 +22,7 @@ from ...git_gate import GitGate
from ...log import die from ...log import die
from ...pipelock import PipelockProxy from ...pipelock import PipelockProxy
from ...supervise import Supervise from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan
from .. import BottleSpec from .. import BottleSpec
from . import util as docker_mod from . import util as docker_mod
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
@@ -62,6 +63,8 @@ def resolve_plan(
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template) provider_runtime = runtime_for(provider.template)
guest_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
# PRD 0016 follow-up: identity, not bare slug. A fresh `start` # PRD 0016 follow-up: identity, not bare slug. A fresh `start`
# mints a random-suffixed identity (so parallel runs of the same # mints a random-suffixed identity (so parallel runs of the same
@@ -177,7 +180,7 @@ 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),
@@ -245,6 +248,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,
) )
+5 -1
View File
@@ -32,6 +32,7 @@ from ...env import resolve_env
from ...git_gate import GitGate from ...git_gate import GitGate
from ...pipelock import PipelockProxy from ...pipelock import PipelockProxy
from ...supervise import Supervise from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
from .util import smolmachines_bundle_subnet, smolmachines_preflight from .util import smolmachines_bundle_subnet, smolmachines_preflight
@@ -60,6 +61,8 @@ def resolve_plan(
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template) provider_runtime = runtime_for(provider.template)
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node")
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
slug = spec.identity or bottle_identity(spec.agent_name) slug = spec.identity or bottle_identity(spec.agent_name)
@@ -130,7 +133,7 @@ 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,
@@ -181,6 +184,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,
) )
+52
View File
@@ -0,0 +1,52 @@
"""Backend-neutral plan for porting the operator workspace."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
WORKSPACE_DIRNAME = "workspace"
DEFAULT_WORKSPACE_OWNER = "node:node"
DEFAULT_WORKSPACE_MODE = "755"
class WorkspaceSpec(Protocol):
copy_cwd: bool
user_cwd: str
@dataclass(frozen=True)
class WorkspacePlan:
"""Resolved workspace contract shared by all bottle backends."""
enabled: bool
host_path: Path
guest_home: str
guest_path: str
workdir: str
owner: str = DEFAULT_WORKSPACE_OWNER
mode: str = DEFAULT_WORKSPACE_MODE
copy_contents: bool = True
copy_git: bool = True
has_host_git_dir: bool = False
def workspace_plan(spec: WorkspaceSpec, *, guest_home: str) -> WorkspacePlan:
"""Resolve the in-bottle workspace path from CLI intent."""
host_path = Path(spec.user_cwd).expanduser()
if spec.copy_cwd:
guest_path = f"{guest_home.rstrip('/')}/{WORKSPACE_DIRNAME}"
workdir = guest_path
else:
guest_path = guest_home
workdir = guest_home
return WorkspacePlan(
enabled=spec.copy_cwd,
host_path=host_path,
guest_home=guest_home,
guest_path=guest_path,
workdir=workdir,
has_host_git_dir=(host_path / ".git").is_dir(),
)
+4 -1
View File
@@ -33,6 +33,7 @@ from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
SLUG = "demo-abc12" SLUG = "demo-abc12"
@@ -163,8 +164,9 @@ def _plan(
roles=(), roles=(),
),) ),)
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
return DockerBottlePlan( return DockerBottlePlan(
spec=_spec(supervise=supervise, with_git=with_git, with_egress=with_egress), spec=spec,
stage_dir=STAGE, stage_dir=STAGE,
slug=SLUG, slug=SLUG,
container_name=f"bot-bottle-{SLUG}", container_name=f"bot-bottle-{SLUG}",
@@ -189,6 +191,7 @@ def _plan(
dockerfile="", dockerfile="",
guest_env={}, guest_env={},
), ),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
) )
@@ -21,6 +21,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(*, git_user: dict | None = None, def _plan(*, git_user: dict | None = None,
@@ -75,6 +76,7 @@ def _plan(*, git_user: dict | None = None,
dockerfile="", dockerfile="",
guest_env={}, guest_env={},
), ),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
) )
@@ -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"),
) )
+3
View File
@@ -21,6 +21,7 @@ from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan
def _manifest() -> Manifest: def _manifest() -> Manifest:
@@ -109,6 +110,7 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
egress_plan=_egress_plan(tmp), egress_plan=_egress_plan(tmp),
supervise_plan=None, supervise_plan=None,
agent_provision=_agent_provision(), agent_provision=_agent_provision(),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
slug="test-00001", slug="test-00001",
container_name="bot-bottle-test-00001", container_name="bot-bottle-test-00001",
container_name_pinned=False, container_name_pinned=False,
@@ -133,6 +135,7 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
egress_plan=_egress_plan(tmp), egress_plan=_egress_plan(tmp),
supervise_plan=None, supervise_plan=None,
agent_provision=_agent_provision(), agent_provision=_agent_provision(),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
slug="test-00001", slug="test-00001",
bundle_subnet="10.99.0.0/24", bundle_subnet="10.99.0.0/24",
bundle_gateway="10.99.0.1", bundle_gateway="10.99.0.1",
@@ -38,6 +38,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 +145,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"),
) )