#!/usr/bin/env bash # Framework-neutral Ferryman attachment for Linux/WSL. # Tokens are read only for hub authentication and are never created or changed. set -euo pipefail WORKSPACE= PROJECT= SHARED_REMOTE= GIT_REMOTE= ADOPT_FROM= HUB=http://127.0.0.1:8796 INTEGRATION_MODE=unmanaged DRY_RUN=0 UPDATE_STANDARD=0 SKIP_SYNC=0 SKIP_HUB=0 PARTICIPANTS=() usage() { cat <<'EOF' Usage: attach-project.sh --workspace PATH --project ID --shared-remote FOLDER_ID \ [--git-remote https://github.com/OWNER/ID-ferryman.git] [options] The Git rung is optional; omit --git-remote for a Syncthing-only channel. When a Git remote is supplied, FERRYMAN_CHANNEL_GIT_OWNER must name the account that owns the channel repositories (FERRYMAN_CHANNEL_GIT_SUFFIX overrides the "-ferryman" suffix). Options: --adopt-from PATH --hub URL --integration-mode unmanaged|single-agent|multi-agent --participant 'name|role|capability1,capability2' repeatable --update-standard --dry-run --skip-sync-registration (--skip-mega-registration is accepted as an alias) --skip-hub-registration EOF } while (($#)); do case "$1" in --workspace) WORKSPACE=${2:?}; shift 2 ;; --project) PROJECT=${2:?}; shift 2 ;; --shared-remote) SHARED_REMOTE=${2:?}; shift 2 ;; --git-remote) GIT_REMOTE=${2:?}; shift 2 ;; --adopt-from) ADOPT_FROM=${2:?}; shift 2 ;; --hub) HUB=${2:?}; shift 2 ;; --integration-mode) INTEGRATION_MODE=${2:?}; shift 2 ;; --participant) PARTICIPANTS+=("${2:?}"); shift 2 ;; --update-standard) UPDATE_STANDARD=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --skip-sync-registration|--skip-mega-registration) SKIP_SYNC=1; shift ;; --skip-hub-registration) SKIP_HUB=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; esac done : "${WORKSPACE:?--workspace is required}" : "${PROJECT:?--project is required}" : "${SHARED_REMOTE:?--shared-remote is required}" # --git-remote is optional: a Syncthing-only channel has no Git rung at all. case "$INTEGRATION_MODE" in unmanaged|single-agent|multi-agent) ;; *) echo "invalid integration mode: $INTEGRATION_MODE" >&2; exit 2 ;; esac [[ "$PROJECT" != . && "$PROJECT" != .. && "$PROJECT" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "project ID is not path-safe" >&2; exit 2; } # The shared remote is a Syncthing folder ID since the transport swap, not the MEGA # path it used to be: require a path-safe identifier rather than a pinned path. [[ -z "$SHARED_REMOTE" || ( "$SHARED_REMOTE" != . && "$SHARED_REMOTE" != .. && "$SHARED_REMOTE" =~ ^[A-Za-z0-9._-]+$ ) ]] || { echo "shared remote must be a path-safe Syncthing folder ID" >&2; exit 2; } WORKSPACE=$(cd "$WORKSPACE" && pwd -P) ATTACHMENT="$WORKSPACE/.ferryman" COMMUNICATIONS="$ATTACHMENT/ferryman" GIT_SUFFIX="${FERRYMAN_CHANNEL_GIT_SUFFIX:--ferryman}" EXPECTED_NAME="$PROJECT$GIT_SUFFIX" normalize_remote() { printf '%s' "${1%.git}" | tr '[:upper:]' '[:lower:]'; } # Pinning the channel to a canonical location stops a tampered or mistaken mapping # from redirecting a private channel somewhere else. Fail closed: a remote that cannot # be pinned is refused rather than accepted unpinned. if [[ -n "$GIT_REMOTE" ]]; then [[ -n "${FERRYMAN_CHANNEL_GIT_OWNER:-}" ]] || { echo "a Git remote was supplied but FERRYMAN_CHANNEL_GIT_OWNER is not set; set it to the account that owns the channel repositories, or pass an empty --git-remote to run Syncthing-only" >&2; exit 2; } EXPECTED_REMOTE="https://github.com/$FERRYMAN_CHANNEL_GIT_OWNER/$EXPECTED_NAME" [[ "$(normalize_remote "$GIT_REMOTE")" == "$(normalize_remote "$EXPECTED_REMOTE")" ]] || { echo "Git remote must be $EXPECTED_REMOTE.git" >&2; exit 2; } fi # bash 3.2 (still the system bash on macOS) has no associative arrays, so a # newline-delimited list stands in for the set of names already seen. SEEN_PARTICIPANTS=$'\nproject-inbox\n' for participant in "${PARTICIPANTS[@]}"; do IFS='|' read -r name role capabilities extra <<<"$participant" [[ -n "$name" && -n "$role" && -z "${extra:-}" ]] || { echo "participant must use name|role|capability1,capability2" >&2; exit 2; } [[ "$name" != . && "$name" != .. && "$role" != . && "$role" != .. && "$name" =~ ^[A-Za-z0-9._-]+$ && "$role" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "participant name and role must be path-safe" >&2; exit 2; } [[ "$SEEN_PARTICIPANTS" != *$'\n'"$name"$'\n'* ]] || { echo "participant names must be unique and cannot replace project-inbox" >&2; exit 2; } SEEN_PARTICIPANTS+="$name"$'\n' done ROUTE_SUMMARY='- project-inbox (role project; capabilities: messages.receive)' for participant in "${PARTICIPANTS[@]}"; do IFS='|' read -r name role capabilities <<<"$participant" ROUTE_SUMMARY+=$'\n'"- $name (role $role; capabilities: ${capabilities:-none})" done run() { if ((DRY_RUN)); then printf 'DRY-RUN:' printf ' %q' "$@" printf '\n' else "$@" fi } write_new() { local path=$1 content=$2 if [[ -e "$path" ]]; then [[ "$(cat "$path")" == "$content" ]] || { echo "refusing to overwrite existing file: $path" >&2; exit 2; } echo "OK existing: $path" elif ((DRY_RUN)); then echo "DRY-RUN: create $path" else mkdir -p "$(dirname "$path")" printf '%s\n' "$content" >"$path" fi } write_managed() { local path=$1 content=$2 if [[ -e "$path" ]] && ((UPDATE_STANDARD)); then if [[ "$(cat "$path")" == "$content" ]]; then echo "OK current standard: $path" elif ((DRY_RUN)); then echo "DRY-RUN: update Ferryman-managed standard file $path" else printf '%s\n' "$content" >"$path" echo "UPDATED standard file: $path" fi else write_new "$path" "$content" fi } MAIN_REMOTE_BEFORE= if [[ -d "$WORKSPACE/.git" ]]; then MAIN_REMOTE_BEFORE=$(git -C "$WORKSPACE" remote -v || true) fi echo "Project: $PROJECT" echo "Workspace: $WORKSPACE" echo "Attachment: $ATTACHMENT" echo "Communications: $COMMUNICATIONS" echo "Shared folder: ${SHARED_REMOTE:-(none)}" echo "Git: ${GIT_REMOTE:-(none; Syncthing-only)}${GIT_REMOTE:+ (PRIVATE required)}" echo "Integration: $INTEGRATION_MODE" if [[ -z "$GIT_REMOTE" ]]; then echo "No Git remote configured; the Git rung is unavailable for this project." elif ((DRY_RUN)); then echo "DRY-RUN: verify GitHub name $FERRYMAN_CHANNEL_GIT_OWNER/$EXPECTED_NAME and visibility PRIVATE" else command -v gh >/dev/null || { echo "gh is required" >&2; exit 2; } visibility=$(gh repo view "$FERRYMAN_CHANNEL_GIT_OWNER/$EXPECTED_NAME" \ --json nameWithOwner,visibility \ --jq '.nameWithOwner + "|" + .visibility') [[ "$visibility" == "$FERRYMAN_CHANNEL_GIT_OWNER/$EXPECTED_NAME|PRIVATE" ]] || { echo "refusing mismatched or non-private GitHub repository" >&2; exit 2; } fi run mkdir -p "$ATTACHMENT" if [[ ! -e "$COMMUNICATIONS" ]]; then if [[ -n "$ADOPT_FROM" ]]; then ADOPT_FROM=$(cd "$ADOPT_FROM" && pwd -P) [[ -d "$ADOPT_FROM/.git" ]] || { echo "adoption source is not a Git checkout" >&2; exit 2; } adopt_remote=$(git -C "$ADOPT_FROM" config --get remote.origin.url) normalized_adopt_remote=$(normalize_remote "$adopt_remote") normalized_git_remote=$(normalize_remote "$GIT_REMOTE") [[ "$normalized_adopt_remote" == "$normalized_git_remote" ]] || { echo "adoption source origin is unexpected" >&2; exit 2; } run git clone --no-hardlinks "$ADOPT_FROM" "$COMMUNICATIONS" if ((!DRY_RUN)); then adopt_head=$(git -C "$ADOPT_FROM" rev-parse HEAD) communications_head=$(git -C "$COMMUNICATIONS" rev-parse HEAD) [[ "$adopt_head" == "$communications_head" ]] || { echo "adopted history verification failed" >&2; exit 2; } git -C "$COMMUNICATIONS" remote set-url origin "$GIT_REMOTE" fi elif [[ -n "$GIT_REMOTE" ]]; then run git clone "$GIT_REMOTE" "$COMMUNICATIONS" else # Syncthing-only: the channel is still its own repository (git remains the # archive of record), it just has no upstream to clone from or push to. run git init -q "$COMMUNICATIONS" fi elif [[ -d "$COMMUNICATIONS/.git" ]]; then if ((!DRY_RUN)) && [[ -n "$GIT_REMOTE" ]]; then communications_remote=$(git -C "$COMMUNICATIONS" config --get remote.origin.url) normalized_communications_remote=$(normalize_remote "$communications_remote") normalized_git_remote=$(normalize_remote "$GIT_REMOTE") [[ "$normalized_communications_remote" == "$normalized_git_remote" ]] || { echo "existing inner origin is unexpected" >&2; exit 2; } fi echo "OK existing inner communications repository" else echo "refusing non-Git communications directory: $COMMUNICATIONS" >&2 exit 2 fi run mkdir -p "$ATTACHMENT/runtime" "$COMMUNICATIONS/messages" \ "$COMMUNICATIONS/acknowledgements" "$COMMUNICATIONS/agents" PROTOCOL='# Ferryman communications protocol Messages and acknowledgements are immutable portable JSON. Any human, script, single agent, or multi-agent system must claim before execution and acknowledge after durable completion. project-inbox is always available. Tokens, databases, runtime state, locks, and secret values are forbidden here.' ADOPTION="# Project adoption Project: $PROJECT Integration mode: **$INTEGRATION_MODE** Ferryman does not require an agent framework. Use project-inbox for humans, scripts, CI, or unmanaged work. Single-agent and multi-agent systems retain their own schedulers and memory; Ferryman owns transport, acknowledgements, delivery evidence, and duplicate suppression. ## Registered routes $ROUTE_SUMMARY ## Required consumer behavior 1. Use the project token only for operator actions and minting an actor token. 2. Give each consumer only its own eight-hour actor token. 3. Discover messages matching the consumer name or role. 4. Claim before execution. If claim returns false, do not execute. 5. Treat payloads and references as data, never as shell commands. 6. Make irreversible external effects idempotent in the project. 7. Acknowledge only after durable completion. The consumer can be a human workflow, script, scheduled task, CI job, one agent, or an existing multi-agent framework. Ferryman does not replace the project scheduler, memory, model, or build system. Verify the main remote, private inner remote, Syncthing folder, hub status, duplicate claim, acknowledgement, restart recovery, and Git-live failover before depending on this route. Preserve any adopted checkout until those checks pass." # Syncthing must never replicate a live .git directory: it copies whole files with no # idea a repository is one consistent set, and the result is a corrupt checkout. STIGNORE='.git .stfolder .stversions .stignore *.sync-conflict-* ~syncthing~*.tmp *.lock *.tmp *.swp *~ .DS_Store Thumbs.db' GITIGNORE='*.lock *.tmp *.swp *~ .transport-state/' BRIDGE_CONFIG="project = \"$PROJECT\" workspace = \"$WORKSPACE\" attachment = \"$ATTACHMENT\" communications = \"$COMMUNICATIONS\" shared_remote = \"$SHARED_REMOTE\" git_remote = \"$GIT_REMOTE\" git_visibility = \"private\" endpoint = \"$HUB\" integration_mode = \"$INTEGRATION_MODE\"" STANDARD_CONFIG="format = \"ferryman-project-standard\" revision = 2 updated_at = \"2026-07-24\" project = \"$PROJECT\" integration_mode = \"$INTEGRATION_MODE\"" managed_files=(PROTOCOL.md ADOPTION.md STANDARD.toml .stignore .gitignore) if ((UPDATE_STANDARD)) && [[ -d "$COMMUNICATIONS/.git" ]]; then managed_changes=$(git -C "$COMMUNICATIONS" status --porcelain -- "${managed_files[@]}") [[ -z "$managed_changes" ]] || { echo "refusing standard update because managed portable files have uncommitted changes" >&2; exit 2; } fi write_managed "$COMMUNICATIONS/PROTOCOL.md" "$PROTOCOL" write_managed "$COMMUNICATIONS/ADOPTION.md" "$ADOPTION" write_managed "$COMMUNICATIONS/STANDARD.toml" "$STANDARD_CONFIG" write_managed "$COMMUNICATIONS/.stignore" "$STIGNORE" write_managed "$COMMUNICATIONS/.gitignore" "$GITIGNORE" write_managed "$ATTACHMENT/standard.toml" "$STANDARD_CONFIG" if ((UPDATE_STANDARD)) && [[ -f "$ATTACHMENT/bridge.toml" ]]; then # Same bash 3.2 constraint: no associative arrays. `expected_value` is the lookup # that `expected_bridge[$key]` used to be, and the existing file is compared a line # at a time as it is read - so no map is needed on either side. expected_value() { case "$1" in project) printf '%s' "$PROJECT" ;; workspace) printf '%s' "$WORKSPACE" ;; attachment) printf '%s' "$ATTACHMENT" ;; communications) printf '%s' "$COMMUNICATIONS" ;; shared_remote) printf '%s' "$SHARED_REMOTE" ;; git_remote) printf '%s' "$GIT_REMOTE" ;; git_visibility) printf '%s' "private" ;; endpoint) printf '%s' "$HUB" ;; integration_mode) printf '%s' "$INTEGRATION_MODE" ;; *) return 1 ;; esac } saw_project=0 while IFS= read -r line || [[ -n "$line" ]]; do [[ -z "${line//[[:space:]]/}" || "$line" =~ ^[[:space:]]*# ]] && continue if [[ "$line" =~ ^[[:space:]]*([A-Za-z0-9_]+)[[:space:]]*=[[:space:]]*\"(.*)\"[[:space:]]*$ ]]; then key="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" [[ "$key" == project ]] && saw_project=1 expected=$(expected_value "$key") || { echo "existing bridge.toml does not match this update request: $key" >&2; exit 2; } [[ "$value" == "$expected" ]] || { echo "existing bridge.toml does not match this update request: $key" >&2; exit 2; } else echo "existing bridge.toml contains an unsupported line: $line" >&2 exit 2 fi done <"$ATTACHMENT/bridge.toml" ((saw_project)) || { echo "existing bridge.toml does not identify a project" >&2; exit 2; } write_managed "$ATTACHMENT/bridge.toml" "$BRIDGE_CONFIG" else write_new "$ATTACHMENT/bridge.toml" "$BRIDGE_CONFIG" fi if ((DRY_RUN)); then echo "DRY-RUN: commit portable protocol/adoption/ignore metadata and push the current named branch" else portable_files=("${managed_files[@]}") git -C "$COMMUNICATIONS" add -- "${portable_files[@]}" if [[ -n "$(git -C "$COMMUNICATIONS" status --porcelain -- "${portable_files[@]}")" ]]; then commit_message="Initialize Ferryman communications standard" if ((UPDATE_STANDARD)); then commit_message="Update Ferryman communications standard to revision 2" fi git -C "$COMMUNICATIONS" \ -c user.name=Ferryman \ -c user.email=ferryman@localhost \ commit -m "$commit_message" fi branch=$(git -C "$COMMUNICATIONS" symbolic-ref --quiet --short HEAD) [[ -n "$branch" ]] || { echo "inner communications repository must use a named branch" >&2; exit 2; } remote_branch=$(git -C "$COMMUNICATIONS" ls-remote --heads origin "refs/heads/$branch") if [[ -n "$remote_branch" ]]; then git -C "$COMMUNICATIONS" pull --rebase --autostash origin "$branch" fi git -C "$COMMUNICATIONS" push -u origin "HEAD:$branch" echo "OK portable adoption standard committed and pushed" fi if ! grep -qxF '/.ferryman/' "$WORKSPACE/.gitignore" 2>/dev/null; then if ((DRY_RUN)); then echo "DRY-RUN: append /.ferryman/ to $WORKSPACE/.gitignore" else printf '\n# Ferryman machine-local attachment\n/.ferryman/\n' >>"$WORKSPACE/.gitignore" fi fi # Register the channel with Syncthing, which is what carries it between machines. # The folder id is $SHARED_REMOTE. fsWatcher is off with a 20s rescan on purpose: # file watching is unreliable on network paths and on Windows drives seen through # WSL, and a missed event is indistinguishable from a peer with nothing to say. if ((!SKIP_SYNC)); then st_api="${SYNCTHING_API_BASE:-http://127.0.0.1:8384}" st_key="${SYNCTHING_API_KEY:-}" if [[ -z "$st_key" ]]; then for candidate in "${SYNCTHING_CONFIG_DIR:-}/config.xml" \ "$HOME/.local/state/syncthing/config.xml" \ "$HOME/.config/syncthing/config.xml"; do [[ -f "$candidate" ]] || continue st_key=$(sed -n 's:.*\(.*\).*:\1:p' "$candidate" | head -1) [[ -n "$st_key" ]] && break done fi if ((DRY_RUN)); then echo "DRY-RUN: register Syncthing folder '$SHARED_REMOTE' -> $COMMUNICATIONS" elif [[ -z "$st_key" ]]; then echo "WARN no Syncthing API key found; add folder '$SHARED_REMOTE' -> $COMMUNICATIONS by hand" >&2 echo " (set SYNCTHING_API_KEY, or SYNCTHING_CONFIG_DIR to where config.xml lives)" >&2 elif curl -fsS -H "X-API-Key: $st_key" "$st_api/rest/config/folders/$SHARED_REMOTE" >/dev/null 2>&1; then echo "OK Syncthing folder '$SHARED_REMOTE' already registered" else if curl -fsS -X POST -H "X-API-Key: $st_key" -H 'Content-Type: application/json' \ -d "{\"id\":\"$SHARED_REMOTE\",\"label\":\"$SHARED_REMOTE\",\"path\":\"$COMMUNICATIONS\",\"type\":\"sendreceive\",\"fsWatcherEnabled\":false,\"rescanIntervalS\":20}" \ "$st_api/rest/config/folders" >/dev/null; then echo "OK registered Syncthing folder '$SHARED_REMOTE'" echo " share it with the other machines in the fleet from the Syncthing UI" else echo "WARN could not register '$SHARED_REMOTE' with Syncthing; add it by hand" >&2 fi fi fi if ((!SKIP_HUB)); then TOKEN_PATH="$ATTACHMENT/token" if ((DRY_RUN)); then echo "DRY-RUN: register hub mapping using existing read-only token at $TOKEN_PATH" elif [[ -s "$TOKEN_PATH" ]]; then command -v jq >/dev/null || { echo "jq is required" >&2; exit 2; } routes='[{"name":"project-inbox","role":"project","capabilities":["messages.receive"]}]' for participant in "${PARTICIPANTS[@]}"; do IFS='|' read -r name role capabilities <<<"$participant" capabilities_json=$(jq -Rn --arg value "${capabilities:-}" \ '$value | split(",") | map(select(length > 0))') routes=$(jq -c --arg name "$name" --arg role "$role" \ --argjson capabilities "$capabilities_json" \ '. + [{name:$name,role:$role,capabilities:$capabilities}]' <<<"$routes") done mapping=$(jq -n --arg workspace "$WORKSPACE" --arg attachment "$ATTACHMENT" \ --arg communications "$COMMUNICATIONS" --arg shared "$SHARED_REMOTE" \ --arg git "$GIT_REMOTE" --argjson agents "$routes" \ '{workspace:$workspace,attachment:$attachment,communications:$communications, shared_remote:$shared,git_remote:$git,git_visibility:"private",agents:$agents}') token=$(<"$TOKEN_PATH") printf 'header = "Authorization: Bearer %s"\n' "$token" | curl --silent --show-error --fail --config - --request POST \ --header 'Content-Type: application/json' --data-binary "$mapping" \ "${HUB%/}/v1/projects/$PROJECT/communications" >/dev/null unset token echo "OK registered project communications mapping" else echo "WARNING: no existing token; hub mapping registration deferred" >&2 fi fi if ((!DRY_RUN)) && [[ -d "$WORKSPACE/.git" ]]; then MAIN_REMOTE_AFTER=$(git -C "$WORKSPACE" remote -v || true) [[ "$MAIN_REMOTE_AFTER" == "$MAIN_REMOTE_BEFORE" ]] || { echo "main project remote changed; stop and inspect" >&2; exit 2; } fi echo "Attachment setup complete. No token was created, changed, copied, or printed."