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
8 changes: 5 additions & 3 deletions .agents/skills/secondmate-provisioning/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,13 @@ bin/fm-home-seed.sh <id> <home|-> {<project>...|--no-projects}
Provision a whole remote home through its configured SSH host with:

```sh
bin/fm-remote-home-seed.sh <id> <ssh-alias> <remote-root> <remote-home> {<project>...|--no-projects}
bin/fm-remote-home-seed.sh <id> <ssh-alias> <remote-root> <remote-home> {<project>[=<origin-url>]...|--no-projects}
```

The remote command transfers a bounded charter and project-origin manifest, then the remote host clones its own Firstmate home and project origins.
It never copies a project tree or the primary process environment.
You resolve each project's origin yourself - from the captain, the project registry, a clone that exists elsewhere, `gh-axi`, or an explicit paste - and name it as `<project>=<origin-url>`; the seed validates and transports what you supply.
A remote seed therefore creates nothing in this home beyond the route, the charter brief, and a launch record once it is launched: never clone a project into `projects/`, initialize no-mistakes here, or run a fleet sync just to seed a remote secondmate.
A bare `<project>` remains a convenience for a project this home already has cloned, whose configured origin is read instead.
[`docs/remote-secondmates.md`](../../../docs/remote-secondmates.md#provision-a-route) owns the rest of the operator contract, and [`bin/fm-project-origin-lib.sh`](../../../bin/fm-project-origin-lib.sh) owns the accepted origin forms.
Pass `--no-projects` in the project position to seed the project-less home described above; the same mutual-exclusion and fail-loud-on-omission rules apply.
It may only seed a home with no project clones or project-registry entries, and refuses conversion of populated homes without changing them.
`-` durably leases a fresh firstmate worktree via `treehouse get --lease` under the secondmate id.
Expand Down
180 changes: 180 additions & 0 deletions bin/fm-project-origin-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# Validate a project origin URL that one home hands to another.
#
# Firstmate supplies a project's origin instead of discovering it from a local
# clone, and the receiving host re-validates whatever reached it, so this file
# is the single owner of which origins are accepted. Nothing here discovers an
# origin, so no caller has to create a local clone just to learn one. It is
# sourced by both the sending parent (bin/fm-remote-home-seed.sh) and the
# receiving host (bin/fm-remote-home-provision.sh), so an unsafe value is
# refused at each end rather than trusted because the other end already looked
# at it.
#
# Validation is STRUCTURE AND SAFETY ONLY, never the forge or the domain.
# Firstmate is a shared template, so any host must be able to serve a project:
# GitHub, GitHub Enterprise on a private domain, GitLab hosted or self-hosted,
# Bitbucket, Gitea, Codeberg, sr.ht, a bare IP, an SSH config alias, or a plain
# server nobody else has heard of. There is no host, domain, or forge allowlist
# here, and there must never be one.
#
# Accepted forms:
# https://[userinfo@]host[:port]/path, http://…, ssh://…, git://…
# a non-option-shaped plain host or bracketed
# IPv6 literal, an optional numeric port, and
# any path
# file:///path a repository this host can reach as a file
# [user@]host:path scp-like syntax; host may be a name, an SSH
# config alias, an IPv4 address, or a bracketed
# IPv6 literal such as [2001:db8::1]
# /absolute/path a repository on the cloning host's filesystem
#
# Refused:
# remote-helper transports such as "ext::<command>", which git executes as a
# command whenever the cloning host's protocol configuration permits it, and
# the sending home cannot see that configuration
# any other unknown scheme
# option-shaped values a later command line could absorb as a flag
# whitespace and control characters, including embedded newlines
# relative paths, which resolve against whatever directory git happens to
# be in on the other machine
# "/../" traversal inside a local or file: path
fm_project_origin_safe() { # <url>; 0 when the URL is an accepted clone URL
local url=${1-} rest authority userpart hostpart port inner host path

case $url in
'' | -*) return 1 ;;
esac
case $url in
*[[:space:]]* | *[[:cntrl:]]*) return 1 ;;
esac

case $url in
https://?* | http://?* | ssh://?* | git://?*)
rest=${url#*://}
authority=${rest%%/*}
case $authority in
'') return 1 ;;
esac

hostpart=$authority
case $authority in
*@*)
userpart=${authority%@*}
hostpart=${authority##*@}
case $userpart in
'' | -* | *'['* | *']'*) return 1 ;;
esac
;;
esac

case $hostpart in
'['*)
case $hostpart in
*']'*) ;;
*) return 1 ;;
esac
host=${hostpart%%']'*}']'
port=${hostpart#"$host"}
inner=${host#'['}
inner=${inner%']'}
case $inner in
*:*) ;;
*) return 1 ;;
esac
case $inner in
*[!0-9A-Fa-f:.%]*) return 1 ;;
esac
case $port in
'') ;;
:?*)
port=${port#:}
case $port in
*[!0-9]*) return 1 ;;
esac
;;
*) return 1 ;;
esac
;;
*)
case $hostpart in
*'['* | *']'*) return 1 ;;
esac
host=${hostpart%%:*}
case $host in
'' | -* | *[!A-Za-z0-9._-]*) return 1 ;;
esac
if [[ $hostpart == *:* ]]; then
port=${hostpart#*:}
case $port in
'' | *[!0-9]*) return 1 ;;
esac
fi
;;
esac
return 0
;;
file:///?*)
case "/${url#file://}/" in
*/../*) return 1 ;;
esac
return 0
;;
/?*)
case "/$url/" in
*/../*) return 1 ;;
esac
return 0
;;
*://*) return 1 ;;
esac

# scp-like [user@]host:path. Strip the user only when its "@" really precedes
# the host, so a path that merely contains "@" keeps its own colon boundary.
rest=$url
case $url in
*@*)
userpart=${url%%@*}
case $userpart in
*:*) ;;
*) rest=${url#*@} ;;
esac
;;
esac

case $rest in
'['*)
hostpart=${rest%%']'*}']'
path=${rest#"$hostpart"}
case $path in
:?*) path=${path#:} ;;
*) return 1 ;;
esac
inner=${hostpart#'['}
inner=${inner%']'}
# A bracketed host is only meaningful as an IPv6 literal, so require its
# colon rather than accepting brackets around an arbitrary string.
case $inner in
*:*) ;;
*) return 1 ;;
esac
case $inner in
*[!0-9A-Fa-f:.%]*) return 1 ;;
esac
return 0
;;
esac

case $rest in
*:*) ;;
*) return 1 ;;
esac
host=${rest%%:*}
path=${rest#*:}
case $host in
'' | -* | *[!A-Za-z0-9._-]*) return 1 ;;
esac
case $path in
'' | :*) return 1 ;;
esac
return 0
}
14 changes: 10 additions & 4 deletions bin/fm-remote-home-provision.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
# fm-remote-home-provision.sh < manifest
#
# Manifest schema fm-remote-home-provision.v1 carries a base64 charter, the
# base64 parent SSH alias, and one base64 project record per line. The remote
# code root is cloned into an absent home, project origins are cloned on this
# host, the project registry and charter are published, the durable
# .fm-secondmate-parent record names this home's route to its parent as
# base64 parent SSH alias, and one base64 project record per line. Each project
# record's origin is the URL the parent resolved and named, so this host clones
# from it and re-validates it through bin/fm-project-origin-lib.sh instead of
# trusting the sender. The remote code root is cloned into an absent home,
# project origins are cloned on this host, the project registry and charter are
# published, the durable .fm-secondmate-parent record names this home's route to its parent as
# "remote" - read by bin/fm-teardown.sh's cleanup gate so a delegated public
# reply promise, which the subsystem can only carry on the parent's own
# filesystem, is never mistaken for one this child could hold - and the
Expand All @@ -22,6 +24,9 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
FM_HOME=${FM_HOME:?FM_HOME is required}
MAX_MANIFEST_BYTES=1048576

# shellcheck source=bin/fm-project-origin-lib.sh
. "$SCRIPT_DIR/fm-project-origin-lib.sh"

die() { printf 'error: %s\n' "$1" >&2; exit 1; }

base64_decode_to() {
Expand Down Expand Up @@ -213,6 +218,7 @@ EOF
MODE=$(cat "$TMP/mode")
safe_id "$NAME" || die "project name is unsafe: $NAME"
[ -n "$ORIGIN" ] || die "project $NAME has no origin"
fm_project_origin_safe "$ORIGIN" || die "project $NAME origin is not an accepted clone URL: $ORIGIN"
case "$MODE" in no-mistakes|direct-PR) ;; *) die "project $NAME has unsupported remote mode: $MODE" ;; esac
case "$REGISTRY_LINE" in "- $NAME "*) ;; *) die "project $NAME registry line is malformed" ;; esac
DEST="$FM_HOME/projects/$NAME"
Expand Down
48 changes: 39 additions & 9 deletions bin/fm-remote-home-seed.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@
# Register and provision a whole secondmate home on an SSH-reachable host.
#
# Usage:
# fm-remote-home-seed.sh <id> <ssh-alias> <remote-root> <remote-home> {<project>...|--no-projects}
# fm-remote-home-seed.sh <id> <ssh-alias> <remote-root> <remote-home> {<project>[=<origin-url>]...|--no-projects}
#
# The SSH alias must already reach a host whose non-interactive PATH exposes the
# fixed fm-remote-entrypoint.sh from <remote-root>. The command records the
# remote host dimension in data/secondmates.md, gates the host on
# fm-remote-doctor.sh readiness before touching it, sends a bounded provisioning
# manifest through fm-on.sh, and lets the remote host clone its own Firstmate
# home and project origins. No project tree or secret environment is copied.
#
# Each project needs an origin the remote account can clone. Firstmate resolves
# that origin and names it as <project>=<origin-url>, so seeding never requires
# a clone of that project in this home; a bare <project> is accepted only when
# this home already has projects/<project>, whose origin is then read instead.
# bin/fm-project-origin-lib.sh owns which URLs are accepted, and this home's
# data/projects.md still owns the project's registered delivery mode, so an
# unregistered or local-only project is refused rather than provisioned.
# Seeding writes nothing under projects/ and needs no fleet sync first.
#
# Known provisioning failure rolls the registry back. SSH status 255 preserves
# the route and any newly scaffolded brief because completion is unknown and a same-route rerun converges.
set -eu
Expand All @@ -31,9 +41,11 @@ MAX_MANIFEST_BYTES=1048576
. "$SCRIPT_DIR/fm-wake-lib.sh"
# shellcheck source=bin/fm-remote-readiness-lib.sh
. "$SCRIPT_DIR/fm-remote-readiness-lib.sh"
# shellcheck source=bin/fm-project-origin-lib.sh
. "$SCRIPT_DIR/fm-project-origin-lib.sh"

die() { printf 'error: %s\n' "$1" >&2; exit 1; }
usage() { sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'; exit 2; }
usage() { sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'; exit 2; }
encode() { base64 | tr -d '\n'; }
safe_id() { case "$1" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac; }

Expand Down Expand Up @@ -69,12 +81,21 @@ case "$REMOTE_ROOT/" in "$REMOTE_HOME/"*) die "remote code root must not be insi

NO_PROJECTS=0
PROJECT_NAMES=()
PROJECT_ORIGINS=()
for arg in "$@"; do
if [ "$arg" = --no-projects ]; then
NO_PROJECTS=1
else
safe_id "$arg" || die "invalid project name: $arg"
PROJECT_NAMES+=("$arg")
name=${arg%%=*}
origin=
case "$arg" in *=*) origin=${arg#*=} ;; esac
safe_id "$name" || die "invalid project name: $name"
case "$arg" in
*=*) fm_project_origin_safe "$origin" \
|| die "project $name origin is not an accepted clone URL: $origin" ;;
esac
PROJECT_NAMES+=("$name")
PROJECT_ORIGINS+=("$origin")
fi
done
if [ "$NO_PROJECTS" -eq 1 ]; then
Expand Down Expand Up @@ -135,9 +156,10 @@ done < "$BRIEF" > "$TMP/charter.remote"

PROJECTS_CSV=
: > "$TMP/project.records"
PROJECT_INDEX=0
for project in "${PROJECT_NAMES[@]}"; do
SRC="$PROJECTS/$project"
[ -d "$SRC/.git" ] || die "project clone is unavailable: $SRC"
ORIGIN=${PROJECT_ORIGINS[$PROJECT_INDEX]}
PROJECT_INDEX=$((PROJECT_INDEX + 1))
MODE_LINE=$(FM_HOME="$FM_HOME" FM_DATA_OVERRIDE="$DATA" "$SCRIPT_DIR/fm-project-mode.sh" "$project")
read -r MODE _ <<EOF
$MODE_LINE
Expand All @@ -147,9 +169,17 @@ EOF
local-only) die "project $project is local-only and cannot be provisioned remotely" ;;
*) die "project $project has unsupported delivery mode: $MODE" ;;
esac
ORIGIN=$(git -C "$SRC" remote get-url origin 2>/dev/null || true)
[ -n "$ORIGIN" ] || die "project $project has no origin remote"
REGISTRY_LINE=$(awk -v p="$project" '$1 == "-" && $2 == p { print; exit }' "$DATA/projects.md")
# An origin named on the command line is authoritative. Reading one from a
# clone this home happens to have is only a convenience for the already-cloned
# case; it is never a reason to create one.
if [ -z "$ORIGIN" ] && [ -d "$PROJECTS/$project/.git" ]; then
ORIGIN=$(git -C "$PROJECTS/$project" remote get-url origin 2>/dev/null || true)
fi
[ -n "$ORIGIN" ] \
|| die "project $project has no origin; pass $project=<origin-url> so the remote host can clone it"
fm_project_origin_safe "$ORIGIN" \
|| die "project $project origin is not an accepted clone URL: $ORIGIN"
REGISTRY_LINE=$(awk -v p="$project" '$1 == "-" && $2 == p { print; exit }' "$DATA/projects.md" 2>/dev/null || true)
[ -n "$REGISTRY_LINE" ] || die "project $project has no registry record"
NAME_B64=$(printf '%s' "$project" | encode)
ORIGIN_B64=$(printf '%s' "$ORIGIN" | encode)
Expand Down
3 changes: 1 addition & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,8 @@ That keeps spawn launch compatible across claude, codex, opencode, pi, pi-signed
`data/secondmates.md` records persistent secondmates with natural-language scopes, project clone lists, and home paths.
A local route points directly at its home, while a remote route adds an SSH alias and remote Firstmate code root so the entire home and all of its child work stay on that host.
Remote placement pins the remote second-mate agent to Herdr while leaving the remote home's worker backend selection independent, and every non-doctor primary-to-remote `fm-on` command runs through the remote account's Firstmate-owned job worker rather than its SSH process or a Herdr pane.
[`remote-secondmates.md`](remote-secondmates.md) owns current setup, transport, relay, failure, and retirement behavior.
[`remote-secondmates.md`](remote-secondmates.md) owns current setup, supplied-origin provisioning, transport, relay, failure, and retirement behavior.
`fm-home-seed.sh` provisions a local isolated home, clones the listed PR-based projects into it, initializes newly cloned `no-mistakes` projects, copies the charter to `data/charter.md`, and `fm-spawn.sh --secondmate` launches it through the same session-provider and status-file path as any direct report.
`fm-remote-home-seed.sh` sends a bounded charter and origin manifest through the generic transport so the remote host clones and provisions its own home and projects.
For a domain whose subject is the firstmate repo itself, a deliberate `--no-projects` seed creates a project-less home whose crews take pooled worktrees of that repo instead of separate clones.
The signal cannot be mixed with project names or omitted accidentally, and a populated home cannot be converted in place; the full seed contract is in [configuration.md](configuration.md#secondmate-routes-datasecondmatesmd).
Herdr secondmate and child placement follows the launcher-binding contract in [Watching and task containers](herdr-backend.md#watching-and-task-containers).
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ A remote route adds `host:` and `root:` before the existing fields and places th
Use `fm-home-seed.sh validate` to check the complete operational registry contract documented by the command itself.
The main first mate routes by reading those scopes with judgment; the project list is provisioning data, not exclusive ownership.
Use `fm-home-seed.sh <id> - {<project>...|--no-projects}` to lease a fresh local firstmate worktree for the secondmate home.
Use `fm-remote-home-seed.sh <id> <ssh-alias> <remote-root> <remote-home> {<project>...|--no-projects}` to provision a whole home on an SSH-reachable host.
For remote provisioning, including supplied project origins, follow [Remote second mates](remote-secondmates.md#provision-a-route).
Use the deliberate `--no-projects` signal only for a firstmate-repo domain that needs no separate project clones.
It cannot be combined with a project list, and omitting both still fails loudly.
A project-less seed requires no existing project clones or `data/projects.md` entries in the home, so it refuses a populated-home conversion without changing that home.
Expand Down
Loading
Loading