Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/tongs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (ollama-style). 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 <container>`. 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 (ollama-style). 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 <container>`. 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.

## Reference

Expand Down
4 changes: 2 additions & 2 deletions docs/tongs/approval.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.
- Approval is keyed by workspace path + tong name + a hash of the merged definition, stored in `~/.swarmforge/approvals.json`. Any change to the definition re-prompts.
- 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.
- 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.
33 changes: 32 additions & 1 deletion docs/tongs/definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface: # required; how (or whether) the anvil reaches the
# aliases: [gh, git.example] # optional extra DNS names the tong also answers to
mounts: # opt-in magic words only, never raw host paths
- workspace:ro # or workspace:/code:ro to bind it somewhere else
# - volume:cache:/var/cache # a persistent named volume, kept between containers
resources:
memory: 512m # string or number
# gpus: all # optional host GPU access (see Resources)
Expand Down Expand Up @@ -65,10 +66,21 @@ readiness:

## Mounts

Mounts are opt-in **magic words**, never raw host paths. Only two are recognized, spelled `<word>[:/target][:mode]` with the access mode (`ro`/`rw`) last:
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:

- `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.
- `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:<name>:/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 <volume>` (`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.

### Volume naming and scope

The docker volume is named `swarmforge-volume-[<hint>-]<digest>_<name>`. The hint is the tong's filename made docker-safe and cut to 32 characters, only for reading. The digest, a 128-bit SHA-256 prefix over the tong's exact filename and its scope, is the identity, so two tongs share a volume only with the same filename and scope. The scope depends on the tong's layer:

- **org** — the org's tongs directory. This applies to both lifecycles.
- **workspace** — the checkout's top-level path, with symlinks resolved. A clone or worktree at another path gets its own volume; a later checkout at the same path, even of a different repository, inherits it, as it does the approvals. Deleting a checkout leaves its volumes behind. A workspace tong that mounts a volume may not be `shared`: its container would be reused by other checkouts, still mounting this one's volume.
- **user** and **repo** — this machine, across every checkout, so anything such a tong copies from one workspace is visible in the others. A repo tong and the same-named user tong it replaces use one volume.

A volume belongs to the tong's name, not to one definition or one session: an edited definition reuses its data, and concurrent sessions of a `session` tong mount it read-write at once. Keep state there that tolerates that, or that the program locks itself.

## Resources

Expand All @@ -87,3 +99,22 @@ Both are optional and passed to `docker run` as written. `gpus` grants the tong
Docker's other `--gpus` keys are deliberately refused. `driver=`, `capabilities=`, and `options=` can grant more than GPU access — `driver=cdi`, for one, injects any device registered on the host — and `count=` is redundant with a plain count.
GPU access needs a docker host set up for GPU containers — the NVIDIA Container Toolkit on Linux; Docker Desktop's WSL 2 backend provides GPU support itself. Without it `docker run` fails and the launch stops.
A workspace tong's GPU request is called out in its [approval prompt](approval.md).

## Example: a GPU model server

A `shared` `port` tong that serves models from the host's GPUs and keeps its multi-GB model store in a volume, so a recreated container does not download it again:

```yaml
# ~/.swarmforge/tongs/models.yaml
lifecycle: shared
image: ollama/ollama@sha256:...
interface:
kind: port
port: 11434
mounts:
- volume:store:/root/.ollama
resources:
gpus: all
```

The anvil gets `SWARMFORGE_TONG_MODELS_HOST=models` and `SWARMFORGE_TONG_MODELS_PORT=11434`, and the store lives in the volume `swarmforge-volume-models-<digest>_store`. It is named `models` because `make run_ollama` already answers to `ollama` on that network.
14 changes: 13 additions & 1 deletion swarmforge/anvil/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ def render_privilege_summary(name, summary):
mounts = summary.get("mounts") or []
if mounts:
lines.append(" mounts: %s" % ", ".join(str(m) for m in mounts))
volumes = summary.get("volumes") or []
for volume in volumes:
where = "%s at %s" % (volume["name"], volume["target"])
if volume["docker"]:
where += " (docker volume %s)" % volume["docker"]
lines.append(" volume: %s" % where)
if volumes:
lines.append(" persistent: outlives the tong, is mounted by each of its "
"concurrent sessions, and reuses any data left by an earlier "
"definition or checkout at this path")
networks = summary.get("networks") or []
if networks:
lines.append(" networks: %s" % ", ".join(str(n) for n in networks))
Expand Down Expand Up @@ -108,7 +118,9 @@ def gate_workspace_tongs(merged, workspace, approvals_path, prompt=True, out=Non
for name, defn in pending:
if tongs.is_approved(approvals, workspace, name, defn):
continue
out.write(render_privilege_summary(name, tongs.privilege_summary(defn)) + "\n")
summary = tongs.privilege_summary(
defn, name, tongs.volume_scope(tongs.WORKSPACE, workspace=workspace))
out.write(render_privilege_summary(name, summary) + "\n")
if not prompt:
raise ApprovalDenied(
"workspace tong %r is unapproved and --no-prompt fails closed" % name
Expand Down
53 changes: 30 additions & 23 deletions swarmforge/anvil/orchestrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import json
import os
import shutil
import sys
import tempfile
import time

Expand All @@ -32,18 +31,6 @@
from .secretchan import SecretChannel, make_secret_resolver


def _mounts_workspace(defn):
"""True if a tong's `mounts:` request the session workspace.

The magic word may carry a target and/or a mode (e.g. `workspace:/code:ro`),
so compare only the word before the first colon.
"""
for mount in defn.get("mounts") or []:
if isinstance(mount, str) and mount.split(":", 1)[0] == tongs.WORKSPACE_MOUNT:
return True
return False


def _workspace_git_dir_specs(defn, workspace, warn=None):
"""Git-dir mounts a workspace-mounting tong needs beyond the workspace bind.

Expand Down Expand Up @@ -91,7 +78,9 @@ def unsupported_tong_reasons(merged):
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).
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 refused tong is reported rather than started half-wired. Returns a list of
human-readable reason strings (empty == every discovered tong is startable).
Expand All @@ -105,12 +94,21 @@ 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 _mounts_workspace(defn):
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)):
reasons.append(
"tong '%s' is a workspace 'shared' tong that mounts a volume; its "
"volume belongs to this checkout but its container is reused by "
"sessions in other checkouts, so it would leak this checkout's "
"data into theirs (make it a 'session' tong)" % name
)
return reasons


Expand All @@ -135,7 +133,7 @@ def ensure_mcp_harness_supported(merged, harness):


def _start_one_tong(docker, name, defn, *, container, network, alias,
resolver, workspace, label_hash, make_channel):
resolver, workspace, label_hash, make_channel, volume_scope):
"""Start one tong container detached, delivering any secret env over a FIFO.

Resolves the definition's secret references through `resolver` and splits the
Expand Down Expand Up @@ -165,8 +163,7 @@ def _start_one_tong(docker, name, defn, *, container, network, alias,
try:
git_dir_specs = _workspace_git_dir_specs(
defn, workspace,
warn=lambda message: print("tong '%s': %s" % (name, message),
file=sys.stderr))
warn=lambda message: tongs.warn("tong '%s': %s" % (name, message)))
except ValueError as exc:
raise OrchestrationError("tong '%s': %s" % (name, exc))

Expand All @@ -176,7 +173,7 @@ def _start_one_tong(docker, name, defn, *, container, network, alias,
name, defn,
container_name=container, network=network, alias=alias,
env=plain_env, label_hash=label_hash, workspace=workspace,
extra_mount_specs=git_dir_specs,
extra_mount_specs=git_dir_specs, volume_scope=volume_scope,
)
except ValueError as exc:
raise OrchestrationError("tong '%s': %s" % (name, exc))
Expand All @@ -199,7 +196,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,
secret_channel=True, entrypoint=entrypoint, command=command,
extra_mount_specs=git_dir_specs,
extra_mount_specs=git_dir_specs, volume_scope=volume_scope,
)
except ValueError as exc:
raise OrchestrationError("tong '%s': %s" % (name, exc))
Expand All @@ -213,7 +210,8 @@ def _start_one_tong(docker, name, defn, *, container, network, alias,


def _ensure_shared_tong(docker, name, defn, *, container, network, alias,
resolver, workspace, label_hash, make_channel):
resolver, workspace, label_hash, make_channel,
volume_scope):
"""Start a `shared` tong, or reuse the running one, recreating it if stale.

A `shared` tong is one long-lived container keyed by `shared_container_name`.
Expand All @@ -232,7 +230,7 @@ def _ensure_shared_tong(docker, name, defn, *, container, network, alias,
docker, name, defn,
container=container, network=network, alias=alias,
resolver=resolver, workspace=workspace, label_hash=label_hash,
make_channel=make_channel,
make_channel=make_channel, volume_scope=volume_scope,
)


Expand Down Expand Up @@ -334,7 +332,8 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None,
)

# No org layer means no token, and every shared tong keeps its global unscoped name.
org_token = tongs.org_scope_token(dict(opts.layer_dirs).get(tongs.ORG))
org_tongs_dir = dict(opts.layer_dirs).get(tongs.ORG)
org_token = tongs.org_scope_token(org_tongs_dir)
has_org_shared = bool(org_token) and any(
merged[name]["definition"].get("lifecycle") != "session"
and merged[name]["source"] == tongs.ORG
Expand All @@ -346,6 +345,12 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None,
"join their isolated network"
)

volume_scopes = {
name: tongs.volume_scope(merged[name]["source"], org_tongs_dir=org_tongs_dir,
workspace=opts.workspace)
for name in merged
}

plan = tongs.plan_network(merged, base_network, session_id)
injection = tongs.plan_injection(merged, opts.harness)

Expand Down Expand Up @@ -373,6 +378,7 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None,
container=container, network=plan["network"], alias=alias,
resolver=resolver, workspace=opts.workspace,
label_hash=tongs.config_hash(defn), make_channel=make_channel,
volume_scope=volume_scopes[name],
)
probe_network = plan["network"]
else:
Expand All @@ -392,6 +398,7 @@ def run_with_tongs(merged, anvil_cmd, opts, *, docker, providers=None,
container=container, network=tong_network, alias=alias,
resolver=resolver, workspace=opts.workspace,
label_hash=tongs.config_hash(defn), make_channel=make_channel,
volume_scope=volume_scopes[name],
)
ready_checks.append((name, defn, alias, container, probe_network))

Expand Down
11 changes: 8 additions & 3 deletions swarmforge/names.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,17 @@ def path_digest(path):
return digest.hexdigest()[:DIGEST_LENGTH]


def dir_hint(path, limit=None):
"""`path`'s basename as a docker name, cut to `limit` and not left on a separator."""
hint = sanitize_token(os.path.basename(canonical_path(path)))
def name_hint(name, limit=None):
"""`name` as a docker name, cut to `limit` and not left on a separator."""
hint = sanitize_token(name)
return hint[:limit].rstrip("-_.") if limit is not None else hint


def dir_hint(path, limit=None):
"""`path`'s basename as a `name_hint`."""
return name_hint(os.path.basename(canonical_path(path)), limit)


def path_token(path, hint):
"""Docker-name token for one host directory: `hint`, then `path`'s digest.

Expand Down
Loading
Loading