Skip to content
Open
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## 0.20.0 — 2026-04-17

- **Optional SSH commit signing.** New `git_user.signing_key`
field accepts a host-side path (literal, `$VAR`, or `~/...`)
to an SSH private key. When set, the key is bind-mounted
read-only into each agent and post-processor container at
`/etc/swarm/signing_key` and git is configured with
`gpg.format=ssh`, `user.signingkey=/etc/swarm/signing_key`,
and `commit.gpgsign=true`. When absent -- or when the field
resolves to empty via an unset `$VAR` -- signing is
explicitly disabled inside the container to prevent a host
signing config from leaking in. `openssh-client` is now
installed in the container image for the `ssh-keygen -Y
sign` that git invokes. Signing config is factored into
`lib/signing.sh` and shared between `lib/harness.sh` and the
harness test, so the production path and the regression
test exercise the same code.
- **Per-post-processor `max_idle`.** `post_process.max_idle`
controls the idle-session threshold for the post-processor
independently of the top-level agent-facing `max_idle`.
Falls back to the top-level value when omitted, preserving
the prior behaviour for configs that don't set it.

## 0.19.2 — 2026-04-16

- **No more blank `Think:` lines on Opus 4.7.** The activity
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
make \
jq \
sudo \
openssh-client \
&& rm -rf /var/lib/apt/lists/*

# Claude Code refuses --dangerously-skip-permissions as root.
Expand Down Expand Up @@ -56,6 +57,7 @@ RUN git config --global --add safe.directory '*' \
&& git config --global protocol.file.allow always

COPY --chmod=755 lib/harness.sh /harness.sh
COPY --chmod=755 lib/signing.sh /signing.sh
COPY --chmod=755 lib/activity-filter.sh /activity-filter.sh
COPY --chmod=644 lib/agent-system-prompt.md /agent-system-prompt.md
COPY --chmod=644 VERSION /swarm-version
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ Place a `swarm.json` in your repo root:
],
"post_process": {
"prompt": "prompts/review.md",
"model": "claude-opus-4-6"
"model": "claude-opus-4-6",
"max_idle": 2
}
}
```
Expand Down
55 changes: 49 additions & 6 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ Per-group fields in `swarm.json` `agents` array:

Top-level fields: `prompt`, `setup`, `max_idle` (default: `3`),
`max_retry_wait`, `driver`, `inject_git_rules`,
`git_user` (`name`, `email`), `claude_code_version`, `title`,
`tag`, `pricing`, `docker_args`, `post_process`.
`git_user` (`name`, `email`, `signing_key`),
`claude_code_version`, `title`, `tag`, `pricing`,
`docker_args`, `post_process`.

### Retry on rate limits

Expand Down Expand Up @@ -102,6 +103,44 @@ This is useful for mounting the host Docker socket, adding
devices or capabilities, setting network modes, or passing any
other flags that the harness does not manage natively.

### Commit signing

Set `git_user.signing_key` to an SSH private-key path on the
host to sign every commit agents and post-processors make.
Accepts a literal path, a bare `$VAR` reference (expanded from
the host environment), or a path starting with `~/` (expanded
to `$HOME` before mounting):

```json
{
"git_user": {
"name": "swarm-agent",
"email": "agent@swarm.local",
"signing_key": "~/.ssh/swarm-agent-signing"
}
}
```

The key is bind-mounted read-only into each container at
`/etc/swarm/signing_key`, and git inside the container is
configured with:

```
gpg.format = ssh
user.signingkey = /etc/swarm/signing_key
commit.gpgsign = true
```

When `signing_key` is absent -- or resolves to empty via an
unset `$VAR` -- signing is explicitly disabled inside the
container (`commit.gpgsign = false`), overriding anything that
might otherwise leak in from the image or a mounted config.

The host key file must exist at `launch.sh start` time;
otherwise launch fails with `ERROR: signing key not found`.
The container image ships `openssh-client` for the
`ssh-keygen -Y sign` that git invokes.

## Dashboard

```bash
Expand Down Expand Up @@ -223,7 +262,8 @@ Add to `swarm.json`:
"post_process": {
"prompt": "prompts/review.md",
"model": "claude-opus-4-6",
"effort": "low"
"effort": "low",
"max_idle": 2
}
}
```
Expand All @@ -235,9 +275,12 @@ The post-process agent clones the same bare repo, sees all
commits on `agent-work`, runs its prompt, and pushes.

`post_process` also accepts `base_url`, `api_key`,
`auth_token`, `auth`, `tag`, and `driver` -- same fields as
per-group agents -- to route post-processing through a
different provider or credential.
`auth_token`, `auth`, `tag`, `driver`, and `max_idle` -- same
fields as per-group agents -- to route post-processing through
a different provider or credential. `max_idle` controls how
many consecutive sessions with no commits before the
post-processor exits. When omitted it inherits the top-level
`max_idle` (default: `3`).

## Context modes

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.19.2
0.20.0
20 changes: 18 additions & 2 deletions launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,19 @@ MAX_IDLE=$(jq -r '.max_idle // 3' "$CONFIG_FILE")
INJECT_GIT_RULES=$(jq -r 'if has("inject_git_rules") then .inject_git_rules else true end' "$CONFIG_FILE")
GIT_USER_NAME=$(jq -r '.git_user.name // "swarm-agent"' "$CONFIG_FILE")
GIT_USER_EMAIL=$(jq -r '.git_user.email // "agent@swarm.local"' "$CONFIG_FILE")
GIT_SIGNING_KEY=$(jq -r '.git_user.signing_key // empty' "$CONFIG_FILE")
GIT_SIGNING_KEY="$(expand_env_ref "$GIT_SIGNING_KEY")"

# Resolve signing key path and build volume mount.
SIGNING_KEY_ARGS=()
if [ -n "$GIT_SIGNING_KEY" ]; then
GIT_SIGNING_KEY="${GIT_SIGNING_KEY/#\~/$HOME}"
if [ ! -f "$GIT_SIGNING_KEY" ]; then
echo "ERROR: signing key not found: $GIT_SIGNING_KEY" >&2
exit 1
fi
SIGNING_KEY_ARGS=(-v "${GIT_SIGNING_KEY}:/etc/swarm/signing_key:ro")
fi
NUM_AGENTS=$(jq '[.agents[].count] | add' "$CONFIG_FILE")
SWARM_DRIVER_DEFAULT=$(jq -r '.driver // "claude-code"' "$CONFIG_FILE")
MAX_RETRY_WAIT=$(jq -r '.max_retry_wait // 0' "$CONFIG_FILE")
Expand Down Expand Up @@ -293,6 +306,7 @@ cmd_start() {
--name "$NAME" \
-v "${BARE_REPO}:/upstream:rw" \
"${MIRROR_ARGS[@]+"${MIRROR_ARGS[@]}"}" \
"${SIGNING_KEY_ARGS[@]+"${SIGNING_KEY_ARGS[@]}"}" \
"${DOCKER_EXTRA_ARGS[@]+"${DOCKER_EXTRA_ARGS[@]}"}" \
"${EXTRA_ENV[@]+"${EXTRA_ENV[@]}"}" \
-e "SWARM_MODEL=${agent_model}" \
Expand Down Expand Up @@ -420,8 +434,9 @@ cmd_wait() {
}

cmd_post_process() {
local pp_prompt pp_model pp_base_url pp_api_key pp_effort pp_auth pp_auth_token pp_tag pp_driver
local pp_prompt pp_model pp_base_url pp_api_key pp_effort pp_auth pp_auth_token pp_tag pp_driver pp_max_idle
pp_prompt=$(jq -r '.post_process.prompt // empty' "$CONFIG_FILE")
pp_max_idle=$(jq -r '.post_process.max_idle // .max_idle // 3' "$CONFIG_FILE")
pp_model=$(jq -r '.post_process.model // "claude-opus-4-6"' "$CONFIG_FILE")
pp_base_url=$(jq -r '.post_process.base_url // empty' "$CONFIG_FILE")
pp_api_key=$(jq -r '.post_process.api_key // empty' "$CONFIG_FILE")
Expand Down Expand Up @@ -496,14 +511,15 @@ cmd_post_process() {
--name "$NAME" \
-v "${BARE_REPO}:/upstream:rw" \
"${MIRROR_ARGS[@]+"${MIRROR_ARGS[@]}"}" \
"${SIGNING_KEY_ARGS[@]+"${SIGNING_KEY_ARGS[@]}"}" \
"${DOCKER_EXTRA_ARGS[@]+"${DOCKER_EXTRA_ARGS[@]}"}" \
"${EXTRA_ENV[@]+"${EXTRA_ENV[@]}"}" \
-e "SWARM_MODEL=${pp_model}" \
-e "SWARM_EFFORT=${pp_effort}" \
-e "CLAUDE_MODEL=${pp_model}" \
-e "SWARM_PROMPT=${pp_prompt}" \
-e "SWARM_SETUP=${SWARM_SETUP:-}" \
-e "MAX_IDLE=${MAX_IDLE}" \
-e "MAX_IDLE=${pp_max_idle}" \
-e "MAX_RETRY_WAIT=${MAX_RETRY_WAIT}" \
-e "GIT_USER_NAME=${GIT_USER_NAME}" \
-e "GIT_USER_EMAIL=${GIT_USER_EMAIL}" \
Expand Down
4 changes: 3 additions & 1 deletion lib/harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ GIT_USER_NAME="${GIT_USER_NAME:-swarm-agent}"
GIT_USER_EMAIL="${GIT_USER_EMAIL:-agent@swarm.local}"
git config --global user.name "$GIT_USER_NAME"
git config --global user.email "$GIT_USER_EMAIL"
git config --global commit.gpgsign false
# shellcheck source=signing.sh
source /signing.sh
configure_git_signing

# Capture CLI version once for the prepare-commit-msg hook.
AGENT_CLI_VERSION=$(agent_version)
Expand Down
22 changes: 22 additions & 0 deletions lib/signing.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/bin/bash
# Git SSH commit-signing config, driven by file existence.
#
# When the key file exists, enable SSH-format signing and point
# git at the key. Otherwise, disable signing. Writes to the
# global git config so subsequent `git commit` invocations pick
# it up without per-repo plumbing.
#
# Sourced by lib/harness.sh inside the container (key at
# /etc/swarm/signing_key) and by tests/test_harness.sh with a
# sandbox path.

configure_git_signing() {
local key_path="${1:-/etc/swarm/signing_key}"
if [ -f "$key_path" ]; then
git config --global gpg.format ssh
git config --global user.signingkey "$key_path"
git config --global commit.gpgsign true
else
git config --global commit.gpgsign false
fi
}
96 changes: 85 additions & 11 deletions tests/test_config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ assert_contains() {
parse_prompt() { jq -r '.prompt // empty' "$1"; }
parse_setup() { jq -r '.setup // empty' "$1"; }
parse_max_idle() { jq -r '.max_idle // 3' "$1"; }
parse_git_name() { jq -r '.git_user.name // "swarm-agent"' "$1"; }
parse_git_email() { jq -r '.git_user.email // "agent@swarm.local"' "$1"; }
parse_git_name() { jq -r '.git_user.name // "swarm-agent"' "$1"; }
parse_git_email() { jq -r '.git_user.email // "agent@swarm.local"' "$1"; }
parse_signing_key() { jq -r '.git_user.signing_key // empty' "$1"; }
parse_num_agents() { jq '[.agents[].count] | add' "$1"; }
parse_inject_git_rules() { jq -r 'if has("inject_git_rules") then .inject_git_rules else true end' "$1"; }
parse_title() { jq -r '.title // empty' "$1"; }
parse_pp_prompt() { jq -r '.post_process.prompt // empty' "$1"; }
parse_pp_model() { jq -r '.post_process.model // "claude-opus-4-6"' "$1"; }
parse_pp_max_idle() { jq -r '.post_process.max_idle // .max_idle // 3' "$1"; }

parse_agents_cfg() {
jq -r '.driver as $dd | .agents[] | range(.count) as $i |
Expand Down Expand Up @@ -259,10 +261,45 @@ cat > "$TMPDIR/pp.json" <<'EOF'
}
EOF

assert_eq "pp prompt" "review.md" "$(parse_pp_prompt "$TMPDIR/pp.json")"
assert_eq "pp model" "claude-sonnet-4-5" "$(parse_pp_model "$TMPDIR/pp.json")"
assert_eq "pp prompt absent" "" "$(parse_pp_prompt "$TMPDIR/inject_default.json")"
assert_eq "pp model default" "claude-opus-4-6" "$(parse_pp_model "$TMPDIR/inject_default.json")"
assert_eq "pp prompt" "review.md" "$(parse_pp_prompt "$TMPDIR/pp.json")"
assert_eq "pp model" "claude-sonnet-4-5" "$(parse_pp_model "$TMPDIR/pp.json")"
assert_eq "pp max_idle default" "3" "$(parse_pp_max_idle "$TMPDIR/pp.json")"
assert_eq "pp prompt absent" "" "$(parse_pp_prompt "$TMPDIR/inject_default.json")"
assert_eq "pp model default" "claude-opus-4-6" "$(parse_pp_model "$TMPDIR/inject_default.json")"
assert_eq "pp max_idle absent" "3" "$(parse_pp_max_idle "$TMPDIR/inject_default.json")"

cat > "$TMPDIR/pp_idle.json" <<'EOF'
{
"prompt": "p.md",
"max_idle": 5,
"agents": [{ "count": 1, "model": "m" }],
"post_process": {
"prompt": "review.md",
"model": "claude-sonnet-4-5",
"max_idle": 3
}
}
EOF

assert_eq "pp max_idle explicit" "3" \
"$(parse_pp_max_idle "$TMPDIR/pp_idle.json")"
assert_eq "top-level max_idle unchanged" "5" \
"$(parse_max_idle "$TMPDIR/pp_idle.json")"

cat > "$TMPDIR/pp_idle_inherit.json" <<'EOF'
{
"prompt": "p.md",
"max_idle": 7,
"agents": [{ "count": 1, "model": "m" }],
"post_process": {
"prompt": "review.md",
"model": "claude-sonnet-4-5"
}
}
EOF

assert_eq "pp max_idle inherits top-level" "7" \
"$(parse_pp_max_idle "$TMPDIR/pp_idle_inherit.json")"

# ============================================================
echo ""
Expand Down Expand Up @@ -573,7 +610,8 @@ cat > "$TMPDIR/kitchen_sink.json" <<'EOF'
"prompt": "prompts/post.md",
"model": "claude-sonnet-4-6",
"effort": "high",
"auth": "oauth"
"auth": "oauth",
"max_idle": 2
}
}
EOF
Expand Down Expand Up @@ -684,10 +722,11 @@ assert_eq "ks12 auth_token" "\$OPENROUTER_API_KEY" "$t"
assert_eq "ks12 prompt" "prompts/explore.md" "$p"

# Post-process section
assert_eq "ks pp prompt" "prompts/post.md" "$(parse_pp_prompt "$TMPDIR/kitchen_sink.json")"
assert_eq "ks pp model" "claude-sonnet-4-6" "$(parse_pp_model "$TMPDIR/kitchen_sink.json")"
assert_eq "ks pp effort" "high" "$(parse_pp_effort "$TMPDIR/kitchen_sink.json")"
assert_eq "ks pp auth" "oauth" "$(parse_pp_auth "$TMPDIR/kitchen_sink.json")"
assert_eq "ks pp prompt" "prompts/post.md" "$(parse_pp_prompt "$TMPDIR/kitchen_sink.json")"
assert_eq "ks pp model" "claude-sonnet-4-6" "$(parse_pp_model "$TMPDIR/kitchen_sink.json")"
assert_eq "ks pp effort" "high" "$(parse_pp_effort "$TMPDIR/kitchen_sink.json")"
assert_eq "ks pp auth" "oauth" "$(parse_pp_auth "$TMPDIR/kitchen_sink.json")"
assert_eq "ks pp max_idle" "2" "$(parse_pp_max_idle "$TMPDIR/kitchen_sink.json")"

# ============================================================
echo ""
Expand Down Expand Up @@ -1150,6 +1189,41 @@ else
echo " SKIP: ~/.codex/auth.json not present on host (default path test)"
fi

# ============================================================
echo ""
echo "=== 34. signing_key config ==="

cat > "$TMPDIR/signing.json" <<'EOF'
{
"prompt": "p.md",
"git_user": {
"name": "swarm-agent",
"email": "agent@swarm.local",
"signing_key": "/home/agent/.ssh/swarm-agent-signing"
},
"agents": [{ "count": 1, "model": "m" }]
}
EOF

assert_eq "signing key present" "/home/agent/.ssh/swarm-agent-signing" \
"$(parse_signing_key "$TMPDIR/signing.json")"
assert_eq "signing key absent" "" \
"$(parse_signing_key "$TMPDIR/inject_default.json")"

# git_user without signing_key still works.
cat > "$TMPDIR/nosign.json" <<'EOF'
{
"prompt": "p.md",
"git_user": { "name": "bot", "email": "bot@test" },
"agents": [{ "count": 1, "model": "m" }]
}
EOF

assert_eq "signing key missing from git_user" "" \
"$(parse_signing_key "$TMPDIR/nosign.json")"
assert_eq "git name still works" "bot" \
"$(parse_git_name "$TMPDIR/nosign.json")"

# ============================================================
echo ""
echo "==============================="
Expand Down
Loading
Loading