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 ..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
+5 -1
View File
@@ -22,6 +22,7 @@ from ...git_gate import GitGate
from ...log import die
from ...pipelock import PipelockProxy
from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan
from .. import BottleSpec
from . import util as docker_mod
from .bottle_plan import DockerBottlePlan
@@ -62,6 +63,8 @@ def resolve_plan(
bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
guest_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
# mints a random-suffixed identity (so parallel runs of the same
@@ -177,7 +180,7 @@ 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),
@@ -245,6 +248,7 @@ def resolve_plan(
supervise_plan=supervise_plan,
use_runsc=use_runsc,
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 ...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,7 +133,7 @@ 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,
@@ -181,6 +184,7 @@ def resolve_plan(
egress_plan=egress_plan,
supervise_plan=supervise_plan,
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(),
)