From 703d961cecccf4c926c148b5bc00e59571e10696 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 18 Jul 2026 00:56:46 +0200 Subject: [PATCH] fix(doctor): honest module count, temp-path audit warning, connection line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three doctor-page findings from the usertest panel: - Module tile no longer counts the hub's built-in 'system' pseudo-module — a fresh hub shows 0 modules, matching the welcome checklist's definition of an install. Counting is a top-level function with unit tests. - When the audit DB lives in an OS-cleanable temp directory (/var/folders, /tmp, Windows Temp), the daemon-files panel says so instead of presenting the state as healthy. The classifier is a top-level function with unit tests. - The daemon card states who-talks-to-whom-how in one line: endpoint, transport security (TLS / unencrypted-local / unencrypted), and whether a bearer token is attached (length only) — the auditor's baseline the green dot cannot answer. Also syncs pubspec.yaml (0.70.0 -> 0.72.0) with kStudioVersion, which had drifted to 0.71.0 while pubspec stayed behind. Signed-off-by: flemming-it --- lib/data/about_info.dart | 2 +- lib/data/hub.dart | 13 ++++- lib/l10n/app_de.arb | 19 ++++++++ lib/l10n/app_en.arb | 19 ++++++++ lib/l10n/app_localizations.dart | 42 ++++++++++++++++ lib/l10n/app_localizations_de.dart | 27 +++++++++++ lib/l10n/app_localizations_en.dart | 26 ++++++++++ lib/pages/doctor.dart | 78 ++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- test/doctor_facts_test.dart | 78 ++++++++++++++++++++++++++++++ 10 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 test/doctor_facts_test.dart diff --git a/lib/data/about_info.dart b/lib/data/about_info.dart index 5e8fef3..60ce76e 100644 --- a/lib/data/about_info.dart +++ b/lib/data/about_info.dart @@ -4,7 +4,7 @@ /// Studio's own build version. Bump on every UI release so the /// running app self-identifies. -const String kStudioVersion = '0.71.0'; +const String kStudioVersion = '0.72.0'; const String kProductName = 'Ch∆In Studio'; const String kVendorName = 'Flemming.AI (F∆I)'; diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 1939e6e..23112c3 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -1238,7 +1238,7 @@ class HubService { final paths = results[5] as DaemonPathsResponse; // Module count = distinct module_name across capabilities. - final moduleNames = {for (final c in caps) c.moduleName}; + final moduleNames = installedModuleNames(caps); // Per-source-kind breakdown (0.12+). Pre-0.12 hubs return // empty `source_kind` strings — group those under `kind` @@ -1306,6 +1306,17 @@ class UpdateStatus { }); } +/// Distinct installed-module names behind a capability list. +/// Excludes the hub's built-in `system` pseudo-module +/// (`builtin_capabilities` reports `module_name: "system"`): +/// the welcome checklist already treats `system.*` as "built +/// in, not an install", and the doctor tile must count the +/// same way — a fresh hub shows 0 modules, not 1. +Set installedModuleNames(Iterable caps) => { + for (final c in caps) + if (c.moduleName != 'system') c.moduleName, +}; + class DoctorSnapshot { final int moduleCount; final int capabilityCount; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6a6e293..a84c6ef 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1267,6 +1267,25 @@ "doctorPillStopped": "gestoppt", "doctorDaemonControlHint": "Studio ruft das Platform-Binary auf — entspricht chain daemon …, sodass CLI und UI im Gleichschritt bleiben.", "doctorDaemonControlWorking": "Wird ausgeführt…", + "doctorConnLine": "Verbindung: {endpoint} · {transport} · {auth}", + "@doctorConnLine": { + "placeholders": { + "endpoint": {"type": "String"}, + "transport": {"type": "String"}, + "auth": {"type": "String"} + } + }, + "doctorConnTls": "TLS-verschlüsselt", + "doctorConnPlainLocal": "unverschlüsselt (bleibt auf diesem Rechner)", + "doctorConnPlainRemote": "unverschlüsselt", + "doctorConnToken": "Token gesetzt ({count} Zeichen)", + "@doctorConnToken": { + "placeholders": { + "count": {"type": "int"} + } + }, + "doctorConnAnonymous": "ohne Token (anonym)", + "doctorDbVolatileWarning": "Die Audit-Datenbank liegt in einem temporären Ordner, den das Betriebssystem jederzeit aufräumen kann. Für belastbare Nachweise gehört das Datenverzeichnis des Hubs an einen dauerhaften Ort.", "doctorPathLog": "Log", "doctorPathConfig": "Konfig", "doctorPathDb": "Audit-DB", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index caec21b..fccc1a2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1291,6 +1291,25 @@ "doctorPillStopped": "stopped", "doctorDaemonControlHint": "Studio shells out to the platform binary — mirrors chain daemon … exactly so the CLI and UI stay in lockstep.", "doctorDaemonControlWorking": "Working…", + "doctorConnLine": "Connection: {endpoint} · {transport} · {auth}", + "@doctorConnLine": { + "placeholders": { + "endpoint": {"type": "String"}, + "transport": {"type": "String"}, + "auth": {"type": "String"} + } + }, + "doctorConnTls": "TLS-encrypted", + "doctorConnPlainLocal": "unencrypted (stays on this machine)", + "doctorConnPlainRemote": "unencrypted", + "doctorConnToken": "token configured ({count} characters)", + "@doctorConnToken": { + "placeholders": { + "count": {"type": "int"} + } + }, + "doctorConnAnonymous": "no token (anonymous)", + "doctorDbVolatileWarning": "The audit database is stored in a temporary folder the operating system may clean up at any time. For real evidence, move the hub's data directory to a permanent location.", "doctorPathLog": "Log", "doctorPathConfig": "Config", "doctorPathDb": "Audit DB", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 01eeb46..f5c1e67 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3836,6 +3836,48 @@ abstract class AppLocalizations { /// **'Working…'** String get doctorDaemonControlWorking; + /// No description provided for @doctorConnLine. + /// + /// In en, this message translates to: + /// **'Connection: {endpoint} · {transport} · {auth}'** + String doctorConnLine(String endpoint, String transport, String auth); + + /// No description provided for @doctorConnTls. + /// + /// In en, this message translates to: + /// **'TLS-encrypted'** + String get doctorConnTls; + + /// No description provided for @doctorConnPlainLocal. + /// + /// In en, this message translates to: + /// **'unencrypted (stays on this machine)'** + String get doctorConnPlainLocal; + + /// No description provided for @doctorConnPlainRemote. + /// + /// In en, this message translates to: + /// **'unencrypted'** + String get doctorConnPlainRemote; + + /// No description provided for @doctorConnToken. + /// + /// In en, this message translates to: + /// **'token configured ({count} characters)'** + String doctorConnToken(int count); + + /// No description provided for @doctorConnAnonymous. + /// + /// In en, this message translates to: + /// **'no token (anonymous)'** + String get doctorConnAnonymous; + + /// No description provided for @doctorDbVolatileWarning. + /// + /// In en, this message translates to: + /// **'The audit database is stored in a temporary folder the operating system may clean up at any time. For real evidence, move the hub\'s data directory to a permanent location.'** + String get doctorDbVolatileWarning; + /// No description provided for @doctorPathLog. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index bfac38e..e0a531c 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2255,6 +2255,33 @@ class AppLocalizationsDe extends AppLocalizations { @override String get doctorDaemonControlWorking => 'Wird ausgeführt…'; + @override + String doctorConnLine(String endpoint, String transport, String auth) { + return 'Verbindung: $endpoint · $transport · $auth'; + } + + @override + String get doctorConnTls => 'TLS-verschlüsselt'; + + @override + String get doctorConnPlainLocal => + 'unverschlüsselt (bleibt auf diesem Rechner)'; + + @override + String get doctorConnPlainRemote => 'unverschlüsselt'; + + @override + String doctorConnToken(int count) { + return 'Token gesetzt ($count Zeichen)'; + } + + @override + String get doctorConnAnonymous => 'ohne Token (anonym)'; + + @override + String get doctorDbVolatileWarning => + 'Die Audit-Datenbank liegt in einem temporären Ordner, den das Betriebssystem jederzeit aufräumen kann. Für belastbare Nachweise gehört das Datenverzeichnis des Hubs an einen dauerhaften Ort.'; + @override String get doctorPathLog => 'Log'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c1de911..4dae3e9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2261,6 +2261,32 @@ class AppLocalizationsEn extends AppLocalizations { @override String get doctorDaemonControlWorking => 'Working…'; + @override + String doctorConnLine(String endpoint, String transport, String auth) { + return 'Connection: $endpoint · $transport · $auth'; + } + + @override + String get doctorConnTls => 'TLS-encrypted'; + + @override + String get doctorConnPlainLocal => 'unencrypted (stays on this machine)'; + + @override + String get doctorConnPlainRemote => 'unencrypted'; + + @override + String doctorConnToken(int count) { + return 'token configured ($count characters)'; + } + + @override + String get doctorConnAnonymous => 'no token (anonymous)'; + + @override + String get doctorDbVolatileWarning => + 'The audit database is stored in a temporary folder the operating system may clean up at any time. For real evidence, move the hub\'s data directory to a permanent location.'; + @override String get doctorPathLog => 'Log'; diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index d91bbd9..2079e36 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../data/error_presentation.dart'; import '../data/chain_log.dart'; import '../data/hub.dart'; +import '../data/hub_auth_token.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; @@ -334,6 +335,24 @@ class _EventLogPanel extends StatelessWidget { } } +/// True when [path] points into an OS-managed temporary +/// directory (macOS `/var/folders`, Unix `/tmp`, the Windows +/// user/system temp folders) — locations the OS may clean up +/// at any time. The doctor page warns when the audit DB lives +/// in one: an evidence log the OS can silently delete is not +/// evidence. Top-level so the unit test can exercise the +/// classification directly. +bool isVolatilePath(String path) { + if (path.isEmpty) return false; + final p = path.toLowerCase().replaceAll('\\', '/'); + return p.startsWith('/tmp/') || + p.startsWith('/private/tmp/') || + p.startsWith('/var/folders/') || + p.startsWith('/private/var/folders/') || + p.contains('/appdata/local/temp/') || + p.contains('/windows/temp/'); +} + /// Lists the daemon's filesystem paths with one "Open" button /// per row. Windows operators who never touch a shell still /// need a way to inspect the audit DB or edit the operator @@ -374,10 +393,33 @@ class _DaemonPathsPanel extends StatelessWidget { ); } + final theme = Theme.of(context); return ChainCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (isVolatilePath(paths.dbPath)) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.warning_amber_outlined, + size: 16, + color: ChainColors.warning, + ), + const SizedBox(width: ChainSpace.sm), + Expanded( + child: Text( + l.doctorDbVolatileWarning, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: ChainSpace.sm), + ], for (final e in entries) _PathRow(label: e.$1, path: e.$2, icon: e.$3, isDirectory: e.$4), ], @@ -512,10 +554,20 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { bool _binaryMissing = false; ChannelStatusSnapshot? _channels; + /// Trimmed length of `~/.chain/hub-auth-token`, or null when + /// Studio talks to the hub anonymously. Length only — the + /// secret itself never reaches the UI. + int? _tokenChars; + @override void initState() { super.initState(); _refreshStatus(); + HubAuthToken.charCount() + .then((n) { + if (mounted) setState(() => _tokenChars = n); + }) + .catchError((_) {}); } Future _refreshStatus() async { @@ -528,6 +580,21 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { } } + String _connectionLine(AppLocalizations l) { + final ep = HubService.instance.currentEndpoint; + final isLocal = + ep.host == '127.0.0.1' || ep.host == 'localhost' || ep.host == '::1'; + final transport = ep.secure + ? l.doctorConnTls + : isLocal + ? l.doctorConnPlainLocal + : l.doctorConnPlainRemote; + final auth = _tokenChars == null + ? l.doctorConnAnonymous + : l.doctorConnToken(_tokenChars!); + return l.doctorConnLine(ep.toString(), transport, auth); + } + Future _run( String label, Future<({bool ok, String stdout, String stderr})> Function() action, @@ -626,6 +693,17 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { color: theme.colorScheme.onSurfaceVariant, ), ), + const SizedBox(height: 2), + // Who-talks-to-whom-how in one line: endpoint, + // transport security, and whether a bearer token is + // attached — the auditor's baseline questions the + // green "connected" dot alone cannot answer. + SelectableText( + _connectionLine(l), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), const SizedBox(height: ChainSpace.md), Wrap( spacing: ChainSpace.sm, diff --git a/pubspec.yaml b/pubspec.yaml index cfb19e4..5e49b08 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: chain_studio description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" publish_to: 'none' -version: 0.70.0 +version: 0.72.0 environment: sdk: ^3.11.0-200.1.beta diff --git a/test/doctor_facts_test.dart b/test/doctor_facts_test.dart new file mode 100644 index 0000000..becaf5d --- /dev/null +++ b/test/doctor_facts_test.dart @@ -0,0 +1,78 @@ +// Guards two doctor-page facts the usertest panel caught drifting: +// +// 1. The module count must exclude the hub's built-in `system` +// pseudo-module — a fresh hub shows 0 modules, matching the +// welcome checklist's "system.* are built-ins, not installs". +// 2. The volatile-path classifier behind the audit-DB warning: +// an evidence log in an OS-cleanable temp directory must be +// flagged, real operator paths must not. + +import 'package:chain_client_sdk/chain_client_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/pages/doctor.dart'; + +CapabilityEntry _cap(String capability, String moduleName) => + CapabilityEntry(capability: capability, moduleName: moduleName); + +void main() { + group('installedModuleNames', () { + test('a fresh hub (builtins only) counts zero modules', () { + final caps = [_cap('system.approval', 'system')]; + expect(installedModuleNames(caps), isEmpty); + }); + + test('real modules count, builtins stay excluded', () { + final caps = [ + _cap('system.approval', 'system'), + _cap('debug.echo', 'echo'), + _cap('text.extract', 'text-extract'), + _cap('text.diff', 'text-diff'), + ]; + expect( + installedModuleNames(caps), + {'echo', 'text-extract', 'text-diff'}, + ); + }); + + test('one module providing two capabilities counts once', () { + final caps = [ + _cap('doc.hash', 'doc-tools'), + _cap('doc.validate', 'doc-tools'), + ]; + expect(installedModuleNames(caps), {'doc-tools'}); + }); + }); + + group('isVolatilePath', () { + test('flags OS temp locations on every platform', () { + const volatile = [ + '/var/folders/8l/j_3vtbb579z/T/chain_studio_test_x/chain.db', + '/private/var/folders/8l/j_3vtbb579z/T/x/chain.db', + '/tmp/chain-test/.chain/chain.db', + '/private/tmp/chain-test/chain.db', + r'C:\Users\op\AppData\Local\Temp\chain\chain.db', + r'C:\Windows\Temp\chain.db', + ]; + for (final p in volatile) { + expect(isVolatilePath(p), isTrue, reason: p); + } + }); + + test('leaves real operator paths alone', () { + const durable = [ + '/Users/op/.chain/chain.db', + '/home/op/.chain/chain.db', + r'C:\Users\op\.chain\chain.db', + '/var/lib/chain/chain.db', + // "tmp" as a name fragment is not a temp directory. + '/Users/op/tmpfiles/.chain/chain.db', + '', + ]; + for (final p in durable) { + expect(isVolatilePath(p), isFalse, reason: p); + } + }); + }); +}