fix(doctor): honest module count, temp-path audit warning, connection line
Some checks are pending
Security / Security check (push) Waiting to run

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-18 00:56:46 +02:00
parent afa3fa7eae
commit 703d961cec
10 changed files with 303 additions and 3 deletions

View file

@ -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)';

View file

@ -1238,7 +1238,7 @@ class HubService {
final paths = results[5] as DaemonPathsResponse;
// Module count = distinct module_name across capabilities.
final moduleNames = <String>{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<String> installedModuleNames(Iterable<CapabilityEntry> caps) => {
for (final c in caps)
if (c.moduleName != 'system') c.moduleName,
};
class DoctorSnapshot {
final int moduleCount;
final int capabilityCount;

View file

@ -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",

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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<void> _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<void> _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,

View file

@ -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

View file

@ -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);
}
});
});
}