diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 1e8e2ad..a7de1ae 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, 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. | | `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..08ac31f 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. 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..b0ddd22 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 (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 f2ee447..8e2de6d 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, 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 --------------------- @@ -135,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, @@ -166,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/approval.py b/swarmforge/anvil/approval.py index ba88ff5..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, 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,12 +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 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 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)")) @@ -56,6 +48,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..5f99670 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,17 @@ 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 ``--``. ``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 paths = {} workspace = None approvals = None @@ -82,7 +87,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..fdd8089 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 @@ -74,11 +75,6 @@ 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. @@ -94,12 +90,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)): @@ -112,6 +102,64 @@ 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. + + `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: + raise OrchestrationError( + "tmux socket directory %s is reached through a symlink; docker would " + "bind whatever it points at" % host_dir) + 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: + 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: + 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 " + "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 + 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) + + def ensure_mcp_harness_supported(merged, harness): """Refuse MCP tongs when no emitter exists for the selected harness.""" mcp_names = [ @@ -133,7 +181,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_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 @@ -148,6 +197,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_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 builder refuses removes nothing here, since it started nothing (teardown still @@ -167,16 +218,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_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 @@ -190,16 +246,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, - ) - 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) @@ -285,6 +332,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. @@ -312,10 +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`, `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) 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 + 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 {}) @@ -331,6 +383,19 @@ 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) + ] + tmux_server = None + if tmux_names: + try: + 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) org_token = tongs.org_scope_token(org_tongs_dir) @@ -379,6 +444,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_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 6b5468e..8d89e5f 100644 --- a/swarmforge/tongs/__init__.py +++ b/swarmforge/tongs/__init__.py @@ -96,7 +96,9 @@ ORG, READINESS_MODES, REPO, + SESSION_HANDLE_ENV, SOCKET_MOUNT, + TMUX_MOUNT, TRANSPORTS, TRUSTED_LAYERS, USER, @@ -116,18 +118,28 @@ MOUNT_MODES, MOUNT_WORDS, ORG_VOLUME_SCOPE, + TMUX_LAUNCHER_ENV, + SESSION_ONLY_MOUNTS, + TMUX_MOUNT_TARGET, VOLUME_DIGEST_LENGTH, VOLUME_MOUNT, VOLUME_NAME_MAX_LENGTH, WORKSPACE_MOUNT, WORKSPACE_VOLUME_SCOPE, + CheckedMount, + TmuxServer, + checked_mounts, mount_destination, + mount_lifecycle_error, + mount_mode_error, mount_target_error, mounts_word, normalize_mount_target, overlapping_mount_error, parse_mount, reserved_mount_targets, + resolve_tmux_server, + tmux_secret_env_error, tong_mount_specs, tong_volume_name, volume_scope, @@ -209,7 +221,9 @@ "ORG", "READINESS_MODES", "REPO", + "SESSION_HANDLE_ENV", "SOCKET_MOUNT", + "TMUX_MOUNT", "TRANSPORTS", "TRUSTED_LAYERS", "USER", @@ -227,18 +241,28 @@ "MOUNT_MODES", "MOUNT_WORDS", "ORG_VOLUME_SCOPE", + "TMUX_LAUNCHER_ENV", + "SESSION_ONLY_MOUNTS", + "TMUX_MOUNT_TARGET", "VOLUME_DIGEST_LENGTH", "VOLUME_MOUNT", "VOLUME_NAME_MAX_LENGTH", "WORKSPACE_MOUNT", "WORKSPACE_VOLUME_SCOPE", + "CheckedMount", + "TmuxServer", + "checked_mounts", "mount_destination", + "mount_lifecycle_error", + "mount_mode_error", "mount_target_error", "mounts_word", "normalize_mount_target", "overlapping_mount_error", "parse_mount", "reserved_mount_targets", + "resolve_tmux_server", + "tmux_secret_env_error", "tong_mount_specs", "tong_volume_name", "volume_scope", 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/swarmforge/tongs/argv.py b/swarmforge/tongs/argv.py index 019b75c..68bb105 100644 --- a/swarmforge/tongs/argv.py +++ b/swarmforge/tongs/argv.py @@ -15,11 +15,18 @@ 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, + TMUX_LAUNCHER_ENV, + mounts_word, + tong_mount_specs, +) from .secrets import SECRET_FIFO_TMPFS, declared_run_override @@ -132,6 +139,8 @@ def tong_run_argv( command=None, extra_mount_specs=None, volume_scope=None, + tmux_server=None, + session_handle=None, ): """Full `docker run -d` argv that starts one tong container. @@ -145,11 +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 (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 `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. + + 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` @@ -185,13 +202,23 @@ 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": + mounts_tmux = mounts_word(defn, 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 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 ()): + 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 269eff1..26c6184 100644 --- a/swarmforge/tongs/model.py +++ b/swarmforge/tongs/model.py @@ -32,9 +32,15 @@ # 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" +# 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 6873a78..485eebc 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 @@ -14,20 +15,46 @@ from swarmforge.names import canonical_path, name_hint -from .model import ORG, REPO, SOCKET_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 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") +# 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. +_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) + +# 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. @@ -44,6 +71,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`. @@ -162,9 +192,10 @@ 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` - 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. + `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. """ paths = {} env = defn.get("env") @@ -173,6 +204,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 +214,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 +225,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,)) @@ -202,14 +239,12 @@ 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: 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 +252,34 @@ 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. + """ + if word == TMUX_MOUNT and mode == "rw": + return "mount %r: the '%s' mount is always read-only" % (mount, TMUX_MOUNT) + 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 `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] + 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. @@ -268,8 +331,47 @@ 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`. + + 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") + 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 mount_lifecycle_error(mount, word, lifecycle) + 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=()): + 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,25 +382,20 @@ 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. - 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. + mountpoints inside it. `tmux-socket[:ro]` bind-mounts `tmux_socket_dir` -- + 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 + `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 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") @@ -309,7 +406,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] @@ -317,6 +414,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,)) @@ -324,10 +427,68 @@ 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 +def resolve_tmux_server(tmux, tmux_pane=None): + """The launcher's tmux server as a `tmux-socket` tong reaches it, a `TmuxServer`. + + `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)") + 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)) + 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): + """Why a `tmux-socket` definition's secret env is refused, or None if it is fine. + + 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): + 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)]`. @@ -336,14 +497,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 4107d75..adcd3e1 100644 --- a/swarmforge/tongs/validate.py +++ b/swarmforge/tongs/validate.py @@ -20,13 +20,7 @@ parse_duration, parse_gpus, ) -from .mounts import ( - mount_destination, - mount_target_error, - overlapping_mount_error, - parse_mount, - reserved_mount_targets, -) +from .mounts import checked_mounts, tmux_secret_env_error from .secrets import ENV_NAME_RE, partition_secret_env @@ -134,6 +128,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) @@ -147,27 +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, _ = 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) - 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): 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..a247268 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 @@ -82,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 = { @@ -130,6 +108,15 @@ 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_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 +217,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 +273,19 @@ 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", + "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 +854,105 @@ 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, + check_tmux_dir=_skip_tmux_dir_check): + 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, check_tmux_dir=check_tmux_dir, + 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, "not running inside tmux"): + 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, + check_tmux_dir=_skip_tmux_dir_check, + 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_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, + "'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"), + 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 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, []) + + 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" @@ -1041,6 +1142,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`.""" 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): diff --git a/tests/test_tongs_argv.py b/tests/test_tongs_argv.py index d9aa42e..7f2bffa 100644 --- a/tests/test_tongs_argv.py +++ b/tests/test_tongs_argv.py @@ -352,6 +352,68 @@ 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 + 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_server=tmux_server, 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_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",)): + 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, "'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): 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 1ca93fc..87c254e 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"), []) @@ -402,5 +462,71 @@ def test_mount_specs_non_string_raises(self): tongs.tong_mount_specs({"mounts": [123]}, "/ws") +class TmuxSocketTests(unittest.TestCase): + """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_server("/tmp/tmux-1000/default,4242,3", "%7"), + ("/tmp/tmux-1000", "/tmp/tmux-1000/default", + {"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_server("/run/user/1000/tmux-1000/work,1,0"), + ("/run/user/1000/tmux-1000", "/run/user/1000/tmux-1000/work", + {"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_server(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_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_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_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_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_server(value) + + def test_pane_id_checked(self): + 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.resolve_tmux_server(tmux, pane) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_tongs_validate.py b/tests/test_tongs_validate.py index 1b3dc67..538834c 100644 --- a/tests/test_tongs_validate.py +++ b/tests/test_tongs_validate.py @@ -214,6 +214,55 @@ 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) + + 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_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"])) + 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={