Compare commits

..

15 Commits

Author SHA1 Message Date
didericis-codex 92518a43b5 docs: activate install script prd
lint / lint (push) Successful in 1m33s
test / unit (pull_request) Successful in 29s
test / integration (pull_request) Successful in 16s
2026-06-22 20:31:13 -04:00
didericis-codex 6b5fbe84cb feat: add install script packaging 2026-06-22 20:31:13 -04:00
didericis e1e68b52d4 ci(prd): rename PRD to prd-new placeholder per new convention 2026-06-22 20:31:13 -04:00
didericis c9a910f740 docs(prd): renumber PRD 0054 → 0057 (0054 slot taken by named-labelled-agents) 2026-06-22 20:31:13 -04:00
didericis b71b8cf296 docs(prd): PRD 0054 - install script 2026-06-22 20:31:13 -04:00
didericis-claude 1a5b6e25f8 fix: add type annotations to _modal stub for pyright
test / unit (pull_request) Successful in 34s
test / integration (pull_request) Successful in 19s
lint / lint (push) Successful in 1m36s
test / unit (push) Successful in 30s
test / integration (push) Successful in 18s
Update Quality Badges / update-badges (push) Successful in 1m19s
2026-06-22 19:30:53 +00:00
didericis-claude 54760964cf fix: label becomes the full slug; re-prompt on collision
lint / lint (push) Failing after 1m44s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 17s
When a label is given it is now used verbatim as the slug (no random
suffix), so two launches with the same label collide by design.  The
CLI re-prompts via the TUI name modal with a disclaimer when the
candidate slug is already in use among running bottles.
2026-06-22 19:26:39 +00:00
didericis-claude e463670649 feat: use label as container slug prefix when provided
lint / lint (push) Successful in 1m45s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 19s
When a user names a bottle via the TUI label field, that name is now
used as the slug prefix for the container identity instead of always
falling back to the agent name.
2026-06-22 19:16:53 +00:00
didericis-claude 6e6890ebd9 feat: remove cyan and white from color palette
lint / lint (push) Successful in 1m41s
test / unit (push) Successful in 33s
test / integration (push) Successful in 18s
Update Quality Badges / update-badges (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 19:09:22 +00:00
didericis-claude 609b3ed090 feat: drop dim colors, keep only bright variants renamed to base names
test / unit (pull_request) Successful in 38s
test / integration (pull_request) Successful in 21s
lint / lint (push) Successful in 1m40s
test / unit (push) Successful in 34s
test / integration (push) Successful in 19s
Update Quality Badges / update-badges (push) Successful in 1m19s
Remove the 8 non-bright and 1 bright-black colors from all color maps.
Rename the remaining 7 bright-* colors to their base names (e.g.
bright-green → green) so the palette is smaller and always vibrant.

Update _init_color_pairs in tui.py to always apply A_BOLD (all palette
entries are now bright variants), and fix all tests to match.
2026-06-22 18:59:51 +00:00
didericis 65faa40b9a refactor(backend): remove _validate_git_entries host key-file check
test / unit (pull_request) Successful in 37s
test / integration (pull_request) Successful in 18s
lint / lint (push) Successful in 1m39s
test / unit (push) Successful in 37s
test / integration (push) Successful in 18s
Update Quality Badges / update-badges (push) Successful in 1m38s
The git-gate copies the identity file at start time and surfaces a
clear failure then; the pre-launch presence check was redundant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:44:46 -04:00
didericis 9f97de115b fix(git-gate): skip host key-file check for gitea-provider repos
lint / lint (push) Successful in 1m39s
test / unit (pull_request) Successful in 34s
test / integration (pull_request) Successful in 18s
_validate_git_entries was written for static keys (PRD 0008) and ran
os.path.isfile() on every entry's IdentityFile. gitea-provider repos
(PRD 0047/0048) create their deploy key at provision time, so
IdentityFile is empty at parse — tripping the check with an empty path
("git upstream key file not found for '<name>': "). Gate the host-file
check on the static provider; gitea entries have nothing to verify here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:21:11 -04:00
didericis 8f21f4df19 refactor(manifest-extends): thread resolved repos through recursion
lint / lint (push) Successful in 1m32s
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 16s
Replace the lossy _entry_to_raw round-trip with a repos_cache threaded
alongside the ManifestBottle cache in _resolve_one_bottle. Each bottle's
effective git-gate.repos is stored as raw dicts keyed by name, so a child
field-merges directly against its parent's raw repos instead of
reconstructing them from parsed ManifestGitEntry objects.

_resolve_repos_raw now owns the union/clear/inherit semantics on plain
dicts; _merge_bottles just injects the precomputed merged set before
parsing. Drops _entry_to_raw entirely, removing the maintenance hazard
where a new ManifestGitEntry field would silently vanish from inherited
repos.

Addresses review feedback on #238.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgEFTXcWZjA8n7ntq2zHQQ
2026-06-19 22:53:27 -04:00
didericis-claude ff7a52c1d2 refactor(manifest-extends): simplify git-gate repo merge to union + dict unpack
lint / lint (push) Failing after 1m31s
test / unit (pull_request) Successful in 30s
test / integration (pull_request) Successful in 17s
Replace the bespoke _pre_merge_git_repos loop and _merge_git_remotes
with a single _merge_git_repos_raw that does a name-keyed union merge
at the raw dict level: build parent_repos from _entry_to_raw, then
for each name in set(child) | set(parent) produce {**parent.get(n,{}),
**child.get(n,{})}. child.git after from_dict already has the full
merged set, so _merge_git_remotes is no longer needed.
2026-06-20 02:25:09 +00:00
didericis-claude 4ed6b84863 feat(manifest-extends): field-merge same-name git-gate repos on extends
lint / lint (push) Successful in 1m34s
test / unit (pull_request) Successful in 27s
test / integration (pull_request) Successful in 15s
When a child bottle declares a git-gate repo with the same name as a
parent repo, merge field-by-field (child wins, parent provides fallback)
instead of letting the child entry silently replace the parent entry.
This lets a child override only `key:` without repeating `url:` and
`host_key:`. Change the merge key in _merge_git_remotes from UpstreamHost
to Name, which is the natural unique identity for a repo entry.

Closes #237
2026-06-20 02:02:12 +00:00
16 changed files with 328 additions and 148 deletions
+5 -17
View File
@@ -45,7 +45,7 @@ from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provi
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 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 ..env import resolve_env, ResolvedEnv
@@ -356,16 +356,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
pass 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
the named skills are present on the host, and every git and the named skills are present on the host. Subclasses with
IdentityFile resolves. Subclasses with additional preconditions additional preconditions should override and call
should override and call `super()._validate(spec)` first.""" `super()._validate(spec)` first."""
manifest = spec.manifest manifest = spec.manifest
manifest.require_agent(spec.agent_name) manifest.require_agent(spec.agent_name)
agent = manifest.agents[spec.agent_name] agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
self._validate_skills(agent.skills) self._validate_skills(agent.skills)
self._validate_git_entries(bottle.git)
self._validate_agent_provider_dockerfile(spec) self._validate_agent_provider_dockerfile(spec)
def _validate_skills(self, skills: Sequence[str]) -> None: def _validate_skills(self, skills: Sequence[str]) -> None:
@@ -380,16 +378,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
f"Create it under ~/.claude/skills/, then re-run." f"Create it under ~/.claude/skills/, then re-run."
) )
def _validate_git_entries(self, entries: Sequence[ManifestGitEntry]) -> None:
"""Each entry's IdentityFile must exist on the host (after
expanding leading ~) — the git-gate copies it in at start time
to authenticate the upstream push (PRD 0008). Shape is already
enforced by Manifest validation; this only checks presence."""
for entry in entries:
key = expand_tilde(entry.IdentityFile)
if not os.path.isfile(key):
die(f"git upstream key file not found for '{entry.Name}': {key}")
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None: def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
bottle = spec.manifest.bottle_for(spec.agent_name) bottle = spec.manifest.bottle_for(spec.agent_name)
dockerfile = bottle.agent_provider.dockerfile dockerfile = bottle.agent_provider.dockerfile
+12 -2
View File
@@ -33,8 +33,18 @@ from . import BottleSpec
def mint_slug(spec: BottleSpec) -> str: def mint_slug(spec: BottleSpec) -> str:
"""Return the bottle identity: the recorded identity for a resume, """Return the bottle identity: the recorded identity for a resume,
or a freshly minted one for a new start.""" or a freshly minted one for a new start.
return spec.identity or bottle_identity(spec.agent_name)
When a label is provided it becomes the full slug (no random suffix),
so two launches with the same label collide by design. When no label
is given the identity is minted with a random suffix to avoid
collisions between anonymous launches of the same agent."""
if spec.identity:
return spec.identity
if spec.label:
from .docker import util as docker_mod
return docker_mod.slugify(spec.label)
return bottle_identity(spec.agent_name)
def write_launch_metadata( def write_launch_metadata(
+5 -16
View File
@@ -12,22 +12,11 @@ import shlex
# uses true/24-bit colors for its own chrome, which would otherwise bypass # uses true/24-bit colors for its own chrome, which would otherwise bypass
# the palette entirely. # the palette entirely.
_COLORS: dict[str, tuple[int, str, int, str, str]] = { _COLORS: dict[str, tuple[int, str, int, str, str]] = {
"black": (0, "#2d2d2d", 8, "#5c5c5c", "#0a0a0a"), "red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
"red": (1, "#c0392b", 9, "#e74c3c", "#1a0707"), "green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
"green": (2, "#27ae60", 10, "#2ecc71", "#071a09"), "yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
"yellow": (3, "#d4ac0d", 11, "#f1c40f", "#1a1507"), "blue": (12, "#3498db", 4, "#2471a3", "#080820"),
"blue": (4, "#2471a3", 12, "#3498db", "#07071a"), "magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
"magenta": (5, "#7d3c98", 13, "#9b59b6", "#12071a"),
"cyan": (6, "#148f77", 14, "#1abc9c", "#071a1a"),
"white": (7, "#bdc3c7", 15, "#ecf0f1", "#111111"),
"bright-black": (8, "#5c5c5c", 0, "#2d2d2d", "#111111"),
"bright-red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
"bright-green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
"bright-yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
"bright-blue": (12, "#3498db", 4, "#2471a3", "#080820"),
"bright-magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
"bright-cyan": (14, "#1abc9c", 6, "#148f77", "#082020"),
"bright-white": (15, "#ecf0f1", 7, "#bdc3c7", "#151515"),
} }
# OSC 104 resets all indexed palette entries; OSC 111 resets default background. # OSC 104 resets all indexed palette entries; OSC 111 resets default background.
+5 -16
View File
@@ -11,22 +11,11 @@ from ..manifest import Manifest
from ._common import PROG, USER_CWD from ._common import PROG, USER_CWD
_ANSI_COLOR_CODES: dict[str, str] = { _ANSI_COLOR_CODES: dict[str, str] = {
"black": "\033[30m", "red": "\033[91m",
"red": "\033[31m", "green": "\033[92m",
"green": "\033[32m", "yellow": "\033[93m",
"yellow": "\033[33m", "blue": "\033[94m",
"blue": "\033[34m", "magenta": "\033[95m",
"magenta": "\033[35m",
"cyan": "\033[36m",
"white": "\033[37m",
"bright-black": "\033[90m",
"bright-red": "\033[91m",
"bright-green": "\033[92m",
"bright-yellow": "\033[93m",
"bright-blue": "\033[94m",
"bright-magenta": "\033[95m",
"bright-cyan": "\033[96m",
"bright-white": "\033[97m",
} }
_ANSI_RESET = "\033[0m" _ANSI_RESET = "\033[0m"
+18
View File
@@ -20,9 +20,11 @@ from ..agent_provider import runtime_for
from ..backend import ( from ..backend import (
Bottle, Bottle,
BottleSpec, BottleSpec,
enumerate_active_agents,
get_bottle_backend, get_bottle_backend,
known_backend_names, known_backend_names,
) )
from ..backend.docker import util as docker_mod
from ..backend.docker.bottle_plan import DockerBottlePlan from ..backend.docker.bottle_plan import DockerBottlePlan
from ..bottle_state import ( from ..bottle_state import (
cleanup_state, cleanup_state,
@@ -74,6 +76,7 @@ def cmd_start(argv: list[str]) -> int:
backend_name: str | None = args.backend backend_name: str | None = args.backend
label, color = tui.name_color_modal(default_label=agent_name) label, color = tui.name_color_modal(default_label=agent_name)
label, color = _resolve_unique_label(label, color)
spec = BottleSpec( spec = BottleSpec(
manifest=manifest, manifest=manifest,
@@ -191,6 +194,21 @@ def _identity_from_plan(plan: object) -> str:
return getattr(plan, "slug", "") return getattr(plan, "slug", "")
def _resolve_unique_label(label: str, color: str) -> tuple[str, str]:
"""Re-prompt with a disclaimer until the label's slug is not already
in use among running bottles. Passes through unchanged when no
collision is found on the first check."""
while True:
slug_candidate = docker_mod.slugify(label)
active_slugs = {a.slug for a in enumerate_active_agents()}
if slug_candidate not in active_slugs:
return label, color
label, color = tui.name_color_modal(
default_label=label,
disclaimer=f'"{label}" is already in use',
)
def _text_prompt_yes() -> bool: def _text_prompt_yes() -> bool:
"""Default `prompt_yes` for CLI use: reads y/N from the """Default `prompt_yes` for CLI use: reads y/N from the
controlling tty via stderr prompt + tty-line read.""" controlling tty via stderr prompt + tty-line read."""
+19 -19
View File
@@ -226,20 +226,15 @@ def _addstr_safe(screen: Any, row: int, col: int, text: str, attr: int = curses.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_ANSI_COLORS = [ _ANSI_COLORS = [
"red", "green", "blue", "yellow", "magenta", "cyan", "white", "black", "red", "green", "yellow", "blue", "magenta",
"bright-red", "bright-green", "bright-blue", "bright-yellow",
"bright-magenta", "bright-cyan", "bright-white", "bright-black",
] ]
_CURSES_COLOR_MAP: dict[str, int] = { _CURSES_COLOR_MAP: dict[str, int] = {
"black": curses.COLOR_BLACK,
"red": curses.COLOR_RED, "red": curses.COLOR_RED,
"green": curses.COLOR_GREEN, "green": curses.COLOR_GREEN,
"yellow": curses.COLOR_YELLOW, "yellow": curses.COLOR_YELLOW,
"blue": curses.COLOR_BLUE, "blue": curses.COLOR_BLUE,
"magenta": curses.COLOR_MAGENTA, "magenta": curses.COLOR_MAGENTA,
"cyan": curses.COLOR_CYAN,
"white": curses.COLOR_WHITE,
} }
_COLOR_NONE = "(none)" _COLOR_NONE = "(none)"
@@ -248,11 +243,15 @@ _COLOR_NONE = "(none)"
def name_color_modal( def name_color_modal(
default_label: str, default_label: str,
*, *,
disclaimer: str = "",
tty_path: str = "/dev/tty", tty_path: str = "/dev/tty",
) -> tuple[str, str]: ) -> tuple[str, str]:
"""Present a two-step curses modal: first edit the agent label, """Present a two-step curses modal: first edit the agent label,
then optionally pick a color. then optionally pick a color.
``disclaimer`` is shown below the input field — use it to surface
an error from a previous attempt (e.g. name already in use).
Returns ``(label, color)`` where ``color`` is one of the 16 ANSI Returns ``(label, color)`` where ``color`` is one of the 16 ANSI
color name strings or ``""`` for no color. Falls back to color name strings or ``""`` for no color. Falls back to
``(default_label, "")`` on any error (terminal too small, not a tty). ``(default_label, "")`` on any error (terminal too small, not a tty).
@@ -264,14 +263,14 @@ def name_color_modal(
try: try:
fd_dup = os.dup(tty_fd.fileno()) fd_dup = os.dup(tty_fd.fileno())
return _run_name_color(default_label, tty_fd=fd_dup) return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return default_label, "" return default_label, ""
finally: finally:
tty_fd.close() tty_fd.close()
def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]: def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") -> tuple[str, str]:
import io import io
orig_stdin = sys.__stdin__ orig_stdin = sys.__stdin__
orig_stdout = sys.__stdout__ orig_stdout = sys.__stdout__
@@ -286,7 +285,7 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
curses.cbreak() curses.cbreak()
screen.keypad(True) screen.keypad(True)
try: try:
label = _label_step(screen, default_label) label = _label_step(screen, default_label, disclaimer=disclaimer)
color = _color_step(screen, label) color = _color_step(screen, label)
finally: finally:
screen.keypad(False) screen.keypad(False)
@@ -299,14 +298,14 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
return label, color return label, color
def _label_step(screen: Any, default_label: str) -> str: def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str:
"""Step 1: edit the label. First printable key replaces the """Step 1: edit the label. First printable key replaces the
pre-fill; subsequent keys append. Enter confirms.""" pre-fill; subsequent keys append. Enter confirms."""
text = default_label text = default_label
replaced = False # True once the user has typed their first char replaced = False # True once the user has typed their first char
while True: while True:
_render_label(screen, text) _render_label(screen, text, disclaimer=disclaimer)
try: try:
key = screen.getch() key = screen.getch()
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -330,7 +329,7 @@ def _label_step(screen: Any, default_label: str) -> str:
text += chr(key) text += chr(key)
def _render_label(screen: Any, text: str) -> None: def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None:
screen.erase() screen.erase()
rows, cols = screen.getmaxyx() rows, cols = screen.getmaxyx()
sep = "" * min(cols - 1, 40) sep = "" * min(cols - 1, 40)
@@ -338,8 +337,12 @@ def _render_label(screen: Any, text: str) -> None:
_addstr_safe(screen, 1, 0, sep) _addstr_safe(screen, 1, 0, sep)
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE) _addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
_addstr_safe(screen, 3, 0, sep) _addstr_safe(screen, 3, 0, sep)
if rows > 5: row = 4
_addstr_safe(screen, 5, 0, "[any key] edit [Enter] confirm", curses.A_DIM) if disclaimer and rows > row + 1:
_addstr_safe(screen, row, 0, disclaimer[:cols - 1], curses.A_BOLD)
row += 1
if rows > row + 1:
_addstr_safe(screen, row, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
screen.refresh() screen.refresh()
@@ -379,13 +382,10 @@ def _init_color_pairs() -> dict[str, int]:
curses.use_default_colors() curses.use_default_colors()
pair_idx = 2 # pair 1 reserved for other uses pair_idx = 2 # pair 1 reserved for other uses
for name in _ANSI_COLORS: for name in _ANSI_COLORS:
base = name.replace("bright-", "") fg = _CURSES_COLOR_MAP.get(name, curses.COLOR_WHITE)
fg = _CURSES_COLOR_MAP.get(base, curses.COLOR_WHITE)
try: try:
curses.init_pair(pair_idx, fg, -1) curses.init_pair(pair_idx, fg, -1)
attr = curses.color_pair(pair_idx) attr = curses.color_pair(pair_idx) | curses.A_BOLD
if name.startswith("bright-"):
attr |= curses.A_BOLD
attrs[name] = attr attrs[name] = attr
pair_idx += 1 pair_idx += 1
except curses.error: except curses.error:
+10 -32
View File
@@ -42,41 +42,19 @@ def _prompt_path(guest_home: str) -> str:
_STATUS_LINE_COLORS = { _STATUS_LINE_COLORS = {
"black": "\033[30m", "red": "\033[91m",
"red": "\033[31m", "green": "\033[92m",
"green": "\033[32m", "yellow": "\033[93m",
"yellow": "\033[33m", "blue": "\033[94m",
"blue": "\033[34m", "magenta": "\033[95m",
"magenta": "\033[35m",
"cyan": "\033[36m",
"white": "\033[37m",
"bright-black": "\033[90m",
"bright-red": "\033[91m",
"bright-green": "\033[92m",
"bright-yellow": "\033[93m",
"bright-blue": "\033[94m",
"bright-magenta": "\033[95m",
"bright-cyan": "\033[96m",
"bright-white": "\033[97m",
} }
_CLAUDE_THEME_COLORS = { _CLAUDE_THEME_COLORS = {
"black": "black", "red": "redBright",
"red": "red", "green": "greenBright",
"green": "green", "yellow": "yellowBright",
"yellow": "yellow", "blue": "blueBright",
"blue": "blue", "magenta": "magentaBright",
"magenta": "magenta",
"cyan": "cyan",
"white": "white",
"bright-black": "blackBright",
"bright-red": "redBright",
"bright-green": "greenBright",
"bright-yellow": "yellowBright",
"bright-blue": "blueBright",
"bright-magenta": "magentaBright",
"bright-cyan": "cyanBright",
"bright-white": "whiteBright",
} }
+66 -18
View File
@@ -5,16 +5,20 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import ManifestBottle, ManifestGitEntry from .manifest import ManifestBottle
from .manifest_egress import ManifestEgressConfig from .manifest_egress import ManifestEgressConfig
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]: def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
"""Apply `extends:` chains and return resolved ManifestBottle objects.""" """Apply `extends:` chains and return resolved ManifestBottle objects."""
cache: dict[str, ManifestBottle] = {} cache: dict[str, ManifestBottle] = {}
# Per-bottle effective git-gate.repos, as raw dicts keyed by repo name.
# Threaded alongside `cache` so a child can field-merge against its
# parent's repos without reconstructing them from parsed entries.
repos_cache: dict[str, dict[str, object]] = {}
for name in raws: for name in raws:
if name not in cache: if name not in cache:
_resolve_one_bottle(name, raws, cache, ()) _resolve_one_bottle(name, raws, cache, repos_cache, ())
return cache return cache
@@ -22,6 +26,7 @@ def _resolve_one_bottle(
name: str, name: str,
raws: dict[str, dict[str, object]], raws: dict[str, dict[str, object]],
cache: dict[str, ManifestBottle], cache: dict[str, ManifestBottle],
repos_cache: dict[str, dict[str, object]],
seen: tuple[str, ...], seen: tuple[str, ...],
) -> ManifestBottle: ) -> ManifestBottle:
from .manifest import ManifestBottle, ManifestError from .manifest import ManifestBottle, ManifestError
@@ -41,6 +46,7 @@ def _resolve_one_bottle(
if parent_name_raw is None: if parent_name_raw is None:
bottle = ManifestBottle.from_dict(name, child_raw) bottle = ManifestBottle.from_dict(name, child_raw)
cache[name] = bottle cache[name] = bottle
repos_cache[name] = _resolve_repos_raw({}, child_raw)
return bottle return bottle
if not isinstance(parent_name_raw, str): if not isinstance(parent_name_raw, str):
@@ -60,20 +66,33 @@ def _resolve_one_bottle(
f"bottle '{name}' extends '{parent_name}' which is not " f"bottle '{name}' extends '{parent_name}' which is not "
f"defined. Available bottles: {avail}" f"defined. Available bottles: {avail}"
) )
parent = _resolve_one_bottle(parent_name, raws, cache, seen + (name,)) parent = _resolve_one_bottle(
bottle = _merge_bottles(parent, child_raw, name) parent_name, 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)
cache[name] = bottle cache[name] = bottle
repos_cache[name] = merged_repos_raw
return bottle return bottle
def _merge_bottles( def _merge_bottles(
parent: ManifestBottle, parent: ManifestBottle,
child_raw: dict[str, object], child_raw: dict[str, object],
merged_repos_raw: dict[str, object],
name: str, name: str,
) -> ManifestBottle: ) -> ManifestBottle:
"""Apply PRD 0025 merge rules.""" """Apply PRD 0025 merge rules."""
from .manifest import ManifestBottle, ManifestGitUser from .manifest import ManifestBottle, ManifestGitUser
from .manifest_egress import validate_egress_routes from .manifest_egress import validate_egress_routes
from .manifest_util import as_json_object
# git-gate.repos: when the child declares repos, inject the already
# name-merged repo set (computed by _resolve_repos_raw) so the child
# parses with the full inherited+overridden list (issue #237).
if _child_declares_git_gate_repos(child_raw):
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
child_raw = {**child_raw, "git-gate": {**git_raw, "repos": merged_repos_raw}}
# Parse the child's declared fields into a ManifestBottle (with the # Parse the child's declared fields into a ManifestBottle (with the
# usual defaults for anything missing). Validation runs the same # usual defaults for anything missing). Validation runs the same
@@ -92,11 +111,11 @@ def _merge_bottles(
email=child.git_user.email or parent.git_user.email, email=child.git_user.email or parent.git_user.email,
) )
# git-gate.repos: missing means inherit; an explicit empty object # git-gate.repos: when declared, child.git already holds the merged
# clears; otherwise parent and child merge by UpstreamHost with # set (an explicit empty dict clears parent, leaving child.git empty).
# child entries replacing duplicate hosts. # When omitted, the parent's entries are inherited verbatim.
if _child_declares_git_gate_repos(child_raw): if _child_declares_git_gate_repos(child_raw):
merged_git = _merge_git_remotes(parent.git, child.git) if child.git else () merged_git = child.git
else: else:
merged_git = parent.git merged_git = parent.git
@@ -130,6 +149,45 @@ def _merge_bottles(
) )
def _resolve_repos_raw(
parent_repos: dict[str, object],
child_raw: dict[str, object],
) -> dict[str, object]:
"""Compute a bottle's effective git-gate.repos as raw dicts.
Repos are keyed by name. When the child omits git-gate.repos it
inherits the parent's set verbatim; an explicit empty dict clears it.
Otherwise parent and child unite by name, with same-name entries
field-merged (parent fields are defaults, child fields win)."""
from .manifest_util import as_json_object
if not _child_declares_git_gate_repos(child_raw):
return parent_repos
child_repos = _declared_repos_raw(child_raw)
if not child_repos:
return {}
# Parent entries keep their order; child-only names are appended.
names = list(parent_repos) + [n for n in child_repos if n not in parent_repos]
return {
name: {
**as_json_object(parent_repos.get(name, {}), "parent git-gate repo"),
**as_json_object(child_repos.get(name, {}), "child git-gate repo"),
}
for name in names
}
def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
"""Return the child's explicitly declared git-gate.repos as raw dicts,
or an empty dict when none are declared."""
from .manifest_util import as_json_object
if not _child_declares_git_gate_repos(child_raw):
return {}
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
return as_json_object(git_raw.get("repos", {}), "child git-gate.repos")
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool: def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
from .manifest_util import as_json_object from .manifest_util import as_json_object
@@ -140,16 +198,6 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
return "repos" in git_obj return "repos" in git_obj
def _merge_git_remotes(
parent: tuple[ManifestGitEntry, ...],
child: tuple[ManifestGitEntry, ...],
) -> tuple[ManifestGitEntry, ...]:
by_host = {entry.UpstreamHost: entry for entry in parent}
for entry in child:
by_host[entry.UpstreamHost] = entry
return tuple(by_host.values())
def _merge_egress( def _merge_egress(
parent: ManifestEgressConfig, parent: ManifestEgressConfig,
child: ManifestEgressConfig, child: ManifestEgressConfig,
+3 -4
View File
@@ -92,10 +92,9 @@ class TestSandboxEscape(unittest.TestCase):
"on PATH: curl -sSL https://smolmachines.com/install.sh | sh" "on PATH: curl -sSL https://smolmachines.com/install.sh | sh"
) )
# Throwaway "identity file" so the manifest's _validate_git_entries # Throwaway "identity file" for the git-gate's `identity` field.
# passes (it only checks `os.path.isfile`, not that the content is # It need not be a real SSH key: test 5 reaches gitleaks before
# a real SSH key). Test 5 reaches gitleaks before any SSH attempt # any SSH attempt anyway.
# anyway.
fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.") fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.")
os.close(fd) os.close(fd)
cls._key_path = Path(kp) cls._key_path = Path(kp)
+1 -1
View File
@@ -74,7 +74,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
instance_name="bot-bottle-test", instance_name="bot-bottle-test",
prompt_file=prompt_file, prompt_file=prompt_file,
label="review-api", label="review-api",
color="bright-cyan", color="cyan",
) )
prompt = prompt_file.read_text() prompt = prompt_file.read_text()
config = Path(tmp, "codex-config.toml").read_text() config = Path(tmp, "codex-config.toml").read_text()
+32
View File
@@ -16,6 +16,7 @@ from bot_bottle import bottle_state
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.backend import BottleSpec from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.resolve_common import mint_slug
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
@@ -115,5 +116,36 @@ class TestSmolmachinesPrepare(_FakeStateMixin, unittest.TestCase):
) )
class TestMintSlug(unittest.TestCase):
def _spec(self, *, label: str = "", identity: str = "") -> BottleSpec:
manifest = _manifest()
return BottleSpec(
manifest=manifest,
agent_name="demo",
copy_cwd=False,
user_cwd="/tmp",
label=label,
identity=identity,
)
def test_no_label_uses_agent_name_with_random_suffix(self) -> None:
slug = mint_slug(self._spec(label=""))
self.assertTrue(slug.startswith("demo-"), slug)
# random suffix present — slug is longer than just "demo"
self.assertGreater(len(slug), len("demo-"))
def test_label_becomes_exact_slug(self) -> None:
slug = mint_slug(self._spec(label="my-run"))
self.assertEqual("my-run", slug)
def test_label_with_spaces_slugified_no_suffix(self) -> None:
slug = mint_slug(self._spec(label="My Feature Run"))
self.assertEqual("my-feature-run", slug)
def test_identity_takes_precedence_over_label(self) -> None:
slug = mint_slug(self._spec(label="my-run", identity="fixed-id"))
self.assertEqual("fixed-id", slug)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+8 -11
View File
@@ -11,14 +11,14 @@ class TestPalettePrintf(unittest.TestCase):
def test_known_color_returns_printf(self): def test_known_color_returns_printf(self):
cmd = palette_printf("red") cmd = palette_printf("red")
self.assertTrue(cmd.startswith("printf '")) self.assertTrue(cmd.startswith("printf '"))
self.assertIn("\\033]4;1;", cmd) # normal red self.assertIn("\\033]4;9;", cmd) # bright-red slot
self.assertIn("\\033]4;9;", cmd) # bright red self.assertIn("\\033]4;1;", cmd) # normal-red slot
self.assertIn("\\033]11;", cmd) # default background tint self.assertIn("\\033]11;", cmd) # default background tint
def test_bright_variant_sets_both_slots(self): def test_color_sets_both_palette_slots(self):
cmd = palette_printf("bright-blue") cmd = palette_printf("blue")
self.assertIn("\\033]4;12;", cmd) # bright-blue self.assertIn("\\033]4;12;", cmd) # bright-blue slot
self.assertIn("\\033]4;4;", cmd) # blue self.assertIn("\\033]4;4;", cmd) # normal-blue slot
def test_unknown_color_returns_empty(self): def test_unknown_color_returns_empty(self):
self.assertEqual("", palette_printf("")) self.assertEqual("", palette_printf(""))
@@ -26,10 +26,7 @@ class TestPalettePrintf(unittest.TestCase):
def test_all_named_colors_produce_output(self): def test_all_named_colors_produce_output(self):
colors = [ colors = [
"black", "red", "green", "yellow", "red", "green", "yellow", "blue", "magenta",
"blue", "magenta", "cyan", "white",
"bright-black", "bright-red", "bright-green", "bright-yellow",
"bright-blue", "bright-magenta", "bright-cyan", "bright-white",
] ]
for color in colors: for color in colors:
with self.subTest(color=color): with self.subTest(color=color):
@@ -65,7 +62,7 @@ class TestExecShellScript(unittest.TestCase):
self.assertFalse(agent_part.startswith("exec ")) self.assertFalse(agent_part.startswith("exec "))
def test_title_and_color_both_appear(self): def test_title_and_color_both_appear(self):
script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="cyan") script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="magenta")
assert script is not None assert script is not None
self.assertIn("bot", script) self.assertIn("bot", script)
self.assertIn("\\033]4;", script) self.assertIn("\\033]4;", script)
+59
View File
@@ -14,6 +14,7 @@ from unittest.mock import MagicMock, patch
import bot_bottle.cli.start as start_mod import bot_bottle.cli.start as start_mod
import bot_bottle.cli.tui as tui_mod import bot_bottle.cli.tui as tui_mod
from bot_bottle.backend import ActiveAgent
def _make_manifest(agent_names: list[str]): def _make_manifest(agent_names: list[str]):
@@ -133,5 +134,63 @@ class TestCmdStartSelector(unittest.TestCase):
self._launch_mock.assert_not_called() self._launch_mock.assert_not_called()
def _active_agent(slug: str) -> ActiveAgent:
return ActiveAgent(
backend_name="docker",
slug=slug,
agent_name="demo",
started_at="2026-01-01T00:00:00+00:00",
services=(),
)
class TestCmdStartLabelCollision(unittest.TestCase):
"""cmd_start re-prompts when the label's slug is already running."""
def setUp(self):
self._manifest = _make_manifest(["researcher"])
patch("bot_bottle.cli.start.Manifest.resolve", return_value=self._manifest).start()
self._launch_mock = patch(
"bot_bottle.cli.start._launch_bottle", return_value=0,
).start()
self.addCleanup(patch.stopall)
def test_no_collision_proceeds_without_reprompt(self):
with (
patch.object(tui_mod, "name_color_modal", return_value=("researcher", "")) as modal,
patch("bot_bottle.cli.start.enumerate_active_agents", return_value=[]),
):
rc = start_mod.cmd_start(["researcher"])
self.assertEqual(0, rc)
modal.assert_called_once()
self._launch_mock.assert_called_once()
def test_collision_reprompts_with_disclaimer(self):
collision_agent = _active_agent("researcher")
call_count = 0
def _modal(default_label: str, *, disclaimer: str = "", **_kw: object) -> tuple[str, str]:
nonlocal call_count
call_count += 1
if call_count == 1:
return "researcher", ""
return "researcher-2", ""
with (
patch.object(tui_mod, "name_color_modal", side_effect=_modal) as modal,
patch(
"bot_bottle.cli.start.enumerate_active_agents",
side_effect=[[collision_agent], []],
),
):
rc = start_mod.cmd_start(["researcher"])
self.assertEqual(0, rc)
self.assertEqual(2, modal.call_count)
second_call_kwargs = modal.call_args_list[1][1]
self.assertIn("researcher", second_call_kwargs.get("disclaimer", ""))
self.assertIn("already in use", second_call_kwargs.get("disclaimer", ""))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+3 -3
View File
@@ -276,7 +276,7 @@ class TestClaudeUiProvision(unittest.TestCase):
instance_name="bot-bottle-demo-abc12", instance_name="bot-bottle-demo-abc12",
prompt_file=prompt_file, prompt_file=prompt_file,
label="research-ui", label="research-ui",
color="bright-cyan", color="blue",
) )
settings = json.loads((state_dir / "claude-settings.json").read_text()) settings = json.loads((state_dir / "claude-settings.json").read_text())
statusline = (state_dir / "claude-statusline.sh").read_text() statusline = (state_dir / "claude-statusline.sh").read_text()
@@ -288,9 +288,9 @@ class TestClaudeUiProvision(unittest.TestCase):
self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"]) self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"])
self.assertEqual("custom:bot-bottle-research-ui", settings["theme"]) self.assertEqual("custom:bot-bottle-research-ui", settings["theme"])
self.assertIn("research-ui", statusline) self.assertIn("research-ui", statusline)
self.assertIn("\x1b[96m", statusline) self.assertIn("\x1b[94m", statusline)
self.assertEqual("dark", theme["base"]) self.assertEqual("dark", theme["base"])
self.assertEqual("ansi:cyanBright", theme["overrides"]["claude"]) self.assertEqual("ansi:blueBright", theme["overrides"]["claude"])
def test_runs_verify_commands(self): def test_runs_verify_commands(self):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
+1 -1
View File
@@ -158,7 +158,7 @@ class TestCodexProvisionPrompt(unittest.TestCase):
instance_name="bot-bottle-demo-abc12", instance_name="bot-bottle-demo-abc12",
prompt_file=prompt_file, prompt_file=prompt_file,
label="research-ui", label="research-ui",
color="bright-cyan", color="cyan",
) )
config = (state_dir / "codex-config.toml").read_text() config = (state_dir / "codex-config.toml").read_text()
prompt_text = prompt_file.read_text() prompt_text = prompt_file.read_text()
+81 -8
View File
@@ -113,8 +113,8 @@ class TestExtendsEnvMerge(unittest.TestCase):
class TestExtendsGitMerge(unittest.TestCase): class TestExtendsGitMerge(unittest.TestCase):
"""git-gate.user overlays by field; git-gate.repos merges by upstream """git-gate.user overlays by field; git-gate.repos merges by name,
host, with child entries replacing duplicate hosts.""" with same-name child entries merging field-by-field (child wins)."""
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}} _GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}}
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}} _GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}}
@@ -130,19 +130,21 @@ class TestExtendsGitMerge(unittest.TestCase):
names = [e.Name for e in m.bottles["child"].git] names = [e.Name for e in m.bottles["child"].git]
self.assertEqual(["a", "b"], names) self.assertEqual(["a", "b"], names)
def test_child_git_repo_replaces_same_host(self): def test_child_git_repo_different_name_same_host_coexists(self):
replacement = {"url": "ssh://git@host-a/replacement.git", "key": {"provider": "static", "path": "/dev/null"}} # Repos are keyed by Name, not UpstreamHost: two repos with
# different names on the same host both survive the merge.
same_host_b = {"url": "ssh://git@host-a/b.git", "key": {"provider": "static", "path": "/dev/null"}}
m = _build( m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={ child={
"extends": "base", "extends": "base",
"git-gate": {"repos": {"a2": replacement}}, "git-gate": {"repos": {"a2": same_host_b}},
}, },
) )
entries = m.bottles["child"].git entries = m.bottles["child"].git
self.assertEqual(1, len(entries)) self.assertEqual(2, len(entries))
self.assertEqual("a2", entries[0].Name) names = {e.Name for e in entries}
self.assertEqual("replacement.git", entries[0].UpstreamPath) self.assertEqual({"a", "a2"}, names)
def test_child_omits_git_gate_inherits_full_list(self): def test_child_omits_git_gate_inherits_full_list(self):
m = _build( m = _build(
@@ -164,6 +166,77 @@ class TestExtendsGitMerge(unittest.TestCase):
) )
self.assertEqual((), m.bottles["child"].git) self.assertEqual((), m.bottles["child"].git)
def test_child_same_name_repo_merges_key_field(self):
# Issue #237: child repo with same name as parent should merge
# field-by-field. Child overrides only `key`; parent's url and
# host_key are preserved.
parent_entry = {
"url": "ssh://git@host-a/repo.git",
"host_key": "ecdsa-sha2-nistp256 AAAA",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {"repo": {
"key": {"provider": "gitea", "forge_token_env": "GITEA_TOKEN"},
}}},
},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
e = entries[0]
self.assertEqual("repo", e.Name)
self.assertEqual("ssh://git@host-a/repo.git", e.Upstream)
self.assertEqual("ecdsa-sha2-nistp256 AAAA", e.KnownHostKey)
self.assertEqual("gitea", e.Key.provider)
self.assertEqual("GITEA_TOKEN", e.Key.forge_token_env)
def test_child_same_name_repo_overrides_url(self):
# Child can override url on a same-name repo; other parent fields
# fall through.
parent_entry = {
"url": "ssh://git@host-a/old.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {"repo": {
"url": "ssh://git@host-b/new.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}}},
},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
self.assertEqual("ssh://git@host-b/new.git", entries[0].Upstream)
def test_child_same_name_plus_new_repo(self):
# Same-name repo is field-merged; a distinct new name in child
# is appended.
parent_entry = {
"url": "ssh://git@host-a/repo.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {
"repo": {"key": {"provider": "gitea", "forge_token_env": "TOK"}},
"other": self._GIT_ENTRY_B,
}},
},
)
child = m.bottles["child"]
names = {e.Name for e in child.git}
self.assertEqual({"repo", "other"}, names)
repo_entry = next(e for e in child.git if e.Name == "repo")
self.assertEqual("gitea", repo_entry.Key.provider)
def test_child_git_user_inherits_parent_repos(self): def test_child_git_user_inherits_parent_repos(self):
m = _build( m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},