diff --git a/docs/health-diagnostics.md b/docs/health-diagnostics.md index 6e9435b..2b60adc 100644 --- a/docs/health-diagnostics.md +++ b/docs/health-diagnostics.md @@ -24,7 +24,8 @@ No underlying capability is silently removed: Checks are read-only. Opening Health refreshes a missing or older-than-five- minutes result once; no background timer polls the system. **Run checks** is the explicit refresh action. A result has a stable identifier, group, label, -status, value, bounded detail, affected component, and an optional next action. +status, value, bounded detail, affected component, owner attribution, sanitized +source path, and an optional next action. Check status is one of `ok`, `warning`, `error`, or `info`; report state is `healthy`, `warning`, or `error`. The UI adds transient `checking` and initial `not checked` states. @@ -53,12 +54,30 @@ and recent runtime errors remain as quiet icon-and-text rows. Other successful implementation checks stay hidden: they provide no user action and surface automatically if their state becomes abnormal. -An expanded error exposes a stable `SHIBUMI-HEALTH/` code and two -explicit actions. **Copy** places the bounded code, result, version, -component, evidence, and suggested action on the clipboard. **Open issue** -opens this repository's GitHub issue form with the same report prefilled; it -does not submit anything. The Copy action briefly changes to **Copied** as -feedback. Warnings remain review-only and do not encourage a +### Ownership attribution + +Every check carries an `owner` of `shibumi`, `omarchy`, `third-party`, or +`unknown`, plus a sanitized `sourcePath` and optional `pluginId`. Runtime log +findings are grouped by this attribution instead of being presented as one +Shibumi error. `/usr/share/omarchy/**` findings are Omarchy-owned; installed +`hancore.shibumi.*` roots are Shibumi-owned; and unrelated user plugin roots, +including OmaConnect, are third-party-owned only when the local install state +or plugin registry verifies the ID. Bare names and unverified explicit plugin +fields remain `unknown`. Competing Shibumi and non-Shibumi sources on one line +also remain `unknown`; a canonical Omarchy path with a competing local path or +explicit foreign plugin ID is likewise ambiguous. An Omarchy path may still +outvote an incidental, unanchored plugin name. Ownership must not be assigned +by guesswork. + +An expanded error exposes a stable `SHIBUMI-HEALTH/` code and a +**Copy** action. Copy places only the bounded, sanitized code, result, owner, +version, plugin/source identity, evidence, and suggested action on the +clipboard. A Shibumi-owned error with `issueEligible: true` additionally gets +**Open issue**, which opens this repository's GitHub issue form with the same +report prefilled; it does not submit anything. Omarchy, third-party, and +unknown findings never receive a Shibumi issue action; their next step points +to the relevant owner or upstream support path. The Copy action briefly changes +to **Copied** as feedback. Warnings remain review-only and do not encourage a bug report without evidence of an actual failure. The collapsed report fits without a scrollbar. Expanding an Attention detail diff --git a/hancore.shibumi.control-center/ControlMainPage.qml b/hancore.shibumi.control-center/ControlMainPage.qml index a664567..4678876 100644 --- a/hancore.shibumi.control-center/ControlMainPage.qml +++ b/hancore.shibumi.control-center/ControlMainPage.qml @@ -105,13 +105,26 @@ Column { return "Checked " + Qt.formatDateTime(new Date(epoch * 1000), "HH:mm") } + function ownerLabel(owner) { + if (owner === "shibumi") return "Shibumi" + if (owner === "omarchy") return "Omarchy" + if (owner === "third-party") return "Third party" + return "Unattributed" + } + function checkDetail(check) { const lines = [] if (String(check.status || "") === "error") lines.push("Code: " + diagnosticCode(check)) + if (String(check.owner || "") !== "") + lines.push("Owner: " + ownerLabel(String(check.owner))) if (String(check.detail || "") !== "") lines.push(String(check.detail)) if (String(check.component || "") !== "") lines.push("Component: " + String(check.component)) + if (String(check.pluginId || "") !== "") + lines.push("Plugin: " + String(check.pluginId)) + if (String(check.sourcePath || "") !== "") + lines.push("Source: " + String(check.sourcePath)) if (String(check.action || "") !== "") lines.push("Next: " + String(check.action)) return lines.join("\n") @@ -126,12 +139,17 @@ Column { const fields = [ "Code: " + diagnosticCode(check), "Status: " + String(check.status || "unknown"), + "Owner: " + ownerLabel(String(check.owner || "unknown")), "Check: " + String(check.label || "Unknown check"), "Result: " + String(check.value || ""), "Version: Shibumi " + installedShibumiVersion ] if (String(check.component || "") !== "") fields.push("Component: " + String(check.component)) + if (String(check.pluginId || "") !== "") + fields.push("Plugin: " + String(check.pluginId)) + if (String(check.sourcePath || "") !== "") + fields.push("Source: " + String(check.sourcePath)) if (String(check.detail || "") !== "") fields.push("Detail: " + String(check.detail)) if (String(check.action || "") !== "") @@ -149,6 +167,9 @@ Column { } function diagnosticIssueUrl(check) { + if (!check || String(check.status || "") !== "error" + || check.issueEligible !== true + || String(check.owner || "") !== "shibumi") return "" const title = "[Health] " + diagnosticCode(check) + " · " + String(check.label || "Runtime error") const body = "\n\n```text\n" @@ -158,7 +179,8 @@ Column { } function openDiagnosticIssue(check) { - Qt.openUrlExternally(diagnosticIssueUrl(check)) + const url = diagnosticIssueUrl(check) + if (url !== "") Qt.openUrlExternally(url) } TextEdit { @@ -414,6 +436,9 @@ Column { readonly property bool expanded: interactive && root.expandedCheckId === String(check.id || "") readonly property bool reportable: String(check.status || "") === "error" + readonly property bool issueEligible: reportable + && check.issueEligible === true + && String(check.owner || "") === "shibumi" implicitHeight: rowContent.implicitHeight + Commons.Style.space(14) radius: root.controller.controlRadius @@ -547,7 +572,8 @@ Column { CompactSettingChoice { id: openIssue - width: Commons.Style.space(90) + visible: checkRow.issueEligible + width: visible ? Commons.Style.space(90) : 0 controller: root.controller label: "Open issue" primary: true diff --git a/hancore.shibumi.control-center/HealthService.qml b/hancore.shibumi.control-center/HealthService.qml index 9c21b56..0bd1f33 100644 --- a/hancore.shibumi.control-center/HealthService.qml +++ b/hancore.shibumi.control-center/HealthService.qml @@ -51,13 +51,67 @@ Item { return runChecks(false) } + function sanitizeDiagnosticText(value, limit) { + let text = String(value || "") + .replace(/\u0000/g, "") + const containsSensitive = /authorization|cookie|credential|password|secret|ssid|token/i + .test(text) + text = text + .replace(/\r/g, "") + .replace(/\/home\/[^\/\s]+/g, "~") + .replace(/\b(?:https?|ftp):\/\/[^\s]+/gi, "[URL redacted]") + .replace(/\b(?:bearer|basic)\s+[^\s]+/gi, + "[authorization redacted]") + .replace(/\b(?:password|passwd|passphrase|token|secret|cookie|credential|ssid|authorization)\b\s*["']?\s*[:=]\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,}]+)/gi, + "[sensitive value redacted]") + if (containsSensitive + || /authorization|cookie|credential|password|secret|ssid|token/i.test(text)) + return "[sensitive diagnostic redacted]" + const max = Math.max(1, Number(limit || 320)) + return text.length <= max ? text : text.slice(0, max - 1) + "…" + } + function acceptReport(raw) { try { const parsed = JSON.parse(String(raw || "{}")) + const owners = ["shibumi", "omarchy", "third-party", "unknown"] + const statuses = ["ok", "warning", "error", "info"] if (Number(parsed.schemaVersion || 0) !== 1 || !Array.isArray(parsed.checks) || typeof parsed.summary !== "string") throw new Error("unsupported report") + parsed.checks = parsed.checks.map(function(check) { + if (!check || typeof check !== "object" + || typeof check.id !== "string" + || typeof check.status !== "string" + || statuses.indexOf(check.status) < 0) + throw new Error("invalid check") + if (check.owner !== undefined + && (typeof check.owner !== "string" + || owners.indexOf(check.owner) < 0)) + throw new Error("invalid check owner") + const normalized = Object.assign({}, check) + normalized.owner = check.owner === undefined ? "unknown" : check.owner + const rawDiagnostic = String(check.value || "") + " " + + String(check.detail || "") + " " + + String(check.component || "") + " " + + String(check.sourcePath || "") + " " + + String(check.action || "") + const sensitive = /authorization|cookie|credential|password|secret|ssid|token/i + .test(rawDiagnostic) + normalized.label = root.sanitizeDiagnosticText(check.label, 160) + normalized.value = root.sanitizeDiagnosticText(check.value, 160) + normalized.detail = root.sanitizeDiagnosticText(check.detail, 900) + normalized.component = root.sanitizeDiagnosticText(check.component, 240) + normalized.action = root.sanitizeDiagnosticText(check.action, 240) + normalized.sourcePath = root.sanitizeDiagnosticText( + check.sourcePath, 240) + normalized.pluginId = root.sanitizeDiagnosticText(check.pluginId, 240) + normalized.upstream = root.sanitizeDiagnosticText(check.upstream, 240) + normalized.issueEligible = normalized.owner === "shibumi" + && check.issueEligible === true && !sensitive + return normalized + }) report = parsed failure = "" return true diff --git a/hancore.shibumi.control-center/manager/shibumi-health b/hancore.shibumi.control-center/manager/shibumi-health index 105d4bb..fd2d66d 100755 --- a/hancore.shibumi.control-center/manager/shibumi-health +++ b/hancore.shibumi.control-center/manager/shibumi-health @@ -29,6 +29,34 @@ SENSITIVE_PATTERNS = re.compile( r"authorization|cookie|credential|password|secret|ssid|token", re.IGNORECASE, ) +SOURCE_PATH_PATTERN = re.compile( + r"(?P(?:file://)?(?:/|~/|[A-Za-z0-9_.-]+/)[^\s\"'`()]+?" + r"\.(?:qml|js|py|sh)(?::\d+(?::\d+)?)?(?=$|[\s,;:)\]]))", + re.IGNORECASE, +) +EXPLICIT_PLUGIN_PATTERN = re.compile( + r"(?i)\b(?:plugin|component|source|module)\s*[:=]\s*[\"']?" + r"(?P[A-Za-z0-9_.-]+)" +) +URL_AUTHORITY_PATTERN = re.compile( + r"(?i)\b[A-Za-z][A-Za-z0-9+.-]*://(?!/)[^/\s]+" +) +OWNER_SHIBUMI = "shibumi" +OWNER_OMARCHY = "omarchy" +OWNER_THIRD_PARTY = "third-party" +OWNER_UNKNOWN = "unknown" +OWNERS = { + OWNER_SHIBUMI, + OWNER_OMARCHY, + OWNER_THIRD_PARTY, + OWNER_UNKNOWN, +} +OWNER_ORDER = { + OWNER_SHIBUMI: 0, + OWNER_OMARCHY: 1, + OWNER_THIRD_PARTY: 2, + OWNER_UNKNOWN: 3, +} PACKAGE_NAME = "shibumi-shell" AUR_INFO_URL = ( "https://aur.archlinux.org/rpc/v5/info?arg%5B%5D=shibumi-shell" @@ -45,6 +73,11 @@ class Check: detail: str = "" component: str = "" action: str = "" + owner: str = OWNER_UNKNOWN + sourcePath: str = "" + pluginId: str = "" + issueEligible: bool = False + upstream: str = "" def safe_text(value: Any, limit: int = 320) -> str: @@ -52,6 +85,29 @@ def safe_text(value: Any, limit: int = 320) -> str: return text if len(text) <= limit else text[: limit - 1] + "…" +def safe_public_text(value: Any, limit: int = 320) -> str: + text = str(value or "").replace("\x00", "") + text = re.sub(r"(? dict[str, Any]: value = json.loads(path.read_text(encoding="utf-8")) if not isinstance(value, dict): @@ -169,17 +225,29 @@ class Probe: detail: str = "", component: str = "", action: str = "", + owner: str = OWNER_UNKNOWN, + source_path: str = "", + plugin_id: str = "", + issue_eligible: bool = False, + upstream: str = "", ) -> None: + safe_owner = owner if owner in OWNERS else OWNER_UNKNOWN self.checks.append( Check( check_id, group, label, status if status in STATUS_ORDER else "error", - safe_text(value, 120), - safe_text(detail, 900), - self.display_path(component), - safe_text(action, 240), + self.public_text(value, 120), + self.public_text(detail, 900), + self.public_text(self.display_path(component), 240), + self.public_text(action, 240), + safe_owner, + self.public_text(self.display_path(source_path), 240) + if source_path else "", + self.public_text(plugin_id, 240), + bool(issue_eligible and safe_owner == OWNER_SHIBUMI), + self.public_text(upstream, 240), ) ) @@ -198,6 +266,197 @@ class Probe: text = text.replace(home + "/", "~/") return re.sub(r"(? str: + """Return bounded diagnostic text safe for clipboard and issue drafts.""" + return safe_public_text(self.redact_home_paths(value), limit) + + def is_omarchy_path(self, value: Any) -> bool: + text = str(value or "").replace("file://", "") + text = re.sub(r":\d+(?::\d+)?$", "", text) + if not text.startswith("/"): + return False + candidate = os.path.normpath(text).lower() + roots = ( + Path("/usr/share/omarchy"), + self.omarchy_root, + self.home / ".local/share/omarchy", + ) + return any( + candidate == os.path.normpath(root).lower() + or candidate.startswith(os.path.normpath(root).lower() + "/") + for root in roots + ) + + def plugin_id_for_path(self, value: Any) -> str: + text = str(value or "").replace("file://", "") + text = re.sub(r":\d+(?::\d+)?$", "", text) + roots = [str(self.plugin_dir).rstrip("/")] + foreign_root = re.match(r"^/(?:home/[^/\s]+|root)/\.config/omarchy/plugins", text) + if foreign_root: + roots.append(foreign_root.group(0)) + for root in roots: + prefix = root + "/" + if text.startswith(prefix): + plugin_id = text[len(prefix):].split("/", 1)[0] + return plugin_id + return "" + + def source_paths(self, value: Any) -> list[str]: + text = str(value or "").replace("file://", "") + paths: list[str] = [] + for match in SOURCE_PATH_PATTERN.finditer(text): + candidate = match.group("path").rstrip(".,;)]}") + if candidate.startswith("//") or candidate in paths: + continue + paths.append(candidate) + return paths + + def source_path(self, value: Any) -> str: + """Return the first source path for compatibility with diagnostics callers.""" + return (self.source_paths(value) or [""])[0] + + def known_plugin_ids(self) -> dict[str, str]: + """Return plugin IDs verified by install state or the live registry.""" + known: dict[str, str] = {} + state_plugins = self.state.get("plugins", []) + if isinstance(state_plugins, list): + for plugin_id in state_plugins: + if isinstance(plugin_id, str) and plugin_id: + known.setdefault(plugin_id.lower(), plugin_id) + for entry in self.registry: + plugin_id = entry.get("id") + if isinstance(plugin_id, str) and plugin_id: + known.setdefault(plugin_id.lower(), plugin_id) + return known + + def plugin_owner(self, plugin_id: str) -> str: + lowered = plugin_id.lower() + if lowered.startswith("hancore.shibumi."): + return OWNER_SHIBUMI + if lowered.startswith("omarchy."): + return OWNER_OMARCHY + return OWNER_THIRD_PARTY + + def attributed(self, owner: str, source_paths: list[str], plugin_ids: list[str]) -> dict[str, str]: + return { + "owner": owner, + "sourcePath": "; ".join(source_paths), + "pluginId": "; ".join(dict.fromkeys(plugin_ids)), + } + + def attribute_source(self, value: Any) -> dict[str, str]: + """Classify only authoritative, unambiguous local runtime sources.""" + raw_text = str(value or "") + if URL_AUTHORITY_PATTERN.search(raw_text): + return self.attributed(OWNER_UNKNOWN, [], []) + text = raw_text.replace("file://", "") + paths = self.source_paths(text) + known = self.known_plugin_ids() + omarchy_paths = [path for path in paths if self.is_omarchy_path(path)] + plugin_candidates: list[tuple[str, str]] = [] + unverified_plugin_paths: list[tuple[str, str]] = [] + unrelated_paths: list[str] = [] + for path in paths: + if path in omarchy_paths: + continue + candidate = self.plugin_id_for_path(path) + if not candidate: + unrelated_paths.append(path) + continue + verified = known.get(candidate.lower(), "") + if verified: + plugin_candidates.append((path, verified)) + else: + unverified_plugin_paths.append((path, candidate)) + all_evidence_paths = [ + *omarchy_paths, + *[path for path, _ in plugin_candidates], + *[path for path, _ in unverified_plugin_paths], + *unrelated_paths, + ] + competing_unrelated_paths = [ + path + for path in unrelated_paths + if path.startswith("/") or path.startswith("~/") + ] + + # Explicit plugin fields are accepted only when install state or the + # live registry verifies every ID. Bare names in free-form text remain + # unknown because they do not identify an authoritative local source. + explicit_ids = [ + match.group("id") + for match in EXPLICIT_PLUGIN_PATTERN.finditer(text) + ] + verified_explicit_ids = [ + known.get(plugin_id.lower(), "") for plugin_id in explicit_ids + ] + explicit_owners = { + self.plugin_owner(plugin_id) + for plugin_id in verified_explicit_ids + if plugin_id + } + + # Canonical Omarchy source roots take precedence over incidental plugin + # names, but not over competing source evidence. An explicit, verified + # Omarchy ID is compatible; any other or unverified evidence is not. + omarchy_is_unambiguous = ( + not plugin_candidates + and not unverified_plugin_paths + and not competing_unrelated_paths + and ( + not explicit_ids + or ( + all(verified_explicit_ids) + and explicit_owners == {OWNER_OMARCHY} + ) + ) + ) + if omarchy_paths and omarchy_is_unambiguous: + source_lowered = omarchy_paths[0].lower() + plugin_id = "" + if "activewindow.qml" in source_lowered: + plugin_id = "omarchy.active-window" + elif "/shell/" + "plugins/" in source_lowered: + plugin_id = "omarchy.shell" + return self.attributed( + OWNER_OMARCHY, + omarchy_paths, + [plugin_id] if plugin_id else [], + ) + if omarchy_paths: + return self.attributed( + OWNER_UNKNOWN, + all_evidence_paths, + [plugin_id for _, plugin_id in unverified_plugin_paths] + + [plugin_id for _, plugin_id in plugin_candidates] + + explicit_ids, + ) + + if ( + unverified_plugin_paths + or unrelated_paths + or (explicit_ids and not all(verified_explicit_ids)) + ): + return self.attributed( + OWNER_UNKNOWN, + all_evidence_paths, + [ + plugin_id + for _, plugin_id in unverified_plugin_paths + ] + explicit_ids, + ) + + evidence_ids = [ + *[plugin_id for _, plugin_id in plugin_candidates], + *verified_explicit_ids, + ] + if not evidence_ids: + return self.attributed(OWNER_UNKNOWN, all_evidence_paths, []) + owners = {self.plugin_owner(plugin_id) for plugin_id in evidence_ids} + if len(owners) != 1: + return self.attributed(OWNER_UNKNOWN, all_evidence_paths, evidence_ids) + return self.attributed(owners.pop(), all_evidence_paths, evidence_ids) + def load_state_and_config(self) -> None: state_path = self.state_dir / "install.json" try: @@ -698,9 +957,7 @@ class Probe: if result.returncode != 0: raw_detail = (result.stderr or result.stdout).strip() if raw_detail and not SENSITIVE_PATTERNS.search(raw_detail): - detail = self.redact_home_paths( - safe_text(raw_detail, 280) - ) + detail = self.redact_home_paths(safe_text(raw_detail, 280)) else: detail = f"qs log exited with {result.returncode}" self.add( @@ -711,6 +968,8 @@ class Probe: "Log unavailable", detail, "current Quickshell configuration", + "Copy the bounded detail and inspect the affected component.", + owner=OWNER_UNKNOWN, ) return raw = result.stdout @@ -721,25 +980,81 @@ class Probe: if "Configuration Loaded" in lines[index]: lines = lines[index + 1 :] break - matches = [] + matches: list[tuple[str, dict[str, str]]] = [] + suppressed_sensitive = False for line in lines: - if not ERROR_PATTERNS.search(line) or SENSITIVE_PATTERNS.search(line): + if not ERROR_PATTERNS.search(line): + continue + if SENSITIVE_PATTERNS.search(line): + suppressed_sensitive = True continue - line = self.redact_home_paths(line) - matches.append(safe_text(line, 280)) + attribution = self.attribute_source(line) + matches.append((safe_text(self.redact_home_paths(line), 280), attribution)) matches = matches[-8:] - self.add( - "runtime-errors", - "Runtime", - "Recent runtime errors", - "error" if matches else "ok", - f"{len(matches)} relevant error(s)" if matches else "None detected", - " | ".join(matches) if matches - else "No recent Shibumi loader, type, reference, binding-loop, or provider failure was found.", - "current Quickshell configuration", - "Copy the bounded detail and inspect the affected component." - if matches else "", - ) + if suppressed_sensitive: + self.add( + "runtime-errors-sensitive", + "Runtime", + "Sensitive runtime findings", + "warning", + "Details redacted", + "One or more relevant runtime findings contained sensitive terms and were omitted from the Health report.", + "current Quickshell configuration", + "Review the local shell log using a trusted terminal; Health will not expose the sensitive detail.", + owner=OWNER_UNKNOWN, + ) + if not matches: + if suppressed_sensitive: + return + self.add( + "runtime-errors", + "Runtime", + "Recent runtime errors", + "ok", + "None detected", + "No recent Shibumi loader, type, reference, binding-loop, or provider failure was found.", + "current Quickshell configuration", + owner=OWNER_UNKNOWN, + ) + return + + grouped: dict[str, list[tuple[str, dict[str, str]]]] = {} + for match in matches: + grouped.setdefault(match[1]["owner"], []).append(match) + for owner in sorted(grouped, key=lambda item: OWNER_ORDER[item]): + findings = grouped[owner] + plugin_ids = sorted({item["pluginId"] for _, item in findings if item["pluginId"]}) + source_paths = sorted({item["sourcePath"] for _, item in findings if item["sourcePath"]}) + if owner == OWNER_SHIBUMI: + check_id = "runtime-errors" + label = "Recent Shibumi runtime errors" + action = "Copy the sanitized report or prepare a Shibumi issue draft for review." + elif owner == OWNER_OMARCHY: + check_id = "runtime-errors-omarchy" + label = "Omarchy runtime errors" + action = "Inspect the Omarchy source or report this finding upstream; Shibumi cannot file it." + elif owner == OWNER_THIRD_PARTY: + check_id = "runtime-errors-third-party" + label = "Third-party runtime errors" + action = "Inspect the named third-party plugin and use its upstream support channel; Shibumi cannot file it." + else: + check_id = "runtime-errors" if len(grouped) == 1 else "runtime-errors-unknown" + label = "Recent runtime errors" if len(grouped) == 1 else "Unattributed runtime errors" + action = "Confirm the source before assigning ownership; Shibumi will not file an unattributed issue." + self.add( + check_id, + "Runtime", + label, + "error", + f"{len(findings)} relevant error(s)", + " | ".join(line for line, _ in findings), + "; ".join(plugin_ids) or "current Quickshell configuration", + action, + owner=owner, + source_path="; ".join(source_paths), + plugin_id="; ".join(plugin_ids), + issue_eligible=owner == OWNER_SHIBUMI, + ) except (OSError, subprocess.TimeoutExpired) as error: self.add( "runtime-errors", @@ -749,6 +1064,7 @@ class Probe: "Log unavailable", str(error), "current Quickshell configuration", + owner=OWNER_UNKNOWN, ) def source_root(self) -> Path: @@ -1176,7 +1492,7 @@ def main() -> int: "Health runner", "error", "Failed", - safe_text(error, 500), + safe_public_text(error, 500), ) ) ], diff --git a/tests/control-center-regression.sh b/tests/control-center-regression.sh index 6915652..7c45933 100755 --- a/tests/control-center-regression.sh +++ b/tests/control-center-regression.sh @@ -1052,7 +1052,9 @@ for health_contract in \ '? "SOURCE CHECKOUT" : "CHECKING …"' \ 'return "SHIBUMI-HEALTH/" + String(check.id || "UNKNOWN")' \ 'function diagnosticIssueUrl(check)' \ - 'Qt.openUrlExternally(diagnosticIssueUrl(check))' \ + 'const url = diagnosticIssueUrl(check)' \ + 'if (url !== "") Qt.openUrlExternally(url)' \ + 'function ownerLabel(owner)' \ 'label: root.copiedCheckId === String(checkRow.check.id || "")' \ '? "Copied" : "Copy"' \ 'label: "Open issue"' \ diff --git a/tests/control-center-smoke.qml b/tests/control-center-smoke.qml index 8178c52..b44790b 100644 --- a/tests/control-center-smoke.qml +++ b/tests/control-center-smoke.qml @@ -1213,6 +1213,10 @@ ShellRoot { value: "1 loader error", detail: "Unable to assign Example.qml:42", component: "hancore.shibumi.example", + pluginId: "hancore.shibumi.example", + sourcePath: "hancore.shibumi.example/Example.qml:42", + owner: "shibumi", + issueEligible: true, action: "Review the affected component." }] } @@ -1255,11 +1259,43 @@ ShellRoot { || decodeURIComponent(issueUrl).indexOf( "Code: SHIBUMI-HEALTH/RUNTIME-ERRORS") < 0) return root.fail("Health error report or issue URL is incomplete") + if (health.diagnosticIssueUrl({ + id: "runtime-errors-omarchy", + status: "error", + owner: "omarchy", + issueEligible: true + }) !== "" + || health.diagnosticIssueUrl({ + id: "runtime-errors", + status: "warning", + owner: "shibumi", + issueEligible: true + }) !== "") + return root.fail("Health filing gate accepted an external or warning finding") health.copyDiagnostic(error) if (health.copiedCheckId !== "runtime-errors") return root.fail("Health error report was not copied") const stableReport = panel.healthService.report + const unsafeReport = JSON.stringify({ + schemaVersion: 1, + summary: "unsafe fixture", + checks: [{ + id: "runtime-errors", + status: "error", + label: "Sensitive fixture", + value: "1 error", + detail: "password=\"secret value\"", + owner: "shibumi", + issueEligible: true + }] + }) + if (!panel.healthService.acceptReport(unsafeReport) + || panel.healthService.report.checks[0].issueEligible + || panel.healthService.report.checks[0].detail + .indexOf("secret value") >= 0) + return root.fail("Health report sanitization or filing gate failed") + panel.healthService.report = stableReport if (panel.healthService.acceptReport("{broken") || panel.healthService.report !== stableReport || panel.healthService.failure === "") diff --git a/tests/fixtures/health-attribution/ambiguous-ownership.log b/tests/fixtures/health-attribution/ambiguous-ownership.log new file mode 100644 index 0000000..2d61915 --- /dev/null +++ b/tests/fixtures/health-attribution/ambiguous-ownership.log @@ -0,0 +1,2 @@ +INFO Configuration Loaded +TypeError: failed to load /home/test/.config/omarchy/plugins/hancore.shibumi.control-center/ControlMainPage.qml:123 and /home/test/.config/omarchy/plugins/OmaConnect/Main.qml:18 diff --git a/tests/fixtures/health-attribution/mixed-runtime-errors.log b/tests/fixtures/health-attribution/mixed-runtime-errors.log new file mode 100644 index 0000000..8e1b5c1 --- /dev/null +++ b/tests/fixtures/health-attribution/mixed-runtime-errors.log @@ -0,0 +1,4 @@ +INFO Configuration Loaded +TypeError: failed to create /usr/share/omarchy/shell/plugins/bar/widgets/ActiveWindow.qml:42 +ReferenceError: OmaConnect failed in /home/test/.config/omarchy/plugins/OmaConnect/Main.qml:18 +Binding loop: /home/test/.config/omarchy/plugins/hancore.shibumi.control-center/ControlMainPage.qml:123 diff --git a/tests/fixtures/health-attribution/nested-omarchy-name.log b/tests/fixtures/health-attribution/nested-omarchy-name.log new file mode 100644 index 0000000..a9a0aa4 --- /dev/null +++ b/tests/fixtures/health-attribution/nested-omarchy-name.log @@ -0,0 +1,2 @@ +INFO Configuration Loaded +TypeError: /home/test/.config/omarchy/plugins/OmaConnect/usr/share/omarchy/Fake.qml:7 diff --git a/tests/fixtures/health-attribution/omaconnect.log b/tests/fixtures/health-attribution/omaconnect.log new file mode 100644 index 0000000..12078ca --- /dev/null +++ b/tests/fixtures/health-attribution/omaconnect.log @@ -0,0 +1,2 @@ +INFO Configuration Loaded +ReferenceError: OmaConnect failed in /home/test/.config/omarchy/plugins/OmaConnect/Main.qml:18 diff --git a/tests/fixtures/health-attribution/omarchy-active-window.log b/tests/fixtures/health-attribution/omarchy-active-window.log new file mode 100644 index 0000000..8364a09 --- /dev/null +++ b/tests/fixtures/health-attribution/omarchy-active-window.log @@ -0,0 +1,2 @@ +INFO Configuration Loaded +TypeError: failed to create /usr/share/omarchy/shell/plugins/bar/widgets/ActiveWindow.qml:42 diff --git a/tests/fixtures/health-attribution/omarchy-explicit-ambiguity.log b/tests/fixtures/health-attribution/omarchy-explicit-ambiguity.log new file mode 100644 index 0000000..0cc2f8d --- /dev/null +++ b/tests/fixtures/health-attribution/omarchy-explicit-ambiguity.log @@ -0,0 +1,2 @@ +INFO Configuration Loaded +TypeError: failed to load /usr/share/omarchy/shell/plugins/bar/widgets/ActiveWindow.qml:42; plugin: hancore.shibumi.control-center diff --git a/tests/fixtures/health-attribution/omarchy-unrelated-ambiguity.log b/tests/fixtures/health-attribution/omarchy-unrelated-ambiguity.log new file mode 100644 index 0000000..156f478 --- /dev/null +++ b/tests/fixtures/health-attribution/omarchy-unrelated-ambiguity.log @@ -0,0 +1,2 @@ +INFO Configuration Loaded +TypeError: failed to load /usr/share/omarchy/shell/plugins/bar/widgets/ActiveWindow.qml:42 and /home/test/Widget.qml:9 diff --git a/tests/fixtures/health-attribution/ownership-precedence.log b/tests/fixtures/health-attribution/ownership-precedence.log new file mode 100644 index 0000000..16eb489 --- /dev/null +++ b/tests/fixtures/health-attribution/ownership-precedence.log @@ -0,0 +1,2 @@ +INFO Configuration Loaded +TypeError: hancore.shibumi.control-center/ControlMainPage.qml failed while loading /usr/share/omarchy/shell/plugins/bar/widgets/ActiveWindow.qml:42 and mentions OmaConnect diff --git a/tests/fixtures/health-attribution/path-explicit-ambiguity.log b/tests/fixtures/health-attribution/path-explicit-ambiguity.log new file mode 100644 index 0000000..b7e1580 --- /dev/null +++ b/tests/fixtures/health-attribution/path-explicit-ambiguity.log @@ -0,0 +1,2 @@ +INFO Configuration Loaded +TypeError: failed to load /home/test/.config/omarchy/plugins/hancore.shibumi.control-center/ControlMainPage.qml:123; plugin: OmaConnect diff --git a/tests/fixtures/health-attribution/path-unrelated-ambiguity.log b/tests/fixtures/health-attribution/path-unrelated-ambiguity.log new file mode 100644 index 0000000..1743e7a --- /dev/null +++ b/tests/fixtures/health-attribution/path-unrelated-ambiguity.log @@ -0,0 +1,2 @@ +INFO Configuration Loaded +TypeError: failed to load /home/test/.config/omarchy/plugins/hancore.shibumi.control-center/ControlMainPage.qml:123 and /home/test/Widget.qml:9 diff --git a/tests/fixtures/health-attribution/shibumi-control-center.log b/tests/fixtures/health-attribution/shibumi-control-center.log new file mode 100644 index 0000000..bb0257a --- /dev/null +++ b/tests/fixtures/health-attribution/shibumi-control-center.log @@ -0,0 +1,2 @@ +INFO Configuration Loaded +Binding loop: /home/test/.config/omarchy/plugins/hancore.shibumi.control-center/ControlMainPage.qml:123 diff --git a/tests/health-diagnostics-regression.sh b/tests/health-diagnostics-regression.sh index 2154079..7f1c286 100755 --- a/tests/health-diagnostics-regression.sh +++ b/tests/health-diagnostics-regression.sh @@ -11,6 +11,7 @@ health_contract="$repo_root/docs/health-diagnostics.md" health_service="$repo_root/hancore.shibumi.control-center/HealthService.qml" bar_widget="$repo_root/hancore.shibumi.control-center/BarWidget.qml" control_panel="$repo_root/hancore.shibumi.control-center/ControlCenterPanel.qml" +health_page="$repo_root/hancore.shibumi.control-center/ControlMainPage.qml" [[ -x $health_runner ]] || { printf 'health diagnostics regression failed: runner is not executable\n' >&2 @@ -30,7 +31,14 @@ for contract in \ '["qs", "list", "--all", "--json"]' \ '[str(command), "shell", "ping"]' \ '"--tail",' \ - 'SENSITIVE_PATTERNS'; do + 'SENSITIVE_PATTERNS' \ + 'OWNER_SHIBUMI' \ + 'def attribute_source' \ + 'def is_omarchy_path' \ + 'runtime-errors-omarchy' \ + 'runtime-errors-sensitive' \ + 'suppressed_sensitive' \ + 'issue_eligible=owner == OWNER_SHIBUMI'; do rg -Fq "$contract" "$health_runner" || { printf 'health diagnostics regression failed: missing %s\n' "$contract" >&2 exit 1 @@ -44,7 +52,12 @@ for contract in \ 'if (healthProbe.running || healthCommand === "") return false' \ 'Number(parsed.schemaVersion || 0) !== 1' \ 'typeof parsed.summary !== "string"' \ - 'Array.isArray(parsed.checks)'; do + 'Array.isArray(parsed.checks)' \ + 'const owners = ["shibumi", "omarchy", "third-party", "unknown"]' \ + 'normalized.issueEligible = normalized.owner === "shibumi"' \ + 'function sanitizeDiagnosticText(value, limit)' \ + 'const sensitive = /authorization|cookie|credential|password|secret|ssid|token/i' \ + 'return "[sensitive diagnostic redacted]"'; do rg -Fq "$contract" "$health_service" || { printf 'health diagnostics regression failed: missing service contract %s\n' \ "$contract" >&2 @@ -58,12 +71,29 @@ rg -Fq 'if (opened) healthState.ensureFresh(300)' "$bar_widget" \ || { printf 'health diagnostics regression failed: open refresh missing\n' >&2; exit 1; } rg -Fq 'required property var healthService' "$control_panel" \ || { printf 'health diagnostics regression failed: panel facade missing\n' >&2; exit 1; } +for contract in \ + 'function ownerLabel(owner)' \ + 'String(check.status || "") !== "error"' \ + 'check.issueEligible === true' \ + 'String(check.owner || "") !== "shibumi"'; do + rg -Fq "$contract" "$health_page" || { + printf 'health diagnostics regression failed: missing UI attribution contract %s\n' \ + "$contract" >&2 + exit 1 + } +done if rg -q 'Timer\s*\{' "$health_service"; then printf 'health diagnostics regression failed: health service must not poll\n' >&2 exit 1 fi +if rg -q 'gh (issue create|api)|api\.github\.com|requests\.post|curl .*api\.github' \ + "$health_runner" "$health_page"; then + printf 'health diagnostics regression failed: Health must not perform GitHub writes\n' >&2 + exit 1 +fi + rg -Fq 'Lock**, **Suspend**, **Reboot**, and **Shutdown** remain in Omarchy' \ "$health_contract" || { printf 'health diagnostics regression failed: retired actions undocumented\n' >&2 diff --git a/tests/test_shibumi_health.py b/tests/test_shibumi_health.py index d45afbb..e5569cf 100644 --- a/tests/test_shibumi_health.py +++ b/tests/test_shibumi_health.py @@ -20,6 +20,7 @@ / "manager" / "shibumi-health" ) +HEALTH_ATTRIBUTION_FIXTURES = REPO_ROOT / "tests/fixtures/health-attribution" QUICKSHELL_EMPTY_REGISTRY = "No running instances.\n" INVALID_EMPTY_REGISTRY_OUTPUTS = ( @@ -129,6 +130,18 @@ def setUp(self) -> None: "enabled": False, "active": False, }, + { + "id": "hancore.shibumi.control-center", + "kinds": ["service"], + "enabled": True, + "active": True, + }, + { + "id": "OmaConnect", + "kinds": ["service"], + "enabled": True, + "active": True, + }, ] self.write_json(self.registry_file, self.registry) self.process_file = self.root / "processes.json" @@ -633,6 +646,158 @@ def test_bar_mismatch_and_failed_lifecycle_are_errors(self) -> None: self.assertEqual(checks["bar-runtime"]["status"], "error") self.assertEqual(checks["lifecycle"]["value"], "Last switch failed") + def test_runtime_errors_are_attributed_without_cross_owner_issue_filing(self) -> None: + self.log_file.write_text( + (HEALTH_ATTRIBUTION_FIXTURES / "mixed-runtime-errors.log") + .read_text(encoding="utf-8"), + encoding="utf-8", + ) + checks = self.by_id(self.run_health()) + + shibumi = checks["runtime-errors"] + self.assertEqual(shibumi["owner"], "shibumi") + self.assertEqual(shibumi["pluginId"], "hancore.shibumi.control-center") + self.assertIn("ControlMainPage.qml:123", shibumi["sourcePath"]) + self.assertTrue(shibumi["issueEligible"]) + + omarchy = checks["runtime-errors-omarchy"] + self.assertEqual(omarchy["owner"], "omarchy") + self.assertEqual(omarchy["pluginId"], "omarchy.active-window") + self.assertIn("/usr/share/omarchy/", omarchy["sourcePath"]) + self.assertFalse(omarchy["issueEligible"]) + self.assertIn("cannot file", omarchy["action"]) + + third_party = checks["runtime-errors-third-party"] + self.assertEqual(third_party["owner"], "third-party") + self.assertEqual(third_party["pluginId"], "OmaConnect") + self.assertIn("OmaConnect/Main.qml:18", third_party["sourcePath"]) + self.assertFalse(third_party["issueEligible"]) + self.assertIn("upstream", third_party["action"]) + + def test_authoritative_omarchy_path_wins_over_incidental_plugin_names(self) -> None: + self.log_file.write_text( + (HEALTH_ATTRIBUTION_FIXTURES / "ownership-precedence.log") + .read_text(encoding="utf-8"), + encoding="utf-8", + ) + check = self.by_id(self.run_health())["runtime-errors-omarchy"] + self.assertEqual(check["owner"], "omarchy") + self.assertEqual(check["pluginId"], "omarchy.active-window") + self.assertFalse(check["issueEligible"]) + + def test_omarchy_path_with_unrelated_source_remains_unknown(self) -> None: + self.log_file.write_text( + (HEALTH_ATTRIBUTION_FIXTURES / "omarchy-unrelated-ambiguity.log") + .read_text(encoding="utf-8"), + encoding="utf-8", + ) + check = self.by_id(self.run_health())["runtime-errors"] + self.assertEqual(check["owner"], "unknown") + self.assertFalse(check["issueEligible"]) + + def test_omarchy_path_with_explicit_shibumi_id_remains_unknown(self) -> None: + self.log_file.write_text( + (HEALTH_ATTRIBUTION_FIXTURES / "omarchy-explicit-ambiguity.log") + .read_text(encoding="utf-8"), + encoding="utf-8", + ) + check = self.by_id(self.run_health())["runtime-errors"] + self.assertEqual(check["owner"], "unknown") + self.assertFalse(check["issueEligible"]) + + def test_unanchored_plugin_names_and_urls_remain_unknown(self) -> None: + probe = self.module["Probe"](False) + attribution = probe.attribute_source( + "TypeError: https://user:secret@host/hancore.shibumi.control-center/Thing.qml:4" + ) + self.assertEqual(attribution["owner"], "unknown") + self.assertNotIn("secret", attribution["sourcePath"]) + + def test_unverified_explicit_plugin_id_remains_unknown(self) -> None: + probe = self.module["Probe"](False) + attribution = probe.attribute_source( + "TypeError: plugin: hancore.shibumi.not-installed" + ) + self.assertEqual(attribution["owner"], "unknown") + self.assertEqual(attribution["pluginId"], "hancore.shibumi.not-installed") + + def test_mixed_verified_plugin_owners_remain_unknown(self) -> None: + self.log_file.write_text( + (HEALTH_ATTRIBUTION_FIXTURES / "ambiguous-ownership.log") + .read_text(encoding="utf-8"), + encoding="utf-8", + ) + check = self.by_id(self.run_health())["runtime-errors"] + self.assertEqual(check["owner"], "unknown") + self.assertFalse(check["issueEligible"]) + self.assertIn("hancore.shibumi.control-center", check["pluginId"]) + self.assertIn("OmaConnect", check["pluginId"]) + + def test_plugin_path_and_explicit_owner_conflict_remains_unknown(self) -> None: + self.log_file.write_text( + (HEALTH_ATTRIBUTION_FIXTURES / "path-explicit-ambiguity.log") + .read_text(encoding="utf-8"), + encoding="utf-8", + ) + check = self.by_id(self.run_health())["runtime-errors"] + self.assertEqual(check["owner"], "unknown") + self.assertFalse(check["issueEligible"]) + + def test_plugin_path_and_unrelated_path_remains_unknown(self) -> None: + self.log_file.write_text( + (HEALTH_ATTRIBUTION_FIXTURES / "path-unrelated-ambiguity.log") + .read_text(encoding="utf-8"), + encoding="utf-8", + ) + check = self.by_id(self.run_health())["runtime-errors"] + self.assertEqual(check["owner"], "unknown") + self.assertFalse(check["issueEligible"]) + + def test_nested_omarchy_name_does_not_override_third_party_root(self) -> None: + self.log_file.write_text( + (HEALTH_ATTRIBUTION_FIXTURES / "nested-omarchy-name.log") + .read_text(encoding="utf-8"), + encoding="utf-8", + ) + check = self.by_id(self.run_health())["runtime-errors-third-party"] + self.assertEqual(check["owner"], "third-party") + self.assertEqual(check["pluginId"], "OmaConnect") + + def test_public_text_redacts_quoted_sensitive_values(self) -> None: + probe = self.module["Probe"](False) + sanitized = probe.public_text( + '"password":"secret value" token="long secret token"' + ) + self.assertNotIn("secret value", sanitized) + self.assertNotIn("long secret token", sanitized) + escaped = probe.public_text('password="sec\\\"ret"') + self.assertNotIn("sec", escaped) + self.assertIn("redacted", sanitized) + + def test_unattributed_runtime_error_is_not_issue_eligible(self) -> None: + self.log_file.write_text( + "INFO Configuration Loaded\n" + "TypeError in /home/test/Widget.qml:9\n", + encoding="utf-8", + ) + check = self.by_id(self.run_health())["runtime-errors"] + self.assertEqual(check["owner"], "unknown") + self.assertFalse(check["issueEligible"]) + self.assertIn("Confirm the source", check["action"]) + + def test_sensitive_runtime_error_does_not_report_clean(self) -> None: + self.log_file.write_text( + "INFO Configuration Loaded\n" + "TypeError password=secret-value\n", + encoding="utf-8", + ) + checks = self.by_id(self.run_health()) + self.assertNotIn("runtime-errors", checks) + check = checks["runtime-errors-sensitive"] + self.assertEqual(check["status"], "warning") + self.assertEqual(check["value"], "Details redacted") + self.assertNotIn("secret-value", check["detail"]) + def test_logs_are_filtered_redacted_and_bounded(self) -> None: self.log_file.write_text( "TypeError from the previous configuration\n"