From 8a7af069837585ff76d3f32318ff06dd14c9c73a Mon Sep 17 00:00:00 2001 From: Jeena Date: Thu, 29 Oct 2026 08:33:23 +0900 Subject: [PATCH 1/3] container: Fail early on missing subuid/subgid ranges Rootless Podman falls back to a single-UID user namespace when the invoking user has no /etc/subuid and /etc/subgid entry, and then any chown to an unmapped UID fails with EINVAL. Newer pacman versions hit this during builds and updates: their download sandbox chowns its temporary download directory to the 'alpm' user, which made image builds fail with a cryptic traceback after several minutes of output. Accounts created manually or before shadow 4.11.1-3 (October 2022) get no subordinate ID ranges automatically, while freshly created ones do. Rather than working around this in the image (disabling pacman's sandbox would silently drop a security feature), detect the missing ranges before building and stop with the exact fix, using the first unclaimed 65536-ID block so the printed command never collides with other users' ranges. Link the Arch Wiki section and Podman's rootless tutorial for background. --- README.md | 2 +- agent-container.py | 82 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2072a9e..929f4e1 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ socket goes away. ## Install -You need rootless Podman on the host. On Arch Linux the packages `podman` and `passt` (or `slirp4netns`) are enough; your user also needs subuid/subgid entries, which `usermod --add-subuids 100000-165535 --add-subgids 100000-165535 ` sets up if the package did not: +You need rootless Podman on the host. On Arch Linux the packages `podman` and `passt` (or `slirp4netns`) are enough; your user also needs subuid/subgid entries, which `usermod --add-subuids 100000-165535 --add-subgids 100000-165535 ` sets up if the package did not (accounts created before shadow 4.11.1-3 get no entries automatically — see [Podman: Set subuid and subgid](https://wiki.archlinux.org/title/Podman#Set_subuid_and_subgid)): ```sh sudo pacman -S podman passt diff --git a/agent-container.py b/agent-container.py index a7312b6..1029344 100755 --- a/agent-container.py +++ b/agent-container.py @@ -363,7 +363,89 @@ class AgentContainer: == 0 ) + # Subordinate ID block size; rootless Podman needs at least 65536 IDs so + # every container UID is mappable. 100000 is the default first block the + # shadow suite allocates on Arch (and the number the Arch Wiki suggests). + SUBID_SIZE = 65536 + SUBID_START = 100000 + + @classmethod + def _free_subid_range(cls) -> str: + """Return the first unclaimed 65536-ID block as "start-end". + + Ranges in /etc/subuid and /etc/subgid must never overlap between + users (a shared range would let one user impersonate another inside + containers), so both files' claims are considered. + """ + taken = [] + for path in ("/etc/subuid", "/etc/subgid"): + try: + with open(path) as f: + for line in f: + parts = line.split(":") + if len(parts) < 3: + continue + try: + taken.append( + (int(parts[1]), int(parts[1]) + int(parts[2])) + ) + except ValueError: + continue + except FileNotFoundError: + pass + taken.sort() + start = cls.SUBID_START + for lo, hi in taken: + if hi <= start: + continue + if lo - start >= cls.SUBID_SIZE: + break + start = max(start, hi) + return f"{start}-{start + cls.SUBID_SIZE - 1}" + + def _subid_guard(self) -> None: + """Warn when rootless Podman has no subuid/subgid ranges for this user. + + Without a range in /etc/subuid and /etc/subgid, Podman falls back to a + single-UID user namespace: the container only has the invoking user's + UID, and chown to any other UID fails with EINVAL. This breaks e.g. + pacman's download sandbox, which chowns its download directory to the + 'alpm' user. Distros like Fedora allocate ranges for every user + automatically, but accounts created manually (e.g. on Arch) may not + have one. + """ + if self.RUNTIME != "podman" or os.geteuid() == 0: + return + + def has_entry(path: str) -> bool: + try: + with open(path) as f: + return any(line.split(":", 1)[0] == self.host_username for line in f) + except FileNotFoundError: + return False + + missing = [ + p for p in ("/etc/subuid", "/etc/subgid") if not has_entry(p) + ] + if not missing: + return + + range_str = self._free_subid_range() + logger.error( + f"No subuid/subgid entry for user '{self.host_username}' in" + " /etc/subuid, /etc/subgid — rootless Podman has only a single UID" + " available, so operations inside containers that chown to other" + " UIDs (e.g. pacman's alpm download sandbox) fail with 'Invalid" + " argument'. Fix once per user:\n" + f" sudo usermod --add-subuids {range_str} --add-subgids {range_str} {self.host_username}\n" + " podman system migrate\n" + " See https://wiki.archlinux.org/title/Podman#Set_subuid_and_subgid" + " or https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md" + ) + sys.exit(1) + def build_image(self, no_cache: bool = False, pull: bool = False) -> None: + self._subid_guard() logger.info( f"Building image '{self.IMAGE}' with user {self.host_username} ({self.host_uid}:{self.host_gid})" ) From 43dc757b929b56880035ff2959a8f23a68c06140 Mon Sep 17 00:00:00 2001 From: Jeena Date: Thu, 29 Oct 2026 08:42:06 +0900 Subject: [PATCH 2/3] container: Use fully qualified image name in FROM Podman only resolves short image names through an unqualified search registry, which is not configured by default on Arch (and on Ubuntu setups without a registries.d file), so builds fail at the first step with 'short-name "archlinux" did not resolve'. Naming the registry explicitly makes the image build on any distro without per-distro configuration. --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index faa411f..c2968d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM archlinux:latest +# Fully qualified registry: Podman on distros without an unqualified search +# registry configured (e.g. Arch or Ubuntu) cannot resolve the short name. +FROM docker.io/library/archlinux:latest ARG USERNAME=dev ARG UID=1000 From fcbfa325459fbef0ce8515d0f6ad1ae0ac480970 Mon Sep 17 00:00:00 2001 From: Jeena Date: Thu, 29 Oct 2026 18:56:03 +0900 Subject: [PATCH 3/3] container: Stop gracefully on podman stop Containers used `sleep infinity` as PID 1, which never reacts to SIGTERM: the kernel does not apply default signal dispositions to PID 1, and sleep has no SIGTERM handler, so every `podman stop` silently ignored the signal, waited out the full 10-second grace period, and fell back to SIGKILL. PID 1 now traps TERM and INT itself and exits on request, so one-off manual stops of a still-running agent container end immediately instead of after the kill timeout. The PID 1 command is baked in at create time, so containers predating this change are detected and recreated automatically on the next run. --- CHANGELOG.md | 22 ++++++++++++++++++++++ agent-container.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52b3aaa..38c6e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Release notes +## 1.0.1 — 2026-10-29 + +`podman stop` now stops a running agent container immediately instead of +after a 10-second SIGTERM timeout followed by SIGKILL. + +### Why + +Containers used `sleep infinity` as PID 1. The kernel does not apply +default signal dispositions to PID 1, and `sleep` has no SIGTERM handler, +so the stop signal was silently ignored and every stop fell through to +SIGKILL. PID 1 now traps TERM (and INT) itself and exits on request: + +```sh +trap "exit 0" TERM INT; sleep infinity & wait +``` + +### Upgrading + +The PID 1 command is baked in when a container is created, so existing +containers are detected on the next run and recreated automatically. +Nothing in the container home is touched. + ## 1.0.0 — 2026-10-29 Containers now run with rootless Podman instead of Docker. diff --git a/agent-container.py b/agent-container.py index 1029344..c430f88 100755 --- a/agent-container.py +++ b/agent-container.py @@ -179,6 +179,12 @@ class AgentContainer: ) self.remove_container() + if self.container_exists() and not self._container_cmd_matches(): + logger.info( + "Recreating container: it predates the SIGTERM-aware PID 1" + ) + self.remove_container() + if not self.container_exists(): self.create_container() @@ -513,7 +519,11 @@ class AgentContainer: self.IMAGE, "sh", "-c", - "sleep infinity", + # PID 1 ignores signals with default dispositions, so a bare + # `sleep infinity` never reacts to SIGTERM and every + # `podman stop` times out into SIGKILL. Trapping TERM (and + # INT) makes stop requests end the container immediately. + 'trap "exit 0" TERM INT; sleep infinity & wait', ] ) @@ -555,6 +565,26 @@ class AgentContainer: ).stdout return "AGENT_CONTAINER=1" in env + def _container_cmd_matches(self) -> bool: + """Whether the container runs the current PID 1 command. + + The PID 1 entrypoint is baked in at create time and can only change + by recreating the container. Comparing against Config.Cmd catches + containers created before the SIGTERM-aware fix.""" + cmd = subprocess.run( + [ + self.RUNTIME, + "container", + "inspect", + "-f", + "{{json .Config.Cmd}}", + self.container_name, + ], + capture_output=True, + text=True, + ).stdout.strip() + return "TERM INT" in cmd + def start_container(self) -> bool: subprocess.run( [self.RUNTIME, "start", self.container_name],