Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dbe44e7cc |
@@ -220,19 +220,18 @@ class AgentProvider(ABC):
|
|||||||
Override for images that run as a different user or use a
|
Override for images that run as a different user or use a
|
||||||
non-standard home directory."""
|
non-standard home directory."""
|
||||||
from .log import info
|
from .log import info
|
||||||
# FIXME: re-enable workspace planning
|
workspace = plan.workspace_plan
|
||||||
# workspace = plan.workspace_plan
|
if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir:
|
||||||
# if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir:
|
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} -> {bottle.name}:{guest_workspace_git}")
|
||||||
# info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
|
bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
|
||||||
# bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
|
bottle.cp_in(host_git, guest_workspace_git)
|
||||||
# bottle.cp_in(host_git, guest_workspace_git)
|
bottle.exec(
|
||||||
# bottle.exec(
|
f"chown -R {shlex.quote(workspace.owner)} "
|
||||||
# f"chown -R {shlex.quote(workspace.owner)} "
|
f"{shlex.quote(guest_workspace_git)}",
|
||||||
# f"{shlex.quote(guest_workspace_git)}",
|
user="root",
|
||||||
# user="root",
|
)
|
||||||
# )
|
|
||||||
|
|
||||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
if manifest_bottle.git:
|
if manifest_bottle.git:
|
||||||
@@ -328,7 +327,7 @@ def runtime_for(template: str) -> AgentProviderRuntime:
|
|||||||
return get_provider(template).runtime
|
return get_provider(template).runtime
|
||||||
|
|
||||||
|
|
||||||
def build_agent_provision_plan(
|
def agent_provision_plan(
|
||||||
*,
|
*,
|
||||||
template: str,
|
template: str,
|
||||||
dockerfile: str,
|
dockerfile: str,
|
||||||
|
|||||||
@@ -39,27 +39,16 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Generic, Sequence, TypeVar
|
from typing import Any, Generic, Sequence, TypeVar
|
||||||
|
|
||||||
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
from ..agent_provider import AgentProvisionPlan, get_provider
|
||||||
from ..egress import EgressPlan
|
from ..egress import EgressPlan
|
||||||
from ..git_gate import GitGatePlan
|
from ..git_gate import GitGatePlan
|
||||||
from ..log import die, info
|
from ..log import die, info
|
||||||
from ..manifest import ManifestGitEntry, Manifest
|
from ..manifest import ManifestGitEntry, Manifest
|
||||||
from ..supervise import SupervisePlan
|
from ..supervise import SupervisePlan
|
||||||
from ..util import expand_tilde
|
from ..util import expand_tilde
|
||||||
from ..env import resolve_env, ResolvedEnv
|
|
||||||
# from ..workspace import WorkspacePlan
|
# from ..workspace import WorkspacePlan
|
||||||
from .print_util import print_multi, visible_agent_env_names
|
from .print_util import print_multi, visible_agent_env_names
|
||||||
from .util import host_skill_dir
|
from .util import host_skill_dir
|
||||||
from .resolve_common import (
|
|
||||||
merge_provision_env_vars,
|
|
||||||
mint_slug,
|
|
||||||
prepare_agent_state_dir,
|
|
||||||
prepare_egress,
|
|
||||||
prepare_git_gate,
|
|
||||||
prepare_supervise,
|
|
||||||
resolve_manifest_dockerfile,
|
|
||||||
write_launch_metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -277,70 +266,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
|
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
|
def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
|
||||||
"""Template method: run cross-backend host-side validation, then
|
"""Template method: run cross-backend host-side validation, then
|
||||||
delegate to the subclass's `_resolve_plan` for the
|
delegate to the subclass's `_resolve_plan` for the
|
||||||
backend-specific resolution (names, scratch files, etc.). The
|
backend-specific resolution (names, scratch files, etc.). The
|
||||||
validation step is enforced here so a future backend cannot
|
validation step is enforced here so a future backend cannot
|
||||||
accidentally skip it. No remote/runtime resources are created."""
|
accidentally skip it. No remote/runtime resources are created."""
|
||||||
self._validate(spec)
|
self._validate(spec)
|
||||||
|
return self._resolve_plan(spec, stage_dir=stage_dir)
|
||||||
self._preflight()
|
|
||||||
|
|
||||||
manifest = spec.manifest
|
|
||||||
manifest_bottle = manifest.bottle_for(spec.agent_name)
|
|
||||||
manfiest_agent_provider = manifest_bottle.agent_provider
|
|
||||||
agent_provider = get_provider(manfiest_agent_provider.template)
|
|
||||||
agent_image = agent_provider.runtime.image
|
|
||||||
resolved_env = resolve_env(manifest, spec.agent_name)
|
|
||||||
|
|
||||||
slug = mint_slug(spec)
|
|
||||||
write_launch_metadata(slug, spec, compose_project="", backend="smolmachines")
|
|
||||||
|
|
||||||
agent_dockerfile_path = resolve_manifest_dockerfile(manfiest_agent_provider.dockerfile, spec)
|
|
||||||
instance_name = f"bot-bottle-{slug}"
|
|
||||||
|
|
||||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
|
|
||||||
|
|
||||||
agent_provision_plan = build_agent_provision_plan(
|
|
||||||
template=manfiest_agent_provider.template,
|
|
||||||
dockerfile=agent_dockerfile_path,
|
|
||||||
state_dir=agent_dir,
|
|
||||||
guest_home="/home/node", # FIXME: should be coming from the agent plan
|
|
||||||
guest_env=self._build_guest_env(resolved_env),
|
|
||||||
forward_host_credentials=manfiest_agent_provider.forward_host_credentials,
|
|
||||||
auth_token=manfiest_agent_provider.auth_token,
|
|
||||||
host_env=dict(os.environ),
|
|
||||||
# trusted_project_path=workspace_plan.workdir,
|
|
||||||
label=spec.label,
|
|
||||||
color=spec.color,
|
|
||||||
)
|
|
||||||
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
|
|
||||||
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
|
|
||||||
supervise_plan = prepare_supervise(manifest_bottle, slug)
|
|
||||||
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
|
|
||||||
|
|
||||||
return self._resolve_plan(
|
|
||||||
spec,
|
|
||||||
instance_name=instance_name, # FIXME: move to agent provision plan
|
|
||||||
agent_image=agent_image, # FIXME: move to agent provision plan
|
|
||||||
prompt_file=prompt_file, # FIXME: move to agent provision plan
|
|
||||||
agent_dockerfile_path=agent_dockerfile_path, # FIXME: move to agent provision plan
|
|
||||||
agent_provision_plan=agent_provision_plan,
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
stage_dir=stage_dir
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
|
||||||
"""
|
|
||||||
tasks to do before resolving a plan
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _validate(self, spec: BottleSpec) -> None:
|
def _validate(self, spec: BottleSpec) -> None:
|
||||||
"""Cross-backend pre-launch checks. Confirms the agent exists,
|
"""Cross-backend pre-launch checks. Confirms the agent exists,
|
||||||
@@ -392,17 +325,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _resolve_plan(self,
|
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
|
||||||
spec: BottleSpec,
|
|
||||||
instance_name: str,
|
|
||||||
agent_image: str,
|
|
||||||
prompt_file: Path,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
agent_dockerfile_path: str,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
supervise_plan: SupervisePlan | None,
|
|
||||||
stage_dir: Path) -> PlanT:
|
|
||||||
"""Backend-specific plan resolution: image/container names,
|
"""Backend-specific plan resolution: image/container names,
|
||||||
env-file, prompt-file, proxy plan, runtime detection. Called by
|
env-file, prompt-file, proxy plan, runtime detection. Called by
|
||||||
`prepare` after `_validate` succeeds."""
|
`prepare` after `_validate` succeeds."""
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
# state file (~/.bot-bottle/state/<slug>/Dockerfile) after a
|
# state file (~/.bot-bottle/state/<slug>/Dockerfile) after a
|
||||||
# capability-block remediation (PRD 0016).
|
# capability-block remediation (PRD 0016).
|
||||||
dockerfile_path: str
|
dockerfile_path: str
|
||||||
|
env_file: Path # docker --env-file: NAME=VALUE literals
|
||||||
# name -> value for vars forwarded into the docker-run child process
|
# name -> value for vars forwarded into the docker-run child process
|
||||||
# via subprocess env (so values never land on argv or in a file).
|
# via subprocess env (so values never land on argv or in a file).
|
||||||
# repr=False keeps secret/interpolated/OAuth values out of any
|
# repr=False keeps secret/interpolated/OAuth values out of any
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
if plan.use_runsc:
|
if plan.use_runsc:
|
||||||
service["runtime"] = "runsc"
|
service["runtime"] = "runsc"
|
||||||
|
if plan.env_file and plan.env_file.exists() and plan.env_file.stat().st_size > 0:
|
||||||
|
service["env_file"] = [str(plan.env_file)]
|
||||||
|
|
||||||
volumes: list[dict[str, Any]] = []
|
volumes: list[dict[str, Any]] = []
|
||||||
if plan.supervise_plan is not None:
|
if plan.supervise_plan is not None:
|
||||||
|
|||||||
@@ -1,52 +1,94 @@
|
|||||||
"""Prepare step for the Docker bottle backend.
|
"""Prepare step for the Docker bottle backend.
|
||||||
|
|
||||||
`resolve_plan` does all host-side resolution (image and container
|
`resolve_plan` does all host-side resolution (image and container
|
||||||
names, prompt-file, proxy plan, runtime detection) and returns a
|
names, env-file, prompt-file, proxy plan, runtime detection) and
|
||||||
frozen DockerBottlePlan. No Docker resources are created; the only
|
returns a frozen DockerBottlePlan. No Docker resources are created;
|
||||||
side effects are scratch files under `stage_dir` and a probe of
|
the only side effects are scratch files under `stage_dir` and a probe
|
||||||
`docker info`. Cross-backend host-side validation has already run
|
of `docker info`. Cross-backend host-side validation has already run
|
||||||
via the base class's `prepare` template before this is called.
|
via the base class's `prepare` template before this is called.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...agent_provider import agent_provision_plan, get_provider
|
||||||
|
from ...env import ResolvedEnv, resolve_env
|
||||||
|
from ...log import die
|
||||||
|
# from ...workspace import workspace_plan as resolve_workspace_plan
|
||||||
|
from .. import BottleSpec
|
||||||
|
from ..resolve_common import (
|
||||||
|
merge_provision_env_vars,
|
||||||
|
mint_slug,
|
||||||
|
prepare_agent_state_dir,
|
||||||
|
prepare_egress,
|
||||||
|
prepare_git_gate,
|
||||||
|
prepare_supervise,
|
||||||
|
resolve_manifest_dockerfile,
|
||||||
|
write_launch_metadata,
|
||||||
|
)
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .. import BottleSpec
|
# from ...bottle_state import (
|
||||||
from ...env import ResolvedEnv
|
# # clear_preserve_marker,
|
||||||
from ...agent_provider import AgentProvisionPlan
|
# per_bottle_dockerfile,
|
||||||
from ...egress import EgressPlan
|
# per_bottle_dockerfile_path,
|
||||||
from ...supervise import SupervisePlan
|
# per_bottle_image_tag,
|
||||||
from ...git_gate import GitGatePlan
|
# )
|
||||||
|
from .sidecar_bundle import sidecar_bundle_container_name
|
||||||
|
|
||||||
def preflight():
|
def preflight():
|
||||||
docker_mod.require_docker()
|
docker_mod.require_docker()
|
||||||
|
|
||||||
def build_guest_env(resolved_env: ResolvedEnv):
|
|
||||||
# resolved = resolve_env(spec.manifest, spec.agent_name)
|
|
||||||
# forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
|
||||||
return dict(resolved_env.literals)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_plan(
|
def resolve_plan(
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
slug: str,
|
*,
|
||||||
resolved_env: ResolvedEnv,
|
|
||||||
instance_name: str,
|
|
||||||
agent_image: str,
|
|
||||||
agent_dockerfile_path: str,
|
|
||||||
prompt_file: Path,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
supervise_plan: SupervisePlan,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||||
that the agent and its skills/git-gate keys are present —
|
that the agent and its skills/git-gate keys are present —
|
||||||
validation already ran in the base class."""
|
validation already ran in the base class."""
|
||||||
|
preflight()
|
||||||
|
|
||||||
|
manifest = spec.manifest
|
||||||
|
manifest_bottle = manifest.bottle_for(spec.agent_name)
|
||||||
|
manfiest_agent_provider = manifest_bottle.agent_provider
|
||||||
|
agent_provider = get_provider(manfiest_agent_provider.template)
|
||||||
|
|
||||||
|
slug = mint_slug(spec)
|
||||||
|
# FIXME: don't thin the compose project should be directly written to metadata like this,
|
||||||
|
# should probably be a backend specific metadata field for details like this
|
||||||
|
write_launch_metadata(slug, spec, compose_project=f"bot-bottle-{slug}", backend="docker")
|
||||||
|
|
||||||
|
agent_image = agent_provider.runtime.image
|
||||||
|
agent_dockerfile_path = resolve_manifest_dockerfile(manfiest_agent_provider.dockerfile, spec)
|
||||||
|
instance_name = f"bot-bottle-{slug}"
|
||||||
|
|
||||||
|
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
|
||||||
|
env_file = agent_dir / "agent.env"
|
||||||
|
|
||||||
|
agent_provision = agent_provision_plan(
|
||||||
|
template=manfiest_agent_provider.template,
|
||||||
|
dockerfile=agent_dockerfile_path,
|
||||||
|
state_dir=agent_dir,
|
||||||
|
guest_home="/home/node", # FIXME: should be coming from the agent plan
|
||||||
|
forward_host_credentials=manfiest_agent_provider.forward_host_credentials,
|
||||||
|
auth_token=manfiest_agent_provider.auth_token,
|
||||||
|
host_env=dict(os.environ),
|
||||||
|
# trusted_project_path=workspace_plan.workdir,
|
||||||
|
label=spec.label,
|
||||||
|
color=spec.color,
|
||||||
|
)
|
||||||
|
agent_provision = merge_provision_env_vars(agent_provision)
|
||||||
|
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision)
|
||||||
|
supervise_plan = prepare_supervise(manifest_bottle, slug)
|
||||||
|
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
|
||||||
|
|
||||||
|
resolved = resolve_env(manifest, spec.agent_name)
|
||||||
|
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
||||||
|
_write_env_file(resolved, env_file)
|
||||||
|
|
||||||
# ==== docker specific setup ====
|
# ==== docker specific setup ====
|
||||||
use_runsc = docker_mod.runsc_available()
|
use_runsc = docker_mod.runsc_available()
|
||||||
@@ -59,14 +101,32 @@ def resolve_plan(
|
|||||||
# container_name_pinned=container_name_pinned,
|
# container_name_pinned=container_name_pinned,
|
||||||
image=agent_image,
|
image=agent_image,
|
||||||
dockerfile_path=agent_dockerfile_path,
|
dockerfile_path=agent_dockerfile_path,
|
||||||
forwarded_env=dict(resolved_env.forwarded),
|
env_file=env_file,
|
||||||
|
forwarded_env=forwarded_env,
|
||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=git_gate_plan,
|
||||||
egress_plan=egress_plan,
|
egress_plan=egress_plan,
|
||||||
supervise_plan=supervise_plan,
|
supervise_plan=supervise_plan,
|
||||||
use_runsc=use_runsc,
|
use_runsc=use_runsc,
|
||||||
agent_provision=agent_provision_plan,
|
agent_provision=agent_provision,
|
||||||
# workspace_plan=workspace_plan,
|
# workspace_plan=workspace_plan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_env_file(resolved: ResolvedEnv, env_file: Path) -> None:
|
||||||
|
"""Serialize the literal portion of a ResolvedEnv into docker's
|
||||||
|
`--env-file` syntax (NAME=VALUE per line, mode 600 since the file
|
||||||
|
may carry verbatim values from the manifest). Forwarded names ride
|
||||||
|
on the plan as a structured tuple instead."""
|
||||||
|
env_lines: list[str] = []
|
||||||
|
for name, value in resolved.literals.items():
|
||||||
|
if "\n" in value:
|
||||||
|
die(
|
||||||
|
f"env entry {name} (literal) contains a newline; "
|
||||||
|
f"docker --env-file cannot represent multi-line values."
|
||||||
|
)
|
||||||
|
env_lines.append(f"{name}={value}")
|
||||||
|
env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
|
||||||
|
env_file.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -83,14 +83,16 @@ def prepare_egress(
|
|||||||
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
|
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
|
||||||
|
|
||||||
|
|
||||||
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
|
def prepare_supervise(
|
||||||
|
bottle: ManifestBottle, slug: str, *, dockerfile_content: str = "",
|
||||||
|
) -> SupervisePlan | None:
|
||||||
"""Prepare the supervise sidecar state dir. Returns None when
|
"""Prepare the supervise sidecar state dir. Returns None when
|
||||||
bottle.supervise is falsy."""
|
bottle.supervise is falsy."""
|
||||||
if not bottle.supervise:
|
if not bottle.supervise:
|
||||||
return None
|
return None
|
||||||
supervise_dir = supervise_state_dir(slug)
|
supervise_dir = supervise_state_dir(slug)
|
||||||
supervise_dir.mkdir(parents=True, exist_ok=True)
|
supervise_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return Supervise().prepare(slug, supervise_dir)
|
return Supervise().prepare(slug, supervise_dir, dockerfile_content=dockerfile_content)
|
||||||
|
|
||||||
|
|
||||||
def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan:
|
def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class SmolmachinesBottlePlan(BottlePlan):
|
|||||||
# `machine_create --from`. The pipeline runs at launch time
|
# `machine_create --from`. The pipeline runs at launch time
|
||||||
# (not prepare time) so the docker build output doesn't garble
|
# (not prepare time) so the docker build output doesn't garble
|
||||||
# the dashboard's preflight modal.
|
# the dashboard's preflight modal.
|
||||||
agent_image: str
|
agent_image_ref: str
|
||||||
# In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since
|
# In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since
|
||||||
# the guest has no DNS resolver inside the TSI allowlist.
|
# the guest has no DNS resolver inside the TSI allowlist.
|
||||||
# Passed to `smolvm machine create` as `-e K=V` flags.
|
# Passed to `smolvm machine create` as `-e K=V` flags.
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ def launch(
|
|||||||
# here, not in prepare, so the docker-build output doesn't
|
# here, not in prepare, so the docker-build output doesn't
|
||||||
# garble the dashboard's preflight modal.
|
# garble the dashboard's preflight modal.
|
||||||
agent_from_path = _ensure_smolmachine(
|
agent_from_path = _ensure_smolmachine(
|
||||||
plan.agent_image,
|
plan.agent_image_ref,
|
||||||
dockerfile=plan.agent_dockerfile_path,
|
dockerfile=plan.agent_dockerfile_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,56 +10,31 @@ No VM bringup — that's `launch.launch`'s job."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .. import BottleSpec
|
from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, get_provider
|
||||||
from ...env import ResolvedEnv
|
|
||||||
from ...agent_provider import AgentProvisionPlan
|
|
||||||
from ...egress import EgressPlan
|
|
||||||
from ...supervise import SupervisePlan
|
|
||||||
from ...git_gate import GitGatePlan
|
|
||||||
|
|
||||||
from ...backend import BottleSpec
|
from ...backend import BottleSpec
|
||||||
from ...env import ResolvedEnv
|
from ...env import resolve_env
|
||||||
# from ...workspace import workspace_plan as resolve_workspace_plan
|
# from ...workspace import workspace_plan as resolve_workspace_plan
|
||||||
|
from ..resolve_common import (
|
||||||
|
merge_provision_env_vars,
|
||||||
|
mint_slug,
|
||||||
|
prepare_agent_state_dir,
|
||||||
|
prepare_egress,
|
||||||
|
prepare_git_gate,
|
||||||
|
prepare_supervise,
|
||||||
|
resolve_manifest_dockerfile,
|
||||||
|
write_launch_metadata,
|
||||||
|
)
|
||||||
from .bottle_plan import SmolmachinesBottlePlan
|
from .bottle_plan import SmolmachinesBottlePlan
|
||||||
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
||||||
|
|
||||||
def preflight():
|
def preflight():
|
||||||
smolmachines_preflight()
|
smolmachines_preflight()
|
||||||
|
|
||||||
def build_guest_env(resolved_env: ResolvedEnv):
|
|
||||||
# Agent's env: resolve through resolve_env() so ?prompt entries
|
|
||||||
# are prompted and ${HOST_VAR} entries are interpolated — matching
|
|
||||||
# the Docker backend's contract. Forwarded (secret/interpolated)
|
|
||||||
# values still reach the guest as -e K=V smolvm flags because
|
|
||||||
# smolvm 0.8.0 has no env-file or stdin injection path; this is
|
|
||||||
# the known argv-exposure gap documented in PRD 0038.
|
|
||||||
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated
|
|
||||||
# in launch.py after bundle bringup.
|
|
||||||
return {
|
|
||||||
**resolved_env.literals,
|
|
||||||
**resolved_env.forwarded,
|
|
||||||
"NO_PROXY": "localhost,127.0.0.1",
|
|
||||||
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
|
|
||||||
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
|
|
||||||
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_plan(
|
def resolve_plan(
|
||||||
spec: BottleSpec,
|
spec: BottleSpec, *, stage_dir: Path
|
||||||
slug: str,
|
|
||||||
resolved_env: ResolvedEnv,
|
|
||||||
instance_name: str,
|
|
||||||
agent_image: str,
|
|
||||||
agent_dockerfile_path: str,
|
|
||||||
prompt_file: Path,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
supervise_plan: SupervisePlan,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
stage_dir: Path,
|
|
||||||
) -> SmolmachinesBottlePlan:
|
) -> SmolmachinesBottlePlan:
|
||||||
"""Materialize the smolmachines plan. The bundle's docker
|
"""Materialize the smolmachines plan. The bundle's docker
|
||||||
subnet + pinned IP are derived from the slug; the agent's
|
subnet + pinned IP are derived from the slug; the agent's
|
||||||
@@ -68,9 +43,60 @@ def resolve_plan(
|
|||||||
pull. Per-bottle guest env + the TSI allow_cidrs land on the
|
pull. Per-bottle guest env + the TSI allow_cidrs land on the
|
||||||
plan for launch to pass straight through to
|
plan for launch to pass straight through to
|
||||||
`machine create` flags."""
|
`machine create` flags."""
|
||||||
|
preflight()
|
||||||
|
|
||||||
|
|
||||||
|
manifest = spec.manifest
|
||||||
|
manifest_bottle = manifest.bottle_for(spec.agent_name)
|
||||||
|
manfiest_agent_provider = manifest_bottle.agent_provider
|
||||||
|
agent_provider = get_provider(manfiest_agent_provider.template)
|
||||||
|
|
||||||
|
slug = mint_slug(spec)
|
||||||
|
write_launch_metadata(slug, spec, compose_project="", backend="smolmachines")
|
||||||
|
|
||||||
# ==== smolmachines specific setup ====
|
# ==== smolmachines specific setup ====
|
||||||
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
|
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
|
||||||
|
# Agent's env: resolve through resolve_env() so ?prompt entries
|
||||||
|
# are prompted and ${HOST_VAR} entries are interpolated — matching
|
||||||
|
# the Docker backend's contract. Forwarded (secret/interpolated)
|
||||||
|
# values still reach the guest as -e K=V smolvm flags because
|
||||||
|
# smolvm 0.8.0 has no env-file or stdin injection path; this is
|
||||||
|
# the known argv-exposure gap documented in PRD 0038.
|
||||||
|
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated
|
||||||
|
# in launch.py after bundle bringup.
|
||||||
|
resolved = resolve_env(manifest, spec.agent_name)
|
||||||
|
guest_env: dict[str, str] = {
|
||||||
|
**resolved.literals,
|
||||||
|
**resolved.forwarded,
|
||||||
|
"NO_PROXY": "localhost,127.0.0.1",
|
||||||
|
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
|
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
|
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
|
}
|
||||||
|
# ==============
|
||||||
|
|
||||||
|
agent_dockerfile_path = resolve_manifest_dockerfile(manfiest_agent_provider.dockerfile, spec)
|
||||||
|
instance_name = f"bot-bottle-{slug}"
|
||||||
|
|
||||||
|
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
|
||||||
|
|
||||||
|
agent_provision = agent_provision_plan(
|
||||||
|
template=manfiest_agent_provider.template,
|
||||||
|
dockerfile=agent_dockerfile_path,
|
||||||
|
state_dir=agent_dir,
|
||||||
|
guest_home="/home/node", # FIXME: should be coming from the agent plan
|
||||||
|
guest_env=guest_env,
|
||||||
|
forward_host_credentials=manfiest_agent_provider.forward_host_credentials,
|
||||||
|
auth_token=manfiest_agent_provider.auth_token,
|
||||||
|
host_env=dict(os.environ),
|
||||||
|
# trusted_project_path=workspace_plan.workdir,
|
||||||
|
label=spec.label,
|
||||||
|
color=spec.color,
|
||||||
|
)
|
||||||
|
agent_provision = merge_provision_env_vars(agent_provision)
|
||||||
|
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision)
|
||||||
|
supervise_plan = prepare_supervise(manifest_bottle, slug)
|
||||||
|
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
|
||||||
|
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
@@ -80,12 +106,12 @@ def resolve_plan(
|
|||||||
bundle_gateway=gateway,
|
bundle_gateway=gateway,
|
||||||
bundle_ip=bundle_ip,
|
bundle_ip=bundle_ip,
|
||||||
machine_name=instance_name,
|
machine_name=instance_name,
|
||||||
agent_image=agent_image,
|
agent_image_ref=agent_image_ref,
|
||||||
guest_env=agent_provision_plan.guest_env,
|
guest_env=agent_provision.guest_env,
|
||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=git_gate_plan,
|
||||||
egress_plan=egress_plan,
|
egress_plan=egress_plan,
|
||||||
supervise_plan=supervise_plan,
|
supervise_plan=supervise_plan,
|
||||||
agent_provision=agent_provision_plan,
|
agent_provision=agent_provision,
|
||||||
# workspace_plan=workspace_plan,
|
# workspace_plan=workspace_plan,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from bot_bottle.agent_provider import (
|
from bot_bottle.agent_provider import (
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||||
build_agent_provision_plan,
|
agent_provision_plan,
|
||||||
)
|
)
|
||||||
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ def _jwt(exp: int) -> str:
|
|||||||
class TestAgentProviderRuntime(unittest.TestCase):
|
class TestAgentProviderRuntime(unittest.TestCase):
|
||||||
def test_codex_plan_declares_home_state(self):
|
def test_codex_plan_declares_home_state(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = build_agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
guest_home="/home/node",
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="/tmp/Dockerfile.codex",
|
dockerfile="/tmp/Dockerfile.codex",
|
||||||
@@ -50,7 +50,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
|
|
||||||
def test_codex_trusts_requested_project_path(self):
|
def test_codex_trusts_requested_project_path(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
build_agent_provision_plan(
|
agent_provision_plan(
|
||||||
guest_home="/home/node",
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
@@ -68,7 +68,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
"auth_mode": "chatgpt",
|
"auth_mode": "chatgpt",
|
||||||
"tokens": {"access_token": _jwt(2000000000)},
|
"tokens": {"access_token": _jwt(2000000000)},
|
||||||
}))
|
}))
|
||||||
plan = build_agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
guest_home="/home/node",
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
@@ -88,7 +88,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
|
|
||||||
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
|
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = build_agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
guest_home="/home/node",
|
guest_home="/home/node",
|
||||||
template="claude",
|
template="claude",
|
||||||
dockerfile="/tmp/Dockerfile.claude",
|
dockerfile="/tmp/Dockerfile.claude",
|
||||||
@@ -110,7 +110,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
|
|
||||||
def test_claude_trusts_requested_project_path(self):
|
def test_claude_trusts_requested_project_path(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
build_agent_provision_plan(
|
agent_provision_plan(
|
||||||
guest_home="/home/node",
|
guest_home="/home/node",
|
||||||
template="claude",
|
template="claude",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
@@ -129,7 +129,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
"auth_mode": "chatgpt",
|
"auth_mode": "chatgpt",
|
||||||
"tokens": {"access_token": _jwt(2000000000)},
|
"tokens": {"access_token": _jwt(2000000000)},
|
||||||
}))
|
}))
|
||||||
plan = build_agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
guest_home="/home/node",
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
@@ -145,7 +145,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
|
|
||||||
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
|
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = build_agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
guest_home="/home/node",
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
@@ -162,7 +162,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
|
|
||||||
def test_claude_without_auth_token_has_passthrough_egress_route(self):
|
def test_claude_without_auth_token_has_passthrough_egress_route(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = build_agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
guest_home="/home/node",
|
guest_home="/home/node",
|
||||||
template="claude",
|
template="claude",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
@@ -185,7 +185,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
"auth_mode": "chatgpt",
|
"auth_mode": "chatgpt",
|
||||||
"tokens": {"access_token": access},
|
"tokens": {"access_token": access},
|
||||||
}))
|
}))
|
||||||
plan = build_agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
guest_home="/home/node",
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
@@ -200,7 +200,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
|
|
||||||
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
|
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = build_agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
guest_home="/home/node",
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
|
|||||||
bundle_gateway="10.99.0.1",
|
bundle_gateway="10.99.0.1",
|
||||||
bundle_ip="10.99.0.2",
|
bundle_ip="10.99.0.2",
|
||||||
machine_name="bot-bottle-test-00001",
|
machine_name="bot-bottle-test-00001",
|
||||||
agent_image="bot-bottle-claude:latest",
|
agent_image_ref="bot-bottle-claude:latest",
|
||||||
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
|
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
|
||||||
prompt_file=stage / "prompt.txt",
|
prompt_file=stage / "prompt.txt",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ def _plan(
|
|||||||
bundle_gateway="192.168.50.1",
|
bundle_gateway="192.168.50.1",
|
||||||
bundle_ip=bundle_ip,
|
bundle_ip=bundle_ip,
|
||||||
machine_name="bot-bottle-demo-abc12",
|
machine_name="bot-bottle-demo-abc12",
|
||||||
agent_image="bot-bottle-claude:latest",
|
agent_image_ref="bot-bottle-claude:latest",
|
||||||
guest_env=dict(guest_env or {}),
|
guest_env=dict(guest_env or {}),
|
||||||
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
|
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
|
||||||
git_gate_plan=GitGatePlan(
|
git_gate_plan=GitGatePlan(
|
||||||
|
|||||||
Reference in New Issue
Block a user