refactor(manifest): key git config by host
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 42s

This commit is contained in:
2026-05-28 00:49:34 -04:00
parent 85104742ca
commit 59ee32cc8d
17 changed files with 356 additions and 159 deletions
+15 -17
View File
@@ -256,11 +256,13 @@ field (PRD 0025). The parent's resolved config is the base; the
child's declared fields overlay. Merge rules: child's declared fields overlay. Merge rules:
- `env:` — dict merge, child wins on key collision. - `env:` — dict merge, child wins on key collision.
- `git:`, `egress:`, `supervise:` — full replace when the child - `git.user:` — per-field overlay (child's non-empty `name` /
declares the field. An explicit `git: []` clears the parent's
list; omitting the field inherits the parent's verbatim.
- `git_user:` — per-field overlay (child's non-empty `name` /
`email` wins; empty falls through to parent). `email` wins; empty falls through to parent).
- `git.remotes:` — dict merge by host, child wins on host collision.
An explicit `git.remotes: {}` clears the parent's remotes; omitting
`git.remotes` inherits the parent's remotes.
- `egress:`, `supervise:` — full replace when the child declares the
field.
```yaml ```yaml
--- ---
@@ -286,19 +288,15 @@ env:
GIT_AUTHOR_NAME: didericis GIT_AUTHOR_NAME: didericis
git: git:
- Name: claude-bottle user:
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git name: "Eric Bauerfeld"
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea email: "eric+claude@dideric.is"
KnownHostKey: ssh-ed25519 AAAA... remotes:
gitea.dideric.is:
# Optional per-bottle git identity. When set, `git config --global Name: claude-bottle
# user.name` / `user.email` are applied inside the bottle at Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
# provisioning so the agent's commits land with this attribution IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
# instead of git refusing to commit. Either field can be set KnownHostKey: ssh-ed25519 AAAA...
# independently. Issue #86.
git_user:
name: "Eric Bauerfeld"
email: "eric+claude@dideric.is"
# Routes declared here are held by a per-bottle cred-proxy sidecar, # Routes declared here are held by a per-bottle cred-proxy sidecar,
# not the agent. Each route names a path the agent dials, the # not the agent. Each route names a path the agent dials, the
@@ -11,7 +11,7 @@ Three concerns, all about git in the agent:
ls-remote) transparently hits the per-agent git-gate. The ls-remote) transparently hits the per-agent git-gate. The
gate mirrors the upstream in both directions, so URL gate mirrors the upstream in both directions, so URL
rewriting is symmetric. rewriting is symmetric.
3. If the bottle declares `git_user` (issue #86), set 3. If the bottle declares `git.user` (issue #86), set
`git config --global user.{name,email}` inside the bottle so `git config --global user.{name,email}` inside the bottle so
the agent's commits are attributed to that identity. the agent's commits are attributed to that identity.
""" """
@@ -94,7 +94,7 @@ def _provision_git_user(plan: DockerBottlePlan, target: str) -> None:
Runs as the `node` user so `--global` lands in Runs as the `node` user so `--global` lands in
`/home/node/.gitconfig` (matching the existing `/home/node/.gitconfig` (matching the existing
`_provision_git_gate_config` write location). No-op when the `_provision_git_gate_config` write location). No-op when the
bottle didn't declare `git_user`. bottle didn't declare `git.user`.
Each field set independently — name-only or email-only Each field set independently — name-only or email-only
configs only run the `git config` line for the field configs only run the `git config` line for the field
@@ -11,7 +11,7 @@ Three concerns, all about git in the agent:
against a declared upstream transparently hits the per-bottle against a declared upstream transparently hits the per-bottle
git-gate. The gate mirrors the upstream in both directions, git-gate. The gate mirrors the upstream in both directions,
so URL rewriting is symmetric. so URL rewriting is symmetric.
3. If the bottle declares `git_user` (issue #86), set 3. If the bottle declares `git.user` (issue #86), set
`git config --global user.{name,email}` inside the guest so `git config --global user.{name,email}` inside the guest so
the agent's commits are attributed to that identity. the agent's commits are attributed to that identity.
@@ -113,7 +113,7 @@ def _provision_git_user(
"""Apply `git config --global user.{name,email}` inside the """Apply `git config --global user.{name,email}` inside the
guest as the node user so --global lands in the same guest as the node user so --global lands in the same
`/home/node/.gitconfig` that `_provision_git_gate_config` `/home/node/.gitconfig` that `_provision_git_gate_config`
writes to. No-op when the bottle didn't declare `git_user`. writes to. No-op when the bottle didn't declare `git.user`.
Runs via `runuser -u node --`; HOME is forced via smolvm's Runs via `runuser -u node --`; HOME is forced via smolvm's
`-e` flag because runuser (without -l) inherits root's `-e` flag because runuser (without -l) inherits root's
+127 -41
View File
@@ -14,8 +14,9 @@ the system prompt, for bottles the body is human documentation
Bottle schema (frontmatter): Bottle schema (frontmatter):
extends: <bottle-name> # optional (PRD 0025) extends: <bottle-name> # optional (PRD 0025)
env: { <NAME>: <env-entry>, ... } env: { <NAME>: <env-entry>, ... }
git: [ <git-entry>, ... ] git:
git_user: { name: <str>, email: <str> } # optional user: { name: <str>, email: <str> } # optional
remotes: { <host>: <git-entry>, ... } # optional
egress: { routes: [ <egress-route>, ... ] } egress: { routes: [ <egress-route>, ... ] }
supervise: <bool> # optional supervise: <bool> # optional
@@ -88,31 +89,61 @@ class GitEntry:
@classmethod @classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "GitEntry": def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "GitEntry":
d = _as_json_object(raw, f"bottle '{bottle_name}' git[{idx}]") d = _as_json_object(raw, f"bottle '{bottle_name}' git[{idx}]")
return cls._from_object(bottle_name, d, f"git[{idx}]", None)
@classmethod
def from_remote_dict(
cls, bottle_name: str, host_key: str, raw: object
) -> "GitEntry":
if not host_key:
die(f"bottle '{bottle_name}' git.remotes has an empty host key")
d = _as_json_object(raw, f"bottle '{bottle_name}' git.remotes[{host_key!r}]")
return cls._from_object(
bottle_name, d, f"git.remotes[{host_key!r}]", host_key,
)
@classmethod
def _from_object(
cls,
bottle_name: str,
d: dict[str, object],
label: str,
host_key: str | None,
) -> "GitEntry":
name = d.get("Name") name = d.get("Name")
if not isinstance(name, str) or not name: if not isinstance(name, str) or not name:
die(f"bottle '{bottle_name}' git[{idx}] missing required string field 'Name'") die(
f"bottle '{bottle_name}' {label} missing required string "
f"field 'Name'"
)
upstream = d.get("Upstream") upstream = d.get("Upstream")
if not isinstance(upstream, str) or not upstream: if not isinstance(upstream, str) or not upstream:
die( die(
f"bottle '{bottle_name}' git '{name}' missing required string field " f"bottle '{bottle_name}' {label} '{name}' missing required string field "
f"'Upstream'" f"'Upstream'"
) )
ident = d.get("IdentityFile") ident = d.get("IdentityFile")
if not isinstance(ident, str) or not ident: if not isinstance(ident, str) or not ident:
die( die(
f"bottle '{bottle_name}' git '{name}' missing required string field " f"bottle '{bottle_name}' {label} '{name}' missing required string field "
f"'IdentityFile'" f"'IdentityFile'"
) )
khk = _opt_str( khk = _opt_str(
d.get("KnownHostKey"), d.get("KnownHostKey"),
f"bottle '{bottle_name}' git '{name}' KnownHostKey", f"bottle '{bottle_name}' {label} '{name}' KnownHostKey",
) )
extra_hosts = _opt_extra_hosts( extra_hosts = _opt_extra_hosts(
d.get("ExtraHosts"), f"bottle '{bottle_name}' git '{name}' ExtraHosts" d.get("ExtraHosts"),
f"bottle '{bottle_name}' {label} '{name}' ExtraHosts",
) )
user, host, port, path = _parse_git_upstream( user, host, port, path = _parse_git_upstream(
upstream, f"bottle '{bottle_name}' git '{name}' Upstream" upstream, f"bottle '{bottle_name}' {label} '{name}' Upstream"
) )
if host_key is not None and host_key != host:
die(
f"bottle '{bottle_name}' git.remotes key {host_key!r} "
f"does not match Upstream host {host!r}"
)
return cls( return cls(
Name=name, Name=name,
Upstream=upstream, Upstream=upstream,
@@ -177,28 +208,28 @@ class GitUser:
@classmethod @classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "GitUser": def from_dict(cls, bottle_name: str, raw: object) -> "GitUser":
d = _as_json_object(raw, f"bottle '{bottle_name}' git_user") d = _as_json_object(raw, f"bottle '{bottle_name}' git.user")
for k in d.keys(): for k in d.keys():
if k not in {"name", "email"}: if k not in {"name", "email"}:
die( die(
f"bottle '{bottle_name}' git_user has unknown key {k!r}; " f"bottle '{bottle_name}' git.user has unknown key {k!r}; "
f"allowed: name, email" f"allowed: name, email"
) )
name = d.get("name", "") name = d.get("name", "")
email = d.get("email", "") email = d.get("email", "")
if not isinstance(name, str): if not isinstance(name, str):
die( die(
f"bottle '{bottle_name}' git_user.name must be a string " f"bottle '{bottle_name}' git.user.name must be a string "
f"(was {type(name).__name__})" f"(was {type(name).__name__})"
) )
if not isinstance(email, str): if not isinstance(email, str):
die( die(
f"bottle '{bottle_name}' git_user.email must be a string " f"bottle '{bottle_name}' git.user.email must be a string "
f"(was {type(email).__name__})" f"(was {type(email).__name__})"
) )
if not name and not email: if not name and not email:
die( die(
f"bottle '{bottle_name}' git_user is set but neither " f"bottle '{bottle_name}' git.user is set but neither "
f"name nor email is non-empty; remove the block or " f"name nor email is non-empty; remove the block or "
f"fill at least one field." f"fill at least one field."
) )
@@ -208,6 +239,37 @@ class GitUser:
return not self.name and not self.email return not self.name and not self.email
def _parse_git_config(
bottle_name: str,
raw: object,
) -> tuple[tuple[GitEntry, ...], GitUser]:
d = _as_json_object(raw, f"bottle '{bottle_name}' git")
for k in d.keys():
if k not in {"user", "remotes"}:
die(
f"bottle '{bottle_name}' git has unknown key {k!r}; "
f"allowed: user, remotes"
)
git_user = (
GitUser.from_dict(bottle_name, d["user"])
if "user" in d
else GitUser()
)
git: tuple[GitEntry, ...] = ()
remotes_raw = d.get("remotes")
if remotes_raw is not None:
remotes = _as_json_object(remotes_raw, f"bottle '{bottle_name}' git.remotes")
git = tuple(
GitEntry.from_remote_dict(bottle_name, host, entry)
for host, entry in remotes.items()
)
_validate_unique_git_names(bottle_name, git)
return git, git_user
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressRoute: class EgressRoute:
"""One route on the per-bottle egress sidecar (PRD 0017). """One route on the per-bottle egress sidecar (PRD 0017).
@@ -396,9 +458,9 @@ class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict) env: Mapping[str, str] = field(default_factory=_empty_str_dict)
git: tuple[GitEntry, ...] = () git: tuple[GitEntry, ...] = ()
# Per-bottle git identity (issue #86). Empty default — bottles # Per-bottle git identity (issue #86). Empty default — bottles
# that don't set `git_user:` in the manifest skip the # that don't set `git.user:` in the manifest skip the
# `git config --global` step entirely. Set independently of # `git config --global` step entirely. Set independently of
# the `git:` upstream list above: a bottle can declare a user # the `git.remotes:` upstream map above: a bottle can declare a user
# identity without any git-gate upstreams, and vice versa. # identity without any git-gate upstreams, and vice versa.
git_user: GitUser = field(default_factory=GitUser) git_user: GitUser = field(default_factory=GitUser)
egress: EgressConfig = field(default_factory=EgressConfig) egress: EgressConfig = field(default_factory=EgressConfig)
@@ -432,6 +494,20 @@ class Bottle:
f"credential and gitleaks-scan pushes." f"credential and gitleaks-scan pushes."
) )
if "git_user" in d:
die(
f"bottle '{name}' has a 'git_user' field, which has been "
f"removed. Move it under 'git.user'."
)
unknown = set(d.keys()) - _BOTTLE_KEYS
if unknown:
allowed = ", ".join(sorted(_BOTTLE_KEYS))
die(
f"bottle '{name}' has unknown key(s) {sorted(unknown)}; "
f"allowed keys are {allowed}."
)
env: dict[str, str] = {} env: dict[str, str] = {}
env_raw = d.get("env") env_raw = d.get("env")
if env_raw is not None: if env_raw is not None:
@@ -445,16 +521,10 @@ class Bottle:
env[var] = value env[var] = value
git: tuple[GitEntry, ...] = () git: tuple[GitEntry, ...] = ()
git_user = GitUser()
git_raw = d.get("git") git_raw = d.get("git")
if git_raw is not None: if git_raw is not None:
if not isinstance(git_raw, list): git, git_user = _parse_git_config(name, git_raw)
die(f"bottle '{name}' git must be an array (was {type(git_raw).__name__})")
git_list = cast(list[object], git_raw)
git = tuple(
GitEntry.from_dict(name, i, entry)
for i, entry in enumerate(git_list)
)
_validate_unique_git_names(name, git)
if "tokens" in d: if "tokens" in d:
die( die(
@@ -479,12 +549,6 @@ class Bottle:
f"See docs/prds/0017-egress-via-mitmproxy.md." f"See docs/prds/0017-egress-via-mitmproxy.md."
) )
git_user = (
GitUser.from_dict(name, d["git_user"])
if "git_user" in d
else GitUser()
)
egress = ( egress = (
EgressConfig.from_dict(name, d["egress"]) EgressConfig.from_dict(name, d["egress"])
if "egress" in d if "egress" in d
@@ -840,7 +904,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
# sets dies with a "did you mean" pointer — typos shouldn't silently # sets dies with a "did you mean" pointer — typos shouldn't silently
# ghost into an empty config. # ghost into an empty config.
_BOTTLE_KEYS = frozenset( _BOTTLE_KEYS = frozenset(
{"env", "extends", "git", "git_user", "egress", "supervise"} {"env", "extends", "git", "egress", "supervise"}
) )
_AGENT_KEYS_REQUIRED = frozenset({"bottle"}) _AGENT_KEYS_REQUIRED = frozenset({"bottle"})
_AGENT_KEYS_OPTIONAL = frozenset({"skills"}) _AGENT_KEYS_OPTIONAL = frozenset({"skills"})
@@ -984,10 +1048,9 @@ def _merge_bottles(
name: str, name: str,
) -> Bottle: ) -> Bottle:
"""Apply PRD 0025 merge rules: parent is base; child's declared """Apply PRD 0025 merge rules: parent is base; child's declared
fields overlay. List-valued fields full-replace when the child fields overlay. env merges dict-style with child-wins on key
declared them (presence-driven on the raw dict, so an explicit collision; git.user overlays per-field; git.remotes merges by
`git: []` clears the parent's list); env merges dict-style upstream host with child entries replacing duplicate hosts."""
with child-wins on key collision; git_user overlays per-field."""
# Parse the child's declared fields into a Bottle (with the # Parse the child's declared fields into a Bottle (with the
# usual defaults for anything missing). Validation runs the same # usual defaults for anything missing). Validation runs the same
# way it would for a leaf bottle — typos / wrong types die here. # way it would for a leaf bottle — typos / wrong types die here.
@@ -996,20 +1059,25 @@ def _merge_bottles(
# env: dict merge, child wins on collision. # env: dict merge, child wins on collision.
merged_env = {**parent.env, **child.env} merged_env = {**parent.env, **child.env}
# git_user: per-field overlay. Each non-empty field on child # git.user: per-field overlay. Each non-empty field on child
# wins; empties fall through to parent. The default GitUser() # wins; empties fall through to parent. The default GitUser()
# is two empty strings, so a child that omits the block # is two empty strings, so a child that omits git.user
# inherits the parent's user verbatim. # inherits the parent's user verbatim.
merged_git_user = GitUser( merged_git_user = GitUser(
name=child.git_user.name or parent.git_user.name, name=child.git_user.name or parent.git_user.name,
email=child.git_user.email or parent.git_user.email, email=child.git_user.email or parent.git_user.email,
) )
# Presence-driven full-replace for the list-valued + scalar # git.remotes: missing means inherit; an explicit empty object
# fields. "Did the child's raw dict have this key?" is the # clears; otherwise parent and child merge by UpstreamHost with
# source of truth — an explicit `git: []` means "drop the # child entries replacing duplicate hosts.
# parent's git list", whereas a missing `git:` means "inherit". if _child_declares_git_remotes(child_raw):
merged_git = child.git if "git" in child_raw else parent.git merged_git = _merge_git_remotes(parent.git, child.git) if child.git else ()
else:
merged_git = parent.git
# Presence-driven full-replace for the remaining list-valued +
# scalar fields.
merged_egress = child.egress if "egress" in child_raw else parent.egress merged_egress = child.egress if "egress" in child_raw else parent.egress
merged_supervise = ( merged_supervise = (
child.supervise if "supervise" in child_raw else parent.supervise child.supervise if "supervise" in child_raw else parent.supervise
@@ -1024,6 +1092,24 @@ def _merge_bottles(
) )
def _child_declares_git_remotes(child_raw: dict[str, object]) -> bool:
git_raw = child_raw.get("git")
if git_raw is None:
return False
git_obj = _as_json_object(git_raw, "child git")
return "remotes" in git_obj
def _merge_git_remotes(
parent: tuple[GitEntry, ...],
child: tuple[GitEntry, ...],
) -> tuple[GitEntry, ...]:
by_host = {entry.UpstreamHost: entry for entry in parent}
for entry in child:
by_host[entry.UpstreamHost] = entry
return tuple(by_host.values())
def _load_agents_from_dir( def _load_agents_from_dir(
agents_dir: Path, agents_dir: Path,
bottle_names: set[str], bottle_names: set[str],
+8 -6
View File
@@ -269,12 +269,14 @@ cred_proxy:
token_ref: GITEA_TOKEN token_ref: GITEA_TOKEN
role: [git-insteadof, tea-login] role: [git-insteadof, tea-login]
git: git:
- Name: claude-bottle remotes:
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git gitea.dideric.is:
IdentityFile: ~/.ssh/gitea-delos-2.pem Name: claude-bottle
ExtraHosts: Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
gitea.dideric.is: 100.78.141.42 IdentityFile: ~/.ssh/gitea-delos-2.pem
KnownHostKey: ssh-rsa AAAAB3... ExtraHosts:
gitea.dideric.is: 100.78.141.42
KnownHostKey: ssh-rsa AAAAB3...
egress: egress:
allowlist: allowlist:
- example.com - example.com
@@ -191,9 +191,11 @@ egress:
- host: api.anthropic.com - host: api.anthropic.com
git: git:
- Name: throwaway remotes:
Upstream: ssh://git@127.0.0.1:22/throwaway.git 127.0.0.1:
IdentityFile: ~/.ssh/cb-test-key # fixture key Name: throwaway
Upstream: ssh://git@127.0.0.1:22/throwaway.git
IdentityFile: ~/.ssh/cb-test-key # fixture key
--- ---
``` ```
+13 -13
View File
@@ -105,23 +105,17 @@ overlay it. For each field on `Bottle`:
| Field | Type | Merge | | Field | Type | Merge |
|--------------|-----------------------|---------------------------------------------| |--------------|-----------------------|---------------------------------------------|
| `env` | `Mapping[str, str]` | dict merge, child wins on key collision | | `env` | `Mapping[str, str]` | dict merge, child wins on key collision |
| `git` | `tuple[GitEntry,…]` | full replace if child declares `git:` | | `git.user` | `GitUser` | child overlay: child's non-empty fields win |
| `git_user` | `GitUser` | child overlay: child's non-empty fields win | | `git.remotes`| `tuple[GitEntry,…]` | dict merge by host, child wins |
| `egress` | `EgressConfig` | full replace if child declares `egress:` | | `egress` | `EgressConfig` | full replace if child declares `egress:` |
| `supervise` | `bool` | full replace if child declares `supervise:` | | `supervise` | `bool` | full replace if child declares `supervise:` |
Why full-replace for the list-valued fields (`git[]`, Why full-replace for `egress.routes[]`:
`egress.routes[]`):
- **Ordering matters.** Egress route ordering is part of the - **Ordering matters.** Egress route ordering is part of the
match semantics (first matching host wins). Merging two match semantics (first matching host wins). Merging two
ordered lists by name introduces "where does the child's route ordered lists by name introduces "where does the child's route
go?" ambiguity. go?" ambiguity.
- **Name collisions are ambiguous.** If parent has
`git: [{Name: foo, Upstream: A}]` and child has `git:
[{Name: foo, Upstream: B}]`, "merge" could mean override-B or
error-on-collision. Full-replace makes the operator's
intent explicit.
- **Simpler precedence.** "Child declares X → X wins, full - **Simpler precedence.** "Child declares X → X wins, full
stop" is one sentence; partial merges need a table per list. stop" is one sentence; partial merges need a table per list.
@@ -129,9 +123,15 @@ The `env` dict is the one exception because dict-merge has no
ordering concern and dict-keyed overrides are the obvious user ordering concern and dict-keyed overrides are the obvious user
expectation. (Same model as shell `export` precedence.) expectation. (Same model as shell `export` precedence.)
The `git_user` dataclass-overlay (each non-empty field wins `git.remotes` is also keyed, so it follows dict-style inheritance:
individually) is so a parent can declare `git_user.name` and a children can override one host without restating every remote. The
child can add just `git_user.email`. The default `GitUser()` remote entry is replaced as a whole on host collision because
`Upstream`, `IdentityFile`, `KnownHostKey`, and `ExtraHosts` are
tightly coupled.
The `git.user` dataclass-overlay (each non-empty field wins
individually) is so a parent can declare `git.user.name` and a
child can add just `git.user.email`. The default `GitUser()`
fields are empty strings, which are treated as "not set" for fields are empty strings, which are treated as "not set" for
overlay purposes — same `is_empty()` predicate the provisioner overlay purposes — same `is_empty()` predicate the provisioner
uses. uses.
@@ -191,7 +191,7 @@ moot because there's only one bottle source.
- Implement `_merge(parent: Bottle, child_raw: dict, name: str) - Implement `_merge(parent: Bottle, child_raw: dict, name: str)
-> Bottle` with the rules table above. -> Bottle` with the rules table above.
- Unit tests: simple two-bottle extends, env merge with - Unit tests: simple two-bottle extends, env merge with
collision, list-replace for git + egress, git_user overlay, collision, host-keyed git remote merge, egress list-replace, git.user overlay,
supervise override, missing parent dies, cycle dies, deeper supervise override, missing parent dies, cycle dies, deeper
chains (A extends B extends C). chains (A extends B extends C).
3. **Docs.** Add an `extends:` example to the README's manifest 3. **Docs.** Add an `extends:` example to the README's manifest
+15 -13
View File
@@ -42,20 +42,22 @@ def fixture_with_git_dict() -> dict[str, Any]:
return { return {
"bottles": { "bottles": {
"dev": { "dev": {
"git": [ "git": {
{ "remotes": {
"Name": "claude-bottle", "gitea.dideric.is": {
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git", "Name": "claude-bottle",
"IdentityFile": "/dev/null", "Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
"KnownHostKey": "ssh-ed25519 AAAA...", "IdentityFile": "/dev/null",
"KnownHostKey": "ssh-ed25519 AAAA...",
},
"github.com": {
"Name": "foo",
"Upstream": "ssh://git@github.com/didericis/foo.git",
"IdentityFile": "/dev/null",
"KnownHostKey": "ssh-ed25519 BBBB...",
},
}, },
{ }
"Name": "foo",
"Upstream": "ssh://git@github.com/didericis/foo.git",
"IdentityFile": "/dev/null",
"KnownHostKey": "ssh-ed25519 BBBB...",
},
]
} }
}, },
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+7 -5
View File
@@ -120,11 +120,13 @@ class TestSandboxEscape(unittest.TestCase):
# is intentionally unreachable — the pre-receive # is intentionally unreachable — the pre-receive
# gitleaks hook must reject BEFORE git-gate # gitleaks hook must reject BEFORE git-gate
# attempts the upstream push. # attempts the upstream push.
"git": [{ "git": {"remotes": {
"Name": "throwaway", "unreachable.invalid": {
"Upstream": "ssh://git@unreachable.invalid:22/throwaway.git", "Name": "throwaway",
"IdentityFile": str(cls._key_path), "Upstream": "ssh://git@unreachable.invalid:22/throwaway.git",
}], "IdentityFile": str(cls._key_path),
},
}},
}, },
}, },
"agents": { "agents": {
+7 -5
View File
@@ -43,11 +43,13 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
if supervise: if supervise:
bottle["supervise"] = True bottle["supervise"] = True
if with_git: if with_git:
bottle["git"] = [{ bottle["git"] = {"remotes": {
"Name": "upstream", "example.com": {
"Upstream": "ssh://git@example.com:22/x/y.git", "Name": "upstream",
"IdentityFile": "/etc/hostname", # any existing file "Upstream": "ssh://git@example.com:22/x/y.git",
}] "IdentityFile": "/etc/hostname", # any existing file
},
}}
if with_egress: if with_egress:
bottle["egress"] = { bottle["egress"] = {
"routes": [{ "routes": [{
+1 -1
View File
@@ -26,7 +26,7 @@ def _plan(*, git_user: dict | None = None,
stage_dir: Path | None = None) -> DockerBottlePlan: stage_dir: Path | None = None) -> DockerBottlePlan:
bottle_json: dict = {} bottle_json: dict = {}
if git_user is not None: if git_user is not None:
bottle_json["git_user"] = git_user bottle_json["git"] = {"user": git_user}
manifest = Manifest.from_json_obj({ manifest = Manifest.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+8 -6
View File
@@ -51,12 +51,14 @@ class TestExtraHostsPlumbing(unittest.TestCase):
m = Manifest.from_json_obj({ m = Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
"git": [{ "git": {"remotes": {
"Name": "claude-bottle", "gitea.dideric.is": {
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git", "Name": "claude-bottle",
"IdentityFile": "/dev/null", "Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
"ExtraHosts": {"gitea.dideric.is": "100.78.141.42"}, "IdentityFile": "/dev/null",
}], "ExtraHosts": {"gitea.dideric.is": "100.78.141.42"},
},
}},
}, },
}, },
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+56 -21
View File
@@ -116,10 +116,9 @@ class TestExtendsEnvMerge(unittest.TestCase):
self.assertEqual({"A": "1", "B": "2"}, dict(m.bottles["child"].env)) self.assertEqual({"A": "1", "B": "2"}, dict(m.bottles["child"].env))
class TestExtendsListsFullReplace(unittest.TestCase): class TestExtendsGitMerge(unittest.TestCase):
"""git: and egress: are full-replace when the child declares """git.user overlays by field; git.remotes merges by upstream
them partial merge would be ambiguous (ordering + name host, with child entries replacing duplicate hosts."""
collisions). See PRD 0025 "Merge rules"."""
_GIT_ENTRY_A = { _GIT_ENTRY_A = {
"Name": "a", "Name": "a",
@@ -132,31 +131,67 @@ class TestExtendsListsFullReplace(unittest.TestCase):
"IdentityFile": "/dev/null", "IdentityFile": "/dev/null",
} }
def test_child_git_replaces_parent_entirely(self): def test_child_git_remotes_merge_with_parent(self):
m = _build( m = _build(
base={"git": [self._GIT_ENTRY_A]}, base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}},
child={"extends": "base", "git": [self._GIT_ENTRY_B]}, child={
"extends": "base",
"git": {"remotes": {"host-b": self._GIT_ENTRY_B}},
},
) )
names = [e.Name for e in m.bottles["child"].git] names = [e.Name for e in m.bottles["child"].git]
self.assertEqual(["b"], names) self.assertEqual(["a", "b"], names)
def test_child_git_remote_replaces_same_host(self):
replacement = {
"Name": "a2",
"Upstream": "ssh://git@host-a/replacement.git",
"IdentityFile": "/dev/null",
}
m = _build(
base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}},
child={
"extends": "base",
"git": {"remotes": {"host-a": replacement}},
},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
self.assertEqual("a2", entries[0].Name)
self.assertEqual("replacement.git", entries[0].UpstreamPath)
def test_child_omits_git_inherits_full_list(self): def test_child_omits_git_inherits_full_list(self):
m = _build( m = _build(
base={"git": [self._GIT_ENTRY_A, self._GIT_ENTRY_B]}, base={"git": {"remotes": {
"host-a": self._GIT_ENTRY_A,
"host-b": self._GIT_ENTRY_B,
}}},
child={"extends": "base"}, child={"extends": "base"},
) )
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_explicit_empty_git_clears_parent(self): def test_child_explicit_empty_git_clears_parent(self):
# `git: []` is the documented way to say "drop the # `git.remotes: {}` is the documented way to say "drop
# parent's list" rather than "inherit it". # the parent's remotes" rather than "inherit them".
m = _build( m = _build(
base={"git": [self._GIT_ENTRY_A]}, base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}},
child={"extends": "base", "git": []}, child={"extends": "base", "git": {"remotes": {}}},
) )
self.assertEqual((), m.bottles["child"].git) self.assertEqual((), m.bottles["child"].git)
def test_child_git_user_inherits_parent_remotes(self):
m = _build(
base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}},
child={"extends": "base", "git": {"user": {"name": "Child"}}},
)
self.assertEqual(["a"], [e.Name for e in m.bottles["child"].git])
self.assertEqual("Child", m.bottles["child"].git_user.name)
class TestExtendsListsFullReplace(unittest.TestCase):
"""egress: remains full-replace when the child declares it."""
def test_child_egress_replaces_parent_entirely(self): def test_child_egress_replaces_parent_entirely(self):
m = _build( m = _build(
base={"egress": {"routes": [{"host": "a.example.com"}]}}, base={"egress": {"routes": [{"host": "a.example.com"}]}},
@@ -178,12 +213,12 @@ class TestExtendsListsFullReplace(unittest.TestCase):
class TestExtendsGitUserOverlay(unittest.TestCase): class TestExtendsGitUserOverlay(unittest.TestCase):
"""git_user: per-field overlay. Each non-empty field on child """git.user: per-field overlay. Each non-empty field on child
wins; empties fall through to parent.""" wins; empties fall through to parent."""
def test_parent_full_child_omits(self): def test_parent_full_child_omits(self):
m = _build( m = _build(
base={"git_user": {"name": "Parent", "email": "p@x"}}, base={"git": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base"}, child={"extends": "base"},
) )
u = m.bottles["child"].git_user u = m.bottles["child"].git_user
@@ -192,10 +227,10 @@ class TestExtendsGitUserOverlay(unittest.TestCase):
def test_child_overrides_both(self): def test_child_overrides_both(self):
m = _build( m = _build(
base={"git_user": {"name": "Parent", "email": "p@x"}}, base={"git": {"user": {"name": "Parent", "email": "p@x"}}},
child={ child={
"extends": "base", "extends": "base",
"git_user": {"name": "Child", "email": "c@x"}, "git": {"user": {"name": "Child", "email": "c@x"}},
}, },
) )
u = m.bottles["child"].git_user u = m.bottles["child"].git_user
@@ -206,8 +241,8 @@ class TestExtendsGitUserOverlay(unittest.TestCase):
# Parent sets only name; child sets only email. Both end # Parent sets only name; child sets only email. Both end
# up populated on the child. # up populated on the child.
m = _build( m = _build(
base={"git_user": {"name": "Parent"}}, base={"git": {"user": {"name": "Parent"}}},
child={"extends": "base", "git_user": {"email": "c@x"}}, child={"extends": "base", "git": {"user": {"email": "c@x"}}},
) )
u = m.bottles["child"].git_user u = m.bottles["child"].git_user
self.assertEqual("Parent", u.name) self.assertEqual("Parent", u.name)
@@ -215,8 +250,8 @@ class TestExtendsGitUserOverlay(unittest.TestCase):
def test_child_overrides_only_email(self): def test_child_overrides_only_email(self):
m = _build( m = _build(
base={"git_user": {"name": "Parent", "email": "p@x"}}, base={"git": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base", "git_user": {"email": "c@x"}}, child={"extends": "base", "git": {"user": {"email": "c@x"}}},
) )
u = m.bottles["child"].git_user u = m.bottles["child"].git_user
# Child overrides email; name inherited from parent. # Child overrides email; name inherited from parent.
+52 -8
View File
@@ -8,11 +8,26 @@ from claude_bottle.manifest import Manifest
def _manifest(git_entries): def _manifest(git_entries):
return { return {
"bottles": {"dev": {"git": git_entries}}, "bottles": {"dev": {"git": {"remotes": {
_host_for(entry): entry for entry in git_entries
}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
} }
def _host_for(entry):
upstream = entry.get("Upstream", "")
if "@a.example" in upstream:
return "a.example"
if "@b.example" in upstream:
return "b.example"
if "@github.com" in upstream:
return "github.com"
if "@gitea.dideric.is" in upstream:
return "gitea.dideric.is"
return "example.com"
class TestGitEntryParsing(unittest.TestCase): class TestGitEntryParsing(unittest.TestCase):
def test_parses_minimal_entry(self): def test_parses_minimal_entry(self):
m = Manifest.from_json_obj(_manifest([{ m = Manifest.from_json_obj(_manifest([{
@@ -161,12 +176,34 @@ class TestGitEntryExtraHosts(unittest.TestCase):
class TestGitEntryCrossValidation(unittest.TestCase): class TestGitEntryCrossValidation(unittest.TestCase):
def test_duplicate_name_dies(self): def test_duplicate_name_dies(self):
with self.assertRaises(Die): with self.assertRaises(Die):
Manifest.from_json_obj(_manifest([ Manifest.from_json_obj({
{"Name": "foo", "Upstream": "ssh://git@a.example/x.git", "bottles": {"dev": {"git": {"remotes": {
"IdentityFile": "/dev/null"}, "a.example": {
{"Name": "foo", "Upstream": "ssh://git@b.example/y.git", "Name": "foo",
"IdentityFile": "/dev/null"}, "Upstream": "ssh://git@a.example/x.git",
])) "IdentityFile": "/dev/null",
},
"b.example": {
"Name": "foo",
"Upstream": "ssh://git@b.example/y.git",
"IdentityFile": "/dev/null",
},
}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def test_remote_key_must_match_upstream_host(self):
with self.assertRaises(Die):
Manifest.from_json_obj({
"bottles": {"dev": {"git": {"remotes": {
"wrong.example": {
"Name": "foo",
"Upstream": "ssh://git@github.com/foo.git",
"IdentityFile": "/dev/null",
},
}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def test_legacy_ssh_field_dies_with_hint(self): def test_legacy_ssh_field_dies_with_hint(self):
# PRD 0009: bottle.ssh is removed; manifests carrying it must # PRD 0009: bottle.ssh is removed; manifests carrying it must
@@ -196,13 +233,20 @@ class TestEmptyGitField(unittest.TestCase):
}) })
self.assertEqual((), m.bottles["dev"].git) self.assertEqual((), m.bottles["dev"].git)
def test_git_array_type_required(self): def test_git_object_type_required(self):
with self.assertRaises(Die): with self.assertRaises(Die):
Manifest.from_json_obj({ Manifest.from_json_obj({
"bottles": {"dev": {"git": "not-a-list"}}, "bottles": {"dev": {"git": "not-a-list"}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
def test_empty_remotes_yields_empty_tuple(self):
m = Manifest.from_json_obj({
"bottles": {"dev": {"git": {"remotes": {}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
self.assertEqual((), m.bottles["dev"].git)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+17 -6
View File
@@ -1,4 +1,4 @@
"""Unit: Bottle.git_user manifest parsing + validation (issue #86).""" """Unit: Bottle git.user manifest parsing + validation (issue #86)."""
import contextlib import contextlib
import io import io
@@ -24,7 +24,7 @@ def _die_message(callable_, *args, **kwargs) -> str:
def _manifest(git_user): def _manifest(git_user):
return { return {
"bottles": {"dev": {"git_user": git_user}}, "bottles": {"dev": {"git": {"user": git_user}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
} }
@@ -53,7 +53,7 @@ class TestGitUserParsing(unittest.TestCase):
self.assertEqual("bot@example.com", u.email) self.assertEqual("bot@example.com", u.email)
def test_omitted_defaults_to_empty(self): def test_omitted_defaults_to_empty(self):
# No git_user block at all → empty GitUser, is_empty True → # No git.user block at all → empty GitUser, is_empty True →
# provisioner skips the `git config` step entirely. # provisioner skips the `git config` step entirely.
m = Manifest.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
@@ -63,7 +63,7 @@ class TestGitUserParsing(unittest.TestCase):
self.assertTrue(u.is_empty()) self.assertTrue(u.is_empty())
def test_both_empty_strings_dies(self): def test_both_empty_strings_dies(self):
# An explicit `git_user: {name: "", email: ""}` is a typo # An explicit `git.user: {name: "", email: ""}` is a typo
# / half-finished edit; fail loudly rather than silently # / half-finished edit; fail loudly rather than silently
# no-op (the operator clearly meant to configure something). # no-op (the operator clearly meant to configure something).
msg = _die_message( msg = _die_message(
@@ -83,13 +83,24 @@ class TestGitUserParsing(unittest.TestCase):
msg = _die_message( msg = _die_message(
Manifest.from_json_obj, _manifest({"name": 42}), Manifest.from_json_obj, _manifest({"name": 42}),
) )
self.assertIn("git_user.name must be a string", msg) self.assertIn("git.user.name must be a string", msg)
def test_non_string_email_dies(self): def test_non_string_email_dies(self):
msg = _die_message( msg = _die_message(
Manifest.from_json_obj, _manifest({"email": ["x@y.z"]}), Manifest.from_json_obj, _manifest({"email": ["x@y.z"]}),
) )
self.assertIn("git_user.email must be a string", msg) self.assertIn("git.user.email must be a string", msg)
def test_legacy_top_level_git_user_dies(self):
msg = _die_message(
Manifest.from_json_obj,
{
"bottles": {"dev": {"git_user": {"name": "Bot"}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
},
)
self.assertIn("git_user", msg)
self.assertIn("git.user", msg)
class TestGitUserDirect(unittest.TestCase): class TestGitUserDirect(unittest.TestCase):
+13 -4
View File
@@ -31,6 +31,12 @@ from claude_bottle.pipelock import PipelockProxyPlan
from claude_bottle.supervise import SupervisePlan from claude_bottle.supervise import SupervisePlan
def _remote_host(g: GitEntry) -> str:
if g.UpstreamHost:
return g.UpstreamHost
return g.Upstream.split("@", 1)[1].split("/", 1)[0].split(":", 1)[0]
def _plan( def _plan(
*, *,
agent_prompt: str = "", agent_prompt: str = "",
@@ -49,17 +55,20 @@ def _plan(
agent_supervise_url: str = "http://127.0.0.1:55556/", agent_supervise_url: str = "http://127.0.0.1:55556/",
) -> SmolmachinesBottlePlan: ) -> SmolmachinesBottlePlan:
bottle_json: dict = {} bottle_json: dict = {}
git_json: dict = {}
if git: if git:
bottle_json["git"] = [ git_json["remotes"] = {
{ _remote_host(g): {
"Name": g.Name, "Name": g.Name,
"Upstream": g.Upstream, "Upstream": g.Upstream,
"IdentityFile": g.IdentityFile, "IdentityFile": g.IdentityFile,
} }
for g in git for g in git
] }
if git_user is not None: if git_user is not None:
bottle_json["git_user"] = git_user git_json["user"] = git_user
if git_json:
bottle_json["git"] = git_json
if supervise: if supervise:
bottle_json["supervise"] = True bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({ manifest = Manifest.from_json_obj({
+8 -6
View File
@@ -265,11 +265,13 @@ class TestRealisticBottleFile(unittest.TestCase):
path_allowlist: path_allowlist:
- /didericis/ - /didericis/
git: git:
- Name: claude-bottle remotes:
Upstream: ssh://git@gitea.dideric.is:30009/x/y.git gitea.dideric.is:
IdentityFile: ~/.ssh/gitea.pem Name: claude-bottle
ExtraHosts: Upstream: ssh://git@gitea.dideric.is:30009/x/y.git
gitea.dideric.is: 100.78.141.42 IdentityFile: ~/.ssh/gitea.pem
ExtraHosts:
gitea.dideric.is: 100.78.141.42
""") """)
# Spot-check the deep parts; the structure is large. # Spot-check the deep parts; the structure is large.
self.assertEqual(2, len(out["egress"]["routes"])) self.assertEqual(2, len(out["egress"]["routes"]))
@@ -283,7 +285,7 @@ class TestRealisticBottleFile(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
"100.78.141.42", "100.78.141.42",
out["git"][0]["ExtraHosts"]["gitea.dideric.is"], out["git"]["remotes"]["gitea.dideric.is"]["ExtraHosts"]["gitea.dideric.is"],
) )