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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 47 additions & 18 deletions bin/fm-claude-trust.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
# fm-claude-trust.sh --secondmate-home <home> <id>
# fm-claude-trust.sh --lab-home <home>
# <worktree> the isolated task worktree this spawn launches into
# <project> the primary checkout that worktree belongs to
# <project> the primary checkout that worktree belongs to (or, for a pooled
# Treehouse slot, a clone of the same origin)
# <home> the seeded secondmate home this spawn launches into
# <id> the secondmate id that home must already be marked for
# --lab-home the disposable lab home bin/fm-live-lab.sh launches a lab
Expand Down Expand Up @@ -51,9 +52,10 @@
# (`es`/`F1e`) has no such fallback: it reads ONLY the canonical project-root
# entry, and that root is never the worktree - Claude Code's own git-root
# canonicalization (`Fr`/`Se`) walks a linked worktree's `.git` file through
# its `commondir` pointer back to the PRIMARY CHECKOUT, exactly the <project>
# argument this script already receives for the worktree-mode scope test
# below. So the trust flag is registered on BOTH the worktree entry (for
# its `commondir` pointer back to the worktree's PRIMARY CHECKOUT, which may
# belong to another clone for a shared Treehouse pool slot. The scope test
# below resolves and verifies that checkout. So the trust flag is registered
# on BOTH the worktree entry (for
# trust's ancestor-walk fallback and defense in depth) and the project entry
# (the trust check's first, canonical-shaped, look); the two external-imports
# flags land on those same two entries only when the project entry already
Expand Down Expand Up @@ -89,8 +91,10 @@
# argument. Git is the ground truth, so the argument is never trusted on its
# own word: a primary checkout (git dir == common dir), a worktree of an
# unrelated repo, a subdirectory of a worktree, a plain directory, and a home
# directory are each refused. Refusal is a non-zero exit, never a warning and
# never a silent skip. When <project> is itself a linked worktree (a
# directory are each refused. The one exception to the shared common dir is a
# Treehouse pool slot that is a worktree of another clone of <project>'s origin
# (see the cross-clone block below). Refusal is a non-zero exit, never a
# warning and never a silent skip. When <project> is itself a linked worktree (a
# secondmate home spawned from, rather than as, the primary checkout),
# refusing outright would wedge a relaunch that is otherwise perfectly valid:
# its own common dir already IS the primary checkout's own git dir (git's
Expand Down Expand Up @@ -228,6 +232,8 @@ refuse() { echo "error: refusing to pre-register Claude trust: $1" >&2; exit 1;

# shellcheck source=bin/fm-gate-refuse-lib.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/fm-gate-refuse-lib.sh"
# shellcheck source=bin/fm-git-origin-lib.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/fm-git-origin-lib.sh"

real_dir() { (cd -P -- "$1" 2>/dev/null && pwd -P); }

Expand Down Expand Up @@ -298,34 +304,57 @@ if [ "$MODE" = worktree ]; then

PROJ_COMMON=$(common_dir_of "$PROJ_REAL") || true
[ -n "$PROJ_COMMON" ] || refuse "project '$PROJ_REAL' is not inside a git repository"
[ "$WT_COMMON" = "$PROJ_COMMON" ] || refuse "'$TARGET_REAL' is not a worktree of project '$PROJ_REAL'"
# Treehouse shares one pool among every clone of an origin, so a secondmate
# home's spawn can be handed a slot that is a worktree of another home's clone
# (the root home's, say). That slot is accepted only on positive evidence: it
# must sit in a Treehouse pool (the pool's own state file beside its slot
# directory) and its repository must share a non-empty origin with <project>.
# An unrelated repository, a pool-less linked worktree, and a primary
# checkout are still refused. The project entry written below is then the
# slot's own canonical checkout, because that is where Claude Code's git-root
# canonicalization sends the worktree and so where the imports check reads.
CROSS_CLONE=0
if [ "$WT_COMMON" != "$PROJ_COMMON" ]; then
WT_POOL_STATE="$(dirname -- "$(dirname -- "$TARGET_REAL")")/treehouse-state.json"
if [ -f "$WT_POOL_STATE" ] && [ ! -L "$WT_POOL_STATE" ] && fm_git_same_origin "$TARGET_REAL" "$PROJ_REAL"; then
CROSS_CLONE=1
else
refuse "'$TARGET_REAL' is not a worktree of project '$PROJ_REAL'"
fi
fi

# The external-imports flags must land on the primary checkout - its own git
# dir equals the common dir - because that is exactly the path Claude Code's
# own git-root canonicalization collapses every linked worktree to. When
# <project> is itself a linked worktree (a secondmate home spawned from,
# rather than as, the primary checkout), refusing outright would wedge a
# relaunch that is otherwise perfectly valid: PROJ_COMMON already IS that
# primary checkout's own git dir (git's git-common-dir answer never changes
# by which worktree asks), so the checkout is derived structurally from it -
# relaunch that is otherwise perfectly valid: the selected CANON_BASE already
# IS that primary checkout's own git dir (WT_COMMON for a cross-clone slot,
# PROJ_COMMON otherwise), so the checkout is derived structurally from it -
# its parent directory in the standard non-bare, non-GIT_DIR-overridden
# layout this script already requires elsewhere - and verified, never
# assumed: the candidate's own resolved git dir must equal PROJ_COMMON, the
# assumed: the candidate's own resolved git dir must equal CANON_BASE, the
# same primary-checkout definition used above, or this refuses rather than
# guess.
PROJ_GIT_DIR=$(git -C "$PROJ_REAL" rev-parse --absolute-git-dir 2>/dev/null) || true
[ -n "$PROJ_GIT_DIR" ] || refuse "project '$PROJ_REAL' has no resolvable git directory"
PROJ_GIT_DIR=$(real_dir "$PROJ_GIT_DIR") || true
[ -n "$PROJ_GIT_DIR" ] || refuse "project '$PROJ_REAL' has an unresolvable git directory"
if [ "$PROJ_GIT_DIR" = "$PROJ_COMMON" ]; then
if [ "$CROSS_CLONE" = 1 ]; then
CANON_BASE=$WT_COMMON
PROJ_GIT_DIR=
else
CANON_BASE=$PROJ_COMMON
PROJ_GIT_DIR=$(git -C "$PROJ_REAL" rev-parse --absolute-git-dir 2>/dev/null) || true
[ -n "$PROJ_GIT_DIR" ] || refuse "project '$PROJ_REAL' has no resolvable git directory"
PROJ_GIT_DIR=$(real_dir "$PROJ_GIT_DIR") || true
[ -n "$PROJ_GIT_DIR" ] || refuse "project '$PROJ_REAL' has an unresolvable git directory"
fi
if [ "$PROJ_GIT_DIR" = "$CANON_BASE" ]; then
PROJ_CANON=$PROJ_REAL
else
PROJ_CANON=$(real_dir "$(dirname -- "$PROJ_COMMON")") || true
PROJ_CANON=$(real_dir "$(dirname -- "$CANON_BASE")") || true
[ -n "$PROJ_CANON" ] \
|| refuse "project '$PROJ_REAL' is a linked worktree whose primary checkout could not be resolved"
CANON_GIT_DIR=$(git -C "$PROJ_CANON" rev-parse --absolute-git-dir 2>/dev/null) || true
CANON_GIT_DIR=$(real_dir "${CANON_GIT_DIR:-}") || true
[ -n "$CANON_GIT_DIR" ] && [ "$CANON_GIT_DIR" = "$PROJ_COMMON" ] \
[ -n "$CANON_GIT_DIR" ] && [ "$CANON_GIT_DIR" = "$CANON_BASE" ] \
|| refuse "project '$PROJ_REAL' is a linked worktree whose primary checkout could not be resolved"
fi
elif [ "$MODE" = lab-home ]; then
Expand Down
36 changes: 36 additions & 0 deletions bin/fm-git-origin-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# fm-git-origin-lib.sh - the shared notion of "the same repository" across
# separate clones, with no source-time side effects.
#
# Treehouse keys one worktree pool by repository name plus a hash of the
# origin URL, so every home on this machine whose project clone has the same
# origin draws from one pool: a slot handed to a secondmate home may be a
# worktree of the root home's clone (see fm_treehouse_project_lock_path in
# fm-wake-lib.sh, which serializes that shared pool). Callers that must decide
# whether a pooled worktree belongs to a project compare this identity rather
# than git common dirs alone.

# Print the resolved origin identity of the repository containing <dir>, or
# fail when it has no origin. A local origin path is resolved to its physical
# path so two spellings of one bare repository agree.
fm_git_origin_identity() { # <dir>
local dir=$1 origin
[ -d "$dir" ] || return 1
origin=$(git -C "$dir" remote get-url origin 2>/dev/null || true)
[ -n "$origin" ] || return 1
case "$origin" in
/*) [ ! -d "$origin" ] || origin=$(CDPATH='' cd -- "$origin" 2>/dev/null && pwd -P) || return 1 ;;
*://*|*:* ) ;;
*) [ ! -d "$dir/$origin" ] || origin=$(CDPATH='' cd -- "$dir/$origin" 2>/dev/null && pwd -P) || return 1 ;;
esac
printf '%s\n' "$origin"
}

# Succeed when both directories are in repositories that share a non-empty
# origin identity.
fm_git_same_origin() { # <dir-a> <dir-b>
local a b
a=$(fm_git_origin_identity "$1") || return 1
b=$(fm_git_origin_identity "$2") || return 1
[ "$a" = "$b" ]
}
1 change: 1 addition & 0 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ for _teardown_source in \
fm-nm-run-lib.sh \
fm-wake-lib.sh \
fm-path-lib.sh \
fm-git-origin-lib.sh \
fm-lease-lib.sh
do
teardown_require_source "$SCRIPT_DIR/$_teardown_source"
Expand Down
22 changes: 10 additions & 12 deletions bin/fm-wake-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ FM_WAKE_QUEUE_LOCK="${FM_WAKE_QUEUE_LOCK:-$STATE/.wake-queue.lock}"
FM_LOCK_STALE_AFTER="${FM_LOCK_STALE_AFTER:-2}"
# shellcheck source=bin/fm-path-lib.sh
. "$FM_WAKE_LIB_DIR/fm-path-lib.sh"
# shellcheck source=bin/fm-git-origin-lib.sh
. "$FM_WAKE_LIB_DIR/fm-git-origin-lib.sh"
# Resolved once at source time: fm_pid_identity and fm_path_mtime run inside 0.2s
# confirm and 0.5s attach polls, and forking uname per call is a measurable cost on
# the platform (Git Bash/MSYS) that already pays the highest fork price.
Expand Down Expand Up @@ -1403,18 +1405,10 @@ fm_firstmate_root_home() {
# separate clones of one origin share a single lock; an origin-less local-only
# project falls back to its own worktree top instead of failing to resolve.
fm_treehouse_project_lock_path() { # <project-dir>
local project=$1 root origin identity hash top
local project=$1 root identity hash top
[ -d "$project" ] || return 1
root=$(fm_firstmate_root_home "$FM_HOME") || return 1
origin=$(git -C "$project" remote get-url origin 2>/dev/null || true)
if [ -n "$origin" ]; then
case "$origin" in
/*) [ ! -d "$origin" ] || origin=$(CDPATH='' cd -- "$origin" 2>/dev/null && pwd -P) || return 1 ;;
*://*|*:* ) ;;
*) [ ! -d "$project/$origin" ] || origin=$(CDPATH='' cd -- "$project/$origin" 2>/dev/null && pwd -P) || return 1 ;;
esac
identity=$origin
else
if ! identity=$(fm_git_origin_identity "$project"); then
top=$(git -C "$project" rev-parse --show-toplevel 2>/dev/null) || return 1
top=$(CDPATH='' cd -- "$top" 2>/dev/null && pwd -P) || return 1
identity=$top
Expand All @@ -1426,7 +1420,8 @@ fm_treehouse_project_lock_path() { # <project-dir>

# A Treehouse slot has the managed pool's fixed <pool>/<slot>/<repo> layout.
# Require both its pool state and the same Git common directory as the recorded
# project; an ordinary linked worktree is not evidence that Treehouse owns it.
# project, or a clone of the same origin because the pool is shared across homes;
# an ordinary linked worktree is not evidence that Treehouse owns it.
fm_treehouse_pool_slot() { # <project-dir> <worktree>
local project=$1 worktree=$2 slot pool state project_common slot_common
[ -d "$project" ] && [ -d "$worktree" ] || return 1
Expand All @@ -1438,7 +1433,10 @@ fm_treehouse_pool_slot() { # <project-dir> <worktree>
slot_common=$(git -C "$slot" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1
project_common=$(CDPATH='' cd -- "$project_common" 2>/dev/null && pwd -P) || return 1
slot_common=$(CDPATH='' cd -- "$slot_common" 2>/dev/null && pwd -P) || return 1
[ "$project_common" = "$slot_common" ]
[ "$project_common" = "$slot_common" ] && return 0
# The pool is shared by every clone of one origin, so a slot may be a
# worktree of another home's clone of this project.
fm_git_same_origin "$project" "$slot"
}

# Slot-owner claim: which task a Treehouse pool slot currently belongs to.
Expand Down
2 changes: 1 addition & 1 deletion docs/orca-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Reinstall the CLI and rerun; [`verification/runtime-backends.md`](verification/r
- Escape is unsupported.
- Orca exposes no stable CLI version or protocol marker, so readiness is the compatibility gate rather than a version floor.
- Only the verified terminal-handle and worktree result fields are accepted; speculative response shapes are rejected.
- Orca's worktree shape is unverified against the spawn-time Claude workspace-trust check in `bin/fm-claude-trust.sh`, which refuses any path that is not a linked git worktree sharing the project's git common dir, so a claude spawn on Orca fails loudly at that check rather than launching if Orca clones instead of linking.
- Orca's worktree shape is unverified against the spawn-time Claude workspace-trust check; [`fm-claude-trust.sh`](../bin/fm-claude-trust.sh)'s header owns the scope contract, and a claude spawn on Orca fails loudly at that check rather than launching if Orca clones instead of linking.

## Regression entry points

Expand Down
1 change: 1 addition & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md
| `fm-wake-grant.sh` | Serialize Pi supervision-branch wake-row claim activation, publication, release, and deactivation |
| `fm-wake-lib.sh` | Shared durable wake queue, recovery generations, portable locks, and watcher identity/health helpers |
| `fm-path-lib.sh` | Fork-free `dirname`/`basename` equivalents with no source-time side effects |
| `fm-git-origin-lib.sh` | Shared origin-identity helpers so separate clones of one origin (a shared Treehouse pool) count as the same repository |
| `fm-classify-lib.sh` | Shared wake classification, durable keyed-decision folds and scans, unread status selection, home-owned status-append ranges, and bounded latest-event snapshots |
| `fm-send.sh` | Steer a task via a durable inbox record plus doorbell, or send a supported key or typed harness invocation through the recorded backend |
| `fm-branch-prompt.sh` | Emit the shared supervision branch's byte-stable system prompt ([pi-supervision-branch.md](pi-supervision-branch.md), [supervision-host.md](supervision-host.md)) |
Expand Down
1 change: 1 addition & 0 deletions tests/fm-afk-return.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ install_runner() { # <case-dir>
cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/"
cp "$ROOT/bin/fm-lock-lib.sh" "$dir/bin/"
cp "$ROOT/bin/fm-path-lib.sh" "$dir/bin/"
cp "$ROOT/bin/fm-git-origin-lib.sh" "$dir/bin/"
cp "$ROOT/bin/fm-classify-lib.sh" "$dir/bin/"
# fm-timeout-lib.sh: the shared hard bound fm-classify-lib.sh sources for the
# wedge detector's bounded worktree write probe.
Expand Down
1 change: 1 addition & 0 deletions tests/fm-claude-stop-autoarm.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ install_autoarm_scripts() {
cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh"
cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh"
cp "$ROOT/bin/fm-path-lib.sh" "$dir/bin/fm-path-lib.sh"
cp "$ROOT/bin/fm-git-origin-lib.sh" "$dir/bin/fm-git-origin-lib.sh"
cp "$ROOT/bin/fm-session-lock-lib.sh" "$dir/bin/fm-session-lock-lib.sh"
cp "$ROOT/bin/fm-cursor-lib.sh" "$dir/bin/fm-cursor-lib.sh"
cp "$ROOT/bin/fm-hook-host-lib.sh" "$dir/bin/fm-hook-host-lib.sh"
Expand Down
79 changes: 79 additions & 0 deletions tests/fm-claude-trust.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,81 @@ test_foreign_project_worktree_is_refused() {
pass "fm-claude-trust.sh: refuses a worktree belonging to another project"
}

# A Treehouse pool is shared by every clone of one origin, so a secondmate
# home's spawn can be handed a slot that is a worktree of another home's clone.
# pooled_slot_of_other_clone <case-dir> <main-clone> <slot-name>: a pool laid out
# as <pool>/<slot>/<repo> with its state file, holding a worktree of <main-clone>.
pooled_slot_of_other_clone() {
local case_dir=$1 main=$2 slot=$3 pool="$1/pool"
mkdir -p "$pool/$slot"
printf '{"worktrees":[]}\n' > "$pool/treehouse-state.json"
git -C "$main" worktree add --quiet --detach "$pool/$slot/project"
printf '%s\n' "$pool/$slot/project"
}

test_pooled_worktree_of_another_clone_of_the_same_origin_is_trusted() {
local rec out main_wt second
rec=$(make_case same-origin-pool)
read_case "$rec"
main_wt=$(pooled_slot_of_other_clone "$CASE_DIR" "$PROJ" 1)
second="$CASE_DIR/second-home-project"
git clone --quiet "file://$PROJ.origin.git" "$second"
out=$(run_trust "$CONFIG" "$main_wt" "$second")
expect_code 0 $? "a pooled worktree of another clone of the same origin must be trusted: $out"
assert_trusted "$CONFIG/.claude.json" "$main_wt" "the pooled worktree was not trusted"
assert_trusted "$CONFIG/.claude.json" "$PROJ" "the slot's own canonical checkout was not trusted"
assert_not_trusted "$CONFIG/.claude.json" "$second" "the second home's clone was trusted though the worktree does not belong to it"
pass "fm-claude-trust.sh: trusts a pooled worktree of another clone of the same origin"
}

test_cross_clone_worktree_outside_a_pool_is_refused() {
local rec out second
rec=$(make_case same-origin-no-pool)
read_case "$rec"
second="$CASE_DIR/second-home-project"
git clone --quiet "file://$PROJ.origin.git" "$second"
out=$(run_trust "$CONFIG" "$WT" "$second")
expect_code 1 $? "a non-pooled worktree of another clone must be refused: $out"
assert_contains "$out" "is not a worktree of project" "the refusal did not name the project mismatch"
assert_not_trusted "$CONFIG/.claude.json" "$WT" "a non-pooled foreign worktree was trusted"
pass "fm-claude-trust.sh: refuses another clone's worktree that is not in a Treehouse pool"
}

test_pooled_worktree_of_an_unrelated_origin_is_refused() {
local rec out pooled other
rec=$(make_case pool-unrelated-origin)
read_case "$rec"
other="$CASE_DIR/other-project"
fm_git_init_commit "$other"
fm_git_add_origin "$other" "$other.origin.git"
pooled=$(pooled_slot_of_other_clone "$CASE_DIR" "$other" 1)
out=$(run_trust "$CONFIG" "$pooled" "$PROJ")
expect_code 1 $? "a pooled worktree of an unrelated origin must be refused: $out"
assert_contains "$out" "is not a worktree of project" "the refusal did not name the project mismatch"
assert_not_trusted "$CONFIG/.claude.json" "$pooled" "a pooled unrelated worktree was trusted"
pass "fm-claude-trust.sh: refuses a pooled worktree of an unrelated origin"
}

test_pool_slot_recognizes_another_clone_of_the_same_origin() {
local rec main_wt second other pooled_other
rec=$(make_case pool-slot-origin)
read_case "$rec"
main_wt=$(pooled_slot_of_other_clone "$CASE_DIR" "$PROJ" 1)
second="$CASE_DIR/second-home-project"
git clone --quiet "file://$PROJ.origin.git" "$second"
other="$CASE_DIR/other-project"
fm_git_init_commit "$other"
fm_git_add_origin "$other" "$other.origin.git"
pooled_other=$(pooled_slot_of_other_clone "$CASE_DIR" "$other" 2)
# shellcheck disable=SC2016
FM_HOME="$CASE_DIR" FM_ROOT="$ROOT" bash -c '. "$FM_ROOT/bin/fm-wake-lib.sh"; fm_treehouse_pool_slot "$1" "$2"' _ "$second" "$main_wt" \
|| fail "a pool slot of another clone of the same origin was not recognized as the project's slot"
# shellcheck disable=SC2016
FM_HOME="$CASE_DIR" FM_ROOT="$ROOT" bash -c '. "$FM_ROOT/bin/fm-wake-lib.sh"; fm_treehouse_pool_slot "$1" "$2"' _ "$second" "$pooled_other" \
&& fail "a pool slot of an unrelated origin was recognized as the project's slot"
pass "fm_treehouse_pool_slot: matches slots by origin across clones and still rejects unrelated ones"
}

test_worktree_subdirectory_is_refused() {
local rec out sub
rec=$(make_case subdir)
Expand Down Expand Up @@ -856,6 +931,10 @@ test_relative_config_dir_is_refused
test_non_git_directory_is_refused
test_missing_directory_is_refused
test_foreign_project_worktree_is_refused
test_pooled_worktree_of_another_clone_of_the_same_origin_is_trusted
test_cross_clone_worktree_outside_a_pool_is_refused
test_pooled_worktree_of_an_unrelated_origin_is_refused
test_pool_slot_recognizes_another_clone_of_the_same_origin
test_worktree_subdirectory_is_refused
test_project_argument_that_is_itself_a_worktree_resolves_to_the_primary_checkout
test_unrelated_store_content_is_preserved
Expand Down
Loading
Loading