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..e315147d86b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,8 +161,9 @@ 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. -Malformed, multi-line, symlinked, hardlinked, special, or otherwise unsafe values are rejected rather than treated as a default. +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 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. diff --git a/tests/fm-startup-memory-budget.test.sh b/tests/fm-startup-memory-budget.test.sh index 625444298d5..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" @@ -158,10 +171,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" }