feat(studio): registry credentials panel in Settings (v0.44.0)

The hub install path now reads `~/.fai/registry-token` as a
fallback when `FAI_REGISTRY_TOKEN` is unset (platform v0.10.92).
Studio now writes that file directly: a fresh operator pastes
the PAT into Settings → Registry credentials, hits Save, and
the next install attempt resolves the auth wall without any
shell or env-var setup.

The token never round-trips back into Studio after save —
status is shown only as "Configured (40 chars)" / "Not set"
with no display of the secret itself. The Clear action deletes
the file. On Unix the file is chmod-ed to 0600 (owner-only
read/write); Windows leaves the default user ACL in place.

Storage is strictly local: `~/.fai/registry-token`, never sent
to a remote service. The hint text under the field says so to
make the data flow explicit.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-09 13:38:47 +02:00
parent 01f3f773cf
commit 878f0a4e28
8 changed files with 464 additions and 6 deletions

View file

@ -0,0 +1,66 @@
import 'dart:io';
import 'package:path/path.dart' as p;
/// Operator-managed registry auth token, kept at
/// `~/.fai/registry-token` (mode 0600 on Unix). The hub reads
/// this file at install time when `FAI_REGISTRY_TOKEN` is unset
/// see `download_to_temp` in `crates/fai_hub/src/lib.rs`.
///
/// Studio writes the file directly so a fresh install never
/// requires the operator to fiddle with shell env vars.
class RegistryToken {
static String _faiHome() {
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
if (home == null || home.isEmpty) {
throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)');
}
return p.join(home, '.fai');
}
/// Absolute path to the token file.
static String get path => p.join(_faiHome(), 'registry-token');
/// True iff the file exists with a non-empty trimmed body.
static Future<bool> isConfigured() async {
final f = File(path);
if (!await f.exists()) return false;
final content = await f.readAsString();
return content.trim().isNotEmpty;
}
/// Length of the trimmed token, or null when the file is
/// missing / empty. Used for status display ("Configured
/// (40 chars)") without ever surfacing the secret.
static Future<int?> charCount() async {
final f = File(path);
if (!await f.exists()) return null;
final content = await f.readAsString();
final trimmed = content.trim();
return trimmed.isEmpty ? null : trimmed.length;
}
/// Persist [token] to disk, creating `~/.fai/` if needed.
/// On Unix the file is chmod-ed to 0600 (owner read/write
/// only). On Windows the default user-ACL is left alone
/// best-effort, no PowerShell handshake.
static Future<void> write(String token) async {
final f = File(path);
await f.parent.create(recursive: true);
await f.writeAsString(token.trim(), flush: true);
if (Platform.isLinux || Platform.isMacOS) {
try {
await Process.run('chmod', ['600', f.path]);
} catch (_) {
// best-effort; if chmod is unavailable the file still
// exists with default perms (usually 0644, world-readable
// but only on the operator's own machine).
}
}
}
/// Delete the token file. No-op when the file doesn't exist.
static Future<void> delete() async {
final f = File(path);
if (await f.exists()) await f.delete();
}
}

View file

@ -433,6 +433,22 @@
"@flowsOpenInEditorFailed": {
"placeholders": { "error": { "type": "String" } }
},
"registryCredentialsHeader": "REGISTRY-ZUGANGSDATEN",
"registryCredentialsBlurb": "Token zum Herunterladen von .fai-Modulen aus einer Registry mit Anmeldepflicht (Forgejo, private GitHub-Repos). Gespeichert in ~/.fai/registry-token, Modus 0600. Die Umgebungsvariable FAI_REGISTRY_TOKEN hat weiterhin Vorrang, wenn gesetzt.",
"registryTokenStatusConfigured": "Konfiguriert ({chars} Zeichen)",
"@registryTokenStatusConfigured": { "placeholders": { "chars": { "type": "int" } } },
"registryTokenStatusNotSet": "Nicht gesetzt",
"registryTokenFieldLabel": "Token",
"registryTokenFieldHint": "Forgejo- oder GitHub-PAT einfügen",
"registryTokenSaveButton": "Speichern",
"registryTokenClearButton": "Entfernen",
"registryTokenStorageHint": "Lokal gespeichert in ~/.fai/registry-token. Wird nie an einen Remote-Dienst übertragen.",
"registryTokenSavedToast": "Registry-Token gespeichert.",
"registryTokenClearedToast": "Registry-Token entfernt.",
"registryTokenSaveFailedToast": "Speichern fehlgeschlagen: {error}",
"@registryTokenSaveFailedToast": {
"placeholders": { "error": { "type": "String" } }
},
"welcomeChecklistAllSetTitle": "Du bist eingerichtet.",
"welcomeChecklistAllSetBody": "Drei Stränge, an denen du als nächstes ziehen kannst:",
"welcomeChecklistNextAuditTitle": "Audit-Log lesen",

View file

@ -434,6 +434,22 @@
"@flowsOpenInEditorFailed": {
"placeholders": { "error": { "type": "String" } }
},
"registryCredentialsHeader": "REGISTRY CREDENTIALS",
"registryCredentialsBlurb": "Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.fai/registry-token, mode 0600. The FAI_REGISTRY_TOKEN env var still wins when set.",
"registryTokenStatusConfigured": "Configured ({chars} chars)",
"@registryTokenStatusConfigured": { "placeholders": { "chars": { "type": "int" } } },
"registryTokenStatusNotSet": "Not set",
"registryTokenFieldLabel": "Token",
"registryTokenFieldHint": "Paste your Forgejo or GitHub personal access token",
"registryTokenSaveButton": "Save",
"registryTokenClearButton": "Clear",
"registryTokenStorageHint": "Saved locally to ~/.fai/registry-token. Never sent to a remote service.",
"registryTokenSavedToast": "Registry token saved.",
"registryTokenClearedToast": "Registry token cleared.",
"registryTokenSaveFailedToast": "Could not save: {error}",
"@registryTokenSaveFailedToast": {
"placeholders": { "error": { "type": "String" } }
},
"welcomeChecklistAllSetTitle": "You're set up.",
"welcomeChecklistAllSetBody": "Three threads to pull on next:",
"welcomeChecklistNextAuditTitle": "Read the audit log",

View file

@ -1970,6 +1970,78 @@ abstract class AppLocalizations {
/// **'Could not open file: {error}'**
String flowsOpenInEditorFailed(String error);
/// No description provided for @registryCredentialsHeader.
///
/// In en, this message translates to:
/// **'REGISTRY CREDENTIALS'**
String get registryCredentialsHeader;
/// No description provided for @registryCredentialsBlurb.
///
/// In en, this message translates to:
/// **'Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.fai/registry-token, mode 0600. The FAI_REGISTRY_TOKEN env var still wins when set.'**
String get registryCredentialsBlurb;
/// No description provided for @registryTokenStatusConfigured.
///
/// In en, this message translates to:
/// **'Configured ({chars} chars)'**
String registryTokenStatusConfigured(int chars);
/// No description provided for @registryTokenStatusNotSet.
///
/// In en, this message translates to:
/// **'Not set'**
String get registryTokenStatusNotSet;
/// No description provided for @registryTokenFieldLabel.
///
/// In en, this message translates to:
/// **'Token'**
String get registryTokenFieldLabel;
/// No description provided for @registryTokenFieldHint.
///
/// In en, this message translates to:
/// **'Paste your Forgejo or GitHub personal access token'**
String get registryTokenFieldHint;
/// No description provided for @registryTokenSaveButton.
///
/// In en, this message translates to:
/// **'Save'**
String get registryTokenSaveButton;
/// No description provided for @registryTokenClearButton.
///
/// In en, this message translates to:
/// **'Clear'**
String get registryTokenClearButton;
/// No description provided for @registryTokenStorageHint.
///
/// In en, this message translates to:
/// **'Saved locally to ~/.fai/registry-token. Never sent to a remote service.'**
String get registryTokenStorageHint;
/// No description provided for @registryTokenSavedToast.
///
/// In en, this message translates to:
/// **'Registry token saved.'**
String get registryTokenSavedToast;
/// No description provided for @registryTokenClearedToast.
///
/// In en, this message translates to:
/// **'Registry token cleared.'**
String get registryTokenClearedToast;
/// No description provided for @registryTokenSaveFailedToast.
///
/// In en, this message translates to:
/// **'Could not save: {error}'**
String registryTokenSaveFailedToast(String error);
/// No description provided for @welcomeChecklistAllSetTitle.
///
/// In en, this message translates to:

View file

@ -1117,6 +1117,48 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Konnte Datei nicht öffnen: $error';
}
@override
String get registryCredentialsHeader => 'REGISTRY-ZUGANGSDATEN';
@override
String get registryCredentialsBlurb =>
'Token zum Herunterladen von .fai-Modulen aus einer Registry mit Anmeldepflicht (Forgejo, private GitHub-Repos). Gespeichert in ~/.fai/registry-token, Modus 0600. Die Umgebungsvariable FAI_REGISTRY_TOKEN hat weiterhin Vorrang, wenn gesetzt.';
@override
String registryTokenStatusConfigured(int chars) {
return 'Konfiguriert ($chars Zeichen)';
}
@override
String get registryTokenStatusNotSet => 'Nicht gesetzt';
@override
String get registryTokenFieldLabel => 'Token';
@override
String get registryTokenFieldHint => 'Forgejo- oder GitHub-PAT einfügen';
@override
String get registryTokenSaveButton => 'Speichern';
@override
String get registryTokenClearButton => 'Entfernen';
@override
String get registryTokenStorageHint =>
'Lokal gespeichert in ~/.fai/registry-token. Wird nie an einen Remote-Dienst übertragen.';
@override
String get registryTokenSavedToast => 'Registry-Token gespeichert.';
@override
String get registryTokenClearedToast => 'Registry-Token entfernt.';
@override
String registryTokenSaveFailedToast(String error) {
return 'Speichern fehlgeschlagen: $error';
}
@override
String get welcomeChecklistAllSetTitle => 'Du bist eingerichtet.';

View file

@ -1131,6 +1131,49 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Could not open file: $error';
}
@override
String get registryCredentialsHeader => 'REGISTRY CREDENTIALS';
@override
String get registryCredentialsBlurb =>
'Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.fai/registry-token, mode 0600. The FAI_REGISTRY_TOKEN env var still wins when set.';
@override
String registryTokenStatusConfigured(int chars) {
return 'Configured ($chars chars)';
}
@override
String get registryTokenStatusNotSet => 'Not set';
@override
String get registryTokenFieldLabel => 'Token';
@override
String get registryTokenFieldHint =>
'Paste your Forgejo or GitHub personal access token';
@override
String get registryTokenSaveButton => 'Save';
@override
String get registryTokenClearButton => 'Clear';
@override
String get registryTokenStorageHint =>
'Saved locally to ~/.fai/registry-token. Never sent to a remote service.';
@override
String get registryTokenSavedToast => 'Registry token saved.';
@override
String get registryTokenClearedToast => 'Registry token cleared.';
@override
String registryTokenSaveFailedToast(String error) {
return 'Could not save: $error';
}
@override
String get welcomeChecklistAllSetTitle => 'You\'re set up.';

View file

@ -6,6 +6,7 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart';
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../data/registry_token.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
@ -41,6 +42,10 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
SystemAiStatus? _aiStatus;
List<McpClientInfo>? _mcpClients;
List<N8nEndpointInfo>? _n8nEndpoints;
/// Length of the configured registry token, or null when the
/// `~/.fai/registry-token` file is missing / empty. Reloaded
/// after save / clear.
int? _registryTokenChars;
@override
void initState() {
@ -53,6 +58,51 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
_loadAiStatus();
_loadMcpClients();
_loadN8nEndpoints();
_loadRegistryToken();
}
Future<void> _loadRegistryToken() async {
try {
final n = await RegistryToken.charCount();
if (!mounted) return;
setState(() => _registryTokenChars = n);
} catch (_) {/* fail-quiet */}
}
Future<void> _saveRegistryToken(String token) async {
try {
await RegistryToken.write(token);
await _loadRegistryToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenSavedToast)),
);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
);
}
}
Future<void> _clearRegistryToken() async {
try {
await RegistryToken.delete();
await _loadRegistryToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenClearedToast)),
);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
);
}
}
Future<void> _loadN8nEndpoints() async {
@ -453,6 +503,12 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
onRefresh: _refreshN8nEndpoints,
),
],
const SizedBox(height: FaiSpace.lg),
_RegistryCredentialsPanel(
configuredChars: _registryTokenChars,
onSave: _saveRegistryToken,
onClear: _clearRegistryToken,
),
],
),
),
@ -1381,12 +1437,159 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
/// metadata; nothing is auto-spawned. Trust model:
/// referencing public projects with their canonical install
/// command is the same legal / security shape as a package
/// manager listing a third-party repo.
/// Credentials panel for the operator's registry auth token.
/// The hub reads the same token via `~/.fai/registry-token`
/// (or the `FAI_REGISTRY_TOKEN` env var, which still wins)
/// when downloading `.fai` bundles from a registry behind a
/// signin wall Forgejo with REQUIRE_SIGNIN_VIEW=true,
/// GitHub-private releases, etc.
///
/// All entries point at official Anthropic MCP servers from
/// `github.com/modelcontextprotocol/servers` plus a few
/// community-curated standards. Refresh the list when the
/// upstream README changes.
/// The token itself is never round-tripped back into Studio
/// after save: `configuredChars` is the trimmed length, used
/// only as a "Configured (40 chars)" status display so the
/// operator knows something landed without seeing the secret.
class _RegistryCredentialsPanel extends StatefulWidget {
/// Trimmed length of the persisted token, or null when none
/// is configured.
final int? configuredChars;
final ValueChanged<String> onSave;
final Future<void> Function() onClear;
const _RegistryCredentialsPanel({
required this.configuredChars,
required this.onSave,
required this.onClear,
});
@override
State<_RegistryCredentialsPanel> createState() =>
_RegistryCredentialsPanelState();
}
class _RegistryCredentialsPanelState
extends State<_RegistryCredentialsPanel> {
final _controller = TextEditingController();
bool _obscured = true;
@override
void initState() {
super.initState();
_controller.addListener(() {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final isConfigured = widget.configuredChars != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.registryCredentialsHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.registryCredentialsBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
Icon(
isConfigured ? Icons.check_circle : Icons.radio_button_unchecked,
size: 14,
color: isConfigured
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.xs),
Text(
isConfigured
? l.registryTokenStatusConfigured(widget.configuredChars!)
: l.registryTokenStatusNotSet,
style: theme.textTheme.bodySmall?.copyWith(
color: isConfigured
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
fontWeight: isConfigured ? FontWeight.w600 : null,
),
),
const Spacer(),
if (isConfigured)
TextButton.icon(
icon: const Icon(Icons.delete_outline, size: 14),
onPressed: () async {
await widget.onClear();
_controller.clear();
},
label: Text(l.registryTokenClearButton),
),
],
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
Expanded(
child: TextField(
controller: _controller,
obscureText: _obscured,
decoration: InputDecoration(
labelText: l.registryTokenFieldLabel,
hintText: l.registryTokenFieldHint,
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: IconButton(
icon: Icon(
_obscured ? Icons.visibility : Icons.visibility_off,
size: 16,
),
onPressed: () => setState(() => _obscured = !_obscured),
),
),
),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon(
icon: const Icon(Icons.save_outlined, size: 14),
label: Text(l.registryTokenSaveButton),
onPressed: _controller.text.trim().isEmpty
? null
: () {
widget.onSave(_controller.text);
_controller.clear();
},
),
],
),
const SizedBox(height: 4),
Text(
l.registryTokenStorageHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
);
}
}
class _McpSuggestion {
final String name;
final IconData icon;