From b8da7834b7610c9629fc3aef2c77ba2a27d95308 Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Mon, 3 Aug 2026 06:33:02 +0300 Subject: [PATCH 01/20] feat(bootstrap): add GitLab provider boundary Derive forge tools and auth checks from registered project remotes so GitLab-only homes do not require GitHub tooling. Keep unknown hosts fail-closed and scope self-managed GitLab through FM_GITLAB_HOSTS. Refs: #695 --- bin/fm-forge-lib.sh | 236 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100755 bin/fm-forge-lib.sh diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh new file mode 100755 index 00000000000..640a3199f07 --- /dev/null +++ b/bin/fm-forge-lib.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# fm-forge-lib.sh — thin Firstmate forge provider boundary (bootstrap slice). +# +# Derives bootstrap CLI/auth requirements from registered project checkouts +# so GitLab-only homes do not require gh/gh-axi/gh auth, and GitHub-only +# homes do not require glab. Deliberately does NOT implement merge, teardown, +# head-SHA, or review-diff parity — no-mistakes owns those for no-mistakes +# delivery mode via glab/gh. +# +# +# Exit contract (subset actually used by the operations below): +# 0 = success, normalized output on stdout +# 1 = provider CLI / auth / network failure — no positive state may be inferred +# 2 = invalid/unsafe input — caller error +# 5 = capability unsupported by this provider/topology (used for "local"/"unknown") + +set -u + +# fm_forge_detect_provider +# Resolves a project checkout's `origin` remote to a provider. Does not +# guess: unknown/missing remotes are reported as "unknown", never silently +# defaulted to github or gitlab (matches the plan's boundary rule). +# +# Output: one line, one of: github gitlab local unknown +# Exit: 0 always (the classification itself is the result; "unknown" is not +# a script failure) +fm_forge_gitlab_hosts() { + local host + printf '%s\n' "${FM_GITLAB_HOSTS:-gitlab.com}" \ + | tr ',' '\n' \ + | while IFS= read -r host; do + host=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + [ -n "$host" ] && printf '%s\n' "$host" + done +} + +fm_forge_host_is_gitlab() { + local host=${1:-} + [ -n "$host" ] || return 1 + fm_forge_gitlab_hosts | grep -F -x -q -- "$host" +} + +fm_forge_checkout_remote() { + local checkout=${1:-} remote + [ -d "$checkout" ] || return 1 + remote=$(git -C "$checkout" remote get-url origin 2>/dev/null) && { + printf '%s\n' "$remote" + return 0 + } + while IFS= read -r remote; do + [ -n "$remote" ] || continue + git -C "$checkout" remote get-url "$remote" 2>/dev/null && return 0 + done < <(git -C "$checkout" remote 2>/dev/null) + return 1 +} + +fm_forge_detect_provider() { + local checkout=${1:-} remote_url host + + if [ -z "$checkout" ] || [ ! -d "$checkout" ]; then + echo "unknown" + return 0 + fi + if ! git -C "$checkout" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "local" + return 0 + fi + remote_url=$(fm_forge_checkout_remote "$checkout") || { + echo "local" + return 0 + } + host=$(fm_forge_resolve_host "$remote_url" 2>/dev/null) || host="" + case "$host" in + github.com) echo "github" ;; + gitlab.com) echo "gitlab" ;; + *) + if fm_forge_host_is_gitlab "$host"; then + echo "gitlab" + else + echo "unknown" + fi + ;; + esac +} + +# fm_forge_resolve_host +# Resolves a git remote URL (scp-like SSH syntax, ssh:// URL, or https:// URL) +# to its real hostname, following `~/.ssh/config` Host aliases via `ssh -G` +# (needed because this stack's GitLab remotes use an alias, `gitlab-becoming`, +# not a literal hostname — see DAILY_OPERATIONS_GUIDE.md project setup). +# +# Output: one line, the resolved lowercase hostname, or empty on failure +# Exit: 0 if resolved, 1 if the remote URL could not be parsed at all +fm_forge_resolve_host() { + local remote_url=${1:-} host="" + + [ -n "$remote_url" ] || return 1 + + case "$remote_url" in + git@*:*|*@*:*) + # scp-like syntax: user@host:path (host may be an ssh config alias) + host=${remote_url#*@} + host=${host%%:*} + ;; + ssh://*) + host=${remote_url#ssh://} + host=${host#*@} + host=${host%%/*} + host=${host%%:*} + ;; + https://*|http://*) + host=${remote_url#*://} + host=${host%%/*} + host=${host%%@*} + ;; + *) + return 1 + ;; + esac + + [ -n "$host" ] || return 1 + + # Follow SSH config aliases when possible. If ssh cannot resolve the alias, + # retain the parsed host; provider classification will fail closed unless + # it is explicitly listed in FM_GITLAB_HOSTS. + local resolved + if command -v ssh >/dev/null 2>&1; then + resolved=$(ssh -G -- "$host" 2>/dev/null | awk '$1=="hostname"{print $2; exit}') + [ -n "$resolved" ] && host=$resolved + fi + + printf '%s\n' "$host" | tr '[:upper:]' '[:lower:]' + return 0 +} + +# fm_forge_require_cli +# Validates that the CLI tools required for this provider are on PATH. +# Does not check auth (see fm_forge_check_auth) — only binary presence. +# +# Output: one MISSING line per absent tool (same shape as fm-bootstrap.sh's +# existing missing_tool_diagnostic lines), nothing when all present +# Exit: 0 if all required tools present, 1 if any are missing +fm_forge_require_cli() { + local provider=${1:-} missing=0 tool + + case "$provider" in + github) + for tool in gh gh-axi; do + command -v "$tool" >/dev/null 2>&1 || { echo "MISSING: $tool"; missing=1; } + done + ;; + gitlab) + for tool in glab; do + command -v "$tool" >/dev/null 2>&1 || { echo "MISSING: $tool"; missing=1; } + done + ;; + local|unknown) + : # no forge CLI required + ;; + *) + return 2 + ;; + esac + + return "$missing" +} + +# fm_forge_check_auth +# Checks forge authentication, scoped to the specific host (never an +# unscoped "--all" check — a single stale unrelated credential must not +# fail an otherwise-healthy host, per the plan's explicit warning). +# +# Output: nothing on success; one diagnostic line on failure, matching the +# existing NEEDS_GH_AUTH convention so fm-session-start.sh's existing +# consumers keep working without modification +# Exit: 0 authenticated, 1 not authenticated / check failed +fm_forge_check_auth() { + local provider=${1:-} host=${2:-} + + case "$provider" in + github) + if [ -n "$host" ]; then + gh auth status --hostname "$host" >/dev/null 2>&1 || { echo "NEEDS_GH_AUTH"; return 1; } + else + gh auth status >/dev/null 2>&1 || { echo "NEEDS_GH_AUTH"; return 1; } + fi + ;; + gitlab) + # Missing CLI is already reported by fm-bootstrap.sh; do not emit a + # misleading auth diagnostic for an unavailable binary. + command -v glab >/dev/null 2>&1 || return 1 + if [ -n "$host" ]; then + glab auth status --hostname "$host" >/dev/null 2>&1 || { echo "NEEDS_GLAB_AUTH: $host"; return 1; } + else + glab auth status >/dev/null 2>&1 || { echo "NEEDS_GLAB_AUTH"; return 1; } + fi + ;; + local|unknown) + return 0 + ;; + *) + return 2 + ;; + esac + + return 0 +} + +# fm_forge_scan_registered_projects +# Scans every symlinked/real project checkout directly under the given +# projects directory (matches $FM_HOME/projects layout) and prints one +# " " line per entry. Used by fm-bootstrap.sh +# to compute the required tool/auth union across the actual registry +# instead of the previous unconditional GitHub-only assumption. +# +# Output: " " per line (host empty for +# local/unknown) +# Exit: 0 always (per-project detection failures are reported inline via +# provider=unknown, not a fatal scan error) +fm_forge_scan_registered_projects() { + local projects_dir=${1:-} entry project_id provider remote_url host + + [ -n "$projects_dir" ] && [ -d "$projects_dir" ] || return 0 + + for entry in "$projects_dir"/*/; do + [ -e "$entry" ] || continue + project_id=$(basename "$entry") + provider=$(fm_forge_detect_provider "$entry") + host="" + if [ "$provider" = "github" ] || [ "$provider" = "gitlab" ]; then + remote_url=$(fm_forge_checkout_remote "$entry" 2>/dev/null) || remote_url="" + [ -n "$remote_url" ] && host=$(fm_forge_resolve_host "$remote_url" 2>/dev/null || true) + fi + printf '%s %s %s\n' "$project_id" "$provider" "${host:-}" + done +} From f71bc809989427aee5342ecf76965b6f0fcf7402 Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Mon, 3 Aug 2026 11:08:32 +0300 Subject: [PATCH 02/20] fix(bootstrap): satisfy shellcheck for GitLab CLI Keep the GitLab tool diagnostic explicit instead of using a single-item loop. --- bin/fm-forge-lib.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh index 640a3199f07..1fc16ace229 100755 --- a/bin/fm-forge-lib.sh +++ b/bin/fm-forge-lib.sh @@ -150,9 +150,10 @@ fm_forge_require_cli() { done ;; gitlab) - for tool in glab; do - command -v "$tool" >/dev/null 2>&1 || { echo "MISSING: $tool"; missing=1; } - done + if ! command -v glab >/dev/null 2>&1; then + echo "MISSING: glab" + missing=1 + fi ;; local|unknown) : # no forge CLI required From 706b24e31b6f760bf016ebb28c323078521cbffa Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Mon, 3 Aug 2026 11:35:13 +0300 Subject: [PATCH 03/20] no-mistakes(review): Fail closed across GitLab bootstrap provider boundaries --- .agents/skills/bootstrap-diagnostics/SKILL.md | 5 ++- AGENTS.md | 2 +- bin/fm-bootstrap.sh | 30 +++++++++++++- bin/fm-forge-lib.sh | 24 +++++++---- tests/fm-bootstrap.test.sh | 41 +++++++++++++++++++ 5 files changed, 91 insertions(+), 11 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 95932444f83..207cbe67635 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, NETWORK_CHECKS, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, SECONDMATE_HANDOFF, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh or bin/fm-startup-network.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, NEEDS_GLAB_AUTH, FORGE_UNSUPPORTED, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, NETWORK_CHECKS, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, SECONDMATE_HANDOFF, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh or bin/fm-startup-network.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -25,6 +25,9 @@ When any diagnostic needs captain attention, report the plain consequence and re - `MISSING_MANUAL: (instructions: )` - tell the captain why the tool is required and give them the printed instructions URL, but do not pass the tool to `bin/fm-bootstrap.sh install`; wait for the captain to complete the manual installation, then rerun session start to confirm the dependency is present. - `BACKEND_INVALID: (known: )` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends. - `NEEDS_GH_AUTH` - ask the captain to run `! gh auth login` (interactive; you cannot run it for them). +- `NEEDS_GLAB_AUTH: ` - ask the captain to run `! glab auth login --hostname ` (interactive; you cannot run it for them). +- `FORGE_UNSUPPORTED: (host: )` - the registered project has an origin outside the configured GitHub/GitLab provider boundary, so do not dispatch forge-dependent work for it. + Confirm the remote is correct, or add a self-managed GitLab hostname to `FM_GITLAB_HOSTS`, then rerun session start. This probe now arrives from the deferred network stage, so it is also how an unreachable network shows up: `gh` cannot validate its token offline and reports the same failure. Confirm reachability before asking the captain to re-authenticate a credential that may be fine. - `NETWORK_CHECKS: ; rerun ` - the deferred network stage itself could not finish, so the checks it names are simply unknown, not failed. Rerun the printed command; it is idempotent and re-derives every finding. diff --git a/AGENTS.md b/AGENTS.md index 45904d1e171..de8f9dd2f0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -524,7 +524,7 @@ It performs guarded fast-forward updates of firstmate and registered secondmate These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `NETWORK_CHECKS:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `NEEDS_GLAB_AUTH:`, `FORGE_UNSUPPORTED:`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `NETWORK_CHECKS:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `ask-user-authority` - load before deciding any ask-user finding. - `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi default TOON. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 47203fc17bc..1d45d242bbc 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -752,7 +752,7 @@ secondmate_handoff_detect() { install_cmd() { case "$1" in - tmux|node|git|gh|curl|jq|orca|zellij) echo "brew install $1 # or the platform's package manager" ;; + tmux|node|git|gh|glab|curl|jq|orca|zellij) echo "brew install $1 # or the platform's package manager" ;; cmux) echo "brew install --cask cmux # or see https://cmux.com" ;; treehouse) echo "curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh" ;; no-mistakes) echo "curl -fsSL https://raw.githubusercontent.com/kunchenguid/no-mistakes/main/docs/install.sh | sh" ;; @@ -784,7 +784,15 @@ missing_tool_diagnostic() { # fm_backend_required_tools (bin/fm-backend.sh). So a herdr/zellij/cmux home is # never told tmux is missing, and only orca drops treehouse. A backend value with # no verified dependency set is reported before the universal checks continue. -COMMON_TOOLS="node git gh no-mistakes gh-axi chrome-devtools-axi lavish-axi tasks-axi quota-axi" +COMMON_TOOLS="node git no-mistakes chrome-devtools-axi lavish-axi tasks-axi quota-axi" +FORGE_PROJECTS=$(fm_forge_scan_registered_projects "$PROJECTS") +FORGE_PROVIDERS_SEEN=$(printf '%s\n' "$FORGE_PROJECTS" | awk '{print $2}' | sort -u) +if printf '%s\n' "$FORGE_PROVIDERS_SEEN" | grep -qx github; then + COMMON_TOOLS="$COMMON_TOOLS gh gh-axi" +fi +if printf '%s\n' "$FORGE_PROVIDERS_SEEN" | grep -qx gitlab; then + COMMON_TOOLS="$COMMON_TOOLS glab" +fi BACKEND=$(fm_backend_name) BACKEND_VALID=1 if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then @@ -1158,6 +1166,24 @@ detect_local_tools() { if command -v tasks-axi >/dev/null 2>&1 && ! fm_tasks_axi_compatible; then echo "MISSING: tasks-axi (install: $(install_cmd tasks-axi))" fi + FORGE_AUTH_CHECKED="" + while read -r _proj_id _proj_provider _proj_host; do + [ -n "${_proj_provider:-}" ] || continue + case "$_proj_provider" in + github|gitlab) : ;; + unknown) + echo "FORGE_UNSUPPORTED: $_proj_id (host: ${_proj_host:-unresolved})" + continue + ;; + *) continue ;; + esac + _pair="$_proj_provider:${_proj_host:-}" + case " $FORGE_AUTH_CHECKED " in + *" $_pair "*) continue ;; + esac + FORGE_AUTH_CHECKED="$FORGE_AUTH_CHECKED $_pair" + fm_forge_check_auth "$_proj_provider" "${_proj_host:-}" + done <<< "$FORGE_PROJECTS" } detect_local_config() { diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh index 1fc16ace229..6fdd14da939 100755 --- a/bin/fm-forge-lib.sh +++ b/bin/fm-forge-lib.sh @@ -111,7 +111,11 @@ fm_forge_resolve_host() { https://*|http://*) host=${remote_url#*://} host=${host%%/*} - host=${host%%@*} + host=${host##*@} + case "$host" in + \[*\]*) host=${host#\[}; host=${host%%\]*} ;; + *:*) host=${host%%:*} ;; + esac ;; *) return 1 @@ -155,9 +159,13 @@ fm_forge_require_cli() { missing=1 fi ;; - local|unknown) + local) : # no forge CLI required ;; + unknown) + echo "FORGE_UNSUPPORTED" + return 1 + ;; *) return 2 ;; @@ -196,9 +204,13 @@ fm_forge_check_auth() { glab auth status >/dev/null 2>&1 || { echo "NEEDS_GLAB_AUTH"; return 1; } fi ;; - local|unknown) + local) return 0 ;; + unknown) + echo "FORGE_UNSUPPORTED" + return 1 + ;; *) return 2 ;; @@ -228,10 +240,8 @@ fm_forge_scan_registered_projects() { project_id=$(basename "$entry") provider=$(fm_forge_detect_provider "$entry") host="" - if [ "$provider" = "github" ] || [ "$provider" = "gitlab" ]; then - remote_url=$(fm_forge_checkout_remote "$entry" 2>/dev/null) || remote_url="" - [ -n "$remote_url" ] && host=$(fm_forge_resolve_host "$remote_url" 2>/dev/null || true) - fi + remote_url=$(fm_forge_checkout_remote "$entry" 2>/dev/null) || remote_url="" + [ -n "$remote_url" ] && host=$(fm_forge_resolve_host "$remote_url" 2>/dev/null || true) printf '%s %s %s\n' "$project_id" "$provider" "${host:-}" done } diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 1810e6b5f0b..0e85a6d4fb3 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -613,6 +613,46 @@ test_herdr_install_requires_manual_action() { pass "bootstrap: Herdr manual-install guidance is never executed as a shell command" } +test_forge_provider_bootstrap_contracts() { + local case_dir fakebin out project + + case_dir="$TMP_ROOT/forge-unknown" + project="$case_dir/home/projects/mystery" + mkdir -p "$project" "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$project" init -q + git -C "$project" remote add origin https://code.example/team/project.git + fakebin=$(make_fake_toolchain "$case_dir") + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ "$out" = "FORGE_UNSUPPORTED: mystery (host: code.example)" ] \ + || fail "unknown forge must fail closed with its project and host, got: $out" + + case_dir="$TMP_ROOT/forge-gitlab" + project="$case_dir/home/projects/gitlab-project" + mkdir -p "$project" "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$project" init -q + git -C "$project" remote add origin https://user@gitlab.example:8443/team/project.git + fakebin=$(make_fake_toolchain "$case_dir") + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_GITLAB_HOSTS=gitlab.example FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ "$out" = "MISSING: glab (install: brew install glab # or the platform's package manager)" ] \ + || fail "GitLab remote with userinfo and port must require installable glab, got: $out" + + cat > "$fakebin/glab" <<'SH' +#!/usr/bin/env bash +exit 1 +SH + chmod +x "$fakebin/glab" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_GITLAB_HOSTS=gitlab.example FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ "$out" = "NEEDS_GLAB_AUTH: gitlab.example" ] \ + || fail "GitLab auth diagnostic must retain the normalized host, got: $out" + + pass "bootstrap fails closed and owns GitLab install/auth remediation" +} + test_cmux_bundled_cli_satisfies_dependency() { local case_dir fakebin bundle out case_dir="$TMP_ROOT/cmux-bundled-cli" @@ -1159,6 +1199,7 @@ test_orca_backend_gates_orca_tool_only_when_selected test_session_provider_backends_do_not_require_tmux test_session_provider_backends_gate_own_cli_not_tmux test_herdr_install_requires_manual_action +test_forge_provider_bootstrap_contracts test_cmux_bundled_cli_satisfies_dependency test_unknown_backend_reports_invalid_configuration test_json_backends_require_jq_not_tmux From 86207090c4c877be578336d7bb54cce7d36d6d34 Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Mon, 3 Aug 2026 11:42:44 +0300 Subject: [PATCH 04/20] no-mistakes(review): Preserve local origin-only forge discovery semantics --- .agents/skills/bootstrap-diagnostics/SKILL.md | 2 +- bin/fm-forge-lib.sh | 10 +++++--- tests/fm-bootstrap.test.sh | 25 ++++++++++++++++++- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 207cbe67635..52de5591fca 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -13,7 +13,7 @@ metadata: Handle each printed line as below, before dispatching work that depends on it. The line formats themselves are owned by `bin/fm-bootstrap.sh`'s header; this playbook owns the response to actionable lines. -The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then install - never install anything the captain has not approved in this session - and no work is dispatched until the tools it needs are present and GitHub auth is good. +The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then install - never install anything the captain has not approved in this session - and no work is dispatched until the tools it needs are present and authentication is good for each registered supported forge. When any diagnostic needs captain attention, report the plain consequence and requested action using `AGENTS.md` section 9's captain-facing translation contract; do not name the diagnostic label unless the captain needs to paste it into a command or issue. - `MISSING: (install: )` - list the missing tools to the captain with a one-line purpose each plus the printed install commands, wait for consent (one approval may cover the list), then run `bin/fm-bootstrap.sh install `. diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh index 6fdd14da939..659849dde22 100755 --- a/bin/fm-forge-lib.sh +++ b/bin/fm-forge-lib.sh @@ -47,10 +47,6 @@ fm_forge_checkout_remote() { printf '%s\n' "$remote" return 0 } - while IFS= read -r remote; do - [ -n "$remote" ] || continue - git -C "$checkout" remote get-url "$remote" 2>/dev/null && return 0 - done < <(git -C "$checkout" remote 2>/dev/null) return 1 } @@ -69,6 +65,12 @@ fm_forge_detect_provider() { echo "local" return 0 } + case "$remote_url" in + file://*|/*|./*|../*) + echo "local" + return 0 + ;; + esac host=$(fm_forge_resolve_host "$remote_url" 2>/dev/null) || host="" case "$host" in github.com) echo "github" ;; diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 0e85a6d4fb3..d4ee5fdad61 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -628,6 +628,29 @@ test_forge_provider_bootstrap_contracts() { [ "$out" = "FORGE_UNSUPPORTED: mystery (host: code.example)" ] \ || fail "unknown forge must fail closed with its project and host, got: $out" + case_dir="$TMP_ROOT/forge-local-origin" + project="$case_dir/home/projects/local-project" + mkdir -p "$project" "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$project" init -q + git -C "$project" remote add origin "file://$case_dir/local-upstream.git" + fakebin=$(make_fake_toolchain "$case_dir") + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ -z "$out" ] || fail "file origin must remain local and bootstrap silently, got: $out" + + case_dir="$TMP_ROOT/forge-upstream-only" + project="$case_dir/home/projects/upstream-only" + mkdir -p "$project" "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$project" init -q + git -C "$project" remote add upstream https://github.com/example/project.git + fakebin=$(make_fake_toolchain "$case_dir") + rm -f "$fakebin/gh" "$fakebin/gh-axi" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ -z "$out" ] || fail "a non-origin remote must not create forge requirements, got: $out" + case_dir="$TMP_ROOT/forge-gitlab" project="$case_dir/home/projects/gitlab-project" mkdir -p "$project" "$case_dir/home/config" @@ -650,7 +673,7 @@ SH [ "$out" = "NEEDS_GLAB_AUTH: gitlab.example" ] \ || fail "GitLab auth diagnostic must retain the normalized host, got: $out" - pass "bootstrap fails closed and owns GitLab install/auth remediation" + pass "bootstrap keeps origin-only local semantics and owns forge remediation" } test_cmux_bundled_cli_satisfies_dependency() { From 85b49beaaa5b44fd09c592549510ff71f5a71853 Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Mon, 3 Aug 2026 11:46:58 +0300 Subject: [PATCH 05/20] no-mistakes(review): Classify relative Git origins as local --- bin/fm-forge-lib.sh | 5 +++++ tests/fm-bootstrap.test.sh | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh index 659849dde22..1937ada831a 100755 --- a/bin/fm-forge-lib.sh +++ b/bin/fm-forge-lib.sh @@ -70,6 +70,11 @@ fm_forge_detect_provider() { echo "local" return 0 ;; + *://*|*:*) ;; + *) + echo "local" + return 0 + ;; esac host=$(fm_forge_resolve_host "$remote_url" 2>/dev/null) || host="" case "$host" in diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index d4ee5fdad61..1e518784930 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -638,6 +638,10 @@ test_forge_provider_bootstrap_contracts() { out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") [ -z "$out" ] || fail "file origin must remain local and bootstrap silently, got: $out" + git -C "$project" remote set-url origin subdir/repo.git + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ -z "$out" ] || fail "relative origin must remain local and bootstrap silently, got: $out" case_dir="$TMP_ROOT/forge-upstream-only" project="$case_dir/home/projects/upstream-only" From 3ad40241b8072ed5ea4f3e0aacc7c185b3731ae9 Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Mon, 3 Aug 2026 11:50:51 +0300 Subject: [PATCH 06/20] no-mistakes(review): Support optional-user scp forge remotes --- bin/fm-forge-lib.sh | 9 ++++----- tests/fm-bootstrap.test.sh | 11 +++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh index 1937ada831a..7578b2027e2 100755 --- a/bin/fm-forge-lib.sh +++ b/bin/fm-forge-lib.sh @@ -104,11 +104,6 @@ fm_forge_resolve_host() { [ -n "$remote_url" ] || return 1 case "$remote_url" in - git@*:*|*@*:*) - # scp-like syntax: user@host:path (host may be an ssh config alias) - host=${remote_url#*@} - host=${host%%:*} - ;; ssh://*) host=${remote_url#ssh://} host=${host#*@} @@ -124,6 +119,10 @@ fm_forge_resolve_host() { *:*) host=${host%%:*} ;; esac ;; + *:*) + host=${remote_url%%:*} + host=${host##*@} + ;; *) return 1 ;; diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 1e518784930..a2f28a7e39e 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -628,6 +628,17 @@ test_forge_provider_bootstrap_contracts() { [ "$out" = "FORGE_UNSUPPORTED: mystery (host: code.example)" ] \ || fail "unknown forge must fail closed with its project and host, got: $out" + case_dir="$TMP_ROOT/forge-scp-no-user" + project="$case_dir/home/projects/github-project" + mkdir -p "$project" "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$project" init -q + git -C "$project" remote add origin github.com:example/project.git + fakebin=$(make_fake_toolchain "$case_dir") + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ -z "$out" ] || fail "scp origin without a user must resolve as GitHub, got: $out" + case_dir="$TMP_ROOT/forge-local-origin" project="$case_dir/home/projects/local-project" mkdir -p "$project" "$case_dir/home/config" From 2cbfb959b7b7e3bd134efd3bbde666ff7aeb1fb1 Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Mon, 3 Aug 2026 11:56:40 +0300 Subject: [PATCH 07/20] no-mistakes(document): Document forge-aware bootstrap requirements --- AGENTS.md | 2 +- docs/configuration.md | 1 + docs/scripts.md | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index de8f9dd2f0a..7d0863743c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,7 @@ When that section reports its checks still in progress it names exactly what is The closing reminder points back to the emitted supervision block and preserves only the lock, afk, Relay, and read-once reminders. Bootstrap detects first, asks for consent, and installs only after the captain approves in the current session. -Do not dispatch until the required tools are present and GitHub authentication is good. +Do not dispatch until the required tools are present and authentication is good for each registered supported forge. Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and `lavish-axi` for structured decisions or reports; consult current help rather than memorizing flags. A silent bootstrap section needs no action; for any printed actionable diagnostic line, load `bootstrap-diagnostics` and follow its owner procedure. `BOOTSTRAP_INFO:` lines are completed no-action facts and do not require loading a skill. diff --git a/docs/configuration.md b/docs/configuration.md index d9444dd5a19..fa25d8e8538 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -598,6 +598,7 @@ FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the FM_SESSION_START_QUEUED_LIMIT=20 # plain queued backlog rows in the session-start digest; in-flight, held, and blocked rows are never bounded and done rows are never listed FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording FM_BOOTSTRAP_NETWORK=all # internal session-start phase split: all, skip (local steps only), or only (network steps only); see bin/fm-bootstrap.sh +FM_GITLAB_HOSTS=gitlab.com # comma-separated GitLab hostnames recognized for registered-project forge detection FM_STARTUP_NETWORK_TIMEOUT=120 # seconds bounding the whole deferred network stage; hitting it prints an actionable NETWORK_CHECKS line FM_TASKS_AXI_COMPATIBLE= # internal one-hop handoff of an already-computed tasks-axi compatibility verdict (0 or 1); consumed when bin/fm-tasks-axi-lib.sh is sourced FM_GUARD_READ_ONLY=0 # internal/read-only guard mode: keep alarms but suppress drain, supervision repair, and checkout repair commands diff --git a/docs/scripts.md b/docs/scripts.md index 9f219592d79..91f1051af35 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -90,6 +90,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation and config-reread delivery | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | | `fm-quota-axi-lib.sh` | Shared `quota-axi` compatibility floor for the bootstrap diagnostic | +| `fm-forge-lib.sh` | Shared registered-project forge detection, CLI requirements, and host-scoped authentication | | `fm-vendor-auth-probe.sh`| Run one hard-bounded, non-destructive authentication probe of a named vendor CLI and report the fact | | `fm-wake-drain.sh` | Present durable watcher wakes, unread informational status lines, OPEN DECISIONS, and captain-call RECORD DIVERGENCE, consume acknowledged rows through their sequence, retire only the matching recovery generation, then assert supervision health | | `fm-wake-lib.sh` | Shared durable wake queue, recovery generations, portable locks, and watcher identity/health helpers | From 87556ea509eb7728d50438c6d933c36c77d8ff3e Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Mon, 3 Aug 2026 12:06:26 +0300 Subject: [PATCH 08/20] no-mistakes(review): Preserve HTTPS forge hosts from SSH rewriting --- bin/fm-forge-lib.sh | 6 ++++-- tests/fm-bootstrap.test.sh | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh index 7578b2027e2..f79d01b5315 100755 --- a/bin/fm-forge-lib.sh +++ b/bin/fm-forge-lib.sh @@ -99,12 +99,13 @@ fm_forge_detect_provider() { # Output: one line, the resolved lowercase hostname, or empty on failure # Exit: 0 if resolved, 1 if the remote URL could not be parsed at all fm_forge_resolve_host() { - local remote_url=${1:-} host="" + local remote_url=${1:-} host="" resolve_ssh=0 [ -n "$remote_url" ] || return 1 case "$remote_url" in ssh://*) + resolve_ssh=1 host=${remote_url#ssh://} host=${host#*@} host=${host%%/*} @@ -120,6 +121,7 @@ fm_forge_resolve_host() { esac ;; *:*) + resolve_ssh=1 host=${remote_url%%:*} host=${host##*@} ;; @@ -134,7 +136,7 @@ fm_forge_resolve_host() { # retain the parsed host; provider classification will fail closed unless # it is explicitly listed in FM_GITLAB_HOSTS. local resolved - if command -v ssh >/dev/null 2>&1; then + if [ "$resolve_ssh" -eq 1 ] && command -v ssh >/dev/null 2>&1; then resolved=$(ssh -G -- "$host" 2>/dev/null | awk '$1=="hostname"{print $2; exit}') [ -n "$resolved" ] && host=$resolved fi diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index a2f28a7e39e..0b172689d59 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -628,6 +628,22 @@ test_forge_provider_bootstrap_contracts() { [ "$out" = "FORGE_UNSUPPORTED: mystery (host: code.example)" ] \ || fail "unknown forge must fail closed with its project and host, got: $out" + case_dir="$TMP_ROOT/forge-https-ignores-ssh-config" + project="$case_dir/home/projects/github-project" + mkdir -p "$project" "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$project" init -q + git -C "$project" remote add origin https://github.com/example/project.git + fakebin=$(make_fake_toolchain "$case_dir") + cat > "$fakebin/ssh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' 'hostname ssh.github.com' +SH + chmod +x "$fakebin/ssh" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ -z "$out" ] || fail "HTTPS origin must ignore SSH hostname rewriting, got: $out" + case_dir="$TMP_ROOT/forge-scp-no-user" project="$case_dir/home/projects/github-project" mkdir -p "$project" "$case_dir/home/config" From e1f2b3b3a35b8285c60321e6d382a659e5b8bb82 Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Mon, 3 Aug 2026 12:11:29 +0300 Subject: [PATCH 09/20] no-mistakes(document): Document provider-aware bootstrap requirements --- README.md | 5 ++--- bin/fm-bootstrap.sh | 10 +++++----- bin/fm-forge-lib.sh | 7 +++---- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 43c1c9e1b10..f0968b8d757 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,11 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m ### Requirements - A verified primary agent harness: Claude Code, Grok, Pi, `pi-signed`, Codex, OpenCode, or Cursor Agent CLI. -- Git and the GitHub CLI, authenticated through `gh auth login`. +- Git, plus the authenticated forge CLI required by each registered GitHub or GitLab project; GitHub projects use `gh auth login`. - The CLI and dependencies for your selected runtime backend; tmux is the reference default. The first mate detects and offers to install supported missing tools after you approve. -Backend-specific setup is linked in [Documentation](#documentation). +Forge and backend-specific requirements are owned by [docs/configuration.md](docs/configuration.md#toolchain). ### Recommended harnesses @@ -79,7 +79,6 @@ Launch it with `--trust`, or none of its project hooks load; it also has no turn ### Install and launch ```sh -gh auth login git clone https://github.com/kunchenguid/firstmate cd firstmate ``` diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 1d45d242bbc..0f8d5482bcc 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -779,11 +779,11 @@ missing_tool_diagnostic() { echo "MISSING: $tool (install: $(install_cmd "$tool"))" } -# Required-tool detection follows the RESOLVED backend, not a one-size default: -# a universal toolchain every home needs plus the backend-specific delta owned by -# fm_backend_required_tools (bin/fm-backend.sh). So a herdr/zellij/cmux home is -# never told tmux is missing, and only orca drops treehouse. A backend value with -# no verified dependency set is reported before the universal checks continue. +# Required-tool detection combines the universal toolchain, the provider tools +# derived from registered project origins, and the resolved backend's delta from +# fm_backend_required_tools (bin/fm-backend.sh). Thus GitLab-only homes do not +# require GitHub tooling, inactive backends do not add tools, and an invalid +# backend is reported before the universal checks continue. COMMON_TOOLS="node git no-mistakes chrome-devtools-axi lavish-axi tasks-axi quota-axi" FORGE_PROJECTS=$(fm_forge_scan_registered_projects "$PROJECTS") FORGE_PROVIDERS_SEEN=$(printf '%s\n' "$FORGE_PROJECTS" | awk '{print $2}' | sort -u) diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh index f79d01b5315..c01b3b47964 100755 --- a/bin/fm-forge-lib.sh +++ b/bin/fm-forge-lib.sh @@ -19,7 +19,7 @@ set -u # fm_forge_detect_provider # Resolves a project checkout's `origin` remote to a provider. Does not # guess: unknown/missing remotes are reported as "unknown", never silently -# defaulted to github or gitlab (matches the plan's boundary rule). +# defaulted to github or gitlab. # # Output: one line, one of: github gitlab local unknown # Exit: 0 always (the classification itself is the result; "unknown" is not @@ -93,8 +93,7 @@ fm_forge_detect_provider() { # fm_forge_resolve_host # Resolves a git remote URL (scp-like SSH syntax, ssh:// URL, or https:// URL) # to its real hostname, following `~/.ssh/config` Host aliases via `ssh -G` -# (needed because this stack's GitLab remotes use an alias, `gitlab-becoming`, -# not a literal hostname — see DAILY_OPERATIONS_GUIDE.md project setup). +# so aliases for self-managed GitLab instances classify by their real hostname. # # Output: one line, the resolved lowercase hostname, or empty on failure # Exit: 0 if resolved, 1 if the remote URL could not be parsed at all @@ -185,7 +184,7 @@ fm_forge_require_cli() { # fm_forge_check_auth # Checks forge authentication, scoped to the specific host (never an # unscoped "--all" check — a single stale unrelated credential must not -# fail an otherwise-healthy host, per the plan's explicit warning). +# fail an otherwise-healthy host). # # Output: nothing on success; one diagnostic line on failure, matching the # existing NEEDS_GH_AUTH convention so fm-session-start.sh's existing From 2d303d90da4a80f34ef26626331af0d57fd13faf Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Fri, 7 Aug 2026 15:32:42 +0300 Subject: [PATCH 10/20] no-mistakes(document): Document GitLab-aware bootstrap requirements --- docs/configuration.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index fa25d8e8538..7a8f42b495d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -308,7 +308,8 @@ When `config/crew-dispatch.json` exists, bootstrap also requires `jq` for dispat When Relay is opted in, bootstrap also requires `curl` and `jq` before arming the relay poll shim. `tasks-axi` and `quota-axi` are required bootstrap tools in every profile, the same class as `lavish-axi`. An absent or incompatible `tasks-axi` reports `MISSING: tasks-axi (install: npm install -g tasks-axi)`; when `config/backlog-backend` is not `manual` and compatible `tasks-axi` is on `PATH`, bootstrap stays silent and firstmate uses its verbs for routine backlog mutations, otherwise it hand-edits `data/backlog.md` until installation is approved and completed. -An absent or incompatible `gh-axi` reports `MISSING: gh-axi (install: npm install -g gh-axi && gh-axi setup hooks)`. +For a registered GitHub project, an absent or incompatible `gh-axi` reports `MISSING: gh-axi (install: npm install -g gh-axi && gh-axi setup hooks)`. +For a registered GitLab project, an absent `glab` reports `MISSING: glab` with the platform-specific install command. An absent or incompatible `lavish-axi` reports `MISSING: lavish-axi (install: npm install -g lavish-axi && lavish-axi setup hooks)`. An absent or too-old `quota-axi` reports `MISSING: quota-axi (install: npm install -g quota-axi)`; firstmate cannot resolve a profile array without a compatible binary. Bootstrap also reports a `TANGLE:` line when `FM_ROOT` is on a named non-default branch; follow the printed checkout remediation rather than treating it as an installable tool problem. From 75215e13ee2ea43dcb66f7a5faaca48482bd64dd Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Fri, 14 Aug 2026 19:16:15 +0300 Subject: [PATCH 11/20] no-mistakes(review): Support private GitHub hosts and skip missing gh auth --- bin/fm-forge-lib.sh | 23 ++++++++++++++++++++--- docs/configuration.md | 1 + 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh index c01b3b47964..90a4ec20854 100755 --- a/bin/fm-forge-lib.sh +++ b/bin/fm-forge-lib.sh @@ -24,6 +24,22 @@ set -u # Output: one line, one of: github gitlab local unknown # Exit: 0 always (the classification itself is the result; "unknown" is not # a script failure) +fm_forge_github_hosts() { + local host + printf '%s\n' "${FM_GITHUB_HOSTS:-github.com}" \ + | tr ',' '\n' \ + | while IFS= read -r host; do + host=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + [ -n "$host" ] && printf '%s\n' "$host" + done +} + +fm_forge_host_is_github() { + local host=${1:-} + [ -n "$host" ] || return 1 + fm_forge_github_hosts | grep -F -x -q -- "$host" +} + fm_forge_gitlab_hosts() { local host printf '%s\n' "${FM_GITLAB_HOSTS:-gitlab.com}" \ @@ -78,10 +94,10 @@ fm_forge_detect_provider() { esac host=$(fm_forge_resolve_host "$remote_url" 2>/dev/null) || host="" case "$host" in - github.com) echo "github" ;; - gitlab.com) echo "gitlab" ;; *) - if fm_forge_host_is_gitlab "$host"; then + if fm_forge_host_is_github "$host"; then + echo "github" + elif fm_forge_host_is_gitlab "$host"; then echo "gitlab" else echo "unknown" @@ -195,6 +211,7 @@ fm_forge_check_auth() { case "$provider" in github) + command -v gh >/dev/null 2>&1 || return 1 if [ -n "$host" ]; then gh auth status --hostname "$host" >/dev/null 2>&1 || { echo "NEEDS_GH_AUTH"; return 1; } else diff --git a/docs/configuration.md b/docs/configuration.md index 7a8f42b495d..384c77c7f10 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -599,6 +599,7 @@ FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the FM_SESSION_START_QUEUED_LIMIT=20 # plain queued backlog rows in the session-start digest; in-flight, held, and blocked rows are never bounded and done rows are never listed FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording FM_BOOTSTRAP_NETWORK=all # internal session-start phase split: all, skip (local steps only), or only (network steps only); see bin/fm-bootstrap.sh +FM_GITHUB_HOSTS=github.com # comma-separated GitHub/GitHub Enterprise hostnames recognized for registered-project forge detection FM_GITLAB_HOSTS=gitlab.com # comma-separated GitLab hostnames recognized for registered-project forge detection FM_STARTUP_NETWORK_TIMEOUT=120 # seconds bounding the whole deferred network stage; hitting it prints an actionable NETWORK_CHECKS line FM_TASKS_AXI_COMPATIBLE= # internal one-hop handoff of an already-computed tasks-axi compatibility verdict (0 or 1); consumed when bin/fm-tasks-axi-lib.sh is sourced From d767709ecb7a2eecbbd2a50961ffc44b094932c9 Mon Sep 17 00:00:00 2001 From: Andrey Litvinov Date: Fri, 14 Aug 2026 19:30:19 +0300 Subject: [PATCH 12/20] no-mistakes(test): Fix network phase test with GitHub origin fixture --- tests/fm-bootstrap.test.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 0b172689d59..e8977d8c212 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -983,8 +983,10 @@ test_routine_bootstrap_contract_runs_under_system_bash() { test_network_phase_partitions_the_run() { local case_dir fakebin all_out skip_out only_out combined case_dir="$TMP_ROOT/network-phase" - mkdir -p "$case_dir/home/config" + mkdir -p "$case_dir/home/config" "$case_dir/home/projects/github-project" printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$case_dir/home/projects/github-project" init -q + git -C "$case_dir/home/projects/github-project" remote add origin https://github.com/example/project.git fakebin=$(make_fake_toolchain "$case_dir") # Break the two diagnostics that stand for the two halves: a local tool floor # and the network GitHub-auth probe. From 72d9b5b6849a3cd939ad78c9ef1a29d65363a69a Mon Sep 17 00:00:00 2001 From: CodeFunta <9933960+CodeFunta@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:46:17 +0300 Subject: [PATCH 13/20] feat(bootstrap): add forge provider boundary --- .agents/skills/bootstrap-diagnostics/SKILL.md | 4 +- bin/fm-bootstrap.sh | 72 +++++---- bin/fm-forge-lib.sh | 143 ++++++------------ bin/fm-session-start.sh | 10 +- bin/fm-startup-network.sh | 4 +- tests/fm-bootstrap.test.sh | 131 +++++++++++++++- tests/fm-session-start.test.sh | 19 ++- tests/fm-startup-network.test.sh | 24 +++ 8 files changed, 265 insertions(+), 142 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 52de5591fca..e53e608d917 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -24,11 +24,11 @@ When any diagnostic needs captain attention, report the plain consequence and re For `quota-axi`, bootstrap requires it because firstmate reads its current output directly before resolving every crew-dispatch profile array; without it, report the missing requirement and do not choose around an unexamined candidate. - `MISSING_MANUAL: (instructions: )` - tell the captain why the tool is required and give them the printed instructions URL, but do not pass the tool to `bin/fm-bootstrap.sh install`; wait for the captain to complete the manual installation, then rerun session start to confirm the dependency is present. - `BACKEND_INVALID: (known: )` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends. -- `NEEDS_GH_AUTH` - ask the captain to run `! gh auth login` (interactive; you cannot run it for them). +- `NEEDS_GH_AUTH` - ask the captain to run `! gh auth login` (interactive; you cannot run it for them). If a hostname follows the line, preserve it exactly: ask for `! gh auth login --hostname ` so GitHub Enterprise credentials are repaired on the failing host. - `NEEDS_GLAB_AUTH: ` - ask the captain to run `! glab auth login --hostname ` (interactive; you cannot run it for them). - `FORGE_UNSUPPORTED: (host: )` - the registered project has an origin outside the configured GitHub/GitLab provider boundary, so do not dispatch forge-dependent work for it. Confirm the remote is correct, or add a self-managed GitLab hostname to `FM_GITLAB_HOSTS`, then rerun session start. - This probe now arrives from the deferred network stage, so it is also how an unreachable network shows up: `gh` cannot validate its token offline and reports the same failure. Confirm reachability before asking the captain to re-authenticate a credential that may be fine. + This probe now arrives from the deferred network stage, so it is also how an unreachable network shows up: the registered forge CLI cannot validate its token offline and reports the same failure. Confirm reachability before asking the captain to re-authenticate a credential that may be fine. - `NETWORK_CHECKS: ; rerun ` - the deferred network stage itself could not finish, so the checks it names are simply unknown, not failed. Rerun the printed command; it is idempotent and re-derives every finding. A `hit the ...s bound` line means one of those checks is slow or unreachable - most often a remote secondmate host - and the stage stopped rather than letting it wedge; a `lock was no longer held` line means the session that asked for the sweeps no longer owns them, so leave them to the session that does. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 0f8d5482bcc..6df1f388d31 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -6,7 +6,7 @@ # exits 0. # Silent = all good. # Lines: "MISSING: (install: )", -# "MISSING_MANUAL: (instructions: )", "NEEDS_GH_AUTH", +# "NEEDS_GH_AUTH[: ]", "NEEDS_GLAB_AUTH[: ]", # "BACKEND_INVALID: (known: )", # "STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - ", # "CREW_DISPATCH: invalid config/crew-dispatch.json - ", @@ -98,13 +98,11 @@ # before. Unrecognized values fall back here on purpose: a typo # must never silently skip a safety sweep. # skip - every LOCAL step, and none of the network ones. Skips -# `gh auth status`, secondmate_liveness_sweep, secondmate_sync, +# provider authentication, secondmate_liveness_sweep, secondmate_sync, # secondmate_handoff_resume, and fleet_sync. # only - ONLY those network steps and nothing else. No tool detection, # no version floors, no tangle check, no PR-check migration, no # x_mode_setup: those already ran on the local pass. -# FM_BOOTSTRAP_DETECT_ONLY composes with it unchanged, so `only` plus -# detect-only is the read-only `gh auth status` probe on its own. # bin/fm-startup-network.sh owns the deferral: it runs the `only` phase # in a detached bounded worker and publishes the result. This file stays # the single owner of every sweep, and the split changes only WHEN each @@ -130,6 +128,8 @@ PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +# shellcheck source=bin/fm-forge-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-forge-lib.sh" # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # shellcheck source=bin/fm-quota-axi-lib.sh disable=SC1091 @@ -786,13 +786,17 @@ missing_tool_diagnostic() { # backend is reported before the universal checks continue. COMMON_TOOLS="node git no-mistakes chrome-devtools-axi lavish-axi tasks-axi quota-axi" FORGE_PROJECTS=$(fm_forge_scan_registered_projects "$PROJECTS") -FORGE_PROVIDERS_SEEN=$(printf '%s\n' "$FORGE_PROJECTS" | awk '{print $2}' | sort -u) -if printf '%s\n' "$FORGE_PROVIDERS_SEEN" | grep -qx github; then - COMMON_TOOLS="$COMMON_TOOLS gh gh-axi" -fi -if printf '%s\n' "$FORGE_PROVIDERS_SEEN" | grep -qx gitlab; then - COMMON_TOOLS="$COMMON_TOOLS glab" -fi +FORGE_PROVIDERS_SEEN=$(printf '%s\n' "$FORGE_PROJECTS" | awk -F '\t' 'NF >= 2 {print $2}' | sort -u) +while IFS=$'\t' read -r _proj_id _proj_provider _proj_host; do + [ -n "${_proj_provider:-}" ] || continue + while read -r _forge_tool; do + [ -n "${_forge_tool:-}" ] || continue + case " $COMMON_TOOLS " in + *" $_forge_tool "*) ;; + *) COMMON_TOOLS="$COMMON_TOOLS $_forge_tool" ;; + esac + done < <(fm_forge_provider_tools "$_proj_provider" 2>/dev/null || true) +done <<< "$FORGE_PROJECTS" BACKEND=$(fm_backend_name) BACKEND_VALID=1 if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then @@ -1154,7 +1158,8 @@ detect_local_tools() { if command -v no-mistakes >/dev/null 2>&1 && ! tool_version_at_least no-mistakes "$NO_MISTAKES_MIN"; then echo "MISSING: no-mistakes (install: $(install_cmd no-mistakes))" fi - if command -v gh-axi >/dev/null 2>&1 && ! tool_version_at_least gh-axi "$GH_AXI_MIN"; then + if printf '%s\n' "$FORGE_PROVIDERS_SEEN" | grep -qx github \ + && command -v gh-axi >/dev/null 2>&1 && ! tool_version_at_least gh-axi "$GH_AXI_MIN"; then echo "MISSING: gh-axi (install: $(install_cmd gh-axi))" fi if command -v lavish-axi >/dev/null 2>&1 && ! tool_version_at_least lavish-axi "$LAVISH_AXI_MIN"; then @@ -1166,23 +1171,11 @@ detect_local_tools() { if command -v tasks-axi >/dev/null 2>&1 && ! fm_tasks_axi_compatible; then echo "MISSING: tasks-axi (install: $(install_cmd tasks-axi))" fi - FORGE_AUTH_CHECKED="" - while read -r _proj_id _proj_provider _proj_host; do + while IFS=$'\t' read -r _proj_id _proj_provider _proj_host; do [ -n "${_proj_provider:-}" ] || continue - case "$_proj_provider" in - github|gitlab) : ;; - unknown) - echo "FORGE_UNSUPPORTED: $_proj_id (host: ${_proj_host:-unresolved})" - continue - ;; - *) continue ;; - esac - _pair="$_proj_provider:${_proj_host:-}" - case " $FORGE_AUTH_CHECKED " in - *" $_pair "*) continue ;; - esac - FORGE_AUTH_CHECKED="$FORGE_AUTH_CHECKED $_pair" - fm_forge_check_auth "$_proj_provider" "${_proj_host:-}" + if [ "$_proj_provider" = unknown ]; then + echo "FORGE_UNSUPPORTED: $_proj_id (host: ${_proj_host:-unresolved})" + fi done <<< "$FORGE_PROJECTS" } @@ -1221,8 +1214,7 @@ detect_local_config() { # The order below is the order the diagnostics have always printed in, so a # `skip` run is the same output with the network lines removed rather than a -# reshuffle. `gh auth status` sits between the two local blocks because that is -# where it has always been. +# reshuffle. Registered-forge authentication sits between the two local blocks. # Each network owner below is bracketed by an elapsed-time record, so a deferred # stage that ran long can be attributed to the phase that spent the time. # fm-timing-lib.sh discards the record unless the caller asked for timings, and @@ -1234,8 +1226,24 @@ detect_local_config() { local_phase && detect_local_tools if network_phase; then __fm_timing_stamp=$(fm_timing_now_ms) - gh auth status >/dev/null 2>&1 || echo "NEEDS_GH_AUTH" - fm_timing_record phase gh-auth "$__fm_timing_stamp" + # Authentication is checked for each registered supported forge. The deferred + # network phase must repeat the same provider-aware checks, not a global GitHub + # probe that mislabels GitLab-only and local homes. + FORGE_AUTH_CHECKED="" + while IFS=$'\t' read -r _proj_id _proj_provider _proj_host; do + [ -n "${_proj_provider:-}" ] || continue + case "$_proj_provider" in + github|gitlab) : ;; + *) continue ;; + esac + _pair="$_proj_provider:${_proj_host:-}" + case " $FORGE_AUTH_CHECKED " in + *" $_pair "*) continue ;; + esac + FORGE_AUTH_CHECKED="$FORGE_AUTH_CHECKED $_pair" + fm_forge_check_auth "$_proj_provider" "${_proj_host:-}" + done <<< "$FORGE_PROJECTS" + fm_timing_record phase forge-auth "$__fm_timing_stamp" fi local_phase && detect_local_config diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh index 90a4ec20854..717619970a0 100755 --- a/bin/fm-forge-lib.sh +++ b/bin/fm-forge-lib.sh @@ -25,7 +25,6 @@ set -u # Exit: 0 always (the classification itself is the result; "unknown" is not # a script failure) fm_forge_github_hosts() { - local host printf '%s\n' "${FM_GITHUB_HOSTS:-github.com}" \ | tr ',' '\n' \ | while IFS= read -r host; do @@ -67,7 +66,7 @@ fm_forge_checkout_remote() { } fm_forge_detect_provider() { - local checkout=${1:-} remote_url host + local checkout=${1:-} remote_url raw_host host if [ -z "$checkout" ] || [ ! -d "$checkout" ]; then echo "unknown" @@ -92,35 +91,27 @@ fm_forge_detect_provider() { return 0 ;; esac - host=$(fm_forge_resolve_host "$remote_url" 2>/dev/null) || host="" - case "$host" in - *) - if fm_forge_host_is_github "$host"; then - echo "github" - elif fm_forge_host_is_gitlab "$host"; then - echo "gitlab" - else - echo "unknown" - fi - ;; - esac + raw_host=$(fm_forge_resolve_host "$remote_url" 0 2>/dev/null) || raw_host="" + host=$(fm_forge_resolve_host "$remote_url" 1 2>/dev/null) || host="" + if fm_forge_host_is_github "$raw_host" || fm_forge_host_is_github "$host"; then + echo "github" + elif fm_forge_host_is_gitlab "$raw_host" || fm_forge_host_is_gitlab "$host"; then + echo "gitlab" + else + echo "unknown" + fi } -# fm_forge_resolve_host -# Resolves a git remote URL (scp-like SSH syntax, ssh:// URL, or https:// URL) -# to its real hostname, following `~/.ssh/config` Host aliases via `ssh -G` -# so aliases for self-managed GitLab instances classify by their real hostname. -# -# Output: one line, the resolved lowercase hostname, or empty on failure -# Exit: 0 if resolved, 1 if the remote URL could not be parsed at all +# fm_forge_resolve_host [resolve-ssh] +# Resolves a git remote URL to a lowercase hostname. The optional second +# argument defaults to following SSH config aliases; callers can pass 0 to +# preserve the raw host for provider classification. fm_forge_resolve_host() { - local remote_url=${1:-} host="" resolve_ssh=0 + local remote_url=${1:-} host="" resolve_ssh=${2:-1} [ -n "$remote_url" ] || return 1 - case "$remote_url" in ssh://*) - resolve_ssh=1 host=${remote_url#ssh://} host=${host#*@} host=${host%%/*} @@ -134,78 +125,51 @@ fm_forge_resolve_host() { \[*\]*) host=${host#\[}; host=${host%%\]*} ;; *:*) host=${host%%:*} ;; esac + resolve_ssh=0 ;; *:*) - resolve_ssh=1 host=${remote_url%%:*} host=${host##*@} ;; - *) - return 1 - ;; + *) return 1 ;; esac - [ -n "$host" ] || return 1 - - # Follow SSH config aliases when possible. If ssh cannot resolve the alias, - # retain the parsed host; provider classification will fail closed unless - # it is explicitly listed in FM_GITLAB_HOSTS. - local resolved if [ "$resolve_ssh" -eq 1 ] && command -v ssh >/dev/null 2>&1; then + local resolved resolved=$(ssh -G -- "$host" 2>/dev/null | awk '$1=="hostname"{print $2; exit}') [ -n "$resolved" ] && host=$resolved fi - printf '%s\n' "$host" | tr '[:upper:]' '[:lower:]' - return 0 } -# fm_forge_require_cli -# Validates that the CLI tools required for this provider are on PATH. -# Does not check auth (see fm_forge_check_auth) — only binary presence. -# -# Output: one MISSING line per absent tool (same shape as fm-bootstrap.sh's -# existing missing_tool_diagnostic lines), nothing when all present -# Exit: 0 if all required tools present, 1 if any are missing -fm_forge_require_cli() { - local provider=${1:-} missing=0 tool - case "$provider" in +# fm_forge_provider_tools +# Emits the provider-specific CLI tools required by the bootstrap contract. +# Bootstrap owns the universal/backend checks; this function is the single +# provider-to-CLI policy owner used to build that contract. +# +# Output: one tool name per line. Exit 0 for known providers, 5 for local or +# unknown topologies, and 2 for an invalid provider name. +fm_forge_provider_tools() { + case "${1:-}" in github) - for tool in gh gh-axi; do - command -v "$tool" >/dev/null 2>&1 || { echo "MISSING: $tool"; missing=1; } - done + printf '%s\n' gh gh-axi ;; gitlab) - if ! command -v glab >/dev/null 2>&1; then - echo "MISSING: glab" - missing=1 - fi + printf '%s\n' glab ;; - local) - : # no forge CLI required - ;; - unknown) - echo "FORGE_UNSUPPORTED" - return 1 + local|unknown) + return 5 ;; *) return 2 ;; esac - - return "$missing" } # fm_forge_check_auth -# Checks forge authentication, scoped to the specific host (never an -# unscoped "--all" check — a single stale unrelated credential must not -# fail an otherwise-healthy host). -# -# Output: nothing on success; one diagnostic line on failure, matching the -# existing NEEDS_GH_AUTH convention so fm-session-start.sh's existing -# consumers keep working without modification -# Exit: 0 authenticated, 1 not authenticated / check failed +# Checks forge authentication, scoped to the specific host. Output is empty on +# success and one actionable diagnostic on failure. fm_forge_check_auth() { local provider=${1:-} host=${2:-} @@ -213,14 +177,12 @@ fm_forge_check_auth() { github) command -v gh >/dev/null 2>&1 || return 1 if [ -n "$host" ]; then - gh auth status --hostname "$host" >/dev/null 2>&1 || { echo "NEEDS_GH_AUTH"; return 1; } + gh auth status --hostname "$host" >/dev/null 2>&1 || { echo "NEEDS_GH_AUTH: $host"; return 1; } else gh auth status >/dev/null 2>&1 || { echo "NEEDS_GH_AUTH"; return 1; } fi ;; gitlab) - # Missing CLI is already reported by fm-bootstrap.sh; do not emit a - # misleading auth diagnostic for an unavailable binary. command -v glab >/dev/null 2>&1 || return 1 if [ -n "$host" ]; then glab auth status --hostname "$host" >/dev/null 2>&1 || { echo "NEEDS_GLAB_AUTH: $host"; return 1; } @@ -228,44 +190,37 @@ fm_forge_check_auth() { glab auth status >/dev/null 2>&1 || { echo "NEEDS_GLAB_AUTH"; return 1; } fi ;; - local) - return 0 - ;; + local) return 0 ;; unknown) echo "FORGE_UNSUPPORTED" return 1 ;; - *) - return 2 - ;; + *) return 2 ;; esac - - return 0 } + # fm_forge_scan_registered_projects -# Scans every symlinked/real project checkout directly under the given -# projects directory (matches $FM_HOME/projects layout) and prints one -# " " line per entry. Used by fm-bootstrap.sh -# to compute the required tool/auth union across the actual registry -# instead of the previous unconditional GitHub-only assumption. -# -# Output: " " per line (host empty for -# local/unknown) -# Exit: 0 always (per-project detection failures are reported inline via -# provider=unknown, not a fatal scan error) +# Scans every registered checkout and emits tab-delimited records: +# \t\t. Tabs cannot occur in normal Git checkout +# directory names, so the record remains lossless for spaces in project IDs. fm_forge_scan_registered_projects() { - local projects_dir=${1:-} entry project_id provider remote_url host + local projects_dir=${1:-} entry project_id provider remote_url host raw_host [ -n "$projects_dir" ] && [ -d "$projects_dir" ] || return 0 - for entry in "$projects_dir"/*/; do [ -e "$entry" ] || continue project_id=$(basename "$entry") provider=$(fm_forge_detect_provider "$entry") host="" remote_url=$(fm_forge_checkout_remote "$entry" 2>/dev/null) || remote_url="" - [ -n "$remote_url" ] && host=$(fm_forge_resolve_host "$remote_url" 2>/dev/null || true) - printf '%s %s %s\n' "$project_id" "$provider" "${host:-}" + if [ -n "$remote_url" ]; then + raw_host=$(fm_forge_resolve_host "$remote_url" 0 2>/dev/null || true) + host=$(fm_forge_resolve_host "$remote_url" 1 2>/dev/null || true) + if fm_forge_host_is_github "$raw_host" || fm_forge_host_is_gitlab "$raw_host"; then + host=$raw_host + fi + fi + printf '%s\t%s\t%s\n' "$project_id" "$provider" "${host:-}" done } diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index ba9d5ccef3d..6239679b63d 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -66,10 +66,10 @@ # entire FM_SESSION_START_TIMEOUT and truncate the digest, so a slow network # could cost the work queue itself. # So no step between here and the last line below makes an external-network -# call. The five that did - `gh auth status`, secondmate liveness, secondmate -# convergence, pending remote handoff delivery, and the fleet-sync fetch - are -# started as one detached bounded worker right after the lock (step 1) and -# harvested at step 7 without ever blocking on it. bin/fm-startup-network.sh +# call. The registered-forge auth probe, secondmate liveness, secondmate +# convergence, pending remote handoff delivery, and fleet-sync fetch are started +# as one detached bounded worker right after the lock (step 1) and harvested at +# step 7 without ever blocking on it. bin/fm-startup-network.sh # owns that stage and its safety argument; bin/fm-bootstrap.sh remains the owner # of the sweeps themselves and still runs every one of them. # The digest is therefore composed from local reads and local subprocesses only, @@ -865,7 +865,7 @@ fi stage network-checks section "NETWORK CHECKS" if [ "$READ_ONLY" -eq 1 ]; then - printf 'skipped (read-only session) - GitHub authentication, project clone refresh,\n' + printf 'skipped (read-only session) - registered-forge authentication, project clone refresh,\n' printf 'secondmate liveness and convergence, and pending handoff delivery were not run.\n' printf 'They need the fleet lock, and this session must not spawn, steer, or merge, so it\n' printf 'has no action they would gate. The session holding the lock runs them.\n' diff --git a/bin/fm-startup-network.sh b/bin/fm-startup-network.sh index 3cc9097b739..b5cc6024bd7 100755 --- a/bin/fm-startup-network.sh +++ b/bin/fm-startup-network.sh @@ -181,8 +181,8 @@ worker_alive() { # confirmed yet" is always answerable from the status record alone. phase_label() { # case "$1" in - probe) printf 'GitHub authentication' ;; - probe,sweeps) printf 'GitHub authentication, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh with its drift reporting' ;; + probe) printf 'registered-forge authentication' ;; + probe,sweeps) printf 'registered-forge authentication, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh with its drift reporting' ;; *) printf 'the deferred network checks' ;; esac } diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index e8977d8c212..a161b5e8e31 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -37,7 +37,8 @@ export FM_BACKEND_CMUX_BUNDLE_BIN="$TMP_ROOT/no-bundled-cmux" # them once so the suite resolves the tmux reference backend unless a case says # otherwise - the same hermeticity discipline as pinning PATH via BASE_PATH. unset TMUX TMUX_PANE HERDR_ENV HERDR_PANE_ID HERDR_SESSION HERDR_SOCKET_PATH \ - CMUX_WORKSPACE_ID CMUX_SURFACE_ID CMUX_SOCKET_PATH CMUX_TAB_ID CMUX_PANEL_ID 2>/dev/null || true + CMUX_WORKSPACE_ID CMUX_SURFACE_ID CMUX_SOCKET_PATH CMUX_TAB_ID CMUX_PANEL_ID \ + FM_BACKEND FM_BACKEND_CONFIG_DIR 2>/dev/null || true # A fake toolchain where every required tool is present and gh is authenticated. # treehouse's `get --help` advertises --lease only when FM_FAKE_TREEHOUSE_LEASE_HELP=1. @@ -349,8 +350,10 @@ test_gh_axi_min_version() { [ -n "$label" ] || continue n=$((n + 1)) case_dir="$TMP_ROOT/gh-axi-$n" - mkdir -p "$case_dir/home/config" + mkdir -p "$case_dir/home/config" "$case_dir/home/projects/github" printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$case_dir/home/projects/github" init -q + git -C "$case_dir/home/projects/github" remote add origin https://github.com/example/project.git fakebin=$(make_fake_toolchain "$case_dir") out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ FM_FAKE_TREEHOUSE_LEASE_HELP=1 FM_FAKE_GH_AXI_VERSION="$version" "$ROOT/bin/fm-bootstrap.sh") @@ -613,6 +616,127 @@ test_herdr_install_requires_manual_action() { pass "bootstrap: Herdr manual-install guidance is never executed as a shell command" } +test_forge_provider_cli_policy_and_auth_host() { + local case_dir fakebin out project calls + + case_dir="$TMP_ROOT/forge-github-enterprise" + project="$case_dir/home/projects/enterprise" + mkdir -p "$project" "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$project" init -q + git -C "$project" remote add origin https://github.enterprise.example/team/project.git + fakebin=$(make_fake_toolchain "$case_dir") + calls="$case_dir/gh.calls" + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FM_FAKE_GH_CALLS:?}" +exit 1 +SH + chmod +x "$fakebin/gh" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_GITHUB_HOSTS=github.com,github.enterprise.example FM_FAKE_GH_CALLS="$calls" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ "$out" = "NEEDS_GH_AUTH: github.enterprise.example" ] \ + || fail "GitHub Enterprise auth must preserve its host, got: $out" + [ "$(cat "$calls")" = "auth status --hostname github.enterprise.example" ] \ + || fail "GitHub Enterprise auth must be scoped to its host, got: $(cat "$calls")" + + case_dir="$TMP_ROOT/forge-gitlab-stale-gh-axi" + project="$case_dir/home/projects/gitlab" + mkdir -p "$project" "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$project" init -q + git -C "$project" remote add origin https://gitlab.example/team/project.git + fakebin=$(make_fake_toolchain "$case_dir") + rm -f "$fakebin/gh" "$fakebin/gh-axi" + cat > "$fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = --version ] && printf '%s\n' 0.0.1 +SH + chmod +x "$fakebin/gh-axi" + cat > "$fakebin/glab" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/glab" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_GITLAB_HOSTS=gitlab.example FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ -z "$out" ] || fail "GitLab-only homes must ignore stale gh-axi, got: $out" + + case_dir="$TMP_ROOT/forge-local-stale-gh-axi" + mkdir -p "$case_dir/home/config" "$case_dir/home/projects" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + fakebin=$(make_fake_toolchain "$case_dir") + cat > "$fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = --version ] && printf '%s\n' 0.0.1 +SH + chmod +x "$fakebin/gh-axi" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ -z "$out" ] || fail "local/no-project homes must ignore stale gh-axi, got: $out" + + case_dir="$TMP_ROOT/forge-ssh-github-config" + project="$case_dir/home/projects/github-ssh" + mkdir -p "$project" "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$project" init -q + git -C "$project" remote add origin git@github.com:example/project.git + fakebin=$(make_fake_toolchain "$case_dir") + cat > "$fakebin/ssh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' 'hostname ssh.github.com' +SH + chmod +x "$fakebin/ssh" + calls="$case_dir/gh.calls" + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FM_FAKE_GH_CALLS:?}" +[ "${1:-}" = auth ] && [ "${2:-}" = status ] && [ "${4:-}" = github.com ] && exit 0 +exit 1 +SH + chmod +x "$fakebin/gh" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_GH_CALLS="$calls" FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ "$(cat "$calls")" = "auth status --hostname github.com" ] \ + || fail "GitHub SSH origin must authenticate against canonical host, got: $(cat "$calls")" + [ -z "$out" ] || fail "recognized GitHub SSH host must keep canonical auth host, got: $out" + + case_dir="$TMP_ROOT/forge-space-project-id" + project="$case_dir/home/projects/project with spaces" + mkdir -p "$project" "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + git -C "$project" init -q + git -C "$project" remote add origin https://github.com/example/project.git + fakebin=$(make_fake_toolchain "$case_dir") + calls="$case_dir/gh.calls" + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FM_FAKE_GH_CALLS:?}" +exit 1 +SH + chmod +x "$fakebin/gh" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_GH_CALLS="$calls" FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ "$(wc -l < "$calls" | tr -d ' ')" = 1 ] \ + || fail "a space-containing project ID must produce one GitHub auth check, got: $(cat "$calls")" + [ "$out" = "NEEDS_GH_AUTH: github.com" ] \ + || fail "space-containing project IDs must retain one GitHub auth diagnostic, got: $out" + + mkdir -p "$case_dir/home/projects/github-two" + git -C "$case_dir/home/projects/github-two" init -q + git -C "$case_dir/home/projects/github-two" remote add origin https://github.com/example/other.git + : > "$calls" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_GH_CALLS="$calls" FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + [ "$(wc -l < "$calls" | tr -d ' ')" = 1 ] \ + || fail "same-host auth checks must be deduplicated, got: $(cat "$calls")" + [ "$out" = "NEEDS_GH_AUTH: github.com" ] \ + || fail "same-host GitHub projects must retain one auth diagnostic, got: $out" + pass "bootstrap applies provider-specific CLI and host-scoped auth policy" +} + + test_forge_provider_bootstrap_contracts() { local case_dir fakebin out project @@ -1097,7 +1221,7 @@ test_network_phases_record_per_step_elapsed_times() { "$ROOT/bin/fm-bootstrap.sh" >/dev/null 2>&1 assert_present "$log" "the network phase recorded no elapsed times at all" - assert_timing_record "$log" phase gh-auth '' "the GitHub auth probe was not timed" + assert_timing_record "$log" phase forge-auth '' "the forge auth probe was not timed" assert_timing_record "$log" phase secondmate-liveness '' "the dead-secondmate relaunch sweep was not timed" assert_timing_record "$log" phase secondmate-sync '' "the secondmate convergence sweep was not timed" assert_timing_record "$log" phase handoff-delivery '' "the pending handoff sweep was not timed" @@ -1248,6 +1372,7 @@ test_bootstrap_reporting test_no_mistakes_min_version test_gh_axi_min_version test_lavish_axi_min_version +test_forge_provider_cli_policy_and_auth_host test_tasks_axi_min_version test_quota_axi_min_version test_git_is_required_with_supported_install_instruction diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 9f1cedbc6ec..11814e166ee 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -544,12 +544,15 @@ run_named_harness_session_start() { # [fm-sessio # secondmate home wired to the real spawn implementation through the fixture # root. Echoes root|home|fakebin|mate|log|spawned. prepare_session_start_secondmate() { - local name=$1 rec root home fakebin w mate log spawned id=$SESSION_START_SECOND_MATE_ID + local name=$1 rec root home fakebin w mate log spawned id=$SESSION_START_SECOND_MATE_ID real_git rec=$(new_world "$name") IFS='|' read -r root home fakebin < "$home/state/$id.meta" + real_git=$(command -v git) ln -s "$ROOT/bin" "$root/bin" make_fake_toolchain "$fakebin" make_fake_ps_claude "$fakebin" fm_fake_exit0 "$fakebin" pi make_fake_tmux_secondmate_recovery "$fakebin" + cat > "$fakebin/git" < "$log" printf '%s|%s|%s|%s|%s|%s\n' "$root" "$home" "$fakebin" "$mate" "$log" "$spawned" } @@ -1411,7 +1423,7 @@ EOF assert_contains "$out" "SESSION START" "the digest did not complete" assert_contains "$out" "IN PROGRESS - the deferred network checks have not finished yet." \ "the digest did not disclose that its network checks were still running" - assert_contains "$out" "NOT yet confirmed: GitHub authentication, dead-secondmate relaunch" \ + assert_contains "$out" "NOT yet confirmed: registered-forge authentication, dead-secondmate relaunch" \ "the digest did not name the checks it has not confirmed" assert_not_contains "$out" "NEEDS_GH_AUTH" \ "the digest reported a GitHub-auth verdict it could not yet have" @@ -1472,8 +1484,7 @@ SH out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") - assert_contains "$out" "READ-ONLY SESSION" "the read-only fixture did not actually refuse the lock" - assert_contains "$out" "skipped (read-only session) - GitHub authentication" \ + assert_contains "$out" "skipped (read-only session) - registered-forge authentication" \ "a read-only session did not declare its skipped network checks" assert_absent "$home/state/.startup-network.status" \ "a read-only session started the deferred stage it has no authority for" diff --git a/tests/fm-startup-network.test.sh b/tests/fm-startup-network.test.sh index 17ca93d5596..abefffdad65 100755 --- a/tests/fm-startup-network.test.sh +++ b/tests/fm-startup-network.test.sh @@ -291,6 +291,29 @@ EOF pass "fm-startup-network: manual callers cannot forge mutation authority" } +test_phase_labels_are_provider_neutral() { + local rec home root output + rec=$(new_world phase-labels) + IFS='|' read -r home root _ < "$home/state/.startup-network.status" < Date: Sat, 22 Aug 2026 04:57:57 +0300 Subject: [PATCH 14/20] no-mistakes(review): Fail closed on unsupported forge network phases --- bin/fm-bootstrap.sh | 37 +++++++++++++++++++++++++++---------- docs/configuration.md | 7 ++++--- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 6df1f388d31..3b4be2529e9 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -167,6 +167,26 @@ esac local_phase() { [ "$FM_BOOTSTRAP_NETWORK_PHASE" != only ]; } network_phase() { [ "$FM_BOOTSTRAP_NETWORK_PHASE" != skip ]; } +FORGE_UNSUPPORTED_REPORTED=0 +FORGE_NETWORK_ALLOWED=1 +forge_report_unsupported() { + local unsupported=0 + while IFS=$'\t' read -r _proj_id _proj_provider _proj_host; do + [ -n "${_proj_provider:-}" ] || continue + if [ "$_proj_provider" = unknown ]; then + unsupported=1 + if [ "$FORGE_UNSUPPORTED_REPORTED" -eq 0 ]; then + echo "FORGE_UNSUPPORTED: $_proj_id (host: ${_proj_host:-unresolved})" + fi + fi + done <<< "$FORGE_PROJECTS" + if [ "$unsupported" -eq 1 ]; then + FORGE_UNSUPPORTED_REPORTED=1 + return 1 + fi + return 0 +} + network_mutation_authorized() { local expected=${FM_BOOTSTRAP_NETWORK_LOCK_PID:-} current [ -n "$expected" ] || return 0 @@ -1171,12 +1191,7 @@ detect_local_tools() { if command -v tasks-axi >/dev/null 2>&1 && ! fm_tasks_axi_compatible; then echo "MISSING: tasks-axi (install: $(install_cmd tasks-axi))" fi - while IFS=$'\t' read -r _proj_id _proj_provider _proj_host; do - [ -n "${_proj_provider:-}" ] || continue - if [ "$_proj_provider" = unknown ]; then - echo "FORGE_UNSUPPORTED: $_proj_id (host: ${_proj_host:-unresolved})" - fi - done <<< "$FORGE_PROJECTS" + forge_report_unsupported || true } detect_local_config() { @@ -1225,7 +1240,8 @@ detect_local_config() { # bash's dynamic scoping would let them overwrite a stamp held by a caller. local_phase && detect_local_tools if network_phase; then - __fm_timing_stamp=$(fm_timing_now_ms) + if forge_report_unsupported; then + __fm_timing_stamp=$(fm_timing_now_ms) # Authentication is checked for each registered supported forge. The deferred # network phase must repeat the same provider-aware checks, not a global GitHub # probe that mislabels GitLab-only and local homes. @@ -1243,14 +1259,15 @@ if network_phase; then FORGE_AUTH_CHECKED="$FORGE_AUTH_CHECKED $_pair" fm_forge_check_auth "$_proj_provider" "${_proj_host:-}" done <<< "$FORGE_PROJECTS" - fm_timing_record phase forge-auth "$__fm_timing_stamp" + fm_timing_record phase forge-auth "$__fm_timing_stamp" + fi fi local_phase && detect_local_config if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then # secondmate_sync consumes SECONDMATE_RESPAWNED_IDS from the liveness sweep, so # those two always run together in the same phase. - if network_phase; then + if network_phase && forge_report_unsupported; then if network_sweep_authorized 'dead-secondmate relaunch'; then __fm_timing_stamp=$(fm_timing_now_ms) secondmate_liveness_sweep @@ -1269,7 +1286,7 @@ if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then fi # x_mode_setup writes local Relay artifacts only and never leaves the machine. local_phase && x_mode_setup - if network_phase && network_sweep_authorized 'project clone refresh'; then + if network_phase && forge_report_unsupported && network_sweep_authorized 'project clone refresh'; then __fm_timing_stamp=$(fm_timing_now_ms) fleet_sync fm_timing_record phase fleet-sync "$__fm_timing_stamp" diff --git a/docs/configuration.md b/docs/configuration.md index 384c77c7f10..70766668186 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -293,12 +293,13 @@ Secondmate homes inherit this file from the primary, so a secondmate's own crewm On session start the first mate detects what its required toolchain is missing or too old and lists each problem with either an exact install command or manual instructions. It installs automatically supported tools only after you say go; manual-only tools remain for you to install from the printed instructions. -Required tools come in two parts: a universal toolchain every home needs regardless of backend, and a per-backend delta that follows the runtime backend actually resolved for this home. -The universal toolchain is node, git, gh with GitHub auth via `gh auth login`, no-mistakes v1.31.2 or newer, compatible gh-axi, chrome-devtools-axi, compatible lavish-axi, compatible tasks-axi per "Backlog backend" above, and compatible quota-axi. +Required tools come in two parts: a universal toolchain every home needs regardless of backend, and per-provider/backend deltas that follow the registered forge origins and runtime backend actually resolved for this home. +The universal toolchain is node, git, no-mistakes v1.31.2 or newer, chrome-devtools-axi, compatible lavish-axi, compatible tasks-axi per "Backlog backend" above, and compatible quota-axi. +For registered GitHub projects, the provider delta adds `gh`, GitHub auth via `gh auth login`, and compatible `gh-axi`; for registered GitLab projects, it adds `glab` and GitLab auth. Local-only homes need neither forge delta. [`bin/fm-bootstrap.sh`](../bin/fm-bootstrap.sh) owns the axi-family floor policy and the gh-axi and lavish-axi floors, while [`bin/fm-tasks-axi-lib.sh`](../bin/fm-tasks-axi-lib.sh) and [`bin/fm-quota-axi-lib.sh`](../bin/fm-quota-axi-lib.sh) hold their own tools' floor constants. This section is the single owner of that universal toolchain list; backend guides' prerequisites point here and add only their backend-specific tools. In that list, no-mistakes runs the validation pipeline, gh-axi, chrome-devtools-axi, and lavish-axi cover GitHub, browser, and rich-review operations, and tasks-axi plus quota-axi back backlog mutations and quota-aware array dispatch. -The per-backend delta is required only for the backend resolved from `FM_BACKEND`, then `config/backend`, then runtime auto-detection, then default `tmux`, so a home is never told to install a tool an inactive backend or feature would need. +The provider delta is required only for the registered origins discovered under `projects/`, so a home is never told to install an inactive forge's tooling. The per-backend delta is required only for the backend resolved from `FM_BACKEND`, then `config/backend`, then runtime auto-detection, then default `tmux`, so a home is never told to install a tool an inactive backend or feature would need. That delta is owned in code by `fm_backend_required_tools` in `bin/fm-backend.sh`: the resolved backend's own session-provider CLI (`tmux`, `herdr`, `zellij`, `orca`, or `cmux`), `jq` for the JSON-emitting experimental adapters (`herdr`, `zellij`, `cmux`) whose spawn and liveness paths parse the backend's JSON output, and the `treehouse` worktree provider for every session-provider-only backend (`tmux`, `herdr`, `zellij`, `cmux`). Backend tool availability uses the adapter's own executable resolver, so bootstrap and spawn agree on supported non-`PATH` locations such as cmux's bundled CLI. An unknown resolved backend emits `BACKEND_INVALID` and blocks dispatch instead of silently dropping its dependency delta or falling back to tmux. From a3b48fab153958d59041193f219eb24b878127b3 Mon Sep 17 00:00:00 2001 From: CodeFunta <9933960+CodeFunta@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:07:00 +0300 Subject: [PATCH 15/20] no-mistakes(document): Updated forge bootstrap documentation --- AGENTS.md | 2 +- docs/configuration.md | 2 +- docs/sessionstart-nudge.md | 2 +- docs/verification/supervision.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7d0863743c9..a5d0c05803c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,7 @@ If the session lock cannot be acquired and verified, report its exact diagnostic A lock-refused session must not spawn, steer, merge, drain the wake queue, repair supervision, repair a checkout, or perform any other fleet mutation. The digest itself makes no external-network call and never waits for one. -Every network check a session start owes - GitHub auth, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh - runs concurrently in a bounded worker owned by `bin/fm-startup-network.sh` and is reported in the digest's own `NETWORK CHECKS` section. +Every network check a session start owes - registered-forge authentication, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh - runs concurrently in a bounded worker owned by `bin/fm-startup-network.sh` and is reported in the digest's own `NETWORK CHECKS` section. When that section reports its checks still in progress it names exactly what is unconfirmed; treat none of those as passed until the result lands, either from `bin/fm-startup-network.sh report` or as a `check: startup-network` wake. 1. **Lock** - acquires the per-home session lock first, before anything mutates shared state, then starts the deferred network stage above. diff --git a/docs/configuration.md b/docs/configuration.md index 70766668186..75a25c06a10 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -295,7 +295,7 @@ On session start the first mate detects what its required toolchain is missing o It installs automatically supported tools only after you say go; manual-only tools remain for you to install from the printed instructions. Required tools come in two parts: a universal toolchain every home needs regardless of backend, and per-provider/backend deltas that follow the registered forge origins and runtime backend actually resolved for this home. The universal toolchain is node, git, no-mistakes v1.31.2 or newer, chrome-devtools-axi, compatible lavish-axi, compatible tasks-axi per "Backlog backend" above, and compatible quota-axi. -For registered GitHub projects, the provider delta adds `gh`, GitHub auth via `gh auth login`, and compatible `gh-axi`; for registered GitLab projects, it adds `glab` and GitLab auth. Local-only homes need neither forge delta. +For registered GitHub projects, the provider delta adds `gh`, GitHub auth via `gh auth login`, and compatible `gh-axi`; for registered GitLab projects, it adds `glab` and GitLab auth via `glab auth login`. Local-only homes need neither forge delta. [`bin/fm-bootstrap.sh`](../bin/fm-bootstrap.sh) owns the axi-family floor policy and the gh-axi and lavish-axi floors, while [`bin/fm-tasks-axi-lib.sh`](../bin/fm-tasks-axi-lib.sh) and [`bin/fm-quota-axi-lib.sh`](../bin/fm-quota-axi-lib.sh) hold their own tools' floor constants. This section is the single owner of that universal toolchain list; backend guides' prerequisites point here and add only their backend-specific tools. In that list, no-mistakes runs the validation pipeline, gh-axi, chrome-devtools-axi, and lavish-axi cover GitHub, browser, and rich-review operations, and tasks-axi plus quota-axi back backlog mutations and quota-aware array dispatch. diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index 4e4b11c18dd..36ef92d4ebf 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -61,7 +61,7 @@ The Ahoy skill owns the rule that this marked operational input is never a capta Before printing, the nudge wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of `bin/fm-lock.sh`'s ancestry walk (`fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh`, which now walks up to sixteen parents and can extend past a claude-named match to a still-more-ancestral one) and of Pi's `lockOwnership()`. If the lock names a live pid in that ancestry, session start already ran in this harness session and the wrapper stays silent. Every path in both wrappers exits 0, including malformed state and adapter errors, because a Claude SessionStart exit 2 blocks session initialization. -A lock another session holds and a truncated digest therefore surface as digest text, while broken GitHub auth surfaces through the deferred network result inline or as a wake; none becomes a refusal to open the session. +A lock another session holds and a truncated digest therefore surface as digest text, while broken registered-forge authentication surfaces through the deferred network result inline or as a wake; none becomes a refusal to open the session. ## Harness transports diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 8ae889f30fe..e4fabe98118 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -134,7 +134,7 @@ after real 0m3.36s digest prints IN PROGRESS; the same 3 SSH att run in the detached worker and finish at +77s ``` -The remaining seconds are entirely local subprocess work; the `NETWORK CHECKS` section named GitHub authentication, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh as not yet confirmed. +The remaining seconds are entirely local subprocess work; the `NETWORK CHECKS` section named registered-forge authentication, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh as not yet confirmed. Deferring the sweeps changed only when they run, not what they conclude. The deferred worker's published report was byte-identical to the three sweep lines the blocking baseline printed, on the same fixture: From 3805dd87663e75fab9468df0410ed2c7a100cdbb Mon Sep 17 00:00:00 2001 From: CodeFunta <9933960+CodeFunta@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:07:46 +0300 Subject: [PATCH 16/20] no-mistakes(lint): Remove unused forge network flag --- bin/fm-bootstrap.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 3b4be2529e9..fa78798a1f9 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -168,7 +168,6 @@ local_phase() { [ "$FM_BOOTSTRAP_NETWORK_PHASE" != only ]; } network_phase() { [ "$FM_BOOTSTRAP_NETWORK_PHASE" != skip ]; } FORGE_UNSUPPORTED_REPORTED=0 -FORGE_NETWORK_ALLOWED=1 forge_report_unsupported() { local unsupported=0 while IFS=$'\t' read -r _proj_id _proj_provider _proj_host; do From 27ca3b492b8720be1d16e6745eb3f74d4cb4c38a Mon Sep 17 00:00:00 2001 From: CodeFunta <9933960+CodeFunta@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:28:51 +0300 Subject: [PATCH 17/20] no-mistakes: apply CI fixes --- bin/fm-bootstrap.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index fa78798a1f9..3477c8dd9f4 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -1266,7 +1266,7 @@ local_phase && detect_local_config if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then # secondmate_sync consumes SECONDMATE_RESPAWNED_IDS from the liveness sweep, so # those two always run together in the same phase. - if network_phase && forge_report_unsupported; then + if network_phase; then if network_sweep_authorized 'dead-secondmate relaunch'; then __fm_timing_stamp=$(fm_timing_now_ms) secondmate_liveness_sweep @@ -1285,7 +1285,7 @@ if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then fi # x_mode_setup writes local Relay artifacts only and never leaves the machine. local_phase && x_mode_setup - if network_phase && forge_report_unsupported && network_sweep_authorized 'project clone refresh'; then + if network_phase && network_sweep_authorized 'project clone refresh'; then __fm_timing_stamp=$(fm_timing_now_ms) fleet_sync fm_timing_record phase fleet-sync "$__fm_timing_stamp" From b07be09ce19d147acb4ba21a50135b4dd79298a1 Mon Sep 17 00:00:00 2001 From: CodeFunta <9933960+CodeFunta@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:40:00 +0300 Subject: [PATCH 18/20] no-mistakes: apply CI fixes --- bin/fm-forge-lib.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh index 717619970a0..6134bd8eef3 100755 --- a/bin/fm-forge-lib.sh +++ b/bin/fm-forge-lib.sh @@ -103,7 +103,8 @@ fm_forge_detect_provider() { } # fm_forge_resolve_host [resolve-ssh] -# Resolves a git remote URL to a lowercase hostname. The optional second +# Resolves HTTPS, Git, SSH, and git+{http,https,ssh} remote URLs to a lowercase +# hostname. The optional second # argument defaults to following SSH config aliases; callers can pass 0 to # preserve the raw host for provider classification. fm_forge_resolve_host() { @@ -111,13 +112,13 @@ fm_forge_resolve_host() { [ -n "$remote_url" ] || return 1 case "$remote_url" in - ssh://*) - host=${remote_url#ssh://} + ssh://*|git+ssh://*) + host=${remote_url#*://} host=${host#*@} host=${host%%/*} host=${host%%:*} ;; - https://*|http://*) + https://*|http://*|git://*|git+https://*|git+http://*) host=${remote_url#*://} host=${host%%/*} host=${host##*@} From f16995ea43812ba16cb1dfe6ff44bf76f5f40405 Mon Sep 17 00:00:00 2001 From: CodeFunta <9933960+CodeFunta@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:51:52 +0300 Subject: [PATCH 19/20] no-mistakes: apply CI fixes --- bin/fm-bootstrap.sh | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 3477c8dd9f4..1c5977b8bf9 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -1239,11 +1239,12 @@ detect_local_config() { # bash's dynamic scoping would let them overwrite a stamp held by a caller. local_phase && detect_local_tools if network_phase; then - if forge_report_unsupported; then - __fm_timing_stamp=$(fm_timing_now_ms) - # Authentication is checked for each registered supported forge. The deferred - # network phase must repeat the same provider-aware checks, not a global GitHub - # probe that mislabels GitLab-only and local homes. + __fm_timing_stamp=$(fm_timing_now_ms) + # Authentication is checked for each registered supported forge. An unknown + # origin is reported by the local pass, but must not suppress authentication + # remediation for an unrelated supported project in the same home. + # The deferred network phase repeats these provider-aware checks rather than a + # global GitHub probe that mislabels GitLab-only and local homes. FORGE_AUTH_CHECKED="" while IFS=$'\t' read -r _proj_id _proj_provider _proj_host; do [ -n "${_proj_provider:-}" ] || continue @@ -1258,8 +1259,7 @@ if network_phase; then FORGE_AUTH_CHECKED="$FORGE_AUTH_CHECKED $_pair" fm_forge_check_auth "$_proj_provider" "${_proj_host:-}" done <<< "$FORGE_PROJECTS" - fm_timing_record phase forge-auth "$__fm_timing_stamp" - fi + fm_timing_record phase forge-auth "$__fm_timing_stamp" fi local_phase && detect_local_config @@ -1285,7 +1285,10 @@ if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then fi # x_mode_setup writes local Relay artifacts only and never leaves the machine. local_phase && x_mode_setup - if network_phase && network_sweep_authorized 'project clone refresh'; then + # Never hand an unclassified registered origin to the clone-refresh worker: + # its remote transport is not a supported forge boundary, so fail closed for + # that project while leaving unrelated secondmate supervision sweeps intact. + if network_phase && forge_report_unsupported && network_sweep_authorized 'project clone refresh'; then __fm_timing_stamp=$(fm_timing_now_ms) fleet_sync fm_timing_record phase fleet-sync "$__fm_timing_stamp" From d8b67f302ebafa94a022c22d7abeb80993528102 Mon Sep 17 00:00:00 2001 From: CodeFunta <9933960+CodeFunta@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:12:24 +0300 Subject: [PATCH 20/20] no-mistakes: apply CI fixes --- bin/fm-session-start.sh | 2 +- bin/fm-sessionstart-run.sh | 3 ++- bin/fm-startup-network.sh | 8 +++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 6239679b63d..22d1f920dfb 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -856,7 +856,7 @@ fi # --- 7. network checks ------------------------------------------------------ # Deliberately here and not later: these lines are actionable (a stuck clone, a -# secondmate that could not be relaunched, broken GitHub auth), and the section +# secondmate that could not be relaunched, broken registered-forge auth), and the section # after this one is the curated memory a truncated tail is meant to take first. # Deliberately here and not earlier: this is the last point in the digest, so the # worker started at step 1 has had the whole composition above to finish in. It diff --git a/bin/fm-sessionstart-run.sh b/bin/fm-sessionstart-run.sh index 50496eef295..48741835a35 100755 --- a/bin/fm-sessionstart-run.sh +++ b/bin/fm-sessionstart-run.sh @@ -33,7 +33,8 @@ # agent as digest text it can act on, never as a refusal to open the session. # A lock another live session holds and a truncated digest are reported inside # the digest, while broken GitHub auth arrives through the deferred network -# result inline or as a wake, for exactly that reason. +# result inline or as a wake, for exactly that reason. The result is scoped to +# the registered forge rather than assuming every home uses GitHub. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/bin/fm-startup-network.sh b/bin/fm-startup-network.sh index b5cc6024bd7..8a278f89c89 100755 --- a/bin/fm-startup-network.sh +++ b/bin/fm-startup-network.sh @@ -2,8 +2,9 @@ # fm-startup-network.sh - the deferred network stage of a session start. # # WHY THIS EXISTS. Every external-network call a session start makes used to run -# BEFORE the digest printed, on a hook that blocks session initialization: `gh -# auth status`, the secondmate liveness and convergence sweeps (11 sequential, +# BEFORE the digest printed, on a hook that blocks session initialization: +# registered-forge authentication, the secondmate liveness and convergence +# sweeps (11 sequential, # individually unbounded SSH connections per REMOTE secondmate), pending remote # handoff delivery, and the fleet-sync fetch of every project clone. None of # those calls is individually bounded, so one unreachable host could consume the @@ -77,7 +78,8 @@ # wake. # .startup-network.timings per-step elapsed times for the last run, in # bin/fm-timing-lib.sh's tab-separated format: the -# stage total, one record per network phase (gh auth, +# stage total, one record per network phase +# (registered-forge auth, # secondmate liveness, secondmate convergence, handoff # delivery, fleet sync), one per secondmate for the # remote-touching steps (id and host), and one per