From efb67d5ba5384f137b8377b95a8da95d8f84181a Mon Sep 17 00:00:00 2001 From: Daan Aerts Date: Sat, 22 Aug 2026 13:22:53 +0200 Subject: [PATCH 1/3] fix(startup-memory-budget): resolve through a symlinked config directory A home whose config/ is a symlink into a machine-configuration repo is a supported deployment layout, and every other setting under config/ is already read through it. Only the startup-memory budget refused, so fm-startup-memory-budget.sh read/report failed outright and every session start printed "STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - config directory is symlinked". The rejection bought no integrity property. It tested only the final path component, so a symlinked ancestor passed silently, and it could never detect the thing it looked like it was guarding: two homes resolving to one physical config directory. The checks that actually stop this scalar from being aliased or shared are per-file and are unchanged - the budget file itself must still be a regular, non-symlinked, single-linked file holding exactly one positive decimal value and one newline. The directory now only has to resolve to a real directory, so a dangling symlink and a symlink to a file are still rejected, with the existing "config directory is not a directory" diagnostic. Also anchors the config ignore rule to /config so a symlinked config/ is ignored rather than showing up as an untracked entry in a captain's home. --- .gitignore | 2 +- bin/fm-startup-memory-budget-lib.sh | 12 ++++++++---- docs/configuration.md | 3 ++- tests/fm-startup-memory-budget.test.sh | 17 +++++++++++++++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 27c23e4f537..3b482b77e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ scratchpad/ __pycache__/ *.pyc .env -config/ +/config diff --git a/bin/fm-startup-memory-budget-lib.sh b/bin/fm-startup-memory-budget-lib.sh index f2c06014b8e..edc32fa916a 100644 --- a/bin/fm-startup-memory-budget-lib.sh +++ b/bin/fm-startup-memory-budget-lib.sh @@ -29,12 +29,16 @@ fm_startup_memory_budget_link_count() { fi } +# fm_startup_memory_budget_config_dir_safe +# The directory only has to resolve to a real directory. A symlinked config/ is +# a legitimate deployment layout - a home whose config/ is linked into a +# machine-configuration repo - and every other config consumer already resolves +# through it. Rejecting it here bought no integrity property either, because +# the test could only ever see the final path component: a symlinked ancestor +# passed silently. The per-file symlink and hardlink checks below are what +# actually stop this scalar from being aliased or shared. fm_startup_memory_budget_config_dir_safe() { local dir=$1 - if [ -L "$dir" ]; then - fm_startup_memory_budget_fail "config directory is symlinked" - return 1 - fi if [ ! -d "$dir" ]; then fm_startup_memory_budget_fail "config directory is not a directory" return 1 diff --git a/docs/configuration.md b/docs/configuration.md index cd4b0b3ab0a..337518860f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,7 +161,8 @@ There is no shared learnings file by captain decision. The locked mutable bootstrap path materializes its visible default of `7500` estimated tokens in a primary home when the file is absent. To select another allowance, replace the primary home's file with one valid positive value in the exact format below; the next locked bootstrap convergence or `bin/fm-config-push.sh` propagates it to registered secondmates. A secondmate does not create an independent default and instead receives the primary value through the inherited-local-material contract in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). -The file must be one positive base-10 integer followed by exactly one newline in a regular, single-linked file beneath a non-symlinked `config/` directory. +The file must be one positive base-10 integer followed by exactly one newline in a regular, single-linked file beneath a `config/` directory that resolves to a real directory. +A `config/` that is itself a symlink into a machine-configuration repo is a supported layout and is resolved through, exactly as every other setting on this page already is. Malformed, multi-line, symlinked, hardlinked, special, or otherwise unsafe values are rejected rather than treated as a default. Use `bin/fm-startup-memory-budget.sh read` to validate and print the effective value, or `bin/fm-startup-memory-budget.sh report` to account for the three files. The stable local estimate is `ceil(UTF-8 bytes / 3)` per file, a conservative portable approximation rather than a provider-exact tokenizer. diff --git a/tests/fm-startup-memory-budget.test.sh b/tests/fm-startup-memory-budget.test.sh index 625444298d5..252e3ce1a4f 100755 --- a/tests/fm-startup-memory-budget.test.sh +++ b/tests/fm-startup-memory-budget.test.sh @@ -158,10 +158,23 @@ test_safe_parser_rejects_ambiguous_and_unsafe_values() { rm -f "$home/config/startup-memory-budget" rm -rf "$home/config" - ln -s "$TMP_ROOT/parser-config-target" "$home/config" mkdir -p "$TMP_ROOT/parser-config-target" printf '88\n' > "$TMP_ROOT/parser-config-target/startup-memory-budget" - expect_rejected_read "$home" 'config directory is symlinked' + ln -s "$TMP_ROOT/parser-config-target" "$home/config" + [ "$(FM_HOME="$home" "$BUDGET" read)" = 88 ] || fail "a symlinked config directory was not resolved through" + + rm -f "$home/config/startup-memory-budget" + ln -s "$outside" "$TMP_ROOT/parser-config-target/startup-memory-budget" + expect_rejected_read "$home" 'file is symlinked' + + rm -f "$home/config" + ln -s "$TMP_ROOT/parser-config-missing" "$home/config" + expect_rejected_read "$home" 'config directory is not a directory' + + rm -f "$home/config" + printf '99\n' > "$TMP_ROOT/parser-config-file" + ln -s "$TMP_ROOT/parser-config-file" "$home/config" + expect_rejected_read "$home" 'config directory is not a directory' pass "budget parser accepts one exact positive value and rejects malformed or unsafe inputs" } From c36292808c7e04fb8376bb5ab1e537c9a94d9427 Mon Sep 17 00:00:00 2001 From: Daan Aerts Date: Sat, 22 Aug 2026 17:38:41 +0200 Subject: [PATCH 2/3] no-mistakes(review): test bootstrap materialization through symlinked config dir --- tests/fm-startup-memory-budget.test.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/fm-startup-memory-budget.test.sh b/tests/fm-startup-memory-budget.test.sh index 252e3ce1a4f..e369389679c 100755 --- a/tests/fm-startup-memory-budget.test.sh +++ b/tests/fm-startup-memory-budget.test.sh @@ -93,7 +93,7 @@ run_bootstrap() { } test_primary_bootstrap_materializes_visible_default() { - local rec root home fakebin out second + local rec root home fakebin out second linked linked_config rec=$(new_bootstrap_world materialize) root=${rec%%|*} home=${rec#*|} @@ -111,6 +111,19 @@ test_primary_bootstrap_materializes_visible_default() { [ "$(<"$home/config/startup-memory-budget")" = 321 ] \ || fail "bootstrap replaced a valid captain-selected budget" + linked="$TMP_ROOT/materialize/linked-home" + linked_config="$TMP_ROOT/materialize/linked-config" + mkdir -p "$linked/data" "$linked/state" "$linked_config" + ln -s "$linked_config" "$linked/config" + out=$(run_bootstrap "$root" "$linked" "$fakebin") + case "$out" in + *STARTUP_MEMORY_BUDGET:*) fail "bootstrap rejected a symlinked config directory: $out" ;; + esac + [ "$(<"$linked_config/startup-memory-budget")" = 7500 ] \ + || fail "bootstrap did not materialize the default through a symlinked config directory" + [ "$(FM_HOME="$linked" "$BUDGET" read)" = 7500 ] \ + || fail "read command did not expose the default published through a symlinked config directory" + second="$TMP_ROOT/materialize/secondmate" mkdir -p "$second/config" "$second/data" "$second/state" printf '%s\n' sm > "$second/.fm-secondmate-home" From e17a9499a4d666579979abaeadef6fe447b8fef3 Mon Sep 17 00:00:00 2001 From: Daan Aerts Date: Sat, 22 Aug 2026 22:52:51 +0200 Subject: [PATCH 3/3] no-mistakes(review): scope budget-file rejection wording in configuration docs --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 337518860f9..e315147d86b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -163,7 +163,7 @@ To select another allowance, replace the primary home's file with one valid posi A secondmate does not create an independent default and instead receives the primary value through the inherited-local-material contract in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). The file must be one positive base-10 integer followed by exactly one newline in a regular, single-linked file beneath a `config/` directory that resolves to a real directory. A `config/` that is itself a symlink into a machine-configuration repo is a supported layout and is resolved through, exactly as every other setting on this page already is. -Malformed, multi-line, symlinked, hardlinked, special, or otherwise unsafe values are rejected rather than treated as a default. +Malformed or multi-line values, a symlinked, hardlinked, or special budget file, and anything else unsafe are rejected rather than treated as a default. Use `bin/fm-startup-memory-budget.sh read` to validate and print the effective value, or `bin/fm-startup-memory-budget.sh report` to account for the three files. The stable local estimate is `ceil(UTF-8 bytes / 3)` per file, a conservative portable approximation rather than a provider-exact tokenizer. An inherited `data/captain-shared.md` counts in a secondmate's total but remains primary-owned and read-only there.