Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8080661557 | ||
|
|
d7b1dd9601 | ||
|
|
0db2d19a22 | ||
|
|
8eb7e6354e |
+114
-7
@@ -209,23 +209,38 @@ steps:
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const updated = [];
|
||||
const exactVersions = new Map(); // name -> bumped next version
|
||||
|
||||
function walk(dir) {
|
||||
function walk(dir, visit) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||
walk(fullPath);
|
||||
if (fs.existsSync(packagePath)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
|
||||
visit(manifest, packagePath);
|
||||
}
|
||||
}
|
||||
walk(fullPath, visit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackage(packagePath) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||
// #1389: two passes. Pass 1 bumps every publishable manifest to
|
||||
// <stable+1>-next.<pipeline> exactly as before, recording name ->
|
||||
// bumped version. Pass 2 rewrites every published manifest's
|
||||
// @mosaicstack/* dependency entries (dependencies, devDependencies,
|
||||
// peerDependencies, optionalDependencies) to the EXACT same-pipeline
|
||||
// build. A caret range like ^0.0.3-next.2636 leaves the resolver free
|
||||
// to pick any later build — and on a host with a stale cache, an
|
||||
// installer-side scaffold pinned at stable, or a registry hiccup, that
|
||||
// freedom is how a "next" install ends up executing stable-era code
|
||||
// (web1 evidence: old tier validator, missing migrations). Exact pins
|
||||
// make the defect class unrepresentable regardless of resolver path.
|
||||
function bump(manifest, packagePath) {
|
||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||
if (!stableMatch) {
|
||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||
@@ -234,13 +249,40 @@ steps:
|
||||
const oldVersion = manifest.version;
|
||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
exactVersions.set(manifest.name, manifest.version);
|
||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root);
|
||||
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
let pinnedEntries = 0;
|
||||
function pin(manifest, packagePath) {
|
||||
let changed = false;
|
||||
for (const field of DEP_FIELDS) {
|
||||
const deps = manifest[field];
|
||||
if (!deps || typeof deps !== 'object') continue;
|
||||
for (const [name, range] of Object.entries(deps)) {
|
||||
if (!name.startsWith('@mosaicstack/')) continue;
|
||||
const exact = exactVersions.get(name);
|
||||
if (!exact) {
|
||||
throw new Error(
|
||||
manifest.name + ' depends on ' + name +
|
||||
' which has no bumped version in this publish set — cannot pin');
|
||||
}
|
||||
if (range === exact) continue;
|
||||
deps[name] = exact;
|
||||
pinnedEntries++;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root, bump);
|
||||
for (const root of roots) walk(root, pin);
|
||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||
for (const line of updated) console.log('[publish-next] ' + line);
|
||||
console.log('[publish-next] pinned ' + pinnedEntries + ' @mosaicstack/* dep entries to exact same-pipeline versions across ' + updated.length + ' manifests');
|
||||
NODE
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||
@@ -250,6 +292,71 @@ steps:
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
# #1389 post-publish guard: every freshly published manifest must carry
|
||||
# EXACT same-pipeline @mosaicstack/* dep pins (no ranges, no stable
|
||||
# fallback). A leak here fails the pipeline instead of shipping.
|
||||
node <<'GUARD'
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const registry = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const published = [];
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) {
|
||||
const m = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (m.name?.startsWith('@mosaicstack/') && !m.private) published.push(m.name);
|
||||
}
|
||||
walk(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const root of roots) walk(root);
|
||||
let failures = 0;
|
||||
for (const name of published) {
|
||||
let manifest;
|
||||
try {
|
||||
const out = execFileSync('npm', ['view', name + '@next', '--json', '--registry', registry],
|
||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
||||
const arr = JSON.parse(out);
|
||||
manifest = Array.isArray(arr) ? arr[arr.length - 1] : arr;
|
||||
} catch (e) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ': npm view failed: ' + e.message);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
for (const field of fields) {
|
||||
const deps = manifest[field];
|
||||
if (!deps || typeof deps !== 'object') continue;
|
||||
for (const [dep, range] of Object.entries(deps)) {
|
||||
if (!dep.startsWith('@mosaicstack/')) continue;
|
||||
const expected = dep === name ? manifest.version : null;
|
||||
const isExactPin = /^\d+\.\d+\.\d+-next\./.test(range);
|
||||
const samePipeline = range.endsWith('-next.' + pipelineNumber);
|
||||
if (!isExactPin) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' range "' + range + '" is not an exact -next pin (stable-leak class, #1389)');
|
||||
failures++;
|
||||
} else if (!samePipeline) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' pinned "' + range + '" but this pipeline published -next.' + pipelineNumber + ' (cross-pipeline pin)');
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures > 0) {
|
||||
console.error('[publish-next-guard] FATAL: ' + failures + ' dep-pin violation(s) — stable-dep leak into next publish (#1389)');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[publish-next-guard] OK: all ' + published.length + ' published manifests carry exact same-pipeline @mosaicstack/* dep pins');
|
||||
GUARD
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
|
||||
@@ -379,8 +379,54 @@ check_fleet_transport() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_structure_anchor_provisioning() {
|
||||
# T51 WP0b (spec §1.2a + PHASE2-MAP F7): audit the two declaration anchors.
|
||||
# Doctor runs from operator shells and CI where the launcher exports do not
|
||||
# exist, so this is an AUDIT ONLY — it never exports, writes, or fabricates
|
||||
# values for consumption. Four states (charter):
|
||||
# both present+nonempty PASS (values reported as paths only)
|
||||
# one missing/empty WARN naming the var + the launcher as authority
|
||||
# neither present INFORMATIONAL launcher-equivalent derivation,
|
||||
# explicitly non-authoritative, + launcher warning;
|
||||
# never an error by design (F7(b))
|
||||
# Severity follows the doctor's existing conventions: pass/note are quiet
|
||||
# (note unless --verbose), warn counts toward --fail-on-warn.
|
||||
local host_root="${MOSAIC_HOST_ROOT:-}" brain_home="${MOSAIC_BRAIN_HOME:-}"
|
||||
# T51P2WP0BRW B1: presence is tracked SEPARATELY from value — `${VAR:-}`
|
||||
# collapses exported-empty into genuinely-unset, which mis-filed both-empty
|
||||
# and the mixed empty/unset states as informational. Only BOTH-genuinely-
|
||||
# absent may be informational (charter state 3); any present-but-empty or
|
||||
# single-present state warns.
|
||||
local host_set=0 brain_set=0
|
||||
[[ -v MOSAIC_HOST_ROOT ]] && host_set=1
|
||||
[[ -v MOSAIC_BRAIN_HOME ]] && brain_set=1
|
||||
if [[ "$host_set" -eq 1 && "$brain_set" -eq 1 && -n "$host_root" && -n "$brain_home" ]]; then
|
||||
pass "Structure anchors provisioned: MOSAIC_HOST_ROOT=$host_root MOSAIC_BRAIN_HOME=$brain_home (paths reported only; not expanded, not consumed)"
|
||||
return
|
||||
fi
|
||||
if [[ "$host_set" -eq 0 && "$brain_set" -eq 0 ]]; then
|
||||
note "Structure anchors not provisioned in this environment. Launcher-equivalent derivation (INFORMATIONAL, NON-AUTHORITATIVE — seats receive the authoritative values from the launchers): MOSAIC_HOST_ROOT would default to the operator home; MOSAIC_BRAIN_HOME would default to the brain tree resolved at launch. Doctor does not guess values for consumption; it audits provisioning."
|
||||
note "Provision both anchors via the seat launchers (launch-seat.sh / launch-seat-claude.sh export them; see T51 spec §1.2a)."
|
||||
return
|
||||
fi
|
||||
# At least one variable is present (possibly empty), or exactly one exists:
|
||||
# every missing/empty anchor gets its own loud WARN naming the launchers.
|
||||
if [[ "$host_set" -eq 0 ]]; then
|
||||
warn "MOSAIC_HOST_ROOT is not set in this environment while MOSAIC_BRAIN_HOME is — declaration consumers fail closed without it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
elif [[ -z "$host_root" ]]; then
|
||||
warn "MOSAIC_HOST_ROOT is present but EMPTY in this environment — declaration consumers fail closed without a usable value (spec §1.2a). The seat launchers are the authoritative source."
|
||||
fi
|
||||
if [[ "$brain_set" -eq 0 ]]; then
|
||||
warn "MOSAIC_BRAIN_HOME is not set in this environment while MOSAIC_HOST_ROOT is — the projects/ mirror and brain declaration resolve from it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
elif [[ -z "$brain_home" ]]; then
|
||||
warn "MOSAIC_BRAIN_HOME is present but EMPTY in this environment — the projects/ mirror and brain declaration resolve from it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
fi
|
||||
}
|
||||
|
||||
check_fleet_transport
|
||||
|
||||
check_structure_anchor_provisioning
|
||||
|
||||
check_brain_home
|
||||
|
||||
# Legacy migration surfaces should no longer contain symlink trees.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# Covers the structure-anchor provisioning check in `mosaic-doctor` (T51 WP0b).
|
||||
#
|
||||
# Same discipline as test-brain-home-check.sh: functions are extracted from the
|
||||
# shipped script (exact header + closing brace), never copied — a test carrying
|
||||
# its own copy of the logic keeps passing after the shipped copy changes.
|
||||
#
|
||||
# Four contract states (charter T51P2WP0B-20260824):
|
||||
# 1. both present+nonempty -> pass ([OK]), no warns, no notes
|
||||
# 2a. host missing, brain set -> warn naming MOSAIC_HOST_ROOT + launchers
|
||||
# 2b. brain missing, host set -> warn naming MOSAIC_BRAIN_HOME + launchers
|
||||
# 2c. present-but-EMPTY counts as missing (warns; NEVER informational)
|
||||
# 3. neither present -> informational notes, NON-AUTHORITATIVE, never warn
|
||||
# Arms include genuinely-UNSET (env -u) forms, not only empty strings.
|
||||
# Red control: empty-vs-unset distinction removed in a mutated copy -> suite red.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname "$0")" && pwd)
|
||||
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
|
||||
|
||||
extract_function() {
|
||||
local name="$1"
|
||||
local extracted
|
||||
extracted=$(sed -n "/^${name}() {/,/^}/p" "$DOCTOR")
|
||||
[ -n "$extracted" ] || fail "could not extract ${name}() from mosaic-doctor — script reshaped?"
|
||||
printf '%s\n' "$extracted"
|
||||
}
|
||||
|
||||
for fn in check_structure_anchor_provisioning; do
|
||||
extract_function "$fn" >/dev/null
|
||||
done
|
||||
|
||||
# run_case LABEL EXPECT(ok|warn|note) [env assignments as args; -u VAR tokens for unset]
|
||||
run_case() {
|
||||
local label="$1" expect="$2"
|
||||
shift 2
|
||||
local envs=() unsets=()
|
||||
local a
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
-u:*) unsets+=("${a#-u:}") ;;
|
||||
*) envs+=("$a") ;;
|
||||
esac
|
||||
done
|
||||
local out warns notes oks
|
||||
# build the env command with proper -u flags (array expansion must not
|
||||
# glue '-u VAR' into one word)
|
||||
local cmd=(env)
|
||||
local e u
|
||||
# env(1) parses options only before the first assignment — -u flags FIRST
|
||||
for u in "${unsets[@]:-}"; do [ -n "$u" ] && cmd+=(-u "$u"); done
|
||||
for e in "${envs[@]:-}"; do [ -n "$e" ] && cmd+=("$e"); done
|
||||
cmd+=(bash -c "warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function check_structure_anchor_provisioning); check_structure_anchor_provisioning")
|
||||
out=$("${cmd[@]}" 2>&1)
|
||||
warns=$(printf '%s\n' "$out" | grep -c '^\[WARN\]' || true)
|
||||
notes=$(printf '%s\n' "$out" | grep -c '^\[NOTE\]' || true)
|
||||
oks=$(printf '%s\n' "$out" | grep -c '^\[OK\]' || true)
|
||||
if [[ "$expect" == ok && "$oks" -gt 0 && "$warns" -eq 0 && "$notes" -eq 0 ]]; then
|
||||
echo "ok - $label"
|
||||
elif [[ "$expect" == warn && "$warns" -ge 1 && "$notes" -eq 0 ]]; then
|
||||
echo "ok - $label (warned x$warns)"
|
||||
elif [[ "$expect" == note && "$notes" -gt 0 && "$warns" -eq 0 ]]; then
|
||||
echo "ok - $label (noted)"
|
||||
else
|
||||
echo "output: $out" >&2
|
||||
fail "$label: expected $expect (oks=$oks warns=$warns notes=$notes)"
|
||||
fi
|
||||
}
|
||||
|
||||
ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$ROOT"' EXIT
|
||||
HOST="$ROOT/host"
|
||||
BRAIN="$ROOT/brain"
|
||||
|
||||
# ── state 1: both present + nonempty → pass ────────────────────────────────
|
||||
run_case "both anchors present passes" ok \
|
||||
MOSAIC_HOST_ROOT="$HOST" MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
|
||||
# ── state 2a: host missing (unset), brain set → exactly one warn ───────────
|
||||
run_case "unset host root warns" warn \
|
||||
-u:MOSAIC_HOST_ROOT MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
|
||||
# ── state 2b: brain missing (unset), host set → exactly one warn ───────────
|
||||
run_case "unset brain home warns" warn \
|
||||
MOSAIC_HOST_ROOT="$HOST" -u:MOSAIC_BRAIN_HOME
|
||||
|
||||
# ── state 2c-empty: present-but-empty counts as missing ────────────────────
|
||||
run_case "empty-string host root warns (empty != set)" warn \
|
||||
MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
run_case "empty-string brain home warns (empty != set)" warn \
|
||||
MOSAIC_HOST_ROOT="$HOST" MOSAIC_BRAIN_HOME=
|
||||
|
||||
# ── state 3: neither present (genuinely unset) → notes, never warn ─────────
|
||||
run_case "both unset yields non-authoritative notes" note \
|
||||
-u:MOSAIC_HOST_ROOT -u:MOSAIC_BRAIN_HOME
|
||||
run_case "both empty-string warns (empty is present, not absent)" warn \
|
||||
MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME=
|
||||
run_case "host empty + brain unset warns" warn \
|
||||
MOSAIC_HOST_ROOT= -u:MOSAIC_BRAIN_HOME
|
||||
run_case "host unset + brain empty warns" warn \
|
||||
-u:MOSAIC_HOST_ROOT MOSAIC_BRAIN_HOME=
|
||||
|
||||
# ── red control (mutation): presence tracking removed → red ────────────────
|
||||
# Mutant regresses to the reviewed defect shape: presence derived from
|
||||
# NONEMPTINESS (the `${VAR:-}` collapse) instead of true -v tracking. Both-empty
|
||||
# then looks genuinely-absent and is mis-filed as informational; the both-empty
|
||||
# warn arm above finds no WARN and the suite reds.
|
||||
MUT="$ROOT/mosaic-doctor.mutant"
|
||||
sed 's/\[\[ -v MOSAIC_HOST_ROOT \]\] \&\& host_set=1/[[ -n "${MOSAIC_HOST_ROOT:-}" ]] \&\& host_set=1/; s/\[\[ -v MOSAIC_BRAIN_HOME \]\] \&\& brain_set=1/[[ -n "${MOSAIC_BRAIN_HOME:-}" ]] \&\& brain_set=1/' \
|
||||
"$DOCTOR" > "$MUT"
|
||||
if cmp -s "$DOCTOR" "$MUT"; then
|
||||
echo "SKIP red control (mutation anchor not found — sed pattern drifted)" >&2
|
||||
else
|
||||
mut_fn=$(sed -n "/^check_structure_anchor_provisioning() {/,/^}/p" "$MUT")
|
||||
outm=$(env MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME= bash -c \
|
||||
"warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $mut_fn; check_structure_anchor_provisioning" 2>&1)
|
||||
if printf '%s\n' "$outm" | grep -q '^\[NOTE\]'; then
|
||||
echo "ok - red control bites (mutant collapses empty into informational; shipped does not)"
|
||||
else
|
||||
fail "red control did not reproduce the regression shape (mutant output unexpected)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "structure anchor doctor check: all arms passed"
|
||||
@@ -515,12 +515,40 @@ FIXTURES="$TMP/fixtures.tsv"
|
||||
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/issues?q=a%%20b"}}\ta percent-escape in a READ is not this hook'"'"'s business\n'
|
||||
} > "$FIXTURES"
|
||||
|
||||
# Flake containment (#1380-FF, pipelines 2635/2637): a transient failure of the
|
||||
# ASSERTION TOOLING (grep rc>=2 — fork/alloc error under node pressure) is not a
|
||||
# contract drift, but the `if ! ... | grep -Fq` shape printed the identical
|
||||
# FAIL line for both, failing merge verifies on correct guard output. Classify
|
||||
# instead: retry tool errors 3x; rc=1 is the real mismatch; persistent tool
|
||||
# failure reports TOOL-ERROR (fail stays 1 — never green on infra noise — but
|
||||
# the line names the class so a re-run can be judged, not debugged).
|
||||
assert_out_contains() { # needle why
|
||||
local attempt rc1
|
||||
for attempt in 1 2 3; do
|
||||
printf '%s' "$out" | grep -Fq -- "$1" && return 0
|
||||
rc1=$?
|
||||
[ "$rc1" -eq 1 ] && return 1 # grep answered NO — real mismatch
|
||||
sleep 0.2 # rc>=2: grep itself errored — retry
|
||||
done
|
||||
printf 'TOOL-ERROR %s (assertion grep failed 3x — infra/tooling, not guard drift)\n' "$2" >&2
|
||||
return 2
|
||||
}
|
||||
|
||||
fail=0 n=0
|
||||
while IFS=$'\t' read -r want payload why remedy; do
|
||||
[ -n "${want:-}" ] || continue
|
||||
n=$((n + 1))
|
||||
out="$(printf '%s' "$payload" | "$GUARD" 2>&1)"
|
||||
got=$?
|
||||
# Flake containment (stack#1380-FF, pipeline 2635): a transient failure of
|
||||
# the ASSERTION TOOLING (grep/fork/alloc error under node pressure) is not a
|
||||
# contract drift, but this loop's `if !` shape made it indistinguishable
|
||||
# from one — the harness printed the advice-mismatch FAIL and failed a merge
|
||||
# verify on a run whose guard output was correct. Distinguish the two: an
|
||||
# assertion tool that itself fails is retried a bounded number of times, and
|
||||
# if it never succeeds the case reports a TOOL-ERROR line (fail=1 stays, so
|
||||
# the run is never green-on-infra-noise, but the line names the real class
|
||||
# and a re-run can be judged instead of debugged as a guard defect).
|
||||
if [ "$got" != "$want" ]; then
|
||||
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
|
||||
fail=1
|
||||
@@ -530,10 +558,15 @@ while IFS=$'\t' read -r want payload why remedy; do
|
||||
# now it was invisible here: the harness read the exit code and nothing else,
|
||||
# so /issues/1/labels blocking with "use issue-create.sh" passed every run for
|
||||
# six rounds. Where a fixture states the remediation it expects, assert it.
|
||||
if [ -n "${remedy:-}" ] && ! printf '%s' "$out" | grep -Fq -- "$remedy"; then
|
||||
printf 'FAIL %s (blocked, but the advice does not name %s)\n' "$why" "$remedy"
|
||||
fail=1
|
||||
continue
|
||||
if [ -n "${remedy:-}" ]; then
|
||||
assert_out_contains "$remedy" "$why"
|
||||
arcret=$?
|
||||
if [ "$arcret" -eq 1 ]; then
|
||||
printf 'FAIL %s (blocked, but the advice does not name %s)\n' "$why" "$remedy"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
[ "$arcret" -eq 0 ] || { fail=1; continue; }
|
||||
fi
|
||||
printf 'ok %s\n' "$why"
|
||||
done < "$FIXTURES"
|
||||
@@ -572,10 +605,15 @@ home_case() {
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
|
||||
printf 'FAIL %s (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
||||
fail=1
|
||||
return
|
||||
if [ -n "$needle" ]; then
|
||||
assert_out_contains "$needle" "$why"
|
||||
arcret=$?
|
||||
if [ "$arcret" -eq 1 ]; then
|
||||
printf 'FAIL %s (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
[ "$arcret" -eq 0 ] || { fail=1; return; }
|
||||
fi
|
||||
printf 'ok %s\n' "$why"
|
||||
}
|
||||
@@ -665,10 +703,15 @@ lone_case() {
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
|
||||
printf 'FAIL %s [standalone] (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
||||
fail=1
|
||||
return
|
||||
if [ -n "$needle" ]; then
|
||||
assert_out_contains "$needle" "$why"
|
||||
arcret=$?
|
||||
if [ "$arcret" -eq 1 ]; then
|
||||
printf 'FAIL %s [standalone] (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
[ "$arcret" -eq 0 ] || { fail=1; return; }
|
||||
fi
|
||||
printf 'ok %s [standalone]\n' "$why"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
renderPeerReach,
|
||||
readFleetCommsBlock,
|
||||
resolveCommsBlock,
|
||||
resolveFleetIdentity,
|
||||
resolvePeerCommand,
|
||||
renderToolsContractStatus,
|
||||
} from './comms-onboarding.js';
|
||||
@@ -61,6 +62,68 @@ describe('shared fleet roster v1 resolver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// stack#1380 verification unblock: the fleet's own roster-v2 tooling writes
|
||||
// the v1 body plus a generation fence and seat lifecycle/launch envelopes.
|
||||
// The parser tolerates exactly that envelope (validated, opaque to comms).
|
||||
const V2_ROSTER = [
|
||||
'version: 2',
|
||||
'generation: 8',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socket_name: mosaic-fleet',
|
||||
'defaults:',
|
||||
' working_directory: ~/.mosaic',
|
||||
' runtime: claude',
|
||||
'agents:',
|
||||
' - name: orch-01',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' model: opus',
|
||||
' reasoning: high',
|
||||
' lifecycle:',
|
||||
' enabled: true',
|
||||
' desired_state: running',
|
||||
' launch:',
|
||||
' yolo: false',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
it('accepts the roster-v2 envelope (generation + lifecycle/launch) on the v1 body', () => {
|
||||
const resolved = parseFleetRosterV1(V2_ROSTER, 'yaml');
|
||||
expect(resolved.tmux.socketName).toBe('mosaic-fleet');
|
||||
expect(resolved.agents[0]?.name).toBe('orch-01');
|
||||
});
|
||||
|
||||
it('rejects a non-integer generation', () => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(V2_ROSTER.replace('generation: 8', 'generation: eight'), 'yaml'),
|
||||
).toThrow(/generation must be a non-negative integer/);
|
||||
});
|
||||
|
||||
it('rejects an invalid lifecycle desired_state', () => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(
|
||||
V2_ROSTER.replace('desired_state: running', 'desired_state: paused'),
|
||||
'yaml',
|
||||
),
|
||||
).toThrow(/desired_state must be running\|stopped/);
|
||||
});
|
||||
|
||||
it('rejects unknown fields inside the lifecycle envelope', () => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(
|
||||
V2_ROSTER.replace(' enabled: true', ' enabled: true\n surprise: 1'),
|
||||
'yaml',
|
||||
),
|
||||
).toThrow(/lifecycle has unknown field/);
|
||||
});
|
||||
|
||||
it('rejects a non-boolean launch.yolo', () => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(V2_ROSTER.replace('yolo: false', 'yolo: sometimes'), 'yaml'),
|
||||
).toThrow(/launch\.yolo must be a boolean/);
|
||||
});
|
||||
|
||||
it('rejects unknown fields instead of leniently constructing a second roster view', () => {
|
||||
expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow(
|
||||
/unknown field/i,
|
||||
@@ -493,6 +556,11 @@ describe('resolvePeerCommand', () => {
|
||||
describe('readFleetCommsBlock — spawned-agent context', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
// Hermetic helper fallback (stack#1380): the resolver probes
|
||||
// $HOME/.config/mosaic when mosaicHome itself carries no helper — point
|
||||
// HOME at a sandbox parent so tests never see the real host install.
|
||||
vi.stubEnv('HOME', mkdtempSync(join(tmpdir(), 'mosaic-homeless-')));
|
||||
vi.stubEnv('MOSAIC_HOME', '');
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
@@ -501,7 +569,10 @@ describe('readFleetCommsBlock — spawned-agent context', () => {
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses the authoritative self host and global socket from the shared roster resolver', () => {
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
|
||||
@@ -564,23 +635,27 @@ describe('readFleetCommsBlock — spawned-agent context', () => {
|
||||
},
|
||||
],
|
||||
[
|
||||
'symlink',
|
||||
'symlink escaping the install home',
|
||||
() => {
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
rmSync(helper);
|
||||
writeFileSync(join(home, 'real-send.sh'), '#!/bin/sh\n');
|
||||
symlinkSync(join(home, 'real-send.sh'), helper);
|
||||
const outside = mkdtempSync(join(tmpdir(), 'mosaic-helper-outside-'));
|
||||
writeFileSync(join(outside, 'real-send.sh'), '#!/bin/sh\n', { mode: 0o755 });
|
||||
symlinkSync(join(outside, 'real-send.sh'), helper);
|
||||
},
|
||||
],
|
||||
['non-executable', () => chmodSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 0o644)],
|
||||
])('fails closed for a %s helper with deterministic repair guidance', (_case, mutate) => {
|
||||
mutate();
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).toContain('mosaic update --repair-tools');
|
||||
expect(result.error).toContain('no active context or session was rewritten');
|
||||
});
|
||||
])(
|
||||
'fails closed for a %s helper with deterministic guidance (no forbidden remedy)',
|
||||
(_case, mutate) => {
|
||||
mutate();
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).not.toContain('--repair-tools'); // stack#1380 M5a
|
||||
expect(result.error).toContain('no active context or session was rewritten');
|
||||
},
|
||||
);
|
||||
|
||||
it('does not rewrite the roster while resolving context', () => {
|
||||
const path = join(home, 'fleet', 'roster.yaml');
|
||||
@@ -603,11 +678,11 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('uses the supported repair command when installed TOOLS.md is missing', () => {
|
||||
it('names operator-verified recovery instead of a forbidden remedy when installed TOOLS.md is missing', () => {
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(status).not.toContain('--reseed');
|
||||
expect(status).toContain('authorized operator');
|
||||
expect(status).not.toContain('--repair-tools'); // stack#1380 M5a
|
||||
expect(status).not.toContain('--reseed');
|
||||
});
|
||||
|
||||
it('reports stale preserved content without rewriting it', () => {
|
||||
@@ -616,8 +691,8 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
|
||||
writeFileSync(path, stale);
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('fleet-comms-contract: 1');
|
||||
expect(status).toContain('digest-qualified backup');
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(status).toContain('authorized operator');
|
||||
expect(status).not.toContain('--repair-tools'); // stack#1380 M5a
|
||||
expect(status).toContain('active context was not rewritten');
|
||||
expect(readFileSync(path, 'utf8')).toBe(stale);
|
||||
});
|
||||
@@ -648,7 +723,7 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
|
||||
expect(renderToolsContractStatus(home)).not.toBe('');
|
||||
});
|
||||
|
||||
it('treats installed TOOLS.md symlinks as stale without following or rewriting them', () => {
|
||||
it('reads an installed TOOLS.md symlink whose validated target diverges (stack#1380 resolve-then-validate)', () => {
|
||||
const external = join(home, 'external-tools.md');
|
||||
const externalContent = '# external\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(external, externalContent);
|
||||
@@ -656,24 +731,23 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
|
||||
|
||||
const status = renderToolsContractStatus(home);
|
||||
|
||||
expect(status).toContain('unavailable');
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(status).toContain('does not byte-match');
|
||||
expect(readFileSync(external, 'utf8')).toBe(externalContent);
|
||||
});
|
||||
|
||||
it('treats source TOOLS.md symlinks as unavailable without following them', () => {
|
||||
const external = join(home, 'external-source.md');
|
||||
it('treats a source TOOLS.md symlink escaping the install home as unavailable', () => {
|
||||
const outside = mkdtempSync(join(tmpdir(), 'mosaic-source-outside-'));
|
||||
const content = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(external, content);
|
||||
writeFileSync(join(outside, 'external-source.md'), content);
|
||||
rmSync(join(home, 'defaults', 'TOOLS.md'));
|
||||
symlinkSync(external, join(home, 'defaults', 'TOOLS.md'));
|
||||
symlinkSync(join(outside, 'external-source.md'), join(home, 'defaults', 'TOOLS.md'));
|
||||
writeFileSync(join(home, 'TOOLS.md'), content);
|
||||
|
||||
const status = renderToolsContractStatus(home);
|
||||
|
||||
expect(status).toContain('source contract');
|
||||
expect(status).toContain('unavailable');
|
||||
expect(readFileSync(external, 'utf8')).toBe(content);
|
||||
expect(readFileSync(join(outside, 'external-source.md'), 'utf8')).toBe(content);
|
||||
});
|
||||
|
||||
it('accepts byte-equal bounded source and installed contracts', () => {
|
||||
@@ -730,3 +804,91 @@ describe('resolveCommsBlock — mosaic agent comms-block', () => {
|
||||
expect(result.error).toContain('requires');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFleetIdentity — stack#1380 split-home layouts', () => {
|
||||
// Brain-shaped mosaicHome (fleet state, NO tools/tmux) + framework config
|
||||
// home carrying the helper, roster unified by the framework-created symlink
|
||||
// <configHome>/fleet/roster.yaml -> <brain>/fleet/roster.yaml. This is the
|
||||
// host layout that was down; all probes are POSITIONAL per the #1380
|
||||
// verification protocol (an object arg proves nothing — M5b). HOME is
|
||||
// stubbed so the config-default fallback stays inside the sandbox.
|
||||
let brain: string;
|
||||
let configHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const parent = mkdtempSync(join(tmpdir(), 'mosaic-i1380-parent-'));
|
||||
brain = join(parent, 'brain');
|
||||
// Framework home at the stubbed DEFAULT location so the fallback derives
|
||||
// exactly as in production ($HOME/.config/mosaic), not by coincidence.
|
||||
configHome = join(parent, 'home', '.config', 'mosaic');
|
||||
vi.stubEnv('HOME', join(parent, 'home'));
|
||||
vi.stubEnv('MOSAIC_HOME', '');
|
||||
mkdirSync(join(brain, 'fleet'), { recursive: true });
|
||||
writeFileSync(join(brain, 'fleet', 'roster.yaml'), ROSTER, { mode: 0o600 });
|
||||
mkdirSync(join(configHome, 'fleet'), { recursive: true });
|
||||
symlinkSync(join(brain, 'fleet', 'roster.yaml'), join(configHome, 'fleet', 'roster.yaml'));
|
||||
mkdirSync(join(configHome, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(configHome, 'tools', 'tmux', 'agent-send.sh'), '#!/bin/sh\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
process.env['MOSAIC_BRAIN_HOME'] = brain;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(join(brain, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves a member through the roster symlink under the config home', () => {
|
||||
const result = resolveFleetIdentity(configHome, 'orchestrator', 'w-jarvis');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.identity?.member.name).toBe('orchestrator');
|
||||
expect(result.identity?.agentSendPath).toBe(join(configHome, 'tools', 'tmux', 'agent-send.sh'));
|
||||
});
|
||||
|
||||
it('resolves a member when mosaicHome is the brain (helper found under the framework home)', () => {
|
||||
const result = resolveFleetIdentity(brain, 'enhancer', 'w-jarvis');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.identity?.member.name).toBe('enhancer');
|
||||
expect(result.identity?.agentSendPath).toBe(join(configHome, 'tools', 'tmux', 'agent-send.sh'));
|
||||
});
|
||||
|
||||
it('no-name control stays a quiet no-op', () => {
|
||||
expect(resolveFleetIdentity(configHome, undefined, 'w-jarvis')).toEqual({ ok: true });
|
||||
expect(resolveFleetIdentity(brain, undefined, 'w-jarvis')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('a nonce name fails naming membership, not the symlink or the helper', () => {
|
||||
const result = resolveFleetIdentity(configHome, 'nonce-' + Date.now(), 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).not.toContain('symbolic link');
|
||||
expect(result.error).not.toContain('helper');
|
||||
expect(result.error).toContain('nonce-');
|
||||
});
|
||||
|
||||
it('a non-member failure names membership, not the symlink (protocol control)', () => {
|
||||
const result = resolveFleetIdentity(configHome, 'jarvis', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).not.toContain('symbolic link');
|
||||
expect(result.error).not.toContain('helper is unavailable');
|
||||
expect(result.error).toContain('orchestrator'); // known-member listing
|
||||
});
|
||||
|
||||
it('names every searched framework home when the helper is missing everywhere', () => {
|
||||
rmSync(join(configHome, 'tools'), { recursive: true, force: true });
|
||||
const result = resolveFleetIdentity(brain, 'orchestrator', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('agent-send.sh');
|
||||
expect(result.error).toContain(join(brain, 'tools', 'tmux', 'agent-send.sh'));
|
||||
expect(result.error).not.toContain('--repair-tools');
|
||||
});
|
||||
|
||||
it('readFleetCommsBlock composes the full contract on the split-home layout', () => {
|
||||
const result = readFleetCommsBlock(configHome, 'orchestrator', 'w-jarvis');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toContain(
|
||||
'Helper: `' + join(configHome, 'tools', 'tmux', 'agent-send.sh'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { readRegularFileSecure } from './secure-file.js';
|
||||
import { resolveBrainHome } from './brain-home.js';
|
||||
import {
|
||||
parseFleetRosterV1,
|
||||
resolveInstalledFleetRosterPath,
|
||||
@@ -260,7 +261,13 @@ context and have an authorized operator relaunch only this exact roster member w
|
||||
|
||||
function validateAgentSendHelper(path: string, mosaicHome: string): string | undefined {
|
||||
try {
|
||||
readRegularFileSecure(path, { root: mosaicHome, executable: true });
|
||||
readRegularFileSecure(path, {
|
||||
root: mosaicHome,
|
||||
executable: true,
|
||||
// The helper tree may live under the framework config home while this
|
||||
// caller's mosaicHome is the brain; both are framework-owned roots.
|
||||
symlinkTargetRoots: [resolveBrainHome(mosaicHome)],
|
||||
});
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
@@ -268,8 +275,48 @@ function validateAgentSendHelper(path: string, mosaicHome: string): string | und
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework install homes probed for tools/tmux/agent-send.sh (stack#1380 M2).
|
||||
* The helper ships with the FRAMEWORK install, which on split-home layouts is
|
||||
* the config home — not the brain (~/.mosaic carries fleet state, no tools).
|
||||
*/
|
||||
function frameworkHelperHomes(mosaicHome: string): string[] {
|
||||
const homes = [resolve(mosaicHome)];
|
||||
const envHome = process.env['MOSAIC_HOME'];
|
||||
if (envHome && envHome.trim() !== '' && resolve(envHome) !== resolve(mosaicHome)) {
|
||||
homes.push(resolve(envHome));
|
||||
}
|
||||
const configDefault = join(homedir(), '.config', 'mosaic');
|
||||
if (resolve(configDefault) !== resolve(mosaicHome)) homes.push(configDefault);
|
||||
return homes;
|
||||
}
|
||||
|
||||
function resolveAgentSendHelper(mosaicHome: string): { path: string; error?: string } {
|
||||
const homes = frameworkHelperHomes(mosaicHome);
|
||||
for (const home of homes) {
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
if (!existsSync(helper)) continue;
|
||||
const error = validateAgentSendHelper(helper, home);
|
||||
if (!error) return { path: helper };
|
||||
// Present but unsafe: surface that verdict instead of silently probing on.
|
||||
return { path: helper, error };
|
||||
}
|
||||
return {
|
||||
path: join(resolve(mosaicHome), 'tools', 'tmux', 'agent-send.sh'),
|
||||
error:
|
||||
`fleet helper agent-send.sh was not found under any framework install home ` +
|
||||
`(${homes.map((h) => join(h, 'tools', 'tmux', 'agent-send.sh')).join('; ')}). ` +
|
||||
`Verify the framework install for this host (the helper ships with the framework ` +
|
||||
`config home; the brain home carries fleet state, not tools) and have an authorized ` +
|
||||
`operator restore it if missing.`,
|
||||
};
|
||||
}
|
||||
|
||||
function helperFailureGuidance(reason: string): string {
|
||||
return `${reason}. Run \`mosaic update --repair-tools\` to restore the supported current-version helper and TOOLS contract, then retry exact-member composition; no active context or session was rewritten.`;
|
||||
// stack#1380 M5a: `mosaic update --repair-tools` is a forbidden remedy on the
|
||||
// affected estate (and wrong for a layout/missing-helper failure). Name the
|
||||
// actual recovery shape instead.
|
||||
return `${reason}. Verify the framework install provides tools/tmux/agent-send.sh under the framework config home and that the roster resolves (split-home layouts symlink the roster into the brain); contact the operator if it persists; no active context or session was rewritten.`;
|
||||
}
|
||||
|
||||
export function resolveFleetIdentity(
|
||||
@@ -278,9 +325,15 @@ export function resolveFleetIdentity(
|
||||
localHost: string = shortHostname(),
|
||||
): FleetIdentityResult {
|
||||
if (!requestedName) return { ok: true };
|
||||
const agentSendPath = join(mosaicHome, 'tools', 'tmux', 'agent-send.sh');
|
||||
const helperError = validateAgentSendHelper(agentSendPath, mosaicHome);
|
||||
if (helperError) return { ok: false, error: helperFailureGuidance(helperError) };
|
||||
const helper = resolveAgentSendHelper(mosaicHome);
|
||||
if (helper.error) return { ok: false, error: helperFailureGuidance(helper.error) };
|
||||
const agentSendPath = helper.path;
|
||||
|
||||
// Split-home layouts unify the roster by symlinking
|
||||
// <configHome>/fleet/roster.yaml -> <brain>/fleet/roster.yaml. The secure
|
||||
// read resolves that framework-created symlink when the brain is a
|
||||
// sanctioned target root (stack#1380 M1).
|
||||
const rosterSymlinkRoots = [resolveBrainHome(mosaicHome)];
|
||||
|
||||
let rosterPath: string;
|
||||
try {
|
||||
@@ -301,7 +354,10 @@ export function resolveFleetIdentity(
|
||||
let roster: FleetRoster;
|
||||
try {
|
||||
roster = parseFleetRosterV1(
|
||||
readRegularFileSecure(rosterPath, { root: mosaicHome }).content.toString('utf8'),
|
||||
readRegularFileSecure(rosterPath, {
|
||||
root: mosaicHome,
|
||||
symlinkTargetRoots: rosterSymlinkRoots,
|
||||
}).content.toString('utf8'),
|
||||
rosterPath.endsWith('.json') ? 'json' : 'yaml',
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -399,7 +455,9 @@ function boundedContractDigest(
|
||||
}
|
||||
|
||||
function replacementGuidance(): string {
|
||||
return `Run \`mosaic update --repair-tools\` to make a digest-qualified backup and restore the supported current-version TOOLS contract, then have an authorized operator explicitly relaunch the exact roster member. The active context was not rewritten.`;
|
||||
// stack#1380 M5a: never recommend the forbidden --repair-tools remedy from
|
||||
// error text; name the operator-verified recovery shape instead.
|
||||
return `Verify the installed TOOLS contract against the framework source with an authorized operator (the installed file must byte-match the supported current version) and have the operator explicitly relaunch the exact roster member. The active context was not rewritten.`;
|
||||
}
|
||||
|
||||
/** Detect preserved installed TOOLS.md drift without changing it. */
|
||||
|
||||
@@ -7,6 +7,7 @@ import { canonicalizeRoleClass } from '../commands/fleet-personas.js';
|
||||
interface RawFleetRoster {
|
||||
version?: unknown;
|
||||
transport?: unknown;
|
||||
generation?: unknown;
|
||||
tmux?: {
|
||||
socket_name?: unknown;
|
||||
socketName?: unknown;
|
||||
@@ -41,6 +42,10 @@ interface RawFleetRoster {
|
||||
resetBetweenTasks?: unknown;
|
||||
kickstart_template?: unknown;
|
||||
kickstartTemplate?: unknown;
|
||||
model?: unknown;
|
||||
reasoning?: unknown;
|
||||
lifecycle?: { enabled?: unknown; desired_state?: unknown };
|
||||
launch?: { yolo?: unknown };
|
||||
}>;
|
||||
connector?: {
|
||||
kind?: unknown;
|
||||
@@ -216,7 +221,17 @@ function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
|
||||
'runtimes',
|
||||
'agents',
|
||||
'connector',
|
||||
// stack#1380 verification unblock: the fleet's own roster-v2 mutation
|
||||
// tooling writes a `generation` fence on the same v1 body. Tolerated here
|
||||
// as an opaque non-negative integer; comms semantics are unchanged.
|
||||
'generation',
|
||||
]);
|
||||
if (
|
||||
raw.generation !== undefined &&
|
||||
(typeof raw.generation !== 'number' || !Number.isInteger(raw.generation) || raw.generation < 0)
|
||||
) {
|
||||
throw new Error('Fleet roster generation must be a non-negative integer.');
|
||||
}
|
||||
if (raw.tmux !== undefined) {
|
||||
assertObject(raw.tmux, 'Fleet roster tmux');
|
||||
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
|
||||
@@ -231,6 +246,8 @@ function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
|
||||
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
// stack#1380 verification unblock: roster-v2 default runtime hint.
|
||||
'runtime',
|
||||
]);
|
||||
}
|
||||
if (raw.runtimes !== undefined) {
|
||||
@@ -243,7 +260,9 @@ function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (raw.version !== 1) throw new Error('Fleet roster version must be 1.');
|
||||
if (raw.version !== 1 && raw.version !== 2) {
|
||||
throw new Error('Fleet roster version must be 1 or 2.');
|
||||
}
|
||||
if (raw.transport !== 'tmux') throw new Error('Fleet roster transport must be "tmux".');
|
||||
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
|
||||
throw new Error('Fleet roster must define at least one agent.');
|
||||
@@ -318,7 +337,52 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
||||
'resetBetweenTasks',
|
||||
'kickstart_template',
|
||||
'kickstartTemplate',
|
||||
// stack#1380 verification unblock: roster-v2 envelope fields written by
|
||||
// the fleet's own mutation tooling. Validated, then opaque to comms.
|
||||
'model',
|
||||
'reasoning',
|
||||
'lifecycle',
|
||||
'launch',
|
||||
]);
|
||||
if (raw.model !== undefined && typeof raw.model !== 'string') {
|
||||
throw new Error('Fleet roster agent model must be a string.');
|
||||
}
|
||||
if (raw.reasoning !== undefined && typeof raw.reasoning !== 'string') {
|
||||
throw new Error('Fleet roster agent reasoning must be a string.');
|
||||
}
|
||||
const lifecycle = raw.lifecycle as { enabled?: unknown; desired_state?: unknown } | undefined;
|
||||
if (lifecycle !== undefined) {
|
||||
if (typeof lifecycle !== 'object' || lifecycle === null) {
|
||||
throw new Error('Fleet roster agent lifecycle must be an object.');
|
||||
}
|
||||
const lifecycleKeys = Object.keys(lifecycle);
|
||||
if (!lifecycleKeys.every((key) => key === 'enabled' || key === 'desired_state')) {
|
||||
throw new Error('Fleet roster agent lifecycle has unknown field(s).');
|
||||
}
|
||||
if (lifecycle.enabled !== undefined && typeof lifecycle.enabled !== 'boolean') {
|
||||
throw new Error('Fleet roster agent lifecycle.enabled must be a boolean.');
|
||||
}
|
||||
if (
|
||||
lifecycle.desired_state !== undefined &&
|
||||
(typeof lifecycle.desired_state !== 'string' ||
|
||||
!['running', 'stopped'].includes(lifecycle.desired_state))
|
||||
) {
|
||||
throw new Error('Fleet roster agent lifecycle.desired_state must be running|stopped.');
|
||||
}
|
||||
}
|
||||
const launch = raw.launch as { yolo?: unknown } | undefined;
|
||||
if (launch !== undefined) {
|
||||
if (typeof launch !== 'object' || launch === null) {
|
||||
throw new Error('Fleet roster agent launch must be an object.');
|
||||
}
|
||||
const launchKeys = Object.keys(launch);
|
||||
if (!launchKeys.every((key) => key === 'yolo')) {
|
||||
throw new Error('Fleet roster agent launch has unknown field(s).');
|
||||
}
|
||||
if (launch.yolo !== undefined && typeof launch.yolo !== 'boolean') {
|
||||
throw new Error('Fleet roster agent launch.yolo must be a boolean.');
|
||||
}
|
||||
}
|
||||
const name = stringValue(raw.name, '', 'Fleet roster agent name');
|
||||
const runtime = stringValue(
|
||||
raw.runtime,
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
type PathLike,
|
||||
} from 'node:fs';
|
||||
import type * as NodeFs from 'node:fs';
|
||||
import type { Stats } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
interface FilesystemRaceState {
|
||||
afterLstat?: (path: string) => void;
|
||||
afterOpen?: (path: string) => void;
|
||||
afterStat?: (path: string, stats: Stats) => Stats;
|
||||
}
|
||||
|
||||
const filesystemRaceState = vi.hoisted<FilesystemRaceState>(() => ({}));
|
||||
@@ -29,6 +31,10 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
filesystemRaceState.afterLstat?.(String(path));
|
||||
return result;
|
||||
},
|
||||
statSync: (path: PathLike) => {
|
||||
const result = actual.statSync(path);
|
||||
return filesystemRaceState.afterStat?.(String(path), result) ?? result;
|
||||
},
|
||||
openSync: (path: PathLike, flags: string | number, mode?: number) => {
|
||||
const fd = actual.openSync(path, flags, mode);
|
||||
filesystemRaceState.afterOpen?.(String(path));
|
||||
@@ -46,11 +52,13 @@ describe('secure file reads', () => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-secure-file-'));
|
||||
filesystemRaceState.afterLstat = undefined;
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
filesystemRaceState.afterStat = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
filesystemRaceState.afterLstat = undefined;
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
filesystemRaceState.afterStat = undefined;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -60,27 +68,89 @@ describe('secure file reads', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a symlink in a file ancestor', () => {
|
||||
// stack#1380: the guard resolves symlinks and validates the resolved target
|
||||
// instead of refusing any symlink component.
|
||||
it('permits a symlink ancestor whose resolved target is inside the root', () => {
|
||||
const external = join(root, 'external');
|
||||
mkdirSync(external);
|
||||
writeFileSync(join(external, 'file'), 'external\n');
|
||||
symlinkSync(external, join(root, 'linked'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'linked', 'file'), { root })).toThrow(
|
||||
'path ancestor is a symbolic link',
|
||||
);
|
||||
const snapshot = readRegularFileSecure(join(root, 'linked', 'file'), { root });
|
||||
expect(snapshot.content.toString('utf8')).toBe('external\n');
|
||||
});
|
||||
|
||||
it('rejects a symlink target', () => {
|
||||
const external = join(root, 'external');
|
||||
it('permits a symlinked file whose resolved target is inside the root', () => {
|
||||
const external = join(root, 'external-file');
|
||||
writeFileSync(external, 'external\n');
|
||||
symlinkSync(external, join(root, 'linked-file'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'linked-file'), { root })).toThrow(
|
||||
'file is a symbolic link',
|
||||
const snapshot = readRegularFileSecure(join(root, 'linked-file'), { root });
|
||||
expect(snapshot.content.toString('utf8')).toBe('external\n');
|
||||
});
|
||||
|
||||
it('permits a symlink resolving into an additional sanctioned root (split-home roster shape)', () => {
|
||||
const brain = `${root}-brain`;
|
||||
mkdirSync(join(brain, 'fleet'), { recursive: true });
|
||||
writeFileSync(join(brain, 'fleet', 'roster.yaml'), 'roster\n', { mode: 0o600 });
|
||||
mkdirSync(join(root, 'fleet'));
|
||||
symlinkSync(join(brain, 'fleet', 'roster.yaml'), join(root, 'fleet', 'roster.yaml'));
|
||||
|
||||
const snapshot = readRegularFileSecure(join(root, 'fleet', 'roster.yaml'), {
|
||||
root,
|
||||
symlinkTargetRoots: [brain],
|
||||
});
|
||||
expect(snapshot.content.toString('utf8')).toBe('roster\n');
|
||||
});
|
||||
|
||||
it('refuses a symlink whose resolved target escapes every sanctioned root', () => {
|
||||
const outside = mkdtempSync(join(tmpdir(), 'mosaic-secure-outside-'));
|
||||
try {
|
||||
mkdirSync(join(root, 'fleet'), { recursive: true });
|
||||
writeFileSync(join(outside, 'roster.yaml'), 'escaped\n', { mode: 0o600 });
|
||||
symlinkSync(join(outside, 'roster.yaml'), join(root, 'fleet', 'roster.yaml'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'fleet', 'roster.yaml'), { root })).toThrow(
|
||||
/symlink target escapes managed roots/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a group-writable symlink target', () => {
|
||||
const loose = join(root, 'loose');
|
||||
mkdirSync(loose);
|
||||
chmodSync(loose, 0o770); // group-writable bit survives umask via explicit chmod
|
||||
writeFileSync(join(loose, 'file'), 'loose\n');
|
||||
symlinkSync(loose, join(root, 'linked-loose'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'linked-loose', 'file'), { root })).toThrow(
|
||||
/group- or world-writable/,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a symlink target owned by another user', () => {
|
||||
const external = join(root, 'foreign');
|
||||
mkdirSync(external);
|
||||
writeFileSync(join(external, 'file'), 'foreign\n');
|
||||
symlinkSync(external, join(root, 'linked-foreign'));
|
||||
|
||||
filesystemRaceState.afterStat = (path, stats): Stats => {
|
||||
if (resolve(path) === resolve(external)) {
|
||||
return { ...stats, uid: stats.uid + 4242 } as Stats;
|
||||
}
|
||||
return stats;
|
||||
};
|
||||
try {
|
||||
expect(() => readRegularFileSecure(join(root, 'linked-foreign', 'file'), { root })).toThrow(
|
||||
/not owned by the current user/,
|
||||
);
|
||||
} finally {
|
||||
filesystemRaceState.afterStat = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps ancestor traversal bound when an opened directory is substituted', () => {
|
||||
const tools = join(root, 'tools');
|
||||
const displacedTools = join(root, 'tools.displaced');
|
||||
|
||||
@@ -7,14 +7,25 @@ import {
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { platform } from 'node:os';
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export interface SecureFileReadOptions {
|
||||
root: string;
|
||||
maxBytes?: number;
|
||||
executable?: boolean;
|
||||
/**
|
||||
* Additional roots a symlink component may resolve into (stack#1380).
|
||||
* Default: only the managed root itself. Every symlink hop is validated —
|
||||
* containment under the root or one of these roots, current-user ownership,
|
||||
* no group/world-writable mode — and refusal stays the default for anything
|
||||
* else. Callers that operate the split-home layout pass the brain home so
|
||||
* the framework-created roster symlink resolves.
|
||||
*/
|
||||
symlinkTargetRoots?: string[];
|
||||
}
|
||||
|
||||
export interface SecureFileSnapshot {
|
||||
@@ -88,7 +99,57 @@ function openDirectoryChain(absoluteDirectory: string): { fd: number; descriptor
|
||||
}
|
||||
}
|
||||
|
||||
function openFileBeneathRoot(root: string, target: string): { fd: number; descriptors: number[] } {
|
||||
function containedUnder(root: string, target: string): boolean {
|
||||
const rel = relative(resolve(root), resolve(target));
|
||||
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) && rel !== '';
|
||||
}
|
||||
|
||||
const MAX_SYMLINK_HOPS = 40;
|
||||
|
||||
/**
|
||||
* Resolve every symlink on `lexical` component-wise, validating each hop
|
||||
* (stack#1380 resolve-then-validate): the hop target must stay under one of
|
||||
* the sanctioned roots, must be owned by the current user (or root), and must
|
||||
* not be group- or world-writable. Returns a symlink-free absolute path.
|
||||
*/
|
||||
function resolveRealPath(lexical: string, sanctionedRoots: string[]): string {
|
||||
const hopTargets: string[] = [];
|
||||
let current: string = sep;
|
||||
for (const piece of resolve(lexical).split(sep).filter(Boolean)) {
|
||||
current = resolve(current, piece);
|
||||
for (let hops = 0; lstatSync(current).isSymbolicLink(); ) {
|
||||
if (++hops > MAX_SYMLINK_HOPS) {
|
||||
throw new Error(`symlink chain exceeds ${MAX_SYMLINK_HOPS} hops: ${lexical}`);
|
||||
}
|
||||
const linkTarget = readlinkSync(current);
|
||||
const absolute = resolve(dirname(current), linkTarget);
|
||||
if (!sanctionedRoots.some((root) => containedUnder(root, absolute))) {
|
||||
throw new Error(
|
||||
`symlink target escapes managed roots [${sanctionedRoots.join(', ')}]: ${absolute}`,
|
||||
);
|
||||
}
|
||||
hopTargets.push(absolute);
|
||||
current = absolute;
|
||||
}
|
||||
}
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
||||
for (const hop of hopTargets) {
|
||||
const stat = statSync(hop);
|
||||
if (stat.uid !== uid && stat.uid !== 0) {
|
||||
throw new Error(`symlink target is not owned by the current user: ${hop}`);
|
||||
}
|
||||
if (stat.mode & 0o022) {
|
||||
throw new Error(`symlink target is group- or world-writable: ${hop}`);
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function openFileBeneathRoot(
|
||||
root: string,
|
||||
target: string,
|
||||
symlinkTargetRoots: string[] = [],
|
||||
): { fd: number; descriptors: number[] } {
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalTarget = resolve(target);
|
||||
assertCanonicalContainment(canonicalRoot, canonicalTarget);
|
||||
@@ -96,39 +157,52 @@ function openFileBeneathRoot(root: string, target: string): { fd: number; descri
|
||||
const fileName = components.pop();
|
||||
if (fileName === undefined) throw new Error('managed file path names the managed root');
|
||||
|
||||
const rootChain = openDirectoryChain(canonicalRoot);
|
||||
// stack#1380: resolve-then-validate. The lexical path must name the managed
|
||||
// root (above); symlink components are then resolved hop-by-hop under the
|
||||
// sanctioned roots (validated per hop), and the descriptor traversal walks
|
||||
// the symlink-free real path — keeping the O_NOFOLLOW chain as the race
|
||||
// guard for anything substituted after resolution.
|
||||
let realRoot: string;
|
||||
try {
|
||||
realRoot = resolveRealPath(canonicalRoot, [canonicalRoot]);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError(
|
||||
'secure descriptor traversal failed: symbolic link, unavailable, or not a directory',
|
||||
error,
|
||||
);
|
||||
}
|
||||
const sanctioned = [realRoot, ...symlinkTargetRoots.map((extra) => resolve(extra))];
|
||||
let realTarget: string;
|
||||
try {
|
||||
realTarget = resolveRealPath(canonicalTarget, sanctioned);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !('code' in error)) throw error;
|
||||
throw secureFilesystemError(
|
||||
'secure descriptor traversal failed: symbolic link, unavailable, or not a directory',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (!sanctioned.some((sr) => containedUnder(sr, realTarget) || resolve(sr) === realTarget)) {
|
||||
throw new Error(
|
||||
`resolved path escapes managed roots [${sanctioned.join(', ')}]: ${realTarget}`,
|
||||
);
|
||||
}
|
||||
|
||||
const chain = openDirectoryChain(dirname(realTarget));
|
||||
try {
|
||||
let parentFd = rootChain.fd;
|
||||
for (const component of components) {
|
||||
try {
|
||||
parentFd = openSync(
|
||||
procDescriptorPath(parentFd, component),
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError(
|
||||
'path ancestor is a symbolic link, unavailable, or not a directory',
|
||||
error,
|
||||
);
|
||||
}
|
||||
rootChain.descriptors.push(parentFd);
|
||||
if (!fstatSync(parentFd).isDirectory()) {
|
||||
throw new Error('path ancestor is a symbolic link or not a directory');
|
||||
}
|
||||
}
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(
|
||||
procDescriptorPath(parentFd, fileName),
|
||||
procDescriptorPath(chain.fd, basename(realTarget)),
|
||||
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError('file is a symbolic link or unavailable', error);
|
||||
}
|
||||
rootChain.descriptors.push(fd);
|
||||
return { fd, descriptors: rootChain.descriptors };
|
||||
chain.descriptors.push(fd);
|
||||
return { fd, descriptors: chain.descriptors };
|
||||
} catch (error) {
|
||||
closeDescriptors(rootChain.descriptors);
|
||||
closeDescriptors(chain.descriptors);
|
||||
if (error instanceof Error) throw error;
|
||||
throw new Error('secure managed file open failed');
|
||||
}
|
||||
@@ -203,7 +277,7 @@ export function readRegularFileSecure(
|
||||
path: string,
|
||||
options: SecureFileReadOptions,
|
||||
): SecureFileSnapshot {
|
||||
const openedFile = openFileBeneathRoot(options.root, path);
|
||||
const openedFile = openFileBeneathRoot(options.root, path, options.symlinkTargetRoots ?? []);
|
||||
try {
|
||||
const opened = fstatSync(openedFile.fd);
|
||||
if (!opened.isFile()) throw new Error('managed file is not a regular file');
|
||||
|
||||
@@ -168,7 +168,9 @@ describe('repairFleetCommsTools', () => {
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('symbolic link');
|
||||
// stack#1380: resolve-then-validate — an escaping symlink is still
|
||||
// refused, with the new escape diagnostic.
|
||||
expect(result.reason).toContain('symlink target escapes managed roots');
|
||||
expect(readFileSync(target, 'utf8')).toBe('do not touch\n');
|
||||
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
@@ -222,7 +224,10 @@ describe('repairFleetCommsTools', () => {
|
||||
const result = repairFleetCommsTools(framework, targetHome);
|
||||
|
||||
expect(result, testCase.name).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason, testCase.name).toContain('symbolic link');
|
||||
// stack#1380: escaping ancestor symlinks stay refused. The home case is
|
||||
// caught by the managed-root guard ('is a symbolic link'); deeper
|
||||
// components by resolve-then-validate ('symlink target escapes').
|
||||
expect(result.reason, testCase.name).toMatch(/symbolic link|symlink target escapes/);
|
||||
expect(readdirSync(external), testCase.name).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user