Co-authored-by: coder3 <[email protected]>
This commit was merged in pull request #1195.
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
# PRD: Mosaic Stack v0.1.0
|
||||
|
||||
## Current addendum: #1194 — Installed framework-tool drift detection
|
||||
|
||||
- Compare the framework tools shipped with the executing Mosaic package against the deployed `$MOSAIC_HOME/tools` tree by content hash.
|
||||
- Treat every shipped `tools/**` file as framework-owned/required according to `framework-manifest.txt`, while excluding the explicit operator-owned credential carve-out and preserving installed-only operator/unknown files.
|
||||
- Distinguish and count `IN_SYNC`, `STALE`, `NOT_INSTALLED`, and installed-only classifications; fail non-zero when shipped tools are stale or absent and refuse self-comparison that would make drift unobservable.
|
||||
- Surface the observational check through `mosaic doctor`; do not refresh files, restart seats, or mutate live tooling.
|
||||
- Document identity/messaging/gate behavior changes in the current stale set, the reviewed quiet-window keep-mode refresh command, and post-refresh probes against the installed path.
|
||||
- Prove by construction that a stale and missing deployed tool are detected; that regression must fail before this checker exists.
|
||||
|
||||
## Metadata
|
||||
|
||||
- **Owner:** Jason Woltje
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# #1194 — Installed framework-tool drift detection and refresh analysis
|
||||
|
||||
## Decision
|
||||
|
||||
The reported queue-guard source defect was already fixed on `main` by `58b971ab`; the live failure came from a stale `~/.config/mosaic/tools/git/ci-queue-wait.sh`. The durable fix is therefore a detector, not a duplicate queue-guard patch.
|
||||
|
||||
`mosaic doctor` now compares the framework tools bundled with the executing Mosaic package against the deployed tools tree. Doctor is the selected visibility boundary because it is observational and operator-invoked: unlike session start, it does not add a repository/network scan to every seat launch, and it cannot silently replace identity or messaging tools while seats are active. It reports drift without changing files. `--fail-on-warn` converts detected drift into a non-zero doctor result.
|
||||
|
||||
## Classification
|
||||
|
||||
The existing `framework-manifest.txt` is authoritative. The detector invokes the canonical shared `tools/_lib/manifest.sh classify` implementation over the complete source census and refuses missing, unreadable, malformed, incomplete, or zero-framework ownership output. Policy is therefore read rather than duplicated:
|
||||
|
||||
- Current policy classifies source files under `tools/**` as framework-owned and required in the deployed tools tree.
|
||||
- Current policy explicitly classifies `tools/_lib/credentials.json` operator-owned and excludes it from byte comparison; future policy changes take effect without a detector edit.
|
||||
- A file present only in the deployed tools tree is operator-owned/unknown by the manifest's fail-safe default. The detector reports it as `INSTALLED_ONLY operator-or-unknown` under `--verbose` but does not fail or delete it.
|
||||
- Empty/partial source traversal, unreadable directories/files, symlinked census entries, root aliases, and descendant source aliases all return `CANNOT_ASSERT` rather than manufacturing agreement.
|
||||
|
||||
This means `NOT_INSTALLED` is not suppressed by filename guesses such as “test” or “README”: if it ships below source `tools/**`, the installer contract says it should be installed. Source-only implementation files outside `tools/**` are outside this detector population by construction.
|
||||
|
||||
## Current host analysis (observation only; no refresh performed)
|
||||
|
||||
A direct source-vs-installed census showed broad drift, including identity and messaging behavior:
|
||||
|
||||
- Identity/provider operations: stale `git/detect-platform.sh`, `issue-comment.sh`, `issue-create.sh`, `issue-close.sh`, `issue-view.sh`, `pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `pr-metadata.sh`; missing `pr-edit.sh` and several identity/read-back regression tools.
|
||||
- Messaging/session: stale `tmux/agent-send.sh`, `tmux/send-message.sh`, their regressions, and `fleet/start-agent-session.sh`.
|
||||
- Gate enforcement: stale `git/ci-queue-wait.sh`; missing the queue tri-state/process-level suites and terminal-green verifier.
|
||||
- Lease/QA behavior: stale lease-broker launch/mutation/receipt tools and QA hooks.
|
||||
|
||||
Counts vary with source head and installed local/operator files; the detector prints measured counts every run rather than baking this snapshot into policy.
|
||||
|
||||
## Reviewed refresh command — analyse only, do not run during active seats
|
||||
|
||||
Use the package/release updater's manifest-driven keep-mode sync during a quiet maintenance window:
|
||||
|
||||
```bash
|
||||
MOSAIC_SYNC_ONLY=1 \
|
||||
MOSAIC_INSTALL_MODE=keep \
|
||||
MOSAIC_HOME="$HOME/.config/mosaic" \
|
||||
bash /path/to/reviewed/@mosaicstack/mosaic/framework/install.sh
|
||||
```
|
||||
|
||||
For the globally installed package, resolve the reviewed installer rather than guessing its path:
|
||||
|
||||
```bash
|
||||
PACKAGE_ROOT="$(dirname "$(node -p "require.resolve('@mosaicstack/mosaic/package.json')")")"
|
||||
MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_HOME="$HOME/.config/mosaic" \
|
||||
bash "$PACKAGE_ROOT/framework/install.sh"
|
||||
```
|
||||
|
||||
Do not run this while agent seats are active: the stale set includes identity selection, provider mutation, messaging, queue/merge guards, lease enforcement, and session launch. Syncing those files in place can change behavior between a seat's preflight and mutation.
|
||||
|
||||
## Post-refresh verification
|
||||
|
||||
1. Run `mosaic doctor --fail-on-warn`; require `stale=0 not-installed=0` from the framework drift summary (other unrelated doctor warnings must also be adjudicated).
|
||||
2. Re-run the constructed process-level queue probes against the **installed path**, not the source checkout. Use the source suite while overriding its subject path in a reviewed scratch copy, or reproduce these exact observations:
|
||||
- pending provider payload: guard must print `state=pending`, print the pending context, wait, and exit non-zero/timeout — never return immediately with rc 0;
|
||||
- malformed payload: guard must print `state=malformed` and exit non-zero;
|
||||
- unsupported but valid status vocabulary: guard must print `state=unknown` and exit non-zero.
|
||||
3. Run provider author read-back for one deliberately low-risk wrapper operation before resuming fleet mutation work; wrapper self-report is not identity evidence.
|
||||
4. Relaunch seats only after the quiet-window verification, because existing processes retain loaded environment/context.
|
||||
|
||||
## Probe evidence
|
||||
|
||||
The detector regression constructs a stale installed tool plus a missing shipped tool and observes rc 1 with distinct `STALE` and `NOT_INSTALLED` lines. That case would pass or be invisible before this change because no installed-vs-shipped comparison existed. Additional review-red controls prove:
|
||||
|
||||
- empty and unreadable source censuses return `CANNOT_ASSERT` (they returned clean rc 0 at the first PR head);
|
||||
- deleting the manifest returns `CANNOT_ASSERT`, while changing manifest ownership changes the verdict through the canonical resolver (the first head never opened the manifest);
|
||||
- root and descendant symlink/source aliases cannot return clean (the first head returned clean for a source-backed installed subtree);
|
||||
- a checker hung during doctor is terminated by a bounded watchdog, emits `CANNOT_ASSERT`, and doctor reaches its final warnings line (the first head hung and suppressed the remaining audit).
|
||||
|
||||
Controls retain byte-identical success, exact credential carve-out behavior, and installed-only preservation.
|
||||
@@ -69,7 +69,7 @@ _manifest_glob_to_ere() {
|
||||
out="$out.*"
|
||||
fi
|
||||
else
|
||||
out="$out[^/]*"
|
||||
out="${out}[^/]*"
|
||||
fi
|
||||
else
|
||||
case "$c" in
|
||||
@@ -87,7 +87,8 @@ _manifest_compile_one() {
|
||||
local norm; norm="$(_manifest_norm "$1")"
|
||||
[[ -n "$norm" ]] || return 0
|
||||
if [[ "$norm" == *"*"* ]]; then
|
||||
local re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||
local re
|
||||
re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||
if [[ "$2" == F ]]; then
|
||||
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
|
||||
else
|
||||
@@ -183,7 +184,10 @@ _mo_matches() {
|
||||
for (( i = 0; i < n; i++ )); do
|
||||
if [[ "${_MO_KIND[i]}" == exact ]]; then
|
||||
pat="${_MO_EXACT[i]}"
|
||||
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
||||
# Operator exact entries are file carve-outs, not implicit directory
|
||||
# prefixes. Subtree ownership must be declared explicitly as `dir/**`;
|
||||
# otherwise one bare directory entry can hide all drift beneath it.
|
||||
[[ "$path" == "$pat" ]] && return 0
|
||||
else
|
||||
re="${_MO_RE[i]}"
|
||||
[[ "$path" =~ $re ]] && return 0
|
||||
|
||||
@@ -153,6 +153,38 @@ warn_if_symlink_tree_present() {
|
||||
|
||||
echo "[mosaic-doctor] Mosaic home: $MOSAIC_HOME"
|
||||
|
||||
# Compare the framework tools that this CLI/package ships with the deployed
|
||||
# ~/.config copy that direct wrappers and systemd units actually execute. Doctor
|
||||
# is the right boundary: observational, operator-invoked, and already designed
|
||||
# to report drift without mutating live tooling or restarting active seats.
|
||||
framework_drift_checker="$(cd -- "$(dirname -- "$0")/../quality/scripts" && pwd)/framework-drift-check.py"
|
||||
if [[ -f "$framework_drift_checker" ]]; then
|
||||
echo "[mosaic-doctor] Checking installed framework-tool drift..."
|
||||
drift_timeout="${MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC:-15}"
|
||||
if ! [[ "$drift_timeout" =~ ^[1-9][0-9]*$ ]]; then
|
||||
warn "Invalid MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC='$drift_timeout' (expected positive integer); using 15s"
|
||||
drift_timeout=15
|
||||
fi
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
set +e
|
||||
timeout -s TERM -k 2 "${drift_timeout}s" \
|
||||
python3 "$framework_drift_checker" --installed-root "$MOSAIC_HOME/tools"
|
||||
drift_rc=$?
|
||||
set -e
|
||||
if [[ "$drift_rc" -eq 0 ]]; then
|
||||
pass "Installed framework tools match shipped source"
|
||||
elif [[ "$drift_rc" -eq 124 || "$drift_rc" -eq 137 || "$drift_rc" -eq 143 ]]; then
|
||||
warn "CANNOT_ASSERT framework drift checker timed out after ${drift_timeout}s; continuing remaining doctor checks"
|
||||
else
|
||||
warn "Installed framework-tool drift detected (checker exit $drift_rc; no files changed)"
|
||||
fi
|
||||
else
|
||||
warn "CANNOT_ASSERT timeout utility unavailable; refusing unbounded framework drift check and continuing remaining doctor checks"
|
||||
fi
|
||||
else
|
||||
warn "Framework drift checker is absent from the shipped tools tree"
|
||||
fi
|
||||
|
||||
# Canonical Mosaic checks
|
||||
expect_file "$MOSAIC_HOME/STANDARDS.md"
|
||||
expect_file "$MOSAIC_HOME/USER.md"
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed comparison of deployed Mosaic tools to manifest-owned shipped tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
value = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
value.update(chunk)
|
||||
return value.hexdigest()
|
||||
|
||||
|
||||
def default_source_tools() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def normalize_source(path: Path) -> Path:
|
||||
candidate = path.resolve()
|
||||
return candidate / "tools" if (candidate / "tools").is_dir() else candidate
|
||||
|
||||
|
||||
def assert_traversable_directory(path: Path) -> None:
|
||||
mode = stat.S_IMODE(path.stat(follow_symlinks=False).st_mode)
|
||||
# At least one principal class must have both read and search. This catches
|
||||
# mode-000 even for privileged reviewers for whom os.access() would lie.
|
||||
if not any(mode & read and mode & execute for read, execute in ((0o400, 0o100), (0o040, 0o010), (0o004, 0o001))):
|
||||
raise PermissionError(f"directory has no readable/searchable mode: {path}")
|
||||
|
||||
|
||||
def census(root: Path, *, reject_symlinks: bool) -> dict[str, Path]:
|
||||
result: dict[str, Path] = {}
|
||||
|
||||
def onerror(error: OSError) -> None:
|
||||
raise error
|
||||
|
||||
for current, directories, filenames in os.walk(root, topdown=True, followlinks=False, onerror=onerror):
|
||||
current_path = Path(current)
|
||||
assert_traversable_directory(current_path)
|
||||
for name in directories:
|
||||
entry = current_path / name
|
||||
if entry.is_symlink() and reject_symlinks:
|
||||
# A source symlink makes the shipped census incomplete. Deployed
|
||||
# aliases are assessed later only when they occupy a required
|
||||
# framework path; installed-only aliases remain operator state.
|
||||
raise OSError(f"symlinked directory is not an independent census entry: {entry}")
|
||||
for name in filenames:
|
||||
entry = current_path / name
|
||||
if entry.is_symlink():
|
||||
if reject_symlinks:
|
||||
raise OSError(f"symlinked file is not an independent census entry: {entry}")
|
||||
result[entry.relative_to(root).as_posix()] = entry
|
||||
continue
|
||||
mode = entry.stat(follow_symlinks=False).st_mode
|
||||
if not stat.S_ISREG(mode):
|
||||
raise OSError(f"non-regular census entry: {entry}")
|
||||
if stat.S_IMODE(mode) & 0o444 == 0:
|
||||
raise PermissionError(f"file has no readable mode: {entry}")
|
||||
result[entry.relative_to(root).as_posix()] = entry
|
||||
return result
|
||||
|
||||
|
||||
def classify_with_manifest(source: Path, relatives: list[str]) -> dict[str, str]:
|
||||
framework = source.parent
|
||||
manifest = framework / "framework-manifest.txt"
|
||||
resolver = source / "_lib" / "manifest.sh"
|
||||
if not manifest.is_file() or not os.access(manifest, os.R_OK):
|
||||
raise OSError(f"ownership manifest is missing or unreadable: {manifest}")
|
||||
if not resolver.is_file() or not os.access(resolver, os.R_OK):
|
||||
raise OSError(f"canonical manifest resolver is missing or unreadable: {resolver}")
|
||||
payload = "".join(f"tools/{relative}\n" for relative in relatives)
|
||||
completed = subprocess.run(
|
||||
["bash", str(resolver), "classify"],
|
||||
input=payload,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={**os.environ, "MANIFEST_FILE": str(manifest)},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.strip() or f"resolver rc={completed.returncode}"
|
||||
raise OSError(f"ownership manifest failed canonical resolution: {detail}")
|
||||
classified: dict[str, str] = {}
|
||||
for line in completed.stdout.splitlines():
|
||||
ownership, separator, manifest_path = line.partition("\t")
|
||||
if not separator or not manifest_path.startswith("tools/") or ownership not in {"framework", "operator"}:
|
||||
raise OSError(f"invalid canonical ownership output: {line!r}")
|
||||
relative = manifest_path.removeprefix("tools/")
|
||||
if relative in classified:
|
||||
raise OSError(f"duplicate canonical ownership output: {relative}")
|
||||
classified[relative] = ownership
|
||||
if set(classified) != set(relatives):
|
||||
raise OSError("canonical ownership output did not classify the complete source census")
|
||||
return classified
|
||||
|
||||
|
||||
def has_symlinked_component(root: Path, relative: str) -> bool:
|
||||
current = root
|
||||
for component in Path(relative).parts:
|
||||
current = current / component
|
||||
if current.is_symlink():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Detect deployed Mosaic framework-tool drift")
|
||||
parser.add_argument("--source-root", type=Path, default=Path(os.environ["MOSAIC_FRAMEWORK_SOURCE_ROOT"]) if os.environ.get("MOSAIC_FRAMEWORK_SOURCE_ROOT") else default_source_tools())
|
||||
parser.add_argument("--installed-root", type=Path, default=Path(os.environ.get("MOSAIC_HOME", Path.home() / ".config/mosaic")) / "tools")
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = normalize_source(args.source_root)
|
||||
installed = args.installed_root.resolve()
|
||||
try:
|
||||
if not source.is_dir():
|
||||
raise OSError(f"source tools missing: {source}")
|
||||
if not installed.is_dir():
|
||||
raise OSError(f"installed tools missing: {installed}")
|
||||
if source.samefile(installed):
|
||||
raise OSError("source and installed roots identify the same filesystem object")
|
||||
source_files = census(source, reject_symlinks=True)
|
||||
if not source_files:
|
||||
raise OSError("source tools census is empty")
|
||||
ownership = classify_with_manifest(source, sorted(source_files))
|
||||
required = sorted(relative for relative, owner in ownership.items() if owner == "framework")
|
||||
if not required:
|
||||
raise OSError("ownership manifest classifies zero shipped tools as framework-owned")
|
||||
installed_files = census(installed, reject_symlinks=False)
|
||||
except (OSError, PermissionError) as error:
|
||||
print(f"[framework-drift] CANNOT_ASSERT {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
in_sync: list[str] = []
|
||||
stale: list[str] = []
|
||||
not_installed: list[str] = []
|
||||
unsafe_alias: list[str] = []
|
||||
for relative in required:
|
||||
deployed = installed / relative
|
||||
if not deployed.is_file():
|
||||
not_installed.append(relative)
|
||||
continue
|
||||
if has_symlinked_component(installed, relative):
|
||||
unsafe_alias.append(relative)
|
||||
continue
|
||||
try:
|
||||
if source_files[relative].samefile(deployed):
|
||||
unsafe_alias.append(relative)
|
||||
elif digest(source_files[relative]) == digest(deployed):
|
||||
in_sync.append(relative)
|
||||
else:
|
||||
stale.append(relative)
|
||||
except OSError as error:
|
||||
print(f"[framework-drift] CANNOT_ASSERT cannot compare {relative}: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
source_relative = set(source_files)
|
||||
installed_only = sorted(set(installed_files) - source_relative)
|
||||
if args.verbose:
|
||||
for relative in in_sync:
|
||||
print(f"[framework-drift] IN_SYNC {relative}")
|
||||
for relative in stale:
|
||||
print(f"[framework-drift] STALE {relative}")
|
||||
for relative in not_installed:
|
||||
print(f"[framework-drift] NOT_INSTALLED {relative}")
|
||||
for relative in unsafe_alias:
|
||||
print(f"[framework-drift] UNSAFE_ALIAS {relative}")
|
||||
if args.verbose:
|
||||
for relative in installed_only:
|
||||
print(f"[framework-drift] INSTALLED_ONLY operator-or-unknown {relative}")
|
||||
|
||||
print(
|
||||
"[framework-drift] summary "
|
||||
f"in-sync={len(in_sync)} stale={len(stale)} not-installed={len(not_installed)} "
|
||||
f"unsafe-alias={len(unsafe_alias)} installed-only={len(installed_only)}"
|
||||
)
|
||||
print("[framework-drift] classification canonical framework-manifest ownership; installed-only=operator-or-unknown-preserved")
|
||||
if stale or not_installed or unsafe_alias:
|
||||
print("[framework-drift] FAIL deployed framework tools do not match independent shipped source; schedule a reviewed framework reseed", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
CHECKER = Path(__file__).with_name("framework-drift-check.py")
|
||||
REAL_RESOLVER = CHECKER.parents[2] / "_lib" / "manifest.sh"
|
||||
|
||||
|
||||
class FrameworkDriftCheckTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
root = Path(self.temp.name)
|
||||
self.framework = root / "framework"
|
||||
self.source = self.framework / "tools"
|
||||
self.installed = root / "home" / "tools"
|
||||
for directory in (self.source / "git", self.source / "_lib", self.installed / "git", self.installed / "_lib"):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(REAL_RESOLVER, self.source / "_lib" / "manifest.sh")
|
||||
(self.source / "git" / "guard.sh").write_text("fixed\n")
|
||||
(self.source / "git" / "new-wrapper.sh").write_text("new\n")
|
||||
(self.source / "_lib" / "credentials.json").write_text("source-placeholder\n")
|
||||
self.write_manifest()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
|
||||
def write_manifest(self, operator_extra: str = "") -> None:
|
||||
(self.framework / "framework-manifest.txt").write_text(
|
||||
"[framework]\ntools/**\n[operator]\ntools/_lib/credentials.json\n" + operator_extra
|
||||
)
|
||||
|
||||
def run_check(self, *extra: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(CHECKER), "--source-root", str(self.framework), "--installed-root", str(self.installed), *extra],
|
||||
text=True, capture_output=True, check=False,
|
||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||
)
|
||||
|
||||
def install_matching(self) -> None:
|
||||
for relative in ("git/guard.sh", "git/new-wrapper.sh", "_lib/manifest.sh"):
|
||||
shutil.copy2(self.source / relative, self.installed / relative)
|
||||
(self.installed / "_lib" / "credentials.json").write_text("different-operator-secret\n")
|
||||
|
||||
def test_fails_loudly_and_classifies_stale_missing_and_installed_only(self) -> None:
|
||||
(self.installed / "git" / "guard.sh").write_text("broken\n")
|
||||
shutil.copy2(self.source / "_lib" / "manifest.sh", self.installed / "_lib" / "manifest.sh")
|
||||
(self.installed / "local-helper.sh").write_text("operator\n")
|
||||
result = self.run_check("--verbose")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("STALE git/guard.sh", result.stdout)
|
||||
self.assertIn("NOT_INSTALLED git/new-wrapper.sh", result.stdout)
|
||||
self.assertIn("INSTALLED_ONLY operator-or-unknown local-helper.sh", result.stdout)
|
||||
self.assertIn("FAIL deployed framework tools", result.stderr)
|
||||
|
||||
def test_passes_only_when_every_manifest_owned_source_file_matches(self) -> None:
|
||||
self.install_matching()
|
||||
result = self.run_check()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("stale=0 not-installed=0 unsafe-alias=0", result.stdout)
|
||||
|
||||
def test_exact_operator_directory_does_not_hide_framework_drift_beneath_it(self) -> None:
|
||||
self.install_matching()
|
||||
(self.installed / "git" / "guard.sh").write_text("drift-hidden-by-directory-entry\n")
|
||||
self.write_manifest("tools/git\n")
|
||||
|
||||
result = self.run_check()
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn("STALE git/guard.sh", result.stdout)
|
||||
|
||||
def test_manifest_is_required_and_policy_changes_take_effect(self) -> None:
|
||||
self.install_matching()
|
||||
(self.installed / "git" / "guard.sh").write_text("operator-divergence\n")
|
||||
self.write_manifest("tools/git/guard.sh\n")
|
||||
self.assertEqual(self.run_check().returncode, 0)
|
||||
(self.framework / "framework-manifest.txt").unlink()
|
||||
result = self.run_check()
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT ownership manifest is missing", result.stderr)
|
||||
|
||||
def test_empty_and_unreadable_source_census_cannot_assert(self) -> None:
|
||||
empty_framework = Path(self.temp.name) / "empty-framework"
|
||||
empty_source = empty_framework / "tools"
|
||||
empty_source.mkdir(parents=True)
|
||||
shutil.copy2(self.framework / "framework-manifest.txt", empty_framework / "framework-manifest.txt")
|
||||
# The canonical resolver is supplied outside the empty census solely so
|
||||
# this probe reaches the explicit minimum-population guard.
|
||||
result = subprocess.run([sys.executable, str(CHECKER), "--source-root", str(empty_framework), "--installed-root", str(self.installed)], text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT", result.stderr)
|
||||
|
||||
blocked = self.source / "blocked"
|
||||
blocked.mkdir(); (blocked / "hidden.sh").write_text("hidden\n"); blocked.chmod(0)
|
||||
try:
|
||||
result = self.run_check()
|
||||
finally:
|
||||
blocked.chmod(0o700)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT", result.stderr)
|
||||
self.assertTrue("Permission denied" in result.stderr or "no readable/searchable mode" in result.stderr)
|
||||
|
||||
def test_root_and_descendant_aliases_cannot_report_clean(self) -> None:
|
||||
result = subprocess.run([sys.executable, str(CHECKER), "--source-root", str(self.framework), "--installed-root", str(self.source)], text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("same filesystem object", result.stderr)
|
||||
|
||||
shutil.copy2(self.source / "_lib" / "manifest.sh", self.installed / "_lib" / "manifest.sh")
|
||||
shutil.rmtree(self.installed / "git")
|
||||
(self.installed / "git").symlink_to(self.source / "git", target_is_directory=True)
|
||||
result = self.run_check()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue("symlinked directory" in result.stderr or "UNSAFE_ALIAS" in result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Doctor must contain a stalled drift checker and continue its remaining audit.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOCTOR="$SCRIPT_DIR/../../_scripts/mosaic-doctor"
|
||||
WORK="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/framework-drift-doctor}"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/source/tools/quality/scripts" "$WORK/source/tools/_scripts" "$WORK/home/tools"
|
||||
cp "$DOCTOR" "$WORK/source/tools/_scripts/mosaic-doctor"
|
||||
cat > "$WORK/source/tools/quality/scripts/framework-drift-check.py" <<'PY'
|
||||
import time
|
||||
time.sleep(30)
|
||||
PY
|
||||
|
||||
start=$(date +%s)
|
||||
set +e
|
||||
output=$(MOSAIC_HOME="$WORK/home" MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC=1 \
|
||||
bash "$WORK/source/tools/_scripts/mosaic-doctor" --fail-on-warn 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
elapsed=$(( $(date +%s) - start ))
|
||||
|
||||
[[ "$rc" -ne 0 ]] || { echo "FAIL: checker timeout became doctor success" >&2; exit 1; }
|
||||
[[ "$elapsed" -lt 10 ]] || { echo "FAIL: checker hang escaped watchdog (${elapsed}s)" >&2; exit 1; }
|
||||
[[ "$output" == *"CANNOT_ASSERT framework drift checker timed out"* ]] || {
|
||||
echo "FAIL: missing timeout CANNOT_ASSERT diagnostic" >&2; printf '%s\n' "$output" >&2; exit 1;
|
||||
}
|
||||
[[ "$output" == *"[mosaic-doctor] warnings="* ]] || {
|
||||
echo "FAIL: doctor did not continue after checker timeout" >&2; printf '%s\n' "$output" >&2; exit 1;
|
||||
}
|
||||
|
||||
echo "framework drift doctor watchdog regression passed"
|
||||
@@ -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 && bash framework/tools/fleet/test-start-agent-session.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/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/framework_skill_portability_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-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.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-pr-merge-queue-branch.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/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"
|
||||
"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/tools/fleet/test-start-agent-session.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/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/framework_skill_portability_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-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.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-pr-merge-queue-branch.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/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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -146,6 +146,21 @@ describe('resolveOwnership (deny-wins + fail-safe)', () => {
|
||||
expect(resolveOwnership(m, 'tools/git/pr-create.sh')).toBe('framework');
|
||||
});
|
||||
|
||||
it('limits an exact operator carve-out to that path while requiring /** for subtrees', () => {
|
||||
const exact: FrameworkManifest = {
|
||||
framework: ['tools/**'],
|
||||
operator: ['tools/git'],
|
||||
};
|
||||
expect(resolveOwnership(exact, 'tools/git')).toBe('operator');
|
||||
expect(resolveOwnership(exact, 'tools/git/guard.sh')).toBe('framework');
|
||||
|
||||
const subtree: FrameworkManifest = {
|
||||
framework: ['tools/**'],
|
||||
operator: ['tools/git/**'],
|
||||
};
|
||||
expect(resolveOwnership(subtree, 'tools/git/guard.sh')).toBe('operator');
|
||||
});
|
||||
|
||||
it('framework-declared paths resolve to framework', () => {
|
||||
expect(resolveOwnership(m, 'guides/E2E-DELIVERY.md')).toBe('framework');
|
||||
expect(resolveOwnership(m, 'CONSTITUTION.md')).toBe('framework');
|
||||
|
||||
@@ -154,12 +154,25 @@ export function matchesAny(globs: readonly string[], relPath: string): boolean {
|
||||
return globs.some((g) => matchGlob(g, relPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator entries without wildcards are exact file carve-outs. Treating them
|
||||
* as directory prefixes would let one bare entry hide an entire framework
|
||||
* subtree from reconciliation and drift detection. Operator subtree ownership
|
||||
* remains explicit through `dir/**`.
|
||||
*/
|
||||
function matchesOperatorGlob(glob: string, relPath: string): boolean {
|
||||
const pattern = normalizeRel(glob);
|
||||
if (pattern === '') return false;
|
||||
if (!pattern.includes('*')) return normalizeRel(relPath) === pattern;
|
||||
return matchGlob(pattern, relPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ownership of a mosaic-home-relative path (deny-wins / fail-safe):
|
||||
* operator globs win, then framework globs, else operator by default.
|
||||
*/
|
||||
export function resolveOwnership(manifest: FrameworkManifest, relPath: string): Ownership {
|
||||
if (matchesAny(manifest.operator, relPath)) return 'operator';
|
||||
if (manifest.operator.some((glob) => matchesOperatorGlob(glob, relPath))) return 'operator';
|
||||
if (matchesAny(manifest.framework, relPath)) return 'framework';
|
||||
return 'operator';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user