refactor(backend): pass Bottle to provisioners instead of target string
Closes #178. The backend provision functions now receive a Bottle handle with exec / cp_in methods instead of a raw target string. Provisioner modules use bottle.exec and bottle.cp_in in place of inlined subprocess.run(["docker", "exec"/"cp", ...]) and direct _smolvm.machine_cp / machine_exec calls. This decouples the provisioners from backend-specific runtime primitives so future refactors (e.g. the supervise rework) can swap the bottle's exec implementation without touching every provisioner. Each launch.py constructs the Bottle handle before calling provision so it can be passed in; provision_prompt's return value is wired back onto the bottle's prompt path attribute after the fact.
This commit was merged in pull request #179.
This commit is contained in:
@@ -312,15 +312,13 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
|
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
|
||||||
"""Build/run the bottle and yield a handle; tear down on exit."""
|
"""Build/run the bottle and yield a handle; tear down on exit."""
|
||||||
|
|
||||||
def provision(self, plan: PlanT, target: str) -> str | None:
|
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
|
||||||
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
||||||
the running bottle. Called from `launch` after the container
|
the running bottle. Called from `launch` after the container
|
||||||
/ machine is up. `target` identifies the running instance in
|
/ machine is up. Returns the in-container prompt path if a
|
||||||
backend-specific terms (Docker: resolved container name; fly:
|
prompt was provisioned, else None — the Bottle handle uses it
|
||||||
machine id). Returns the in-container prompt path if a prompt
|
to decide whether to add provider-specific prompt args to the
|
||||||
was provisioned, else None — the Bottle handle uses it to
|
agent's argv.
|
||||||
decide whether to add provider-specific prompt args to the agent's
|
|
||||||
argv.
|
|
||||||
|
|
||||||
Default orchestration: ca → prompt → skills → workspace → 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
|
||||||
@@ -333,16 +331,16 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
on the agent's HTTP_PROXY path so every tool that respects
|
on the agent's HTTP_PROXY path so every tool that respects
|
||||||
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
||||||
intercepted without per-tool reconfiguration."""
|
intercepted without per-tool reconfiguration."""
|
||||||
self.provision_ca(plan, target)
|
self.provision_ca(plan, bottle)
|
||||||
prompt_path = self.provision_prompt(plan, target)
|
prompt_path = self.provision_prompt(plan, bottle)
|
||||||
self.provision_provider_auth(plan, target)
|
self.provision_provider_auth(plan, bottle)
|
||||||
self.provision_skills(plan, target)
|
self.provision_skills(plan, bottle)
|
||||||
self.provision_workspace(plan, target)
|
self.provision_workspace(plan, bottle)
|
||||||
self.provision_git(plan, target)
|
self.provision_git(plan, bottle)
|
||||||
self.provision_supervise(plan, target)
|
self.provision_supervise(plan, bottle)
|
||||||
return prompt_path
|
return prompt_path
|
||||||
|
|
||||||
def provision_ca(self, plan: PlanT, target: str) -> None:
|
def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||||
"""Install the per-bottle CA into the agent's trust store so
|
"""Install the per-bottle CA into the agent's trust store so
|
||||||
the agent trusts the bumped CONNECT cert egress (was
|
the agent trusts the bumped CONNECT cert egress (was
|
||||||
pipelock, pre-PRD-0017) presents. Default impl is a no-op so
|
pipelock, pre-PRD-0017) presents. Default impl is a no-op so
|
||||||
@@ -351,34 +349,34 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
backend overrides to docker-cp the cert in and run
|
backend overrides to docker-cp the cert in and run
|
||||||
`update-ca-certificates`."""
|
`update-ca-certificates`."""
|
||||||
|
|
||||||
def provision_provider_auth(self, plan: PlanT, target: str) -> None:
|
def provision_provider_auth(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||||
"""Install non-secret provider auth marker files into the agent
|
"""Install non-secret provider auth marker files into the agent
|
||||||
home when a provider needs them to select the right auth mode.
|
home when a provider needs them to select the right auth mode.
|
||||||
The default is no-op."""
|
The default is no-op."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def provision_prompt(self, plan: PlanT, target: str) -> str | None:
|
def provision_prompt(self, plan: PlanT, bottle: "Bottle") -> str | None:
|
||||||
"""Copy the prompt file into the running bottle. Returns the
|
"""Copy the prompt file into the running bottle. Returns the
|
||||||
in-container path iff the agent has a non-empty prompt;
|
in-container path iff the agent has a non-empty prompt;
|
||||||
callers use the return value to decide whether to add
|
callers use the return value to decide whether to add
|
||||||
provider-specific prompt args to the agent's argv."""
|
provider-specific prompt args to the agent's argv."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def provision_skills(self, plan: PlanT, target: str) -> None:
|
def provision_skills(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||||
"""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:
|
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||||
"""Copy the operator workspace into the running bottle when
|
"""Copy the operator workspace into the running bottle when
|
||||||
the backend cannot bake it into the agent image. Default is
|
the backend cannot bake it into the agent image. Default is
|
||||||
no-op for backends like Docker that handle this before launch."""
|
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, bottle: "Bottle") -> None:
|
||||||
"""Copy the host's cwd `.git` directory into the running
|
"""Copy the host's cwd `.git` directory into the running
|
||||||
bottle if the user requested --cwd. No-op otherwise."""
|
bottle if the user requested --cwd. No-op otherwise."""
|
||||||
|
|
||||||
def provision_supervise(self, plan: PlanT, target: str) -> None:
|
def provision_supervise(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||||
"""Write the in-bottle Claude Code MCP config so the agent
|
"""Write the in-bottle Claude Code MCP config so the agent
|
||||||
discovers the per-bottle supervise sidecar (PRD 0013).
|
discovers the per-bottle supervise sidecar (PRD 0013).
|
||||||
No-op when bottle.supervise is False or the backend doesn't
|
No-op when bottle.supervise is False or the backend doesn't
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from contextlib import contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator, Sequence
|
from typing import Generator, Sequence
|
||||||
|
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
|
||||||
from . import cleanup as _cleanup
|
from . import cleanup as _cleanup
|
||||||
from . import enumerate as _enumerate
|
from . import enumerate as _enumerate
|
||||||
from . import launch as _launch
|
from . import launch as _launch
|
||||||
@@ -57,23 +57,23 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def provision_ca(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_ca(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
_ca.provision_ca(plan, target)
|
_ca.provision_ca(plan, bottle)
|
||||||
|
|
||||||
def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
|
def provision_prompt(self, plan: DockerBottlePlan, bottle: Bottle) -> str | None:
|
||||||
return _prompt.provision_prompt(plan, target)
|
return _prompt.provision_prompt(plan, bottle)
|
||||||
|
|
||||||
def provision_provider_auth(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_provider_auth(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
_provider_auth.provision_provider_auth(plan, target)
|
_provider_auth.provision_provider_auth(plan, bottle)
|
||||||
|
|
||||||
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_skills(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
_skills.provision_skills(plan, target)
|
_skills.provision_skills(plan, bottle)
|
||||||
|
|
||||||
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_git(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
_git.provision_git(plan, target)
|
_git.provision_git(plan, bottle)
|
||||||
|
|
||||||
def provision_supervise(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_supervise(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
_supervise_prov.provision_supervise(plan, target)
|
_supervise_prov.provision_supervise(plan, bottle)
|
||||||
|
|
||||||
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
||||||
return _cleanup.prepare_cleanup()
|
return _cleanup.prepare_cleanup()
|
||||||
|
|||||||
@@ -208,19 +208,21 @@ def launch(
|
|||||||
compose_dump_logs, project, compose_file, compose_log_path(state_dir),
|
compose_dump_logs, project, compose_file, compose_log_path(state_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 8: provision. Unchanged — uses `docker exec` against
|
# Step 8: provision. Create the bottle first so provisioners
|
||||||
# the agent container by its known name.
|
# can use bottle.exec / bottle.cp_in; set the prompt path
|
||||||
prompt_path = provision(plan, plan.container_name)
|
# returned by provision_prompt after the fact.
|
||||||
|
bottle = DockerBottle(
|
||||||
|
plan.container_name,
|
||||||
|
teardown,
|
||||||
|
None,
|
||||||
|
agent_command=plan.agent_command,
|
||||||
|
agent_prompt_mode=plan.agent_prompt_mode,
|
||||||
|
)
|
||||||
|
bottle._prompt_path = provision(plan, bottle)
|
||||||
|
|
||||||
# Step 9: yield. exec_agent continues to use `docker exec -it`
|
# Step 9: yield. exec_agent continues to use `docker exec -it`
|
||||||
# — the agent runs `sleep infinity` per the renderer's
|
# — the agent runs `sleep infinity` per the renderer's
|
||||||
# service spec.
|
# service spec.
|
||||||
yield DockerBottle(
|
yield bottle
|
||||||
plan.container_name,
|
|
||||||
teardown,
|
|
||||||
prompt_path,
|
|
||||||
agent_command=plan.agent_command,
|
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
teardown()
|
teardown()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Per-provisioner modules for the Docker backend.
|
"""Per-provisioner modules for the Docker backend.
|
||||||
|
|
||||||
Each module exports one top-level function:
|
Each module exports one top-level function:
|
||||||
provision_<thing>(plan: DockerBottlePlan, target: str) -> ...
|
provision_<thing>(plan: DockerBottlePlan, bottle: Bottle) -> ...
|
||||||
|
|
||||||
`DockerBottleBackend.provision_*` methods delegate to these. The
|
`DockerBottleBackend.provision_*` methods delegate to these. The
|
||||||
abstract `BottleBackend.provision_*` surface is unchanged; this
|
abstract `BottleBackend.provision_*` surface is unchanged; this
|
||||||
|
|||||||
@@ -31,33 +31,21 @@ stage dir; nothing in the agent ever sees it."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
from ... import Bottle
|
||||||
|
|
||||||
from ...util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
|
from ...util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
def provision_ca(plan: DockerBottlePlan, target: str) -> None:
|
def provision_ca(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Copy the agent-facing CA cert into the agent, rebuild the
|
"""Copy the agent-facing CA cert into the agent, rebuild the
|
||||||
trust bundle, emit a one-line fingerprint log. Called from
|
trust bundle, emit a one-line fingerprint log. Called from
|
||||||
`BottleBackend.provision` after the agent container is up."""
|
`BottleBackend.provision` after the agent container is up."""
|
||||||
container = target
|
|
||||||
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
|
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
|
||||||
|
|
||||||
subprocess.run(
|
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
||||||
["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"],
|
bottle.exec(
|
||||||
stdout=subprocess.DEVNULL,
|
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
|
||||||
check=True,
|
user="root",
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", "-u", "0", container, "chmod", "644", AGENT_CA_PATH],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", "-u", "0", container, "update-ca-certificates"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log_ca_fingerprint(cert_host_path, label)
|
log_ca_fingerprint(cert_host_path, label)
|
||||||
|
|||||||
@@ -19,74 +19,63 @@ Three concerns, all about git in the agent:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import shlex
|
||||||
|
|
||||||
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
|
||||||
from .. import util as docker_mod
|
from ... import Bottle
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
def provision_git(plan: DockerBottlePlan, target: str) -> None:
|
def provision_git(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Set up git inside the bottle. Runs all three subcases; each
|
"""Set up git inside the bottle. Runs all three subcases; each
|
||||||
no-ops when its condition isn't met."""
|
no-ops when its condition isn't met."""
|
||||||
_provision_cwd_git(plan, target)
|
_provision_cwd_git(plan, bottle)
|
||||||
_provision_git_gate_config(plan, target)
|
_provision_git_gate_config(plan, bottle)
|
||||||
_provision_git_user(plan, target)
|
_provision_git_user(plan, bottle)
|
||||||
|
|
||||||
|
|
||||||
def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
|
def _provision_cwd_git(plan: DockerBottlePlan, bottle: Bottle) -> 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."""
|
||||||
workspace = plan.workspace_plan
|
workspace = plan.workspace_plan
|
||||||
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
|
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
|
||||||
return
|
return
|
||||||
container = target
|
|
||||||
guest_workspace_git = f"{workspace.guest_path}/.git"
|
guest_workspace_git = f"{workspace.guest_path}/.git"
|
||||||
host_git = str(workspace.host_path / ".git")
|
host_git = str(workspace.host_path / ".git")
|
||||||
info(f"copying {host_git} -> {container}:{guest_workspace_git}")
|
info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
|
||||||
subprocess.run(
|
bottle.cp_in(host_git, guest_workspace_git)
|
||||||
["docker", "cp", host_git, f"{container}:{guest_workspace_git}"],
|
bottle.exec(
|
||||||
stdout=subprocess.DEVNULL,
|
f"chown -R {shlex.quote(workspace.owner)} {shlex.quote(guest_workspace_git)}",
|
||||||
check=True,
|
user="root",
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "exec", "-u", "0", container,
|
|
||||||
"chown", "-R", workspace.owner, guest_workspace_git,
|
|
||||||
],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
|
def _provision_git_gate_config(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Write ~/.gitconfig in the bottle with the git-gate
|
"""Write ~/.gitconfig in the bottle with the git-gate
|
||||||
insteadOf rules. No-op when the bottle has no `git` entries."""
|
insteadOf rules. No-op when the bottle has no `git` entries."""
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
if not bottle.git:
|
if not manifest_bottle.git:
|
||||||
return
|
return
|
||||||
container = target
|
|
||||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
container_gitconfig = f"{container_home}/.gitconfig"
|
container_gitconfig = f"{container_home}/.gitconfig"
|
||||||
|
|
||||||
content = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME)
|
content = git_gate_render_gitconfig(manifest_bottle.git, GIT_GATE_HOSTNAME)
|
||||||
config_file = plan.stage_dir / "agent_gitconfig"
|
config_file = plan.stage_dir / "agent_gitconfig"
|
||||||
config_file.write_text(content)
|
config_file.write_text(content)
|
||||||
config_file.chmod(0o600)
|
config_file.chmod(0o600)
|
||||||
|
|
||||||
info(f"writing {container_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
|
info(f"writing {container_gitconfig} with {len(manifest_bottle.git)} insteadOf rule(s)")
|
||||||
subprocess.run(
|
bottle.cp_in(str(config_file), container_gitconfig)
|
||||||
["docker", "cp", str(config_file), f"{container}:{container_gitconfig}"],
|
bottle.exec(
|
||||||
stdout=subprocess.DEVNULL,
|
f"chown node:node {shlex.quote(container_gitconfig)} && "
|
||||||
check=True,
|
f"chmod 644 {shlex.quote(container_gitconfig)}",
|
||||||
|
user="root",
|
||||||
)
|
)
|
||||||
docker_mod.docker_exec_root(container, ["chown", "node:node", container_gitconfig])
|
|
||||||
docker_mod.docker_exec_root(container, ["chmod", "644", container_gitconfig])
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_user(plan: DockerBottlePlan, target: str) -> None:
|
def _provision_git_user(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Apply `git config --global user.{name,email}` inside the
|
"""Apply `git config --global user.{name,email}` inside the
|
||||||
bottle so the agent's commits are attributed to the operator-
|
bottle so the agent's commits are attributed to the operator-
|
||||||
chosen identity instead of the agent image's default
|
chosen identity instead of the agent image's default
|
||||||
@@ -101,23 +90,19 @@ def _provision_git_user(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
Each field set independently — name-only or email-only
|
Each field set independently — name-only or email-only
|
||||||
configs only run the `git config` line for the field
|
configs only run the `git config` line for the field
|
||||||
present."""
|
present."""
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
gu = bottle.git_user
|
gu = manifest_bottle.git_user
|
||||||
if gu.is_empty():
|
if gu.is_empty():
|
||||||
return
|
return
|
||||||
if gu.name:
|
if gu.name:
|
||||||
info(f"git config --global user.name = {gu.name!r}")
|
info(f"git config --global user.name = {gu.name!r}")
|
||||||
subprocess.run(
|
bottle.exec(
|
||||||
["docker", "exec", "-u", "node", target,
|
f"git config --global user.name {shlex.quote(gu.name)}",
|
||||||
"git", "config", "--global", "user.name", gu.name],
|
user="node",
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
)
|
||||||
if gu.email:
|
if gu.email:
|
||||||
info(f"git config --global user.email = {gu.email!r}")
|
info(f"git config --global user.email = {gu.email!r}")
|
||||||
subprocess.run(
|
bottle.exec(
|
||||||
["docker", "exec", "-u", "node", target,
|
f"git config --global user.email {shlex.quote(gu.email)}",
|
||||||
"git", "config", "--global", "user.email", gu.email],
|
user="node",
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,36 +7,26 @@ actually has a prompt — the return value signals which case."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
from ... import Bottle
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
def provision_prompt(plan: DockerBottlePlan, target: str) -> str | None:
|
def provision_prompt(plan: DockerBottlePlan, bottle: Bottle) -> str | None:
|
||||||
"""Copy the prompt file into the container, fix ownership/mode.
|
"""Copy the prompt file into the container, fix ownership/mode.
|
||||||
Returns the in-container path if the agent has a non-empty
|
Returns the in-container path if the agent has a non-empty
|
||||||
prompt (drives --append-system-prompt-file), else None. The
|
prompt (drives --append-system-prompt-file), else None. The
|
||||||
file is copied either way so the path always exists."""
|
file is copied either way so the path always exists."""
|
||||||
container = target
|
|
||||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
|
in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
|
||||||
|
|
||||||
subprocess.run(
|
bottle.cp_in(str(plan.prompt_file), in_container_prompt_path)
|
||||||
["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
# `docker cp` preserves host UID; re-own/mode as root so node
|
# `docker cp` preserves host UID; re-own/mode as root so node
|
||||||
# can read its own mode-600 prompt regardless of host UID.
|
# can read its own mode-600 prompt regardless of host UID.
|
||||||
subprocess.run(
|
bottle.exec(
|
||||||
["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path],
|
f"chown node:node {in_container_prompt_path} && "
|
||||||
stdout=subprocess.DEVNULL,
|
f"chmod 600 {in_container_prompt_path}",
|
||||||
check=True,
|
user="root",
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||||
|
|||||||
@@ -2,35 +2,34 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import shlex
|
||||||
|
|
||||||
|
from ....log import die
|
||||||
|
from ... import Bottle
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
def provision_provider_auth(plan: DockerBottlePlan, target: str) -> None:
|
def provision_provider_auth(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Apply provider-owned guest setup through Docker primitives."""
|
"""Apply provider-owned guest setup through the bottle's exec / cp_in."""
|
||||||
provision = plan.agent_provision
|
provision = plan.agent_provision
|
||||||
for d in provision.dirs:
|
for d in provision.dirs:
|
||||||
_exec(target, ["mkdir", "-p", d.guest_path])
|
_exec(bottle, f"mkdir -p {shlex.quote(d.guest_path)}", d.guest_path)
|
||||||
_exec(target, ["chown", d.owner, d.guest_path])
|
_exec(bottle, f"chown {shlex.quote(d.owner)} {shlex.quote(d.guest_path)}", d.guest_path)
|
||||||
_exec(target, ["chmod", d.mode, d.guest_path])
|
_exec(bottle, f"chmod {shlex.quote(d.mode)} {shlex.quote(d.guest_path)}", d.guest_path)
|
||||||
for command in provision.pre_copy:
|
for command in provision.pre_copy:
|
||||||
_exec(target, list(command.argv))
|
_exec(bottle, shlex.join(command.argv), command.error)
|
||||||
for f in provision.files:
|
for f in provision.files:
|
||||||
subprocess.run(
|
bottle.cp_in(str(f.host_path), f.guest_path)
|
||||||
["docker", "cp", str(f.host_path), f"{target}:{f.guest_path}"],
|
_exec(bottle, f"chown {shlex.quote(f.owner)} {shlex.quote(f.guest_path)}", f.guest_path)
|
||||||
stdout=subprocess.DEVNULL,
|
_exec(bottle, f"chmod {shlex.quote(f.mode)} {shlex.quote(f.guest_path)}", f.guest_path)
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
_exec(target, ["chown", f.owner, f.guest_path])
|
|
||||||
_exec(target, ["chmod", f.mode, f.guest_path])
|
|
||||||
for command in provision.verify:
|
for command in provision.verify:
|
||||||
_exec(target, list(command.argv))
|
_exec(bottle, shlex.join(command.argv), command.error)
|
||||||
|
|
||||||
|
|
||||||
def _exec(target: str, argv: list[str]) -> None:
|
def _exec(bottle: Bottle, script: str, error: str) -> None:
|
||||||
subprocess.run(
|
result = bottle.exec(script, user="root")
|
||||||
["docker", "exec", "-u", "0", target, *argv],
|
if result.returncode != 0:
|
||||||
stdout=subprocess.DEVNULL,
|
detail = (result.stderr or result.stdout).strip()
|
||||||
check=True,
|
if detail:
|
||||||
)
|
detail = f": {detail}"
|
||||||
|
die(f"agent provider provisioning: {error}{detail}")
|
||||||
|
|||||||
@@ -9,54 +9,36 @@ a partial container."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ....log import die, info
|
from ....log import die, info
|
||||||
from ...util import host_skill_dir
|
from ...util import host_skill_dir
|
||||||
|
from ... import Bottle
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
def provision_skills(plan: DockerBottlePlan, target: str) -> None:
|
def provision_skills(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Copy each of the agent's named skills from the host's
|
"""Copy each of the agent's named skills from the host's
|
||||||
~/.claude/skills/<name>/ into the container's equivalent path.
|
~/.claude/skills/<name>/ into the container's equivalent path.
|
||||||
For each skill: ensure parent dir, wipe any prior copy, then
|
For each skill: ensure parent dir, wipe any prior copy, then
|
||||||
`docker cp <host>/. <container>:<dst>/` so the contents are
|
`cp_in <host>/. <container>:<dst>/` so the contents are
|
||||||
copied into a freshly-created destination dir. No-op when the
|
copied into a freshly-created destination dir. No-op when the
|
||||||
agent has no skills."""
|
agent has no skills."""
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
|
|
||||||
container = target
|
|
||||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
skills_dir = os.environ.get(
|
skills_dir = os.environ.get(
|
||||||
"BOT_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
|
"BOT_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
|
||||||
)
|
)
|
||||||
|
|
||||||
subprocess.run(
|
bottle.exec(f"mkdir -p {skills_dir}", user="node")
|
||||||
["docker", "exec", container, "mkdir", "-p", skills_dir],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
for n in agent.skills:
|
for n in agent.skills:
|
||||||
src = host_skill_dir(n)
|
src = host_skill_dir(n)
|
||||||
if not os.path.isdir(src):
|
if not os.path.isdir(src):
|
||||||
die(f"skill '{n}' disappeared from host between validation and copy at {src}.")
|
die(f"skill '{n}' disappeared from host between validation and copy at {src}.")
|
||||||
dst = f"{skills_dir}/{n}"
|
dst = f"{skills_dir}/{n}"
|
||||||
info(f"copying skill {n} into {container}:{dst}")
|
info(f"copying skill {n} into {bottle.name}:{dst}")
|
||||||
subprocess.run(
|
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="node")
|
||||||
["docker", "exec", container, "rm", "-rf", dst],
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", container, "mkdir", "-p", dst],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "cp", f"{src}/.", f"{container}:{dst}/"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ sidecar that isn't running.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ....log import info, warn
|
from ....log import info, warn
|
||||||
from ....supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
|
from ....supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
|
||||||
|
from ... import Bottle
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ def supervise_mcp_url() -> str:
|
|||||||
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
||||||
|
|
||||||
|
|
||||||
def provision_supervise(plan: DockerBottlePlan, target: str) -> None:
|
def provision_supervise(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Run `claude mcp add` inside the agent container to register
|
"""Run `claude mcp add` inside the agent container to register
|
||||||
the supervise sidecar in claude-code's user config. No-op when
|
the supervise sidecar in claude-code's user config. No-op when
|
||||||
bottle.supervise is False.
|
bottle.supervise is False.
|
||||||
@@ -43,16 +42,11 @@ def provision_supervise(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
if plan.supervise_plan is None:
|
if plan.supervise_plan is None:
|
||||||
return
|
return
|
||||||
url = supervise_mcp_url()
|
url = supervise_mcp_url()
|
||||||
argv = [
|
|
||||||
"docker", "exec", "-u", "node", target,
|
|
||||||
"claude", "mcp", "add",
|
|
||||||
"--scope", "user",
|
|
||||||
"--transport", "http",
|
|
||||||
_SUPERVISE_MCP_NAME,
|
|
||||||
url,
|
|
||||||
]
|
|
||||||
info(f"registering supervise MCP server in agent claude config → {url}")
|
info(f"registering supervise MCP server in agent claude config → {url}")
|
||||||
r = subprocess.run(argv, capture_output=True, text=True, check=False)
|
r = bottle.exec(
|
||||||
|
f"claude mcp add --scope user --transport http {_SUPERVISE_MCP_NAME} {url}",
|
||||||
|
user="node",
|
||||||
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
warn(
|
warn(
|
||||||
f"`claude mcp add supervise` failed (exit {r.returncode}): "
|
f"`claude mcp add supervise` failed (exit {r.returncode}): "
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from contextlib import contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator, Sequence
|
from typing import Generator, Sequence
|
||||||
|
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
|
||||||
from . import cleanup as _cleanup
|
from . import cleanup as _cleanup
|
||||||
from . import enumerate as _enumerate
|
from . import enumerate as _enumerate
|
||||||
from . import launch as _launch
|
from . import launch as _launch
|
||||||
@@ -54,39 +54,39 @@ class SmolmachinesBottleBackend(
|
|||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def provision_ca(
|
def provision_ca(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
) -> None:
|
) -> None:
|
||||||
_ca.provision_ca(plan, target)
|
_ca.provision_ca(plan, bottle)
|
||||||
|
|
||||||
def provision_prompt(
|
def provision_prompt(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
return _prompt.provision_prompt(plan, target)
|
return _prompt.provision_prompt(plan, bottle)
|
||||||
|
|
||||||
def provision_provider_auth(
|
def provision_provider_auth(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
) -> None:
|
) -> None:
|
||||||
_provider_auth.provision_provider_auth(plan, target)
|
_provider_auth.provision_provider_auth(plan, bottle)
|
||||||
|
|
||||||
def provision_skills(
|
def provision_skills(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
) -> None:
|
) -> None:
|
||||||
_skills.provision_skills(plan, target)
|
_skills.provision_skills(plan, bottle)
|
||||||
|
|
||||||
def provision_workspace(
|
def provision_workspace(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
) -> None:
|
) -> None:
|
||||||
_workspace.provision_workspace(plan, target)
|
_workspace.provision_workspace(plan, bottle)
|
||||||
|
|
||||||
def provision_git(
|
def provision_git(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
) -> None:
|
) -> None:
|
||||||
_git.provision_git(plan, target)
|
_git.provision_git(plan, bottle)
|
||||||
|
|
||||||
def provision_supervise(
|
def provision_supervise(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
) -> None:
|
) -> None:
|
||||||
_supervise.provision_supervise(plan, target)
|
_supervise.provision_supervise(plan, bottle)
|
||||||
|
|
||||||
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
|
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
|
||||||
return _cleanup.prepare_cleanup()
|
return _cleanup.prepare_cleanup()
|
||||||
|
|||||||
@@ -113,15 +113,16 @@ def launch(
|
|||||||
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
||||||
_init_vm(plan)
|
_init_vm(plan)
|
||||||
|
|
||||||
prompt_path = provision(plan, plan.machine_name)
|
bottle = SmolmachinesBottle(
|
||||||
|
|
||||||
yield SmolmachinesBottle(
|
|
||||||
plan.machine_name,
|
plan.machine_name,
|
||||||
prompt_path=prompt_path,
|
prompt_path=None,
|
||||||
guest_env=plan.guest_env,
|
guest_env=plan.guest_env,
|
||||||
agent_command=plan.agent_command,
|
agent_command=plan.agent_command,
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
agent_prompt_mode=plan.agent_prompt_mode,
|
||||||
)
|
)
|
||||||
|
bottle._prompt_path = provision(plan, bottle)
|
||||||
|
|
||||||
|
yield bottle
|
||||||
finally:
|
finally:
|
||||||
_teardown_smolmachines(stack, plan)
|
_teardown_smolmachines(stack, plan)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
trust store (PRD 0023 chunk 4d).
|
trust store (PRD 0023 chunk 4d).
|
||||||
|
|
||||||
Mirrors `backend.docker.provision.ca`: select the right CA (egress
|
Mirrors `backend.docker.provision.ca`: select the right CA (egress
|
||||||
when the bottle has routes, else pipelock), `smolvm machine cp` it
|
when the bottle has routes, else pipelock), copy it to Debian's
|
||||||
to Debian's `/usr/local/share/ca-certificates/` path,
|
`/usr/local/share/ca-certificates/` path,
|
||||||
`update-ca-certificates` to rebuild the trust bundle, and log the
|
`update-ca-certificates` to rebuild the trust bundle, and log the
|
||||||
fingerprint once. The selected cert depends on the agent's
|
fingerprint once. The selected cert depends on the agent's
|
||||||
HTTP_PROXY target — same logic as the docker backend, since the
|
HTTP_PROXY target — same logic as the docker backend, since the
|
||||||
@@ -24,20 +24,20 @@ from ...util import (
|
|||||||
log_ca_fingerprint,
|
log_ca_fingerprint,
|
||||||
select_ca_cert,
|
select_ca_cert,
|
||||||
)
|
)
|
||||||
from .. import smolvm as _smolvm
|
from ... import Bottle, ExecResult
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
_SIGKILL_EXIT = 128 + 9
|
_SIGKILL_EXIT = 128 + 9
|
||||||
|
|
||||||
|
|
||||||
def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def provision_ca(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Copy the agent-facing CA cert into the guest, rebuild the
|
"""Copy the agent-facing CA cert into the guest, rebuild the
|
||||||
trust bundle, emit a one-line fingerprint log. Called from
|
trust bundle, emit a one-line fingerprint log. Called from
|
||||||
`BottleBackend.provision` after the smolvm guest is up."""
|
`BottleBackend.provision` after the smolvm guest is up."""
|
||||||
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
|
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
|
||||||
|
|
||||||
_smolvm.machine_cp(str(cert_host_path), f"{target}:{AGENT_CA_PATH}")
|
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
||||||
# Mode 0644 — readable to non-root tools in the guest.
|
# Mode 0644 — readable to non-root tools in the guest.
|
||||||
# update-ca-certificates rebuilds the bundle at AGENT_CA_BUNDLE,
|
# update-ca-certificates rebuilds the bundle at AGENT_CA_BUNDLE,
|
||||||
# which is what curl / Python ssl / OpenSSL-based tools read by
|
# which is what curl / Python ssl / OpenSSL-based tools read by
|
||||||
@@ -45,21 +45,21 @@ def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
# REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python
|
# REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python
|
||||||
# `requests` / libraries that don't load the system bundle.
|
# `requests` / libraries that don't load the system bundle.
|
||||||
#
|
#
|
||||||
r = _install_ca(target)
|
r = _install_ca(bottle)
|
||||||
if r.returncode == _SIGKILL_EXIT:
|
if r.returncode == _SIGKILL_EXIT:
|
||||||
# smolvm/libkrun can SIGKILL an otherwise-normal exec
|
# smolvm/libkrun can SIGKILL an otherwise-normal exec
|
||||||
# during early-VM provisioning. `update-ca-certificates`
|
# during early-VM provisioning. `update-ca-certificates`
|
||||||
# is idempotent, so retry the same install once after a
|
# is idempotent, so retry the same install once after a
|
||||||
# short settle delay before treating it as fatal.
|
# short settle delay before treating it as fatal.
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
r = _install_ca(target)
|
r = _install_ca(bottle)
|
||||||
|
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
# update-ca-certificates not adding our cert is fatal —
|
# update-ca-certificates not adding our cert is fatal —
|
||||||
# claude-code's TLS handshake against the egress-MITM'd
|
# claude-code's TLS handshake against the egress-MITM'd
|
||||||
# api.anthropic.com would fail downstream. Bail early
|
# api.anthropic.com would fail downstream. Bail early
|
||||||
# with what we can see (output is captured by smolvm so
|
# with what we can see (output is captured so we can
|
||||||
# we can surface it).
|
# surface it).
|
||||||
die(
|
die(
|
||||||
f"update-ca-certificates didn't add the agent CA "
|
f"update-ca-certificates didn't add the agent CA "
|
||||||
f"(exit {r.returncode}): "
|
f"(exit {r.returncode}): "
|
||||||
@@ -70,21 +70,21 @@ def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
log_ca_fingerprint(cert_host_path, label)
|
log_ca_fingerprint(cert_host_path, label)
|
||||||
|
|
||||||
|
|
||||||
def _install_ca(target: str) -> _smolvm.SmolvmRunResult:
|
def _install_ca(bottle: Bottle) -> ExecResult:
|
||||||
# chown + chmod + update-ca-certificates + bundle
|
# chown + chmod + update-ca-certificates + bundle
|
||||||
# verification run in one `sh -c` so we only pay one
|
# verification run in one exec so we only pay one
|
||||||
# machine_exec round trip; the `&&` chaining surfaces the
|
# round trip; the `&&` chaining surfaces the first failure
|
||||||
# first failure as the return code. The verify check is more
|
# as the return code. The verify check is more stable than
|
||||||
# stable than requiring "1 added" in stdout: a retry after a
|
# requiring "1 added" in stdout: a retry after a
|
||||||
# partially-completed first run may legitimately report "0
|
# partially-completed first run may legitimately report "0
|
||||||
# added" while the cert is already installed.
|
# added" while the cert is already installed.
|
||||||
return _smolvm.machine_exec(target, [
|
return bottle.exec(
|
||||||
"sh", "-c",
|
|
||||||
f"chown root:root {AGENT_CA_PATH} && "
|
f"chown root:root {AGENT_CA_PATH} && "
|
||||||
f"chmod 644 {AGENT_CA_PATH} && "
|
f"chmod 644 {AGENT_CA_PATH} && "
|
||||||
f"update-ca-certificates && "
|
f"update-ca-certificates && "
|
||||||
f"openssl verify -CAfile {AGENT_CA_BUNDLE} {AGENT_CA_PATH}",
|
f"openssl verify -CAfile {AGENT_CA_BUNDLE} {AGENT_CA_PATH}",
|
||||||
])
|
user="root",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Re-exported for the launch/provision_ca caller + tests. The path
|
# Re-exported for the launch/provision_ca caller + tests. The path
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ git_gate module."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ....git_gate import git_gate_render_gitconfig
|
from ....git_gate import git_gate_render_gitconfig
|
||||||
from ....log import info
|
from ....log import info
|
||||||
from .. import smolvm as _smolvm
|
from ... import Bottle
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -46,15 +47,15 @@ def _guest_home() -> str:
|
|||||||
return os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
return os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
||||||
|
|
||||||
|
|
||||||
def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def provision_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Set up git inside the guest. Runs all three subcases; each
|
"""Set up git inside the guest. Runs all three subcases; each
|
||||||
no-ops when its condition isn't met."""
|
no-ops when its condition isn't met."""
|
||||||
_provision_cwd_git(plan, target)
|
_provision_cwd_git(plan, bottle)
|
||||||
_provision_git_gate_config(plan, target)
|
_provision_git_gate_config(plan, bottle)
|
||||||
_provision_git_user(plan, target)
|
_provision_git_user(plan, bottle)
|
||||||
|
|
||||||
|
|
||||||
def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def _provision_cwd_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> 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."""
|
||||||
@@ -63,25 +64,26 @@ def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
return
|
return
|
||||||
guest_workspace_git = f"{workspace.guest_path}/.git"
|
guest_workspace_git = f"{workspace.guest_path}/.git"
|
||||||
host_git = str(workspace.host_path / ".git")
|
host_git = str(workspace.host_path / ".git")
|
||||||
info(f"copying {host_git} -> {target}:{guest_workspace_git}")
|
info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
|
||||||
# mkdir -p the workspace dir so `machine cp` lands the .git
|
# mkdir -p the workspace dir so cp_in lands the .git
|
||||||
# directly there even on first-time bottles.
|
# directly there even on first-time bottles.
|
||||||
_smolvm.machine_exec(target, ["mkdir", "-p", workspace.guest_path])
|
bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
|
||||||
_smolvm.machine_cp(
|
bottle.cp_in(host_git, guest_workspace_git)
|
||||||
host_git, f"{target}:{guest_workspace_git}",
|
# cp_in 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(
|
bottle.exec(
|
||||||
target, ["chown", "-R", workspace.owner, guest_workspace_git],
|
f"chown -R {shlex.quote(workspace.owner)} {shlex.quote(guest_workspace_git)}",
|
||||||
|
user="root",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def _provision_git_gate_config(
|
||||||
|
plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
|
) -> None:
|
||||||
"""Write ~/.gitconfig in the guest with the git-gate insteadOf
|
"""Write ~/.gitconfig in the guest with the git-gate insteadOf
|
||||||
rules. No-op when the bottle has no `git` entries."""
|
rules. No-op when the bottle has no `git` entries."""
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
if not bottle.git:
|
if not manifest_bottle.git:
|
||||||
return
|
return
|
||||||
|
|
||||||
# `<loopback alias>:<host port>` form: the bundle's git-gate
|
# `<loopback alias>:<host port>` form: the bundle's git-gate
|
||||||
@@ -90,11 +92,11 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
|
|||||||
# TSI, not the docker bridge IP) can dial it. launch.py
|
# TSI, not the docker bridge IP) can dial it. launch.py
|
||||||
# populates `plan.agent_git_gate_host` after bundle bringup.
|
# populates `plan.agent_git_gate_host` after bundle bringup.
|
||||||
content = git_gate_render_gitconfig(
|
content = git_gate_render_gitconfig(
|
||||||
bottle.git, plan.agent_git_gate_host, scheme="http",
|
manifest_bottle.git, plan.agent_git_gate_host, scheme="http",
|
||||||
)
|
)
|
||||||
|
|
||||||
guest_gitconfig = f"{_guest_home()}/.gitconfig"
|
guest_gitconfig = f"{_guest_home()}/.gitconfig"
|
||||||
# Stage the file under the plan's stage_dir so `machine cp`
|
# Stage the file under the plan's stage_dir so cp_in
|
||||||
# has a stable host path. The plan's stage_dir is cleaned up
|
# has a stable host path. The plan's stage_dir is cleaned up
|
||||||
# by start.py's session-end teardown.
|
# by start.py's session-end teardown.
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
@@ -105,41 +107,38 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
|
|||||||
config_file = Path(f.name)
|
config_file = Path(f.name)
|
||||||
os.chmod(config_file, 0o600)
|
os.chmod(config_file, 0o600)
|
||||||
|
|
||||||
info(f"writing {guest_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
|
info(f"writing {guest_gitconfig} with {len(manifest_bottle.git)} insteadOf rule(s)")
|
||||||
_smolvm.machine_cp(str(config_file), f"{target}:{guest_gitconfig}")
|
bottle.cp_in(str(config_file), guest_gitconfig)
|
||||||
_smolvm.machine_exec(target, ["chown", "node:node", guest_gitconfig])
|
bottle.exec(
|
||||||
_smolvm.machine_exec(target, ["chmod", "644", guest_gitconfig])
|
f"chown node:node {shlex.quote(guest_gitconfig)} && "
|
||||||
|
f"chmod 644 {shlex.quote(guest_gitconfig)}",
|
||||||
|
user="root",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_user(
|
def _provision_git_user(
|
||||||
plan: SmolmachinesBottlePlan, target: str,
|
plan: SmolmachinesBottlePlan, bottle: Bottle,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Apply `git config --global user.{name,email}` inside the
|
"""Apply `git config --global user.{name,email}` inside the
|
||||||
guest as the node user so --global lands in the same
|
guest as the node user so --global lands in the same
|
||||||
`/home/node/.gitconfig` that `_provision_git_gate_config`
|
`/home/node/.gitconfig` that `_provision_git_gate_config`
|
||||||
writes to. No-op when the bottle didn't declare `git.user`.
|
writes to. No-op when the bottle didn't declare `git.user`.
|
||||||
|
|
||||||
Runs via `runuser -u node --`; HOME is forced via smolvm's
|
SmolmachinesBottle.exec(user="node") automatically sets
|
||||||
`-e` flag because runuser (without -l) inherits root's
|
HOME=/home/node so --global writes to /home/node/.gitconfig."""
|
||||||
HOME=/root, which would put --global in the wrong file."""
|
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
gu = manifest_bottle.git_user
|
||||||
gu = bottle.git_user
|
|
||||||
if gu.is_empty():
|
if gu.is_empty():
|
||||||
return
|
return
|
||||||
env = {"HOME": _guest_home(), "USER": "node"}
|
|
||||||
if gu.name:
|
if gu.name:
|
||||||
info(f"git config --global user.name = {gu.name!r}")
|
info(f"git config --global user.name = {gu.name!r}")
|
||||||
_smolvm.machine_exec(
|
bottle.exec(
|
||||||
target,
|
f"git config --global user.name {shlex.quote(gu.name)}",
|
||||||
["runuser", "-u", "node", "--",
|
user="node",
|
||||||
"git", "config", "--global", "user.name", gu.name],
|
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
if gu.email:
|
if gu.email:
|
||||||
info(f"git config --global user.email = {gu.email!r}")
|
info(f"git config --global user.email = {gu.email!r}")
|
||||||
_smolvm.machine_exec(
|
bottle.exec(
|
||||||
target,
|
f"git config --global user.email {shlex.quote(gu.email)}",
|
||||||
["runuser", "-u", "node", "--",
|
user="node",
|
||||||
"git", "config", "--global", "user.email", gu.email],
|
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ exists) but `--append-system-prompt-file` only fires when the
|
|||||||
agent actually has a prompt — the return value signals which
|
agent actually has a prompt — the return value signals which
|
||||||
case, mirroring the docker backend's contract.
|
case, mirroring the docker backend's contract.
|
||||||
|
|
||||||
`smolvm machine cp` lands files as root inside the VM; the claude
|
cp_in lands files as root inside the VM; the claude
|
||||||
process runs as `node`, so we chown + chmod the prompt after the
|
process runs as `node`, so we chown + chmod the prompt after the
|
||||||
copy. Same flow as the docker backend's provision_prompt."""
|
copy. Same flow as the docker backend's provision_prompt."""
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from .. import smolvm as _smolvm
|
from ... import Bottle
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ from ..bottle_plan import SmolmachinesBottlePlan
|
|||||||
_DEFAULT_GUEST_HOME = "/home/node"
|
_DEFAULT_GUEST_HOME = "/home/node"
|
||||||
|
|
||||||
|
|
||||||
def provision_prompt(plan: SmolmachinesBottlePlan, target: str) -> str | None:
|
def provision_prompt(plan: SmolmachinesBottlePlan, bottle: Bottle) -> str | None:
|
||||||
"""Copy the prompt file into the running smolvm guest, fix
|
"""Copy the prompt file into the running smolvm guest, fix
|
||||||
ownership/mode. Returns the in-guest path if the agent has a
|
ownership/mode. Returns the in-guest path if the agent has a
|
||||||
non-empty prompt (drives --append-system-prompt-file), else
|
non-empty prompt (drives --append-system-prompt-file), else
|
||||||
@@ -32,11 +32,13 @@ def provision_prompt(plan: SmolmachinesBottlePlan, target: str) -> str | None:
|
|||||||
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
||||||
in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt"
|
in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt"
|
||||||
|
|
||||||
_smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}")
|
bottle.cp_in(str(plan.prompt_file), in_guest_prompt_path)
|
||||||
# machine cp lands as root, source's 0o600 mode is preserved —
|
# cp_in lands as root, source's 0o600 mode is preserved —
|
||||||
# node can't read its own prompt without these two.
|
# node can't read its own prompt without these two.
|
||||||
_smolvm.machine_exec(target, ["chown", "node:node", in_guest_prompt_path])
|
bottle.exec(
|
||||||
_smolvm.machine_exec(target, ["chmod", "600", in_guest_prompt_path])
|
f"chown node:node {in_guest_prompt_path} && chmod 600 {in_guest_prompt_path}",
|
||||||
|
user="root",
|
||||||
|
)
|
||||||
|
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||||
return in_guest_prompt_path if agent.prompt else None
|
return in_guest_prompt_path if agent.prompt else None
|
||||||
|
|||||||
@@ -2,30 +2,32 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
|
||||||
from ....log import die
|
from ....log import die
|
||||||
from .. import smolvm as _smolvm
|
from ... import Bottle
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
def provision_provider_auth(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def provision_provider_auth(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Apply provider-owned guest setup through smolvm primitives."""
|
"""Apply provider-owned guest setup through the bottle's exec / cp_in."""
|
||||||
provision = plan.agent_provision
|
provision = plan.agent_provision
|
||||||
for d in provision.dirs:
|
for d in provision.dirs:
|
||||||
_exec(target, ["mkdir", "-p", d.guest_path], f"could not create {d.guest_path}")
|
_exec(bottle, f"mkdir -p {shlex.quote(d.guest_path)}", f"could not create {d.guest_path}")
|
||||||
_exec(target, ["chown", d.owner, d.guest_path], f"could not chown {d.guest_path}")
|
_exec(bottle, f"chown {shlex.quote(d.owner)} {shlex.quote(d.guest_path)}", f"could not chown {d.guest_path}")
|
||||||
_exec(target, ["chmod", d.mode, d.guest_path], f"could not chmod {d.guest_path}")
|
_exec(bottle, f"chmod {shlex.quote(d.mode)} {shlex.quote(d.guest_path)}", f"could not chmod {d.guest_path}")
|
||||||
for command in provision.pre_copy:
|
for command in provision.pre_copy:
|
||||||
_exec(target, list(command.argv), command.error)
|
_exec(bottle, shlex.join(command.argv), command.error)
|
||||||
for f in provision.files:
|
for f in provision.files:
|
||||||
_smolvm.machine_cp(str(f.host_path), f"{target}:{f.guest_path}")
|
bottle.cp_in(str(f.host_path), f.guest_path)
|
||||||
_exec(target, ["chown", f.owner, f.guest_path], f"could not chown {f.guest_path}")
|
_exec(bottle, f"chown {shlex.quote(f.owner)} {shlex.quote(f.guest_path)}", f"could not chown {f.guest_path}")
|
||||||
_exec(target, ["chmod", f.mode, f.guest_path], f"could not chmod {f.guest_path}")
|
_exec(bottle, f"chmod {shlex.quote(f.mode)} {shlex.quote(f.guest_path)}", f"could not chmod {f.guest_path}")
|
||||||
for command in provision.verify:
|
for command in provision.verify:
|
||||||
_exec(target, list(command.argv), command.error)
|
_exec(bottle, shlex.join(command.argv), command.error)
|
||||||
|
|
||||||
|
|
||||||
def _exec(target: str, argv: list[str], error: str) -> None:
|
def _exec(bottle: Bottle, script: str, error: str) -> None:
|
||||||
result = _smolvm.machine_exec(target, argv)
|
result = bottle.exec(script, user="root")
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
detail = (result.stderr or result.stdout).strip()
|
detail = (result.stderr or result.stdout).strip()
|
||||||
if detail:
|
if detail:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import os
|
|||||||
|
|
||||||
from ....log import die, info
|
from ....log import die, info
|
||||||
from ...util import host_skill_dir
|
from ...util import host_skill_dir
|
||||||
from .. import smolvm as _smolvm
|
from ... import Bottle
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -24,18 +24,18 @@ from ..bottle_plan import SmolmachinesBottlePlan
|
|||||||
_DEFAULT_SKILLS_DIR = "/home/node/.claude/skills"
|
_DEFAULT_SKILLS_DIR = "/home/node/.claude/skills"
|
||||||
|
|
||||||
|
|
||||||
def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def provision_skills(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Copy each of the agent's named skills from the host's
|
"""Copy each of the agent's named skills from the host's
|
||||||
~/.claude/skills/<name>/ into the guest's equivalent path.
|
~/.claude/skills/<name>/ into the guest's equivalent path.
|
||||||
For each skill: `mkdir -p` the destination, `smolvm machine cp`
|
For each skill: `mkdir -p` the destination, cp_in the host
|
||||||
the host source dir over, then chown the result to node:node so
|
source dir over, then chown the result to node:node so the
|
||||||
the agent can read it. No-op when the agent has no skills.
|
agent can read it. No-op when the agent has no skills.
|
||||||
|
|
||||||
smolvm machine cp on a directory copies recursively (same
|
cp_in on a directory copies recursively; unlike docker cp's
|
||||||
semantics as `cp -r`); unlike docker cp's trailing-slash
|
trailing-slash convention, smolvm doesn't need the `/.` suffix
|
||||||
convention, smolvm doesn't need the `/.` suffix dance.
|
dance.
|
||||||
|
|
||||||
machine cp lands files as root inside the VM, so we chown each
|
cp_in lands files as root inside the VM, so we chown each
|
||||||
skill tree over to node:node after the copy — same pattern as
|
skill tree over to node:node after the copy — same pattern as
|
||||||
the docker backend's provision_prompt."""
|
the docker backend's provision_prompt."""
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||||
@@ -46,7 +46,7 @@ def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
"BOT_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
|
"BOT_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
|
||||||
)
|
)
|
||||||
|
|
||||||
_smolvm.machine_exec(target, ["mkdir", "-p", skills_dir])
|
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
||||||
|
|
||||||
for name in agent.skills:
|
for name in agent.skills:
|
||||||
src = host_skill_dir(name)
|
src = host_skill_dir(name)
|
||||||
@@ -56,8 +56,8 @@ def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
f"validation and copy at {src}."
|
f"validation and copy at {src}."
|
||||||
)
|
)
|
||||||
dst = f"{skills_dir}/{name}"
|
dst = f"{skills_dir}/{name}"
|
||||||
info(f"copying skill {name} into {target}:{dst}")
|
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||||
# Wipe any prior copy so re-runs don't accumulate.
|
# Wipe any prior copy so re-runs don't accumulate.
|
||||||
_smolvm.machine_exec(target, ["rm", "-rf", dst])
|
bottle.exec(f"rm -rf {dst}", user="root")
|
||||||
_smolvm.machine_cp(src, f"{target}:{dst}")
|
bottle.cp_in(src, dst)
|
||||||
_smolvm.machine_exec(target, ["chown", "-R", "node:node", dst])
|
bottle.exec(f"chown -R node:node {dst}", user="root")
|
||||||
|
|||||||
@@ -7,21 +7,21 @@ stuck-recovery MCP tools (pipelock-block, capability-block) at
|
|||||||
startup.
|
startup.
|
||||||
|
|
||||||
Mirrors `backend.docker.provision.supervise` — same `claude mcp
|
Mirrors `backend.docker.provision.supervise` — same `claude mcp
|
||||||
add` call, just dispatched via `smolvm machine exec` instead of
|
add` call, just dispatched via bottle.exec instead of
|
||||||
`docker exec`, and against `<bundle_ip>:<port>` instead of the
|
`docker exec`, and against `<bundle_ip>:<port>` instead of the
|
||||||
short `supervise` alias (no DNS in the TSI-allowlisted guest)."""
|
short `supervise` alias (no DNS in the TSI-allowlisted guest)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ....log import info, warn
|
from ....log import info, warn
|
||||||
from .. import smolvm as _smolvm
|
from ... import Bottle
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
_SUPERVISE_MCP_NAME = "supervise"
|
_SUPERVISE_MCP_NAME = "supervise"
|
||||||
|
|
||||||
|
|
||||||
def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def provision_supervise(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Run `claude mcp add` inside the guest to register the
|
"""Run `claude mcp add` inside the guest to register the
|
||||||
supervise sidecar in claude-code's user config. No-op when
|
supervise sidecar in claude-code's user config. No-op when
|
||||||
bottle.supervise is False.
|
bottle.supervise is False.
|
||||||
@@ -38,22 +38,13 @@ def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
return
|
return
|
||||||
url = plan.agent_supervise_url
|
url = plan.agent_supervise_url
|
||||||
info(f"registering supervise MCP server in agent claude config → {url}")
|
info(f"registering supervise MCP server in agent claude config → {url}")
|
||||||
# `claude mcp add --scope user` writes to ~/.claude.json. The
|
# `claude mcp add --scope user` writes to ~/.claude.json. Run
|
||||||
# agent is the `node` user; smolvm machine_exec runs as root
|
# as node so the config lands in /home/node/.claude.json.
|
||||||
# by default, so we have to switch user explicitly and set
|
# SmolmachinesBottle.exec sets HOME and USER automatically
|
||||||
# HOME so the config lands in /home/node/.claude.json (where
|
# for the requested user.
|
||||||
# the agent's claude actually reads it from).
|
r = bottle.exec(
|
||||||
r = _smolvm.machine_exec(
|
f"claude mcp add --scope user --transport http {_SUPERVISE_MCP_NAME} {url}",
|
||||||
target,
|
user="node",
|
||||||
[
|
|
||||||
"runuser", "-u", "node", "--",
|
|
||||||
"env", "HOME=/home/node",
|
|
||||||
"claude", "mcp", "add",
|
|
||||||
"--scope", "user",
|
|
||||||
"--transport", "http",
|
|
||||||
_SUPERVISE_MCP_NAME,
|
|
||||||
url,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
warn(
|
warn(
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ from __future__ import annotations
|
|||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
from ....log import info
|
from ....log import info
|
||||||
from .. import smolvm as _smolvm
|
from ... import Bottle
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
def provision_workspace(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def provision_workspace(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Copy host cwd contents to the planned guest workspace."""
|
"""Copy host cwd contents to the planned guest workspace."""
|
||||||
workspace = plan.workspace_plan
|
workspace = plan.workspace_plan
|
||||||
if not (workspace.enabled and workspace.copy_contents):
|
if not (workspace.enabled and workspace.copy_contents):
|
||||||
@@ -20,17 +20,13 @@ def provision_workspace(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
guest_parent_q = shlex.quote(guest_parent)
|
guest_parent_q = shlex.quote(guest_parent)
|
||||||
owner_q = shlex.quote(workspace.owner)
|
owner_q = shlex.quote(workspace.owner)
|
||||||
mode_q = shlex.quote(workspace.mode)
|
mode_q = shlex.quote(workspace.mode)
|
||||||
info(f"copying {workspace.host_path} -> {target}:{workspace.guest_path}")
|
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
|
||||||
_smolvm.machine_exec(
|
bottle.exec(
|
||||||
target,
|
f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}",
|
||||||
["sh", "-c", f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}"],
|
user="root",
|
||||||
)
|
)
|
||||||
_smolvm.machine_cp(str(workspace.host_path), f"{target}:{workspace.guest_path}")
|
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
|
||||||
_smolvm.machine_exec(
|
bottle.exec(
|
||||||
target,
|
f"chown -R {owner_q} {guest_path_q} && chmod {mode_q} {guest_path_q}",
|
||||||
[
|
user="root",
|
||||||
"sh", "-c",
|
|
||||||
f"chown -R {owner_q} {guest_path_q} && "
|
|
||||||
f"chmod {mode_q} {guest_path_q}",
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
"""Unit: docker backend `_provision_git_user` (issue #86).
|
"""Unit: docker backend `_provision_git_user` (issue #86).
|
||||||
|
|
||||||
Mocks `subprocess.run` and asserts the `docker exec -u node …
|
Mocks `bottle.exec` / `bottle.cp_in` and asserts on the script
|
||||||
git config --global …` argv shape. The cwd + git-gate passes
|
strings and user parameter. The cwd + git-gate passes are covered
|
||||||
are covered indirectly by the existing integration-shaped tests
|
indirectly by the existing integration-shaped tests in
|
||||||
in test_smolmachines_provision; this file targets just the new
|
test_smolmachines_provision; this file targets just the git_user
|
||||||
git_user pass."""
|
pass."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, call
|
||||||
|
|
||||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||||
from bot_bottle.backend import BottleSpec
|
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from bot_bottle.backend.docker.provision import git as _git
|
from bot_bottle.backend.docker.provision import git as _git
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
@@ -82,16 +82,22 @@ def _plan(*, git_user: dict | None = None,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _git_config_calls(mock_run) -> list[list[str]]:
|
def _make_bottle(name: str = "bot-bottle-demo-abc12") -> MagicMock:
|
||||||
"""Filter `subprocess.run` calls down to the ones that run
|
bottle = MagicMock(spec=Bottle)
|
||||||
`git config --global` inside the bottle, returning each argv."""
|
bottle.name = name
|
||||||
out: list[list[str]] = []
|
bottle.exec.return_value = ExecResult(returncode=0, stdout="", stderr="")
|
||||||
for call in mock_run.call_args_list:
|
return bottle
|
||||||
argv = call.args[0]
|
|
||||||
if (len(argv) >= 5
|
|
||||||
and argv[0] == "docker" and argv[1] == "exec"
|
def _git_config_exec_calls(bottle: MagicMock) -> list[tuple[str, str]]:
|
||||||
and "git" in argv and "config" in argv):
|
"""Filter bottle.exec calls to git-config invocations.
|
||||||
out.append(list(argv))
|
Returns list of (script, user) tuples."""
|
||||||
|
out = []
|
||||||
|
for c in bottle.exec.call_args_list:
|
||||||
|
script = c.args[0] if c.args else c.kwargs.get("script", "")
|
||||||
|
user = c.kwargs.get("user", c.args[1] if len(c.args) > 1 else "node")
|
||||||
|
if "git config" in script:
|
||||||
|
out.append((script, user))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -104,71 +110,65 @@ class TestProvisionGitUser(unittest.TestCase):
|
|||||||
self._tmp.cleanup()
|
self._tmp.cleanup()
|
||||||
|
|
||||||
def test_noop_when_no_git_user(self):
|
def test_noop_when_no_git_user(self):
|
||||||
with patch.object(_git.subprocess, "run") as run:
|
bottle = _make_bottle()
|
||||||
_git._provision_git_user(
|
_git._provision_git_user(_plan(stage_dir=self.stage), bottle)
|
||||||
_plan(stage_dir=self.stage), "bot-bottle-demo-abc12",
|
self.assertEqual([], _git_config_exec_calls(bottle))
|
||||||
)
|
|
||||||
self.assertEqual([], _git_config_calls(run))
|
|
||||||
|
|
||||||
def test_copies_cwd_git_to_workspace_plan_path(self):
|
def test_copies_cwd_git_to_workspace_plan_path(self):
|
||||||
cwd = self.stage / "cwd"
|
cwd = self.stage / "cwd"
|
||||||
(cwd / ".git").mkdir(parents=True)
|
(cwd / ".git").mkdir(parents=True)
|
||||||
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
|
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
|
||||||
with patch.object(_git.subprocess, "run") as run:
|
bottle = _make_bottle()
|
||||||
_git._provision_cwd_git(plan, "bot-bottle-demo-abc12")
|
_git._provision_cwd_git(plan, bottle)
|
||||||
|
|
||||||
self.assertEqual(
|
bottle.cp_in.assert_called_once_with(
|
||||||
[
|
f"{cwd}/.git",
|
||||||
"docker", "cp", f"{cwd}/.git",
|
"/home/node/workspace/.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],
|
|
||||||
)
|
)
|
||||||
|
chown_calls = [
|
||||||
|
c for c in bottle.exec.call_args_list
|
||||||
|
if "chown" in (c.args[0] if c.args else "")
|
||||||
|
]
|
||||||
|
self.assertEqual(1, len(chown_calls))
|
||||||
|
self.assertIn("node:node", chown_calls[0].args[0])
|
||||||
|
self.assertIn("/home/node/workspace/.git", chown_calls[0].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"},
|
||||||
stage_dir=self.stage,
|
stage_dir=self.stage,
|
||||||
)
|
)
|
||||||
with patch.object(_git.subprocess, "run") as run:
|
bottle = _make_bottle()
|
||||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
_git._provision_git_user(plan, bottle)
|
||||||
calls = _git_config_calls(run)
|
calls = _git_config_exec_calls(bottle)
|
||||||
self.assertEqual(2, len(calls))
|
self.assertEqual(2, len(calls))
|
||||||
# All `docker exec` invocations run as `-u node` so the
|
for script, user in calls:
|
||||||
# --global config lands in /home/node/.gitconfig.
|
self.assertEqual("node", user)
|
||||||
for argv in calls:
|
self.assertIn("git config --global", script)
|
||||||
self.assertEqual(
|
self.assertIn("user.name", calls[0][0])
|
||||||
["docker", "exec", "-u", "node", "bot-bottle-demo-abc12",
|
self.assertIn("Eric Bauerfeld", calls[0][0])
|
||||||
"git", "config", "--global"],
|
self.assertIn("user.email", calls[1][0])
|
||||||
argv[:8],
|
self.assertIn("eric@dideric.is", calls[1][0])
|
||||||
)
|
|
||||||
self.assertEqual(["user.name", "Eric Bauerfeld"], calls[0][8:])
|
|
||||||
self.assertEqual(["user.email", "eric@dideric.is"], calls[1][8:])
|
|
||||||
|
|
||||||
def test_name_only_sets_only_name(self):
|
def test_name_only_sets_only_name(self):
|
||||||
plan = _plan(git_user={"name": "Bot"}, stage_dir=self.stage)
|
plan = _plan(git_user={"name": "Bot"}, stage_dir=self.stage)
|
||||||
with patch.object(_git.subprocess, "run") as run:
|
bottle = _make_bottle()
|
||||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
_git._provision_git_user(plan, bottle)
|
||||||
calls = _git_config_calls(run)
|
calls = _git_config_exec_calls(bottle)
|
||||||
self.assertEqual(1, len(calls))
|
self.assertEqual(1, len(calls))
|
||||||
self.assertEqual(["user.name", "Bot"], calls[0][8:])
|
self.assertIn("user.name", calls[0][0])
|
||||||
|
self.assertIn("Bot", calls[0][0])
|
||||||
|
|
||||||
def test_email_only_sets_only_email(self):
|
def test_email_only_sets_only_email(self):
|
||||||
plan = _plan(
|
plan = _plan(
|
||||||
git_user={"email": "bot@example.com"}, stage_dir=self.stage,
|
git_user={"email": "bot@example.com"}, stage_dir=self.stage,
|
||||||
)
|
)
|
||||||
with patch.object(_git.subprocess, "run") as run:
|
bottle = _make_bottle()
|
||||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
_git._provision_git_user(plan, bottle)
|
||||||
calls = _git_config_calls(run)
|
calls = _git_config_exec_calls(bottle)
|
||||||
self.assertEqual(1, len(calls))
|
self.assertEqual(1, len(calls))
|
||||||
self.assertEqual(["user.email", "bot@example.com"], calls[0][8:])
|
self.assertIn("user.email", calls[0][0])
|
||||||
|
self.assertIn("bot@example.com", calls[0][0])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from bot_bottle.agent_provider import (
|
from bot_bottle.agent_provider import (
|
||||||
AgentProvisionDir,
|
AgentProvisionDir,
|
||||||
AgentProvisionFile,
|
AgentProvisionFile,
|
||||||
AgentProvisionPlan,
|
AgentProvisionPlan,
|
||||||
)
|
)
|
||||||
from bot_bottle.backend import BottleSpec
|
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from bot_bottle.backend.docker.provision import provider_auth as _provider_auth
|
from bot_bottle.backend.docker.provision import provider_auth as _provider_auth
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
@@ -110,80 +110,62 @@ def _agent_provision(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bottle(name: str = "bot-bottle-demo-abc12") -> MagicMock:
|
||||||
|
bottle = MagicMock(spec=Bottle)
|
||||||
|
bottle.name = name
|
||||||
|
bottle.exec.return_value = ExecResult(returncode=0, stdout="", stderr="")
|
||||||
|
return bottle
|
||||||
|
|
||||||
|
|
||||||
class TestProvisionProviderAuth(unittest.TestCase):
|
class TestProvisionProviderAuth(unittest.TestCase):
|
||||||
def test_noop_for_non_codex_provider(self):
|
def test_noop_for_non_codex_provider(self):
|
||||||
with patch.object(_provider_auth.subprocess, "run") as run:
|
bottle = _make_bottle()
|
||||||
_provider_auth.provision_provider_auth(
|
_provider_auth.provision_provider_auth(
|
||||||
_plan(agent_provider_template="claude"), "bot-bottle-demo-abc12",
|
_plan(agent_provider_template="claude"), bottle,
|
||||||
)
|
)
|
||||||
self.assertEqual(0, run.call_count)
|
self.assertEqual(0, bottle.cp_in.call_count)
|
||||||
|
self.assertEqual(0, bottle.exec.call_count)
|
||||||
|
|
||||||
def test_codex_provider_trusts_launch_dir_without_auth_file(self):
|
def test_codex_provider_trusts_launch_dir_without_auth_file(self):
|
||||||
with patch.object(_provider_auth.subprocess, "run") as run:
|
bottle = _make_bottle()
|
||||||
_provider_auth.provision_provider_auth(
|
_provider_auth.provision_provider_auth(_plan(), bottle)
|
||||||
_plan(), "bot-bottle-demo-abc12",
|
scripts = [c.args[0] for c in bottle.exec.call_args_list]
|
||||||
)
|
self.assertTrue(
|
||||||
argvs = [call.args[0] for call in run.call_args_list]
|
any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts)
|
||||||
|
)
|
||||||
|
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
("/tmp/codex-config.toml", "/home/node/.codex/config.toml"),
|
||||||
"mkdir", "-p", "/home/node/.codex"],
|
cp_calls,
|
||||||
argvs,
|
|
||||||
)
|
)
|
||||||
trust_config = next(
|
self.assertTrue(
|
||||||
a for a in argvs
|
any("chown" in s and "/home/node/.codex/config.toml" in s for s in scripts)
|
||||||
if a[:2] == ["docker", "cp"] and a[2] == "/tmp/codex-config.toml"
|
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertTrue(
|
||||||
"bot-bottle-demo-abc12:/home/node/.codex/config.toml",
|
any("chmod" in s and "/home/node/.codex/config.toml" in s for s in scripts)
|
||||||
trust_config[3],
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
|
||||||
"chown", "node:node", "/home/node/.codex/config.toml"],
|
|
||||||
argvs,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
|
||||||
"chmod", "600", "/home/node/.codex/config.toml"],
|
|
||||||
argvs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_copies_dummy_auth_json_to_codex_home(self):
|
def test_copies_dummy_auth_json_to_codex_home(self):
|
||||||
with patch.object(_provider_auth.subprocess, "run") as run:
|
bottle = _make_bottle()
|
||||||
_provider_auth.provision_provider_auth(
|
_provider_auth.provision_provider_auth(
|
||||||
_plan(codex_auth_file=Path("/tmp/codex-auth.json")),
|
_plan(codex_auth_file=Path("/tmp/codex-auth.json")),
|
||||||
"bot-bottle-demo-abc12",
|
bottle,
|
||||||
)
|
)
|
||||||
argvs = [call.args[0] for call in run.call_args_list]
|
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
("/tmp/codex-config.toml", "/home/node/.codex/config.toml"),
|
||||||
"mkdir", "-p", "/home/node/.codex"],
|
cp_calls,
|
||||||
argvs,
|
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
("/tmp/codex-auth.json", "/home/node/.codex/auth.json"),
|
||||||
"chown", "node:node", "/home/node/.codex"],
|
cp_calls,
|
||||||
argvs,
|
|
||||||
)
|
)
|
||||||
self.assertIn(
|
scripts = [c.args[0] for c in bottle.exec.call_args_list]
|
||||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
self.assertTrue(
|
||||||
"chmod", "700", "/home/node/.codex"],
|
any("chown" in s and "/home/node/.codex/auth.json" in s for s in scripts)
|
||||||
argvs,
|
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertTrue(
|
||||||
["docker", "cp", "/tmp/codex-auth.json",
|
any("chmod" in s and "/home/node/.codex/auth.json" in s for s in scripts)
|
||||||
"bot-bottle-demo-abc12:/home/node/.codex/auth.json"],
|
|
||||||
argvs,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
|
||||||
"chown", "node:node", "/home/node/.codex/auth.json"],
|
|
||||||
argvs,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
["docker", "exec", "-u", "0", "bot-bottle-demo-abc12",
|
|
||||||
"chmod", "600", "/home/node/.codex/auth.json"],
|
|
||||||
argvs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Unit: smolmachines provisioning helpers (PRD 0023 chunks 4a + 4d).
|
"""Unit: smolmachines provisioning helpers (PRD 0023 chunks 4a + 4d).
|
||||||
|
|
||||||
Tests mock `smolvm.machine_cp` / `smolvm.machine_exec` and assert
|
Tests mock `bottle.exec` / `bottle.cp_in` and assert on the
|
||||||
on the dispatched call shape. The real round-trip lives in the
|
dispatched script shape. The real round-trip lives in the chunk-4
|
||||||
chunk-4 integration smoke."""
|
integration smoke."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from bot_bottle.agent_provider import (
|
from bot_bottle.agent_provider import (
|
||||||
AgentProvisionCommand,
|
AgentProvisionCommand,
|
||||||
@@ -19,7 +19,7 @@ from bot_bottle.agent_provider import (
|
|||||||
AgentProvisionFile,
|
AgentProvisionFile,
|
||||||
AgentProvisionPlan,
|
AgentProvisionPlan,
|
||||||
)
|
)
|
||||||
from bot_bottle.backend import BottleSpec
|
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||||
from bot_bottle.backend.smolmachines.bottle_plan import (
|
from bot_bottle.backend.smolmachines.bottle_plan import (
|
||||||
SmolmachinesBottlePlan,
|
SmolmachinesBottlePlan,
|
||||||
)
|
)
|
||||||
@@ -33,7 +33,6 @@ from bot_bottle.backend.smolmachines.provision import (
|
|||||||
workspace as _workspace,
|
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.egress import EgressPlan, EgressRoute
|
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 GitEntry, Manifest
|
from bot_bottle.manifest import GitEntry, Manifest
|
||||||
@@ -42,6 +41,28 @@ from bot_bottle.supervise import SupervisePlan
|
|||||||
from bot_bottle.workspace import workspace_plan
|
from bot_bottle.workspace import workspace_plan
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bottle(
|
||||||
|
name: str = "bot-bottle-demo-abc12",
|
||||||
|
exec_result: ExecResult | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
bottle = MagicMock(spec=Bottle)
|
||||||
|
bottle.name = name
|
||||||
|
bottle.exec.return_value = (
|
||||||
|
exec_result if exec_result is not None
|
||||||
|
else ExecResult(returncode=0, stdout="", stderr="")
|
||||||
|
)
|
||||||
|
return bottle
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_scripts(bottle: MagicMock) -> list[str]:
|
||||||
|
"""All script strings passed to bottle.exec, in call order."""
|
||||||
|
return [c.args[0] for c in bottle.exec.call_args_list]
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_users(bottle: MagicMock) -> list[str]:
|
||||||
|
"""user= kwarg from each bottle.exec call, in order."""
|
||||||
|
return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list]
|
||||||
|
|
||||||
|
|
||||||
def _plan(
|
def _plan(
|
||||||
*,
|
*,
|
||||||
@@ -203,235 +224,181 @@ def _agent_provision(
|
|||||||
|
|
||||||
|
|
||||||
class TestProvisionPrompt(unittest.TestCase):
|
class TestProvisionPrompt(unittest.TestCase):
|
||||||
def test_cp_uses_smolvm_machine_cp_with_machine_path_syntax(self):
|
def test_cp_uses_bottle_cp_in(self):
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp"
|
_prompt.provision_prompt(_plan(), bottle)
|
||||||
) as cp, patch(
|
bottle.cp_in.assert_called_once_with(
|
||||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_exec"
|
|
||||||
):
|
|
||||||
_prompt.provision_prompt(_plan(), "bot-bottle-demo-abc12")
|
|
||||||
cp.assert_called_once_with(
|
|
||||||
"/tmp/state/demo-abc12/agent/prompt.txt",
|
"/tmp/state/demo-abc12/agent/prompt.txt",
|
||||||
"bot-bottle-demo-abc12:/home/node/.bot-bottle-prompt.txt",
|
"/home/node/.bot-bottle-prompt.txt",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_returns_path_when_agent_has_prompt(self):
|
def test_returns_path_when_agent_has_prompt(self):
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp"
|
r = _prompt.provision_prompt(
|
||||||
), patch(
|
_plan(agent_prompt="You are a helpful assistant."),
|
||||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_exec"
|
bottle,
|
||||||
):
|
)
|
||||||
r = _prompt.provision_prompt(
|
|
||||||
_plan(agent_prompt="You are a helpful assistant."),
|
|
||||||
"bot-bottle-demo-abc12",
|
|
||||||
)
|
|
||||||
self.assertEqual("/home/node/.bot-bottle-prompt.txt", r)
|
self.assertEqual("/home/node/.bot-bottle-prompt.txt", r)
|
||||||
|
|
||||||
def test_returns_none_when_agent_has_no_prompt(self):
|
def test_returns_none_when_agent_has_no_prompt(self):
|
||||||
# The file is still copied (path-must-exist contract);
|
# The file is still copied (path-must-exist contract);
|
||||||
# only the return value differs.
|
# only the return value differs.
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp"
|
r = _prompt.provision_prompt(_plan(agent_prompt=""), bottle)
|
||||||
) as cp, patch(
|
|
||||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_exec"
|
|
||||||
):
|
|
||||||
r = _prompt.provision_prompt(_plan(agent_prompt=""), "bot-bottle-demo-abc12")
|
|
||||||
self.assertIsNone(r)
|
self.assertIsNone(r)
|
||||||
cp.assert_called_once()
|
bottle.cp_in.assert_called_once()
|
||||||
|
|
||||||
def test_chowns_to_node_after_copy(self):
|
def test_chowns_to_node_after_copy(self):
|
||||||
# machine cp lands as root; without the chown, the node user
|
# cp_in lands as root; without the chown, the node user
|
||||||
# can't read its own mode-600 prompt.
|
# can't read its own mode-600 prompt.
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_cp"
|
_prompt.provision_prompt(_plan(), bottle)
|
||||||
), patch(
|
scripts = _exec_scripts(bottle)
|
||||||
"bot_bottle.backend.smolmachines.provision.prompt._smolvm.machine_exec"
|
self.assertTrue(
|
||||||
) as ex:
|
any("chown node:node" in s and "/home/node/.bot-bottle-prompt.txt" in s
|
||||||
_prompt.provision_prompt(_plan(), "bot-bottle-demo-abc12")
|
for s in scripts)
|
||||||
argv_seen = [call.args[1] for call in ex.call_args_list]
|
|
||||||
self.assertIn(
|
|
||||||
["chown", "node:node", "/home/node/.bot-bottle-prompt.txt"],
|
|
||||||
argv_seen,
|
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertTrue(
|
||||||
["chmod", "600", "/home/node/.bot-bottle-prompt.txt"],
|
any("chmod 600" in s and "/home/node/.bot-bottle-prompt.txt" in s
|
||||||
argv_seen,
|
for s in scripts)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestProvisionProviderAuth(unittest.TestCase):
|
class TestProvisionProviderAuth(unittest.TestCase):
|
||||||
def _patch(self):
|
|
||||||
return (
|
|
||||||
patch(
|
|
||||||
"bot_bottle.backend.smolmachines.provision.provider_auth._smolvm.machine_cp"
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"bot_bottle.backend.smolmachines.provision.provider_auth._smolvm.machine_exec"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_noop_for_non_codex_provider(self):
|
def test_noop_for_non_codex_provider(self):
|
||||||
cp_p, ex_p = self._patch()
|
bottle = _make_bottle()
|
||||||
with cp_p as cp, ex_p as ex:
|
_provider_auth.provision_provider_auth(_plan(), bottle)
|
||||||
_provider_auth.provision_provider_auth(_plan(), "bot-bottle-demo-abc12")
|
self.assertEqual(0, bottle.cp_in.call_count)
|
||||||
self.assertEqual(0, cp.call_count)
|
self.assertEqual(0, bottle.exec.call_count)
|
||||||
self.assertEqual(0, ex.call_count)
|
|
||||||
|
|
||||||
def test_codex_provider_trusts_launch_dir_without_auth_file(self):
|
def test_codex_provider_trusts_launch_dir_without_auth_file(self):
|
||||||
cp_p, ex_p = self._patch()
|
bottle = _make_bottle()
|
||||||
with cp_p as cp, ex_p as ex:
|
_provider_auth.provision_provider_auth(
|
||||||
ex.return_value = SmolvmRunResult(0, "", "")
|
_plan(agent_provider_template="codex"),
|
||||||
_provider_auth.provision_provider_auth(
|
bottle,
|
||||||
_plan(agent_provider_template="codex"),
|
)
|
||||||
"bot-bottle-demo-abc12",
|
bottle.cp_in.assert_called_once_with(
|
||||||
)
|
|
||||||
cp.assert_called_once_with(
|
|
||||||
"/tmp/codex-config.toml",
|
"/tmp/codex-config.toml",
|
||||||
"bot-bottle-demo-abc12:/home/node/.codex/config.toml",
|
"/home/node/.codex/config.toml",
|
||||||
)
|
)
|
||||||
argv_seen = [call.args[1] for call in ex.call_args_list]
|
scripts = _exec_scripts(bottle)
|
||||||
self.assertIn(["mkdir", "-p", "/home/node/.codex"], argv_seen)
|
self.assertTrue(any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts))
|
||||||
self.assertIn(
|
self.assertTrue(
|
||||||
["chown", "node:node", "/home/node/.codex/config.toml"],
|
any("chown" in s and "node:node" in s and "/home/node/.codex/config.toml" in s
|
||||||
argv_seen,
|
for s in scripts)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any("chmod" in s and "600" in s and "/home/node/.codex/config.toml" in s
|
||||||
|
for s in scripts)
|
||||||
)
|
)
|
||||||
self.assertIn(["chmod", "600", "/home/node/.codex/config.toml"], argv_seen)
|
|
||||||
|
|
||||||
def test_copies_dummy_auth_json_to_codex_home(self):
|
def test_copies_dummy_auth_json_to_codex_home(self):
|
||||||
cp_p, ex_p = self._patch()
|
bottle = _make_bottle()
|
||||||
with cp_p as cp, ex_p as ex:
|
_provider_auth.provision_provider_auth(
|
||||||
ex.return_value = SmolvmRunResult(0, "Logged in using ChatGPT\n", "")
|
_plan(
|
||||||
_provider_auth.provision_provider_auth(
|
agent_provider_template="codex",
|
||||||
_plan(
|
codex_auth_file=Path("/tmp/codex-auth.json"),
|
||||||
agent_provider_template="codex",
|
),
|
||||||
codex_auth_file=Path("/tmp/codex-auth.json"),
|
bottle,
|
||||||
),
|
)
|
||||||
"bot-bottle-demo-abc12",
|
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
|
||||||
)
|
|
||||||
cp_calls = [call.args for call in cp.call_args_list]
|
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
("/tmp/codex-config.toml",
|
("/tmp/codex-config.toml", "/home/node/.codex/config.toml"),
|
||||||
"bot-bottle-demo-abc12:/home/node/.codex/config.toml"),
|
|
||||||
cp_calls,
|
cp_calls,
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
("/tmp/codex-auth.json",
|
("/tmp/codex-auth.json", "/home/node/.codex/auth.json"),
|
||||||
"bot-bottle-demo-abc12:/home/node/.codex/auth.json"),
|
|
||||||
cp_calls,
|
cp_calls,
|
||||||
)
|
)
|
||||||
argv_seen = [call.args[1] for call in ex.call_args_list]
|
scripts = _exec_scripts(bottle)
|
||||||
self.assertIn(["mkdir", "-p", "/home/node/.codex"], argv_seen)
|
self.assertTrue(any("mkdir -p" in s and "/home/node/.codex" in s for s in scripts))
|
||||||
self.assertIn(
|
self.assertTrue(
|
||||||
["chown", "node:node", "/home/node/.codex"],
|
any("chown" in s and "node:node" in s and s.rstrip().endswith("/home/node/.codex")
|
||||||
argv_seen,
|
for s in scripts)
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertTrue(
|
||||||
["chmod", "700", "/home/node/.codex"],
|
any("chmod" in s and "700" in s and s.rstrip().endswith("/home/node/.codex")
|
||||||
argv_seen,
|
for s in scripts)
|
||||||
)
|
)
|
||||||
self.assertIn(
|
# The pre_copy `find ... -delete` script should be present
|
||||||
[
|
# (shlex.join properly quotes the `(`/`)`/`*.sqlite`).
|
||||||
"find", "/home/node/.codex",
|
self.assertTrue(
|
||||||
"-maxdepth", "1",
|
any("find" in s and "-delete" in s and "*.sqlite" in s for s in scripts)
|
||||||
"-type", "f",
|
|
||||||
"(",
|
|
||||||
"-name", "*.sqlite",
|
|
||||||
"-o", "-name", "*.sqlite-*",
|
|
||||||
"-o", "-name", "*.codex-repair-*.bak",
|
|
||||||
")",
|
|
||||||
"-delete",
|
|
||||||
],
|
|
||||||
argv_seen,
|
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertTrue(
|
||||||
["chown", "node:node", "/home/node/.codex/auth.json"],
|
any("chown" in s and "/home/node/.codex/auth.json" in s for s in scripts)
|
||||||
argv_seen,
|
|
||||||
)
|
)
|
||||||
self.assertIn(["chmod", "600", "/home/node/.codex/auth.json"], argv_seen)
|
self.assertTrue(
|
||||||
self.assertIn(
|
any("chmod" in s and "600" in s and "/home/node/.codex/auth.json" in s
|
||||||
[
|
for s in scripts)
|
||||||
"runuser", "-u", "node", "--",
|
)
|
||||||
"env",
|
# Verify command runs `codex login status` via runuser node.
|
||||||
"HOME=/home/node",
|
self.assertTrue(
|
||||||
"CODEX_HOME=/home/node/.codex",
|
any("runuser" in s and "codex login status" in s for s in scripts)
|
||||||
"codex", "login", "status",
|
|
||||||
],
|
|
||||||
argv_seen,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_honors_codex_home_from_guest_env(self):
|
def test_honors_codex_home_from_guest_env(self):
|
||||||
cp_p, ex_p = self._patch()
|
bottle = _make_bottle()
|
||||||
with cp_p as cp, ex_p as ex:
|
_provider_auth.provision_provider_auth(
|
||||||
ex.return_value = SmolvmRunResult(0, "Logged in using ChatGPT\n", "")
|
_plan(
|
||||||
|
agent_provider_template="codex",
|
||||||
|
codex_auth_file=Path("/tmp/codex-auth.json"),
|
||||||
|
guest_env={"CODEX_HOME": "/run/codex-home"},
|
||||||
|
),
|
||||||
|
bottle,
|
||||||
|
)
|
||||||
|
cp_calls = [c.args for c in bottle.cp_in.call_args_list]
|
||||||
|
self.assertIn(
|
||||||
|
("/tmp/codex-config.toml", "/run/codex-home/config.toml"),
|
||||||
|
cp_calls,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
("/tmp/codex-auth.json", "/run/codex-home/auth.json"),
|
||||||
|
cp_calls,
|
||||||
|
)
|
||||||
|
scripts = _exec_scripts(bottle)
|
||||||
|
self.assertTrue(
|
||||||
|
any("runuser" in s and "CODEX_HOME=/run/codex-home" in s and "codex login status" in s
|
||||||
|
for s in scripts)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dies_when_codex_home_cannot_be_created(self):
|
||||||
|
bottle = _make_bottle(
|
||||||
|
exec_result=ExecResult(1, "", "mkdir: nope\n"),
|
||||||
|
)
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
_provider_auth.provision_provider_auth(
|
_provider_auth.provision_provider_auth(
|
||||||
_plan(
|
_plan(
|
||||||
agent_provider_template="codex",
|
agent_provider_template="codex",
|
||||||
codex_auth_file=Path("/tmp/codex-auth.json"),
|
codex_auth_file=Path("/tmp/codex-auth.json"),
|
||||||
guest_env={"CODEX_HOME": "/run/codex-home"},
|
|
||||||
),
|
),
|
||||||
"bot-bottle-demo-abc12",
|
bottle,
|
||||||
)
|
)
|
||||||
cp_calls = [call.args for call in cp.call_args_list]
|
self.assertEqual(0, bottle.cp_in.call_count)
|
||||||
self.assertIn(
|
self.assertEqual(1, bottle.exec.call_count)
|
||||||
("/tmp/codex-config.toml",
|
|
||||||
"bot-bottle-demo-abc12:/run/codex-home/config.toml"),
|
|
||||||
cp_calls,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
("/tmp/codex-auth.json",
|
|
||||||
"bot-bottle-demo-abc12:/run/codex-home/auth.json"),
|
|
||||||
cp_calls,
|
|
||||||
)
|
|
||||||
argv_seen = [call.args[1] for call in ex.call_args_list]
|
|
||||||
self.assertIn(
|
|
||||||
[
|
|
||||||
"runuser", "-u", "node", "--",
|
|
||||||
"env",
|
|
||||||
"HOME=/home/node",
|
|
||||||
"CODEX_HOME=/run/codex-home",
|
|
||||||
"codex", "login", "status",
|
|
||||||
],
|
|
||||||
argv_seen,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_dies_when_codex_home_cannot_be_created(self):
|
|
||||||
cp_p, ex_p = self._patch()
|
|
||||||
with cp_p as cp, ex_p as ex:
|
|
||||||
ex.return_value = SmolvmRunResult(1, "", "mkdir: nope\n")
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
_provider_auth.provision_provider_auth(
|
|
||||||
_plan(
|
|
||||||
agent_provider_template="codex",
|
|
||||||
codex_auth_file=Path("/tmp/codex-auth.json"),
|
|
||||||
),
|
|
||||||
"bot-bottle-demo-abc12",
|
|
||||||
)
|
|
||||||
self.assertEqual(0, cp.call_count)
|
|
||||||
self.assertEqual(1, ex.call_count)
|
|
||||||
|
|
||||||
def test_dies_when_codex_rejects_dummy_auth(self):
|
def test_dies_when_codex_rejects_dummy_auth(self):
|
||||||
cp_p, ex_p = self._patch()
|
# CODEX_HOME setup ok, but codex login status fails (last exec).
|
||||||
with cp_p, ex_p as ex:
|
bottle = _make_bottle()
|
||||||
# CODEX_HOME setup ok (0), but codex login status fails (1).
|
bottle.exec.side_effect = [
|
||||||
ex.side_effect = [
|
ExecResult(0, "", ""), # mkdir CODEX_HOME
|
||||||
SmolvmRunResult(0, "", ""), # mkdir CODEX_HOME
|
ExecResult(0, "", ""), # chown CODEX_HOME
|
||||||
SmolvmRunResult(0, "", ""), # chown CODEX_HOME
|
ExecResult(0, "", ""), # chmod CODEX_HOME
|
||||||
SmolvmRunResult(0, "", ""), # chmod CODEX_HOME
|
ExecResult(0, "", ""), # find ... -delete (pre_copy)
|
||||||
SmolvmRunResult(0, "", ""), # reset runtime db files
|
ExecResult(0, "", ""), # chown config.toml
|
||||||
SmolvmRunResult(0, "", ""), # chown config.toml
|
ExecResult(0, "", ""), # chmod config.toml
|
||||||
SmolvmRunResult(0, "", ""), # chmod config.toml
|
ExecResult(0, "", ""), # chown auth.json
|
||||||
SmolvmRunResult(0, "", ""), # chown auth.json
|
ExecResult(0, "", ""), # chmod auth.json
|
||||||
SmolvmRunResult(0, "", ""), # chmod auth.json
|
ExecResult(1, "Not logged in\n", ""), # login status (verify)
|
||||||
SmolvmRunResult(1, "Not logged in\n", ""), # login status
|
]
|
||||||
]
|
with self.assertRaises(SystemExit):
|
||||||
with self.assertRaises(SystemExit):
|
_provider_auth.provision_provider_auth(
|
||||||
_provider_auth.provision_provider_auth(
|
_plan(
|
||||||
_plan(
|
agent_provider_template="codex",
|
||||||
agent_provider_template="codex",
|
codex_auth_file=Path("/tmp/codex-auth.json"),
|
||||||
codex_auth_file=Path("/tmp/codex-auth.json"),
|
),
|
||||||
),
|
bottle,
|
||||||
"bot-bottle-demo-abc12",
|
)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestProvisionSkills(unittest.TestCase):
|
class TestProvisionSkills(unittest.TestCase):
|
||||||
@@ -442,98 +409,69 @@ class TestProvisionSkills(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_no_op_when_agent_has_no_skills(self):
|
def test_no_op_when_agent_has_no_skills(self):
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp"
|
_skills.provision_skills(_plan(skills=[]), bottle)
|
||||||
) as cp, patch(
|
self.assertEqual(0, bottle.cp_in.call_count)
|
||||||
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec"
|
self.assertEqual(0, bottle.exec.call_count)
|
||||||
) as ex:
|
|
||||||
_skills.provision_skills(_plan(skills=[]), "bot-bottle-demo-abc12")
|
|
||||||
self.assertEqual(0, cp.call_count)
|
|
||||||
self.assertEqual(0, ex.call_count)
|
|
||||||
|
|
||||||
def test_mkdir_plus_cp_per_skill(self):
|
def test_mkdir_plus_cp_per_skill(self):
|
||||||
|
bottle = _make_bottle()
|
||||||
with self._patch_host_skill_dir({
|
with self._patch_host_skill_dir({
|
||||||
"init-prd": "/host/skills/init-prd",
|
"init-prd": "/host/skills/init-prd",
|
||||||
"verify": "/host/skills/verify",
|
"verify": "/host/skills/verify",
|
||||||
}), patch(
|
}), patch(
|
||||||
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
|
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
), patch(
|
):
|
||||||
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp"
|
|
||||||
) as cp, patch(
|
|
||||||
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec"
|
|
||||||
) as ex:
|
|
||||||
_skills.provision_skills(
|
_skills.provision_skills(
|
||||||
_plan(skills=["init-prd", "verify"]),
|
_plan(skills=["init-prd", "verify"]),
|
||||||
"bot-bottle-demo-abc12",
|
bottle,
|
||||||
)
|
)
|
||||||
|
|
||||||
# mkdir -p once + (rm -rf + chown) per skill = 5 exec calls.
|
# mkdir skills_dir once + (rm -rf + chown) per skill = 5 exec calls.
|
||||||
self.assertEqual(5, ex.call_count)
|
self.assertEqual(5, bottle.exec.call_count)
|
||||||
mkdir_call = ex.call_args_list[0]
|
scripts = _exec_scripts(bottle)
|
||||||
self.assertEqual(
|
self.assertTrue(
|
||||||
("bot-bottle-demo-abc12", ["mkdir", "-p", "/home/node/.claude/skills"]),
|
any("mkdir -p" in s and "/home/node/.claude/skills" in s for s in scripts)
|
||||||
mkdir_call.args,
|
|
||||||
)
|
)
|
||||||
# Two cp calls, one per skill, into the per-skill subdir.
|
# Two cp calls, one per skill, into the per-skill subdir.
|
||||||
self.assertEqual(2, cp.call_count)
|
self.assertEqual(2, bottle.cp_in.call_count)
|
||||||
cp_targets = {call.args[1] for call in cp.call_args_list}
|
cp_targets = {call.args[1] for call in bottle.cp_in.call_args_list}
|
||||||
self.assertEqual(
|
|
||||||
{
|
|
||||||
"bot-bottle-demo-abc12:/home/node/.claude/skills/init-prd",
|
|
||||||
"bot-bottle-demo-abc12:/home/node/.claude/skills/verify",
|
|
||||||
},
|
|
||||||
cp_targets,
|
|
||||||
)
|
|
||||||
# Each skill gets a chown -R node:node so claude can read it.
|
|
||||||
chown_argvs = [
|
|
||||||
call.args[1] for call in ex.call_args_list
|
|
||||||
if call.args[1][:1] == ["chown"]
|
|
||||||
]
|
|
||||||
self.assertEqual(2, len(chown_argvs))
|
|
||||||
chown_targets = {argv[-1] for argv in chown_argvs}
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{
|
{
|
||||||
"/home/node/.claude/skills/init-prd",
|
"/home/node/.claude/skills/init-prd",
|
||||||
"/home/node/.claude/skills/verify",
|
"/home/node/.claude/skills/verify",
|
||||||
},
|
},
|
||||||
chown_targets,
|
cp_targets,
|
||||||
)
|
)
|
||||||
|
# Each skill gets a chown -R node:node so claude can read it.
|
||||||
|
chown_scripts = [s for s in scripts if "chown -R node:node" in s]
|
||||||
|
self.assertEqual(2, len(chown_scripts))
|
||||||
|
|
||||||
def test_skills_dir_overridable_via_env(self):
|
def test_skills_dir_overridable_via_env(self):
|
||||||
import os
|
import os
|
||||||
|
bottle = _make_bottle()
|
||||||
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
|
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
|
||||||
patch(
|
patch(
|
||||||
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
|
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
), \
|
), \
|
||||||
patch.dict(os.environ, {"BOT_BOTTLE_GUEST_SKILLS_DIR": "/home/node/.claude/skills"}), \
|
patch.dict(os.environ, {"BOT_BOTTLE_GUEST_SKILLS_DIR": "/home/node/.claude/skills"}):
|
||||||
patch(
|
_skills.provision_skills(_plan(skills=["init-prd"]), bottle)
|
||||||
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp"
|
|
||||||
) as cp, \
|
|
||||||
patch(
|
|
||||||
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec"
|
|
||||||
):
|
|
||||||
_skills.provision_skills(_plan(skills=["init-prd"]), "bot-bottle-demo-abc12")
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"bot-bottle-demo-abc12:/home/node/.claude/skills/init-prd",
|
"/home/node/.claude/skills/init-prd",
|
||||||
cp.call_args.args[1],
|
bottle.cp_in.call_args.args[1],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_missing_skill_dies(self):
|
def test_missing_skill_dies(self):
|
||||||
|
bottle = _make_bottle()
|
||||||
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
|
with self._patch_host_skill_dir({"init-prd": "/host/skills/init-prd"}), \
|
||||||
patch(
|
patch(
|
||||||
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
|
"bot_bottle.backend.smolmachines.provision.skills.os.path.isdir",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
), \
|
|
||||||
patch(
|
|
||||||
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_cp"
|
|
||||||
), \
|
|
||||||
patch(
|
|
||||||
"bot_bottle.backend.smolmachines.provision.skills._smolvm.machine_exec"
|
|
||||||
):
|
):
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
_skills.provision_skills(_plan(skills=["init-prd"]), "bot-bottle-demo-abc12")
|
_skills.provision_skills(_plan(skills=["init-prd"]), bottle)
|
||||||
|
|
||||||
|
|
||||||
def _write_self_signed_cert(path: Path) -> None:
|
def _write_self_signed_cert(path: Path) -> None:
|
||||||
@@ -553,7 +491,7 @@ def _write_self_signed_cert(path: Path) -> None:
|
|||||||
class TestProvisionCA(unittest.TestCase):
|
class TestProvisionCA(unittest.TestCase):
|
||||||
"""provision_ca selects the right CA cert (egress when the
|
"""provision_ca selects the right CA cert (egress when the
|
||||||
bottle has routes, else pipelock) and dispatches
|
bottle has routes, else pipelock) and dispatches
|
||||||
machine_cp + machine_exec in the right order."""
|
cp_in + exec in the right order."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.")
|
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.")
|
||||||
@@ -566,10 +504,10 @@ class TestProvisionCA(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self._tmp.cleanup()
|
self._tmp.cleanup()
|
||||||
|
|
||||||
# provision_ca dies hard if update-ca-certificates' stdout
|
# provision_ca dies hard if update-ca-certificates' exit
|
||||||
# doesn't include "1 added"; supply a stock success return
|
# is non-zero; supply a stock success return so the bulk of
|
||||||
# so the bulk of the tests below exercise the happy path.
|
# the tests below exercise the happy path.
|
||||||
_UPDATE_OK = SmolvmRunResult(
|
_UPDATE_OK = ExecResult(
|
||||||
returncode=0,
|
returncode=0,
|
||||||
stdout="Updating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\n",
|
stdout="Updating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\n",
|
||||||
stderr="",
|
stderr="",
|
||||||
@@ -577,27 +515,20 @@ class TestProvisionCA(unittest.TestCase):
|
|||||||
|
|
||||||
def test_pipelock_path_when_no_routes(self):
|
def test_pipelock_path_when_no_routes(self):
|
||||||
plan = _plan(pipelock_ca_path=self.pipelock_ca)
|
plan = _plan(pipelock_ca_path=self.pipelock_ca)
|
||||||
with patch(
|
bottle = _make_bottle(exec_result=self._UPDATE_OK)
|
||||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp"
|
_ca.provision_ca(plan, bottle)
|
||||||
) as cp, patch(
|
bottle.cp_in.assert_called_once_with(
|
||||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec",
|
|
||||||
return_value=self._UPDATE_OK,
|
|
||||||
) as ex:
|
|
||||||
_ca.provision_ca(plan, "bot-bottle-demo-abc12")
|
|
||||||
cp.assert_called_once_with(
|
|
||||||
str(self.pipelock_ca),
|
str(self.pipelock_ca),
|
||||||
"bot-bottle-demo-abc12:" + _ca.AGENT_CA_PATH,
|
_ca.AGENT_CA_PATH,
|
||||||
)
|
)
|
||||||
# chmod + chown + update-ca-certificates are now folded
|
# chmod + chown + update-ca-certificates are folded into
|
||||||
# into one `sh -c` invocation (working around a smolvm
|
# one exec invocation; look at the single exec's script
|
||||||
# exec warm-up SIGKILL race), so we look at the single
|
# rather than expecting separate calls.
|
||||||
# exec's argv rather than expecting separate calls.
|
bottle.exec.assert_called_once()
|
||||||
ex.assert_called_once()
|
script = bottle.exec.call_args.args[0]
|
||||||
argv = ex.call_args.args[1]
|
self.assertIn("chmod 644", script)
|
||||||
self.assertEqual("sh", argv[0])
|
self.assertIn("update-ca-certificates", script)
|
||||||
self.assertEqual("-c", argv[1])
|
self.assertEqual("root", bottle.exec.call_args.kwargs.get("user"))
|
||||||
self.assertIn("chmod 644", argv[2])
|
|
||||||
self.assertIn("update-ca-certificates", argv[2])
|
|
||||||
|
|
||||||
def test_egress_path_when_routes_declared(self):
|
def test_egress_path_when_routes_declared(self):
|
||||||
plan = _plan(
|
plan = _plan(
|
||||||
@@ -605,51 +536,39 @@ class TestProvisionCA(unittest.TestCase):
|
|||||||
egress_ca_path=self.egress_ca,
|
egress_ca_path=self.egress_ca,
|
||||||
pipelock_ca_path=self.pipelock_ca,
|
pipelock_ca_path=self.pipelock_ca,
|
||||||
)
|
)
|
||||||
with patch(
|
bottle = _make_bottle(exec_result=self._UPDATE_OK)
|
||||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp"
|
_ca.provision_ca(plan, bottle)
|
||||||
) as cp, patch(
|
|
||||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec",
|
|
||||||
return_value=self._UPDATE_OK,
|
|
||||||
):
|
|
||||||
_ca.provision_ca(plan, "bot-bottle-demo-abc12")
|
|
||||||
# When routes are declared, egress is the agent's first hop,
|
# When routes are declared, egress is the agent's first hop,
|
||||||
# so egress's CA is the one that gets installed.
|
# so egress's CA is the one that gets installed.
|
||||||
cp.assert_called_once_with(
|
bottle.cp_in.assert_called_once_with(
|
||||||
str(self.egress_ca),
|
str(self.egress_ca),
|
||||||
"bot-bottle-demo-abc12:" + _ca.AGENT_CA_PATH,
|
_ca.AGENT_CA_PATH,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_retries_smolvm_sigkill_during_update_ca(self):
|
def test_retries_smolvm_sigkill_during_update_ca(self):
|
||||||
plan = _plan(pipelock_ca_path=self.pipelock_ca)
|
plan = _plan(pipelock_ca_path=self.pipelock_ca)
|
||||||
killed = SmolvmRunResult(
|
killed = ExecResult(
|
||||||
returncode=137,
|
returncode=137,
|
||||||
stdout="Updating certificates in /etc/ssl/certs...\n",
|
stdout="Updating certificates in /etc/ssl/certs...\n",
|
||||||
stderr="",
|
stderr="",
|
||||||
)
|
)
|
||||||
|
bottle = _make_bottle()
|
||||||
|
bottle.exec.side_effect = [killed, self._UPDATE_OK]
|
||||||
with patch(
|
with patch(
|
||||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp"
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec",
|
|
||||||
side_effect=[killed, self._UPDATE_OK],
|
|
||||||
) as ex, patch(
|
|
||||||
"bot_bottle.backend.smolmachines.provision.ca.time.sleep"
|
"bot_bottle.backend.smolmachines.provision.ca.time.sleep"
|
||||||
) as sleep:
|
) as sleep:
|
||||||
_ca.provision_ca(plan, "bot-bottle-demo-abc12")
|
_ca.provision_ca(plan, bottle)
|
||||||
|
|
||||||
self.assertEqual(2, ex.call_count)
|
self.assertEqual(2, bottle.exec.call_count)
|
||||||
sleep.assert_called_once_with(1.0)
|
sleep.assert_called_once_with(1.0)
|
||||||
|
|
||||||
def test_dies_when_selected_cert_missing(self):
|
def test_dies_when_selected_cert_missing(self):
|
||||||
# Plan claims a pipelock cert at a path that doesn't exist —
|
# Plan claims a pipelock cert at a path that doesn't exist —
|
||||||
# something went wrong in launch's pipelock_tls_init.
|
# something went wrong in launch's pipelock_tls_init.
|
||||||
plan = _plan(pipelock_ca_path=self.tmp / "does-not-exist.pem")
|
plan = _plan(pipelock_ca_path=self.tmp / "does-not-exist.pem")
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp"
|
with self.assertRaises(SystemExit):
|
||||||
), patch(
|
_ca.provision_ca(plan, bottle)
|
||||||
"bot_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec"
|
|
||||||
):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
_ca.provision_ca(plan, "bot-bottle-demo-abc12")
|
|
||||||
|
|
||||||
|
|
||||||
class TestProvisionGit(unittest.TestCase):
|
class TestProvisionGit(unittest.TestCase):
|
||||||
@@ -665,16 +584,10 @@ class TestProvisionGit(unittest.TestCase):
|
|||||||
self._tmp.cleanup()
|
self._tmp.cleanup()
|
||||||
|
|
||||||
def test_noop_when_no_cwd_and_no_git_entries(self):
|
def test_noop_when_no_cwd_and_no_git_entries(self):
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp"
|
_git.provision_git(_plan(stage_dir=self.stage), bottle)
|
||||||
) as cp, patch(
|
bottle.cp_in.assert_not_called()
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
bottle.exec.assert_not_called()
|
||||||
) as ex:
|
|
||||||
_git.provision_git(
|
|
||||||
_plan(stage_dir=self.stage), "bot-bottle-demo-abc12",
|
|
||||||
)
|
|
||||||
cp.assert_not_called()
|
|
||||||
ex.assert_not_called()
|
|
||||||
|
|
||||||
def test_copies_cwd_git_when_copy_cwd_and_git_present(self):
|
def test_copies_cwd_git_when_copy_cwd_and_git_present(self):
|
||||||
# Stage a fake host .git dir under user_cwd so the path-
|
# Stage a fake host .git dir under user_cwd so the path-
|
||||||
@@ -684,33 +597,25 @@ class TestProvisionGit(unittest.TestCase):
|
|||||||
plan = _plan(
|
plan = _plan(
|
||||||
copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage,
|
copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage,
|
||||||
)
|
)
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp"
|
_git.provision_git(plan, bottle)
|
||||||
) as cp, patch(
|
bottle.cp_in.assert_called_once_with(
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
|
||||||
) as ex:
|
|
||||||
_git.provision_git(plan, "bot-bottle-demo-abc12")
|
|
||||||
cp.assert_called_once_with(
|
|
||||||
f"{cwd}/.git",
|
f"{cwd}/.git",
|
||||||
"bot-bottle-demo-abc12:/home/node/workspace/.git",
|
"/home/node/workspace/.git",
|
||||||
)
|
)
|
||||||
argvs = [c.args[1] for c in ex.call_args_list]
|
scripts = _exec_scripts(bottle)
|
||||||
self.assertIn(["mkdir", "-p", "/home/node/workspace"], argvs)
|
self.assertTrue(any("mkdir -p" in s and "/home/node/workspace" in s for s in scripts))
|
||||||
# chown the workspace tree so the agent (node) owns it.
|
# chown the workspace tree so the agent (node) owns it.
|
||||||
self.assertIn(
|
self.assertTrue(
|
||||||
["chown", "-R", "node:node", "/home/node/workspace/.git"],
|
any("chown -R" in s and "node:node" in s and "/home/node/workspace/.git" in s
|
||||||
argvs,
|
for s in scripts)
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_skips_cwd_when_copy_cwd_false(self):
|
def test_skips_cwd_when_copy_cwd_false(self):
|
||||||
plan = _plan(copy_cwd=False, stage_dir=self.stage)
|
plan = _plan(copy_cwd=False, stage_dir=self.stage)
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp"
|
_git.provision_git(plan, bottle)
|
||||||
) as cp, patch(
|
bottle.cp_in.assert_not_called()
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
|
||||||
):
|
|
||||||
_git.provision_git(plan, "bot-bottle-demo-abc12")
|
|
||||||
cp.assert_not_called()
|
|
||||||
|
|
||||||
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
|
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
|
||||||
# Smolmachines's TSI-allowlisted guest dials git-gate via
|
# Smolmachines's TSI-allowlisted guest dials git-gate via
|
||||||
@@ -726,15 +631,11 @@ class TestProvisionGit(unittest.TestCase):
|
|||||||
stage_dir=self.stage,
|
stage_dir=self.stage,
|
||||||
agent_git_gate_host="127.0.0.1:9418",
|
agent_git_gate_host="127.0.0.1:9418",
|
||||||
)
|
)
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_cp"
|
_git.provision_git(plan, bottle)
|
||||||
) as cp, patch(
|
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
|
||||||
):
|
|
||||||
_git.provision_git(plan, "bot-bottle-demo-abc12")
|
|
||||||
# The staged gitconfig path is whatever NamedTemporaryFile
|
# The staged gitconfig path is whatever NamedTemporaryFile
|
||||||
# picked; we read its contents.
|
# picked; we read its contents.
|
||||||
cp_call = cp.call_args
|
cp_call = bottle.cp_in.call_args
|
||||||
staged_path = Path(cp_call.args[0])
|
staged_path = Path(cp_call.args[0])
|
||||||
self.assertEqual(self.stage, staged_path.parent)
|
self.assertEqual(self.stage, staged_path.parent)
|
||||||
content = staged_path.read_text()
|
content = staged_path.read_text()
|
||||||
@@ -776,71 +677,63 @@ class TestBundleLaunchSpec(unittest.TestCase):
|
|||||||
|
|
||||||
class TestProvisionGitUser(unittest.TestCase):
|
class TestProvisionGitUser(unittest.TestCase):
|
||||||
"""`_provision_git_user` runs `git config --global` inside the
|
"""`_provision_git_user` runs `git config --global` inside the
|
||||||
guest as the node user with HOME forced via `smolvm -e`
|
guest as the node user. SmolmachinesBottle.exec sets HOME and
|
||||||
(otherwise --global lands in /root/.gitconfig). No-op when the
|
USER automatically for the requested user, so --global lands
|
||||||
bottle didn't declare git_user (issue #86)."""
|
in /home/node/.gitconfig. No-op when the bottle didn't declare
|
||||||
|
git_user (issue #86)."""
|
||||||
|
|
||||||
def _git_config_calls(self, mock_exec):
|
def _git_config_calls(self, bottle: MagicMock) -> list[tuple[str, str]]:
|
||||||
"""Filter machine_exec calls down to git-config invocations,
|
"""Filter bottle.exec calls down to git-config invocations,
|
||||||
return list of (argv, env-dict) tuples."""
|
return list of (script, user) tuples."""
|
||||||
out = []
|
out = []
|
||||||
for c in mock_exec.call_args_list:
|
for c in bottle.exec.call_args_list:
|
||||||
argv = c.args[1] if len(c.args) > 1 else c.kwargs.get("argv", [])
|
script = c.args[0] if c.args else ""
|
||||||
if "git" in argv and "config" in argv:
|
user = c.kwargs.get("user", "node")
|
||||||
out.append((argv, c.kwargs.get("env") or {}))
|
if "git config" in script:
|
||||||
|
out.append((script, user))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def test_noop_when_no_git_user(self):
|
def test_noop_when_no_git_user(self):
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
_git._provision_git_user(_plan(), bottle)
|
||||||
) as ex:
|
self.assertEqual([], self._git_config_calls(bottle))
|
||||||
_git._provision_git_user(_plan(), "bot-bottle-demo-abc12")
|
|
||||||
self.assertEqual([], self._git_config_calls(ex))
|
|
||||||
|
|
||||||
def test_sets_name_and_email_as_node(self):
|
def test_sets_name_and_email_as_node(self):
|
||||||
plan = _plan(git_user={
|
plan = _plan(git_user={
|
||||||
"name": "Eric Bauerfeld",
|
"name": "Eric Bauerfeld",
|
||||||
"email": "eric@dideric.is",
|
"email": "eric@dideric.is",
|
||||||
})
|
})
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
_git._provision_git_user(plan, bottle)
|
||||||
) as ex:
|
calls = self._git_config_calls(bottle)
|
||||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
|
||||||
calls = self._git_config_calls(ex)
|
|
||||||
self.assertEqual(2, len(calls))
|
self.assertEqual(2, len(calls))
|
||||||
# Both go through `runuser -u node --` so they run as node;
|
# Both run as node so SmolmachinesBottle.exec sets HOME=/home/node
|
||||||
# HOME is forced via smolvm -e so --global writes to
|
# automatically, ensuring --global writes to /home/node/.gitconfig.
|
||||||
# /home/node/.gitconfig and not /root/.gitconfig.
|
for script, user in calls:
|
||||||
for argv, env in calls:
|
self.assertEqual("node", user)
|
||||||
self.assertEqual(
|
self.assertIn("git config --global", script)
|
||||||
["runuser", "-u", "node", "--",
|
self.assertIn("user.name", calls[0][0])
|
||||||
"git", "config", "--global"],
|
self.assertIn("Eric Bauerfeld", calls[0][0])
|
||||||
argv[:7],
|
self.assertIn("user.email", calls[1][0])
|
||||||
)
|
self.assertIn("eric@dideric.is", calls[1][0])
|
||||||
self.assertEqual("/home/node", env.get("HOME"))
|
|
||||||
self.assertEqual("node", env.get("USER"))
|
|
||||||
self.assertEqual(["user.name", "Eric Bauerfeld"], calls[0][0][7:])
|
|
||||||
self.assertEqual(["user.email", "eric@dideric.is"], calls[1][0][7:])
|
|
||||||
|
|
||||||
def test_name_only(self):
|
def test_name_only(self):
|
||||||
plan = _plan(git_user={"name": "Bot"})
|
plan = _plan(git_user={"name": "Bot"})
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
_git._provision_git_user(plan, bottle)
|
||||||
) as ex:
|
calls = self._git_config_calls(bottle)
|
||||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
|
||||||
calls = self._git_config_calls(ex)
|
|
||||||
self.assertEqual(1, len(calls))
|
self.assertEqual(1, len(calls))
|
||||||
self.assertEqual(["user.name", "Bot"], calls[0][0][7:])
|
self.assertIn("user.name", calls[0][0])
|
||||||
|
self.assertIn("Bot", calls[0][0])
|
||||||
|
|
||||||
def test_email_only(self):
|
def test_email_only(self):
|
||||||
plan = _plan(git_user={"email": "bot@example.com"})
|
plan = _plan(git_user={"email": "bot@example.com"})
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.git._smolvm.machine_exec"
|
_git._provision_git_user(plan, bottle)
|
||||||
) as ex:
|
calls = self._git_config_calls(bottle)
|
||||||
_git._provision_git_user(plan, "bot-bottle-demo-abc12")
|
|
||||||
calls = self._git_config_calls(ex)
|
|
||||||
self.assertEqual(1, len(calls))
|
self.assertEqual(1, len(calls))
|
||||||
self.assertEqual(["user.email", "bot@example.com"], calls[0][0][7:])
|
self.assertIn("user.email", calls[0][0])
|
||||||
|
self.assertIn("bot@example.com", calls[0][0])
|
||||||
|
|
||||||
|
|
||||||
class TestProvisionWorkspace(unittest.TestCase):
|
class TestProvisionWorkspace(unittest.TestCase):
|
||||||
@@ -853,94 +746,68 @@ class TestProvisionWorkspace(unittest.TestCase):
|
|||||||
|
|
||||||
def test_noop_when_copy_cwd_false(self):
|
def test_noop_when_copy_cwd_false(self):
|
||||||
plan = _plan(copy_cwd=False, stage_dir=self.stage)
|
plan = _plan(copy_cwd=False, stage_dir=self.stage)
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_cp"
|
_workspace.provision_workspace(plan, bottle)
|
||||||
) as cp, patch(
|
bottle.cp_in.assert_not_called()
|
||||||
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_exec"
|
bottle.exec.assert_not_called()
|
||||||
) 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):
|
def test_copies_workspace_to_plan_path_and_chowns(self):
|
||||||
cwd = self.stage / "cwd"
|
cwd = self.stage / "cwd"
|
||||||
cwd.mkdir()
|
cwd.mkdir()
|
||||||
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
|
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.workspace._smolvm.machine_cp"
|
_workspace.provision_workspace(plan, bottle)
|
||||||
) 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(
|
bottle.cp_in.assert_called_once_with(
|
||||||
str(cwd),
|
str(cwd),
|
||||||
"bot-bottle-demo-abc12:/home/node/workspace",
|
"/home/node/workspace",
|
||||||
)
|
)
|
||||||
argvs = [c.args[1] for c in ex.call_args_list]
|
scripts = _exec_scripts(bottle)
|
||||||
self.assertIn(
|
self.assertTrue(
|
||||||
["sh", "-c", "rm -rf /home/node/workspace && mkdir -p /home/node"],
|
any("rm -rf /home/node/workspace" in s and "mkdir -p /home/node" in s
|
||||||
argvs,
|
for s in scripts)
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertTrue(
|
||||||
[
|
any("chown -R node:node /home/node/workspace" in s
|
||||||
"sh", "-c",
|
and "chmod 755 /home/node/workspace" in s
|
||||||
"chown -R node:node /home/node/workspace && "
|
for s in scripts)
|
||||||
"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(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec"
|
_supervise.provision_supervise(_plan(), bottle)
|
||||||
) as ex:
|
bottle.exec.assert_not_called()
|
||||||
_supervise.provision_supervise(_plan(), "bot-bottle-demo-abc12")
|
|
||||||
ex.assert_not_called()
|
|
||||||
|
|
||||||
def test_calls_claude_mcp_add_when_supervise_enabled(self):
|
def test_calls_claude_mcp_add_when_supervise_enabled(self):
|
||||||
plan = _plan(
|
plan = _plan(
|
||||||
supervise=True,
|
supervise=True,
|
||||||
agent_supervise_url="http://127.0.0.1:9100/",
|
agent_supervise_url="http://127.0.0.1:9100/",
|
||||||
)
|
)
|
||||||
with patch(
|
bottle = _make_bottle()
|
||||||
"bot_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec",
|
_supervise.provision_supervise(plan, bottle)
|
||||||
return_value=SmolvmRunResult(returncode=0, stdout="", stderr=""),
|
bottle.exec.assert_called_once()
|
||||||
) as ex:
|
script = bottle.exec.call_args.args[0]
|
||||||
_supervise.provision_supervise(plan, "bot-bottle-demo-abc12")
|
user = bottle.exec.call_args.kwargs.get("user")
|
||||||
ex.assert_called_once()
|
self.assertEqual("node", user)
|
||||||
argv = ex.call_args.args[1]
|
# SmolmachinesBottle.exec(user="node") handles uid switch +
|
||||||
# `claude mcp add --scope user` writes to ~/.claude.json,
|
# HOME setup automatically — the script itself is just the
|
||||||
# and the agent is the `node` user — switch UID + set
|
# claude command.
|
||||||
# HOME so the config lands in /home/node/.claude.json,
|
self.assertIn("claude mcp add", script)
|
||||||
# not root's. URL is the agent-side endpoint (host
|
self.assertIn("--scope user", script)
|
||||||
# loopback + discovered port), not the docker bridge IP.
|
self.assertIn("--transport http", script)
|
||||||
self.assertEqual(
|
self.assertIn("supervise", script)
|
||||||
[
|
self.assertIn("http://127.0.0.1:9100/", script)
|
||||||
"runuser", "-u", "node", "--",
|
|
||||||
"env", "HOME=/home/node",
|
|
||||||
"claude", "mcp", "add",
|
|
||||||
"--scope", "user",
|
|
||||||
"--transport", "http",
|
|
||||||
"supervise",
|
|
||||||
"http://127.0.0.1:9100/",
|
|
||||||
],
|
|
||||||
argv,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_non_zero_exit_logs_warning_but_does_not_raise(self):
|
def test_non_zero_exit_logs_warning_but_does_not_raise(self):
|
||||||
plan = _plan(supervise=True)
|
plan = _plan(supervise=True)
|
||||||
with patch(
|
bottle = _make_bottle(
|
||||||
"bot_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec",
|
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
|
||||||
return_value=SmolvmRunResult(
|
)
|
||||||
returncode=1, stdout="", stderr="boom",
|
# No raise — the bottle still works without the MCP
|
||||||
),
|
# entry, so we log and move on.
|
||||||
):
|
_supervise.provision_supervise(plan, bottle)
|
||||||
# No raise — the bottle still works without the MCP
|
|
||||||
# entry, so we log and move on.
|
|
||||||
_supervise.provision_supervise(plan, "bot-bottle-demo-abc12")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user