Compare commits

..

16 Commits

Author SHA1 Message Date
didericis 913bcab6a0 docs(nested-containers): record what live verification established
test / unit (pull_request) Failing after 33s
tracker-policy-pr / check-pr (pull_request) Failing after 34s
test / integration-docker (pull_request) Successful in 42s
test / integration-firecracker (pull_request) Successful in 4m4s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
Documents the proxy-layer precedence that took four attempts to get
right, the blob-CDN routes every registry needs alongside its own, the
podman 5 networking packages and how each one fails when absent, and
the BusyBox wget caveat. Closes the DNS open question: public
resolution inside a nested container fails by design; reaching the
proxy was the real problem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis 0bf7f9ece6 fix(macos): put the gateway address in the Docker CLI proxy config
This is the layer that actually decides it, and it was in the script
from the start: ~/.docker/config.json's proxies block is what the Docker
CLI copies into every container it starts. Being client-side it beats
the podman service outright, which is why containers.conf `env`,
http_proxy=false, and the service environment each looked correct on
disk and changed nothing a nested container saw.

Also corrects the comment on the service-env substitution, which
claimed to be the one place the address sticks. It is not; it covers
podman's own pulls and non-CLI API clients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis ebde817896 fix(macos): substitute the gateway address before the service starts
Third attempt at the same symptom, and the first at the right layer.
podman's Docker-compatible API injects proxy settings from the *daemon*
environment after containers.conf is applied, so neither the env list
(892bfc16) nor http_proxy=false (21c144ae) could override it — the same
compat-path blind spot already seen with hosts_file. Nested containers
kept reporting `wget: bad address 'bot-bottle-gateway:9099'` through
both.

Substituting the resolved address into the service's own environment,
before `podman system service` starts, is the one place it sticks.
NO_PROXY keeps the name, which is matched against what a client asks
for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis 0b0729562b fix(macos): stop podman re-injecting the gateway name into containers
The address-bearing proxy URLs from 892bfc16 were written correctly but
never took effect: podman copies the host's proxy environment into every
container by default, and that copy overrides containers.conf `env`,
restoring the `bot-bottle-gateway` name a nested container cannot
resolve. Verified on the macOS host — containers still reported
`wget: bad address 'bot-bottle-gateway:9099'` with the addresses in
place.

Setting http_proxy=false disables only podman's own passthrough; the
proxy vars still reach containers, by address, from the env list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis 85e8e1bea2 fix(macos): reach the egress proxy by address in nested containers
podman's containers.conf `hosts_file` turns out to apply only to native
`podman run`; the Docker-compatible API ignores it, and the agent types
`docker`. (`base_hosts_file`, used before this, is a no-op in both
paths on podman 5.4.2.) So no config key can put the gateway name in a
nested container's /etc/hosts.

The name is therefore resolved in the bottle, where /etc/hosts does
carry it, and the address goes into the proxy URL handed to nested
containers. NO_PROXY keeps the name, since that is matched against what
a client asks for.

Verified on the macOS host: with the gateway reachable, a nested
container gets 200 from an allowlisted host and 403 from one that is
not — the egress boundary applies inside nested containers too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis 30e39577c2 fix(egress): emit preserve_auth into the generated routes
The flag from #453 was parsed off the manifest and honored by the
addon, but `_route_to_yaml_fields` never wrote it, so it was dropped on
the way to the proxy and has never worked end to end. Every registry
route silently kept the unconditional Authorization strip.

That is the whole of the Docker Hub / GHCR "unauthorized" blocker in
#392: podman fetched a valid 2658-byte bearer from auth.docker.io, the
proxy stripped it, and registry-1.docker.io answered the resulting
anonymous request with a fresh WWW-Authenticate challenge — which reads
exactly like a credential problem rather than a serializer gap.

Found by diffing a bottle's rendered routes.yaml against its manifest:
preserve_auth was in the manifest and absent from the output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis 08e932805d feat(macos): give nested containers working egress
Answers the last open question in #392: DNS from inside a nested
container was not a phantom, but it was also not a DNS problem. Public
resolution fails there by design — everything egresses through the
proxy — and the actual breakage was two missing pieces:

- The proxy URL names `bot-bottle-gateway`, which resolves only through
  the bottle's own /etc/hosts. Containers got their own hosts file, so
  they failed at "Could not resolve proxy", which reads like broken DNS.
  base_hosts_file propagates the bottle's entries.
- The gateway TLS-intercepts, so a container that does not trust the
  bottle's CA bundle fails with "unable to get local issuer
  certificate". The bundle is now mounted read-only and the usual env
  vars point at it, which covers curl, wget, python, and node without
  distro-specific trust commands.

Verified on the macOS host by reproducing each failure and confirming
that these three conditions applied by hand produced HTTP 200 from
inside a nested container. podman already forwards the proxy env, so
that needed nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis 2f8a7be1b1 fix(macos): install podman 5's full networking stack
netavark shells out to `nft` for the bridge network every compose file
expects, and aardvark-dns serves name resolution inside nested
containers. Neither arrives with the base image's
`--no-install-recommends` podman, and each fails at a different and
misleading layer: without nft containers are created but never start;
without aardvark-dns DNS inside a container fails while the engine, the
pulls, and the bridge all look healthy. That last one is the likeliest
explanation for the "DNS inside nested containers fails entirely" note
in #392 — a missing package, not a design gap.

Verified on the macOS host: with passt alone, `docker run` reached
netavark and died on "unable to execute nft".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis 1f64e4b6be fix(macos): install passt for podman 5 rootless networking
podman 5 uses pasta as the default rootless network helper; podman 4
used slirp4netns. The spike was written against bookworm's podman 4.3.1,
so its package list never included passt — and the trixie bump (#451)
changed the default out from under it. Every nested container failed to
start with "could not find pasta", while image pulls and the service
itself worked, which disguised a missing dependency as a compat-API bug.

The bootstrap now checks for pasta up front so this fails with a clear
message instead of surfacing at the first `docker run`.

Note the sun_path fix in a12d2191 did NOT cause or cure this; that path
was over the 108-byte limit independently and would have bitten as soon
as attach was reached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis 90522d335a fix(macos): shorten the podman runtime dir to fit sun_path
podman derives conmon's attach socket as
`$XDG_RUNTIME_DIR/libpod/tmp/socket/<64-hex-id>/attach`. With the
descriptive `/tmp/bot-bottle-podman-run` that path is 116 bytes, past
the 108-byte sun_path limit, so every attached `docker run` failed with
"unable to upgrade to tcp, received 500" — while image pulls and the
service itself worked, which is what made it look like a compat-API bug
rather than a path-length one.

Found on the macOS host: quay.io pulled fine and then no container
could be started. Test pins the budget so a more readable name cannot
silently reintroduce it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis adeb581cdf fix(manifest): OR nested_containers across composed bottles
merge_bottles_runtime sees resolved bottles, where a bottle that never
mentioned the key is indistinguishable from one that set it false. With
"later replaces" semantics, `--bottle with-containers --bottle claude-dev`
silently dropped the capability — the second bottle's default clobbered
it. OR instead: composing bottles adds capabilities, it does not remove
them. The file-based extends path still sees raw keys, so an explicit
`nested_containers: false` in a child continues to turn it back off.

Also surface the flag in the launch summary, where its absence was the
only reason the clobber went unnoticed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis 29c46356ef feat(macos): run containers inside a bottle via guest-local podman
Adds the `nested_containers` bottle flag. On the macOS backend it starts
a rootless podman service inside the bottle and exposes its
Docker-compatible API socket, so the agent still runs `docker` and
`docker compose`. No host daemon socket is mounted and the guest gains
no capabilities; backends that cannot run a guest-local engine reject
the flag in the shared prepare template rather than ignoring it.

Podman rather than rootless Docker because Apple Container's capability
bounding set omits CAP_SYS_ADMIN, which the kernel requires to write a
multi-range uid_map via newuidmap. With no subordinate UID range podman
falls back to a single-UID self-mapping an unprivileged process may
write itself, so the image build strips /etc/subuid and /etc/subgid
entries rather than adding them.

That mapping is also why nested containers are not an isolation layer:
root inside one is the agent user outside it. They are a build/test
convenience; the bottle remains the boundary.

Ports the spike branch onto main, renaming docker_access — it implied
Docker and granted access to nothing on the host — and drops podman
from the derived layer now that every built-in image ships it (#451).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00
didericis-codex 8fed02fd1e chore(ci): gate tests on relevant runtime inputs 2026-07-22 01:24:02 +00:00
Quality Badge Bot a7b2befc06 chore: update quality badges
- Coverage: 83%
- Core coverage: 94%

[skip ci]
2026-07-22 01:19:07 +00:00
didericis-codex 6fdf090469 fix(ci): authenticate badge pushes with PAT
test / unit (push) Failing after 2s
test / integration-docker (push) Successful in 10s
test / integration-firecracker (push) Successful in 2m59s
test / coverage (push) Has been skipped
test / publish-infra (push) Has been skipped
2026-07-22 01:09:42 +00:00
didericis-codex d32e9cc3c3 fix(ci): use Gitea token for badge updates
test / integration-docker (push) Successful in 11s
test / unit (push) Successful in 36s
test / integration-firecracker (push) Successful in 3m13s
test / coverage (push) Successful in 43s
test / publish-infra (push) Successful in 44s
2026-07-22 00:53:32 +00:00
3 changed files with 26 additions and 13 deletions
+24 -11
View File
@@ -1,4 +1,5 @@
# Run the project's test suite on every PR push and on push to main.
# Run the project's test suite when package or runtime inputs change on a PR
# or on push to main.
#
# The suite uses stdlib `unittest` discovery — no external Python
# dependencies are required to execute it. Tests are split by directory:
@@ -23,22 +24,34 @@ on:
branches:
- main
paths:
- '**.py'
- '.gitea/workflows/**.yml'
- 'scripts/**'
- 'README.md'
# Dockerfiles and pyproject.toml are baked into the infra rootfs; a
# change here alters what the integration/coverage jobs build locally.
- 'bot_bottle/**'
- 'tests/**/*.py'
- 'cli.py'
- 'scripts/coverage.sh'
- 'scripts/critical-modules.txt'
- 'scripts/diff_coverage.py'
- 'scripts/tracker_policy.py'
- 'scripts/firecracker-netpool.sh'
- 'Dockerfile*'
- 'pyproject.toml'
- 'requirements-dev.txt'
- '.coveragerc'
- '.dockerignore'
pull_request:
paths:
- '**.py'
- '.gitea/workflows/**.yml'
- 'scripts/**'
- 'README.md'
- 'bot_bottle/**'
- 'tests/**/*.py'
- 'cli.py'
- 'scripts/coverage.sh'
- 'scripts/critical-modules.txt'
- 'scripts/diff_coverage.py'
- 'scripts/tracker_policy.py'
- 'scripts/firecracker-netpool.sh'
- 'Dockerfile*'
- 'pyproject.toml'
- 'requirements-dev.txt'
- '.coveragerc'
- '.dockerignore'
workflow_dispatch:
jobs:
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.BADGE_PUSH_TOKEN }}
# No actions/setup-python: the runner image ships Python 3.12 and older
# act_runner engines mishandle setup-python's PATH. Install into the
+1 -1
View File
@@ -5,7 +5,7 @@
# bot-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![coverage](https://img.shields.io/badge/coverage-81%25-brightgreen)](https://coverage.readthedocs.io/)
[![coverage](https://img.shields.io/badge/coverage-83%25-brightgreen)](https://coverage.readthedocs.io/)
[![core coverage](https://img.shields.io/badge/core%20coverage-94%25-brightgreen)](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.