Repository navigation
Expand file tree
/
Copy pathAppConfig.cs
More file actions
202 lines (189 loc) · 11.2 KB
/
Copy pathAppConfig.cs
File metadata and controls
202 lines (189 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
// ═══════════════════════════════════════════════════════════════════════
// AppConfig — single persistent-config directory rule
//
// Every file the user edits or the app persists that must SURVIVE updates lives
// under <app folder>\PersistentData\ — never next to the assemblies. Updates
// replace the app folder, so anything loose next to the exe is either
// regenerated by the SDK (agent.*.json) or would be overwritten/shipped by
// mistake. Per-user state that must not follow a portable copy stays in the OS
// app-data folder. See docs-dev/ARCHITECTURE.md "Where files live (storage
// tiers)" and docs-dev/RELEASE-CHECKLIST.md.
// ═══════════════════════════════════════════════════════════════════════
using System.Reflection;
/// <summary>Central definition of the persistent-config layout plus the one-time migration
/// from the legacy "next to the executable" locations, the default-appsettings seed and the
/// DEBUG tripwire that refuses to start when config json sits next to the assemblies.</summary>
public static class AppConfig
{
private static string? _persistentDir;
/// <summary>The default persistent-config folder, next to the executable.</summary>
private static string DefaultPersistentDir => Path.Combine(AppContext.BaseDirectory, "PersistentData");
/// <summary>User-editable configuration folder: inside the app folder but never part of
/// any update (it is not in the release archive and the updater never touches it).
///
/// When the app folder is not writable — the app installed under a protected location
/// (C:\Program Files on Windows, a system directory on Linux/macOS) and run without
/// elevation, or read-only media — the folder falls back to the OS app-data folder so the
/// app still starts. <see cref="Initialize"/> resolves it once; before that the property
/// reports the default location. Regression: crash report #83 (DirectoryNotFoundException
/// at startup because the config provider was built on a folder that could not be created).</summary>
public static string PersistentDir => _persistentDir ?? DefaultPersistentDir;
/// <summary>Server/user configuration file (persistent, user-editable).</summary>
public static string AppSettingsFile => Path.Combine(PersistentDir, "appsettings.json");
/// <summary>LLM provider definitions file (persistent, user-editable).</summary>
public static string ProvidersFile => Path.Combine(PersistentDir, "providers.json");
/// <summary>Telegram chat-medium config file (persistent, user-editable).</summary>
public static string TelegramFile => Path.Combine(PersistentDir, "telegram.json");
/// <summary>Per-tool policy file (persistent, user-editable).</summary>
public static string ToolsFile => Path.Combine(PersistentDir, "tools.json");
/// <summary>TUI agent tool-set file — the last /tools selection (custom combination or
/// agent-set preset), restored at startup so the enabled tools survive a restart.</summary>
public static string ToolsetFile => Path.Combine(PersistentDir, "toolset.json");
/// <summary>Telegram session file (auth keys, written on the first login).</summary>
public static string TelegramSessionFile => Path.Combine(PersistentDir, "telegram.session");
/// <summary>SDK-generated json that legitimately sits next to the executable — runtime
/// payload, not configuration (never flagged by <see cref="GuardNoStrayRootJson"/>).</summary>
private static string[] GeneratedRootJson()
{
var name = Assembly.GetExecutingAssembly().GetName().Name ?? "agent";
return [name + ".deps.json", name + ".runtimeconfig.json", name + ".staticwebassets.endpoints.json"];
}
/// <summary>Runs once at startup, before anything reads configuration:
/// anchors the process CWD to the executable folder, migrates legacy root config files
/// into <see cref="PersistentDir"/> and seeds the default appsettings.json there.</summary>
public static void Initialize()
{
// The process must behave identically no matter which directory the user launched it
// from: anchor the CWD to the executable folder so relative paths (ours or a spawned
// child's) resolve against the install dir, never against the caller's terminal.
try { Directory.SetCurrentDirectory(AppContext.BaseDirectory); } catch { }
// Resolve a writable persistent-config folder. The default is PersistentData next to the
// executable; when that cannot be created or written (read-only install dir, no write
// permission — e.g. the app installed under C:\Program Files and run without elevation,
// or read-only media) fall back to the OS app-data folder. Without this the app boots
// but crashes a few lines later when the ASP.NET config provider is built: the
// PhysicalFileProvider constructor throws DirectoryNotFoundException on a missing
// folder, and the failure is silent until then. Regression: crash report #83.
var preferred = DefaultPersistentDir;
if (EnsureWritable(preferred))
{
_persistentDir = preferred;
}
else
{
// Fallback to the OS app-data folder. GetFolderPath can return an empty string on a
// misconfigured environment (no XDG_DATA_HOME and no resolvable HOME on Linux); a
// relative fallback would then resolve against the CWD and break the config seeding,
// so require a rooted path and fall back to the temp dir otherwise.
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var root = !string.IsNullOrWhiteSpace(appData) && Path.IsPathRooted(appData)
? appData
: Path.GetTempPath();
var fallback = Path.Combine(root, "agent", "PersistentData");
EnsureWritable(fallback);
_persistentDir = fallback;
Console.Error.WriteLine(
$"AppConfig: cannot write to {preferred} (read-only folder or no permission) — " +
$"persistent configuration moved to {fallback}");
}
MigrateLegacy(AppSettingsFile, "appsettings.json");
MigrateLegacy(ProvidersFile, "providers.json");
MigrateLegacy(TelegramFile, "telegram.json");
MigrateLegacy(TelegramSessionFile, "telegram.session");
MigrateLegacy(ToolsFile, "tools.json");
// Seed the default appsettings.json (embedded copy of the repo file) so the ASP.NET
// config chain and the file-writing components (SipBridge, TUI) always find it. A
// user-edited file already in place is never overwritten.
if (!File.Exists(AppSettingsFile))
{
try
{
using var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("AgentBridge.appsettings.default.json");
if (stream != null)
{
using var file = File.Create(AppSettingsFile);
stream.CopyTo(file);
Console.WriteLine($"AppConfig: seeded default appsettings.json in {PersistentDir}");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"AppConfig: cannot seed default appsettings.json: {ex.Message}");
}
}
}
/// <summary>Moves one legacy file from the executable folder into PersistentData — only
/// when the new location has no file yet, so a newer user-edited copy is never clobbered.</summary>
private static void MigrateLegacy(string target, string fileName)
{
try
{
var source = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(source) || File.Exists(target)) return;
Directory.CreateDirectory(PersistentDir);
File.Move(source, target);
Console.WriteLine($"AppConfig: migrated {source} → {target} (persistent config now lives in PersistentData)");
}
catch (Exception ex)
{
Console.Error.WriteLine($"AppConfig: cannot migrate legacy '{fileName}': {ex.Message}");
}
}
/// <summary>Creates <paramref name="dir"/> if missing and verifies it is actually writable
/// (a folder can exist yet be read-only). Returns true when the folder is usable for
/// persistent configuration, false when it could not be created or written.</summary>
private static bool EnsureWritable(string dir)
{
try
{
Directory.CreateDirectory(dir);
var probe = Path.Combine(dir, ".writetest");
File.WriteAllText(probe, "");
File.Delete(probe);
return true;
}
catch
{
return false;
}
}
/// <summary>DEBUG tripwire: refuses to start when a config/state *.json sits next to the
/// executable — the layout rule the release checklist enforces. Runs after
/// <see cref="Initialize"/> (known files were already migrated), so anything still there
/// is a mistake the programmer must see before it ships.</summary>
public static void GuardNoStrayRootJson()
{
#if DEBUG
string[] stray;
try
{
var allowed = GeneratedRootJson();
stray = Directory.EnumerateFiles(AppContext.BaseDirectory, "*.json", SearchOption.TopDirectoryOnly)
.Select(Path.GetFileName)
.Where(name => name != null && !allowed.Contains(name, StringComparer.OrdinalIgnoreCase))
.Select(name => name!)
.ToArray();
}
catch (Exception ex)
{
Console.Error.WriteLine($"AppConfig: cannot scan {AppContext.BaseDirectory} for stray json: {ex.Message}");
return;
}
if (stray.Length == 0) return;
Console.Error.WriteLine();
Console.Error.WriteLine("AgentBridge (DEBUG) refuses to start: json file(s) found next to the executable.");
Console.Error.WriteLine($" Directory: {AppContext.BaseDirectory}");
foreach (var name in stray) Console.Error.WriteLine($" - {name}");
Console.Error.WriteLine();
Console.Error.WriteLine("Persistent/user configuration must live under PersistentData\\ (or the OS app-data folder),");
Console.Error.WriteLine("never next to the assemblies: the update replaces the app folder, so such files would be");
Console.Error.WriteLine("overwritten or shipped by mistake — the single-config-directory rule exists to avoid that.");
Console.Error.WriteLine();
Console.Error.WriteLine("How to fix: move these files under PersistentData\\ (config there is read automatically after");
Console.Error.WriteLine("the startup migration) or delete them if they are build/test leftovers. Then start again.");
Console.Error.WriteLine("Before any push/release, run the developer checklist: docs-dev/RELEASE-CHECKLIST.md");
Environment.Exit(1);
#endif
}
}