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"
}