From da95c4352bcee2f4de4618bfab05ed8ec01b134a Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sun, 4 Oct 2026 11:35:47 -0500 Subject: [PATCH 01/10] Add a read-only tmux-socket mount word for tongs A tmux socket is host command execution as its owner, as strong as the docker socket, so the word comes with the narrowest grammar the mount vocabulary allows: no target, and no write access. tmux-socket and tmux-socket:ro are accepted and tmux-socket:rw is refused, by one helper that validation and argv assembly both ask, so the two cannot disagree. A client only needs connect() on the socket, which a read-only bind allows; a writable one would let the tong replace the socket the host's own clients dial. The mount binds the socket's directory rather than the socket, since tmux deletes and recreates the socket when its server restarts. It lands at the fixed /run/swarmforge-tmux, beside rather than under the secret tmpfs at /run/swarmforge, so validation never depends on the host environment, and it is reserved so a workspace target cannot overlap it. The host directory arrives as a keyword, like the docker socket path; nothing in the pure core reads the environment. --- swarmforge/tongs/__init__.py | 6 ++++ swarmforge/tongs/model.py | 3 ++ swarmforge/tongs/mounts.py | 59 +++++++++++++++++++++++++++-------- swarmforge/tongs/validate.py | 6 ++-- tests/test_tongs_mounts.py | 60 ++++++++++++++++++++++++++++++++++++ tests/test_tongs_validate.py | 17 ++++++++++ 6 files changed, 137 insertions(+), 14 deletions(-) diff --git a/swarmforge/tongs/__init__.py b/swarmforge/tongs/__init__.py index 6b5468e..c0c05b0 100644 --- a/swarmforge/tongs/__init__.py +++ b/swarmforge/tongs/__init__.py @@ -97,6 +97,7 @@ READINESS_MODES, REPO, SOCKET_MOUNT, + TMUX_MOUNT, TRANSPORTS, TRUSTED_LAYERS, USER, @@ -116,12 +117,14 @@ MOUNT_MODES, MOUNT_WORDS, ORG_VOLUME_SCOPE, + TMUX_MOUNT_TARGET, VOLUME_DIGEST_LENGTH, VOLUME_MOUNT, VOLUME_NAME_MAX_LENGTH, WORKSPACE_MOUNT, WORKSPACE_VOLUME_SCOPE, mount_destination, + mount_mode_error, mount_target_error, mounts_word, normalize_mount_target, @@ -210,6 +213,7 @@ "READINESS_MODES", "REPO", "SOCKET_MOUNT", + "TMUX_MOUNT", "TRANSPORTS", "TRUSTED_LAYERS", "USER", @@ -227,12 +231,14 @@ "MOUNT_MODES", "MOUNT_WORDS", "ORG_VOLUME_SCOPE", + "TMUX_MOUNT_TARGET", "VOLUME_DIGEST_LENGTH", "VOLUME_MOUNT", "VOLUME_NAME_MAX_LENGTH", "WORKSPACE_MOUNT", "WORKSPACE_VOLUME_SCOPE", "mount_destination", + "mount_mode_error", "mount_target_error", "mounts_word", "normalize_mount_target", diff --git a/swarmforge/tongs/model.py b/swarmforge/tongs/model.py index 269eff1..7c45207 100644 --- a/swarmforge/tongs/model.py +++ b/swarmforge/tongs/model.py @@ -32,6 +32,9 @@ # The mount word granting docker control; here so every module spells it one way. SOCKET_MOUNT = "docker-socket" +# The mount word granting the launcher's tmux server: host command execution, like the socket. +TMUX_MOUNT = "tmux-socket" + # A container cannot re-share a bind mount, so a broker needs the workspace's host path. WORKSPACE_HOST_ENV = "SWARMFORGE_WORKSPACE_HOST_PATH" diff --git a/swarmforge/tongs/mounts.py b/swarmforge/tongs/mounts.py index 6873a78..ad1e7c9 100644 --- a/swarmforge/tongs/mounts.py +++ b/swarmforge/tongs/mounts.py @@ -14,16 +14,18 @@ from swarmforge.names import canonical_path, name_hint -from .model import ORG, REPO, SOCKET_MOUNT, USER, WORKSPACE, workspace_key +from .model import ORG, REPO, SOCKET_MOUNT, TMUX_MOUNT, USER, WORKSPACE, workspace_key from .secrets import SECRET_FIFO_DIR, SECRET_INJECT_SHELL, partition_secret_env WORKSPACE_MOUNT = "workspace" VOLUME_MOUNT = "volume" -MOUNT_WORDS = (WORKSPACE_MOUNT, SOCKET_MOUNT, VOLUME_MOUNT) +MOUNT_WORDS = (WORKSPACE_MOUNT, SOCKET_MOUNT, VOLUME_MOUNT, TMUX_MOUNT) TARGETED_MOUNT_WORDS = (WORKSPACE_MOUNT, VOLUME_MOUNT) DEFAULT_WORKSPACE_MOUNT_TARGET = "/workspace" DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock" +# Fixed, so validation never depends on the host; beside, not under, the secret tmpfs. +TMUX_MOUNT_TARGET = "/run/swarmforge-tmux" # An allowlist, so an unvetted mount option cannot reach the daemon. MOUNT_MODES = ("ro", "rw") @@ -162,7 +164,8 @@ def reserved_mount_targets(defn, socket_path=DEFAULT_DOCKER_SOCKET): Derived from the definition, since each only exists for the tongs that ask for it: the secret-delivery tmpfs (and the shell whose wrapper creates and reads the FIFO on it) come with secret references, the docker socket with a - `docker-socket` mount. `mount_target_error` + `docker-socket` mount, the tmux socket directory with a `tmux-socket` mount. + `mount_target_error` compares destinations against this map, as written -- a target that reaches one of these only through a symlink inside the image is not something it can see. """ @@ -173,6 +176,8 @@ def reserved_mount_targets(defn, socket_path=DEFAULT_DOCKER_SOCKET): paths[SECRET_INJECT_SHELL] = "the shell the secret wrapper execs" if mounts_word(defn, SOCKET_MOUNT): paths[socket_path] = "where the docker socket is mounted" + if mounts_word(defn, TMUX_MOUNT): + paths[TMUX_MOUNT_TARGET] = "where the tmux socket directory is mounted" return paths @@ -181,8 +186,10 @@ def mount_destination(word, target, socket_path=DEFAULT_DOCKER_SOCKET): The declared target when there is one, otherwise the word's default: /workspace for the workspace, its own host path for the socket (that is where a docker - client looks for it); a volume has none. Raises `ValueError` for a targetless - volume and any other word, so a new word cannot inherit the socket's default. + client looks for it), and `TMUX_MOUNT_TARGET` for the tmux socket directory + (the tong's `TMUX` names the socket there); a volume has none. Raises + `ValueError` for a targetless volume and any other word, so a new word + cannot inherit the socket's default. """ if word == WORKSPACE_MOUNT: return normalize_mount_target(target or DEFAULT_WORKSPACE_MOUNT_TARGET) @@ -190,6 +197,8 @@ def mount_destination(word, target, socket_path=DEFAULT_DOCKER_SOCKET): return normalize_mount_target(socket_path) if word == VOLUME_MOUNT and target is not None: return normalize_mount_target(target) + if word == TMUX_MOUNT: + return TMUX_MOUNT_TARGET raise ValueError("mount %r has no destination" % (word,)) @@ -209,7 +218,7 @@ def mount_target_error(mount, word, target, destination, reserved): if target is not None: return ("mount %r: only the %s mounts take a target path" % (mount, " and ".join(repr(w) for w in TARGETED_MOUNT_WORDS))) - # The socket lands on its own reserved path by construction. + # Each socket lands on its own reserved path by construction. return None for path, why in sorted(reserved.items()): if _targets_overlap(destination, path): @@ -217,6 +226,20 @@ def mount_target_error(mount, word, target, destination, reserved): return None +def mount_mode_error(mount, word, mode): + """Why a mount's access mode is refused, or None if it is fine. + + `tmux-socket` is read-only whatever it declares. A tmux client only needs + `connect()` on the socket, which a read-only bind allows; a writable bind of + the socket directory would let the tong replace the socket the host's own + tmux clients dial. Validation and argv assembly both ask this, like + `mount_target_error`, so both refuse `tmux-socket:rw` with one message. + """ + if word == TMUX_MOUNT and mode == "rw": + return "mount %r: the '%s' mount is always read-only" % (mount, TMUX_MOUNT) + return None + + def overlapping_mount_error(mount, destination, volume, placed): """Why a mount collides with one already placed, or None if it is clear. @@ -269,7 +292,8 @@ def volume_scope(layer, org_tongs_dir=None, workspace=None): def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, - tong_name=None, volume_scope=None, extra_mount_specs=()): + tong_name=None, volume_scope=None, extra_mount_specs=(), + tmux_socket_dir=None): """Concrete docker `-v` specs for a tong's `mounts:` magic words. Returns the list of `-v` *values* (the orchestrator pairs each with a `-v` @@ -280,12 +304,16 @@ def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, `tong_volume_name(tong_name, name, volume_scope)`, which docker creates on first use and the launcher never removes. A volume may not overlap one of `extra_mount_specs` (see `tong_run_argv`), or docker would leave their - mountpoints inside it. + mountpoints inside it. `tmux-socket[:ro]` bind-mounts `tmux_socket_dir` -- + the host directory holding the launcher's tmux socket, from + `resolve_tmux_socket` -- read-only at `TMUX_MOUNT_TARGET`. The directory + rather than the socket, because tmux deletes and recreates the socket when + its server restarts. Raises `ValueError` for a non-string entry, a malformed mount, an unusable or - colliding destination, a `workspace` mount when no workspace path is known, or - a `volume` mount without a `tong_name` and `volume_scope` to name it by -- a - definition never names a raw host path, so anything else is a mistake that - should stop the launch. + colliding destination, a refused access mode, a `workspace` or `tmux-socket` + mount when no host source for it is known, or a `volume` mount without a + `tong_name` and `volume_scope` to name it by -- a definition never names a raw + host path, so anything else is a mistake that should stop the launch. """ specs = [] reserved = reserved_mount_targets(defn, socket_path) @@ -296,6 +324,7 @@ def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, word, volume, target, mode = parse_mount(mount) destination = mount_destination(word, target, socket_path) reason = (mount_target_error(mount, word, target, destination, reserved) + or mount_mode_error(mount, word, mode) or overlapping_mount_error(mount, destination, volume, placed)) if reason: raise ValueError(reason) @@ -317,6 +346,12 @@ def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, raise ValueError( "mount %r: %s overlaps %s, which is mounted alongside the " "workspace" % (mount, destination, extra_destination)) + elif word == TMUX_MOUNT: + if not tmux_socket_dir: + raise ValueError( + "mount '%s' requested but no tmux socket directory is known" % TMUX_MOUNT) + source = tmux_socket_dir + mode = "ro" else: # Unreachable via `parse_mount`; a new word must not inherit the socket bind. raise ValueError("mount %r has no docker spec" % (mount,)) diff --git a/swarmforge/tongs/validate.py b/swarmforge/tongs/validate.py index 4107d75..de55b6f 100644 --- a/swarmforge/tongs/validate.py +++ b/swarmforge/tongs/validate.py @@ -22,6 +22,7 @@ ) from .mounts import ( mount_destination, + mount_mode_error, mount_target_error, overlapping_mount_error, parse_mount, @@ -156,12 +157,13 @@ def err(msg): err("mount entries must be strings, got %r" % (mount,)) continue try: - word, volume, target, _ = parse_mount(mount) + word, volume, target, mode = parse_mount(mount) destination = mount_destination(word, target) except ValueError as exc: err(str(exc)) continue - reason = mount_target_error(mount, word, target, destination, reserved) + reason = (mount_target_error(mount, word, target, destination, reserved) + or mount_mode_error(mount, word, mode)) if reason is None: reason = overlapping_mount_error(mount, destination, volume, placed) if reason: diff --git a/tests/test_tongs_mounts.py b/tests/test_tongs_mounts.py index 1ca93fc..6d51102 100644 --- a/tests/test_tongs_mounts.py +++ b/tests/test_tongs_mounts.py @@ -120,6 +120,35 @@ def test_reserved_targets_follow_the_definitions_wiring(self): ["/run/d.sock"], ) + def test_tmux_socket_reserved_only_with_the_mount(self): + self.assertEqual( + tongs.reserved_mount_targets({"mounts": ["tmux-socket"]}), + {tongs.TMUX_MOUNT_TARGET: "where the tmux socket directory is mounted"}, + ) + self.assertNotIn( + tongs.TMUX_MOUNT_TARGET, + tongs.reserved_mount_targets({"mounts": ["workspace", "docker-socket"]}), + ) + + def test_tmux_socket_destination_is_fixed_beside_the_secret_tmpfs(self): + self.assertEqual(tongs.mount_destination("tmux-socket", None), "/run/swarmforge-tmux") + self.assertEqual(tongs.TMUX_MOUNT_TARGET, "/run/swarmforge-tmux") + # Under the secret tmpfs it would be buried by it. + self.assertIsNone(tongs.overlapping_mount_error( + "tmux-socket", tongs.TMUX_MOUNT_TARGET, None, + [("secrets", tongs.SECRET_FIFO_DIR, None)], + )) + + def test_tmux_socket_accepts_ro_and_refuses_rw(self): + self.assertEqual(tongs.parse_mount("tmux-socket"), ("tmux-socket", None, None, None)) + self.assertIsNone(tongs.mount_mode_error("tmux-socket", "tmux-socket", None)) + self.assertIsNone(tongs.mount_mode_error("tmux-socket:ro", "tmux-socket", "ro")) + self.assertIn( + "always read-only", + tongs.mount_mode_error("tmux-socket:rw", "tmux-socket", "rw"), + ) + self.assertIsNone(tongs.mount_mode_error("workspace:rw", "workspace", "rw")) + def test_mount_destination_defaults_per_word(self): self.assertEqual(tongs.mount_destination("workspace", None), "/workspace") self.assertEqual(tongs.mount_destination("workspace", "//code/"), "/code") @@ -353,6 +382,37 @@ def test_mount_specs_volume_overlapping_a_ride_along_mount_raises(self): 2, ) + def test_mount_specs_tmux_socket_binds_the_directory_read_only(self): + for mount in ("tmux-socket", "tmux-socket:ro"): + self.assertEqual( + tongs.tong_mount_specs( + {"mounts": [mount]}, "/ws", tmux_socket_dir="/tmp/tmux-1000"), + ["/tmp/tmux-1000:/run/swarmforge-tmux:ro"], + mount, + ) + + def test_mount_specs_tmux_socket_rw_raises(self): + with self.assertRaisesRegex(ValueError, "always read-only"): + tongs.tong_mount_specs( + {"mounts": ["tmux-socket:rw"]}, "/ws", tmux_socket_dir="/tmp/tmux-1000") + + def test_mount_specs_tmux_socket_target_raises(self): + with self.assertRaisesRegex(ValueError, "only the 'workspace' and 'volume' mounts take a target"): + tongs.tong_mount_specs( + {"mounts": ["tmux-socket:/tmux"]}, "/ws", tmux_socket_dir="/tmp/tmux-1000") + + def test_mount_specs_tmux_socket_without_host_source_raises(self): + with self.assertRaisesRegex(ValueError, "no tmux socket directory is known"): + tongs.tong_mount_specs({"mounts": ["tmux-socket"]}, "/ws") + + def test_mount_specs_workspace_overlapping_the_tmux_directory_raises(self): + for target in ("/run", "/run/swarmforge-tmux", "/run/swarmforge-tmux/sub"): + with self.assertRaisesRegex(ValueError, "overlaps /run/swarmforge-tmux"): + tongs.tong_mount_specs( + {"mounts": ["workspace:" + target, "tmux-socket"]}, "/ws", + tmux_socket_dir="/tmp/tmux-1000", + ) + def test_mount_specs_no_mounts_is_empty(self): self.assertEqual(tongs.tong_mount_specs({}, "/ws"), []) diff --git a/tests/test_tongs_validate.py b/tests/test_tongs_validate.py index 1b3dc67..62e21dd 100644 --- a/tests/test_tongs_validate.py +++ b/tests/test_tongs_validate.py @@ -214,6 +214,23 @@ def test_one_volume_mounted_twice_rejected(self): "t", self._base(mounts=["volume:models:/a", "volume:models:/b:ro"])) self.assertTrue(any("already mounted" in e for e in errors), errors) + def test_tmux_socket_mount_accepted_read_only(self): + for mount in ("tmux-socket", "tmux-socket:ro"): + self.assertEqual(tongs.validate_tong("t", self._base(mounts=[mount])), [], mount) + + def test_tmux_socket_mount_rw_rejected(self): + errors = tongs.validate_tong("t", self._base(mounts=["tmux-socket:rw"])) + self.assertTrue(any("always read-only" in e for e in errors), errors) + + def test_tmux_socket_mount_target_rejected(self): + errors = tongs.validate_tong("t", self._base(mounts=["tmux-socket:/tmux"])) + self.assertTrue(any("only the 'workspace' and 'volume' mounts take a target path" in e for e in errors)) + + def test_target_overlapping_the_tmux_mount_rejected(self): + errors = tongs.validate_tong( + "t", self._base(mounts=["workspace:/run", "tmux-socket"])) + self.assertTrue(any("overlaps /run/swarmforge-tmux" in e for e in errors), errors) + def test_extra_aliases_accepted_on_network_facing_kinds(self): # A client matching a certificate CN dials the dotted name, not the canonical alias. defn = self._base(interface={ From 8417a5a828f3322faa399ab18b6e05e8e0e7addf Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sun, 4 Oct 2026 11:37:31 -0500 Subject: [PATCH 02/10] Point a tmux-socket tong at the launcher's tmux server The tong gets the server through TMUX, re-pointed from the launcher's socket path to the same socket under /run/swarmforge-tmux, so a stock tmux client reaches it with no flags. TMUX_PANE gives it the caller's pane as tmux's default target, from which it can work out the window without the launcher running any tmux command, and SWARMFORGE_SESSION_HANDLE names the session it serves. A session tmux-socket tong also gets SWARMFORGE_WORKSPACE_HOST_PATH, as a session docker-socket broker does. These describe the host and the session, which a definition cannot know, so they replace a plain value its env: sets for those names, and a name the launcher has no value for is dropped rather than left to the definition. A secret reference on one of them would be exported by the FIFO wrapper after docker's -e values and win, so validation refuses it. A tong without the mount gets none of them, and the argv builder refuses the mount outright on anything but a session tong, so no caller can hand a shared container a tmux server. $TMUX is parsed strictly and the launch stops on anything unexpected. The socket must sit directly in tmux's own per-user directory (tmux-): the mount binds the socket's parent, so a socket at an arbitrary -S path could otherwise hand the tong a home directory. The path also may not carry a ':' (docker's -v separator), a ',' (tmux reads the path up to the first one), or whitespace. --- swarmforge/tongs/__init__.py | 10 +++++ swarmforge/tongs/argv.py | 63 ++++++++++++++++++++++---- swarmforge/tongs/model.py | 3 ++ swarmforge/tongs/mounts.py | 87 +++++++++++++++++++++++++++++++++++- swarmforge/tongs/validate.py | 4 ++ tests/test_tongs_argv.py | 68 ++++++++++++++++++++++++++++ tests/test_tongs_mounts.py | 61 +++++++++++++++++++++++++ tests/test_tongs_validate.py | 20 +++++++++ 8 files changed, 307 insertions(+), 9 deletions(-) diff --git a/swarmforge/tongs/__init__.py b/swarmforge/tongs/__init__.py index c0c05b0..ec8d744 100644 --- a/swarmforge/tongs/__init__.py +++ b/swarmforge/tongs/__init__.py @@ -96,6 +96,7 @@ ORG, READINESS_MODES, REPO, + SESSION_HANDLE_ENV, SOCKET_MOUNT, TMUX_MOUNT, TRANSPORTS, @@ -117,12 +118,14 @@ MOUNT_MODES, MOUNT_WORDS, ORG_VOLUME_SCOPE, + TMUX_LAUNCHER_ENV, TMUX_MOUNT_TARGET, VOLUME_DIGEST_LENGTH, VOLUME_MOUNT, VOLUME_NAME_MAX_LENGTH, WORKSPACE_MOUNT, WORKSPACE_VOLUME_SCOPE, + check_tmux_pane, mount_destination, mount_mode_error, mount_target_error, @@ -131,6 +134,8 @@ overlapping_mount_error, parse_mount, reserved_mount_targets, + resolve_tmux_socket, + tmux_secret_env_error, tong_mount_specs, tong_volume_name, volume_scope, @@ -212,6 +217,7 @@ "ORG", "READINESS_MODES", "REPO", + "SESSION_HANDLE_ENV", "SOCKET_MOUNT", "TMUX_MOUNT", "TRANSPORTS", @@ -231,12 +237,14 @@ "MOUNT_MODES", "MOUNT_WORDS", "ORG_VOLUME_SCOPE", + "TMUX_LAUNCHER_ENV", "TMUX_MOUNT_TARGET", "VOLUME_DIGEST_LENGTH", "VOLUME_MOUNT", "VOLUME_NAME_MAX_LENGTH", "WORKSPACE_MOUNT", "WORKSPACE_VOLUME_SCOPE", + "check_tmux_pane", "mount_destination", "mount_mode_error", "mount_target_error", @@ -245,6 +253,8 @@ "overlapping_mount_error", "parse_mount", "reserved_mount_targets", + "resolve_tmux_socket", + "tmux_secret_env_error", "tong_mount_specs", "tong_volume_name", "volume_scope", diff --git a/swarmforge/tongs/argv.py b/swarmforge/tongs/argv.py index 019b75c..ed1d913 100644 --- a/swarmforge/tongs/argv.py +++ b/swarmforge/tongs/argv.py @@ -15,11 +15,19 @@ from .model import ( LABEL_CONFIG_HASH, LABEL_TONG_NAME, + SESSION_HANDLE_ENV, SOCKET_MOUNT, + TMUX_MOUNT, WORKSPACE_HOST_ENV, parse_gpus, ) -from .mounts import DEFAULT_DOCKER_SOCKET, mounts_word, tong_mount_specs +from .mounts import ( + DEFAULT_DOCKER_SOCKET, + check_tmux_pane, + mounts_word, + resolve_tmux_socket, + tong_mount_specs, +) from .secrets import SECRET_FIFO_TMPFS, declared_run_override @@ -132,6 +140,9 @@ def tong_run_argv( command=None, extra_mount_specs=None, volume_scope=None, + tmux=None, + tmux_pane=None, + session_handle=None, ): """Full `docker run -d` argv that starts one tong container. @@ -145,11 +156,26 @@ def tong_run_argv( non-secret values from `plan_tong_secrets`) is passed as `-e` in sorted order; resolved secret values never appear here -- they arrive over the in-container FIFO instead. - A socket-holding (broker) `session` tong additionally receives - `SWARMFORGE_WORKSPACE_HOST_PATH` so it can bind-mount the session workspace into - the workers it spawns; a tong that sets that name itself keeps its own value. A - `shared` socket tong does not get it -- its container is reused across sessions, - so a per-session workspace path would be stale (and a leak) for later ones. + A socket-holding `session` tong -- a broker that mounts `docker-socket`, or a + tong that mounts `tmux-socket` -- additionally receives + `SWARMFORGE_WORKSPACE_HOST_PATH` so the workers it spawns can be given the + session workspace; a tong that sets that name itself keeps its own value. A + `shared` socket tong does not get it -- its container is reused across + sessions, so a per-session workspace path would be stale (and a leak) for + later ones. + + A `tmux-socket` tong is pointed at the launcher's tmux server: `tmux` (the + launcher's raw `$TMUX`) becomes the mount's host source and the tong's `TMUX` + (see `resolve_tmux_socket`), `tmux_pane` its `TMUX_PANE`, and `session_handle` + its `SWARMFORGE_SESSION_HANDLE`. These describe the host and the session, which + a definition cannot know, so they replace a plain value its `env:` sets for + those names -- and a name the launcher has no value for is dropped rather than + left to the definition. A secret reference on one of them would arrive over + the FIFO after these and win, so validation refuses it (see + `tmux_secret_env_error`). Raises `ValueError` when the definition mounts + `tmux-socket` and is not a `session` tong, `tmux` is empty or malformed, or + `tmux_pane` is malformed. A tong without the mount gets none of them, whatever + is passed. When the tong has secret env, the launcher passes `secret_channel=True` (which mounts a tmpfs at `SECRET_FIFO_DIR` for the wrapper's FIFO), `entrypoint` @@ -185,13 +211,34 @@ def tong_run_argv( if secret_channel: argv += ["--tmpfs", SECRET_FIFO_TMPFS] effective_env = dict(env or {}) - if workspace and mounts_word(defn, SOCKET_MOUNT) and defn.get("lifecycle") == "session": + tmux_socket_dir = None + if mounts_word(defn, TMUX_MOUNT): + if defn.get("lifecycle") != "session": + raise ValueError( + "mount '%s' is only allowed on a 'session' tong; a shared container " + "would carry one session's tmux server into the next" % TMUX_MOUNT) + if not tmux: + raise ValueError( + "mount '%s' requested but the launcher is not running inside tmux " + "($TMUX is unset)" % TMUX_MOUNT) + tmux_socket_dir, _, effective_env["TMUX"] = resolve_tmux_socket(tmux) + launcher_env = { + "TMUX_PANE": check_tmux_pane(tmux_pane) if tmux_pane else None, + SESSION_HANDLE_ENV: session_handle, + } + for key, value in launcher_env.items(): + effective_env.pop(key, None) + if value: + effective_env[key] = value + has_socket = mounts_word(defn, SOCKET_MOUNT) or mounts_word(defn, TMUX_MOUNT) + if workspace and has_socket and defn.get("lifecycle") == "session": effective_env.setdefault(WORKSPACE_HOST_ENV, workspace) for key in sorted(effective_env): argv += ["-e", "%s=%s" % (key, effective_env[key])] for spec in tong_mount_specs(defn, workspace, socket_path=socket_path, tong_name=name, volume_scope=volume_scope, - extra_mount_specs=extra_mount_specs or ()): + extra_mount_specs=extra_mount_specs or (), + tmux_socket_dir=tmux_socket_dir): argv += ["-v", spec] for spec in extra_mount_specs or []: argv += ["-v", spec] diff --git a/swarmforge/tongs/model.py b/swarmforge/tongs/model.py index 7c45207..26c6184 100644 --- a/swarmforge/tongs/model.py +++ b/swarmforge/tongs/model.py @@ -38,6 +38,9 @@ # A container cannot re-share a bind mount, so a broker needs the workspace's host path. WORKSPACE_HOST_ENV = "SWARMFORGE_WORKSPACE_HOST_PATH" +# A tmux-socket tong's handle on the session it serves: the anvil's `--name`. +SESSION_HANDLE_ENV = "SWARMFORGE_SESSION_HANDLE" + def workspace_key(workspace): """The key a checkout's approvals and volumes are stored under: its path, symlinks resolved.""" diff --git a/swarmforge/tongs/mounts.py b/swarmforge/tongs/mounts.py index ad1e7c9..9cc2955 100644 --- a/swarmforge/tongs/mounts.py +++ b/swarmforge/tongs/mounts.py @@ -14,7 +14,16 @@ from swarmforge.names import canonical_path, name_hint -from .model import ORG, REPO, SOCKET_MOUNT, TMUX_MOUNT, USER, WORKSPACE, workspace_key +from .model import ( + ORG, + REPO, + SESSION_HANDLE_ENV, + SOCKET_MOUNT, + TMUX_MOUNT, + USER, + WORKSPACE, + workspace_key, +) from .secrets import SECRET_FIFO_DIR, SECRET_INJECT_SHELL, partition_secret_env @@ -30,6 +39,14 @@ # An allowlist, so an unvetted mount option cannot reach the daemon. MOUNT_MODES = ("ro", "rw") +# What tmux creates (mode 0700) under $TMUX_TMPDIR or /tmp for each user's sockets. +_TMUX_SOCKET_DIR_RE = re.compile(r"tmux-[0-9]+") +_DECIMAL_RE = re.compile(r"[0-9]+") +_TMUX_PANE_RE = re.compile(r"%[0-9]+") + +# The env the launcher sets on a tmux-socket tong; a definition never supplies it. +TMUX_LAUNCHER_ENV = ("TMUX", "TMUX_PANE", SESSION_HANDLE_ENV) + VOLUME_NAME_PREFIX = "swarmforge-volume" # 128 bits, so no definition can search its way onto another scope's volume. @@ -363,6 +380,74 @@ def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, return specs +def resolve_tmux_socket(tmux): + """The host directory a `tmux-socket` mount binds, and the tong's own `TMUX`. + + `tmux` is the launcher's raw `$TMUX`, `,,`, + and this is the one place it is parsed. Returns + `(host_dir, socket_path, container_tmux)`: the socket's parent directory, the + socket's host path, and the same value re-pointed at the socket's name under + `TMUX_MOUNT_TARGET`, so a stock tmux client in the tong reaches the host + server with no flags. + + Raises `ValueError` unless the value has exactly three comma-separated fields, + the pid and session id are decimal, and the socket path is a normalized + absolute path with no `:` (docker's `-v` separator), `,` (tmux reads the path + up to the first one), or whitespace. The socket must also sit directly in + tmux's own per-user directory (`tmux-`): binding a `-S` socket's parent + could hand the tong an arbitrary host directory, such as a home directory. + """ + fields = tmux.rsplit(",", 2) if isinstance(tmux, str) else [] + if len(fields) != 3: + raise ValueError( + "$TMUX %r is not ',,'" % (tmux,)) + path, pid, session = fields + if not (_DECIMAL_RE.fullmatch(pid) and _DECIMAL_RE.fullmatch(session)): + raise ValueError( + "$TMUX %r: the server pid and session id must be decimal numbers" % (tmux,)) + if not path.startswith("/") or normalize_mount_target(path) != path: + raise ValueError( + "$TMUX %r: socket path %r is not a normalized absolute path" % (tmux, path)) + if any(char in ":," or char.isspace() for char in path): + raise ValueError( + "$TMUX %r: socket path %r contains ':', ',' or whitespace" % (tmux, path)) + host_dir, socket_name = posixpath.split(path) + if not _TMUX_SOCKET_DIR_RE.fullmatch(posixpath.basename(host_dir)): + raise ValueError( + "$TMUX %r: socket %r is not in tmux's default per-user socket directory " + "(tmux- under $TMUX_TMPDIR or /tmp); a socket at a custom -S path " + "is refused, since its whole directory would be mounted" % (tmux, path)) + return (host_dir, path, + "%s/%s,%s,%s" % (TMUX_MOUNT_TARGET, socket_name, pid, session)) + + +def check_tmux_pane(pane): + """The launcher's `$TMUX_PANE` (`%`), or `ValueError` if it is not one.""" + if not isinstance(pane, str) or not _TMUX_PANE_RE.fullmatch(pane): + raise ValueError("$TMUX_PANE %r is not a tmux pane id ('%%')" % (pane,)) + return pane + + +def tmux_secret_env_error(defn): + """Why a `tmux-socket` definition's secret env is refused, or None if it is fine. + + The launcher owns `TMUX_LAUNCHER_ENV` on a tong that mounts `tmux-socket` and + replaces a plain value the definition gives one of them, but a secret value is + exported by the FIFO wrapper after docker's `-e` values and so would win. + Refusing a secret reference there keeps the launcher's values the ones the + tong runs with. + """ + env = defn.get("env") + if not mounts_word(defn, TMUX_MOUNT) or not isinstance(env, dict): + return None + owned = [name for name in TMUX_LAUNCHER_ENV if name in partition_secret_env(env)[1]] + if not owned: + return None + return ("env %s: the launcher sets %s on a tong that mounts '%s', so the " + "definition may not make them secret references" + % (", ".join(owned), ", ".join(TMUX_LAUNCHER_ENV), TMUX_MOUNT)) + + def workspace_mount_placements(defn, socket_path=DEFAULT_DOCKER_SOCKET): """Where a tong's `workspace` mounts land: `[(destination, mode)]`. diff --git a/swarmforge/tongs/validate.py b/swarmforge/tongs/validate.py index de55b6f..c81a07a 100644 --- a/swarmforge/tongs/validate.py +++ b/swarmforge/tongs/validate.py @@ -27,6 +27,7 @@ overlapping_mount_error, parse_mount, reserved_mount_targets, + tmux_secret_env_error, ) from .secrets import ENV_NAME_RE, partition_secret_env @@ -135,6 +136,9 @@ def err(msg): for secret_name in sorted(secret): if not ENV_NAME_RE.match(secret_name): err("invalid secret env name %r (must be a valid identifier)" % secret_name) + owned_secret = tmux_secret_env_error(defn) + if owned_secret: + err(owned_secret) for argvish in ("entrypoint", "command"): value = defn.get(argvish) diff --git a/tests/test_tongs_argv.py b/tests/test_tongs_argv.py index d9aa42e..5ba07a0 100644 --- a/tests/test_tongs_argv.py +++ b/tests/test_tongs_argv.py @@ -352,6 +352,74 @@ def test_run_argv_omits_workspace_host_path_for_shared_socket_tong(self): ) self.assertNotIn("SWARMFORGE_WORKSPACE_HOST_PATH", " ".join(argv)) + def _tmux_argv(self, env=None, mounts=("tmux-socket",), lifecycle="session", + tmux="/tmp/tmux-1000/default,4242,3", tmux_pane="%7", + session_handle="claude-proj"): + defn = def_of(NONE_TONG) + defn["mounts"] = list(mounts) + defn["lifecycle"] = lifecycle + return tongs.tong_run_argv( + "spawner", defn, container_name="c", network="n", alias="spawner", + env=env, workspace="/host/ws", + tmux=tmux, tmux_pane=tmux_pane, session_handle=session_handle, + ) + + def test_run_argv_tmux_tong_gets_the_host_server_and_session(self): + argv = self._tmux_argv() + self.assertIn("TMUX=/run/swarmforge-tmux/default,4242,3", argv) + self.assertIn("TMUX_PANE=%7", argv) + self.assertIn("SWARMFORGE_SESSION_HANDLE=claude-proj", argv) + self.assertIn("SWARMFORGE_WORKSPACE_HOST_PATH=/host/ws", argv) + mounted = [argv[i + 1] for i, part in enumerate(argv) if part == "-v"] + self.assertEqual(mounted, ["/tmp/tmux-1000:/run/swarmforge-tmux:ro"]) + + def test_run_argv_tmux_values_override_the_definitions_env(self): + argv = self._tmux_argv(env={ + "TMUX": "/elsewhere,1,1", "TMUX_PANE": "%99", + "SWARMFORGE_SESSION_HANDLE": "other", "KEEP": "1", + }) + self.assertIn("TMUX=/run/swarmforge-tmux/default,4242,3", argv) + self.assertIn("TMUX_PANE=%7", argv) + self.assertIn("SWARMFORGE_SESSION_HANDLE=claude-proj", argv) + self.assertIn("KEEP=1", argv) + joined = " ".join(argv) + for stale in ("/elsewhere", "%99", "=other"): + self.assertNotIn(stale, joined) + + def test_run_argv_tmux_names_the_launcher_lacks_are_dropped(self): + # A definition cannot know the caller's pane, so it never supplies one. + argv = self._tmux_argv(env={"TMUX_PANE": "%99", "SWARMFORGE_SESSION_HANDLE": "x"}, + tmux_pane=None, session_handle=None) + joined = " ".join(argv) + self.assertNotIn("TMUX_PANE", joined) + self.assertNotIn("SWARMFORGE_SESSION_HANDLE", joined) + self.assertIn("TMUX=/run/swarmforge-tmux/default,4242,3", argv) + + def test_run_argv_tmux_mount_without_tmux_raises(self): + for tmux in (None, ""): + with self.assertRaisesRegex(ValueError, "not running inside tmux"): + self._tmux_argv(tmux=tmux) + + def test_run_argv_tmux_malformed_values_raise(self): + with self.assertRaisesRegex(ValueError, "default per-user socket directory"): + self._tmux_argv(tmux="/home/me/s,1,0") + with self.assertRaisesRegex(ValueError, "pane id"): + self._tmux_argv(tmux_pane="7") + + def test_run_argv_without_tmux_mount_injects_nothing(self): + for mounts in ((), ("docker-socket",), ("workspace:ro",)): + argv = self._tmux_argv(mounts=mounts) + joined = " ".join(argv) + self.assertNotIn("TMUX", joined, mounts) + self.assertNotIn("SWARMFORGE_SESSION_HANDLE", joined, mounts) + self.assertNotIn("swarmforge-tmux", joined, mounts) + + def test_run_argv_tmux_mount_on_a_shared_tong_raises(self): + # Checked first, so no caller can mount a tmux server into a shared container. + for tmux in ("/tmp/tmux-1000/default,4242,3", None): + with self.assertRaisesRegex(ValueError, "only allowed on a 'session' tong"): + self._tmux_argv(lifecycle="shared", tmux=tmux) + def test_anvil_option_value_reads_name_and_network(self): self.assertEqual(tongs.anvil_option_value(ANVIL_ARGV, "--name"), "claude-proj") self.assertEqual(tongs.anvil_option_value(ANVIL_ARGV, "--network"), "opencode-net") diff --git a/tests/test_tongs_mounts.py b/tests/test_tongs_mounts.py index 6d51102..7326ceb 100644 --- a/tests/test_tongs_mounts.py +++ b/tests/test_tongs_mounts.py @@ -462,5 +462,66 @@ def test_mount_specs_non_string_raises(self): tongs.tong_mount_specs({"mounts": [123]}, "/ws") +class TmuxSocketTests(unittest.TestCase): + """Resolving the launcher's `$TMUX` into the directory to bind and the tong's `TMUX`.""" + + def test_resolves_the_directory_and_rewrites_the_value(self): + self.assertEqual( + tongs.resolve_tmux_socket("/tmp/tmux-1000/default,4242,3"), + ("/tmp/tmux-1000", "/tmp/tmux-1000/default", + "/run/swarmforge-tmux/default,4242,3"), + ) + + def test_honors_a_tmux_tmpdir_and_a_named_server(self): + self.assertEqual( + tongs.resolve_tmux_socket("/run/user/1000/tmux-1000/work,1,0"), + ("/run/user/1000/tmux-1000", "/run/user/1000/tmux-1000/work", + "/run/swarmforge-tmux/work,1,0"), + ) + + def test_relative_or_unnormalized_path_refused(self): + for value in ("tmux-1000/default,1,0", "/tmp/tmux-1000/../tmux-1000/default,1,0", + "/tmp/tmux-1000/,1,0", "//tmux-1000/default,1,0"): + with self.assertRaisesRegex(ValueError, "normalized absolute path"): + tongs.resolve_tmux_socket(value) + + def test_wrong_field_count_refused(self): + for value in ("", "/tmp/tmux-1000/default", "/tmp/tmux-1000/default,1"): + with self.assertRaisesRegex(ValueError, ",,"): + tongs.resolve_tmux_socket(value) + with self.assertRaises(ValueError): + tongs.resolve_tmux_socket(None) + + def test_extra_field_refused(self): + # Split from the right, an extra field ends up in the path as a comma. + with self.assertRaisesRegex(ValueError, "contains"): + tongs.resolve_tmux_socket("/tmp/tmux-1000/default,9,1,0") + + def test_non_decimal_pid_or_session_refused(self): + for value in ("/tmp/tmux-1000/default,abc,0", "/tmp/tmux-1000/default,1,$0", + "/tmp/tmux-1000/default,,0", "/tmp/tmux-1000/default,1, 0"): + with self.assertRaisesRegex(ValueError, "decimal"): + tongs.resolve_tmux_socket(value) + + def test_colon_or_whitespace_in_path_refused(self): + for value in ("/tmp/tmux-1000/a:b,1,0", "/tmp/tmux-1000/a b,1,0", + "/tmp/tmux-1000/a\tb,1,0"): + with self.assertRaisesRegex(ValueError, "contains"): + tongs.resolve_tmux_socket(value) + + def test_socket_outside_tmuxs_own_directory_refused(self): + # Binding a -S socket's parent could mount an arbitrary host directory. + for value in ("/home/me/my.sock,1,0", "/tmp/default,1,0", "/default,1,0", + "/tmp/tmux-me/default,1,0", "/tmp/tmux-1000/sub/default,1,0"): + with self.assertRaisesRegex(ValueError, "default per-user socket directory"): + tongs.resolve_tmux_socket(value) + + def test_pane_id_checked(self): + self.assertEqual(tongs.check_tmux_pane("%12"), "%12") + for pane in ("12", "%", "%1a", " %1", "%1\n", None): + with self.assertRaisesRegex(ValueError, "pane id"): + tongs.check_tmux_pane(pane) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_tongs_validate.py b/tests/test_tongs_validate.py index 62e21dd..6af7c48 100644 --- a/tests/test_tongs_validate.py +++ b/tests/test_tongs_validate.py @@ -226,6 +226,26 @@ def test_tmux_socket_mount_target_rejected(self): errors = tongs.validate_tong("t", self._base(mounts=["tmux-socket:/tmux"])) self.assertTrue(any("only the 'workspace' and 'volume' mounts take a target path" in e for e in errors)) + def test_tmux_launcher_env_as_a_secret_rejected(self): + # A FIFO-delivered secret is exported after `-e`, so it would beat the launcher. + for name in ("TMUX", "TMUX_PANE", "SWARMFORGE_SESSION_HANDLE"): + defn = self._base(mounts=["tmux-socket"], env={name: "${secret:op:r}"}) + errors = tongs.validate_tong("t", defn) + self.assertTrue( + any("the launcher sets" in e and name in e for e in errors), (name, errors)) + + def test_tmux_launcher_env_plain_value_accepted(self): + defn = self._base(mounts=["tmux-socket"], env={"TMUX_PANE": "%1"}) + self.assertEqual(tongs.validate_tong("t", defn), []) + + def test_tmux_launcher_env_secret_accepted_without_the_mount(self): + defn = self._base(env={"TMUX": "${secret:op:r}"}) + self.assertEqual(tongs.validate_tong("t", defn), []) + + def test_unrelated_secret_on_a_tmux_tong_accepted(self): + defn = self._base(mounts=["tmux-socket"], env={"TOKEN": "${secret:op:r}"}) + self.assertEqual(tongs.validate_tong("t", defn), []) + def test_target_overlapping_the_tmux_mount_rejected(self): errors = tongs.validate_tong( "t", self._base(mounts=["workspace:/run", "tmux-socket"])) From 49cab66f34dfd57198398b073385b3a6680694de Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sun, 4 Oct 2026 11:39:19 -0500 Subject: [PATCH 03/10] Hand session tongs the launcher's tmux server The launcher reads its own TMUX and TMUX_PANE into its options and passes them, with the session handle, to each session tong it starts. The environment is read only there, in the launcher's entry layer; the pure core takes the values as arguments. A launch whose tongs mount tmux-socket stops before anything starts when the launcher is not in a tmux pane, when TMUX or TMUX_PANE is malformed or the socket is outside tmux's default directory, or when a definition makes one of the names the launcher sets a secret reference, so a bad value never leaves a half-started session to tear down. A shared tong may not mount tmux-socket, for the reason it may not mount the workspace: its container outlives the session and is reused by later ones, so it would carry one session's tmux server, and its pinning to that session's pane, into the next. Shared tongs are never handed the values even if one got that far. The approval prompt calls tmux socket access out on its own line, as it does the docker socket. The line says what the grant really is: the bind covers the whole socket directory, so every tmux server the user runs there, and through any of them any command on the host; the mount being read-only does not narrow that. --- swarmforge/anvil/__init__.py | 17 ++--- swarmforge/anvil/approval.py | 27 ++++--- swarmforge/anvil/cli.py | 15 ++-- swarmforge/anvil/orchestrate.py | 56 +++++++++++++-- swarmforge/tongs/approvals.py | 8 ++- tests/test_anvil_approval.py | 12 ++++ tests/test_anvil_cli.py | 33 +++++++++ tests/test_anvil_orchestrate.py | 122 +++++++++++++++++++++++++++++++- tests/test_tongs_approvals.py | 8 +++ 9 files changed, 266 insertions(+), 32 deletions(-) diff --git a/swarmforge/anvil/__init__.py b/swarmforge/anvil/__init__.py index f2ee447..c9b2c2c 100644 --- a/swarmforge/anvil/__init__.py +++ b/swarmforge/anvil/__init__.py @@ -52,8 +52,8 @@ handed to the tong as environment, so the launcher starts `shared` and `session` tongs reached over the network (`mcp`/`port`) or with no anvil-facing surface (`none`), with or without secrets. A `volume` interface, or a `shared` tong that -mounts the workspace, is refused with a clear message rather than started -half-wired. +mounts the workspace or the tmux socket, is refused with a clear message rather +than started half-wired. MCP config ---------- @@ -94,12 +94,13 @@ ------------------ The user, org, and Swarmforge-repo layers are installed deliberately and are trusted. The workspace is any repo you happened to clone, so a workspace-sourced -tong -- which may request secrets, host mounts, or the docker socket -- is gated: -before the anvil starts, the launcher prints the privilege summary and asks the -user to approve it. Approval is keyed by workspace path + tong name + a hash of -the merged definition (so any change re-prompts) and persists in the user-layer -store passed as `--approvals`. The scripted `--no-prompt` mode fails closed -(refusing the run) rather than auto-approving an unapproved tong. +tong -- which may request secrets, host mounts, the docker socket, or the +launcher's tmux server -- is gated: before the anvil starts, the launcher prints +the privilege summary and asks the user to approve it. Approval is keyed by +workspace path + tong name + a hash of the merged definition (so any change +re-prompts) and persists in the user-layer store passed as `--approvals`. The +scripted `--no-prompt` mode fails closed (refusing the run) rather than +auto-approving an unapproved tong. Passthrough invariant --------------------- diff --git a/swarmforge/anvil/approval.py b/swarmforge/anvil/approval.py index ba88ff5..a330d04 100644 --- a/swarmforge/anvil/approval.py +++ b/swarmforge/anvil/approval.py @@ -2,11 +2,11 @@ The user, org, and Swarmforge-repo layers are installed deliberately and are trusted. The workspace is any repo you happened to clone, so a workspace-sourced -tong -- which may request secrets, host mounts, the docker socket, or host -GPUs -- is gated: its privilege summary is rendered and the user is asked to -approve it before the anvil starts. Approval is keyed by workspace path, tong -name, and a hash of the merged definition, so any change re-prompts, and -persists in the user layer. +tong -- which may request secrets, host mounts, the docker socket, the +launcher's tmux server, or host GPUs -- is gated: its privilege summary is +rendered and the user is asked to approve it before the anvil starts. Approval +is keyed by workspace path, tong name, and a hash of the merged definition, so +any change re-prompts, and persists in the user layer. Deciding what a definition asks for and remembering the answer are the pure core's job; rendering the question and reading the reply are this module's. @@ -25,12 +25,13 @@ def render_privilege_summary(name, summary): """Human-readable block describing what a workspace tong requests. `summary` is the structured output of `tongs.privilege_summary`. Only the - privileges actually requested are shown, and docker-socket access -- the - broadest grant, since it is full control of the host's docker -- is always - called out explicitly so it cannot be approved unseen, as is a GPU request, - which grants the host's GPU devices. Every line passes through - `tongs.printable`, so a definition value cannot move the cursor, erase, or - forge a line of this block. + privileges actually requested are shown, and the two broadest grants -- + docker-socket access (full control of the host's docker) and tmux-socket + access (every tmux server in the user's socket directory, so any command on + the host) -- are always called out on their own lines so neither can be + approved unseen, as is a GPU request, which grants the host's GPU devices. + Every line passes through `tongs.printable`, so a definition value cannot + move the cursor, erase, or forge a line of this block. """ lines = ["Workspace tong %r requests approval:" % name] lines.append(" image: %s" % (summary.get("image") or "(none declared)")) @@ -56,6 +57,10 @@ def render_privilege_summary(name, summary): lines.append(" networks: %s" % ", ".join(str(n) for n in networks)) if summary.get("socket"): lines.append(" docker socket: full host docker control") + if summary.get("tmux"): + lines.append( + " tmux socket: every tmux server in your socket directory -- any " + "command on the host as you (read-only does not limit this)") if summary.get("gpus") is not None: lines.append(" gpus: %s (host GPU access)" % summary["gpus"]) return "\n".join(tongs.printable(line) for line in lines) diff --git a/swarmforge/anvil/cli.py b/swarmforge/anvil/cli.py index 640af6f..823c30d 100644 --- a/swarmforge/anvil/cli.py +++ b/swarmforge/anvil/cli.py @@ -45,10 +45,12 @@ } # `anvil_image` is what the readiness prober runs; `no_prompt` fails the gate closed. +# `tmux`/`tmux_pane` are the launcher's own `$TMUX`/`$TMUX_PANE`, None outside tmux. LauncherOptions = collections.namedtuple( "LauncherOptions", ["layer_dirs", "workspace", "approvals", "providers", "harness", "anvil_image", - "no_prompt"], + "no_prompt", "tmux", "tmux_pane"], + defaults=(None, None), ) @@ -56,14 +58,18 @@ class UsageError(ValueError): """Raised for malformed launcher arguments (reported, then exit 2).""" -def parse_args(argv): +def parse_args(argv, environ=None): """Split launcher options from the anvil command at the first ``--``. Returns ``(options, anvil_cmd)`` where ``options`` is a ``LauncherOptions`` (its ``layer_dirs`` ordered by canonical precedence, only the layers that - were given) and ``anvil_cmd`` is the argv after ``--``. Raises ``UsageError`` + were given) and ``anvil_cmd`` is the argv after ``--``. The options' ``tmux`` + and ``tmux_pane`` come from ``TMUX``/``TMUX_PANE`` in ``environ`` (the + process environment by default), unvalidated -- only a ``tmux-socket`` tong + uses them, and the orchestrator checks them then. Raises ``UsageError`` if the separator is missing, the command is empty, or an option is malformed. """ + environ = os.environ if environ is None else environ paths = {} workspace = None approvals = None @@ -82,7 +88,8 @@ def parse_args(argv): return ( LauncherOptions( layer_dirs, workspace, approvals, providers, harness, - anvil_image, no_prompt + anvil_image, no_prompt, + environ.get("TMUX") or None, environ.get("TMUX_PANE") or None, ), anvil_cmd, ) diff --git a/swarmforge/anvil/orchestrate.py b/swarmforge/anvil/orchestrate.py index 9d6fe09..f0dc4f8 100644 --- a/swarmforge/anvil/orchestrate.py +++ b/swarmforge/anvil/orchestrate.py @@ -80,7 +80,11 @@ def unsupported_tong_reasons(merged): reuses the container (a `session` tong is the right home for a per-workspace mount); * a workspace-sourced `shared` tong that mounts a `volume` -- its volume - is scoped to one checkout but its container is not. + is scoped to one checkout but its container is not; + * a `shared` tong that mounts the `tmux-socket` -- the same container + outlives the session and is reused by later ones, so it would carry one + session's tmux server, and its pinning to that session's pane, into the + next. A refused tong is reported rather than started half-wired. Returns a list of human-readable reason strings (empty == every discovered tong is startable). @@ -109,6 +113,12 @@ def unsupported_tong_reasons(merged): "sessions in other checkouts, so it would leak this checkout's " "data into theirs (make it a 'session' tong)" % name ) + if defn.get("lifecycle") == "shared" and tongs.mounts_word(defn, tongs.TMUX_MOUNT): + reasons.append( + "tong '%s' is a 'shared' tong that mounts the tmux socket; a shared " + "container outlives the session and is reused by later ones, so it " + "would carry one session's tmux server into the next" % name + ) return reasons @@ -133,7 +143,8 @@ def ensure_mcp_harness_supported(merged, harness): def _start_one_tong(docker, name, defn, *, container, network, alias, - resolver, workspace, label_hash, make_channel, volume_scope): + resolver, workspace, label_hash, make_channel, volume_scope, + tmux=None, tmux_pane=None, session_handle=None): """Start one tong container detached, delivering any secret env over a FIFO. Resolves the definition's secret references through `resolver` and splits the @@ -148,6 +159,9 @@ def _start_one_tong(docker, name, defn, *, container, network, alias, the bytes live only in docker's API stream and the container kernel's pipe buffer -- never `-e`, argv, or disk. + `tmux`, `tmux_pane`, and `session_handle` reach `tongs.tong_run_argv`, which + uses them only for a tong that mounts `tmux-socket`. + Once the argv is assembled, any existing container of the same name is removed so a stale or stopped one is replaced cleanly -- but a definition the argv builder refuses removes nothing here, since it started nothing (teardown still @@ -174,6 +188,7 @@ def _start_one_tong(docker, name, defn, *, container, network, alias, container_name=container, network=network, alias=alias, env=plain_env, label_hash=label_hash, workspace=workspace, extra_mount_specs=git_dir_specs, volume_scope=volume_scope, + tmux=tmux, tmux_pane=tmux_pane, session_handle=session_handle, ) except ValueError as exc: raise OrchestrationError("tong '%s': %s" % (name, exc)) @@ -197,6 +212,7 @@ def _start_one_tong(docker, name, defn, *, container, network, alias, env=plain_env, label_hash=label_hash, workspace=workspace, secret_channel=True, entrypoint=entrypoint, command=command, extra_mount_specs=git_dir_specs, volume_scope=volume_scope, + tmux=tmux, tmux_pane=tmux_pane, session_handle=session_handle, ) except ValueError as exc: raise OrchestrationError("tong '%s': %s" % (name, exc)) @@ -312,10 +328,17 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None, tongs are left running. Teardown runs inside the caller's `teardown_guard()`, which keeps signals from cutting it short; the default guards nothing. + A `session` tong that mounts `tmux-socket` is handed the launcher's + `opts.tmux`/`opts.tmux_pane` and the session handle; both values are checked + before anything starts. + Returns the anvil's exit code. Raises `OrchestrationError` if a tong never - becomes ready (the anvil does not run against a half-up environment) or a - `session` tong is discovered with no anvil `--name` to key the session by, and - `SecretResolutionError` if a secret reference cannot be resolved. + becomes ready (the anvil does not run against a half-up environment), a + `session` tong is discovered with no anvil `--name` to key the session by, or + a tong mounts `tmux-socket` while the launcher is outside tmux, its `$TMUX` + or `$TMUX_PANE` is malformed, or the definition makes one of the names the + launcher sets a secret reference, and `SecretResolutionError` if a secret + reference cannot be resolved. """ ensure_mcp_harness_supported(merged, opts.harness) resolver = make_secret_resolver(providers or {}) @@ -331,6 +354,28 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None, "session tongs require the anvil '--name' as a session handle" ) + tmux_names = [ + name for name in sorted(merged) + if tongs.mounts_word(merged[name]["definition"], tongs.TMUX_MOUNT) + ] + for name in tmux_names: + # Validation refuses this too; a secret here would override the launcher's values. + owned_secret = tongs.tmux_secret_env_error(merged[name]["definition"]) + if owned_secret: + raise OrchestrationError("tong '%s': %s" % (name, owned_secret)) + if tmux_names: + if not opts.tmux: + raise OrchestrationError( + "tong(s) %s mount '%s', so the launcher must be started from inside " + "a tmux pane ($TMUX is unset)" % (", ".join(tmux_names), tongs.TMUX_MOUNT) + ) + try: + tongs.resolve_tmux_socket(opts.tmux) + if opts.tmux_pane: + tongs.check_tmux_pane(opts.tmux_pane) + except ValueError as exc: + raise OrchestrationError("tong(s) %s: %s" % (", ".join(tmux_names), exc)) + # No org layer means no token, and every shared tong keeps its global unscoped name. org_tongs_dir = dict(opts.layer_dirs).get(tongs.ORG) org_token = tongs.org_scope_token(org_tongs_dir) @@ -379,6 +424,7 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None, resolver=resolver, workspace=opts.workspace, label_hash=tongs.config_hash(defn), make_channel=make_channel, volume_scope=volume_scopes[name], + tmux=opts.tmux, tmux_pane=opts.tmux_pane, session_handle=session_id, ) probe_network = plan["network"] else: diff --git a/swarmforge/tongs/approvals.py b/swarmforge/tongs/approvals.py index 3ba552c..c27fe91 100644 --- a/swarmforge/tongs/approvals.py +++ b/swarmforge/tongs/approvals.py @@ -11,7 +11,7 @@ import json import os -from .model import SOCKET_MOUNT, WORKSPACE, workspace_key +from .model import SOCKET_MOUNT, TMUX_MOUNT, WORKSPACE, workspace_key from .mounts import mounts_word, parse_mount, tong_volume_name from .secrets import find_secret_refs @@ -33,8 +33,9 @@ def privilege_summary(defn, name=None, volume_scope=None): Gathers the privileges a reviewer must see before approving a workspace-sourced tong: image, secret references, mounts, volumes, networks, - docker-socket access, and host GPU access. Each volume is `{"name", "target", - "docker"}`; `docker` is None unless `name` and `volume_scope` are given. + docker-socket access, tmux-socket access, and host GPU access. Each volume is + `{"name", "target", "docker"}`; `docker` is None unless `name` and + `volume_scope` are given. Rendering and prompting are the caller's job; this just assembles the facts. """ return { @@ -44,6 +45,7 @@ def privilege_summary(defn, name=None, volume_scope=None): "volumes": _declared_volumes(defn, name, volume_scope), "networks": list(defn.get("networks") or []), "socket": mounts_word(defn, SOCKET_MOUNT), + "tmux": mounts_word(defn, TMUX_MOUNT), "gpus": _requested_gpus(defn), } diff --git a/tests/test_anvil_approval.py b/tests/test_anvil_approval.py index d3551df..ccd30a9 100644 --- a/tests/test_anvil_approval.py +++ b/tests/test_anvil_approval.py @@ -79,6 +79,7 @@ def test_omits_unrequested_sections(self): self.assertNotIn("volume", text) self.assertNotIn("docker socket", text) self.assertNotIn("gpus", text) + self.assertNotIn("tmux socket", text) def test_escape_sequences_in_every_field_are_escaped(self): cursor_up_erase = "\x1b[1A\x1b[2K" @@ -163,6 +164,17 @@ def test_non_string_values_still_render(self): self.assertIn(" mounts: 7, ['a']", text) self.assertIn(" networks: 8", text) + def test_calls_out_tmux_socket_on_its_own_line(self): + defn = dict(WORKSPACE_TONG, mounts=["tmux-socket"]) + lines = launcher.render_privilege_summary( + "spawner", tongs.privilege_summary(defn) + ).splitlines() + self.assertIn( + " tmux socket: every tmux server in your socket directory -- any " + "command on the host as you (read-only does not limit this)", + lines, + ) + class GateTests(unittest.TestCase): def setUp(self): diff --git a/tests/test_anvil_cli.py b/tests/test_anvil_cli.py index c93cd2f..7421c08 100644 --- a/tests/test_anvil_cli.py +++ b/tests/test_anvil_cli.py @@ -72,6 +72,25 @@ def test_parses_workspace_approvals_and_no_prompt(self): self.assertTrue(opts.no_prompt) self.assertEqual(cmd, ["x"]) + def test_tmux_options_come_from_the_environment(self): + opts, _ = launcher.parse_args( + ["--", "x"], + environ={"TMUX": "/tmp/tmux-1000/default,4242,3", "TMUX_PANE": "%7"}, + ) + self.assertEqual(opts.tmux, "/tmp/tmux-1000/default,4242,3") + self.assertEqual(opts.tmux_pane, "%7") + + def test_tmux_options_none_outside_tmux(self): + for environ in ({}, {"TMUX": "", "TMUX_PANE": ""}): + opts, _ = launcher.parse_args(["--", "x"], environ=environ) + self.assertIsNone(opts.tmux) + self.assertIsNone(opts.tmux_pane) + + def test_tmux_options_default_to_the_process_environment(self): + with mock.patch.dict(os.environ, {"TMUX": "/tmp/tmux-1/s,1,0", "TMUX_PANE": "%1"}): + opts, _ = launcher.parse_args(["--", "x"]) + self.assertEqual((opts.tmux, opts.tmux_pane), ("/tmp/tmux-1/s,1,0", "%1")) + def test_parses_harness(self): opts, _ = launcher.parse_args(["--harness", "claude", "--", "x"]) self.assertEqual(opts.harness, "claude") @@ -430,6 +449,20 @@ def test_shared_workspace_mount_refused_without_exec(self): self.assertEqual(completed.stdout, "") self.assertIn("workspace", completed.stderr) + def test_shared_tmux_socket_mount_refused_without_exec(self): + with tempfile.TemporaryDirectory() as tmp: + tongs_dir = os.path.join(tmp, "tongs") + os.makedirs(tongs_dir) + with open(os.path.join(tongs_dir, "spawner.yaml"), "w") as handle: + handle.write( + "lifecycle: shared\nimage: x\nmounts:\n - tmux-socket\n" + "interface:\n kind: none\nreadiness:\n mode: none\n" + ) + completed = _run_launcher_raw(["--repo-tongs", tongs_dir]) + self.assertEqual(completed.returncode, 1) + self.assertEqual(completed.stdout, "") + self.assertIn("tmux socket", completed.stderr) + def _write_shared_tong(tmp): """A tong layer under `tmp` holding one startable `shared` tong; returns its path.""" diff --git a/tests/test_anvil_orchestrate.py b/tests/test_anvil_orchestrate.py index d58f7a9..745cf80 100644 --- a/tests/test_anvil_orchestrate.py +++ b/tests/test_anvil_orchestrate.py @@ -130,6 +130,23 @@ def test_volume_allowed_where_container_and_volume_scope_agree(self): session = dict(shared, lifecycle="session") self.assertEqual(launcher.unsupported_tong_reasons(_merged("t", session)), []) + def test_shared_tmux_socket_mount_refused(self): + for mount in ("tmux-socket", "tmux-socket:ro"): + reasons = self._reasons({ + "lifecycle": "shared", "image": "x", "mounts": [mount], + "interface": {"kind": "none"}, "readiness": {"mode": "none"}, + }) + self.assertTrue(any("tmux socket" in r for r in reasons), mount) + + def test_session_tmux_socket_mount_startable(self): + self.assertEqual( + self._reasons({ + "lifecycle": "session", "image": "x", "mounts": ["tmux-socket"], + "interface": {"kind": "none"}, "readiness": {"mode": "none"}, + }), + [], + ) + def test_workspace_refusal_is_shared_scoped(self): # A `session` tong is torn down with the anvil, so it cannot leak. self.assertEqual( @@ -230,10 +247,12 @@ def deliver(self, payload): raise self._deliver_error -def _opts(workspace=None, anvil_image="anvil:img", harness="opencode"): +def _opts(workspace=None, anvil_image="anvil:img", harness="opencode", + tmux=None, tmux_pane=None): return launcher.LauncherOptions( layer_dirs=[], workspace=workspace, approvals=None, providers=None, harness=harness, anvil_image=anvil_image, no_prompt=False, + tmux=tmux, tmux_pane=tmux_pane, ) @@ -284,6 +303,14 @@ def __call__(self): "readiness": {"mode": "none"}, } +SESSION_TMUX = { + "lifecycle": "session", + "image": "spawner", + "mounts": ["tmux-socket"], + "interface": {"kind": "none"}, + "readiness": {"mode": "none"}, +} + # Built on the test interpreter, so no op/pass need be installed; it echoes the {ref}. ECHO_PROVIDERS = { "echo": [sys.executable, "-c", "import sys; sys.stdout.write(sys.argv[1])", "{ref}"] @@ -852,6 +879,99 @@ def test_session_tong_without_anvil_name_raises_before_any_docker_call(self): ) self.assertEqual(docker.calls, []) # nothing created => nothing to tear down + def _run_tmux(self, docker, tmux, tmux_pane=None, workspace=None): + merged = { + "pg": {"source": tongs.REPO, "definition": SESSION_PORT}, + "spawner": {"source": tongs.REPO, "definition": SESSION_TMUX}, + } + return launcher.run_with_tongs( + merged, ANVIL_ARGV, + _opts(workspace=workspace, tmux=tmux, tmux_pane=tmux_pane), + docker=docker, sleep=lambda _s: None, monotonic=_Clock(), + ) + + def test_tmux_tong_outside_tmux_raises_before_any_docker_call(self): + docker = FakeDocker() + with self.assertRaisesRegex(launcher.OrchestrationError, "inside a tmux pane"): + self._run_tmux(docker, tmux=None) + self.assertEqual(docker.calls, []) + + def test_malformed_tmux_raises_before_any_docker_call(self): + for tmux, pane in ( + ("/home/me/custom.sock,1,0", None), + ("/tmp/tmux-1000/default,1", None), + ("/tmp/tmux-1000/default,1,0", "7"), + ): + docker = FakeDocker() + with self.assertRaisesRegex(launcher.OrchestrationError, "tong\\(s\\) spawner"): + self._run_tmux(docker, tmux=tmux, tmux_pane=pane) + self.assertEqual(docker.calls, [], tmux) + + def test_tmux_values_reach_a_tong_started_over_the_secret_channel(self): + docker = FakeDocker() + channels = FakeChannels() + defn = dict(SESSION_TMUX, env={"TOKEN": "${secret:echo:s3cr3t}"}) + launcher.run_with_tongs( + _merged("spawner", defn, source=tongs.REPO), ANVIL_ARGV, + _opts(tmux="/tmp/tmux-1000/default,4242,3", tmux_pane="%7"), + docker=docker, providers=ECHO_PROVIDERS, make_channel=channels, + sleep=lambda _s: None, monotonic=_Clock(), + ) + self.assertEqual(channels.payloads, ["export TOKEN='s3cr3t'\n"]) + (started,) = docker.run_argvs + self.assertIn("--tmpfs", started) + self.assertIn("TMUX=/run/swarmforge-tmux/default,4242,3", started) + self.assertIn("TMUX_PANE=%7", started) + self.assertIn("SWARMFORGE_SESSION_HANDLE=claude-myproject", started) + self.assertEqual( + started[started.index("/tmp/tmux-1000:/run/swarmforge-tmux:ro") - 1], "-v") + + def test_tmux_launcher_env_as_a_secret_never_reaches_docker(self): + docker = FakeDocker() + channels = FakeChannels() + defn = dict(SESSION_TMUX, env={"TMUX_PANE": "${secret:echo:%9}"}) + with self.assertRaisesRegex(launcher.OrchestrationError, + "tong 'spawner'.*TMUX_PANE.*secret reference"): + launcher.run_with_tongs( + _merged("spawner", defn, source=tongs.REPO), ANVIL_ARGV, + _opts(tmux="/tmp/tmux-1000/default,4242,3", tmux_pane="%7"), + docker=docker, providers=ECHO_PROVIDERS, make_channel=channels, + sleep=lambda _s: None, monotonic=_Clock(), + ) + self.assertEqual(docker.calls, []) + self.assertEqual(channels.payloads, []) + + def test_tmux_values_reach_only_the_tmux_tongs_argv(self): + docker = FakeDocker() + rc = self._run_tmux( + docker, tmux="/tmp/tmux-1000/default,4242,3", tmux_pane="%7", + workspace="/home/me/proj", + ) + self.assertEqual(rc, 0) + spawner = next(argv for argv in docker.run_argvs + if "claude-myproject-tong-spawner" in argv) + self.assertIn("TMUX=/run/swarmforge-tmux/default,4242,3", spawner) + self.assertIn("TMUX_PANE=%7", spawner) + self.assertIn("SWARMFORGE_SESSION_HANDLE=claude-myproject", spawner) + self.assertIn("SWARMFORGE_WORKSPACE_HOST_PATH=/home/me/proj", spawner) + self.assertIn("/tmp/tmux-1000:/run/swarmforge-tmux:ro", spawner) + pg = next(argv for argv in docker.run_argvs if "claude-myproject-tong-pg" in argv) + self.assertNotIn("TMUX", " ".join(pg)) + self.assertNotIn("TMUX", " ".join(docker.anvil_argv)) + + def test_shared_tmux_tong_is_never_handed_the_server(self): + # Refused before this in main; reaching here anyway must not wire it. + docker = FakeDocker() + shared = dict(SESSION_TMUX, lifecycle="shared") + with self.assertRaisesRegex(launcher.OrchestrationError, + "only allowed on a 'session' tong"): + launcher.run_with_tongs( + _merged("spawner", shared, source=tongs.REPO), ANVIL_ARGV, + _opts(tmux="/tmp/tmux-1000/default,4242,3"), + docker=docker, sleep=lambda _s: None, monotonic=_Clock(), + ) + self.assertEqual(docker.run_argvs, []) + # --- Per-org isolation of `shared` tongs -------------------------------- _ACME = "/orgs/acme/.swarmforge/tongs" diff --git a/tests/test_tongs_approvals.py b/tests/test_tongs_approvals.py index 3d310c8..9a99942 100644 --- a/tests/test_tongs_approvals.py +++ b/tests/test_tongs_approvals.py @@ -98,6 +98,14 @@ def test_summary_tolerates_malformed_mounts(self): self.assertEqual([v["name"] for v in summary["volumes"]], ["ok"]) self.assertEqual(summary["mounts"], defn["mounts"]) + def test_summary_reports_tmux_socket_access(self): + defn = def_of(GITHUB_TONG) + self.assertFalse(tongs.privilege_summary(defn)["tmux"]) + defn["mounts"] = ["tmux-socket:ro"] + summary = tongs.privilege_summary(defn) + self.assertTrue(summary["tmux"]) + self.assertFalse(summary["socket"]) + class ApprovalKeyingTests(unittest.TestCase): def setUp(self): From cae3ee2d8183a152e10cfdefb72905bde562754f Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sun, 4 Oct 2026 11:51:39 -0500 Subject: [PATCH 04/10] Check the tmux socket directory on disk before binding it The $TMUX parse judges only the spelling of the socket path, but the mount binds the whole directory, and docker follows a symlink in a bind source to wherever it points. Before anything starts, the launcher now refuses a directory reached through a symlink, one that is not a real directory, one owned by another uid, or one open to group or others (tmux creates it 0700), and a socket path that does not hold a socket. Every directory above it must also pass OpenSSH's StrictModes rule: owned by root or the launcher's uid, and not writable by group or others unless sticky, as /tmp is. Without that, a TMUX_TMPDIR under a world-writable, non-sticky directory would let another local user swap the socket directory for a symlink between the check and docker's bind. The check runs on the host side of the launcher, so the pure core stays free of filesystem access, and run_with_tongs takes it as an injectable seam so tests can use a $TMUX that names no real socket. It takes the socket path from the same parse that yields the directory, so $TMUX is read in one place. --- swarmforge/anvil/__init__.py | 2 + swarmforge/anvil/orchestrate.py | 81 ++++++++++++++++++- tests/test_anvil_orchestrate.py | 139 +++++++++++++++++++++++++++++++- 3 files changed, 215 insertions(+), 7 deletions(-) diff --git a/swarmforge/anvil/__init__.py b/swarmforge/anvil/__init__.py index c9b2c2c..132bb91 100644 --- a/swarmforge/anvil/__init__.py +++ b/swarmforge/anvil/__init__.py @@ -136,6 +136,7 @@ from .orchestrate import ( MCP_CONFIG_CONTAINER_PATH, MCP_FILE_ENV, + check_tmux_socket_dir, ensure_mcp_harness_supported, exec_anvil, run_with_tongs, @@ -167,6 +168,7 @@ "TerminationSignal", "MCP_CONFIG_CONTAINER_PATH", "MCP_FILE_ENV", + "check_tmux_socket_dir", "ensure_mcp_harness_supported", "exec_anvil", "run_with_tongs", diff --git a/swarmforge/anvil/orchestrate.py b/swarmforge/anvil/orchestrate.py index f0dc4f8..69c04c4 100644 --- a/swarmforge/anvil/orchestrate.py +++ b/swarmforge/anvil/orchestrate.py @@ -18,6 +18,7 @@ import json import os import shutil +import stat import tempfile import time @@ -122,6 +123,71 @@ def unsupported_tong_reasons(merged): return reasons +def check_tmux_socket_dir(host_dir, socket_path, uid=None): + """Refuse a tmux socket directory that is not plainly the launcher's own. + + `host_dir` and `socket_path` come from a `$TMUX` that `tongs.resolve_tmux_socket` + already accepted, which judged only the spelling. Here the host itself is + checked, since the whole directory is bound into the tong: it must be reached + with no symlink in any component (docker follows one, binding wherever it + points), be a real directory owned by `uid` (the launcher's own by default) + with no group or other permission bits -- tmux creates it 0700 -- and hold + `socket_path` as a socket. + + Every directory above it, up to `/`, must pass OpenSSH's StrictModes rule: + owned by root or `uid`, and not writable by group or others unless sticky + (as `/tmp` is). Otherwise another local user could swap the directory for a + symlink between this check and docker's bind. Raises `OrchestrationError` + when any of this fails. + """ + uid = os.getuid() if uid is None else uid + if os.path.realpath(host_dir) != host_dir: + raise OrchestrationError( + "tmux socket directory %s is reached through a symlink; docker would " + "bind whatever it points at" % host_dir) + try: + dir_stat = os.lstat(host_dir) + except OSError as exc: + raise OrchestrationError("cannot inspect the tmux socket directory: %s" % exc) + if not stat.S_ISDIR(dir_stat.st_mode): + raise OrchestrationError("tmux socket directory %s is not a directory" % host_dir) + if dir_stat.st_uid != uid: + raise OrchestrationError( + "tmux socket directory %s is owned by uid %d, not the launcher's uid %d" + % (host_dir, dir_stat.st_uid, uid)) + if dir_stat.st_mode & 0o077: + raise OrchestrationError( + "tmux socket directory %s is open to group or others (mode %04o); " + "tmux creates it 0700" % (host_dir, stat.S_IMODE(dir_stat.st_mode))) + ancestor = os.path.dirname(host_dir) + while True: + try: + ancestor_stat = os.lstat(ancestor) + except OSError as exc: + raise OrchestrationError("cannot inspect %s above the tmux socket: %s" + % (ancestor, exc)) + if ancestor_stat.st_uid not in (0, uid): + raise OrchestrationError( + "%s, above the tmux socket directory, is owned by uid %d, neither " + "root nor the launcher's uid %d" % (ancestor, ancestor_stat.st_uid, uid)) + mode = ancestor_stat.st_mode + if mode & (stat.S_IWGRP | stat.S_IWOTH) and not mode & stat.S_ISVTX: + raise OrchestrationError( + "%s, above the tmux socket directory, is writable by group or others " + "without the sticky bit (mode %04o), so another user could swap the " + "directory before docker binds it" % (ancestor, stat.S_IMODE(mode))) + parent = os.path.dirname(ancestor) + if parent == ancestor: + break + ancestor = parent + try: + socket_stat = os.lstat(socket_path) + except OSError as exc: + raise OrchestrationError("cannot inspect the tmux socket: %s" % exc) + if not stat.S_ISSOCK(socket_stat.st_mode): + raise OrchestrationError("tmux socket %s is not a socket" % socket_path) + + def ensure_mcp_harness_supported(merged, harness): """Refuse MCP tongs when no emitter exists for the selected harness.""" mcp_names = [ @@ -301,6 +367,7 @@ def _mcp_injection(mcp_config, harness, mcp_dir): def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None, make_channel=SecretChannel, teardown_guard=contextlib.nullcontext, + check_tmux_dir=check_tmux_socket_dir, sleep=time.sleep, monotonic=time.monotonic): """Start the discovered tongs, run the anvil, and tear down session state. @@ -329,14 +396,16 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None, which keeps signals from cutting it short; the default guards nothing. A `session` tong that mounts `tmux-socket` is handed the launcher's - `opts.tmux`/`opts.tmux_pane` and the session handle; both values are checked - before anything starts. + `opts.tmux`/`opts.tmux_pane` and the session handle; both values, and the + socket directory on disk (through `check_tmux_dir`, `check_tmux_socket_dir` + unless a test injects another), are checked before anything starts. Returns the anvil's exit code. Raises `OrchestrationError` if a tong never becomes ready (the anvil does not run against a half-up environment), a `session` tong is discovered with no anvil `--name` to key the session by, or a tong mounts `tmux-socket` while the launcher is outside tmux, its `$TMUX` - or `$TMUX_PANE` is malformed, or the definition makes one of the names the + or `$TMUX_PANE` is malformed, its socket directory fails + `check_tmux_socket_dir`, or the definition makes one of the names the launcher sets a secret reference, and `SecretResolutionError` if a secret reference cannot be resolved. """ @@ -370,11 +439,15 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None, "a tmux pane ($TMUX is unset)" % (", ".join(tmux_names), tongs.TMUX_MOUNT) ) try: - tongs.resolve_tmux_socket(opts.tmux) + tmux_dir, tmux_socket_path, _ = tongs.resolve_tmux_socket(opts.tmux) if opts.tmux_pane: tongs.check_tmux_pane(opts.tmux_pane) except ValueError as exc: raise OrchestrationError("tong(s) %s: %s" % (", ".join(tmux_names), exc)) + try: + check_tmux_dir(tmux_dir, tmux_socket_path) + except OrchestrationError as exc: + raise OrchestrationError("tong(s) %s: %s" % (", ".join(tmux_names), exc)) # No org layer means no token, and every shared tong keeps its global unscoped name. org_tongs_dir = dict(opts.layer_dirs).get(tongs.ORG) diff --git a/tests/test_anvil_orchestrate.py b/tests/test_anvil_orchestrate.py index 745cf80..c1c04b8 100644 --- a/tests/test_anvil_orchestrate.py +++ b/tests/test_anvil_orchestrate.py @@ -6,6 +6,8 @@ import os import shutil import signal +import socket +import stat import subprocess import sys import tempfile @@ -303,6 +305,11 @@ def __call__(self): "readiness": {"mode": "none"}, } +# These tests' `$TMUX` names no real socket; the on-disk check has tests of its own. +def _skip_tmux_dir_check(host_dir, socket_path): + return None + + SESSION_TMUX = { "lifecycle": "session", "image": "spawner", @@ -879,7 +886,8 @@ def test_session_tong_without_anvil_name_raises_before_any_docker_call(self): ) self.assertEqual(docker.calls, []) # nothing created => nothing to tear down - def _run_tmux(self, docker, tmux, tmux_pane=None, workspace=None): + def _run_tmux(self, docker, tmux, tmux_pane=None, workspace=None, + check_tmux_dir=_skip_tmux_dir_check): merged = { "pg": {"source": tongs.REPO, "definition": SESSION_PORT}, "spawner": {"source": tongs.REPO, "definition": SESSION_TMUX}, @@ -887,7 +895,8 @@ def _run_tmux(self, docker, tmux, tmux_pane=None, workspace=None): return launcher.run_with_tongs( merged, ANVIL_ARGV, _opts(workspace=workspace, tmux=tmux, tmux_pane=tmux_pane), - docker=docker, sleep=lambda _s: None, monotonic=_Clock(), + docker=docker, check_tmux_dir=check_tmux_dir, + sleep=lambda _s: None, monotonic=_Clock(), ) def test_tmux_tong_outside_tmux_raises_before_any_docker_call(self): @@ -915,6 +924,7 @@ def test_tmux_values_reach_a_tong_started_over_the_secret_channel(self): _merged("spawner", defn, source=tongs.REPO), ANVIL_ARGV, _opts(tmux="/tmp/tmux-1000/default,4242,3", tmux_pane="%7"), docker=docker, providers=ECHO_PROVIDERS, make_channel=channels, + check_tmux_dir=_skip_tmux_dir_check, sleep=lambda _s: None, monotonic=_Clock(), ) self.assertEqual(channels.payloads, ["export TOKEN='s3cr3t'\n"]) @@ -968,10 +978,28 @@ def test_shared_tmux_tong_is_never_handed_the_server(self): launcher.run_with_tongs( _merged("spawner", shared, source=tongs.REPO), ANVIL_ARGV, _opts(tmux="/tmp/tmux-1000/default,4242,3"), - docker=docker, sleep=lambda _s: None, monotonic=_Clock(), + docker=docker, check_tmux_dir=_skip_tmux_dir_check, + sleep=lambda _s: None, monotonic=_Clock(), ) self.assertEqual(docker.run_argvs, []) + def test_tmux_socket_dir_checked_on_disk_before_any_docker_call(self): + tmux_dir, socket_path = _tmux_socket_dir(_tmux_test_dir(self)) + tmux = "%s,4242,3" % socket_path + os.chmod(tmux_dir, 0o755) + docker = FakeDocker() + with self.assertRaisesRegex(launcher.OrchestrationError, + "tong\\(s\\) spawner: .*open to group or others"): + self._run_tmux(docker, tmux=tmux, check_tmux_dir=launcher.check_tmux_socket_dir) + self.assertEqual(docker.calls, []) + + os.chmod(tmux_dir, 0o700) + docker = FakeDocker() + self._run_tmux(docker, tmux=tmux, check_tmux_dir=launcher.check_tmux_socket_dir) + spawner = next(argv for argv in docker.run_argvs + if "claude-myproject-tong-spawner" in argv) + self.assertIn("%s:/run/swarmforge-tmux:ro" % tmux_dir, spawner) + # --- Per-org isolation of `shared` tongs -------------------------------- _ACME = "/orgs/acme/.swarmforge/tongs" @@ -1161,6 +1189,111 @@ def test_teardown_only_force_removes_the_container_and_network(self): ]) +def _tmux_test_dir(test): + """A fresh directory under a short root the ancestor rule accepts, or skip `test`. + + Kept off TMPDIR: a long one overflows AF_UNIX's socket path limit at bind(), + and a group-writable one fails `check_tmux_socket_dir`'s ancestor rule. + """ + root = os.path.realpath("/tmp") + probe = root + while True: + try: + probe_stat = os.lstat(probe) + except OSError: + test.skipTest("%s is missing on this host" % probe) + mode = probe_stat.st_mode + if (not stat.S_ISDIR(mode) or probe_stat.st_uid not in (0, os.getuid()) + or (mode & (stat.S_IWGRP | stat.S_IWOTH) and not mode & stat.S_ISVTX)): + test.skipTest("%s fails the tmux ancestor rule on this host" % probe) + if os.path.dirname(probe) == probe: + break + probe = os.path.dirname(probe) + path = tempfile.mkdtemp(dir=root) + test.addCleanup(shutil.rmtree, path) + return path + + +def _tmux_socket_dir(parent): + """A `tmux-` directory at 0700 holding a bound socket, as tmux leaves it.""" + tmux_dir = os.path.join(os.path.realpath(parent), "tmux-%d" % os.getuid()) + os.mkdir(tmux_dir, 0o700) + os.chmod(tmux_dir, 0o700) + socket_path = os.path.join(tmux_dir, "default") + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(socket_path) + server.close() + return tmux_dir, socket_path + + +class TmuxSocketDirCheckTests(unittest.TestCase): + """The on-disk check of the socket directory a tmux-socket tong is bound.""" + + def setUp(self): + self.tmp = _tmux_test_dir(self) + self.tmux_dir, self.socket_path = _tmux_socket_dir(self.tmp) + + def _check(self, host_dir=None, socket_path=None, uid=None): + launcher.check_tmux_socket_dir( + host_dir or self.tmux_dir, socket_path or self.socket_path, uid=uid) + + def test_tmuxs_own_directory_accepted(self): + self._check() + + def test_writable_non_sticky_ancestor_refused(self): + # Another user could swap tmux- for a symlink between the check and the bind. + for mode in (0o777, 0o775, 0o757): + parent = tempfile.mkdtemp(dir=self.tmp) + tmux_dir, socket_path = _tmux_socket_dir(parent) + os.chmod(parent, mode) + with self.assertRaisesRegex(launcher.OrchestrationError, + "writable by group or others without the sticky bit"): + self._check(tmux_dir, socket_path) + + def test_sticky_world_writable_ancestor_accepted(self): + parent = tempfile.mkdtemp(dir=self.tmp) + tmux_dir, socket_path = _tmux_socket_dir(parent) + os.chmod(parent, 0o1777) + self._check(tmux_dir, socket_path) + + def test_symlinked_directory_refused(self): + link_parent = os.path.join(os.path.realpath(self.tmp), "elsewhere") + os.mkdir(link_parent) + link = os.path.join(link_parent, os.path.basename(self.tmux_dir)) + os.symlink(self.tmux_dir, link) + with self.assertRaisesRegex(launcher.OrchestrationError, "symlink"): + self._check(link, os.path.join(link, "default")) + + def test_directory_open_to_group_or_others_refused(self): + for mode in (0o755, 0o750, 0o701): + os.chmod(self.tmux_dir, mode) + with self.assertRaisesRegex(launcher.OrchestrationError, "open to group or others"): + self._check() + + def test_directory_owned_by_another_uid_refused(self): + with self.assertRaisesRegex(launcher.OrchestrationError, "owned by uid"): + self._check(uid=os.getuid() + 1) + + def test_regular_file_in_place_of_the_socket_refused(self): + os.unlink(self.socket_path) + with open(self.socket_path, "w") as handle: + handle.write("") + with self.assertRaisesRegex(launcher.OrchestrationError, "is not a socket"): + self._check() + + def test_missing_socket_refused(self): + os.unlink(self.socket_path) + with self.assertRaisesRegex(launcher.OrchestrationError, "cannot inspect the tmux socket:"): + self._check() + + def test_regular_file_in_place_of_the_directory_refused(self): + not_a_dir = os.path.join(os.path.realpath(self.tmp), "tmux-0") + with open(not_a_dir, "w") as handle: + handle.write("") + with self.assertRaisesRegex(launcher.OrchestrationError, "not a directory"): + self._check(not_a_dir, os.path.join(not_a_dir, "default")) + + class WorkspaceGitDirSpecTests(unittest.TestCase): """_workspace_git_dir_specs: the git-dir mounts riding along with `workspace`.""" From 065856405ac58c813ec3e9d1733df7feb182b23d Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sun, 4 Oct 2026 11:39:54 -0500 Subject: [PATCH 05/10] Document the tmux-socket mount word The mounts section gains the third word: what it grants -- every tmux server in the user's socket directory, not just the launcher's, with the pane a default target rather than a restriction -- why it is read-only and what read-only does not limit, why it is session-only, the requirement that the launcher run in a tmux pane with a socket in tmux's default directory, the on-disk checks of that directory, and the environment the tong receives, including that the launcher's names may not be secret references. The tongs overview names tmux-socket beside the workspace as a mount a shared tong may not have. The approval page lists the tmux server among what the prompt shows, and the environment reference gains the variables a tmux-socket tong is given and the two the launcher reads to give them. --- docs/reference/environment.md | 7 +++++-- docs/tongs/README.md | 2 +- docs/tongs/approval.md | 4 ++-- docs/tongs/definitions.md | 5 +++-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 1e8e2ad..eb7ede3 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -143,7 +143,7 @@ What each target runs is in [Testing](../development/testing.md). | `SHELL` | Its basename picks which rc candidates are tried first. | | `PATH` | Only to warn when `~/.local/bin` is not on it. | -The launcher reads `SWARMFORGE_USER_ASSETS_DIR` from its own environment too, as the fallback root for `approvals.json` and `secret-providers.yaml` when a call passes neither `--approvals` nor `--providers`. +The launcher reads `SWARMFORGE_USER_ASSETS_DIR` from its own environment too, as the fallback root for `approvals.json` and `secret-providers.yaml` when a call passes neither `--approvals` nor `--providers`. It also reads `TMUX` and `TMUX_PANE`, which only a [`tmux-socket`](../tongs/definitions.md#mounts) tong uses; `make` passes both through unchanged. [`swarmforge clone` and `swarmforge init`](../cli.md) drop `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR`, `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, and `GIT_NAMESPACE` from the environment they hand git, since `git -C` does not override them. @@ -168,7 +168,10 @@ The launcher reads `SWARMFORGE_USER_ASSETS_DIR` from its own environment too, as | `SWARMFORGE_CONFIG_DEST` `SWARMFORGE_CONFIG_RESET` | the make variables, passed through | `swarmforge.harness.init`. | | `SWARMFORGE_TONG_MCP_FILE` | `/tmp/swarmforge-tong-mcp.json` | `swarmforge.harness.init`, as the generated MCP fragment to merge. Set only for a session with MCP tongs and a harness whose spec takes the path by environment variable; one that takes a flag gets the same mount on its command line. | | `SWARMFORGE_TONG__HOST` `_PORT` `_PATH` | the tong's alias and port, or its mountpoint | The agent. One set per `port` or `volume` [tong](../tongs/definitions.md#interface-kinds). | -| `SWARMFORGE_WORKSPACE_HOST_PATH` | the workspace's path on the host | A socket-holding `session` [broker](../tongs/broker.md) tong. Never a `shared` tong, and never over a value the tong sets itself. | +| `SWARMFORGE_WORKSPACE_HOST_PATH` | the workspace's path on the host | A `session` tong that mounts `docker-socket` (a [broker](../tongs/broker.md)) or [`tmux-socket`](../tongs/definitions.md#mounts). Never a `shared` tong, and never over a value the tong sets itself. | +| `TMUX` | the launcher's `$TMUX`, with the socket re-pointed under `/run/swarmforge-tmux` | A `session` tong that mounts [`tmux-socket`](../tongs/definitions.md#mounts), so a stock `tmux` client reaches the host server. Replaces a plain value the definition sets; a secret reference there is refused at validation. | +| `TMUX_PANE` | the launcher's `$TMUX_PANE` | The same tong, as tmux's default target — a default, not a restriction. Set only when the launcher has one; a plain value the definition sets is dropped either way, and a secret reference is refused. | +| `SWARMFORGE_SESSION_HANDLE` | the anvil's container name (`--name`) | The same tong, as the session it serves. Replaces a plain value the definition sets; a secret reference there is refused at validation. | | `CLAUDE_CONFIG_DIR` | [the merged config dir](../harnesses/claude-code.md#the-config-directory) | `claude`, set by its `pre_exec` hook. | | `CLAUDE_SECURESTORAGE_CONFIG_DIR` | `/home/anvil/.claude` | `claude`, set by the same hook. | | `PYTHONPATH` | `/usr/local/lib/swarmforge` | Both lifecycle drivers, so the copied package imports. Dropped before the harness is exec'd. | diff --git a/docs/tongs/README.md b/docs/tongs/README.md index bd1ccc9..8a14d3f 100644 --- a/docs/tongs/README.md +++ b/docs/tongs/README.md @@ -30,7 +30,7 @@ The workspace layer is gated by [first-run approval](approval.md). ## Lifecycle - **`session`** — started with the anvil, torn down when it exits. Per-session isolation; the default for credential tongs. -- **`shared`** — long-lived, survives across anvil sessions. Started on first use, connected to each session's network via a network alias, and left running on teardown (no refcounting). A running `shared` container whose config-hash docker label still matches the current definition is reused untouched; a missing, stopped, or stale one is recreated automatically. A rotated secret behind an unchanged reference does **not** churn it — force a restart with `docker rm -f `. Recreating the container loses what the tong wrote inside it; state worth keeping, such as a model store, belongs in a [`volume:` mount](definitions.md#mounts). A `shared` tong may not mount the `workspace` (it would leak one session's workspace into the next); use a `session` tong for per-workspace mounts. +- **`shared`** — long-lived, survives across anvil sessions. Started on first use, connected to each session's network via a network alias, and left running on teardown (no refcounting). A running `shared` container whose config-hash docker label still matches the current definition is reused untouched; a missing, stopped, or stale one is recreated automatically. A rotated secret behind an unchanged reference does **not** churn it — force a restart with `docker rm -f `. Recreating the container loses what the tong wrote inside it; state worth keeping, such as a model store, belongs in a [`volume:` mount](definitions.md#mounts). A `shared` tong may not mount the `workspace` (it would leak one session's workspace into the next) or the `tmux-socket` (it would carry one session's tmux server into the next); use a `session` tong for either. ## Reference diff --git a/docs/tongs/approval.md b/docs/tongs/approval.md index 8982a89..9f30ad0 100644 --- a/docs/tongs/approval.md +++ b/docs/tongs/approval.md @@ -1,9 +1,9 @@ # First-run approval The user, org, and repo layers are installed deliberately and are trusted; they skip the gate. -A **workspace**-sourced tong (from a repo you cloned) could otherwise request your secrets, host mounts, the docker socket, or the host's GPUs simply by being present, so the launcher gates it: +A **workspace**-sourced tong (from a repo you cloned) could otherwise request your secrets, host mounts, the docker socket, your tmux server, or the host's GPUs simply by being present, so the launcher gates it: -- Before starting, it prints exactly what the tong requests — image, secret references, mounts, networks, docker-socket access, and GPU access — and asks you to approve. Control and formatting characters in those values are shown as escapes such as `\x1b` or `\n`, so a value cannot move the cursor, restyle, or hide any part of the summary. Every `tongs:` warning escapes them the same way. Each volume is listed with its target and docker volume name, with a note that its data persists and is shared by concurrent sessions. +- Before starting, it prints exactly what the tong requests — image, secret references, mounts, networks, docker-socket access, tmux-socket access, and GPU access — and asks you to approve. Both sockets get a line of their own, since each lets the tong run anything on your host; the tmux line says that this covers every tmux server in your socket directory, and that the mount being read-only does not limit it. Control and formatting characters in those values are shown as escapes such as `\x1b` or `\n`, so a value cannot move the cursor, restyle, or hide any part of the summary. Every `tongs:` warning escapes them the same way. Each volume is listed with its target and docker volume name, with a note that its data persists and is shared by concurrent sessions. - Approval is keyed by workspace path (symlinks resolved, as for its [volumes](definitions.md#volume-naming-and-scope)) + tong name + a hash of the merged definition, stored in `~/.swarmforge/approvals.json`. Any change to the definition re-prompts. - The gate defaults to **No**, and a non-interactive stdin reads as No. A scripted `--no-prompt` run **fails closed** rather than auto-approving. - Approving `image: foo:latest` approves a moving target; **pinned digests are the recommended convention** for workspace tongs. diff --git a/docs/tongs/definitions.md b/docs/tongs/definitions.md index 08bf946..7ce1d0f 100644 --- a/docs/tongs/definitions.md +++ b/docs/tongs/definitions.md @@ -66,11 +66,12 @@ readiness: ## Mounts -Mounts are opt-in **magic words**, never raw host paths. Only three are recognized, each spelled as shown below with the optional access mode (`ro`/`rw`) last: +Mounts are opt-in **magic words**, never raw host paths. Only four are recognized, each spelled as shown below with the optional access mode (`ro`/`rw`) last: -- `workspace[:/target][:mode]` — bind-mounts the session workspace, at `/workspace` unless an absolute `target` says otherwise (e.g. `workspace:ro`, `workspace:/code`, `workspace:/code:ro`). A custom target lets an image that expects its sources elsewhere be used unmodified (it does not set the working directory — the process still starts in the image's own `WORKDIR`). A target is refused unless it is an absolute path free of whitespace, and refused if it resolves to `/`, overlaps another of the tong's mounts, or overlaps a path the tong's own wiring occupies — the secret-delivery tmpfs at `/run/swarmforge` and the `/bin/sh` its wrapper execs (for a tong with secret references), or the docker socket (for a tong that mounts it). The workspace bind is paired with the same git-dir mounts the anvil gets from `swarmforge/gitguard.py`: read-only guards over the config and hooks the host's git obeys, and — when the workspace is a linked worktree or another checkout whose git dir lives outside it — that git dir at its own absolute path, which is where the checkout's `.git` pointer file says to look (without it, git inside the tong fails with "not a git repository"). The one mount the anvil gets and a tong does not is the restated worktree registration — a tong keeps the host's copy, so `git worktree list` inside one names the checkout by its host path (the [git guard](../git-guard.md) has the rest). When every `workspace` mount is `ro`, the ride-along git-dir mounts are forced read-only too. +- `workspace[:/target][:mode]` — bind-mounts the session workspace, at `/workspace` unless an absolute `target` says otherwise (e.g. `workspace:ro`, `workspace:/code`, `workspace:/code:ro`). A custom target lets an image that expects its sources elsewhere be used unmodified (it does not set the working directory — the process still starts in the image's own `WORKDIR`). A target is refused unless it is an absolute path free of whitespace, and refused if it resolves to `/`, overlaps another of the tong's mounts, or overlaps a path the tong's own wiring occupies — the secret-delivery tmpfs at `/run/swarmforge` and the `/bin/sh` its wrapper execs (for a tong with secret references), the docker socket (for a tong that mounts it), or `/run/swarmforge-tmux` (for a tong that mounts the tmux socket). The workspace bind is paired with the same git-dir mounts the anvil gets from `swarmforge/gitguard.py`: read-only guards over the config and hooks the host's git obeys, and — when the workspace is a linked worktree or another checkout whose git dir lives outside it — that git dir at its own absolute path, which is where the checkout's `.git` pointer file says to look (without it, git inside the tong fails with "not a git repository"). The one mount the anvil gets and a tong does not is the restated worktree registration — a tong keeps the host's copy, so `git worktree list` inside one names the checkout by its host path (the [git guard](../git-guard.md) has the rest). When every `workspace` mount is `ro`, the ride-along git-dir mounts are forced read-only too. - `docker-socket[:mode]` — bind-mounts the host docker socket onto the same path inside the container (so it takes no target). This is full host docker control and is always called out explicitly in the workspace approval prompt; it is the grant a broker tong needs. - `volume::/target[:mode]` — mounts a docker **named volume** at `target`, for state the tong keeps between containers (a model store, a cache). The name comes first and is at most 64 letters, digits, `.` and `-`, starting with a letter or digit; the target is required and held to the same rules as a `workspace` target, may not overlap the git dir that a `workspace` mount brings along, and a tong mounts each volume only once. Docker creates the volume on first use and seeds a new, empty one with whatever the image has at `target`. Only that first seeding happens: a later image's files at `target` never replace what the volume holds, so point `target` at a data directory, not at files the image ships. The launcher never removes a volume; remove one by hand with `docker volume rm ` (`docker volume ls --filter name=swarmforge-volume-` lists them). A `volume:` mount is private to its tong; it is unrelated to `interface.kind: volume`, which would share a volume with the anvil and is not wired up yet. +- `tmux-socket[:ro]` — binds the directory holding the launcher's tmux socket at `/run/swarmforge-tmux`, so the tong reaches every tmux server you run in that directory — the one the launcher was started from and any other you started with `-L` — and through any of them can run any command on the host as you. That is as strong a grant as the docker socket, and the approval prompt calls it out the same way. It is always read-only (`tmux-socket:rw` is refused), but read-only only stops the tong deleting or replacing the sockets in that directory: a tmux client needs nothing more than to connect, so it limits nothing the tong can do through a server. It takes no target, and because the directory rather than the socket is bound, a socket tmux recreates on a server restart is still found. It is `session`-only — a `shared` tong is refused, since its container outlives the session and would carry one session's tmux server into the next. The launcher must itself run in a tmux pane, and the socket must sit directly in tmux's default per-user directory (`tmux-` under `$TMUX_TMPDIR` or `/tmp`); a socket at a custom `-S` path is refused, because binding its directory could expose an arbitrary host directory. The launcher also checks that directory on disk: no symlink on the way to it (docker would bind whatever one points at), owned by you, no group or other permission bits (tmux creates it `0700`), and holding a socket at the path `$TMUX` names. Every directory above it, up to `/`, must be owned by root or you and not writable by group or others unless it has the sticky bit (as `/tmp` does), so no other user can swap the directory between the check and docker's bind. The check assumes the docker daemon resolves the bind on the launcher's own filesystem, as the workspace and docker-socket binds already do; a remote `DOCKER_HOST` is not supported for this mount. Any of these failures stops the launch before anything starts. The tong gets `TMUX`, re-pointed at the socket under `/run/swarmforge-tmux` so a stock `tmux` client reaches the launcher's server with no flags; `TMUX_PANE`, the launcher's pane when it has one, which tmux uses as the default target — a default, not a restriction, since the tong can still name any session, window, or pane; `SWARMFORGE_SESSION_HANDLE`, the anvil's container name; and `SWARMFORGE_WORKSPACE_HOST_PATH`. For the first three the launcher's values replace a plain value the definition's `env:` sets (and one the launcher has no value for is dropped), while a secret reference on any of them is refused at validation, since a delivered secret would override the launcher's value. The socket directory's `0700` mode means the tong's process must run as your host uid or as root to connect. ### Volume naming and scope From 610b3aaeacf82a954c813b5b1a523638a9037ab8 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Tue, 6 Oct 2026 10:42:42 -0500 Subject: [PATCH 06/10] Judge mounts in one walk for validation and launch validate_tong, tong_mount_specs and workspace_mount_placements each looped over mounts: with their own copy of the rules. checked_mounts is now the one walk, so a mount is accepted or refused the same way, with the same message, wherever it is checked. --- swarmforge/tongs/__init__.py | 4 ++ swarmforge/tongs/mounts.py | 82 ++++++++++++++++++++++++------------ swarmforge/tongs/validate.py | 36 +++------------- 3 files changed, 65 insertions(+), 57 deletions(-) diff --git a/swarmforge/tongs/__init__.py b/swarmforge/tongs/__init__.py index ec8d744..81b4293 100644 --- a/swarmforge/tongs/__init__.py +++ b/swarmforge/tongs/__init__.py @@ -125,7 +125,9 @@ VOLUME_NAME_MAX_LENGTH, WORKSPACE_MOUNT, WORKSPACE_VOLUME_SCOPE, + CheckedMount, check_tmux_pane, + checked_mounts, mount_destination, mount_mode_error, mount_target_error, @@ -244,7 +246,9 @@ "VOLUME_NAME_MAX_LENGTH", "WORKSPACE_MOUNT", "WORKSPACE_VOLUME_SCOPE", + "CheckedMount", "check_tmux_pane", + "checked_mounts", "mount_destination", "mount_mode_error", "mount_target_error", diff --git a/swarmforge/tongs/mounts.py b/swarmforge/tongs/mounts.py index 9cc2955..89c80ed 100644 --- a/swarmforge/tongs/mounts.py +++ b/swarmforge/tongs/mounts.py @@ -7,6 +7,7 @@ whichever asks. """ +import collections import hashlib import json import posixpath @@ -47,6 +48,10 @@ # The env the launcher sets on a tmux-socket tong; a definition never supplies it. TMUX_LAUNCHER_ENV = ("TMUX", "TMUX_PANE", SESSION_HANDLE_ENV) +# One `mounts:` entry as `checked_mounts` judged it; `error` is None when it is accepted. +CheckedMount = collections.namedtuple( + "CheckedMount", ["mount", "word", "volume", "target", "mode", "destination", "error"]) + VOLUME_NAME_PREFIX = "swarmforge-volume" # 128 bits, so no definition can search its way onto another scope's volume. @@ -308,6 +313,46 @@ def volume_scope(layer, org_tongs_dir=None, workspace=None): raise ValueError("unknown layer %r has no volume scope" % (layer,)) +def checked_mounts(defn, socket_path=DEFAULT_DOCKER_SOCKET): + """Each `mounts:` entry judged by every mount rule, as a `CheckedMount`. + + Validation and argv assembly both walk a definition's mounts through here, so + they accept and refuse the same entries with the same message. An entry is + refused for not being a string, failing `parse_mount`, or any of + `mount_target_error`, `mount_mode_error` and `overlapping_mount_error`; a + refused entry has `error` set (and `word` and `destination` None when it never + parsed) and is not placed, so later entries are judged for overlap against the + accepted ones only. + """ + reserved = reserved_mount_targets(defn, socket_path) + placed = [] # (mount, destination, volume) already accepted + for mount in defn.get("mounts") or []: + if not isinstance(mount, str): + yield CheckedMount(mount, None, None, None, None, None, + "mount entries must be strings, got %r" % (mount,)) + continue + try: + word, volume, target, mode = parse_mount(mount) + destination = mount_destination(word, target, socket_path) + except ValueError as exc: + yield CheckedMount(mount, None, None, None, None, None, str(exc)) + continue + error = (mount_target_error(mount, word, target, destination, reserved) + or mount_mode_error(mount, word, mode) + or overlapping_mount_error(mount, destination, volume, placed)) + if not error: + placed.append((mount, destination, volume)) + yield CheckedMount(mount, word, volume, target, mode, destination, error) + + +def _accepted_mounts(defn, socket_path): + """`checked_mounts`, raising `ValueError` at the first refused entry.""" + for checked in checked_mounts(defn, socket_path): + if checked.error: + raise ValueError(checked.error) + yield checked + + def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, tong_name=None, volume_scope=None, extra_mount_specs=(), tmux_socket_dir=None): @@ -326,25 +371,16 @@ def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, `resolve_tmux_socket` -- read-only at `TMUX_MOUNT_TARGET`. The directory rather than the socket, because tmux deletes and recreates the socket when its server restarts. - Raises `ValueError` for a non-string entry, a malformed mount, an unusable or - colliding destination, a refused access mode, a `workspace` or `tmux-socket` - mount when no host source for it is known, or a `volume` mount without a + Raises `ValueError` for any entry `checked_mounts` refuses, a volume + overlapping one of `extra_mount_specs`, a `workspace` or `tmux-socket` mount + when no host source for it is known, or a `volume` mount without a `tong_name` and `volume_scope` to name it by -- a definition never names a raw host path, so anything else is a mistake that should stop the launch. """ specs = [] - reserved = reserved_mount_targets(defn, socket_path) - placed = [] # (mount, destination, volume) already emitted - for mount in defn.get("mounts") or []: - if not isinstance(mount, str): - raise ValueError("mount entries must be strings, got %r" % (mount,)) - word, volume, target, mode = parse_mount(mount) - destination = mount_destination(word, target, socket_path) - reason = (mount_target_error(mount, word, target, destination, reserved) - or mount_mode_error(mount, word, mode) - or overlapping_mount_error(mount, destination, volume, placed)) - if reason: - raise ValueError(reason) + for checked in _accepted_mounts(defn, socket_path): + mount, word, mode = checked.mount, checked.word, checked.mode + destination = checked.destination if word == WORKSPACE_MOUNT: if not workspace: raise ValueError("mount 'workspace' requested but no workspace path is known") @@ -355,7 +391,7 @@ def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, if not tong_name or volume_scope is None: raise ValueError( "mount %r needs the tong's name and scope to name its volume" % (mount,)) - source = tong_volume_name(tong_name, volume, volume_scope) + source = tong_volume_name(tong_name, checked.volume, volume_scope) for extra in extra_mount_specs: # Ride-along sources never contain a colon; gitguard refuses those. extra_destination = extra.split(":")[1] @@ -376,7 +412,6 @@ def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, if mode: spec += ":" + mode specs.append(spec) - placed.append((mount, destination, volume)) return specs @@ -456,14 +491,9 @@ def workspace_mount_placements(defn, socket_path=DEFAULT_DOCKER_SOCKET): (None when the entry names no mode -- docker's read-write default). The orchestrator asks this to place the git-dir mounts a workspace checkout needs beside the workspace bind (see swarmforge.anvil). Empty when the - definition mounts no workspace. Raises `ValueError` for a malformed + definition mounts no workspace. Raises `ValueError` for a refused entry, like `tong_mount_specs`. """ - placements = [] - for mount in defn.get("mounts") or []: - if not isinstance(mount, str): - raise ValueError("mount entries must be strings, got %r" % (mount,)) - word, _, target, mode = parse_mount(mount) - if word == WORKSPACE_MOUNT: - placements.append((mount_destination(word, target, socket_path), mode)) - return placements + return [(checked.destination, checked.mode) + for checked in _accepted_mounts(defn, socket_path) + if checked.word == WORKSPACE_MOUNT] diff --git a/swarmforge/tongs/validate.py b/swarmforge/tongs/validate.py index c81a07a..adcd3e1 100644 --- a/swarmforge/tongs/validate.py +++ b/swarmforge/tongs/validate.py @@ -20,15 +20,7 @@ parse_duration, parse_gpus, ) -from .mounts import ( - mount_destination, - mount_mode_error, - mount_target_error, - overlapping_mount_error, - parse_mount, - reserved_mount_targets, - tmux_secret_env_error, -) +from .mounts import checked_mounts, tmux_secret_env_error from .secrets import ENV_NAME_RE, partition_secret_env @@ -152,28 +144,10 @@ def err(msg): if value is not None and not isinstance(value, list): err("'%s' must be a list" % listish) - mounts = defn.get("mounts") - if isinstance(mounts, list): - reserved = reserved_mount_targets(defn) - placed = [] # (mount, destination, volume) already accounted for - for mount in mounts: - if not isinstance(mount, str): - err("mount entries must be strings, got %r" % (mount,)) - continue - try: - word, volume, target, mode = parse_mount(mount) - destination = mount_destination(word, target) - except ValueError as exc: - err(str(exc)) - continue - reason = (mount_target_error(mount, word, target, destination, reserved) - or mount_mode_error(mount, word, mode)) - if reason is None: - reason = overlapping_mount_error(mount, destination, volume, placed) - if reason: - err(reason) - continue - placed.append((mount, destination, volume)) + if isinstance(defn.get("mounts"), list): + for checked in checked_mounts(defn): + if checked.error: + err(checked.error) networks = defn.get("networks") if isinstance(networks, list): From 44419b6c3cd0dbbce5774e6c0c7b2b3944a55fd4 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Tue, 6 Oct 2026 10:43:46 -0500 Subject: [PATCH 07/10] Resolve the launcher's tmux server once per launch run_with_tongs parsed $TMUX and $TMUX_PANE to check them, then tong_run_argv parsed them again for every tmux-socket tong. resolve_tmux_server now parses both into a TmuxServer that run_with_tongs checks and hands down. run_with_tongs no longer re-checks the secret env that validation already refused. --- swarmforge/anvil/orchestrate.py | 73 ++++++++++++--------------------- swarmforge/tongs/__init__.py | 8 ++-- swarmforge/tongs/argv.py | 61 ++++++++++++--------------- swarmforge/tongs/mounts.py | 54 ++++++++++++------------ tests/test_anvil_orchestrate.py | 19 +-------- tests/test_tongs_argv.py | 16 +++----- tests/test_tongs_mounts.py | 39 ++++++++++-------- 7 files changed, 114 insertions(+), 156 deletions(-) diff --git a/swarmforge/anvil/orchestrate.py b/swarmforge/anvil/orchestrate.py index 69c04c4..37398d0 100644 --- a/swarmforge/anvil/orchestrate.py +++ b/swarmforge/anvil/orchestrate.py @@ -126,7 +126,7 @@ def unsupported_tong_reasons(merged): def check_tmux_socket_dir(host_dir, socket_path, uid=None): """Refuse a tmux socket directory that is not plainly the launcher's own. - `host_dir` and `socket_path` come from a `$TMUX` that `tongs.resolve_tmux_socket` + `host_dir` and `socket_path` come from a `$TMUX` that `tongs.resolve_tmux_server` already accepted, which judged only the spelling. Here the host itself is checked, since the whole directory is bound into the tong: it must be reached with no symlink in any component (docker follows one, binding wherever it @@ -210,7 +210,7 @@ def ensure_mcp_harness_supported(merged, harness): def _start_one_tong(docker, name, defn, *, container, network, alias, resolver, workspace, label_hash, make_channel, volume_scope, - tmux=None, tmux_pane=None, session_handle=None): + tmux_server=None, session_handle=None): """Start one tong container detached, delivering any secret env over a FIFO. Resolves the definition's secret references through `resolver` and splits the @@ -225,8 +225,8 @@ def _start_one_tong(docker, name, defn, *, container, network, alias, the bytes live only in docker's API stream and the container kernel's pipe buffer -- never `-e`, argv, or disk. - `tmux`, `tmux_pane`, and `session_handle` reach `tongs.tong_run_argv`, which - uses them only for a tong that mounts `tmux-socket`. + `tmux_server` and `session_handle` reach `tongs.tong_run_argv`, which uses + them only for a tong that mounts `tmux-socket`. Once the argv is assembled, any existing container of the same name is removed so a stale or stopped one is replaced cleanly -- but a definition the argv @@ -247,17 +247,21 @@ def _start_one_tong(docker, name, defn, *, container, network, alias, except ValueError as exc: raise OrchestrationError("tong '%s': %s" % (name, exc)) - if not secrets: + def run_argv(**secret_wiring): try: - argv = tongs.tong_run_argv( + return tongs.tong_run_argv( name, defn, container_name=container, network=network, alias=alias, env=plain_env, label_hash=label_hash, workspace=workspace, extra_mount_specs=git_dir_specs, volume_scope=volume_scope, - tmux=tmux, tmux_pane=tmux_pane, session_handle=session_handle, + tmux_server=tmux_server, session_handle=session_handle, + **secret_wiring, ) except ValueError as exc: raise OrchestrationError("tong '%s': %s" % (name, exc)) + + if not secrets: + argv = run_argv() docker.rm_force(container) docker.run_detached(argv) return @@ -271,17 +275,7 @@ def _start_one_tong(docker, name, defn, *, container, network, alias, payload = tongs.render_secret_exports(secrets) # Assembled before the rm_force below: a refused definition must remove nothing. - try: - argv = tongs.tong_run_argv( - name, defn, - container_name=container, network=network, alias=alias, - env=plain_env, label_hash=label_hash, workspace=workspace, - secret_channel=True, entrypoint=entrypoint, command=command, - extra_mount_specs=git_dir_specs, volume_scope=volume_scope, - tmux=tmux, tmux_pane=tmux_pane, session_handle=session_handle, - ) - except ValueError as exc: - raise OrchestrationError("tong '%s': %s" % (name, exc)) + argv = run_argv(secret_channel=True, entrypoint=entrypoint, command=command) try: docker.rm_force(container) docker.run_detached(argv) @@ -395,19 +389,19 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None, tongs are left running. Teardown runs inside the caller's `teardown_guard()`, which keeps signals from cutting it short; the default guards nothing. - A `session` tong that mounts `tmux-socket` is handed the launcher's - `opts.tmux`/`opts.tmux_pane` and the session handle; both values, and the - socket directory on disk (through `check_tmux_dir`, `check_tmux_socket_dir` - unless a test injects another), are checked before anything starts. + When a tong mounts `tmux-socket`, the launcher's `opts.tmux`/`opts.tmux_pane` + are resolved once (`tongs.resolve_tmux_server`) and the socket directory + checked on disk (through `check_tmux_dir`, `check_tmux_socket_dir` unless a + test injects another) before anything starts; each such tong is handed the + result and the session handle. Returns the anvil's exit code. Raises `OrchestrationError` if a tong never becomes ready (the anvil does not run against a half-up environment), a `session` tong is discovered with no anvil `--name` to key the session by, or a tong mounts `tmux-socket` while the launcher is outside tmux, its `$TMUX` - or `$TMUX_PANE` is malformed, its socket directory fails - `check_tmux_socket_dir`, or the definition makes one of the names the - launcher sets a secret reference, and `SecretResolutionError` if a secret - reference cannot be resolved. + or `$TMUX_PANE` is malformed, or its socket directory fails + `check_tmux_socket_dir`, and `SecretResolutionError` if a secret reference + cannot be resolved. """ ensure_mcp_harness_supported(merged, opts.harness) resolver = make_secret_resolver(providers or {}) @@ -427,27 +421,14 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None, name for name in sorted(merged) if tongs.mounts_word(merged[name]["definition"], tongs.TMUX_MOUNT) ] - for name in tmux_names: - # Validation refuses this too; a secret here would override the launcher's values. - owned_secret = tongs.tmux_secret_env_error(merged[name]["definition"]) - if owned_secret: - raise OrchestrationError("tong '%s': %s" % (name, owned_secret)) + tmux_server = None if tmux_names: - if not opts.tmux: - raise OrchestrationError( - "tong(s) %s mount '%s', so the launcher must be started from inside " - "a tmux pane ($TMUX is unset)" % (", ".join(tmux_names), tongs.TMUX_MOUNT) - ) - try: - tmux_dir, tmux_socket_path, _ = tongs.resolve_tmux_socket(opts.tmux) - if opts.tmux_pane: - tongs.check_tmux_pane(opts.tmux_pane) - except ValueError as exc: - raise OrchestrationError("tong(s) %s: %s" % (", ".join(tmux_names), exc)) try: - check_tmux_dir(tmux_dir, tmux_socket_path) - except OrchestrationError as exc: - raise OrchestrationError("tong(s) %s: %s" % (", ".join(tmux_names), exc)) + tmux_server = tongs.resolve_tmux_server(opts.tmux, opts.tmux_pane) + check_tmux_dir(tmux_server.host_dir, tmux_server.socket_path) + except (ValueError, OrchestrationError) as exc: + raise OrchestrationError("tong(s) %s mount '%s': %s" + % (", ".join(tmux_names), tongs.TMUX_MOUNT, exc)) # No org layer means no token, and every shared tong keeps its global unscoped name. org_tongs_dir = dict(opts.layer_dirs).get(tongs.ORG) @@ -497,7 +478,7 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None, resolver=resolver, workspace=opts.workspace, label_hash=tongs.config_hash(defn), make_channel=make_channel, volume_scope=volume_scopes[name], - tmux=opts.tmux, tmux_pane=opts.tmux_pane, session_handle=session_id, + tmux_server=tmux_server, session_handle=session_id, ) probe_network = plan["network"] else: diff --git a/swarmforge/tongs/__init__.py b/swarmforge/tongs/__init__.py index 81b4293..1036643 100644 --- a/swarmforge/tongs/__init__.py +++ b/swarmforge/tongs/__init__.py @@ -126,7 +126,7 @@ WORKSPACE_MOUNT, WORKSPACE_VOLUME_SCOPE, CheckedMount, - check_tmux_pane, + TmuxServer, checked_mounts, mount_destination, mount_mode_error, @@ -136,7 +136,7 @@ overlapping_mount_error, parse_mount, reserved_mount_targets, - resolve_tmux_socket, + resolve_tmux_server, tmux_secret_env_error, tong_mount_specs, tong_volume_name, @@ -247,7 +247,7 @@ "WORKSPACE_MOUNT", "WORKSPACE_VOLUME_SCOPE", "CheckedMount", - "check_tmux_pane", + "TmuxServer", "checked_mounts", "mount_destination", "mount_mode_error", @@ -257,7 +257,7 @@ "overlapping_mount_error", "parse_mount", "reserved_mount_targets", - "resolve_tmux_socket", + "resolve_tmux_server", "tmux_secret_env_error", "tong_mount_specs", "tong_volume_name", diff --git a/swarmforge/tongs/argv.py b/swarmforge/tongs/argv.py index ed1d913..2e1b9de 100644 --- a/swarmforge/tongs/argv.py +++ b/swarmforge/tongs/argv.py @@ -23,9 +23,8 @@ ) from .mounts import ( DEFAULT_DOCKER_SOCKET, - check_tmux_pane, + TMUX_LAUNCHER_ENV, mounts_word, - resolve_tmux_socket, tong_mount_specs, ) from .secrets import SECRET_FIFO_TMPFS, declared_run_override @@ -140,8 +139,7 @@ def tong_run_argv( command=None, extra_mount_specs=None, volume_scope=None, - tmux=None, - tmux_pane=None, + tmux_server=None, session_handle=None, ): """Full `docker run -d` argv that starts one tong container. @@ -164,18 +162,18 @@ def tong_run_argv( sessions, so a per-session workspace path would be stale (and a leak) for later ones. - A `tmux-socket` tong is pointed at the launcher's tmux server: `tmux` (the - launcher's raw `$TMUX`) becomes the mount's host source and the tong's `TMUX` - (see `resolve_tmux_socket`), `tmux_pane` its `TMUX_PANE`, and `session_handle` - its `SWARMFORGE_SESSION_HANDLE`. These describe the host and the session, which - a definition cannot know, so they replace a plain value its `env:` sets for - those names -- and a name the launcher has no value for is dropped rather than - left to the definition. A secret reference on one of them would arrive over - the FIFO after these and win, so validation refuses it (see - `tmux_secret_env_error`). Raises `ValueError` when the definition mounts - `tmux-socket` and is not a `session` tong, `tmux` is empty or malformed, or - `tmux_pane` is malformed. A tong without the mount gets none of them, whatever - is passed. + A `tmux-socket` tong is pointed at the launcher's tmux server: `tmux_server` + (from `resolve_tmux_server`) gives the mount's host source and the tong's + `TMUX` and `TMUX_PANE`, and `session_handle` its `SWARMFORGE_SESSION_HANDLE`. + These describe the host and the session, which a definition cannot know, so + they replace a plain value its `env:` sets for those names -- and a name the + launcher has no value for is dropped rather than left to the definition. A + secret reference on one of them would arrive over the FIFO after these and + win, so validation refuses it (see `tmux_secret_env_error`). Raises + `ValueError` when the definition mounts `tmux-socket` and is not a `session` + tong, or for any mount `tong_mount_specs` refuses, which includes a + `tmux-socket` mount with no `tmux_server`. A tong without the mount gets none + of them, whatever is passed. When the tong has secret env, the launcher passes `secret_channel=True` (which mounts a tmpfs at `SECRET_FIFO_DIR` for the wrapper's FIFO), `entrypoint` @@ -211,30 +209,23 @@ def tong_run_argv( if secret_channel: argv += ["--tmpfs", SECRET_FIFO_TMPFS] effective_env = dict(env or {}) - tmux_socket_dir = None - if mounts_word(defn, TMUX_MOUNT): - if defn.get("lifecycle") != "session": - raise ValueError( - "mount '%s' is only allowed on a 'session' tong; a shared container " - "would carry one session's tmux server into the next" % TMUX_MOUNT) - if not tmux: - raise ValueError( - "mount '%s' requested but the launcher is not running inside tmux " - "($TMUX is unset)" % TMUX_MOUNT) - tmux_socket_dir, _, effective_env["TMUX"] = resolve_tmux_socket(tmux) - launcher_env = { - "TMUX_PANE": check_tmux_pane(tmux_pane) if tmux_pane else None, - SESSION_HANDLE_ENV: session_handle, - } - for key, value in launcher_env.items(): + mounts_tmux = mounts_word(defn, TMUX_MOUNT) + if mounts_tmux and defn.get("lifecycle") != "session": + raise ValueError( + "mount '%s' is only allowed on a 'session' tong; a shared container " + "would carry one session's tmux server into the next" % TMUX_MOUNT) + if mounts_tmux and tmux_server: + launcher_env = dict(tmux_server.env, **{SESSION_HANDLE_ENV: session_handle}) + for key in TMUX_LAUNCHER_ENV: effective_env.pop(key, None) - if value: - effective_env[key] = value - has_socket = mounts_word(defn, SOCKET_MOUNT) or mounts_word(defn, TMUX_MOUNT) + if launcher_env.get(key): + effective_env[key] = launcher_env[key] + has_socket = mounts_tmux or mounts_word(defn, SOCKET_MOUNT) if workspace and has_socket and defn.get("lifecycle") == "session": effective_env.setdefault(WORKSPACE_HOST_ENV, workspace) for key in sorted(effective_env): argv += ["-e", "%s=%s" % (key, effective_env[key])] + tmux_socket_dir = tmux_server.host_dir if tmux_server else None for spec in tong_mount_specs(defn, workspace, socket_path=socket_path, tong_name=name, volume_scope=volume_scope, extra_mount_specs=extra_mount_specs or (), diff --git a/swarmforge/tongs/mounts.py b/swarmforge/tongs/mounts.py index 89c80ed..9f4237a 100644 --- a/swarmforge/tongs/mounts.py +++ b/swarmforge/tongs/mounts.py @@ -68,6 +68,9 @@ VOLUME_NAME_MAX_LENGTH = 64 VOLUME_HINT_LIMIT = 32 +# `env` holds the tong's `TMUX` and `TMUX_PANE`, the pane None outside a pane. +TmuxServer = collections.namedtuple("TmuxServer", ["host_dir", "socket_path", "env"]) + def mounts_word(defn, word): """True if a tong's `mounts:` request the magic `word`. @@ -368,7 +371,7 @@ def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, `extra_mount_specs` (see `tong_run_argv`), or docker would leave their mountpoints inside it. `tmux-socket[:ro]` bind-mounts `tmux_socket_dir` -- the host directory holding the launcher's tmux socket, from - `resolve_tmux_socket` -- read-only at `TMUX_MOUNT_TARGET`. The directory + `resolve_tmux_server` -- read-only at `TMUX_MOUNT_TARGET`. The directory rather than the socket, because tmux deletes and recreates the socket when its server restarts. Raises `ValueError` for any entry `checked_mounts` refuses, a volume @@ -415,23 +418,27 @@ def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, return specs -def resolve_tmux_socket(tmux): - """The host directory a `tmux-socket` mount binds, and the tong's own `TMUX`. - - `tmux` is the launcher's raw `$TMUX`, `,,`, - and this is the one place it is parsed. Returns - `(host_dir, socket_path, container_tmux)`: the socket's parent directory, the - socket's host path, and the same value re-pointed at the socket's name under - `TMUX_MOUNT_TARGET`, so a stock tmux client in the tong reaches the host - server with no flags. - - Raises `ValueError` unless the value has exactly three comma-separated fields, - the pid and session id are decimal, and the socket path is a normalized - absolute path with no `:` (docker's `-v` separator), `,` (tmux reads the path - up to the first one), or whitespace. The socket must also sit directly in - tmux's own per-user directory (`tmux-`): binding a `-S` socket's parent - could hand the tong an arbitrary host directory, such as a home directory. +def resolve_tmux_server(tmux, tmux_pane=None): + """The launcher's tmux server as a `tmux-socket` tong reaches it, a `TmuxServer`. + + `tmux` and `tmux_pane` are the launcher's raw `$TMUX` and `$TMUX_PANE`, and + this is the one place they are parsed. `$TMUX` is + `,,`; the result holds the socket's parent + directory (what the mount binds), the socket's host path, and the tong's env: + `TMUX` re-pointed at the socket's name under `TMUX_MOUNT_TARGET`, so a stock + tmux client in the tong reaches the host server with no flags, and `TMUX_PANE` + as given (None when it is unset). + + Raises `ValueError` when `tmux` is unset, or unless it has exactly three + comma-separated fields, the pid and session id are decimal, and the socket path + is a normalized absolute path with no `:` (docker's `-v` separator), `,` (tmux + reads the path up to the first one), or whitespace. The socket must also sit + directly in tmux's own per-user directory (`tmux-`): binding a `-S` + socket's parent could hand the tong an arbitrary host directory, such as a home + directory. A set `tmux_pane` must be a pane id (`%`). """ + if not tmux: + raise ValueError("the launcher is not running inside tmux ($TMUX is unset)") fields = tmux.rsplit(",", 2) if isinstance(tmux, str) else [] if len(fields) != 3: raise ValueError( @@ -452,15 +459,10 @@ def resolve_tmux_socket(tmux): "$TMUX %r: socket %r is not in tmux's default per-user socket directory " "(tmux- under $TMUX_TMPDIR or /tmp); a socket at a custom -S path " "is refused, since its whole directory would be mounted" % (tmux, path)) - return (host_dir, path, - "%s/%s,%s,%s" % (TMUX_MOUNT_TARGET, socket_name, pid, session)) - - -def check_tmux_pane(pane): - """The launcher's `$TMUX_PANE` (`%`), or `ValueError` if it is not one.""" - if not isinstance(pane, str) or not _TMUX_PANE_RE.fullmatch(pane): - raise ValueError("$TMUX_PANE %r is not a tmux pane id ('%%')" % (pane,)) - return pane + if tmux_pane and not (isinstance(tmux_pane, str) and _TMUX_PANE_RE.fullmatch(tmux_pane)): + raise ValueError("$TMUX_PANE %r is not a tmux pane id ('%%')" % (tmux_pane,)) + container_tmux = "%s/%s,%s,%s" % (TMUX_MOUNT_TARGET, socket_name, pid, session) + return TmuxServer(host_dir, path, {"TMUX": container_tmux, "TMUX_PANE": tmux_pane or None}) def tmux_secret_env_error(defn): diff --git a/tests/test_anvil_orchestrate.py b/tests/test_anvil_orchestrate.py index c1c04b8..ca2f56c 100644 --- a/tests/test_anvil_orchestrate.py +++ b/tests/test_anvil_orchestrate.py @@ -901,7 +901,7 @@ def _run_tmux(self, docker, tmux, tmux_pane=None, workspace=None, def test_tmux_tong_outside_tmux_raises_before_any_docker_call(self): docker = FakeDocker() - with self.assertRaisesRegex(launcher.OrchestrationError, "inside a tmux pane"): + with self.assertRaisesRegex(launcher.OrchestrationError, "not running inside tmux"): self._run_tmux(docker, tmux=None) self.assertEqual(docker.calls, []) @@ -936,21 +936,6 @@ def test_tmux_values_reach_a_tong_started_over_the_secret_channel(self): self.assertEqual( started[started.index("/tmp/tmux-1000:/run/swarmforge-tmux:ro") - 1], "-v") - def test_tmux_launcher_env_as_a_secret_never_reaches_docker(self): - docker = FakeDocker() - channels = FakeChannels() - defn = dict(SESSION_TMUX, env={"TMUX_PANE": "${secret:echo:%9}"}) - with self.assertRaisesRegex(launcher.OrchestrationError, - "tong 'spawner'.*TMUX_PANE.*secret reference"): - launcher.run_with_tongs( - _merged("spawner", defn, source=tongs.REPO), ANVIL_ARGV, - _opts(tmux="/tmp/tmux-1000/default,4242,3", tmux_pane="%7"), - docker=docker, providers=ECHO_PROVIDERS, make_channel=channels, - sleep=lambda _s: None, monotonic=_Clock(), - ) - self.assertEqual(docker.calls, []) - self.assertEqual(channels.payloads, []) - def test_tmux_values_reach_only_the_tmux_tongs_argv(self): docker = FakeDocker() rc = self._run_tmux( @@ -989,7 +974,7 @@ def test_tmux_socket_dir_checked_on_disk_before_any_docker_call(self): os.chmod(tmux_dir, 0o755) docker = FakeDocker() with self.assertRaisesRegex(launcher.OrchestrationError, - "tong\\(s\\) spawner: .*open to group or others"): + "tong\\(s\\) spawner mount 'tmux-socket': .*open to group or others"): self._run_tmux(docker, tmux=tmux, check_tmux_dir=launcher.check_tmux_socket_dir) self.assertEqual(docker.calls, []) diff --git a/tests/test_tongs_argv.py b/tests/test_tongs_argv.py index 5ba07a0..8525891 100644 --- a/tests/test_tongs_argv.py +++ b/tests/test_tongs_argv.py @@ -358,10 +358,11 @@ def _tmux_argv(self, env=None, mounts=("tmux-socket",), lifecycle="session", defn = def_of(NONE_TONG) defn["mounts"] = list(mounts) defn["lifecycle"] = lifecycle + tmux_server = tongs.resolve_tmux_server(tmux, tmux_pane) if tmux else None return tongs.tong_run_argv( "spawner", defn, container_name="c", network="n", alias="spawner", env=env, workspace="/host/ws", - tmux=tmux, tmux_pane=tmux_pane, session_handle=session_handle, + tmux_server=tmux_server, session_handle=session_handle, ) def test_run_argv_tmux_tong_gets_the_host_server_and_session(self): @@ -395,16 +396,9 @@ def test_run_argv_tmux_names_the_launcher_lacks_are_dropped(self): self.assertNotIn("SWARMFORGE_SESSION_HANDLE", joined) self.assertIn("TMUX=/run/swarmforge-tmux/default,4242,3", argv) - def test_run_argv_tmux_mount_without_tmux_raises(self): - for tmux in (None, ""): - with self.assertRaisesRegex(ValueError, "not running inside tmux"): - self._tmux_argv(tmux=tmux) - - def test_run_argv_tmux_malformed_values_raise(self): - with self.assertRaisesRegex(ValueError, "default per-user socket directory"): - self._tmux_argv(tmux="/home/me/s,1,0") - with self.assertRaisesRegex(ValueError, "pane id"): - self._tmux_argv(tmux_pane="7") + def test_run_argv_tmux_mount_without_a_server_raises(self): + with self.assertRaisesRegex(ValueError, "no tmux socket directory is known"): + self._tmux_argv(tmux=None) def test_run_argv_without_tmux_mount_injects_nothing(self): for mounts in ((), ("docker-socket",), ("workspace:ro",)): diff --git a/tests/test_tongs_mounts.py b/tests/test_tongs_mounts.py index 7326ceb..87c254e 100644 --- a/tests/test_tongs_mounts.py +++ b/tests/test_tongs_mounts.py @@ -463,64 +463,69 @@ def test_mount_specs_non_string_raises(self): class TmuxSocketTests(unittest.TestCase): - """Resolving the launcher's `$TMUX` into the directory to bind and the tong's `TMUX`.""" + """Resolving the launcher's `$TMUX`/`$TMUX_PANE` into the directory to bind and the tong's env.""" def test_resolves_the_directory_and_rewrites_the_value(self): self.assertEqual( - tongs.resolve_tmux_socket("/tmp/tmux-1000/default,4242,3"), + tongs.resolve_tmux_server("/tmp/tmux-1000/default,4242,3", "%7"), ("/tmp/tmux-1000", "/tmp/tmux-1000/default", - "/run/swarmforge-tmux/default,4242,3"), + {"TMUX": "/run/swarmforge-tmux/default,4242,3", "TMUX_PANE": "%7"}), ) def test_honors_a_tmux_tmpdir_and_a_named_server(self): self.assertEqual( - tongs.resolve_tmux_socket("/run/user/1000/tmux-1000/work,1,0"), + tongs.resolve_tmux_server("/run/user/1000/tmux-1000/work,1,0"), ("/run/user/1000/tmux-1000", "/run/user/1000/tmux-1000/work", - "/run/swarmforge-tmux/work,1,0"), + {"TMUX": "/run/swarmforge-tmux/work,1,0", "TMUX_PANE": None}), ) def test_relative_or_unnormalized_path_refused(self): for value in ("tmux-1000/default,1,0", "/tmp/tmux-1000/../tmux-1000/default,1,0", "/tmp/tmux-1000/,1,0", "//tmux-1000/default,1,0"): with self.assertRaisesRegex(ValueError, "normalized absolute path"): - tongs.resolve_tmux_socket(value) + tongs.resolve_tmux_server(value) def test_wrong_field_count_refused(self): - for value in ("", "/tmp/tmux-1000/default", "/tmp/tmux-1000/default,1"): + for value in ("/tmp/tmux-1000/default", "/tmp/tmux-1000/default,1"): with self.assertRaisesRegex(ValueError, ",,"): - tongs.resolve_tmux_socket(value) - with self.assertRaises(ValueError): - tongs.resolve_tmux_socket(None) + tongs.resolve_tmux_server(value) + + def test_unset_tmux_refused(self): + for value in ("", None): + with self.assertRaisesRegex(ValueError, r"not running inside tmux \(\$TMUX is unset\)"): + tongs.resolve_tmux_server(value) def test_extra_field_refused(self): # Split from the right, an extra field ends up in the path as a comma. with self.assertRaisesRegex(ValueError, "contains"): - tongs.resolve_tmux_socket("/tmp/tmux-1000/default,9,1,0") + tongs.resolve_tmux_server("/tmp/tmux-1000/default,9,1,0") def test_non_decimal_pid_or_session_refused(self): for value in ("/tmp/tmux-1000/default,abc,0", "/tmp/tmux-1000/default,1,$0", "/tmp/tmux-1000/default,,0", "/tmp/tmux-1000/default,1, 0"): with self.assertRaisesRegex(ValueError, "decimal"): - tongs.resolve_tmux_socket(value) + tongs.resolve_tmux_server(value) def test_colon_or_whitespace_in_path_refused(self): for value in ("/tmp/tmux-1000/a:b,1,0", "/tmp/tmux-1000/a b,1,0", "/tmp/tmux-1000/a\tb,1,0"): with self.assertRaisesRegex(ValueError, "contains"): - tongs.resolve_tmux_socket(value) + tongs.resolve_tmux_server(value) def test_socket_outside_tmuxs_own_directory_refused(self): # Binding a -S socket's parent could mount an arbitrary host directory. for value in ("/home/me/my.sock,1,0", "/tmp/default,1,0", "/default,1,0", "/tmp/tmux-me/default,1,0", "/tmp/tmux-1000/sub/default,1,0"): with self.assertRaisesRegex(ValueError, "default per-user socket directory"): - tongs.resolve_tmux_socket(value) + tongs.resolve_tmux_server(value) def test_pane_id_checked(self): - self.assertEqual(tongs.check_tmux_pane("%12"), "%12") - for pane in ("12", "%", "%1a", " %1", "%1\n", None): + tmux = "/tmp/tmux-1000/default,1,0" + self.assertEqual(tongs.resolve_tmux_server(tmux, "%12").env["TMUX_PANE"], "%12") + self.assertIsNone(tongs.resolve_tmux_server(tmux, "").env["TMUX_PANE"]) + for pane in ("12", "%", "%1a", " %1", "%1\n"): with self.assertRaisesRegex(ValueError, "pane id"): - tongs.check_tmux_pane(pane) + tongs.resolve_tmux_server(tmux, pane) if __name__ == "__main__": From 200dd999caa5dbd0368d19073210bf1da9c305ed Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Tue, 6 Oct 2026 10:44:11 -0500 Subject: [PATCH 08/10] Refuse session-only mounts on shared tongs at validation The shared-tong rule for workspace and tmux-socket lived in unsupported_tong_reasons and an argv raise, so `tongs validate` passed a definition the launch then refused. mount_lifecycle_error now holds it, and checked_mounts applies it everywhere. --- swarmforge/anvil/orchestrate.py | 26 ++++--------------------- swarmforge/tongs/__init__.py | 4 ++++ swarmforge/tongs/argv.py | 7 +------ swarmforge/tongs/mounts.py | 28 +++++++++++++++++++++++---- tests/test_anvil_orchestrate.py | 34 +-------------------------------- tests/test_tongs_argv.py | 2 +- tests/test_tongs_validate.py | 12 ++++++++++++ 7 files changed, 47 insertions(+), 66 deletions(-) diff --git a/swarmforge/anvil/orchestrate.py b/swarmforge/anvil/orchestrate.py index 37398d0..8237b5a 100644 --- a/swarmforge/anvil/orchestrate.py +++ b/swarmforge/anvil/orchestrate.py @@ -75,17 +75,11 @@ def unsupported_tong_reasons(merged): * a `volume` interface -- a shared named volume has no consumer yet, so it is not wired into either container; - * a `shared` tong that mounts the `workspace` -- a `shared` tong is one - long-lived container reused across sessions, so binding one session's - workspace into it would expose that workspace to every later session that - reuses the container (a `session` tong is the right home for a - per-workspace mount); * a workspace-sourced `shared` tong that mounts a `volume` -- its volume - is scoped to one checkout but its container is not; - * a `shared` tong that mounts the `tmux-socket` -- the same container - outlives the session and is reused by later ones, so it would carry one - session's tmux server, and its pinning to that session's pane, into the - next. + is scoped to one checkout but its container is not. + + (A `shared` tong that mounts one session's state is a definition error, + refused by validation through `tongs.mount_lifecycle_error`.) A refused tong is reported rather than started half-wired. Returns a list of human-readable reason strings (empty == every discovered tong is startable). @@ -99,12 +93,6 @@ def unsupported_tong_reasons(merged): "tong '%s' has a 'volume' interface, which this launcher does not " "wire up" % name ) - if defn.get("lifecycle") == "shared" and tongs.mounts_word(defn, tongs.WORKSPACE_MOUNT): - reasons.append( - "tong '%s' is a 'shared' tong that mounts the workspace; a shared " - "container is reused across sessions, so it would leak one " - "session's workspace into the next" % name - ) if (defn.get("lifecycle") == "shared" and tongs.is_workspace_sourced(merged[name]["source"]) and tongs.mounts_word(defn, tongs.VOLUME_MOUNT)): @@ -114,12 +102,6 @@ def unsupported_tong_reasons(merged): "sessions in other checkouts, so it would leak this checkout's " "data into theirs (make it a 'session' tong)" % name ) - if defn.get("lifecycle") == "shared" and tongs.mounts_word(defn, tongs.TMUX_MOUNT): - reasons.append( - "tong '%s' is a 'shared' tong that mounts the tmux socket; a shared " - "container outlives the session and is reused by later ones, so it " - "would carry one session's tmux server into the next" % name - ) return reasons diff --git a/swarmforge/tongs/__init__.py b/swarmforge/tongs/__init__.py index 1036643..8d89e5f 100644 --- a/swarmforge/tongs/__init__.py +++ b/swarmforge/tongs/__init__.py @@ -119,6 +119,7 @@ MOUNT_WORDS, ORG_VOLUME_SCOPE, TMUX_LAUNCHER_ENV, + SESSION_ONLY_MOUNTS, TMUX_MOUNT_TARGET, VOLUME_DIGEST_LENGTH, VOLUME_MOUNT, @@ -129,6 +130,7 @@ TmuxServer, checked_mounts, mount_destination, + mount_lifecycle_error, mount_mode_error, mount_target_error, mounts_word, @@ -240,6 +242,7 @@ "MOUNT_WORDS", "ORG_VOLUME_SCOPE", "TMUX_LAUNCHER_ENV", + "SESSION_ONLY_MOUNTS", "TMUX_MOUNT_TARGET", "VOLUME_DIGEST_LENGTH", "VOLUME_MOUNT", @@ -250,6 +253,7 @@ "TmuxServer", "checked_mounts", "mount_destination", + "mount_lifecycle_error", "mount_mode_error", "mount_target_error", "mounts_word", diff --git a/swarmforge/tongs/argv.py b/swarmforge/tongs/argv.py index 2e1b9de..ee7fbca 100644 --- a/swarmforge/tongs/argv.py +++ b/swarmforge/tongs/argv.py @@ -170,8 +170,7 @@ def tong_run_argv( launcher has no value for is dropped rather than left to the definition. A secret reference on one of them would arrive over the FIFO after these and win, so validation refuses it (see `tmux_secret_env_error`). Raises - `ValueError` when the definition mounts `tmux-socket` and is not a `session` - tong, or for any mount `tong_mount_specs` refuses, which includes a + `ValueError` for any mount `tong_mount_specs` refuses, which includes a `tmux-socket` mount with no `tmux_server`. A tong without the mount gets none of them, whatever is passed. @@ -210,10 +209,6 @@ def tong_run_argv( argv += ["--tmpfs", SECRET_FIFO_TMPFS] effective_env = dict(env or {}) mounts_tmux = mounts_word(defn, TMUX_MOUNT) - if mounts_tmux and defn.get("lifecycle") != "session": - raise ValueError( - "mount '%s' is only allowed on a 'session' tong; a shared container " - "would carry one session's tmux server into the next" % TMUX_MOUNT) if mounts_tmux and tmux_server: launcher_env = dict(tmux_server.env, **{SESSION_HANDLE_ENV: session_handle}) for key in TMUX_LAUNCHER_ENV: diff --git a/swarmforge/tongs/mounts.py b/swarmforge/tongs/mounts.py index 9f4237a..55e972f 100644 --- a/swarmforge/tongs/mounts.py +++ b/swarmforge/tongs/mounts.py @@ -40,6 +40,9 @@ # An allowlist, so an unvetted mount option cannot reach the daemon. MOUNT_MODES = ("ro", "rw") +# Mounts that bind one session's host state, so a reused `shared` container would leak it. +SESSION_ONLY_MOUNTS = {WORKSPACE_MOUNT: "workspace", TMUX_MOUNT: "tmux socket"} + # What tmux creates (mode 0700) under $TMUX_TMPDIR or /tmp for each user's sockets. _TMUX_SOCKET_DIR_RE = re.compile(r"tmux-[0-9]+") _DECIMAL_RE = re.compile(r"[0-9]+") @@ -265,6 +268,21 @@ def mount_mode_error(mount, word, mode): return None +def mount_lifecycle_error(mount, word, lifecycle): + """Why a mount is refused for the tong's lifecycle, or None if it is fine. + + A `SESSION_ONLY_MOUNTS` word binds one session's host state, and a `shared` + container is reused by later sessions, so it would carry that binding into + them. Validation refuses a missing or unknown lifecycle on its own. + """ + if word in SESSION_ONLY_MOUNTS and lifecycle == "shared": + what = SESSION_ONLY_MOUNTS[word] + return ("mount %r: a 'shared' tong may not mount the %s; its container is " + "reused across sessions, so it would carry one session's %s into " + "the next" % (mount, what, what)) + return None + + def overlapping_mount_error(mount, destination, volume, placed): """Why a mount collides with one already placed, or None if it is clear. @@ -322,12 +340,13 @@ def checked_mounts(defn, socket_path=DEFAULT_DOCKER_SOCKET): Validation and argv assembly both walk a definition's mounts through here, so they accept and refuse the same entries with the same message. An entry is refused for not being a string, failing `parse_mount`, or any of - `mount_target_error`, `mount_mode_error` and `overlapping_mount_error`; a - refused entry has `error` set (and `word` and `destination` None when it never - parsed) and is not placed, so later entries are judged for overlap against the - accepted ones only. + `mount_target_error`, `mount_mode_error`, `mount_lifecycle_error` and + `overlapping_mount_error`; a refused entry has `error` set (and `word` and + `destination` None when it never parsed) and is not placed, so later entries + are judged for overlap against the accepted ones only. """ reserved = reserved_mount_targets(defn, socket_path) + lifecycle = defn.get("lifecycle") placed = [] # (mount, destination, volume) already accepted for mount in defn.get("mounts") or []: if not isinstance(mount, str): @@ -342,6 +361,7 @@ def checked_mounts(defn, socket_path=DEFAULT_DOCKER_SOCKET): continue error = (mount_target_error(mount, word, target, destination, reserved) or mount_mode_error(mount, word, mode) + or mount_lifecycle_error(mount, word, lifecycle) or overlapping_mount_error(mount, destination, volume, placed)) if not error: placed.append((mount, destination, volume)) diff --git a/tests/test_anvil_orchestrate.py b/tests/test_anvil_orchestrate.py index ca2f56c..a247268 100644 --- a/tests/test_anvil_orchestrate.py +++ b/tests/test_anvil_orchestrate.py @@ -84,30 +84,6 @@ def test_secret_tong_startable_on_either_lifecycle(self): {"lifecycle": "session", "image": "x", "env": {"T": "${secret:op:r}"}, "interface": {"kind": "port", "port": 5432}, "readiness": {"mode": "none"}}), []) - def test_shared_workspace_mount_refused_but_docker_socket_allowed(self): - # A shared workspace mount leaks the workspace across sessions. - self.assertTrue(any( - "workspace" in r for r in self._reasons({ - "lifecycle": "shared", "image": "x", "mounts": ["workspace:ro"], - "interface": {"kind": "none"}, "readiness": {"mode": "none"}, - }) - )) - self.assertEqual( - self._reasons({ - "lifecycle": "shared", "image": "x", "mounts": ["docker-socket"], - "interface": {"kind": "none"}, "readiness": {"mode": "none"}, - }), - [], - ) - - def test_shared_workspace_refused_with_a_custom_mount_target(self): - self.assertTrue(any( - "workspace" in r for r in self._reasons({ - "lifecycle": "shared", "image": "x", "mounts": ["workspace:/code:ro"], - "interface": {"kind": "none"}, "readiness": {"mode": "none"}, - }) - )) - def test_workspace_shared_tong_with_a_volume_refused(self): # Its container is shared by every checkout while its volume belongs to one. defn = { @@ -132,14 +108,6 @@ def test_volume_allowed_where_container_and_volume_scope_agree(self): session = dict(shared, lifecycle="session") self.assertEqual(launcher.unsupported_tong_reasons(_merged("t", session)), []) - def test_shared_tmux_socket_mount_refused(self): - for mount in ("tmux-socket", "tmux-socket:ro"): - reasons = self._reasons({ - "lifecycle": "shared", "image": "x", "mounts": [mount], - "interface": {"kind": "none"}, "readiness": {"mode": "none"}, - }) - self.assertTrue(any("tmux socket" in r for r in reasons), mount) - def test_session_tmux_socket_mount_startable(self): self.assertEqual( self._reasons({ @@ -959,7 +927,7 @@ def test_shared_tmux_tong_is_never_handed_the_server(self): docker = FakeDocker() shared = dict(SESSION_TMUX, lifecycle="shared") with self.assertRaisesRegex(launcher.OrchestrationError, - "only allowed on a 'session' tong"): + "'shared' tong may not mount the tmux socket"): launcher.run_with_tongs( _merged("spawner", shared, source=tongs.REPO), ANVIL_ARGV, _opts(tmux="/tmp/tmux-1000/default,4242,3"), diff --git a/tests/test_tongs_argv.py b/tests/test_tongs_argv.py index 8525891..7f2bffa 100644 --- a/tests/test_tongs_argv.py +++ b/tests/test_tongs_argv.py @@ -411,7 +411,7 @@ def test_run_argv_without_tmux_mount_injects_nothing(self): def test_run_argv_tmux_mount_on_a_shared_tong_raises(self): # Checked first, so no caller can mount a tmux server into a shared container. for tmux in ("/tmp/tmux-1000/default,4242,3", None): - with self.assertRaisesRegex(ValueError, "only allowed on a 'session' tong"): + with self.assertRaisesRegex(ValueError, "'shared' tong may not mount the tmux socket"): self._tmux_argv(lifecycle="shared", tmux=tmux) def test_anvil_option_value_reads_name_and_network(self): diff --git a/tests/test_tongs_validate.py b/tests/test_tongs_validate.py index 6af7c48..538834c 100644 --- a/tests/test_tongs_validate.py +++ b/tests/test_tongs_validate.py @@ -214,6 +214,18 @@ def test_one_volume_mounted_twice_rejected(self): "t", self._base(mounts=["volume:models:/a", "volume:models:/b:ro"])) self.assertTrue(any("already mounted" in e for e in errors), errors) + def test_shared_tong_may_not_mount_session_state(self): + # A shared container is reused across sessions, so it would leak one into the next. + for mount, what in (("workspace:ro", "workspace"), ("workspace:/code:ro", "workspace"), + ("tmux-socket", "tmux socket"), ("tmux-socket:ro", "tmux socket")): + errors = tongs.validate_tong("t", self._base(lifecycle="shared", mounts=[mount])) + self.assertTrue( + any("'shared' tong may not mount the %s" % what in e for e in errors), errors) + + def test_shared_tong_may_mount_the_docker_socket(self): + self.assertEqual( + tongs.validate_tong("t", self._base(lifecycle="shared", mounts=["docker-socket"])), []) + def test_tmux_socket_mount_accepted_read_only(self): for mount in ("tmux-socket", "tmux-socket:ro"): self.assertEqual(tongs.validate_tong("t", self._base(mounts=[mount])), [], mount) From 11bd190ccf272e8c6eeea2230905bbab0826cf7a Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Tue, 6 Oct 2026 10:44:25 -0500 Subject: [PATCH 09/10] Report tmux directory lstat failures from one place --- swarmforge/anvil/orchestrate.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/swarmforge/anvil/orchestrate.py b/swarmforge/anvil/orchestrate.py index 8237b5a..85b0a1c 100644 --- a/swarmforge/anvil/orchestrate.py +++ b/swarmforge/anvil/orchestrate.py @@ -105,6 +105,14 @@ def unsupported_tong_reasons(merged): return reasons +def _lstat(path, what): + """`os.lstat(path)`, or `OrchestrationError` naming `what` could not be inspected.""" + try: + return os.lstat(path) + except OSError as exc: + raise OrchestrationError("cannot inspect %s: %s" % (what, exc)) + + def check_tmux_socket_dir(host_dir, socket_path, uid=None): """Refuse a tmux socket directory that is not plainly the launcher's own. @@ -127,10 +135,7 @@ def check_tmux_socket_dir(host_dir, socket_path, uid=None): raise OrchestrationError( "tmux socket directory %s is reached through a symlink; docker would " "bind whatever it points at" % host_dir) - try: - dir_stat = os.lstat(host_dir) - except OSError as exc: - raise OrchestrationError("cannot inspect the tmux socket directory: %s" % exc) + dir_stat = _lstat(host_dir, "the tmux socket directory") if not stat.S_ISDIR(dir_stat.st_mode): raise OrchestrationError("tmux socket directory %s is not a directory" % host_dir) if dir_stat.st_uid != uid: @@ -143,11 +148,7 @@ def check_tmux_socket_dir(host_dir, socket_path, uid=None): "tmux creates it 0700" % (host_dir, stat.S_IMODE(dir_stat.st_mode))) ancestor = os.path.dirname(host_dir) while True: - try: - ancestor_stat = os.lstat(ancestor) - except OSError as exc: - raise OrchestrationError("cannot inspect %s above the tmux socket: %s" - % (ancestor, exc)) + ancestor_stat = _lstat(ancestor, "%s above the tmux socket" % ancestor) if ancestor_stat.st_uid not in (0, uid): raise OrchestrationError( "%s, above the tmux socket directory, is owned by uid %d, neither " @@ -162,10 +163,7 @@ def check_tmux_socket_dir(host_dir, socket_path, uid=None): if parent == ancestor: break ancestor = parent - try: - socket_stat = os.lstat(socket_path) - except OSError as exc: - raise OrchestrationError("cannot inspect the tmux socket: %s" % exc) + socket_stat = _lstat(socket_path, "the tmux socket") if not stat.S_ISSOCK(socket_stat.st_mode): raise OrchestrationError("tmux socket %s is not a socket" % socket_path) From 7b14e55b1fcacf9fb1f99ba2a046e6a1849199de Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Tue, 6 Oct 2026 10:46:45 -0500 Subject: [PATCH 10/10] Say each tmux-socket rule once in the prose Docstrings and docs restated the same rules in several places: the approval gate, the shared-mount rule, the tmux env, and "validation and argv agree". Each is now stated where it lives, and the others point there. --- docs/reference/environment.md | 2 +- docs/tongs/approval.md | 2 +- docs/tongs/definitions.md | 2 +- swarmforge/anvil/__init__.py | 14 +++---- swarmforge/anvil/approval.py | 21 +++------- swarmforge/anvil/cli.py | 7 ++-- swarmforge/anvil/orchestrate.py | 40 +++++++------------ swarmforge/tongs/argv.py | 26 +++++-------- swarmforge/tongs/mounts.py | 68 +++++++++++++-------------------- 9 files changed, 68 insertions(+), 114 deletions(-) diff --git a/docs/reference/environment.md b/docs/reference/environment.md index eb7ede3..a7de1ae 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -170,7 +170,7 @@ The launcher reads `SWARMFORGE_USER_ASSETS_DIR` from its own environment too, as | `SWARMFORGE_TONG__HOST` `_PORT` `_PATH` | the tong's alias and port, or its mountpoint | The agent. One set per `port` or `volume` [tong](../tongs/definitions.md#interface-kinds). | | `SWARMFORGE_WORKSPACE_HOST_PATH` | the workspace's path on the host | A `session` tong that mounts `docker-socket` (a [broker](../tongs/broker.md)) or [`tmux-socket`](../tongs/definitions.md#mounts). Never a `shared` tong, and never over a value the tong sets itself. | | `TMUX` | the launcher's `$TMUX`, with the socket re-pointed under `/run/swarmforge-tmux` | A `session` tong that mounts [`tmux-socket`](../tongs/definitions.md#mounts), so a stock `tmux` client reaches the host server. Replaces a plain value the definition sets; a secret reference there is refused at validation. | -| `TMUX_PANE` | the launcher's `$TMUX_PANE` | The same tong, as tmux's default target — a default, not a restriction. Set only when the launcher has one; a plain value the definition sets is dropped either way, and a secret reference is refused. | +| `TMUX_PANE` | the launcher's `$TMUX_PANE` | The same tong, as tmux's default target — a default, not a restriction, since the tong can still name any pane. Set only when the launcher has one; a plain value the definition sets is dropped either way, and a secret reference is refused. | | `SWARMFORGE_SESSION_HANDLE` | the anvil's container name (`--name`) | The same tong, as the session it serves. Replaces a plain value the definition sets; a secret reference there is refused at validation. | | `CLAUDE_CONFIG_DIR` | [the merged config dir](../harnesses/claude-code.md#the-config-directory) | `claude`, set by its `pre_exec` hook. | | `CLAUDE_SECURESTORAGE_CONFIG_DIR` | `/home/anvil/.claude` | `claude`, set by the same hook. | diff --git a/docs/tongs/approval.md b/docs/tongs/approval.md index 9f30ad0..08ac31f 100644 --- a/docs/tongs/approval.md +++ b/docs/tongs/approval.md @@ -3,7 +3,7 @@ The user, org, and repo layers are installed deliberately and are trusted; they skip the gate. A **workspace**-sourced tong (from a repo you cloned) could otherwise request your secrets, host mounts, the docker socket, your tmux server, or the host's GPUs simply by being present, so the launcher gates it: -- Before starting, it prints exactly what the tong requests — image, secret references, mounts, networks, docker-socket access, tmux-socket access, and GPU access — and asks you to approve. Both sockets get a line of their own, since each lets the tong run anything on your host; the tmux line says that this covers every tmux server in your socket directory, and that the mount being read-only does not limit it. Control and formatting characters in those values are shown as escapes such as `\x1b` or `\n`, so a value cannot move the cursor, restyle, or hide any part of the summary. Every `tongs:` warning escapes them the same way. Each volume is listed with its target and docker volume name, with a note that its data persists and is shared by concurrent sessions. +- Before starting, it prints exactly what the tong requests — image, secret references, mounts, networks, docker-socket access, tmux-socket access, and GPU access — and asks you to approve. Both sockets get a line of their own, since each lets the tong run anything on your host. Control and formatting characters in those values are shown as escapes such as `\x1b` or `\n`, so a value cannot move the cursor, restyle, or hide any part of the summary. Every `tongs:` warning escapes them the same way. Each volume is listed with its target and docker volume name, with a note that its data persists and is shared by concurrent sessions. - Approval is keyed by workspace path (symlinks resolved, as for its [volumes](definitions.md#volume-naming-and-scope)) + tong name + a hash of the merged definition, stored in `~/.swarmforge/approvals.json`. Any change to the definition re-prompts. - The gate defaults to **No**, and a non-interactive stdin reads as No. A scripted `--no-prompt` run **fails closed** rather than auto-approving. - Approving `image: foo:latest` approves a moving target; **pinned digests are the recommended convention** for workspace tongs. diff --git a/docs/tongs/definitions.md b/docs/tongs/definitions.md index 7ce1d0f..b0ddd22 100644 --- a/docs/tongs/definitions.md +++ b/docs/tongs/definitions.md @@ -71,7 +71,7 @@ Mounts are opt-in **magic words**, never raw host paths. Only four are recognize - `workspace[:/target][:mode]` — bind-mounts the session workspace, at `/workspace` unless an absolute `target` says otherwise (e.g. `workspace:ro`, `workspace:/code`, `workspace:/code:ro`). A custom target lets an image that expects its sources elsewhere be used unmodified (it does not set the working directory — the process still starts in the image's own `WORKDIR`). A target is refused unless it is an absolute path free of whitespace, and refused if it resolves to `/`, overlaps another of the tong's mounts, or overlaps a path the tong's own wiring occupies — the secret-delivery tmpfs at `/run/swarmforge` and the `/bin/sh` its wrapper execs (for a tong with secret references), the docker socket (for a tong that mounts it), or `/run/swarmforge-tmux` (for a tong that mounts the tmux socket). The workspace bind is paired with the same git-dir mounts the anvil gets from `swarmforge/gitguard.py`: read-only guards over the config and hooks the host's git obeys, and — when the workspace is a linked worktree or another checkout whose git dir lives outside it — that git dir at its own absolute path, which is where the checkout's `.git` pointer file says to look (without it, git inside the tong fails with "not a git repository"). The one mount the anvil gets and a tong does not is the restated worktree registration — a tong keeps the host's copy, so `git worktree list` inside one names the checkout by its host path (the [git guard](../git-guard.md) has the rest). When every `workspace` mount is `ro`, the ride-along git-dir mounts are forced read-only too. - `docker-socket[:mode]` — bind-mounts the host docker socket onto the same path inside the container (so it takes no target). This is full host docker control and is always called out explicitly in the workspace approval prompt; it is the grant a broker tong needs. - `volume::/target[:mode]` — mounts a docker **named volume** at `target`, for state the tong keeps between containers (a model store, a cache). The name comes first and is at most 64 letters, digits, `.` and `-`, starting with a letter or digit; the target is required and held to the same rules as a `workspace` target, may not overlap the git dir that a `workspace` mount brings along, and a tong mounts each volume only once. Docker creates the volume on first use and seeds a new, empty one with whatever the image has at `target`. Only that first seeding happens: a later image's files at `target` never replace what the volume holds, so point `target` at a data directory, not at files the image ships. The launcher never removes a volume; remove one by hand with `docker volume rm ` (`docker volume ls --filter name=swarmforge-volume-` lists them). A `volume:` mount is private to its tong; it is unrelated to `interface.kind: volume`, which would share a volume with the anvil and is not wired up yet. -- `tmux-socket[:ro]` — binds the directory holding the launcher's tmux socket at `/run/swarmforge-tmux`, so the tong reaches every tmux server you run in that directory — the one the launcher was started from and any other you started with `-L` — and through any of them can run any command on the host as you. That is as strong a grant as the docker socket, and the approval prompt calls it out the same way. It is always read-only (`tmux-socket:rw` is refused), but read-only only stops the tong deleting or replacing the sockets in that directory: a tmux client needs nothing more than to connect, so it limits nothing the tong can do through a server. It takes no target, and because the directory rather than the socket is bound, a socket tmux recreates on a server restart is still found. It is `session`-only — a `shared` tong is refused, since its container outlives the session and would carry one session's tmux server into the next. The launcher must itself run in a tmux pane, and the socket must sit directly in tmux's default per-user directory (`tmux-` under `$TMUX_TMPDIR` or `/tmp`); a socket at a custom `-S` path is refused, because binding its directory could expose an arbitrary host directory. The launcher also checks that directory on disk: no symlink on the way to it (docker would bind whatever one points at), owned by you, no group or other permission bits (tmux creates it `0700`), and holding a socket at the path `$TMUX` names. Every directory above it, up to `/`, must be owned by root or you and not writable by group or others unless it has the sticky bit (as `/tmp` does), so no other user can swap the directory between the check and docker's bind. The check assumes the docker daemon resolves the bind on the launcher's own filesystem, as the workspace and docker-socket binds already do; a remote `DOCKER_HOST` is not supported for this mount. Any of these failures stops the launch before anything starts. The tong gets `TMUX`, re-pointed at the socket under `/run/swarmforge-tmux` so a stock `tmux` client reaches the launcher's server with no flags; `TMUX_PANE`, the launcher's pane when it has one, which tmux uses as the default target — a default, not a restriction, since the tong can still name any session, window, or pane; `SWARMFORGE_SESSION_HANDLE`, the anvil's container name; and `SWARMFORGE_WORKSPACE_HOST_PATH`. For the first three the launcher's values replace a plain value the definition's `env:` sets (and one the launcher has no value for is dropped), while a secret reference on any of them is refused at validation, since a delivered secret would override the launcher's value. The socket directory's `0700` mode means the tong's process must run as your host uid or as root to connect. +- `tmux-socket[:ro]` — binds the directory holding the launcher's tmux socket at `/run/swarmforge-tmux`, so the tong reaches every tmux server you run in that directory — the one the launcher was started from and any other you started with `-L` — and through any of them can run any command on the host as you. That is as strong a grant as the docker socket, and the approval prompt calls it out the same way. It is always read-only (`tmux-socket:rw` is refused), but read-only only stops the tong deleting or replacing the sockets in that directory: a tmux client needs nothing more than to connect, so it limits nothing the tong can do through a server. It takes no target, and because the directory rather than the socket is bound, a socket tmux recreates on a server restart is still found. It is `session`-only (see [Lifecycle](README.md#lifecycle)). The launcher must itself run in a tmux pane, and the socket must sit directly in tmux's default per-user directory (`tmux-` under `$TMUX_TMPDIR` or `/tmp`); a socket at a custom `-S` path is refused, because binding its directory could expose an arbitrary host directory. The launcher also checks that directory on disk: no symlink on the way to it (docker would bind whatever one points at), owned by you, no group or other permission bits (tmux creates it `0700`), and holding a socket at the path `$TMUX` names. Every directory above it, up to `/`, must be owned by root or you and not writable by group or others unless it has the sticky bit (as `/tmp` does), so no other user can swap the directory between the check and docker's bind. The check assumes the docker daemon resolves the bind on the launcher's own filesystem, as the workspace and docker-socket binds already do; a remote `DOCKER_HOST` is not supported for this mount. Any of these failures stops the launch before anything starts. The tong gets `TMUX`, `TMUX_PANE`, `SWARMFORGE_SESSION_HANDLE`, and `SWARMFORGE_WORKSPACE_HOST_PATH` ([environment reference](../reference/environment.md#inside-the-container)). The socket directory's `0700` mode means the tong's process must run as your host uid or as root to connect. ### Volume naming and scope diff --git a/swarmforge/anvil/__init__.py b/swarmforge/anvil/__init__.py index 132bb91..8e2de6d 100644 --- a/swarmforge/anvil/__init__.py +++ b/swarmforge/anvil/__init__.py @@ -94,13 +94,13 @@ ------------------ The user, org, and Swarmforge-repo layers are installed deliberately and are trusted. The workspace is any repo you happened to clone, so a workspace-sourced -tong -- which may request secrets, host mounts, the docker socket, or the -launcher's tmux server -- is gated: before the anvil starts, the launcher prints -the privilege summary and asks the user to approve it. Approval is keyed by -workspace path + tong name + a hash of the merged definition (so any change -re-prompts) and persists in the user-layer store passed as `--approvals`. The -scripted `--no-prompt` mode fails closed (refusing the run) rather than -auto-approving an unapproved tong. +tong -- which may request secrets, host mounts, the docker socket, the +launcher's tmux server, or host GPUs -- is gated: before the anvil starts, the +launcher prints the privilege summary and asks the user to approve it. Approval +is keyed by workspace path + tong name + a hash of the merged definition (so any +change re-prompts) and persists in the user-layer store passed as +`--approvals`. The scripted `--no-prompt` mode fails closed (refusing the run) +rather than auto-approving an unapproved tong. Passthrough invariant --------------------- diff --git a/swarmforge/anvil/approval.py b/swarmforge/anvil/approval.py index a330d04..25d9aca 100644 --- a/swarmforge/anvil/approval.py +++ b/swarmforge/anvil/approval.py @@ -1,13 +1,6 @@ """The first-run approval gate for workspace-sourced tongs. -The user, org, and Swarmforge-repo layers are installed deliberately and are -trusted. The workspace is any repo you happened to clone, so a workspace-sourced -tong -- which may request secrets, host mounts, the docker socket, the -launcher's tmux server, or host GPUs -- is gated: its privilege summary is -rendered and the user is asked to approve it before the anvil starts. Approval -is keyed by workspace path, tong name, and a hash of the merged definition, so -any change re-prompts, and persists in the user layer. - +The gate itself is described in `swarmforge.anvil` ("First-run approval"). Deciding what a definition asks for and remembering the answer are the pure core's job; rendering the question and reading the reply are this module's. """ @@ -25,13 +18,11 @@ def render_privilege_summary(name, summary): """Human-readable block describing what a workspace tong requests. `summary` is the structured output of `tongs.privilege_summary`. Only the - privileges actually requested are shown, and the two broadest grants -- - docker-socket access (full control of the host's docker) and tmux-socket - access (every tmux server in the user's socket directory, so any command on - the host) -- are always called out on their own lines so neither can be - approved unseen, as is a GPU request, which grants the host's GPU devices. - Every line passes through `tongs.printable`, so a definition value cannot - move the cursor, erase, or forge a line of this block. + privileges actually requested are shown, and the two host-command grants -- + the docker socket and the tmux socket -- each get a line of their own so + neither can be approved unseen, as does a GPU request. Every line passes + through `tongs.printable`, so a definition value cannot move the cursor, + erase, or forge a line of this block. """ lines = ["Workspace tong %r requests approval:" % name] lines.append(" image: %s" % (summary.get("image") or "(none declared)")) diff --git a/swarmforge/anvil/cli.py b/swarmforge/anvil/cli.py index 823c30d..5f99670 100644 --- a/swarmforge/anvil/cli.py +++ b/swarmforge/anvil/cli.py @@ -63,10 +63,9 @@ def parse_args(argv, environ=None): Returns ``(options, anvil_cmd)`` where ``options`` is a ``LauncherOptions`` (its ``layer_dirs`` ordered by canonical precedence, only the layers that - were given) and ``anvil_cmd`` is the argv after ``--``. The options' ``tmux`` - and ``tmux_pane`` come from ``TMUX``/``TMUX_PANE`` in ``environ`` (the - process environment by default), unvalidated -- only a ``tmux-socket`` tong - uses them, and the orchestrator checks them then. Raises ``UsageError`` + were given) and ``anvil_cmd`` is the argv after ``--``. ``tmux`` and + ``tmux_pane`` are read unvalidated from ``environ`` (the process environment + by default); the orchestrator checks them. Raises ``UsageError`` if the separator is missing, the command is empty, or an option is malformed. """ environ = os.environ if environ is None else environ diff --git a/swarmforge/anvil/orchestrate.py b/swarmforge/anvil/orchestrate.py index 85b0a1c..fdd8089 100644 --- a/swarmforge/anvil/orchestrate.py +++ b/swarmforge/anvil/orchestrate.py @@ -78,9 +78,6 @@ def unsupported_tong_reasons(merged): * a workspace-sourced `shared` tong that mounts a `volume` -- its volume is scoped to one checkout but its container is not. - (A `shared` tong that mounts one session's state is a definition error, - refused by validation through `tongs.mount_lifecycle_error`.) - A refused tong is reported rather than started half-wired. Returns a list of human-readable reason strings (empty == every discovered tong is startable). """ @@ -116,19 +113,14 @@ def _lstat(path, what): def check_tmux_socket_dir(host_dir, socket_path, uid=None): """Refuse a tmux socket directory that is not plainly the launcher's own. - `host_dir` and `socket_path` come from a `$TMUX` that `tongs.resolve_tmux_server` - already accepted, which judged only the spelling. Here the host itself is - checked, since the whole directory is bound into the tong: it must be reached - with no symlink in any component (docker follows one, binding wherever it - points), be a real directory owned by `uid` (the launcher's own by default) - with no group or other permission bits -- tmux creates it 0700 -- and hold - `socket_path` as a socket. - - Every directory above it, up to `/`, must pass OpenSSH's StrictModes rule: - owned by root or `uid`, and not writable by group or others unless sticky - (as `/tmp` is). Otherwise another local user could swap the directory for a - symlink between this check and docker's bind. Raises `OrchestrationError` - when any of this fails. + `tongs.resolve_tmux_server` judged only the spelling of `host_dir` and + `socket_path`; this checks the host, since the whole directory is bound into + the tong. It must be reached through no symlink (docker would bind the + target), be a directory owned by `uid` (the launcher's by default) with no + group or other bits, and hold `socket_path` as a socket. Every ancestor must + pass OpenSSH's StrictModes rule -- owned by root or `uid`, not writable by + group or others unless sticky -- so no other user can swap in a symlink + before docker binds it. Raises `OrchestrationError` otherwise. """ uid = os.getuid() if uid is None else uid if os.path.realpath(host_dir) != host_dir: @@ -205,8 +197,7 @@ def _start_one_tong(docker, name, defn, *, container, network, alias, the bytes live only in docker's API stream and the container kernel's pipe buffer -- never `-e`, argv, or disk. - `tmux_server` and `session_handle` reach `tongs.tong_run_argv`, which uses - them only for a tong that mounts `tmux-socket`. + `tmux_server` and `session_handle` pass through to `tongs.tong_run_argv`. Once the argv is assembled, any existing container of the same name is removed so a stale or stopped one is replaced cleanly -- but a definition the argv @@ -369,19 +360,14 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None, tongs are left running. Teardown runs inside the caller's `teardown_guard()`, which keeps signals from cutting it short; the default guards nothing. - When a tong mounts `tmux-socket`, the launcher's `opts.tmux`/`opts.tmux_pane` - are resolved once (`tongs.resolve_tmux_server`) and the socket directory - checked on disk (through `check_tmux_dir`, `check_tmux_socket_dir` unless a - test injects another) before anything starts; each such tong is handed the - result and the session handle. + When a tong mounts `tmux-socket`, `opts.tmux`/`opts.tmux_pane` are resolved + once and checked on disk by `check_tmux_dir` before anything starts. Returns the anvil's exit code. Raises `OrchestrationError` if a tong never becomes ready (the anvil does not run against a half-up environment), a `session` tong is discovered with no anvil `--name` to key the session by, or - a tong mounts `tmux-socket` while the launcher is outside tmux, its `$TMUX` - or `$TMUX_PANE` is malformed, or its socket directory fails - `check_tmux_socket_dir`, and `SecretResolutionError` if a secret reference - cannot be resolved. + the launcher's tmux server fails those checks, and `SecretResolutionError` if + a secret reference cannot be resolved. """ ensure_mcp_harness_supported(merged, opts.harness) resolver = make_secret_resolver(providers or {}) diff --git a/swarmforge/tongs/argv.py b/swarmforge/tongs/argv.py index ee7fbca..68bb105 100644 --- a/swarmforge/tongs/argv.py +++ b/swarmforge/tongs/argv.py @@ -154,25 +154,19 @@ def tong_run_argv( non-secret values from `plan_tong_secrets`) is passed as `-e` in sorted order; resolved secret values never appear here -- they arrive over the in-container FIFO instead. - A socket-holding `session` tong -- a broker that mounts `docker-socket`, or a - tong that mounts `tmux-socket` -- additionally receives - `SWARMFORGE_WORKSPACE_HOST_PATH` so the workers it spawns can be given the - session workspace; a tong that sets that name itself keeps its own value. A - `shared` socket tong does not get it -- its container is reused across + A `session` tong that mounts `docker-socket` (a broker) or `tmux-socket` also + receives `SWARMFORGE_WORKSPACE_HOST_PATH` so the workers it spawns can be + given the session workspace; a tong that sets that name itself keeps its own + value. A `shared` tong does not get it -- its container is reused across sessions, so a per-session workspace path would be stale (and a leak) for later ones. - A `tmux-socket` tong is pointed at the launcher's tmux server: `tmux_server` - (from `resolve_tmux_server`) gives the mount's host source and the tong's - `TMUX` and `TMUX_PANE`, and `session_handle` its `SWARMFORGE_SESSION_HANDLE`. - These describe the host and the session, which a definition cannot know, so - they replace a plain value its `env:` sets for those names -- and a name the - launcher has no value for is dropped rather than left to the definition. A - secret reference on one of them would arrive over the FIFO after these and - win, so validation refuses it (see `tmux_secret_env_error`). Raises - `ValueError` for any mount `tong_mount_specs` refuses, which includes a - `tmux-socket` mount with no `tmux_server`. A tong without the mount gets none - of them, whatever is passed. + Only a `tmux-socket` tong uses `tmux_server` (from `resolve_tmux_server`) and + `session_handle`: they give its mount source and `TMUX_LAUNCHER_ENV`, which + describe the host and session a definition cannot know, so they replace any + plain value its `env:` sets and drop one the launcher lacks. Raises + `ValueError` for any mount `tong_mount_specs` refuses, including a + `tmux-socket` mount with no `tmux_server`. When the tong has secret env, the launcher passes `secret_channel=True` (which mounts a tmpfs at `SECRET_FIFO_DIR` for the wrapper's FIFO), `entrypoint` diff --git a/swarmforge/tongs/mounts.py b/swarmforge/tongs/mounts.py index 55e972f..485eebc 100644 --- a/swarmforge/tongs/mounts.py +++ b/swarmforge/tongs/mounts.py @@ -40,7 +40,7 @@ # An allowlist, so an unvetted mount option cannot reach the daemon. MOUNT_MODES = ("ro", "rw") -# Mounts that bind one session's host state, so a reused `shared` container would leak it. +# Mount words that bind one session's host state, and what each binds. SESSION_ONLY_MOUNTS = {WORKSPACE_MOUNT: "workspace", TMUX_MOUNT: "tmux socket"} # What tmux creates (mode 0700) under $TMUX_TMPDIR or /tmp for each user's sockets. @@ -193,9 +193,9 @@ def reserved_mount_targets(defn, socket_path=DEFAULT_DOCKER_SOCKET): it: the secret-delivery tmpfs (and the shell whose wrapper creates and reads the FIFO on it) come with secret references, the docker socket with a `docker-socket` mount, the tmux socket directory with a `tmux-socket` mount. - `mount_target_error` - compares destinations against this map, as written -- a target that reaches one - of these only through a symlink inside the image is not something it can see. + `mount_target_error` compares destinations against this map, as written -- a + target that reaches one of these only through a symlink inside the image is + not something it can see. """ paths = {} env = defn.get("env") @@ -239,8 +239,6 @@ def mount_target_error(mount, word, target, destination, reserved): buries the tong's wiring or has docker create a mountpoint inside the user's workspace on the host. `destination` is where the mount actually lands (`mount_destination`), so a default target is judged like a declared one. - Validation and argv assembly both ask this, so both give the same verdict and - the same message for one `reserved` map. """ if word not in TARGETED_MOUNT_WORDS: if target is not None: @@ -260,8 +258,7 @@ def mount_mode_error(mount, word, mode): `tmux-socket` is read-only whatever it declares. A tmux client only needs `connect()` on the socket, which a read-only bind allows; a writable bind of the socket directory would let the tong replace the socket the host's own - tmux clients dial. Validation and argv assembly both ask this, like - `mount_target_error`, so both refuse `tmux-socket:rw` with one message. + tmux clients dial. """ if word == TMUX_MOUNT and mode == "rw": return "mount %r: the '%s' mount is always read-only" % (mount, TMUX_MOUNT) @@ -271,9 +268,9 @@ def mount_mode_error(mount, word, mode): def mount_lifecycle_error(mount, word, lifecycle): """Why a mount is refused for the tong's lifecycle, or None if it is fine. - A `SESSION_ONLY_MOUNTS` word binds one session's host state, and a `shared` - container is reused by later sessions, so it would carry that binding into - them. Validation refuses a missing or unknown lifecycle on its own. + A `shared` container is reused by later sessions, so a `SESSION_ONLY_MOUNTS` + word would carry one session's binding into the next. A missing or unknown + lifecycle is left to validation. """ if word in SESSION_ONLY_MOUNTS and lifecycle == "shared": what = SESSION_ONLY_MOUNTS[word] @@ -337,13 +334,9 @@ def volume_scope(layer, org_tongs_dir=None, workspace=None): def checked_mounts(defn, socket_path=DEFAULT_DOCKER_SOCKET): """Each `mounts:` entry judged by every mount rule, as a `CheckedMount`. - Validation and argv assembly both walk a definition's mounts through here, so - they accept and refuse the same entries with the same message. An entry is - refused for not being a string, failing `parse_mount`, or any of - `mount_target_error`, `mount_mode_error`, `mount_lifecycle_error` and - `overlapping_mount_error`; a refused entry has `error` set (and `word` and - `destination` None when it never parsed) and is not placed, so later entries - are judged for overlap against the accepted ones only. + A refused entry has `error` set (and `word` and `destination` None if it never + parsed) and is not placed, so later entries are checked for overlap against + the accepted ones only. """ reserved = reserved_mount_targets(defn, socket_path) lifecycle = defn.get("lifecycle") @@ -390,10 +383,9 @@ def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, first use and the launcher never removes. A volume may not overlap one of `extra_mount_specs` (see `tong_run_argv`), or docker would leave their mountpoints inside it. `tmux-socket[:ro]` bind-mounts `tmux_socket_dir` -- - the host directory holding the launcher's tmux socket, from - `resolve_tmux_server` -- read-only at `TMUX_MOUNT_TARGET`. The directory - rather than the socket, because tmux deletes and recreates the socket when - its server restarts. + the host directory holding the launcher's tmux socket -- read-only at + `TMUX_MOUNT_TARGET`. The directory rather than the socket, because tmux + recreates the socket when its server restarts. Raises `ValueError` for any entry `checked_mounts` refuses, a volume overlapping one of `extra_mount_specs`, a `workspace` or `tmux-socket` mount when no host source for it is known, or a `volume` mount without a @@ -441,21 +433,15 @@ def tong_mount_specs(defn, workspace, socket_path=DEFAULT_DOCKER_SOCKET, def resolve_tmux_server(tmux, tmux_pane=None): """The launcher's tmux server as a `tmux-socket` tong reaches it, a `TmuxServer`. - `tmux` and `tmux_pane` are the launcher's raw `$TMUX` and `$TMUX_PANE`, and - this is the one place they are parsed. `$TMUX` is - `,,`; the result holds the socket's parent - directory (what the mount binds), the socket's host path, and the tong's env: - `TMUX` re-pointed at the socket's name under `TMUX_MOUNT_TARGET`, so a stock - tmux client in the tong reaches the host server with no flags, and `TMUX_PANE` - as given (None when it is unset). - - Raises `ValueError` when `tmux` is unset, or unless it has exactly three - comma-separated fields, the pid and session id are decimal, and the socket path - is a normalized absolute path with no `:` (docker's `-v` separator), `,` (tmux - reads the path up to the first one), or whitespace. The socket must also sit - directly in tmux's own per-user directory (`tmux-`): binding a `-S` - socket's parent could hand the tong an arbitrary host directory, such as a home - directory. A set `tmux_pane` must be a pane id (`%`). + `tmux` is the launcher's raw `$TMUX` (`,,`) + and `tmux_pane` its `$TMUX_PANE`. The tong's `TMUX` names the same socket under + `TMUX_MOUNT_TARGET`, so a stock tmux client reaches the host server. + + Raises `ValueError` when `tmux` is unset or malformed, or `tmux_pane` is set + and not `%`. The socket path must be normalized and absolute, free of + `:` (docker's `-v` separator), `,` (tmux's field separator) and whitespace, + and sit directly in tmux's per-user `tmux-` directory: binding a `-S` + socket's parent could expose any host directory. """ if not tmux: raise ValueError("the launcher is not running inside tmux ($TMUX is unset)") @@ -488,11 +474,9 @@ def resolve_tmux_server(tmux, tmux_pane=None): def tmux_secret_env_error(defn): """Why a `tmux-socket` definition's secret env is refused, or None if it is fine. - The launcher owns `TMUX_LAUNCHER_ENV` on a tong that mounts `tmux-socket` and - replaces a plain value the definition gives one of them, but a secret value is - exported by the FIFO wrapper after docker's `-e` values and so would win. - Refusing a secret reference there keeps the launcher's values the ones the - tong runs with. + The launcher sets `TMUX_LAUNCHER_ENV` on a `tmux-socket` tong, but the FIFO + wrapper exports secrets after docker's `-e` values, so a secret reference would + override the launcher's value. """ env = defn.get("env") if not mounts_word(defn, TMUX_MOUNT) or not isinstance(env, dict):