Compare commits
4 Commits
fcd1b34e49
...
d3c04c4b36
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c04c4b36 | |||
| bd7ed51d2f | |||
| b665a94f8f | |||
| 46c74422c5 |
@@ -32,15 +32,21 @@ manifest does not carry a backend field; the host picks.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager
|
||||||
from dataclasses import dataclass
|
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 ..log import die
|
from ..agent_provider import AgentProvisionPlan
|
||||||
|
from ..egress import EgressPlan
|
||||||
|
from ..git_gate import GitGatePlan
|
||||||
|
from ..log import die, info
|
||||||
from ..manifest import GitEntry, Manifest
|
from ..manifest import GitEntry, Manifest
|
||||||
|
from ..supervise import SupervisePlan
|
||||||
from ..util import expand_tilde
|
from ..util import expand_tilde
|
||||||
|
from .print_util import print_multi, visible_agent_env_names
|
||||||
from .util import host_skill_dir
|
from .util import host_skill_dir
|
||||||
|
|
||||||
|
|
||||||
@@ -65,15 +71,56 @@ class BottleSpec:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BottlePlan(ABC):
|
class BottlePlan(ABC):
|
||||||
"""Base output of a backend's prepare step. Concrete subclasses
|
"""Base output of a backend's prepare step. Concrete subclasses
|
||||||
(e.g. DockerBottlePlan) add backend-specific resolved fields and
|
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
|
||||||
implement `print`."""
|
|
||||||
|
|
||||||
spec: BottleSpec
|
spec: BottleSpec
|
||||||
stage_dir: Path
|
stage_dir: Path
|
||||||
|
git_gate_plan: GitGatePlan
|
||||||
|
egress_plan: EgressPlan
|
||||||
|
supervise_plan: SupervisePlan | None
|
||||||
|
agent_provision: AgentProvisionPlan
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def print(self, *, remote_control: bool) -> None:
|
def print(self, *, remote_control: bool) -> None:
|
||||||
"""Render the y/N preflight summary to stderr."""
|
"""Render the y/N preflight summary to stderr."""
|
||||||
|
del remote_control
|
||||||
|
spec = self.spec
|
||||||
|
manifest = spec.manifest
|
||||||
|
agent = manifest.agents[spec.agent_name]
|
||||||
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
|
|
||||||
|
env_names = visible_agent_env_names(
|
||||||
|
sorted(
|
||||||
|
set(bottle.env.keys())
|
||||||
|
| set(self.agent_provision.guest_env.keys())
|
||||||
|
),
|
||||||
|
hidden_env_names=self.agent_provision.hidden_env_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(file=sys.stderr)
|
||||||
|
info(f"agent : {spec.agent_name}")
|
||||||
|
info(f"provider : {self.agent_provision.template}")
|
||||||
|
print_multi("env ", env_names)
|
||||||
|
print_multi("skills ", list(agent.skills))
|
||||||
|
info(f"bottle : {agent.bottle}")
|
||||||
|
|
||||||
|
identity = manifest.git_identity_summary(spec.agent_name)
|
||||||
|
if identity:
|
||||||
|
info(f" git identity : {identity}")
|
||||||
|
|
||||||
|
git_lines = [
|
||||||
|
f"{u.name} → {u.upstream_host}:{u.upstream_port}"
|
||||||
|
for u in self.git_gate_plan.upstreams
|
||||||
|
]
|
||||||
|
if git_lines:
|
||||||
|
print_multi(" git gate ", git_lines)
|
||||||
|
|
||||||
|
if self.egress_plan.routes:
|
||||||
|
egress_lines = []
|
||||||
|
for r in self.egress_plan.routes:
|
||||||
|
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
|
||||||
|
egress_lines.append(f"{r.host}{auth}")
|
||||||
|
print_multi(" egress ", egress_lines)
|
||||||
|
print(file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -2,30 +2,25 @@
|
|||||||
|
|
||||||
Carries the Docker-specific resolved fields produced by
|
Carries the Docker-specific resolved fields produced by
|
||||||
DockerBottleBackend.prepare. The launch step consumes it without
|
DockerBottleBackend.prepare. The launch step consumes it without
|
||||||
further resolution; show_plan-style rendering is the `print` method.
|
further resolution; preflight rendering is inherited from BottlePlan.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...agent_provider import AgentProvisionPlan, PromptMode
|
from ...agent_provider import PromptMode
|
||||||
from ...egress import EgressPlan
|
|
||||||
from ...git_gate import GitGatePlan
|
|
||||||
from ...log import info
|
|
||||||
from ...pipelock import PipelockProxyPlan
|
from ...pipelock import PipelockProxyPlan
|
||||||
from ...supervise import SupervisePlan
|
|
||||||
from .. import BottlePlan
|
from .. import BottlePlan
|
||||||
from ..print_util import print_multi, visible_agent_env_names
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DockerBottlePlan(BottlePlan):
|
class DockerBottlePlan(BottlePlan):
|
||||||
"""Docker-specific resolved fields produced by
|
"""Docker-specific resolved fields produced by
|
||||||
DockerBottleBackend.prepare. Inherits `spec` and `stage_dir` from
|
DockerBottleBackend.prepare. Inherits `spec`, `stage_dir`,
|
||||||
BottlePlan."""
|
`git_gate_plan`, `egress_plan`, `supervise_plan`, and
|
||||||
|
`agent_provision` from BottlePlan."""
|
||||||
|
|
||||||
slug: str
|
slug: str
|
||||||
container_name: str
|
container_name: str
|
||||||
@@ -46,13 +41,7 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
forwarded_env: dict[str, str] = field(repr=False)
|
forwarded_env: dict[str, str] = field(repr=False)
|
||||||
prompt_file: Path
|
prompt_file: Path
|
||||||
proxy_plan: PipelockProxyPlan
|
proxy_plan: PipelockProxyPlan
|
||||||
git_gate_plan: GitGatePlan
|
|
||||||
egress_plan: EgressPlan
|
|
||||||
# None when bottle.supervise is False. PRD 0013 supervise sidecar
|
|
||||||
# is opt-in via the manifest's bottle.supervise field.
|
|
||||||
supervise_plan: SupervisePlan | None
|
|
||||||
use_runsc: bool
|
use_runsc: bool
|
||||||
agent_provision: AgentProvisionPlan
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def agent_command(self) -> str:
|
def agent_command(self) -> str:
|
||||||
@@ -65,55 +54,3 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
@property
|
@property
|
||||||
def agent_provider_template(self) -> str:
|
def agent_provider_template(self) -> str:
|
||||||
return self.agent_provision.template
|
return self.agent_provision.template
|
||||||
|
|
||||||
def print(self, *, remote_control: bool) -> None:
|
|
||||||
"""Render the y/N preflight summary to stderr — compact form
|
|
||||||
intended to fit on screen without scrolling. The full
|
|
||||||
structured shape (image, container, runtime, etc.) lives on
|
|
||||||
this dataclass for tooling that wants to introspect it."""
|
|
||||||
del remote_control # not surfaced in the compact summary
|
|
||||||
spec = self.spec
|
|
||||||
manifest = spec.manifest
|
|
||||||
agent = manifest.agents[spec.agent_name]
|
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
|
||||||
# The agent sees the union of literal env names (rendered into
|
|
||||||
# --env-file) and forwarded env names (`-e NAME` with the
|
|
||||||
# value arriving via subprocess env). The forwarded set holds
|
|
||||||
# the OAuth token (CLAUDE_CODE_OAUTH_TOKEN) and any host-env
|
|
||||||
# interpolations from the manifest; egress holds
|
|
||||||
# upstream tokens in its own environ, so no token forwarding
|
|
||||||
# from the agent to the proxy is needed.
|
|
||||||
env_names = visible_agent_env_names(
|
|
||||||
sorted(
|
|
||||||
set(bottle.env.keys())
|
|
||||||
| set(self.forwarded_env.keys())
|
|
||||||
| set(self.agent_provision.guest_env.keys())
|
|
||||||
),
|
|
||||||
hidden_env_names=self.agent_provision.hidden_env_names,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(file=sys.stderr)
|
|
||||||
info(f"agent : {spec.agent_name}")
|
|
||||||
info(f"provider : {self.agent_provider_template}")
|
|
||||||
print_multi("env ", env_names)
|
|
||||||
print_multi("skills ", list(agent.skills))
|
|
||||||
info(f"bottle : {agent.bottle}")
|
|
||||||
|
|
||||||
identity = manifest.git_identity_summary(spec.agent_name)
|
|
||||||
if identity:
|
|
||||||
info(f" git identity : {identity}")
|
|
||||||
|
|
||||||
git_lines = [
|
|
||||||
f"{u.upstream_host}:{u.upstream_port}"
|
|
||||||
for u in self.git_gate_plan.upstreams
|
|
||||||
]
|
|
||||||
if git_lines:
|
|
||||||
print_multi(" git gate ", git_lines)
|
|
||||||
|
|
||||||
if self.egress_plan.routes:
|
|
||||||
egress_lines = []
|
|
||||||
for r in self.egress_plan.routes:
|
|
||||||
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
|
|
||||||
egress_lines.append(f"{r.host}{auth}")
|
|
||||||
print_multi(" egress ", egress_lines)
|
|
||||||
print(file=sys.stderr)
|
|
||||||
|
|||||||
@@ -8,25 +8,20 @@ in chunk 4."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...agent_provider import AgentProvisionPlan, PromptMode
|
from ...agent_provider import PromptMode
|
||||||
from ...egress import EgressPlan
|
|
||||||
from ...git_gate import GitGatePlan
|
|
||||||
from ...log import info
|
|
||||||
from ...pipelock import PipelockProxyPlan
|
from ...pipelock import PipelockProxyPlan
|
||||||
from ...supervise import SupervisePlan
|
|
||||||
from .. import BottlePlan
|
from .. import BottlePlan
|
||||||
from ..print_util import print_multi, visible_agent_env_names
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SmolmachinesBottlePlan(BottlePlan):
|
class SmolmachinesBottlePlan(BottlePlan):
|
||||||
"""Resolved fields the launch step needs to bring up the bottle.
|
"""Resolved fields the launch step needs to bring up the bottle.
|
||||||
|
|
||||||
Inherits `spec` and `stage_dir` from BottlePlan."""
|
Inherits `spec`, `stage_dir`, `git_gate_plan`, `egress_plan`,
|
||||||
|
`supervise_plan`, and `agent_provision` from BottlePlan."""
|
||||||
|
|
||||||
slug: str
|
slug: str
|
||||||
# Per-bottle docker subnet for the sidecar bundle container.
|
# Per-bottle docker subnet for the sidecar bundle container.
|
||||||
@@ -77,12 +72,6 @@ class SmolmachinesBottlePlan(BottlePlan):
|
|||||||
# per-bottle bridge with a pinned IP. The unused fields stay
|
# per-bottle bridge with a pinned IP. The unused fields stay
|
||||||
# at their dataclass defaults.
|
# at their dataclass defaults.
|
||||||
proxy_plan: PipelockProxyPlan
|
proxy_plan: PipelockProxyPlan
|
||||||
git_gate_plan: GitGatePlan
|
|
||||||
egress_plan: EgressPlan
|
|
||||||
# None when bottle.supervise is False, matching the docker
|
|
||||||
# backend's convention.
|
|
||||||
supervise_plan: SupervisePlan | None
|
|
||||||
agent_provision: AgentProvisionPlan
|
|
||||||
# Agent-side endpoints. On Docker Desktop the docker bridge
|
# Agent-side endpoints. On Docker Desktop the docker bridge
|
||||||
# IPs aren't reachable from the smolvm guest (TSI uses macOS
|
# IPs aren't reachable from the smolvm guest (TSI uses macOS
|
||||||
# networking; docker container IPs live in the daemon's VM),
|
# networking; docker container IPs live in the daemon's VM),
|
||||||
@@ -110,42 +99,3 @@ class SmolmachinesBottlePlan(BottlePlan):
|
|||||||
@property
|
@property
|
||||||
def agent_dockerfile_path(self) -> str:
|
def agent_dockerfile_path(self) -> str:
|
||||||
return self.agent_provision.dockerfile
|
return self.agent_provision.dockerfile
|
||||||
|
|
||||||
def print(self, *, remote_control: bool) -> None:
|
|
||||||
"""Compact y/N preflight. Same shape as the Docker
|
|
||||||
backend's so operators see one format across backends."""
|
|
||||||
del remote_control # not surfaced in the compact summary
|
|
||||||
spec = self.spec
|
|
||||||
manifest = spec.manifest
|
|
||||||
agent = manifest.agents[spec.agent_name]
|
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
|
||||||
|
|
||||||
env_names = visible_agent_env_names(
|
|
||||||
sorted(
|
|
||||||
set(bottle.env.keys())
|
|
||||||
| set(self.agent_provision.guest_env.keys())
|
|
||||||
),
|
|
||||||
hidden_env_names=self.agent_provision.hidden_env_names,
|
|
||||||
)
|
|
||||||
upstreams = [
|
|
||||||
f"{g.Name} → {g.Upstream}" for g in bottle.git
|
|
||||||
]
|
|
||||||
# Use the resolved egress_plan (lowercase `host` on the
|
|
||||||
# plan-level EgressRoute) rather than `bottle.egress.routes`,
|
|
||||||
# which is the manifest's capitalized-attr form.
|
|
||||||
routes = [r.host for r in self.egress_plan.routes]
|
|
||||||
|
|
||||||
print(file=sys.stderr)
|
|
||||||
info(f"agent : {spec.agent_name}")
|
|
||||||
info(f"provider : {self.agent_provider_template}")
|
|
||||||
print_multi("env ", env_names)
|
|
||||||
print_multi("skills ", list(agent.skills))
|
|
||||||
info(f"bottle : {agent.bottle}")
|
|
||||||
identity = manifest.git_identity_summary(spec.agent_name)
|
|
||||||
if identity:
|
|
||||||
info(f" git identity : {identity}")
|
|
||||||
if upstreams:
|
|
||||||
print_multi(" git gate ", upstreams)
|
|
||||||
if routes:
|
|
||||||
print_multi(" egress ", routes)
|
|
||||||
print(file=sys.stderr)
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# PRD 0044: Print Parity Across Backends
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** didericis-claude
|
||||||
|
- **Created:** 2026-06-02
|
||||||
|
- **Issue:** #96
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Hoist `git_gate_plan`, `egress_plan`, `agent_provision`, and `supervise_plan`
|
||||||
|
from the concrete `BottlePlan` subclasses up to `BottlePlan`, and implement
|
||||||
|
`print` concretely there. This eliminates the two per-backend output divergences
|
||||||
|
and ensures any future backend gets correct preflight rendering for free.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`BottlePlan.print` is `@abstractmethod`, so each backend provides its own
|
||||||
|
implementation. The two current implementations have drifted:
|
||||||
|
|
||||||
|
| Field | Docker | smolmachines |
|
||||||
|
|---|---|---|
|
||||||
|
| git gate lines | `upstream_host:upstream_port` from resolved `git_gate_plan.upstreams` | `Name → Upstream` from manifest `bottle.git` |
|
||||||
|
| egress lines | `host [auth:scheme]` | `host` only (auth dropped) |
|
||||||
|
|
||||||
|
The smolmachines docstring says "same shape as the Docker backend's so operators
|
||||||
|
see one format across backends" — that intent is real but nothing enforces it.
|
||||||
|
|
||||||
|
The env_names divergence previously noted in this issue was resolved by PRD 0038
|
||||||
|
(smolmachines env contract): `resolved.forwarded` is now merged into
|
||||||
|
`agent_provision.guest_env` at prepare time on both backends, so displayed env
|
||||||
|
names are equivalent.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- `BottlePlan` carries `git_gate_plan`, `egress_plan`, `agent_provision`, and
|
||||||
|
`supervise_plan` as concrete fields; subclasses no longer declare them
|
||||||
|
independently.
|
||||||
|
- `BottlePlan.print` is a concrete method; subclasses have no `print`
|
||||||
|
implementation of their own.
|
||||||
|
- Both backends render git gate lines as `name → upstream_host:upstream_port`
|
||||||
|
(using `git_gate_plan.upstreams`), not the manifest-level URL.
|
||||||
|
- Both backends render egress lines as `host [auth:scheme]` (dropping the
|
||||||
|
annotation only when `auth_scheme` is empty).
|
||||||
|
- Unit tests assert the unified output for both backends from a single shared
|
||||||
|
test helper.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No changes to the Docker or smolmachines launch, prepare, or cleanup paths.
|
||||||
|
- No changes to how env values are resolved or injected (that is PRD 0038).
|
||||||
|
- No changes to the manifest schema or `GitEntry`.
|
||||||
|
- No new CLI flags or dashboard changes.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- `bot_bottle/backend/__init__.py` — add `git_gate_plan`, `egress_plan`,
|
||||||
|
`agent_provision`, and `supervise_plan` fields to `BottlePlan`; replace
|
||||||
|
`@abstractmethod print` with a concrete implementation.
|
||||||
|
- `bot_bottle/backend/docker/bottle_plan.py` — remove the four hoisted fields
|
||||||
|
and the `print` method.
|
||||||
|
- `bot_bottle/backend/smolmachines/bottle_plan.py` — remove the four hoisted
|
||||||
|
fields and the `print` method.
|
||||||
|
- `tests/unit/` — add or update tests asserting unified preflight output; a
|
||||||
|
shared helper can build a minimal plan fixture for each backend and assert
|
||||||
|
the same lines appear.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- Changes to `bot_bottle/backend/print_util.py` beyond what the new `print`
|
||||||
|
implementation requires.
|
||||||
|
- Changes to `BottleCleanupPlan.print` or any other print method.
|
||||||
|
- Integration tests that launch a real bottle.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Move the four fields that both concrete subclasses already declare —
|
||||||
|
`git_gate_plan: GitGatePlan`, `egress_plan: EgressPlan`,
|
||||||
|
`agent_provision: AgentProvisionPlan`, `supervise_plan: SupervisePlan | None`
|
||||||
|
— up to `BottlePlan`. Both backends' `prepare` paths already produce these with
|
||||||
|
the same types, so no prepare-time changes are needed.
|
||||||
|
|
||||||
|
Replace the `@abstractmethod` `print` with a concrete implementation on
|
||||||
|
`BottlePlan` that:
|
||||||
|
|
||||||
|
1. Builds `env_names` from `bottle.env.keys() | agent_provision.guest_env.keys()`
|
||||||
|
filtered through `agent_provision.hidden_env_names`.
|
||||||
|
2. Builds git gate lines from `git_gate_plan.upstreams` as
|
||||||
|
`f"{u.name} → {u.upstream_host}:{u.upstream_port}"`.
|
||||||
|
3. Builds egress lines from `egress_plan.routes` as
|
||||||
|
`f"{r.host} [auth:{r.auth_scheme}]"` when `r.auth_scheme` is non-empty,
|
||||||
|
else `r.host`.
|
||||||
|
4. Renders the standard two-column preflight block (leading blank line, agent,
|
||||||
|
provider, env, skills, bottle, git identity, git gate, egress, trailing blank
|
||||||
|
line).
|
||||||
|
|
||||||
|
Docker's `forwarded_env` keys are already merged into `agent_provision.guest_env`
|
||||||
|
via the `agent_provision_plan` builder, so no special handling is needed for
|
||||||
|
env_names.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Add a shared fixture builder (e.g. `make_plan(backend)`) in a new or existing
|
||||||
|
unit test module that constructs a minimal `DockerBottlePlan` and
|
||||||
|
`SmolmachinesBottlePlan` from the same spec and plan fields.
|
||||||
|
- Assert that `plan.print(remote_control=False)` produces identical git gate and
|
||||||
|
egress lines for both backends given the same `git_gate_plan` and
|
||||||
|
`egress_plan`.
|
||||||
|
- Test the `auth_scheme` annotation: present when non-empty, absent otherwise.
|
||||||
|
- Test git gate rendering: `name → host:port` format.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `python3 -m unittest discover -s tests/unit`
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""Unit: BottlePlan.print parity across Docker and smolmachines (PRD 0044).
|
||||||
|
|
||||||
|
Both backends inherit a single concrete print() from BottlePlan. These
|
||||||
|
tests verify that identical git_gate_plan and egress_plan inputs produce
|
||||||
|
identical preflight output regardless of backend-specific fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||||
|
from bot_bottle.backend import BottleSpec
|
||||||
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
|
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
|
||||||
|
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||||
|
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||||
|
from bot_bottle.manifest import Manifest
|
||||||
|
from bot_bottle.pipelock import PipelockProxyPlan
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest() -> Manifest:
|
||||||
|
return Manifest.from_json_obj({
|
||||||
|
"bottles": {"dev": {}},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _spec(manifest: Manifest, tmp: str) -> BottleSpec:
|
||||||
|
return BottleSpec(
|
||||||
|
manifest=manifest,
|
||||||
|
agent_name="demo",
|
||||||
|
copy_cwd=False,
|
||||||
|
user_cwd=tmp,
|
||||||
|
identity="test-00001",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git_gate_plan(tmp: str) -> GitGatePlan:
|
||||||
|
stage = Path(tmp)
|
||||||
|
return GitGatePlan(
|
||||||
|
slug="test-00001",
|
||||||
|
entrypoint_script=stage / "entrypoint.sh",
|
||||||
|
hook_script=stage / "hook.sh",
|
||||||
|
access_hook_script=stage / "access-hook.sh",
|
||||||
|
upstreams=(
|
||||||
|
GitGateUpstream(
|
||||||
|
name="myrepo",
|
||||||
|
upstream_url="ssh://git@gitea.example.com:30009/org/myrepo.git",
|
||||||
|
upstream_host="gitea.example.com",
|
||||||
|
upstream_port="30009",
|
||||||
|
identity_file="/dev/null",
|
||||||
|
known_host_key="ssh-ed25519 AAAA...",
|
||||||
|
extra_hosts={},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _egress_plan(tmp: str) -> EgressPlan:
|
||||||
|
return EgressPlan(
|
||||||
|
slug="test-00001",
|
||||||
|
routes_path=Path(tmp) / "egress.yaml",
|
||||||
|
routes=(
|
||||||
|
EgressRoute(
|
||||||
|
host="api.example.com",
|
||||||
|
path_allowlist=("/v1/",),
|
||||||
|
auth_scheme="bearer",
|
||||||
|
token_env="EGRESS_TOKEN_0",
|
||||||
|
token_ref="TOKEN",
|
||||||
|
),
|
||||||
|
EgressRoute(
|
||||||
|
host="static.example.com",
|
||||||
|
path_allowlist=("/",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
token_env_map={"EGRESS_TOKEN_0": "TOKEN"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_provision() -> AgentProvisionPlan:
|
||||||
|
return AgentProvisionPlan(
|
||||||
|
template="claude",
|
||||||
|
command="claude",
|
||||||
|
prompt_mode="append_file",
|
||||||
|
image="",
|
||||||
|
dockerfile="",
|
||||||
|
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _proxy_plan(tmp: str) -> PipelockProxyPlan:
|
||||||
|
return PipelockProxyPlan(
|
||||||
|
yaml_path=Path(tmp) / "pipelock.yaml",
|
||||||
|
slug="test-00001",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
|
||||||
|
stage = Path(tmp)
|
||||||
|
return DockerBottlePlan(
|
||||||
|
spec=spec,
|
||||||
|
stage_dir=stage,
|
||||||
|
git_gate_plan=_git_gate_plan(tmp),
|
||||||
|
egress_plan=_egress_plan(tmp),
|
||||||
|
supervise_plan=None,
|
||||||
|
agent_provision=_agent_provision(),
|
||||||
|
slug="test-00001",
|
||||||
|
container_name="bot-bottle-test-00001",
|
||||||
|
container_name_pinned=False,
|
||||||
|
image="bot-bottle-claude:latest",
|
||||||
|
derived_image="",
|
||||||
|
runtime_image="bot-bottle-claude:latest",
|
||||||
|
dockerfile_path="",
|
||||||
|
env_file=stage / "env",
|
||||||
|
forwarded_env={},
|
||||||
|
prompt_file=stage / "prompt.txt",
|
||||||
|
proxy_plan=_proxy_plan(tmp),
|
||||||
|
use_runsc=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
|
||||||
|
stage = Path(tmp)
|
||||||
|
return SmolmachinesBottlePlan(
|
||||||
|
spec=spec,
|
||||||
|
stage_dir=stage,
|
||||||
|
git_gate_plan=_git_gate_plan(tmp),
|
||||||
|
egress_plan=_egress_plan(tmp),
|
||||||
|
supervise_plan=None,
|
||||||
|
agent_provision=_agent_provision(),
|
||||||
|
slug="test-00001",
|
||||||
|
bundle_subnet="10.99.0.0/24",
|
||||||
|
bundle_gateway="10.99.0.1",
|
||||||
|
bundle_ip="10.99.0.2",
|
||||||
|
machine_name="bot-bottle-test-00001",
|
||||||
|
agent_image_ref="bot-bottle-claude:latest",
|
||||||
|
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
|
||||||
|
prompt_file=stage / "prompt.txt",
|
||||||
|
proxy_plan=_proxy_plan(tmp),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_print(plan: DockerBottlePlan | SmolmachinesBottlePlan) -> list[str]:
|
||||||
|
buf = io.StringIO()
|
||||||
|
orig = sys.stderr
|
||||||
|
sys.stderr = buf
|
||||||
|
try:
|
||||||
|
plan.print(remote_control=False)
|
||||||
|
finally:
|
||||||
|
sys.stderr = orig
|
||||||
|
return buf.getvalue().splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitGatePrintParity(unittest.TestCase):
|
||||||
|
"""Both backends render git gate entries as 'name → host:port'."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
|
||||||
|
manifest = _manifest()
|
||||||
|
spec = _spec(manifest, self._tmp)
|
||||||
|
self._docker_lines = _capture_print(_docker_plan(spec, self._tmp))
|
||||||
|
self._smol_lines = _capture_print(_smolmachines_plan(spec, self._tmp))
|
||||||
|
|
||||||
|
def _git_gate_lines(self, lines: list[str]) -> list[str]:
|
||||||
|
return [ln for ln in lines if "git gate" in ln]
|
||||||
|
|
||||||
|
def test_docker_renders_name_arrow_host_port(self) -> None:
|
||||||
|
git_lines = self._git_gate_lines(self._docker_lines)
|
||||||
|
self.assertEqual(1, len(git_lines))
|
||||||
|
self.assertIn("myrepo → gitea.example.com:30009", git_lines[0])
|
||||||
|
|
||||||
|
def test_smolmachines_renders_name_arrow_host_port(self) -> None:
|
||||||
|
git_lines = self._git_gate_lines(self._smol_lines)
|
||||||
|
self.assertEqual(1, len(git_lines))
|
||||||
|
self.assertIn("myrepo → gitea.example.com:30009", git_lines[0])
|
||||||
|
|
||||||
|
def test_git_gate_lines_match_across_backends(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self._git_gate_lines(self._docker_lines),
|
||||||
|
self._git_gate_lines(self._smol_lines),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEgressPrintParity(unittest.TestCase):
|
||||||
|
"""Both backends render egress with auth annotation where present."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
|
||||||
|
manifest = _manifest()
|
||||||
|
spec = _spec(manifest, self._tmp)
|
||||||
|
self._docker_lines = _capture_print(_docker_plan(spec, self._tmp))
|
||||||
|
self._smol_lines = _capture_print(_smolmachines_plan(spec, self._tmp))
|
||||||
|
|
||||||
|
def _egress_section(self, lines: list[str]) -> list[str]:
|
||||||
|
"""Return lines from the egress label through the last route entry.
|
||||||
|
|
||||||
|
print_multi renders the first route on the label line and
|
||||||
|
aligns additional routes as indented continuation lines
|
||||||
|
(no repeated label). Collect the label line plus every
|
||||||
|
non-blank, non-labelled line that follows before the next
|
||||||
|
top-level section begins."""
|
||||||
|
result: list[str] = []
|
||||||
|
collecting = False
|
||||||
|
indent_prefix = None
|
||||||
|
for ln in lines:
|
||||||
|
stripped = ln.lstrip()
|
||||||
|
if "egress" in stripped and ":" in stripped:
|
||||||
|
collecting = True
|
||||||
|
# Determine the continuation indent from this line's prefix.
|
||||||
|
idx = ln.index("egress")
|
||||||
|
indent_prefix = ln[:idx]
|
||||||
|
result.append(ln)
|
||||||
|
elif collecting:
|
||||||
|
if ln.startswith(indent_prefix) and "egress" not in ln and ":" not in ln.lstrip()[:20]:
|
||||||
|
result.append(ln)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test_docker_includes_auth_annotation(self) -> None:
|
||||||
|
combined = "\n".join(self._egress_section(self._docker_lines))
|
||||||
|
self.assertIn("api.example.com [auth:bearer]", combined)
|
||||||
|
|
||||||
|
def test_smolmachines_includes_auth_annotation(self) -> None:
|
||||||
|
combined = "\n".join(self._egress_section(self._smol_lines))
|
||||||
|
self.assertIn("api.example.com [auth:bearer]", combined)
|
||||||
|
|
||||||
|
def test_unauthenticated_route_has_no_annotation(self) -> None:
|
||||||
|
full = "\n".join(self._docker_lines)
|
||||||
|
self.assertIn("static.example.com", full)
|
||||||
|
self.assertNotIn("static.example.com [auth:", full)
|
||||||
|
|
||||||
|
def test_egress_lines_match_across_backends(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self._egress_section(self._docker_lines),
|
||||||
|
self._egress_section(self._smol_lines),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user