@@ -26,9 +26,11 @@ public sealed class SandboxieToolHostService {
2626 "ReadFile" , "WriteFile" , "EditFile" , "SearchText" , "ListFiles" , "ExecuteCommand"
2727 } ;
2828
29+ private const int SandboxieCommandTimeoutMilliseconds = 10_000 ;
30+ private const int ToolHostStartupTimeoutSeconds = 15 ;
31+
2932 private readonly IConnectionMultiplexer _redis ;
3033 private readonly ILogger < SandboxieToolHostService > _logger ;
31- private readonly Dictionary < long , DateTime > _lastToolHostStarts = new ( ) ;
3234 private readonly SemaphoreSlim _lock = new ( 1 , 1 ) ;
3335
3436 public SandboxieToolHostService ( IConnectionMultiplexer redis , ILogger < SandboxieToolHostService > logger ) {
@@ -116,34 +118,69 @@ public async Task<SandboxieInstance> EnsureToolHostAsync(long chatId, Cancellati
116118 try {
117119 var instance = BuildInstance ( chatId ) ;
118120 EnsureBoxesDirectory ( instance . BoxesDirectory ) ;
121+ EnsurePortableBoxDefinition ( instance ) ;
119122 if ( Env . SandboxieAutoRegisterImportBox ) {
120123 EnsureImportBoxDirective ( instance . BoxesDirectory ) ;
121124 }
122- EnsurePortableBoxDefinition ( instance ) ;
123-
124- if ( await IsToolHostAliveAsync ( chatId ) ) {
125- return instance ;
126- }
127125
128- if ( _lastToolHostStarts . TryGetValue ( chatId , out var lastStartedAt ) && DateTime . UtcNow - lastStartedAt < TimeSpan . FromSeconds ( 10 ) ) {
126+ if ( await IsToolHostAliveAsync ( instance ) ) {
129127 return instance ;
130128 }
131129
132- StartToolHost ( instance ) ;
130+ ReloadSandboxieConfiguration ( ) ;
131+ EnsureSandboxieBoxLoaded ( instance ) ;
132+ using var launcher = StartToolHost ( instance ) ;
133+ await WaitForToolHostStartupAsync ( instance , launcher , cancellationToken ) ;
133134 return instance ;
134135 } finally {
135136 _lock . Release ( ) ;
136137 }
137138 }
138139
139140 private void EnsureImportBoxDirective ( string boxesDirectory ) {
141+ var importPath = $ "{ NormalizeSandboxiePath ( boxesDirectory ) } \\ *";
142+ var directive = $ "ImportBox={ importPath } ";
143+ var sbieIniExe = Path . Combine ( Path . GetDirectoryName ( Env . SandboxieStartExe ) ?? string . Empty , "SbieIni.exe" ) ;
144+
145+ if ( File . Exists ( sbieIniExe ) ) {
146+ var query = RunSandboxieCommand (
147+ sbieIniExe ,
148+ new [ ] { "query" , "GlobalSettings" , "ImportBox" } ,
149+ SandboxieCommandTimeoutMilliseconds ) ;
150+ if ( query . ExitCode == 0 && query . StandardOutput
151+ . Split ( new [ ] { '\r ' , '\n ' } , StringSplitOptions . RemoveEmptyEntries | StringSplitOptions . TrimEntries )
152+ . Any ( line => string . Equals ( line , importPath , StringComparison . OrdinalIgnoreCase ) ||
153+ string . Equals ( line , directive , StringComparison . OrdinalIgnoreCase ) ) ) {
154+ return ;
155+ }
156+
157+ var append = RunSandboxieCommand (
158+ sbieIniExe ,
159+ new [ ] { "append" , "/drv" , "GlobalSettings" , "ImportBox" , importPath } ,
160+ SandboxieCommandTimeoutMilliseconds ) ;
161+ var verify = RunSandboxieCommand (
162+ sbieIniExe ,
163+ new [ ] { "query" , "GlobalSettings" , "ImportBox" } ,
164+ SandboxieCommandTimeoutMilliseconds ) ;
165+ if ( append . ExitCode != 0 || verify . ExitCode != 0 || ! verify . StandardOutput
166+ . Split ( new [ ] { '\r ' , '\n ' } , StringSplitOptions . RemoveEmptyEntries | StringSplitOptions . TrimEntries )
167+ . Any ( line => string . Equals ( line , importPath , StringComparison . OrdinalIgnoreCase ) ||
168+ string . Equals ( line , directive , StringComparison . OrdinalIgnoreCase ) ) ) {
169+ throw new InvalidOperationException (
170+ $ "Sandboxie failed to register the portable box directory. ExitCode={ append . ExitCode } , Error={ append . StandardError . Trim ( ) } ") ;
171+ }
172+
173+ _logger . LogInformation ( "Registered Sandboxie ImportBox through SbieIni. Directive={Directive}" , directive ) ;
174+ return ;
175+ }
176+
140177 var iniPath = Env . SandboxieIniPath ;
141178 if ( string . IsNullOrWhiteSpace ( iniPath ) || ! File . Exists ( iniPath ) ) {
142- _logger . LogWarning ( "Sandboxie.ini not found; portable boxes may not be imported automatically. Path={Path}" , iniPath ) ;
143- return ;
179+ throw new FileNotFoundException (
180+ "Neither Sandboxie's SbieIni.exe nor the configured Sandboxie.ini was found; the portable box directory cannot be registered automatically." ,
181+ iniPath ) ;
144182 }
145183
146- var directive = $ "ImportBox={ NormalizeSandboxiePath ( boxesDirectory ) } \\ *";
147184 var text = File . ReadAllText ( iniPath , Encoding . Unicode ) ;
148185 if ( text . IndexOf ( directive , StringComparison . OrdinalIgnoreCase ) >= 0 ) {
149186 return ;
@@ -167,12 +204,28 @@ private void EnsureImportBoxDirective(string boxesDirectory) {
167204 File . WriteAllText ( iniPath , text , Encoding . Unicode ) ;
168205 _logger . LogInformation ( "Added Sandboxie ImportBox directive. Ini={IniPath}, Directive={Directive}" , iniPath , directive ) ;
169206 } catch ( Exception ex ) {
170- _logger . LogWarning ( ex , "Failed to add Sandboxie ImportBox directive. Run once with permissions or add it manually: {Directive}" , directive ) ;
207+ throw new InvalidOperationException (
208+ $ "Failed to register the portable box directory. Add '{ directive } ' under [GlobalSettings] or grant Sandboxie configuration access.",
209+ ex ) ;
210+ }
211+ }
212+
213+ internal static string BuildBoxName ( long chatId , string prefix ) {
214+ var boxName = prefix + ComputeStableHash ( chatId . ToString ( ) ) ;
215+ if ( boxName . Length is 0 or > 38 || boxName . Any ( c =>
216+ ! ( c is >= 'A' and <= 'Z' ) &&
217+ ! ( c is >= 'a' and <= 'z' ) &&
218+ ! ( c is >= '0' and <= '9' ) &&
219+ c != '_' ) ) {
220+ throw new InvalidOperationException (
221+ $ "Sandboxie box name '{ boxName } ' is invalid. Sandboxie allows 1-38 ASCII letters, digits, and underscores.") ;
171222 }
223+
224+ return boxName ;
172225 }
173226
174227 private static SandboxieInstance BuildInstance ( long chatId ) {
175- var boxName = Env . SandboxieBoxPrefix + ComputeStableHash ( chatId . ToString ( ) ) ;
228+ var boxName = BuildBoxName ( chatId , Env . SandboxieBoxPrefix ) ;
176229 var boxesDir = Env . SandboxieBoxImportDirectory ;
177230 return new SandboxieInstance (
178231 chatId ,
@@ -249,7 +302,42 @@ internal static string BuildPortableBoxIni(SandboxieInstance instance) {
249302 return string . Join ( Environment . NewLine , lines ) ;
250303 }
251304
252- private void StartToolHost ( SandboxieInstance instance ) {
305+ private void ReloadSandboxieConfiguration ( ) {
306+ var startExe = Env . SandboxieStartExe ;
307+ if ( ! File . Exists ( startExe ) ) {
308+ throw new FileNotFoundException ( "Sandboxie Start.exe was not found. Configure SandboxieStartExe in Config.json." , startExe ) ;
309+ }
310+
311+ var result = RunSandboxieCommand (
312+ startExe ,
313+ new [ ] { "/silent" , "/reload" } ,
314+ SandboxieCommandTimeoutMilliseconds ) ;
315+ if ( result . ExitCode != 0 ) {
316+ throw new InvalidOperationException (
317+ $ "Sandboxie configuration reload failed. ExitCode={ result . ExitCode } , Error={ result . StandardError . Trim ( ) } ") ;
318+ }
319+ }
320+
321+ private void EnsureSandboxieBoxLoaded ( SandboxieInstance instance ) {
322+ var sbieIniExe = Path . Combine ( Path . GetDirectoryName ( Env . SandboxieStartExe ) ?? string . Empty , "SbieIni.exe" ) ;
323+ if ( ! File . Exists ( sbieIniExe ) ) {
324+ return ;
325+ }
326+
327+ var result = RunSandboxieCommand (
328+ sbieIniExe ,
329+ new [ ] { "query" , "/boxes" , "*" } ,
330+ SandboxieCommandTimeoutMilliseconds ) ;
331+ var isLoaded = result . ExitCode == 0 && result . StandardOutput
332+ . Split ( new [ ] { '\r ' , '\n ' } , StringSplitOptions . RemoveEmptyEntries | StringSplitOptions . TrimEntries )
333+ . Any ( line => string . Equals ( line , instance . BoxName , StringComparison . OrdinalIgnoreCase ) ) ;
334+ if ( ! isLoaded ) {
335+ throw new InvalidOperationException (
336+ $ "Sandboxie did not load box '{ instance . BoxName } ' after configuration reload. Verify ImportBox, the INI filename/section name, and that the box is enabled.") ;
337+ }
338+ }
339+
340+ private Process StartToolHost ( SandboxieInstance instance ) {
253341 var startExe = Env . SandboxieStartExe ;
254342 if ( ! File . Exists ( startExe ) ) {
255343 throw new FileNotFoundException ( "Sandboxie Start.exe was not found. Configure SandboxieStartExe in Config.json." , startExe ) ;
@@ -265,6 +353,7 @@ private void StartToolHost(SandboxieInstance instance) {
265353 UseShellExecute = false ,
266354 CreateNoWindow = true
267355 } ;
356+ psi . ArgumentList . Add ( "/silent" ) ;
268357 psi . ArgumentList . Add ( $ "/box:{ instance . BoxName } ") ;
269358 psi . ArgumentList . Add ( currentExe ) ;
270359 var currentProcess = Process . GetCurrentProcess ( ) ;
@@ -276,13 +365,89 @@ private void StartToolHost(SandboxieInstance instance) {
276365 psi . ArgumentList . Add ( currentProcess . StartTime . ToUniversalTime ( ) . Ticks . ToString ( ) ) ;
277366
278367 var process = Process . Start ( psi ) ?? throw new InvalidOperationException ( "Failed to start Sandboxie tool host process." ) ;
279- _lastToolHostStarts [ instance . ChatId ] = DateTime . UtcNow ;
280368 _logger . LogInformation ( "Started Sandboxie tool host launcher. ChatId={ChatId}, Box={BoxName}, LauncherPid={Pid}" , instance . ChatId , instance . BoxName , process . Id ) ;
369+ return process ;
370+ }
371+
372+ private async Task WaitForToolHostStartupAsync ( SandboxieInstance instance , Process launcher , CancellationToken cancellationToken ) {
373+ var deadline = DateTime . UtcNow + TimeSpan . FromSeconds ( ToolHostStartupTimeoutSeconds ) ;
374+ while ( DateTime . UtcNow < deadline ) {
375+ if ( await IsToolHostAliveAsync ( instance ) ) {
376+ return ;
377+ }
378+
379+ if ( launcher . HasExited && launcher . ExitCode != 0 ) {
380+ throw new InvalidOperationException (
381+ $ "Sandboxie could not start box '{ instance . BoxName } '. Start.exe exited with code { launcher . ExitCode } .") ;
382+ }
383+
384+ await Task . Delay ( 200 , cancellationToken ) ;
385+ }
386+
387+ if ( ! launcher . HasExited ) {
388+ try {
389+ launcher . Kill ( entireProcessTree : true ) ;
390+ } catch {
391+ }
392+ }
393+
394+ throw new TimeoutException (
395+ $ "Sandboxie started box '{ instance . BoxName } ', but its tool host did not report a heartbeat within { ToolHostStartupTimeoutSeconds } seconds.") ;
396+ }
397+
398+ private static ( int ExitCode , string StandardOutput , string StandardError ) RunSandboxieCommand (
399+ string executable ,
400+ IEnumerable < string > arguments ,
401+ int timeoutMilliseconds ) {
402+ var startInfo = new ProcessStartInfo {
403+ FileName = executable ,
404+ UseShellExecute = false ,
405+ RedirectStandardOutput = true ,
406+ RedirectStandardError = true ,
407+ CreateNoWindow = true
408+ } ;
409+ foreach ( var argument in arguments ) {
410+ startInfo . ArgumentList . Add ( argument ) ;
411+ }
412+
413+ using var process = Process . Start ( startInfo ) ??
414+ throw new InvalidOperationException ( $ "Failed to start Sandboxie command '{ executable } '.") ;
415+ var standardOutput = process . StandardOutput . ReadToEndAsync ( ) ;
416+ var standardError = process . StandardError . ReadToEndAsync ( ) ;
417+ if ( ! process . WaitForExit ( timeoutMilliseconds ) ) {
418+ try {
419+ process . Kill ( entireProcessTree : true ) ;
420+ } catch {
421+ }
422+
423+ throw new TimeoutException ( $ "Sandboxie command '{ Path . GetFileName ( executable ) } ' timed out.") ;
424+ }
425+
426+ return (
427+ process . ExitCode ,
428+ standardOutput . GetAwaiter ( ) . GetResult ( ) ,
429+ standardError . GetAwaiter ( ) . GetResult ( ) ) ;
430+ }
431+
432+ private async Task < bool > IsToolHostAliveAsync ( SandboxieInstance instance ) {
433+ var value = await _redis . GetDatabase ( ) . StringGetAsync ( LlmAgentRedisKeys . SandboxToolHeartbeat ( instance . ChatId ) ) ;
434+ if ( ! value . HasValue || string . IsNullOrWhiteSpace ( value . ToString ( ) ) ) {
435+ return false ;
436+ }
437+
438+ try {
439+ var heartbeat = JsonConvert . DeserializeObject < SandboxToolHeartbeatState > ( value . ToString ( ) ) ;
440+ return heartbeat != null &&
441+ heartbeat . ParentProcessId == Environment . ProcessId &&
442+ string . Equals ( heartbeat . BoxName , instance . BoxName , StringComparison . OrdinalIgnoreCase ) ;
443+ } catch ( JsonException ) {
444+ return false ;
445+ }
281446 }
282447
283- private async Task < bool > IsToolHostAliveAsync ( long chatId ) {
284- var value = await _redis . GetDatabase ( ) . StringGetAsync ( LlmAgentRedisKeys . SandboxToolHeartbeat ( chatId ) ) ;
285- return value . HasValue && ! string . IsNullOrWhiteSpace ( value . ToString ( ) ) ;
448+ private sealed class SandboxToolHeartbeatState {
449+ public string BoxName { get ; set ; } = string . Empty ;
450+ public int ParentProcessId { get ; set ; }
286451 }
287452
288453 private static string ComputeStableHash ( string value ) {
0 commit comments