Skip to content

Commit 4d0d6ca

Browse files
committed
Fix Sandboxie portable box startup
1 parent 7108b04 commit 4d0d6ca

4 files changed

Lines changed: 219 additions & 24 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,12 @@
127127
- `AgentProcessMemoryLimitMb`: Agent 进程工作集上限(默认256MB)
128128
- `MaxToolCycles`: LLM工具调用最大迭代次数(默认25),防止无限循环
129129
- `EnableLlmSandboxie`: 是否启用 Sandboxie Plus LLM 工具沙箱(默认false)。启用后 `ReadFile`/`WriteFile`/`EditFile`/`SearchText`/`ListFiles`/`ExecuteCommand` 会通过每群一个 Sandboxie portable box 的 ToolHost 执行。
130-
- `SandboxieStartExe`: Sandboxie Plus `Start.exe` 路径。
131-
- `SandboxieIniPath`: Sandboxie 主配置路径。仅在 `SandboxieAutoRegisterImportBox=true` 时用于自动加入 `ImportBox=<SandboxieBoxImportDirectory>\\*`
132-
- `SandboxieAutoRegisterImportBox`: 是否由程序自动把 portable box 目录注册到 Sandboxie 主配置(默认true)。如希望自行在 Sandboxie Plus 中添加便携容器目录,可设为 false。
130+
- `SandboxieStartExe`: Sandboxie Plus `Start.exe` 路径。程序会使用同目录的 `SbieIni.exe` 注册 portable box 目录,并用 `Start.exe /reload` 重新加载配置和启动 ToolHost。
131+
- `SandboxieIniPath`: Sandboxie 主配置路径。仅当 `SandboxieAutoRegisterImportBox=true` `Start.exe` 同目录不存在 `SbieIni.exe` 时,作为直接写入 `ImportBox` 的回退路径
132+
- `SandboxieAutoRegisterImportBox`: 是否由程序自动把 portable box 目录注册到 Sandboxie 主配置(默认true)。程序先写 box INI,再注册目录、重载配置并启动 box;自动注册失败会立即报告具体错误。如希望自行在 Sandboxie Plus 中添加便携容器目录,可设为 false。
133133
- `SandboxieDenyHostFileSystem`: 是否默认关闭宿主机盘符根目录访问(默认false)。保持 false 时更适合运行 bash/npm/python 等工具链;写入仍由 Sandboxie 虚拟化,敏感项目数据仍会通过 `ClosedFilePath` 阻断。需要极严格白名单模式时可设为 true。
134134
- `SandboxieBoxImportDirectory`: portable box ini 目录;为空时默认 `%LOCALAPPDATA%/TelegramSearchBot/Sandboxie/Boxes`。每个群聊的 box ini 和虚拟文件根都生成在这里。
135+
- `SandboxieBoxPrefix`: 每群 box 名称前缀。Sandboxie 名称只允许 1-38 个 ASCII 字母、数字和下划线;下划线是合法字符,程序会原样保留。前缀与 12 位稳定哈希拼接后的总长度不能超过 38。
135136
- `SandboxieGroupFilesRoot`: 可选的额外每群文件根目录;为空时不开放。配置后,每个群只读开放 `<root>/<chatId>`
136137
- 程序默认会关闭聊天资源父目录 `Photos``Audios``Videos``Files`,再仅为当前群的既有聊天媒体/文件目录生成只读授权:`Photos/<chatId>``Audios/<chatId>``Videos/<chatId>``Files/<chatId>`。其他群的资源目录默认不可读。Lucene `Index_Data` 不开放给 ToolHost;搜索仍由主进程侧服务完成。
137138
- `SandboxieGlobalReadPaths` / `SandboxieGlobalClosedPaths`: 额外全局只读开放/禁止访问路径。

TelegramSearchBot.LLMAgent/Service/SandboxToolConsumer.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ void OnConnectionFailed(object? sender, ConnectionFailedEventArgs args) {
3535

3636
_redis.ConnectionFailed += OnConnectionFailed;
3737
var watchdogTask = RunParentWatchdogAsync(parentProcessId, parentStartTicksUtc, linkedCts);
38-
var heartbeatTask = RunHeartbeatAsync(chatId, boxName, linkedCts.Token);
38+
var heartbeatTask = RunHeartbeatAsync(chatId, boxName, parentProcessId, linkedCts.Token);
3939
var db = _redis.GetDatabase();
4040
var queueKey = LlmAgentRedisKeys.SandboxToolQueue(chatId);
4141
_logger.LogInformation(
@@ -92,13 +92,19 @@ await db.StringSetAsync(
9292
}
9393
}
9494

95-
private async Task RunHeartbeatAsync(long chatId, string boxName, CancellationToken cancellationToken) {
95+
private async Task RunHeartbeatAsync(long chatId, string boxName, int parentProcessId, CancellationToken cancellationToken) {
9696
var db = _redis.GetDatabase();
9797
var key = LlmAgentRedisKeys.SandboxToolHeartbeat(chatId);
9898
while (!cancellationToken.IsCancellationRequested) {
9999
await db.StringSetAsync(
100100
key,
101-
JsonConvert.SerializeObject(new { chatId, boxName, processId = Environment.ProcessId, updatedAtUtc = DateTime.UtcNow }),
101+
JsonConvert.SerializeObject(new {
102+
chatId,
103+
boxName,
104+
processId = Environment.ProcessId,
105+
parentProcessId,
106+
updatedAtUtc = DateTime.UtcNow
107+
}),
102108
TimeSpan.FromSeconds(15));
103109
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
104110
}

TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,29 @@
88
namespace TelegramSearchBot.Test.Service.AI.LLM {
99
[Collection("AgentEnvSerial")]
1010
public class SandboxieToolHostServiceTests {
11+
[Fact]
12+
public void BuildBoxName_PreservesUnderscores() {
13+
var boxName = SandboxieToolHostService.BuildBoxName(12345L, "TGSB_G_");
14+
15+
Assert.StartsWith("TGSB_G_", boxName, StringComparison.Ordinal);
16+
Assert.Equal(19, boxName.Length);
17+
}
18+
19+
[Theory]
20+
[InlineData("TGSB-G-")]
21+
[InlineData("TGSB G ")]
22+
[InlineData("沙箱_")]
23+
public void BuildBoxName_RejectsInvalidPrefixes(string prefix) {
24+
Assert.Throws<InvalidOperationException>(() => SandboxieToolHostService.BuildBoxName(12345L, prefix));
25+
}
26+
27+
[Fact]
28+
public void BuildBoxName_EnforcesSandboxieLengthLimit() {
29+
Assert.Equal(38, SandboxieToolHostService.BuildBoxName(12345L, new string('A', 26)).Length);
30+
Assert.Throws<InvalidOperationException>(() =>
31+
SandboxieToolHostService.BuildBoxName(12345L, new string('A', 27)));
32+
}
33+
1134
[Fact]
1235
public void BuildPortableBoxIni_AllowsOnlyCurrentChatResourceDirectories() {
1336
var originalGroupFilesRoot = Env.SandboxieGroupFilesRoot;

TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs

Lines changed: 183 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)