Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea1f022f1a | |||
| f8f1bd6f32 | |||
| b8e2ce0b4d |
@@ -61,6 +61,7 @@ class AgentProviderRuntime:
|
||||
prompt_mode: PromptMode
|
||||
bypass_args: tuple[str, ...]
|
||||
resume_args: tuple[str, ...]
|
||||
remote_control_args: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -390,7 +391,7 @@ def prompt_args(
|
||||
if prompt_mode == "append_file":
|
||||
return ["--append-system-prompt-file", prompt_path]
|
||||
if prompt_mode == "read_prompt_file":
|
||||
if argv and ("resume" in argv or "remote-control" in argv):
|
||||
if argv and "resume" in argv:
|
||||
return []
|
||||
return [f"Read and follow the instructions in {prompt_path}."]
|
||||
if prompt_mode == "print_read_prompt_file":
|
||||
|
||||
@@ -109,8 +109,9 @@ class BottlePlan(ABC):
|
||||
def workspace_plan(self) -> WorkspacePlan:
|
||||
return workspace_plan(self.spec, guest_home=self.guest_home)
|
||||
|
||||
def print(self) -> None:
|
||||
def print(self, *, remote_control: bool) -> None:
|
||||
"""Render the y/N preflight summary to stderr."""
|
||||
del remote_control
|
||||
spec = self.spec
|
||||
manifest = self.manifest
|
||||
agent = manifest.agent
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
|
||||
from ..bottle_state import egress_state_dir
|
||||
from ..egress import EGRESS_ROUTES_FILENAME
|
||||
from ..egress_addon_core import LOG_OFF, load_config
|
||||
from ..egress_addon_core import load_routes
|
||||
|
||||
|
||||
class EgressApplyError(RuntimeError):
|
||||
@@ -33,15 +33,11 @@ class EgressApplicator(ABC):
|
||||
@staticmethod
|
||||
def validate_routes_content(content: str) -> None:
|
||||
try:
|
||||
config = load_config(content)
|
||||
load_routes(content)
|
||||
except ValueError as e:
|
||||
raise EgressApplyError(
|
||||
f"proposed routes.yaml is not valid: {e}"
|
||||
) from e
|
||||
if config.log != LOG_OFF:
|
||||
raise EgressApplyError(
|
||||
"proposed routes.yaml must not change egress logging"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _routes_path(slug: str) -> Path:
|
||||
|
||||
@@ -68,11 +68,6 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
_ensure_builder_dns()
|
||||
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
||||
if dockerfile:
|
||||
# `container build` resolves -f relative to the current working
|
||||
# directory, not the build context. Anchor a relative Dockerfile to
|
||||
# the context so builds work from any cwd.
|
||||
if not os.path.isabs(dockerfile):
|
||||
dockerfile = os.path.join(context, dockerfile)
|
||||
args.extend(["-f", dockerfile])
|
||||
args.append(context)
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
@@ -28,6 +28,7 @@ from .start import _launch_bottle
|
||||
def cmd_resume(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--remote-control", action="store_true")
|
||||
parser.add_argument(
|
||||
"identity",
|
||||
help="bottle identity from a prior `start` (see its session-end output)",
|
||||
@@ -55,5 +56,6 @@ def cmd_resume(argv: list[str]) -> int:
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
dry_run=args.dry_run,
|
||||
remote_control=args.remote_control,
|
||||
backend_name=backend_name,
|
||||
)
|
||||
|
||||
+10
-4
@@ -42,6 +42,7 @@ def cmd_start(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
|
||||
parser.add_argument("--remote-control", action="store_true")
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=known_backend_names(),
|
||||
@@ -88,6 +89,7 @@ def cmd_start(argv: list[str]) -> int:
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
dry_run=dry_run,
|
||||
remote_control=args.remote_control,
|
||||
backend_name=backend_name,
|
||||
)
|
||||
|
||||
@@ -132,7 +134,7 @@ def prepare_with_preflight(
|
||||
|
||||
|
||||
def attach_agent(
|
||||
bottle: Bottle, *, resume: bool = False,
|
||||
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
|
||||
agent_provider_template: str = "claude",
|
||||
startup_args: tuple[str, ...] = (),
|
||||
) -> int:
|
||||
@@ -151,6 +153,8 @@ def attach_agent(
|
||||
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
||||
)
|
||||
agent_args = list(runtime.bypass_args)
|
||||
if remote_control:
|
||||
agent_args.extend(runtime.remote_control_args)
|
||||
agent_args.extend(startup_args)
|
||||
if resume:
|
||||
agent_args.extend(runtime.resume_args)
|
||||
@@ -214,9 +218,9 @@ def _text_prompt_yes() -> bool:
|
||||
return reply in ("y", "Y", "yes", "YES")
|
||||
|
||||
|
||||
def _text_render_preflight():
|
||||
def _text_render_preflight(*, remote_control: bool):
|
||||
def _render(plan: DockerBottlePlan) -> None:
|
||||
plan.print()
|
||||
plan.print(remote_control=remote_control)
|
||||
return _render
|
||||
|
||||
|
||||
@@ -224,6 +228,7 @@ def _launch_bottle(
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
dry_run: bool,
|
||||
remote_control: bool,
|
||||
backend_name: str | None = None,
|
||||
) -> int:
|
||||
"""Shared launch core for `start` and `resume`. Builds the plan,
|
||||
@@ -235,7 +240,7 @@ def _launch_bottle(
|
||||
plan, identity = prepare_with_preflight(
|
||||
spec,
|
||||
stage_dir=stage_dir,
|
||||
render_preflight=_text_render_preflight(),
|
||||
render_preflight=_text_render_preflight(remote_control=remote_control),
|
||||
prompt_yes=_text_prompt_yes,
|
||||
dry_run=dry_run,
|
||||
backend_name=backend_name,
|
||||
@@ -248,6 +253,7 @@ def _launch_bottle(
|
||||
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||
exit_code = attach_agent(
|
||||
bottle,
|
||||
remote_control=remote_control,
|
||||
agent_provider_template=agent_provider_template,
|
||||
startup_args=plan.agent_provision.startup_args,
|
||||
)
|
||||
|
||||
@@ -91,6 +91,7 @@ _RUNTIME = AgentProviderRuntime(
|
||||
prompt_mode="append_file",
|
||||
bypass_args=("--dangerously-skip-permissions",),
|
||||
resume_args=("--continue",),
|
||||
remote_control_args=("--remote-control",),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# bot-bottle Codex provider image.
|
||||
#
|
||||
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
|
||||
# non-root node user, and the provider CLI installed for that user.
|
||||
# non-root node user, and the provider CLI installed globally.
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates curl procps \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# App-specific deps. Python isn't required by codex itself
|
||||
@@ -17,15 +17,12 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g --no-fund --no-audit @openai/codex@0.136.0 \
|
||||
&& npm cache clean --force
|
||||
|
||||
USER node
|
||||
WORKDIR /home/node
|
||||
|
||||
ENV PATH="/home/node/.local/bin:${PATH}"
|
||||
|
||||
# Remote-control support requires the standalone Codex install layout
|
||||
# under ~/.codex/packages/standalone/current. The npm package can run
|
||||
# the TUI, but remote-control commands expect this installer-owned path.
|
||||
RUN mkdir -p /home/node/.codex \
|
||||
&& curl -fsSL https://chatgpt.com/codex/install.sh | sh
|
||||
RUN mkdir -p /home/node/.codex
|
||||
|
||||
CMD ["codex"]
|
||||
|
||||
@@ -55,6 +55,7 @@ _RUNTIME = AgentProviderRuntime(
|
||||
prompt_mode="read_prompt_file",
|
||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||
resume_args=("resume", "--last"),
|
||||
remote_control_args=(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ _RUNTIME = AgentProviderRuntime(
|
||||
prompt_mode="append_system_prompt",
|
||||
bypass_args=(),
|
||||
resume_args=(),
|
||||
remote_control_args=(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -439,6 +439,15 @@ def route_to_yaml_dict(r: Route) -> dict[str, object]:
|
||||
return d
|
||||
|
||||
|
||||
def load_routes(text: str) -> tuple[Route, ...]:
|
||||
"""Parse YAML text → routes."""
|
||||
try:
|
||||
payload = parse_yaml_subset(text)
|
||||
except YamlSubsetError as e:
|
||||
raise ValueError(f"routes payload: invalid YAML: {e}") from e
|
||||
return parse_routes(payload)
|
||||
|
||||
|
||||
def parse_config(payload: object) -> "Config":
|
||||
"""Parse a full egress config payload (top-level log level + routes)."""
|
||||
if not isinstance(payload, dict):
|
||||
@@ -853,6 +862,7 @@ __all__ = [
|
||||
"is_git_push_request",
|
||||
"is_git_fetch_request",
|
||||
"load_config",
|
||||
"load_routes",
|
||||
"match_route",
|
||||
"outbound_scan_headers",
|
||||
"parse_config",
|
||||
|
||||
+110
-18
@@ -49,33 +49,125 @@ def _resolve_one_bottle(
|
||||
repos_cache[name] = _resolve_repos_raw({}, child_raw)
|
||||
return bottle
|
||||
|
||||
if not isinstance(parent_name_raw, str):
|
||||
# Normalize to list, accepting both str and list[str].
|
||||
raw_list: list[object]
|
||||
if isinstance(parent_name_raw, str):
|
||||
raw_list = [parent_name_raw]
|
||||
elif isinstance(parent_name_raw, list):
|
||||
raw_list = parent_name_raw
|
||||
else:
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends must be a string "
|
||||
f"bottle '{name}' extends must be a string or list of strings "
|
||||
f"(was {type(parent_name_raw).__name__})"
|
||||
)
|
||||
parent_name: str = parent_name_raw
|
||||
if parent_name == name:
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends itself; remove the "
|
||||
f"self-reference"
|
||||
)
|
||||
if parent_name not in raws:
|
||||
avail = ", ".join(sorted(raws.keys())) or "(none)"
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends '{parent_name}' which is not "
|
||||
f"defined. Available bottles: {avail}"
|
||||
)
|
||||
parent = _resolve_one_bottle(
|
||||
parent_name, raws, cache, repos_cache, seen + (name,)
|
||||
|
||||
# Validate each entry before resolving any of them.
|
||||
parent_names: list[str] = []
|
||||
for i, pname in enumerate(raw_list):
|
||||
if not isinstance(pname, str):
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends[{i}] must be a string "
|
||||
f"(was {type(pname).__name__})"
|
||||
)
|
||||
parent_names.append(pname)
|
||||
if pname == name:
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends itself; remove the self-reference"
|
||||
)
|
||||
if pname not in raws:
|
||||
avail = ", ".join(sorted(raws.keys())) or "(none)"
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends '{pname}' which is not "
|
||||
f"defined. Available bottles: {avail}"
|
||||
)
|
||||
|
||||
combined_parent, combined_repos_raw = _fold_parents(
|
||||
parent_names, raws, cache, repos_cache, seen + (name,)
|
||||
)
|
||||
merged_repos_raw = _resolve_repos_raw(repos_cache[parent_name], child_raw)
|
||||
bottle = _merge_bottles(parent, child_raw, merged_repos_raw, name)
|
||||
merged_repos_raw = _resolve_repos_raw(combined_repos_raw, child_raw)
|
||||
bottle = _merge_bottles(combined_parent, child_raw, merged_repos_raw, name)
|
||||
cache[name] = bottle
|
||||
repos_cache[name] = merged_repos_raw
|
||||
return bottle
|
||||
|
||||
|
||||
def _fold_parents(
|
||||
parent_names: list[str],
|
||||
raws: dict[str, dict[str, object]],
|
||||
cache: dict[str, ManifestBottle],
|
||||
repos_cache: dict[str, dict[str, object]],
|
||||
seen: tuple[str, ...],
|
||||
) -> tuple[ManifestBottle, dict[str, object]]:
|
||||
"""Resolve each parent and fold them left-to-right.
|
||||
|
||||
Later parents win over earlier ones on conflict. The `seen` tuple
|
||||
carries the current bottle's name so cycle detection works across
|
||||
every parent edge in the multi-parent graph."""
|
||||
first = parent_names[0]
|
||||
effective = _resolve_one_bottle(first, raws, cache, repos_cache, seen)
|
||||
effective_repos_raw = repos_cache[first]
|
||||
for pname in parent_names[1:]:
|
||||
later = _resolve_one_bottle(pname, raws, cache, repos_cache, seen)
|
||||
later_repos_raw = repos_cache[pname]
|
||||
effective, effective_repos_raw = _fold_two_bottles(
|
||||
effective, effective_repos_raw, later, later_repos_raw
|
||||
)
|
||||
return effective, effective_repos_raw
|
||||
|
||||
|
||||
def _fold_two_bottles(
|
||||
earlier: ManifestBottle,
|
||||
earlier_repos_raw: dict[str, object],
|
||||
later: ManifestBottle,
|
||||
later_repos_raw: dict[str, object],
|
||||
) -> tuple[ManifestBottle, dict[str, object]]:
|
||||
"""Combine two resolved parent bottles; later wins over earlier."""
|
||||
from .manifest import ManifestBottle, ManifestGitUser
|
||||
from .manifest_egress import ManifestEgressConfig
|
||||
from .manifest_git import parse_git_gate_config
|
||||
from .manifest_util import as_json_object
|
||||
|
||||
merged_env = {**earlier.env, **later.env}
|
||||
|
||||
merged_git_user = ManifestGitUser(
|
||||
name=later.git_user.name or earlier.git_user.name,
|
||||
email=later.git_user.email or earlier.git_user.email,
|
||||
)
|
||||
|
||||
# Repos: union by name; for same-name entries, later wins per-field.
|
||||
# Unlike _resolve_repos_raw, an empty later_repos_raw means "no repos
|
||||
# declared" — it does NOT clear the earlier parent's repos.
|
||||
names = list(earlier_repos_raw) + [
|
||||
n for n in later_repos_raw if n not in earlier_repos_raw
|
||||
]
|
||||
merged_repos_raw: dict[str, object] = {
|
||||
n: {
|
||||
**as_json_object(earlier_repos_raw.get(n, {}), "earlier parent repo"),
|
||||
**as_json_object(later_repos_raw.get(n, {}), "later parent repo"),
|
||||
}
|
||||
for n in names
|
||||
}
|
||||
if merged_repos_raw:
|
||||
merged_git, _ = parse_git_gate_config("_fold", {"repos": merged_repos_raw})
|
||||
else:
|
||||
merged_git = ()
|
||||
|
||||
# Egress: routes concatenate; scalar fields use last-wins.
|
||||
merged_egress = ManifestEgressConfig(
|
||||
routes=earlier.egress.routes + later.egress.routes,
|
||||
Log=later.egress.Log,
|
||||
)
|
||||
|
||||
return ManifestBottle(
|
||||
env=merged_env,
|
||||
agent_provider=later.agent_provider,
|
||||
git=merged_git,
|
||||
git_user=merged_git_user,
|
||||
egress=merged_egress,
|
||||
supervise=later.supervise,
|
||||
), merged_repos_raw
|
||||
|
||||
|
||||
def _merge_bottles(
|
||||
parent: ManifestBottle,
|
||||
child_raw: dict[str, object],
|
||||
|
||||
@@ -87,5 +87,7 @@ def load_bottle_chain_from_dir(
|
||||
parent = fm.get("extends")
|
||||
if isinstance(parent, str):
|
||||
to_load.append(parent)
|
||||
elif isinstance(parent, list):
|
||||
to_load.extend(p for p in parent if isinstance(p, str))
|
||||
|
||||
return resolve_bottles(raws)[bottle_name]
|
||||
|
||||
@@ -47,11 +47,11 @@ from pathlib import Path
|
||||
try:
|
||||
# Same-directory imports inside the bundle container; these files are
|
||||
# COPYed flat under /app by Dockerfile.sidecars.
|
||||
from egress_addon_core import LOG_OFF, load_config
|
||||
from egress_addon_core import load_routes
|
||||
import supervise as _sv
|
||||
except ModuleNotFoundError:
|
||||
# Package imports for host-side tests and tooling.
|
||||
from .egress_addon_core import LOG_OFF, load_config
|
||||
from .egress_addon_core import load_routes
|
||||
from . import supervise as _sv
|
||||
|
||||
|
||||
@@ -297,17 +297,12 @@ def validate_proposed_file(tool: str, content: str) -> None:
|
||||
pass
|
||||
elif tool in (_sv.TOOL_EGRESS_ALLOW, _sv.TOOL_EGRESS_BLOCK):
|
||||
try:
|
||||
config = load_config(content)
|
||||
load_routes(content)
|
||||
except ValueError as e:
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: proposed routes.yaml is not valid: {e}",
|
||||
) from e
|
||||
if config.log != LOG_OFF:
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: proposed routes.yaml must not change egress logging",
|
||||
)
|
||||
else:
|
||||
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}")
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# PRD prd-new: Multi-parent `extends:` for bottles
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-06-25
|
||||
- **Issue:** #268
|
||||
- **Extends:** PRD 0025 (`0025-bottle-extends.md`)
|
||||
|
||||
## Summary
|
||||
|
||||
Allow a bottle's `extends:` field to accept either a single bottle name (existing
|
||||
behavior) or a list of bottle names (new). Multiple parents are resolved
|
||||
independently and folded left-to-right into a single effective parent before the
|
||||
child is merged on top. This lets orthogonal concerns (base env, networking/egress,
|
||||
agent provider) live in separate bottles and be composed without forcing them into a
|
||||
linear chain.
|
||||
|
||||
## Problem
|
||||
|
||||
PRD 0025 shipped single-parent `extends:` and listed "No multi-parent inheritance"
|
||||
as a non-goal. In practice, users want to compose multiple orthogonal bottles — a
|
||||
base environment, a networking profile, and an agent-provider override — without
|
||||
creating a three-level linear chain that couples unrelated parents to each other.
|
||||
The linear chain workaround has two problems:
|
||||
|
||||
1. **Ordering constraint.** `networking extends base` works, but then
|
||||
`agent extends networking` can't also pick up `base` without going through
|
||||
`networking`, coupling two unrelated concerns.
|
||||
|
||||
2. **Quadratic duplication.** N orthogonal bottles require O(N²) chain variants
|
||||
(one chain per permutation of applied concerns).
|
||||
|
||||
Multi-parent `extends:` removes both constraints: each orthogonal concern stays in
|
||||
its own bottle, and the child bottle is the only place that names the combination.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `extends:` accepts a list of strings in addition to a plain string.
|
||||
- Backward compat: existing single-string `extends:` is unchanged.
|
||||
- Parents are resolved left-to-right; later entries win on conflict.
|
||||
- Child wins over all parents (unchanged from PRD 0025).
|
||||
- Cycle detection covers multi-parent graphs, not just linear chains.
|
||||
- Diamond inheritance: a shared ancestor is resolved once (via the existing cache).
|
||||
- Invalid list entries (non-string, undefined bottle, self-reference) die at parse
|
||||
with clear messages.
|
||||
- `manifest_loader.py`'s `load_bottle_chain_from_dir` enqueues all parents from a
|
||||
list `extends:` so the resolver sees every bottle in the graph.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No change to the agent-vs-bottle trust boundary (PRD 0025 "Alternatives
|
||||
considered" option 2 stays rejected).
|
||||
- No MRO / C3 linearization. Left-to-right fold is sufficient for the expected use
|
||||
cases.
|
||||
- No preflight display of per-field provenance across multiple parents (same open
|
||||
question as PRD 0025; remains a follow-up).
|
||||
|
||||
## Design
|
||||
|
||||
### Schema
|
||||
|
||||
`extends:` now accepts either form:
|
||||
|
||||
```yaml
|
||||
# single parent (unchanged)
|
||||
extends: base
|
||||
|
||||
# multiple parents (new)
|
||||
extends: [base, networking]
|
||||
```
|
||||
|
||||
Both forms are normalized to a list internally. A list with one element behaves
|
||||
identically to the string form.
|
||||
|
||||
### Merge rules for multi-parent fold
|
||||
|
||||
Parents are folded pairwise left-to-right before the child merge. For each step in
|
||||
the fold, the "earlier" bottle is the running accumulator and the "later" bottle is
|
||||
the next parent. Rules per field:
|
||||
|
||||
| Field | Fold rule |
|
||||
|--------------------|--------------------------------------------------------------|
|
||||
| `env` | dict merge; later wins on key collision |
|
||||
| `git-gate.user` | per-field overlay; later's non-empty fields win |
|
||||
| `git-gate.repos` | union by name; for same-name entries, later wins per-field |
|
||||
| `egress.routes` | concatenate (earlier first, later appended) |
|
||||
| `egress.log` | later wins (last-wins) |
|
||||
| `agent_provider` | later wins (last-wins) |
|
||||
| `supervise` | later wins (last-wins) |
|
||||
|
||||
After the fold, the combined parent is merged against the child using the existing
|
||||
PRD 0025 rules (child always wins). The child's `egress.routes` appends to the
|
||||
combined parent's concatenated routes; `validate_egress_routes` runs once on the
|
||||
final merged set and catches duplicate hosts.
|
||||
|
||||
### Algorithm
|
||||
|
||||
```
|
||||
extends: [p1, p2, p3]
|
||||
|
||||
fold:
|
||||
combined = resolve(p1)
|
||||
combined = fold_two(combined, resolve(p2))
|
||||
combined = fold_two(combined, resolve(p3))
|
||||
|
||||
merge:
|
||||
result = _merge_bottles(combined, child_raw, name)
|
||||
```
|
||||
|
||||
`fold_two(earlier, later)` applies the rules in the table above. Cycle detection
|
||||
(the `seen` tuple) is passed to each parent resolution call unchanged — if any
|
||||
parent's chain circles back to the current bottle, it is caught. The `cache` dict
|
||||
ensures a shared ancestor is only resolved once across all parents.
|
||||
|
||||
### Error cases
|
||||
|
||||
| Condition | Error message shape |
|
||||
|----------------------------------------|------------------------------------------------------------------|
|
||||
| `extends` is not a string or list | `extends must be a string or list of strings (was <type>)` |
|
||||
| A list entry is not a string | `extends[<i>] must be a string (was <type>)` |
|
||||
| A list entry names an undefined bottle | `extends '<name>' which is not defined. Available bottles: ...` |
|
||||
| A list entry is the bottle itself | `extends itself; remove the self-reference` |
|
||||
| Cycle through any parent edge | `is in an extends cycle: <chain>` |
|
||||
|
||||
## Implementation
|
||||
|
||||
### `bot_bottle/manifest_extends.py`
|
||||
|
||||
- `_resolve_one_bottle`: accept `str | list[str]` for `extends`; normalize to list;
|
||||
validate each entry; for a single-entry list fall through to the existing
|
||||
single-parent path; for multiple entries call `_fold_parents` then
|
||||
`_merge_bottles`.
|
||||
- `_fold_parents(parent_names, raws, cache, repos_cache, seen)`: resolve each
|
||||
parent and fold pairwise left-to-right; return `(effective_bottle,
|
||||
effective_repos_raw)`.
|
||||
- `_fold_two_bottles(earlier, earlier_repos_raw, later, later_repos_raw)`: apply
|
||||
the fold rules above; return `(folded_bottle, folded_repos_raw)`.
|
||||
|
||||
### `bot_bottle/manifest_loader.py`
|
||||
|
||||
- `load_bottle_chain_from_dir`: when `extends` is a list, enqueue all parent names
|
||||
for loading (previously only `isinstance(parent, str)` was handled).
|
||||
|
||||
### `tests/unit/test_manifest_extends.py`
|
||||
|
||||
- `TestExtendsErrors.test_non_string_extends_dies`: update to use an integer
|
||||
`extends` value (a list is now valid).
|
||||
- New class `TestExtendsMultiParent` covering all cases listed in the issue.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
Unit tests via `ManifestIndex.from_json_obj` (same resolver surface used by all
|
||||
paths). No integration test changes needed — downstream code consumes the already-
|
||||
merged bottle and is unchanged.
|
||||
|
||||
Test cases:
|
||||
- Two-parent list: env union, egress routes concat, git repos union
|
||||
- Last-parent-wins on scalar (supervise, agent_provider)
|
||||
- Child wins over all parents on conflict
|
||||
- Diamond: two parents share an ancestor; ancestor resolved once
|
||||
- Single-element list: identical to string form
|
||||
- Non-string extends value → ManifestError
|
||||
- Non-string list entry → ManifestError
|
||||
- Undefined bottle in list → ManifestError
|
||||
- Self-reference in list → ManifestError
|
||||
- Cycle through multi-parent edge → ManifestError
|
||||
@@ -102,27 +102,6 @@ class TestAttachAgent(unittest.TestCase):
|
||||
bottle.argv,
|
||||
)
|
||||
|
||||
def test_remote_control_is_provider_startup_arg(self):
|
||||
class Bottle:
|
||||
argv: list[str] = []
|
||||
|
||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||
self.argv = list(argv)
|
||||
return 0
|
||||
|
||||
bottle = Bottle()
|
||||
exit_code = start_mod.attach_agent(
|
||||
bottle, # type: ignore[arg-type]
|
||||
agent_provider_template="codex",
|
||||
startup_args=("remote-control",),
|
||||
)
|
||||
|
||||
self.assertEqual(0, exit_code)
|
||||
self.assertEqual(
|
||||
["--dangerously-bypass-approvals-and-sandbox", "remote-control"],
|
||||
bottle.argv,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -29,9 +29,6 @@ from bot_bottle.supervise import SupervisePlan
|
||||
|
||||
|
||||
_URL = "http://supervise:9100/"
|
||||
_CODEX_DOCKERFILE = (
|
||||
Path(__file__).resolve().parents[2] / "bot_bottle/contrib/codex/Dockerfile"
|
||||
)
|
||||
|
||||
|
||||
def _make_bottle(exec_result: ExecResult | None = None) -> MagicMock:
|
||||
@@ -279,12 +276,6 @@ class TestCodexProvision(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestCodexDockerfile(unittest.TestCase):
|
||||
def test_installs_procps_for_remote_control_pid_management(self):
|
||||
dockerfile = _CODEX_DOCKERFILE.read_text()
|
||||
self.assertIn("procps", dockerfile)
|
||||
|
||||
|
||||
class TestCodexSuperviseMcp(unittest.TestCase):
|
||||
def test_noop_when_supervise_disabled(self):
|
||||
bottle = _make_bottle()
|
||||
|
||||
@@ -136,16 +136,6 @@ class TestClaudeArgv(unittest.TestCase):
|
||||
argv,
|
||||
)
|
||||
|
||||
def test_codex_remote_control_startup_arg_does_not_receive_initial_prompt(self):
|
||||
argv = _codex_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv(
|
||||
["--dangerously-bypass-approvals-and-sandbox", "remote-control"],
|
||||
)
|
||||
self.assertEqual(
|
||||
["docker", "exec", "-it", "bot-bottle-dev-abc", "codex",
|
||||
"--dangerously-bypass-approvals-and-sandbox", "remote-control"],
|
||||
argv,
|
||||
)
|
||||
|
||||
def test_codex_resume_does_not_append_initial_prompt(self):
|
||||
argv = _codex_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv(
|
||||
["--dangerously-bypass-approvals-and-sandbox", "resume", "--last"],
|
||||
|
||||
@@ -31,6 +31,7 @@ class _Provider(AgentProvider):
|
||||
return AgentProviderRuntime(
|
||||
template="test", command="test", image="",
|
||||
prompt_mode="append_file", bypass_args=(), resume_args=(),
|
||||
remote_control_args=(),
|
||||
)
|
||||
def provision_plan(self, **kwargs): # type: ignore[override]
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -322,7 +322,7 @@ class TestRenderRoutes(unittest.TestCase):
|
||||
self.assertEqual([], parse_yaml_subset(rendered)["routes"])
|
||||
|
||||
def test_round_trip_through_addon_core(self):
|
||||
from bot_bottle.egress_addon_core import load_config
|
||||
from bot_bottle.egress_addon_core import load_routes
|
||||
b = _bottle([
|
||||
{"host": "api.github.com",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
|
||||
@@ -333,7 +333,7 @@ class TestRenderRoutes(unittest.TestCase):
|
||||
{"host": "api.anthropic.com"},
|
||||
])
|
||||
routes = egress_routes_for_bottle(b)
|
||||
addon_routes = load_config(egress_render_routes(routes)).routes
|
||||
addon_routes = load_routes(egress_render_routes(routes))
|
||||
self.assertEqual(3, len(addon_routes))
|
||||
self.assertEqual("Bearer", addon_routes[0].auth_scheme)
|
||||
self.assertEqual("EGRESS_TOKEN_0", addon_routes[0].token_env)
|
||||
@@ -341,26 +341,26 @@ class TestRenderRoutes(unittest.TestCase):
|
||||
self.assertEqual("", addon_routes[2].auth_scheme)
|
||||
|
||||
def test_dlp_round_trips(self):
|
||||
from bot_bottle.egress_addon_core import load_config
|
||||
from bot_bottle.egress_addon_core import load_routes
|
||||
b = _bottle([{"host": "x.example", "dlp": {
|
||||
"outbound_detectors": ["token_patterns"],
|
||||
"inbound_detectors": False,
|
||||
}}])
|
||||
routes = egress_routes_for_bottle(b)
|
||||
rendered = egress_render_routes(routes)
|
||||
addon_routes = load_config(rendered).routes
|
||||
addon_routes = load_routes(rendered)
|
||||
self.assertEqual(("token_patterns",), addon_routes[0].outbound_detectors)
|
||||
self.assertEqual((), addon_routes[0].inbound_detectors)
|
||||
|
||||
def test_outbound_on_match_round_trips(self):
|
||||
from bot_bottle.egress_addon_core import load_config
|
||||
from bot_bottle.egress_addon_core import load_routes
|
||||
b = _bottle([{"host": "logs.example", "dlp": {
|
||||
"outbound_on_match": "redact",
|
||||
}}])
|
||||
routes = egress_routes_for_bottle(b)
|
||||
rendered = egress_render_routes(routes)
|
||||
self.assertIn('outbound_on_match: "redact"', rendered)
|
||||
addon_routes = load_config(rendered).routes
|
||||
addon_routes = load_routes(rendered)
|
||||
self.assertEqual("redact", addon_routes[0].outbound_on_match)
|
||||
|
||||
def test_outbound_on_match_default_omitted_from_render(self):
|
||||
@@ -370,12 +370,12 @@ class TestRenderRoutes(unittest.TestCase):
|
||||
self.assertNotIn("outbound_on_match", rendered)
|
||||
|
||||
def test_git_fetch_policy_round_trips(self):
|
||||
from bot_bottle.egress_addon_core import load_config
|
||||
from bot_bottle.egress_addon_core import load_routes
|
||||
b = _bottle([{"host": "github.com", "git": {"fetch": True}}])
|
||||
routes = egress_routes_for_bottle(b)
|
||||
rendered = egress_render_routes(routes)
|
||||
self.assertEqual({"fetch": True}, self._parsed(routes)[0]["git"])
|
||||
addon_routes = load_config(rendered).routes
|
||||
addon_routes = load_routes(rendered)
|
||||
self.assertTrue(addon_routes[0].git_fetch)
|
||||
|
||||
def test_log_zero_omitted_from_render(self):
|
||||
|
||||
@@ -32,6 +32,7 @@ from bot_bottle.egress_addon_core import (
|
||||
is_git_fetch_request,
|
||||
is_git_push_request,
|
||||
load_config,
|
||||
load_routes,
|
||||
match_route,
|
||||
outbound_scan_headers,
|
||||
parse_config,
|
||||
@@ -288,6 +289,47 @@ class TestParseDlp(unittest.TestCase):
|
||||
}]})
|
||||
|
||||
|
||||
# --- load_routes ---------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadRoutes(unittest.TestCase):
|
||||
def test_yaml_text_round_trip(self):
|
||||
routes = load_routes(
|
||||
'routes:\n'
|
||||
' - host: "api.example"\n'
|
||||
)
|
||||
self.assertEqual(1, len(routes))
|
||||
self.assertEqual("api.example", routes[0].host)
|
||||
|
||||
def test_full_route_shape_parses(self):
|
||||
routes = load_routes(
|
||||
'routes:\n'
|
||||
' - host: "api.example"\n'
|
||||
' auth_scheme: "Bearer"\n'
|
||||
' token_env: "EGRESS_TOKEN_0"\n'
|
||||
' matches:\n'
|
||||
' - paths:\n'
|
||||
' - value: "/v1/"\n'
|
||||
' - type: "exact"\n'
|
||||
' value: "/messages"\n'
|
||||
)
|
||||
self.assertEqual(1, len(routes))
|
||||
r = routes[0]
|
||||
self.assertEqual("api.example", r.host)
|
||||
self.assertEqual("Bearer", r.auth_scheme)
|
||||
self.assertEqual("EGRESS_TOKEN_0", r.token_env)
|
||||
self.assertEqual(1, len(r.matches))
|
||||
self.assertEqual(2, len(r.matches[0].paths))
|
||||
|
||||
def test_empty_routes_list(self):
|
||||
routes = load_routes("routes: []\n")
|
||||
self.assertEqual((), routes)
|
||||
|
||||
def test_invalid_yaml_raises_value_error(self):
|
||||
with self.assertRaises(ValueError):
|
||||
load_routes("routes:\n\t- host: x\n")
|
||||
|
||||
|
||||
# --- load_config / parse_config ------------------------------------------
|
||||
|
||||
|
||||
@@ -336,33 +378,6 @@ class TestLoadConfig(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_config("not a dict")
|
||||
|
||||
def test_empty_routes_list(self):
|
||||
cfg = load_config("routes: []\n")
|
||||
self.assertEqual((), cfg.routes)
|
||||
|
||||
def test_full_route_shape_parses(self):
|
||||
cfg = load_config(
|
||||
'routes:\n'
|
||||
' - host: "api.example"\n'
|
||||
' auth_scheme: "Bearer"\n'
|
||||
' token_env: "EGRESS_TOKEN_0"\n'
|
||||
' matches:\n'
|
||||
' - paths:\n'
|
||||
' - value: "/v1/"\n'
|
||||
' - type: "exact"\n'
|
||||
' value: "/messages"\n'
|
||||
)
|
||||
r = cfg.routes[0]
|
||||
self.assertEqual("api.example", r.host)
|
||||
self.assertEqual("Bearer", r.auth_scheme)
|
||||
self.assertEqual("EGRESS_TOKEN_0", r.token_env)
|
||||
self.assertEqual(1, len(r.matches))
|
||||
self.assertEqual(2, len(r.matches[0].paths))
|
||||
|
||||
def test_invalid_yaml_raises_value_error(self):
|
||||
with self.assertRaises(ValueError):
|
||||
load_config("routes:\n\t- host: x\n")
|
||||
|
||||
|
||||
# --- evaluate_matches ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -54,15 +54,6 @@ class TestValidateRoutesContent(unittest.TestCase):
|
||||
' auth_scheme: "Bearer"\n'
|
||||
)
|
||||
|
||||
def test_rejects_log_full(self):
|
||||
with self.assertRaises(EgressApplyError) as cm:
|
||||
applicator.validate_routes_content(
|
||||
'log: 2\n'
|
||||
'routes:\n'
|
||||
' - host: "x.example"\n'
|
||||
)
|
||||
self.assertIn("must not change egress logging", str(cm.exception))
|
||||
|
||||
|
||||
class TestApplyRoutesChange(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -73,33 +73,6 @@ resolver #2
|
||||
)
|
||||
self.assertTrue(run.call_args_list[-1].kwargs["check"])
|
||||
|
||||
def test_build_image_anchors_relative_dockerfile_to_context(self):
|
||||
status = util.subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout=(
|
||||
'[{"status":{"state":"running"},'
|
||||
'"configuration":{"dns":{"nameservers":["9.9.9.9"]}}}]'
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
with patch.object(util.subprocess, "run", return_value=status) as run, \
|
||||
patch.object(util.os, "environ", {
|
||||
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
|
||||
}):
|
||||
util.build_image(
|
||||
"bot-bottle-sidecars:latest",
|
||||
"/repo",
|
||||
dockerfile="Dockerfile.sidecars",
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"container", "build", "-t", "bot-bottle-sidecars:latest",
|
||||
"--dns", "9.9.9.9", "-f", "/repo/Dockerfile.sidecars", "/repo",
|
||||
],
|
||||
run.call_args_list[-1].args[0],
|
||||
)
|
||||
|
||||
def test_commit_container_execs_tar_and_builds_image(self):
|
||||
# stderr is bytes because subprocess.run uses stderr=PIPE without text=True
|
||||
completed = util.subprocess.CompletedProcess(
|
||||
|
||||
@@ -423,9 +423,182 @@ class TestExtendsErrors(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("extends cycle", msg)
|
||||
|
||||
def test_non_string_extends_dies(self):
|
||||
msg = _error_message(_build, child={"extends": ["base"]})
|
||||
self.assertIn("extends must be a string", msg)
|
||||
def test_non_string_non_list_extends_dies(self):
|
||||
msg = _error_message(_build, child={"extends": 123})
|
||||
self.assertIn("extends must be a string or list of strings", msg)
|
||||
|
||||
def test_list_entry_non_string_dies(self):
|
||||
msg = _error_message(_build, child={"extends": [123]})
|
||||
self.assertIn("extends[0] must be a string", msg)
|
||||
|
||||
|
||||
class TestExtendsMultiParent(unittest.TestCase):
|
||||
"""extends: [p1, p2, ...] — multi-parent composition (issue #268)."""
|
||||
|
||||
_GIT_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/k"}}
|
||||
_GIT_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/k"}}
|
||||
|
||||
def test_single_element_list_same_as_string(self):
|
||||
m = _build(
|
||||
base={"env": {"X": "1"}},
|
||||
child={"extends": ["base"]},
|
||||
)
|
||||
self.assertEqual({"X": "1"}, dict(m.bottles["child"].env))
|
||||
|
||||
def test_two_parents_env_union(self):
|
||||
m = _build(
|
||||
p1={"env": {"A": "1"}},
|
||||
p2={"env": {"B": "2"}},
|
||||
child={"extends": ["p1", "p2"]},
|
||||
)
|
||||
self.assertEqual({"A": "1", "B": "2"}, dict(m.bottles["child"].env))
|
||||
|
||||
def test_two_parents_env_last_wins_on_collision(self):
|
||||
m = _build(
|
||||
p1={"env": {"X": "from-p1"}},
|
||||
p2={"env": {"X": "from-p2"}},
|
||||
child={"extends": ["p1", "p2"]},
|
||||
)
|
||||
self.assertEqual("from-p2", m.bottles["child"].env["X"])
|
||||
|
||||
def test_child_wins_over_all_parents(self):
|
||||
m = _build(
|
||||
p1={"env": {"X": "from-p1"}},
|
||||
p2={"env": {"X": "from-p2"}},
|
||||
child={"extends": ["p1", "p2"], "env": {"X": "from-child"}},
|
||||
)
|
||||
self.assertEqual("from-child", m.bottles["child"].env["X"])
|
||||
|
||||
def test_two_parents_supervise_last_wins(self):
|
||||
m = _build(
|
||||
p1={"supervise": False},
|
||||
p2={"supervise": True},
|
||||
child={"extends": ["p1", "p2"]},
|
||||
)
|
||||
self.assertTrue(m.bottles["child"].supervise)
|
||||
|
||||
def test_child_supervise_overrides_all_parents(self):
|
||||
m = _build(
|
||||
p1={"supervise": True},
|
||||
p2={"supervise": True},
|
||||
child={"extends": ["p1", "p2"], "supervise": False},
|
||||
)
|
||||
self.assertFalse(m.bottles["child"].supervise)
|
||||
|
||||
def test_two_parents_egress_routes_concatenated(self):
|
||||
m = _build(
|
||||
p1={"egress": {"routes": [{"host": "a.example.com"}]}},
|
||||
p2={"egress": {"routes": [{"host": "b.example.com"}]}},
|
||||
child={"extends": ["p1", "p2"]},
|
||||
)
|
||||
hosts = [r.Host for r in m.bottles["child"].egress.routes]
|
||||
self.assertEqual(["a.example.com", "b.example.com"], hosts)
|
||||
|
||||
def test_child_egress_appends_after_combined_parents(self):
|
||||
m = _build(
|
||||
p1={"egress": {"routes": [{"host": "a.example.com"}]}},
|
||||
p2={"egress": {"routes": [{"host": "b.example.com"}]}},
|
||||
child={
|
||||
"extends": ["p1", "p2"],
|
||||
"egress": {"routes": [{"host": "c.example.com"}]},
|
||||
},
|
||||
)
|
||||
hosts = [r.Host for r in m.bottles["child"].egress.routes]
|
||||
self.assertEqual(["a.example.com", "b.example.com", "c.example.com"], hosts)
|
||||
|
||||
def test_two_parents_git_repos_union(self):
|
||||
m = _build(
|
||||
p1={"git-gate": {"repos": {"a": self._GIT_A}}},
|
||||
p2={"git-gate": {"repos": {"b": self._GIT_B}}},
|
||||
child={"extends": ["p1", "p2"]},
|
||||
)
|
||||
names = {e.Name for e in m.bottles["child"].git}
|
||||
self.assertEqual({"a", "b"}, names)
|
||||
|
||||
def test_two_parents_git_same_name_later_wins_per_field(self):
|
||||
# Both parents declare the same repo name. p2's `key` wins; p1's
|
||||
# `host_key` is preserved because p2 doesn't override it.
|
||||
p1_entry = {
|
||||
"url": "ssh://git@host-a/repo.git",
|
||||
"host_key": "ecdsa AAAA",
|
||||
"key": {"provider": "static", "path": "/k1"},
|
||||
}
|
||||
p2_entry = {
|
||||
"url": "ssh://git@host-a/repo.git", # required, same url
|
||||
"key": {"provider": "gitea", "forge_token_env": "TOK"},
|
||||
}
|
||||
m = _build(
|
||||
p1={"git-gate": {"repos": {"repo": p1_entry}}},
|
||||
p2={"git-gate": {"repos": {"repo": p2_entry}}},
|
||||
child={"extends": ["p1", "p2"]},
|
||||
)
|
||||
entries = m.bottles["child"].git
|
||||
self.assertEqual(1, len(entries))
|
||||
e = entries[0]
|
||||
self.assertEqual("ssh://git@host-a/repo.git", e.Upstream)
|
||||
self.assertEqual("ecdsa AAAA", e.KnownHostKey)
|
||||
self.assertEqual("gitea", e.Key.provider)
|
||||
|
||||
def test_p1_repos_preserved_when_p2_has_none(self):
|
||||
m = _build(
|
||||
p1={"git-gate": {"repos": {"a": self._GIT_A}}},
|
||||
p2={"env": {"X": "1"}},
|
||||
child={"extends": ["p1", "p2"]},
|
||||
)
|
||||
names = [e.Name for e in m.bottles["child"].git]
|
||||
self.assertEqual(["a"], names)
|
||||
|
||||
def test_diamond_shared_ancestor_resolved_once(self):
|
||||
# a <- b, a <- c; child extends [b, c]
|
||||
# `a` must be resolved once and cached.
|
||||
m = _build(
|
||||
a={"env": {"FROM_A": "1"}, "supervise": False},
|
||||
b={"extends": "a", "env": {"FROM_B": "1"}},
|
||||
c={"extends": "a", "env": {"FROM_C": "1"}},
|
||||
child={"extends": ["b", "c"]},
|
||||
)
|
||||
child = m.bottles["child"]
|
||||
self.assertEqual("1", child.env["FROM_A"])
|
||||
self.assertEqual("1", child.env["FROM_B"])
|
||||
self.assertEqual("1", child.env["FROM_C"])
|
||||
# supervise=False from `a` threads through both b and c; c is the
|
||||
# later parent so its effective supervise (False) wins.
|
||||
self.assertFalse(child.supervise)
|
||||
|
||||
def test_three_parents_env_fold_order(self):
|
||||
m = _build(
|
||||
p1={"env": {"X": "1", "A": "a"}},
|
||||
p2={"env": {"X": "2", "B": "b"}},
|
||||
p3={"env": {"X": "3", "C": "c"}},
|
||||
child={"extends": ["p1", "p2", "p3"]},
|
||||
)
|
||||
env = dict(m.bottles["child"].env)
|
||||
self.assertEqual("3", env["X"])
|
||||
self.assertEqual("a", env["A"])
|
||||
self.assertEqual("b", env["B"])
|
||||
self.assertEqual("c", env["C"])
|
||||
|
||||
def test_undefined_bottle_in_list_dies(self):
|
||||
msg = _error_message(
|
||||
_build,
|
||||
base={"env": {}},
|
||||
child={"extends": ["base", "ghost"]},
|
||||
)
|
||||
self.assertIn("extends 'ghost'", msg)
|
||||
self.assertIn("not defined", msg)
|
||||
|
||||
def test_self_reference_in_list_dies(self):
|
||||
msg = _error_message(_build, child={"extends": ["child"]})
|
||||
self.assertIn("extends itself", msg)
|
||||
|
||||
def test_cycle_through_multi_parent_edge_dies(self):
|
||||
msg = _error_message(
|
||||
_build,
|
||||
a={"extends": ["b", "c"]},
|
||||
b={},
|
||||
c={"extends": "a"},
|
||||
)
|
||||
self.assertIn("extends cycle", msg)
|
||||
|
||||
|
||||
class TestExtendsAvailableInBottleKeys(unittest.TestCase):
|
||||
|
||||
@@ -130,7 +130,7 @@ def _capture_print(plan: DockerBottlePlan | SmolmachinesBottlePlan) -> list[str]
|
||||
orig = sys.stderr
|
||||
sys.stderr = buf
|
||||
try:
|
||||
plan.print()
|
||||
plan.print(remote_control=False)
|
||||
finally:
|
||||
sys.stderr = orig
|
||||
return buf.getvalue().splitlines()
|
||||
|
||||
@@ -42,6 +42,7 @@ class _Provider(AgentProvider):
|
||||
return AgentProviderRuntime(
|
||||
template="test", command="test", image="",
|
||||
prompt_mode="append_file", bypass_args=(), resume_args=(),
|
||||
remote_control_args=(),
|
||||
)
|
||||
def provision_plan(self, **kwargs): # type: ignore[override]
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -67,15 +67,6 @@ class TestValidation(unittest.TestCase):
|
||||
with self.assertRaises(_RpcError):
|
||||
validate_proposed_file(_sv.TOOL_EGRESS_BLOCK, "routes: nope\n")
|
||||
|
||||
def test_egress_routes_yaml_rejects_log_full(self):
|
||||
with self.assertRaises(_RpcError) as cm:
|
||||
validate_proposed_file(
|
||||
_sv.TOOL_EGRESS_ALLOW,
|
||||
"log: 2\nroutes:\n - host: example.com\n",
|
||||
)
|
||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||
self.assertIn("must not change egress logging", cm.exception.message)
|
||||
|
||||
|
||||
# --- JSON-RPC parsing ------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user