Skip to content

Commit 913ba4e

Browse files
authored
fix(exec): migrate approvals to custom state dir (#744)
1 parent 3fdfbfa commit 913ba4e

7 files changed

Lines changed: 383 additions & 39 deletions

File tree

src/OpenClaw.Shared/ExecApprovals/ExecApprovalEvaluation.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public sealed class ExecApprovalEvaluation
2424
public IReadOnlyList<string> AllowAlwaysPatterns { get; }
2525
public IReadOnlyList<ExecAllowlistEntry> AllowlistMatches { get; }
2626

27+
public bool AllAllowlistResolutionsMatched { get; }
28+
2729
// true iff security==allowlist && resolutions.Count>0 && matches.Count==resolutions.Count.
2830
// Research doc 06 derivation rule — must not be re-derived outside the constructor.
2931
public bool AllowlistSatisfied { get; }
@@ -70,9 +72,9 @@ public ExecApprovalEvaluation(
7072

7173
Resolution = allowlistResolutions.Count > 0 ? allowlistResolutions[0] : (ExecCommandResolution?)null;
7274

73-
AllowlistSatisfied = security == ExecSecurity.Allowlist
74-
&& allowlistResolutions.Count > 0
75+
AllAllowlistResolutionsMatched = allowlistResolutions.Count > 0
7576
&& allowlistMatches.Count == allowlistResolutions.Count;
77+
AllowlistSatisfied = security == ExecSecurity.Allowlist && AllAllowlistResolutionsMatched;
7678

7779
AllowlistMatch = AllowlistSatisfied ? allowlistMatches[0] : null;
7880
}

src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Text.Json;
34
using System.Text.Json.Serialization;
45

56
namespace OpenClaw.Shared.ExecApprovals;
@@ -52,15 +53,17 @@ public sealed class ExecApprovalsDefaults
5253
{
5354
public ExecSecurity? Security { get; set; }
5455
public ExecAsk? Ask { get; set; }
55-
public ExecAsk? AskFallback { get; set; }
56+
[JsonConverter(typeof(ExecSecurityFallbackConverter))]
57+
public ExecSecurity? AskFallback { get; set; }
5658
public bool? AutoAllowSkills { get; set; }
5759
}
5860

5961
public sealed class ExecApprovalsAgent
6062
{
6163
public ExecSecurity? Security { get; set; }
6264
public ExecAsk? Ask { get; set; }
63-
public ExecAsk? AskFallback { get; set; }
65+
[JsonConverter(typeof(ExecSecurityFallbackConverter))]
66+
public ExecSecurity? AskFallback { get; set; }
6467
public bool? AutoAllowSkills { get; set; }
6568
public List<ExecAllowlistEntry>? Allowlist { get; set; }
6669
}
@@ -73,13 +76,45 @@ public sealed class ExecApprovalsFile
7376
public Dictionary<string, ExecApprovalsAgent>? Agents { get; set; }
7477
}
7578

79+
internal sealed class ExecSecurityFallbackConverter : JsonConverter<ExecSecurity?>
80+
{
81+
public override ExecSecurity? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
82+
{
83+
if (reader.TokenType == JsonTokenType.Null) return null;
84+
if (reader.TokenType != JsonTokenType.String) throw new JsonException("askFallback must be a string");
85+
return reader.GetString()?.ToLowerInvariant() switch
86+
{
87+
"deny" or "always" => ExecSecurity.Deny,
88+
"allowlist" or "on-miss" => ExecSecurity.Allowlist,
89+
"full" or "off" => ExecSecurity.Full,
90+
var value => throw new JsonException($"Unsupported askFallback value: {value}"),
91+
};
92+
}
93+
94+
public override void Write(Utf8JsonWriter writer, ExecSecurity? value, JsonSerializerOptions options)
95+
{
96+
if (value is null)
97+
{
98+
writer.WriteNullValue();
99+
return;
100+
}
101+
writer.WriteStringValue(value.Value switch
102+
{
103+
ExecSecurity.Deny => "deny",
104+
ExecSecurity.Allowlist => "allowlist",
105+
ExecSecurity.Full => "full",
106+
_ => throw new JsonException($"Unsupported askFallback value: {value}"),
107+
});
108+
}
109+
}
110+
76111
// ── Resolved/runtime contracts (not serialized) ───────────────────────────────
77112

78113
public sealed class ExecApprovalsResolvedDefaults
79114
{
80115
public ExecSecurity Security { get; init; }
81116
public ExecAsk Ask { get; init; }
82-
public ExecAsk AskFallback { get; init; }
117+
public ExecSecurity AskFallback { get; init; }
83118
public bool AutoAllowSkills { get; init; }
84119
}
85120

src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ public async Task<ExecApprovalV2Result> HandleAsync(NodeInvokeRequest request, s
7575
}
7676

7777
var sanitizedEnv = envResult.Allowed as IReadOnlyDictionary<string, string>;
78-
IReadOnlyList<ExecAllowlistEntry> matches = resolved.Defaults.Security == ExecSecurity.Allowlist
78+
var needsAllowlistMatches = resolved.Defaults.Security == ExecSecurity.Allowlist
79+
|| resolved.Defaults.AskFallback == ExecSecurity.Allowlist;
80+
IReadOnlyList<ExecAllowlistEntry> matches = needsAllowlistMatches
7981
? ExecAllowlistMatcher.MatchAll(resolved.Allowlist, identity.AllowlistResolutions)
8082
: [];
8183

@@ -208,16 +210,16 @@ public async Task<ExecApprovalV2Result> HandleAsync(NodeInvokeRequest request, s
208210
// ask=Always → Deny: human approval is a precondition; without UI the only safe outcome is deny.
209211
private static ExecApprovalDecision FallbackDecision(
210212
ExecApprovalEvaluation context,
211-
ExecAsk askFallback)
213+
ExecSecurity askFallback)
212214
{
213-
return askFallback switch
215+
var effectiveFallback = (ExecSecurity)Math.Min((int)context.Security, (int)askFallback);
216+
return effectiveFallback switch
214217
{
215-
ExecAsk.Off => ExecApprovalDecision.AllowOnce,
216-
ExecAsk.OnMiss => context.AllowlistSatisfied
218+
ExecSecurity.Full => ExecApprovalDecision.AllowOnce,
219+
ExecSecurity.Allowlist => context.AllAllowlistResolutionsMatched
217220
? ExecApprovalDecision.AllowOnce
218221
: ExecApprovalDecision.Deny,
219-
ExecAsk.Always => ExecApprovalDecision.Deny,
220-
ExecAsk.Deny => ExecApprovalDecision.Deny,
222+
ExecSecurity.Deny => ExecApprovalDecision.Deny,
221223
_ => ExecApprovalDecision.Deny, // defensive
222224
};
223225
}

src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,17 @@ public sealed class ExecApprovalsStore
2424
};
2525

2626
private readonly string _filePath;
27+
private readonly string? _legacyFilePath;
2728
private readonly IOpenClawLogger _logger;
2829
private readonly SemaphoreSlim _lock = new(1, 1);
2930

31+
private enum LegacyMigrationStatus
32+
{
33+
NotNeeded,
34+
Migrated,
35+
Blocked,
36+
}
37+
3038
private enum LoadFileStatus
3139
{
3240
Missing,
@@ -37,8 +45,31 @@ private enum LoadFileStatus
3745
private readonly record struct LoadFileResult(LoadFileStatus Status, ExecApprovalsFile? File);
3846

3947
public ExecApprovalsStore(string dataPath, IOpenClawLogger logger)
48+
: this(
49+
dataPath,
50+
logger,
51+
Environment.GetEnvironmentVariable("OPENCLAW_STATE_DIR"),
52+
Environment.GetEnvironmentVariable("OPENCLAW_HOME"),
53+
FirstUsablePathValue(
54+
Environment.GetEnvironmentVariable("HOME"),
55+
Environment.GetEnvironmentVariable("USERPROFILE"),
56+
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)))
4057
{
41-
_filePath = Path.Combine(dataPath, "exec-approvals.json");
58+
}
59+
60+
internal ExecApprovalsStore(
61+
string dataPath,
62+
IOpenClawLogger logger,
63+
string? stateDirOverride,
64+
string? openClawHomeOverride = null,
65+
string? osHomeOverride = null)
66+
{
67+
var stateDir = string.IsNullOrWhiteSpace(stateDirOverride)
68+
? dataPath
69+
: ResolveStateDirPath(stateDirOverride, openClawHomeOverride, osHomeOverride);
70+
_filePath = Path.Combine(stateDir, "exec-approvals.json");
71+
var legacyFilePath = Path.Combine(dataPath, "exec-approvals.json");
72+
_legacyFilePath = PathsEqual(_filePath, legacyFilePath) ? null : legacyFilePath;
4273
_logger = logger;
4374
}
4475

@@ -47,6 +78,9 @@ public ExecApprovalsStore(string dataPath, IOpenClawLogger logger)
4778
// No side effects; does not create the file. Used by the evaluator (PR5).
4879
public ExecApprovalsResolved ResolveReadOnly(string? agentId)
4980
{
81+
if (_legacyFilePath is not null && File.Exists(_legacyFilePath) && !File.Exists(_filePath))
82+
return UnmigratedLegacyFallback(agentId);
83+
5084
var result = LoadFile();
5185
return result.Status != LoadFileStatus.Loaded || result.File is null
5286
? DefaultResolved(NormalizeAgentId(agentId))
@@ -69,14 +103,19 @@ public async Task<ExecApprovalsResolved> ResolveAsync(string? agentId)
69103
}
70104
}
71105

106+
public void MigrateLegacyFileIfNeeded() => TryMigrateLegacyFile();
107+
72108
// ── File I/O ──────────────────────────────────────────────────────────────
73109

74110
private LoadFileResult LoadFile()
111+
=> LoadFile(_filePath);
112+
113+
private LoadFileResult LoadFile(string filePath)
75114
{
76-
if (!File.Exists(_filePath)) return new LoadFileResult(LoadFileStatus.Missing, null);
115+
if (!File.Exists(filePath)) return new LoadFileResult(LoadFileStatus.Missing, null);
77116
try
78117
{
79-
var json = File.ReadAllText(_filePath);
118+
var json = File.ReadAllText(filePath);
80119
var file = JsonSerializer.Deserialize<ExecApprovalsFile>(json, JsonOptions);
81120
if (file is null)
82121
{
@@ -105,6 +144,9 @@ private LoadFileResult LoadFile()
105144

106145
private async Task<ExecApprovalsFile> EnsureFileAsync()
107146
{
147+
if (TryMigrateLegacyFile() == LegacyMigrationStatus.Blocked)
148+
return UnmigratedLegacyFallbackFile();
149+
108150
var result = LoadFile();
109151
if (result.Status == LoadFileStatus.Loaded && result.File is not null)
110152
{
@@ -136,6 +178,118 @@ private async Task<ExecApprovalsFile> EnsureFileAsync()
136178
return newFile;
137179
}
138180

181+
private LegacyMigrationStatus TryMigrateLegacyFile()
182+
{
183+
if (_legacyFilePath is null || !File.Exists(_legacyFilePath) || File.Exists(_filePath))
184+
return LegacyMigrationStatus.NotNeeded;
185+
186+
var legacyResult = LoadFile(_legacyFilePath);
187+
if (legacyResult.Status != LoadFileStatus.Loaded || legacyResult.File is null)
188+
{
189+
_logger.Warn($"[EXEC-APPROVALS] Legacy approvals at {_legacyFilePath} could not be migrated; applying default-deny without creating {_filePath}");
190+
return LegacyMigrationStatus.Blocked;
191+
}
192+
193+
var targetDir = Path.GetDirectoryName(_filePath)!;
194+
var archivePath = NextArchivePath(_legacyFilePath);
195+
var tempPath = Path.Combine(targetDir, $".exec-approvals-migration-{Guid.NewGuid():N}.tmp");
196+
try
197+
{
198+
Directory.CreateDirectory(targetDir);
199+
var data = File.ReadAllBytes(_legacyFilePath);
200+
using (var stream = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
201+
{
202+
stream.Write(data);
203+
stream.Flush(flushToDisk: true);
204+
}
205+
File.Move(tempPath, _filePath);
206+
try
207+
{
208+
File.Move(_legacyFilePath, archivePath);
209+
}
210+
catch (Exception ex)
211+
{
212+
_logger.Warn($"[EXEC-APPROVALS] Migrated approvals to {_filePath}, but could not archive {_legacyFilePath} ({ex.Message})");
213+
return LegacyMigrationStatus.Migrated;
214+
}
215+
_logger.Info($"[EXEC-APPROVALS] Migrated {_legacyFilePath} to {_filePath}; archived source as {archivePath}");
216+
return LegacyMigrationStatus.Migrated;
217+
}
218+
catch (IOException) when (File.Exists(_filePath))
219+
{
220+
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
221+
return LegacyMigrationStatus.NotNeeded;
222+
}
223+
catch (Exception ex)
224+
{
225+
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
226+
_logger.Warn($"[EXEC-APPROVALS] Failed to migrate {_legacyFilePath} to {_filePath} ({ex.Message}); applying default-deny without creating a replacement file");
227+
return LegacyMigrationStatus.Blocked;
228+
}
229+
}
230+
231+
private static string NextArchivePath(string legacyFilePath)
232+
{
233+
var archivePath = $"{legacyFilePath}.migrated";
234+
return File.Exists(archivePath) ? $"{archivePath}-{Guid.NewGuid():N}" : archivePath;
235+
}
236+
237+
private static bool PathsEqual(string left, string right) =>
238+
string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase);
239+
240+
private static string ResolveStateDirPath(
241+
string stateDirOverride,
242+
string? openClawHomeOverride,
243+
string? osHomeOverride)
244+
{
245+
var osHome = NormalizePathValue(osHomeOverride) ?? Environment.CurrentDirectory;
246+
var openClawHome = NormalizePathValue(openClawHomeOverride);
247+
var effectiveHome = openClawHome is null
248+
? Path.GetFullPath(osHome)
249+
: Path.GetFullPath(ExpandHomePrefix(openClawHome, osHome));
250+
return Path.GetFullPath(ExpandHomePrefix(stateDirOverride.Trim(), effectiveHome));
251+
}
252+
253+
private static string ExpandHomePrefix(string path, string home) =>
254+
path == "~"
255+
? home
256+
: path.StartsWith($"~{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
257+
|| path.StartsWith($"~{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal)
258+
? Path.Combine(home, path[2..])
259+
: path;
260+
261+
private static string? NormalizePathValue(string? value)
262+
{
263+
var trimmed = value?.Trim();
264+
return string.IsNullOrEmpty(trimmed) || trimmed is "undefined" or "null" ? null : trimmed;
265+
}
266+
267+
private static string? FirstUsablePathValue(params string?[] values)
268+
{
269+
foreach (var value in values)
270+
{
271+
var normalized = NormalizePathValue(value);
272+
if (normalized is not null) return normalized;
273+
}
274+
return null;
275+
}
276+
277+
private static ExecApprovalsFile UnmigratedLegacyFallbackFile() =>
278+
new()
279+
{
280+
Version = 1,
281+
Defaults = new ExecApprovalsDefaults
282+
{
283+
Security = ExecSecurity.Deny,
284+
Ask = ExecAsk.Always,
285+
AskFallback = ExecSecurity.Deny,
286+
},
287+
Agents = [],
288+
};
289+
290+
private static ExecApprovalsResolved UnmigratedLegacyFallback(string? agentId) =>
291+
ResolveFromFile(UnmigratedLegacyFallbackFile(), agentId);
292+
139293
private async Task SaveFileAsync(ExecApprovalsFile file)
140294
{
141295
var dir = Path.GetDirectoryName(_filePath)!;
@@ -276,7 +430,7 @@ private static ExecApprovalsResolved ResolveFromFile(ExecApprovalsFile file, str
276430
// Cascade: agentEntry → wildcard → defaults → systemDefault
277431
var security = agentEntry?.Security ?? wildcardEntry?.Security ?? defaults?.Security ?? ExecSecurity.Deny;
278432
var ask = agentEntry?.Ask ?? wildcardEntry?.Ask ?? defaults?.Ask ?? ExecAsk.OnMiss;
279-
var askFallback = agentEntry?.AskFallback ?? wildcardEntry?.AskFallback ?? defaults?.AskFallback ?? ExecAsk.Deny;
433+
var askFallback = agentEntry?.AskFallback ?? wildcardEntry?.AskFallback ?? defaults?.AskFallback ?? ExecSecurity.Deny;
280434
var autoAllowSkills = agentEntry?.AutoAllowSkills ?? wildcardEntry?.AutoAllowSkills ?? defaults?.AutoAllowSkills ?? false;
281435

282436
// Allowlist: wildcard first, then agent; then normalize dropInvalid=true.
@@ -307,7 +461,7 @@ private static ExecApprovalsResolved DefaultResolved(string agentId) =>
307461
{
308462
Security = ExecSecurity.Deny,
309463
Ask = ExecAsk.OnMiss,
310-
AskFallback = ExecAsk.Deny,
464+
AskFallback = ExecSecurity.Deny,
311465
AutoAllowSkills = false,
312466
},
313467
Allowlist = [],

src/OpenClaw.Tray.WinUI/Services/NodeService.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Microsoft.UI.Dispatching;
88
using OpenClaw.Shared;
99
using OpenClaw.Shared.Capabilities;
10+
using OpenClaw.Shared.ExecApprovals;
1011
using OpenClaw.Shared.Mcp;
1112
using OpenClaw.Shared.Mxc;
1213
using OpenClawTray.A2UI.Actions;
@@ -267,6 +268,8 @@ public async Task DisconnectAsync()
267268

268269
private void RegisterCapabilities()
269270
{
271+
new ExecApprovalsStore(_dataPath, _logger).MigrateLegacyFileIfNeeded();
272+
270273
// Hold the lock across the entire rebuild. The body is sync construction
271274
// (no awaits), so the lock is held briefly and an MCP tools/list arriving
272275
// mid-rebuild waits for a consistent snapshot rather than seeing a half-

0 commit comments

Comments
 (0)