SandboxE2ETests.cs
1 // Copyright 2026 Alibaba Group Holding Ltd. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 using System.Collections.Concurrent; 16 using System.Text; 17 using OpenSandbox.Config; 18 using OpenSandbox.Core; 19 using OpenSandbox.Models; 20 using Xunit; 21 22 namespace OpenSandbox.E2ETests; 23 24 [Collection("CSharp E2E Tests")] 25 public class SandboxE2ETests : IClassFixture<SandboxE2ETestFixture> 26 { 27 private readonly SandboxE2ETestFixture _fixture; 28 29 public SandboxE2ETests(SandboxE2ETestFixture fixture) 30 { 31 _fixture = fixture; 32 } 33 34 [Fact(Timeout = 2 * 60 * 1000)] 35 public async Task Sandbox_Lifecycle_Health_Endpoint_Metrics_Renew_Connect() 36 { 37 var sandbox = _fixture.Sandbox; 38 Assert.False(string.IsNullOrWhiteSpace(sandbox.Id)); 39 Assert.True(await sandbox.IsHealthyAsync()); 40 41 var info = await sandbox.GetInfoAsync(); 42 Assert.Equal(sandbox.Id, info.Id); 43 Assert.Equal(SandboxStates.Running, info.Status.State); 44 Assert.Equal(Constants.DefaultEntrypoint, info.Entrypoint); 45 Assert.NotNull(info.Metadata); 46 Assert.Equal("csharp-e2e-test", info.Metadata!["tag"]); 47 Assert.True(info.ExpiresAt > info.CreatedAt); 48 49 var endpoint = await sandbox.GetEndpointAsync(Constants.DefaultExecdPort); 50 AssertEndpointHasPort(endpoint.EndpointAddress, Constants.DefaultExecdPort); 51 52 var metrics = await sandbox.GetMetricsAsync(); 53 Assert.True(metrics.CpuCount > 0); 54 Assert.True(metrics.CpuUsedPercentage is >= 0.0 and <= 100.0); 55 Assert.True(metrics.MemoryTotalMiB > 0); 56 Assert.True(metrics.MemoryUsedMiB <= metrics.MemoryTotalMiB); 57 AssertRecentTimestampMs(metrics.Timestamp, 120_000); 58 59 var renewResponse = await sandbox.RenewAsync(30 * 60); 60 Assert.NotNull(renewResponse); 61 Assert.NotNull(renewResponse.ExpiresAt); 62 var renewedInfo = await sandbox.GetInfoAsync(); 63 Assert.True(renewedInfo.ExpiresAt > info.ExpiresAt); 64 Assert.True(renewResponse.ExpiresAt > info.ExpiresAt); 65 66 var sandbox2 = await Sandbox.ConnectAsync(new SandboxConnectOptions 67 { 68 ConnectionConfig = _fixture.ConnectionConfig, 69 SandboxId = sandbox.Id 70 }); 71 72 try 73 { 74 Assert.Equal(sandbox.Id, sandbox2.Id); 75 Assert.True(await sandbox2.IsHealthyAsync()); 76 var result = await sandbox2.Commands.RunAsync("echo connect-ok"); 77 Assert.Null(result.Error); 78 Assert.Single(result.Logs.Stdout); 79 Assert.Equal("connect-ok", result.Logs.Stdout[0].Text); 80 } 81 finally 82 { 83 await sandbox2.DisposeAsync(); 84 } 85 } 86 87 [Fact(Timeout = 2 * 60 * 1000)] 88 public async Task Sandbox_XRequestId_Passthrough_OnServerError() 89 { 90 var requestId = $"e2e-csharp-server-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; 91 var missingSandboxId = $"missing-{requestId}"; 92 var baseConfig = _fixture.ConnectionConfig; 93 var config = new ConnectionConfig(new ConnectionConfigOptions 94 { 95 Domain = baseConfig.Domain, 96 Protocol = baseConfig.Protocol, 97 ApiKey = baseConfig.ApiKey, 98 RequestTimeoutSeconds = baseConfig.RequestTimeoutSeconds, 99 Headers = new Dictionary<string, string> { ["X-Request-ID"] = requestId } 100 }); 101 102 var ex = await Assert.ThrowsAsync<SandboxApiException>(async () => 103 { 104 var connected = await Sandbox.ConnectAsync(new SandboxConnectOptions 105 { 106 ConnectionConfig = config, 107 SandboxId = missingSandboxId 108 }); 109 try 110 { 111 await connected.GetInfoAsync(); 112 } 113 finally 114 { 115 await connected.DisposeAsync(); 116 } 117 }); 118 119 Assert.Equal(requestId, ex.RequestId); 120 } 121 122 [Fact(Timeout = 2 * 60 * 1000)] 123 public async Task Sandbox_ManualCleanup_Returns_Null_ExpiresAt() 124 { 125 var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions 126 { 127 ConnectionConfig = _fixture.ConnectionConfig, 128 Image = _fixture.DefaultImage, 129 ManualCleanup = true, 130 ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds, 131 Metadata = new Dictionary<string, string> { ["tag"] = "manual-csharp-e2e-test" } 132 }); 133 134 try 135 { 136 var info = await sandbox.GetInfoAsync(); 137 Assert.Null(info.ExpiresAt); 138 Assert.NotNull(info.Metadata); 139 Assert.Equal("manual-csharp-e2e-test", info.Metadata!["tag"]); 140 } 141 finally 142 { 143 await sandbox.KillAsync(); 144 await sandbox.DisposeAsync(); 145 } 146 } 147 148 [Fact(Timeout = 2 * 60 * 1000)] 149 public async Task Sandbox_Create_With_NetworkPolicy_Get_And_Patch_Egress() 150 { 151 var policySandbox = await Sandbox.CreateAsync(new SandboxCreateOptions 152 { 153 ConnectionConfig = _fixture.ConnectionConfig, 154 Image = _fixture.DefaultImage, 155 TimeoutSeconds = _fixture.DefaultTimeoutSeconds, 156 ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds, 157 NetworkPolicy = new NetworkPolicy 158 { 159 DefaultAction = NetworkRuleAction.Deny, 160 Egress = new List<NetworkRule> { new() { Action = NetworkRuleAction.Allow, Target = "pypi.org" } } 161 } 162 }); 163 164 try 165 { 166 await Task.Delay(5000); 167 168 var initialPolicy = await policySandbox.GetEgressPolicyAsync(); 169 Assert.NotNull(initialPolicy); 170 Assert.Equal(NetworkRuleAction.Deny, initialPolicy.DefaultAction); 171 Assert.NotNull(initialPolicy.Egress); 172 Assert.Contains( 173 initialPolicy.Egress!, 174 rule => rule.Target == "pypi.org" && rule.Action == NetworkRuleAction.Allow); 175 176 var blocked = await policySandbox.Commands.RunAsync("curl -I https://www.github.com"); 177 Assert.NotNull(blocked.Error); 178 179 var allowed = await policySandbox.Commands.RunAsync("curl -I https://pypi.org"); 180 Assert.Null(allowed.Error); 181 182 await policySandbox.PatchEgressRulesAsync(new List<NetworkRule> 183 { 184 new() { Action = NetworkRuleAction.Allow, Target = "www.github.com" }, 185 new() { Action = NetworkRuleAction.Deny, Target = "pypi.org" } 186 }); 187 await Task.Delay(2000); 188 189 var patchedPolicy = await policySandbox.GetEgressPolicyAsync(); 190 Assert.NotNull(patchedPolicy.Egress); 191 Assert.Contains( 192 patchedPolicy.Egress!, 193 rule => rule.Target == "www.github.com" && rule.Action == NetworkRuleAction.Allow); 194 Assert.Contains( 195 patchedPolicy.Egress!, 196 rule => rule.Target == "pypi.org" && rule.Action == NetworkRuleAction.Deny); 197 198 var githubAllowed = await policySandbox.Commands.RunAsync("curl -I https://www.github.com"); 199 Assert.Null(githubAllowed.Error); 200 201 var pypiDenied = await policySandbox.Commands.RunAsync("curl -I https://pypi.org"); 202 Assert.NotNull(pypiDenied.Error); 203 } 204 finally 205 { 206 try 207 { 208 await policySandbox.KillAsync(); 209 } 210 catch 211 { 212 } 213 214 await policySandbox.DisposeAsync(); 215 } 216 } 217 218 [Fact(Timeout = 2 * 60 * 1000)] 219 public async Task Sandbox_Create_With_NetworkPolicy_Get_And_Patch_Egress_Via_ServerProxy() 220 { 221 var policySandbox = await Sandbox.CreateAsync(new SandboxCreateOptions 222 { 223 ConnectionConfig = _fixture.ServerProxyConnectionConfig, 224 Image = _fixture.DefaultImage, 225 TimeoutSeconds = _fixture.DefaultTimeoutSeconds, 226 ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds, 227 NetworkPolicy = new NetworkPolicy 228 { 229 DefaultAction = NetworkRuleAction.Deny, 230 Egress = new List<NetworkRule> { new() { Action = NetworkRuleAction.Allow, Target = "pypi.org" } } 231 } 232 }); 233 234 try 235 { 236 await Task.Delay(5000); 237 238 var egressEndpoint = await policySandbox.GetEndpointAsync(Constants.DefaultEgressPort); 239 Assert.Contains( 240 $"/sandboxes/{policySandbox.Id}/proxy/{Constants.DefaultEgressPort}", 241 egressEndpoint.EndpointAddress); 242 243 var initialPolicy = await policySandbox.GetEgressPolicyAsync(); 244 Assert.NotNull(initialPolicy); 245 Assert.Equal(NetworkRuleAction.Deny, initialPolicy.DefaultAction); 246 Assert.NotNull(initialPolicy.Egress); 247 Assert.Contains( 248 initialPolicy.Egress!, 249 rule => rule.Target == "pypi.org" && rule.Action == NetworkRuleAction.Allow); 250 251 var blocked = await policySandbox.Commands.RunAsync("curl -I https://www.github.com"); 252 Assert.NotNull(blocked.Error); 253 254 var allowed = await policySandbox.Commands.RunAsync("curl -I https://pypi.org"); 255 Assert.Null(allowed.Error); 256 257 await policySandbox.PatchEgressRulesAsync(new List<NetworkRule> 258 { 259 new() { Action = NetworkRuleAction.Allow, Target = "www.github.com" }, 260 new() { Action = NetworkRuleAction.Deny, Target = "pypi.org" } 261 }); 262 await Task.Delay(2000); 263 264 var patchedPolicy = await policySandbox.GetEgressPolicyAsync(); 265 Assert.NotNull(patchedPolicy.Egress); 266 Assert.Contains( 267 patchedPolicy.Egress!, 268 rule => rule.Target == "www.github.com" && rule.Action == NetworkRuleAction.Allow); 269 Assert.Contains( 270 patchedPolicy.Egress!, 271 rule => rule.Target == "pypi.org" && rule.Action == NetworkRuleAction.Deny); 272 } 273 finally 274 { 275 try 276 { 277 await policySandbox.KillAsync(); 278 } 279 catch 280 { 281 } 282 283 await policySandbox.DisposeAsync(); 284 } 285 } 286 287 [Fact(Timeout = 2 * 60 * 1000)] 288 public async Task Sandbox_Create_With_HostVolumeMount() 289 { 290 var hostDir = "/tmp/opensandbox-e2e/host-volume-test"; 291 var containerMountPath = "/mnt/host-data"; 292 var volumeSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions 293 { 294 ConnectionConfig = _fixture.ConnectionConfig, 295 Image = _fixture.DefaultImage, 296 TimeoutSeconds = _fixture.DefaultTimeoutSeconds, 297 ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds, 298 Volumes = new[] 299 { 300 new Volume 301 { 302 Name = "test-host-vol", 303 Host = new Host { Path = hostDir }, 304 MountPath = containerMountPath, 305 ReadOnly = false 306 } 307 } 308 }); 309 310 try 311 { 312 var marker = await volumeSandbox.Commands.RunAsync($"cat {containerMountPath}/marker.txt"); 313 Assert.Null(marker.Error); 314 Assert.Single(marker.Logs.Stdout); 315 Assert.Equal("opensandbox-e2e-marker", marker.Logs.Stdout[0].Text); 316 317 var write = await volumeSandbox.Commands.RunAsync( 318 $"echo 'written-from-sandbox' > {containerMountPath}/sandbox-output.txt"); 319 Assert.Null(write.Error); 320 321 var readBack = await volumeSandbox.Commands.RunAsync($"cat {containerMountPath}/sandbox-output.txt"); 322 Assert.Null(readBack.Error); 323 Assert.Single(readBack.Logs.Stdout); 324 Assert.Equal("written-from-sandbox", readBack.Logs.Stdout[0].Text); 325 } 326 finally 327 { 328 try 329 { 330 await volumeSandbox.KillAsync(); 331 } 332 catch 333 { 334 } 335 336 await volumeSandbox.DisposeAsync(); 337 } 338 } 339 340 [Fact(Timeout = 2 * 60 * 1000)] 341 public async Task Sandbox_Create_With_HostVolumeMount_ReadOnly() 342 { 343 var hostDir = "/tmp/opensandbox-e2e/host-volume-test"; 344 var containerMountPath = "/mnt/host-data-ro"; 345 var roSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions 346 { 347 ConnectionConfig = _fixture.ConnectionConfig, 348 Image = _fixture.DefaultImage, 349 TimeoutSeconds = _fixture.DefaultTimeoutSeconds, 350 ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds, 351 Volumes = new[] 352 { 353 new Volume 354 { 355 Name = "test-host-vol-ro", 356 Host = new Host { Path = hostDir }, 357 MountPath = containerMountPath, 358 ReadOnly = true 359 } 360 } 361 }); 362 363 try 364 { 365 var marker = await roSandbox.Commands.RunAsync($"cat {containerMountPath}/marker.txt"); 366 Assert.Null(marker.Error); 367 Assert.Single(marker.Logs.Stdout); 368 Assert.Equal("opensandbox-e2e-marker", marker.Logs.Stdout[0].Text); 369 370 var write = await roSandbox.Commands.RunAsync($"touch {containerMountPath}/should-fail.txt"); 371 var stat = await roSandbox.Commands.RunAsync( 372 $"test ! -e {containerMountPath}/should-fail.txt && echo OK"); 373 var writeWasRejected = write.Error is not null || write.Logs.Stderr.Count > 0; 374 var fileWasNotCreated = 375 stat.Error is null && 376 stat.Logs.Stdout.Count == 1 && 377 stat.Logs.Stdout[0].Text == "OK"; 378 Assert.True( 379 writeWasRejected || fileWasNotCreated, 380 "Write on read-only host volume should fail or leave no created file."); 381 } 382 finally 383 { 384 try 385 { 386 await roSandbox.KillAsync(); 387 } 388 catch 389 { 390 } 391 392 await roSandbox.DisposeAsync(); 393 } 394 } 395 396 [Fact(Timeout = 2 * 60 * 1000)] 397 public async Task Sandbox_Create_With_PvcVolumeMount() 398 { 399 var pvcVolumeName = "opensandbox-e2e-pvc-test"; 400 var containerMountPath = "/mnt/pvc-data"; 401 var pvcSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions 402 { 403 ConnectionConfig = _fixture.ConnectionConfig, 404 Image = _fixture.DefaultImage, 405 TimeoutSeconds = _fixture.DefaultTimeoutSeconds, 406 ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds, 407 Volumes = new[] 408 { 409 new Volume 410 { 411 Name = "test-pvc-vol", 412 Pvc = new PVC { ClaimName = pvcVolumeName }, 413 MountPath = containerMountPath, 414 ReadOnly = false 415 } 416 } 417 }); 418 419 try 420 { 421 var marker = await pvcSandbox.Commands.RunAsync($"cat {containerMountPath}/marker.txt"); 422 Assert.Null(marker.Error); 423 Assert.Single(marker.Logs.Stdout); 424 Assert.Equal("pvc-marker-data", marker.Logs.Stdout[0].Text); 425 426 var write = await pvcSandbox.Commands.RunAsync( 427 $"echo 'written-to-pvc' > {containerMountPath}/pvc-output.txt"); 428 Assert.Null(write.Error); 429 430 var readBack = await pvcSandbox.Commands.RunAsync($"cat {containerMountPath}/pvc-output.txt"); 431 Assert.Null(readBack.Error); 432 Assert.Single(readBack.Logs.Stdout); 433 Assert.Equal("written-to-pvc", readBack.Logs.Stdout[0].Text); 434 } 435 finally 436 { 437 try 438 { 439 await pvcSandbox.KillAsync(); 440 } 441 catch 442 { 443 } 444 445 await pvcSandbox.DisposeAsync(); 446 } 447 } 448 449 [Fact(Timeout = 2 * 60 * 1000)] 450 public async Task Sandbox_Create_With_PvcVolumeMount_ReadOnly() 451 { 452 var pvcVolumeName = "opensandbox-e2e-pvc-test"; 453 var containerMountPath = "/mnt/pvc-data-ro"; 454 var roSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions 455 { 456 ConnectionConfig = _fixture.ConnectionConfig, 457 Image = _fixture.DefaultImage, 458 TimeoutSeconds = _fixture.DefaultTimeoutSeconds, 459 ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds, 460 Volumes = new[] 461 { 462 new Volume 463 { 464 Name = "test-pvc-vol-ro", 465 Pvc = new PVC { ClaimName = pvcVolumeName }, 466 MountPath = containerMountPath, 467 ReadOnly = true 468 } 469 } 470 }); 471 472 try 473 { 474 var marker = await roSandbox.Commands.RunAsync($"cat {containerMountPath}/marker.txt"); 475 Assert.Null(marker.Error); 476 Assert.Single(marker.Logs.Stdout); 477 Assert.Equal("pvc-marker-data", marker.Logs.Stdout[0].Text); 478 479 var write = await roSandbox.Commands.RunAsync($"touch {containerMountPath}/should-fail.txt"); 480 var stat = await roSandbox.Commands.RunAsync( 481 $"test ! -e {containerMountPath}/should-fail.txt && echo OK"); 482 var writeWasRejected = write.Error is not null || write.Logs.Stderr.Count > 0; 483 var fileWasNotCreated = 484 stat.Error is null && 485 stat.Logs.Stdout.Count == 1 && 486 stat.Logs.Stdout[0].Text == "OK"; 487 Assert.True( 488 writeWasRejected || fileWasNotCreated, 489 "Write on read-only PVC volume should fail or leave no created file."); 490 } 491 finally 492 { 493 try 494 { 495 await roSandbox.KillAsync(); 496 } 497 catch 498 { 499 } 500 501 await roSandbox.DisposeAsync(); 502 } 503 } 504 505 [Fact(Timeout = 2 * 60 * 1000)] 506 public async Task Sandbox_Create_With_PvcVolumeMount_SubPath() 507 { 508 var pvcVolumeName = "opensandbox-e2e-pvc-test"; 509 var containerMountPath = "/mnt/train"; 510 var subPathSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions 511 { 512 ConnectionConfig = _fixture.ConnectionConfig, 513 Image = _fixture.DefaultImage, 514 TimeoutSeconds = _fixture.DefaultTimeoutSeconds, 515 ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds, 516 Volumes = new[] 517 { 518 new Volume 519 { 520 Name = "test-pvc-subpath", 521 Pvc = new PVC { ClaimName = pvcVolumeName }, 522 MountPath = containerMountPath, 523 ReadOnly = false, 524 SubPath = "datasets/train" 525 } 526 } 527 }); 528 529 try 530 { 531 var marker = await subPathSandbox.Commands.RunAsync($"cat {containerMountPath}/marker.txt"); 532 Assert.Null(marker.Error); 533 Assert.Single(marker.Logs.Stdout); 534 Assert.Equal("pvc-subpath-marker", marker.Logs.Stdout[0].Text); 535 536 var ls = await subPathSandbox.Commands.RunAsync($"ls {containerMountPath}/"); 537 Assert.Null(ls.Error); 538 var lsText = string.Join("\n", ls.Logs.Stdout.Select(x => x.Text)); 539 Assert.Contains("marker.txt", lsText, StringComparison.Ordinal); 540 Assert.DoesNotContain("datasets", lsText, StringComparison.Ordinal); 541 542 var write = await subPathSandbox.Commands.RunAsync( 543 $"echo 'subpath-write-test' > {containerMountPath}/output.txt"); 544 Assert.Null(write.Error); 545 546 var readBack = await subPathSandbox.Commands.RunAsync($"cat {containerMountPath}/output.txt"); 547 Assert.Null(readBack.Error); 548 Assert.Single(readBack.Logs.Stdout); 549 Assert.Equal("subpath-write-test", readBack.Logs.Stdout[0].Text); 550 } 551 finally 552 { 553 try 554 { 555 await subPathSandbox.KillAsync(); 556 } 557 catch 558 { 559 } 560 561 await subPathSandbox.DisposeAsync(); 562 } 563 } 564 565 [Fact(Timeout = 2 * 60 * 1000)] 566 public async Task Command_Execution_Success_WorkingDirectory_Background_Failure() 567 { 568 var sandbox = _fixture.Sandbox; 569 570 var stdoutMessages = new ConcurrentBag<OutputMessage>(); 571 var stderrMessages = new ConcurrentBag<OutputMessage>(); 572 var results = new ConcurrentBag<ExecutionResult>(); 573 var errors = new ConcurrentBag<ExecutionError>(); 574 var completedEvents = new ConcurrentBag<ExecutionComplete>(); 575 var initEvents = new ConcurrentBag<ExecutionInit>(); 576 577 var handlers = new ExecutionHandlers 578 { 579 OnStdout = msg => { stdoutMessages.Add(msg); return Task.CompletedTask; }, 580 OnStderr = msg => { stderrMessages.Add(msg); return Task.CompletedTask; }, 581 OnResult = res => { results.Add(res); return Task.CompletedTask; }, 582 OnExecutionComplete = complete => { completedEvents.Add(complete); return Task.CompletedTask; }, 583 OnError = err => { errors.Add(err); return Task.CompletedTask; }, 584 OnInit = init => { initEvents.Add(init); return Task.CompletedTask; } 585 }; 586 587 var echoResult = await sandbox.Commands.RunAsync("echo Hello OpenSandbox E2E", handlers: handlers); 588 Assert.False(string.IsNullOrWhiteSpace(echoResult.Id)); 589 Assert.Null(echoResult.Error); 590 Assert.Single(echoResult.Logs.Stdout); 591 Assert.Equal("Hello OpenSandbox E2E", echoResult.Logs.Stdout[0].Text); 592 AssertRecentTimestampMs(echoResult.Logs.Stdout[0].Timestamp, 60_000); 593 Assert.Equal(0, echoResult.ExitCode); 594 Assert.NotNull(echoResult.Complete); 595 Assert.True(echoResult.Complete!.ExecutionTimeMs >= 0); 596 AssertTerminalEventContract(initEvents, completedEvents, errors, echoResult.Id!); 597 598 var pwdResult = await sandbox.Commands.RunAsync( 599 "pwd", 600 options: new RunCommandOptions { WorkingDirectory = "/tmp" }); 601 Assert.Null(pwdResult.Error); 602 Assert.Single(pwdResult.Logs.Stdout); 603 Assert.Equal("/tmp", pwdResult.Logs.Stdout[0].Text); 604 Assert.Equal(0, pwdResult.ExitCode); 605 Assert.NotNull(pwdResult.Complete); 606 607 var start = DateTime.UtcNow; 608 var backgroundResult = await sandbox.Commands.RunAsync( 609 "sleep 30", 610 options: new RunCommandOptions { Background = true }); 611 var elapsed = DateTime.UtcNow - start; 612 Assert.True(elapsed.TotalSeconds < 10, "Background command should return quickly."); 613 Assert.Null(backgroundResult.ExitCode); 614 615 stdoutMessages = new ConcurrentBag<OutputMessage>(); 616 stderrMessages = new ConcurrentBag<OutputMessage>(); 617 errors = new ConcurrentBag<ExecutionError>(); 618 completedEvents = new ConcurrentBag<ExecutionComplete>(); 619 initEvents = new ConcurrentBag<ExecutionInit>(); 620 621 var failResult = await sandbox.Commands.RunAsync( 622 "nonexistent-command-that-does-not-exist", 623 handlers: new ExecutionHandlers 624 { 625 OnStdout = msg => { stdoutMessages.Add(msg); return Task.CompletedTask; }, 626 OnStderr = msg => { stderrMessages.Add(msg); return Task.CompletedTask; }, 627 OnError = err => { errors.Add(err); return Task.CompletedTask; }, 628 OnExecutionComplete = complete => { completedEvents.Add(complete); return Task.CompletedTask; }, 629 OnInit = init => { initEvents.Add(init); return Task.CompletedTask; } 630 }); 631 632 Assert.NotNull(failResult.Error); 633 Assert.Equal("CommandExecError", failResult.Error!.Name); 634 Assert.True(failResult.Logs.Stderr.Count > 0); 635 Assert.Contains( 636 failResult.Logs.Stderr, 637 msg => msg.Text.Contains("nonexistent-command-that-does-not-exist", StringComparison.Ordinal)); 638 Assert.Null(failResult.Complete); 639 Assert.NotNull(failResult.ExitCode); 640 Assert.Equal(int.Parse(failResult.Error.Value), failResult.ExitCode); 641 AssertTerminalEventContract(initEvents, completedEvents, errors, failResult.Id!); 642 Assert.Empty(completedEvents); 643 } 644 645 [Fact(Timeout = 2 * 60 * 1000)] 646 public async Task Command_Status_And_Background_Logs() 647 { 648 var sandbox = _fixture.Sandbox; 649 650 var execResult = await sandbox.Commands.RunAsync( 651 "sh -c 'echo log-line-1; echo log-line-2; sleep 2'", 652 options: new RunCommandOptions { Background = true }); 653 Assert.False(string.IsNullOrWhiteSpace(execResult.Id)); 654 var commandId = execResult.Id!; 655 656 var status = await sandbox.Commands.GetCommandStatusAsync(commandId); 657 Assert.Equal(commandId, status.Id); 658 Assert.NotNull(status.Running); 659 660 var logsText = new StringBuilder(); 661 long? cursor = null; 662 for (var i = 0; i < 20; i++) 663 { 664 var logs = await sandbox.Commands.GetBackgroundCommandLogsAsync(commandId, cursor); 665 logsText.Append(logs.Content); 666 cursor = logs.Cursor ?? cursor; 667 if (logsText.ToString().Contains("log-line-2", StringComparison.Ordinal)) 668 { 669 break; 670 } 671 672 await Task.Delay(1000); 673 } 674 675 var finalLogs = logsText.ToString(); 676 Assert.Contains("log-line-1", finalLogs, StringComparison.Ordinal); 677 Assert.Contains("log-line-2", finalLogs, StringComparison.Ordinal); 678 } 679 680 [Fact(Timeout = 2 * 60 * 1000)] 681 public async Task Command_Env_Injection() 682 { 683 var sandbox = _fixture.Sandbox; 684 var envKey = "OPEN_SANDBOX_E2E_CMD_ENV"; 685 var envValue = $"env-ok-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; 686 var probeCommand = 687 $"sh -c 'if [ -z \"${{{envKey}:-}}\" ]; then echo \"__EMPTY__\"; else echo \"${{{envKey}}}\"; fi'"; 688 689 var baseline = await sandbox.Commands.RunAsync(probeCommand); 690 Assert.Null(baseline.Error); 691 var baselineOutput = string.Join("\n", baseline.Logs.Stdout.Select(m => m.Text)).Trim(); 692 Assert.Equal("__EMPTY__", baselineOutput); 693 694 var injected = await sandbox.Commands.RunAsync( 695 probeCommand, 696 options: new RunCommandOptions 697 { 698 Envs = new Dictionary<string, string> 699 { 700 [envKey] = envValue, 701 ["OPEN_SANDBOX_E2E_SECOND_ENV"] = "second-ok" 702 } 703 }); 704 Assert.Null(injected.Error); 705 var injectedOutput = string.Join("\n", injected.Logs.Stdout.Select(m => m.Text)).Trim(); 706 Assert.Equal(envValue, injectedOutput); 707 } 708 709 [Fact(Timeout = 2 * 60 * 1000)] 710 public async Task Bash_Session_API_WorkingDirectory_And_Env_Persistence() 711 { 712 var sandbox = _fixture.Sandbox; 713 714 var sid = await sandbox.Commands.CreateSessionAsync(new CreateSessionOptions { WorkingDirectory = "/tmp" }); 715 Assert.False(string.IsNullOrWhiteSpace(sid)); 716 717 var run = await sandbox.Commands.RunInSessionAsync(sid, "pwd"); 718 Assert.Null(run.Error); 719 Assert.Equal(0, run.ExitCode); 720 var stdout = string.Join("", run.Logs.Stdout.Select(m => m.Text)).Trim(); 721 Assert.Equal("/tmp", stdout); 722 723 run = await sandbox.Commands.RunInSessionAsync( 724 sid, 725 "pwd", 726 options: new RunInSessionOptions { WorkingDirectory = "/var" }); 727 Assert.Null(run.Error); 728 Assert.Equal(0, run.ExitCode); 729 stdout = string.Join("", run.Logs.Stdout.Select(m => m.Text)).Trim(); 730 Assert.Equal("/var", stdout); 731 732 run = await sandbox.Commands.RunInSessionAsync( 733 sid, 734 "pwd", 735 options: new RunInSessionOptions { WorkingDirectory = "/tmp" }); 736 Assert.Null(run.Error); 737 Assert.Equal(0, run.ExitCode); 738 stdout = string.Join("", run.Logs.Stdout.Select(m => m.Text)).Trim(); 739 Assert.Equal("/tmp", stdout); 740 741 run = await sandbox.Commands.RunInSessionAsync(sid, "export E2E_SESSION_ENV=session-env-ok"); 742 Assert.Null(run.Error); 743 744 run = await sandbox.Commands.RunInSessionAsync(sid, "echo $E2E_SESSION_ENV"); 745 Assert.Null(run.Error); 746 Assert.Equal(0, run.ExitCode); 747 stdout = string.Join("", run.Logs.Stdout.Select(m => m.Text)).Trim(); 748 Assert.Equal("session-env-ok", stdout); 749 750 run = await sandbox.Commands.RunInSessionAsync(sid, "sh -c 'echo session-fail >&2; exit 7'"); 751 Assert.NotNull(run.Error); 752 Assert.Equal("CommandExecError", run.Error!.Name); 753 Assert.Equal("7", run.Error.Value); 754 Assert.Equal(7, run.ExitCode); 755 Assert.Null(run.Complete); 756 757 var sid2 = await sandbox.Commands.CreateSessionAsync(new CreateSessionOptions { WorkingDirectory = "/var" }); 758 Assert.False(string.IsNullOrWhiteSpace(sid2)); 759 run = await sandbox.Commands.RunInSessionAsync(sid2, "pwd"); 760 Assert.Null(run.Error); 761 Assert.Equal(0, run.ExitCode); 762 stdout = string.Join("", run.Logs.Stdout.Select(m => m.Text)).Trim(); 763 Assert.Equal("/var", stdout); 764 765 await sandbox.Commands.DeleteSessionAsync(sid); 766 await sandbox.Commands.DeleteSessionAsync(sid2); 767 } 768 769 [Fact(Timeout = 2 * 60 * 1000)] 770 public async Task Filesystem_Operations_CRUD_Replace_Move_Delete() 771 { 772 var sandbox = _fixture.Sandbox; 773 774 var testDir1 = $"/tmp/fs_test1_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; 775 var testDir2 = $"/tmp/fs_test2_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; 776 777 await sandbox.Files.CreateDirectoriesAsync(new[] 778 { 779 new CreateDirectoryEntry { Path = testDir1, Mode = 755 }, 780 new CreateDirectoryEntry { Path = testDir2, Mode = 644 } 781 }); 782 783 var dirInfo = await sandbox.Files.GetFileInfoAsync(new[] { testDir1, testDir2 }); 784 Assert.Equal(testDir1, dirInfo[testDir1].Path); 785 Assert.Equal(755, dirInfo[testDir1].Mode); 786 AssertTimesClose(dirInfo[testDir1].CreatedAt, dirInfo[testDir1].ModifiedAt, 2); 787 788 var testFile1 = $"{testDir1}/test_file1.txt"; 789 var testFile2 = $"{testDir1}/test_file2.txt"; 790 var testFile3 = $"{testDir1}/test_file3.txt"; 791 var testContent = "Hello Filesystem! Line 2. Line 3."; 792 793 await sandbox.Files.WriteFilesAsync(new[] 794 { 795 new WriteEntry { Path = testFile1, Data = testContent, Mode = 644 }, 796 new WriteEntry { Path = testFile2, Data = Encoding.UTF8.GetBytes(testContent), Mode = 755 }, 797 new WriteEntry { Path = testFile3, Data = new MemoryStream(Encoding.UTF8.GetBytes(testContent)), Mode = 755 } 798 }); 799 800 var readContent1 = await sandbox.Files.ReadFileAsync( 801 testFile1, 802 new ReadFileOptions { Encoding = "utf-8" }); 803 var readContent1Partial = await sandbox.Files.ReadFileAsync( 804 testFile1, 805 new ReadFileOptions { Encoding = "utf-8", Range = "bytes=0-9" }); 806 var readBytes2 = await sandbox.Files.ReadBytesAsync(testFile2); 807 var readContent2 = Encoding.UTF8.GetString(readBytes2); 808 809 var chunks = new List<byte>(); 810 await foreach (var chunk in sandbox.Files.ReadBytesStreamAsync(testFile3)) 811 { 812 chunks.AddRange(chunk); 813 } 814 815 var readContent3 = Encoding.UTF8.GetString(chunks.ToArray()); 816 817 Assert.Equal(testContent, readContent1); 818 Assert.Equal(testContent, readContent2); 819 Assert.Equal(testContent, readContent3); 820 Assert.Equal(testContent.Substring(0, 10), readContent1Partial); 821 822 var fileInfoMap = await sandbox.Files.GetFileInfoAsync(new[] { testFile1, testFile2, testFile3 }); 823 var expectedSize = Encoding.UTF8.GetBytes(testContent).Length; 824 Assert.Equal(expectedSize, fileInfoMap[testFile1].Size); 825 Assert.Equal(expectedSize, fileInfoMap[testFile2].Size); 826 Assert.Equal(expectedSize, fileInfoMap[testFile3].Size); 827 AssertTimesClose(fileInfoMap[testFile1].CreatedAt, fileInfoMap[testFile1].ModifiedAt, 2); 828 829 var found = new HashSet<string>(); 830 var searchResults = await sandbox.Files.SearchAsync(new SearchEntry { Path = testDir1, Pattern = "*" }); 831 foreach (var entry in searchResults) 832 { 833 found.Add(entry.Path); 834 } 835 Assert.Equal(new HashSet<string> { testFile1, testFile2, testFile3 }, found); 836 837 await sandbox.Files.SetPermissionsAsync(new[] 838 { 839 new SetPermissionEntry { Path = testFile1, Mode = 755 }, 840 new SetPermissionEntry { Path = testFile2, Mode = 600 } 841 }); 842 843 var updatedInfo = await sandbox.Files.GetFileInfoAsync(new[] { testFile1, testFile2 }); 844 Assert.Equal(755, updatedInfo[testFile1].Mode); 845 Assert.Equal(600, updatedInfo[testFile2].Mode); 846 847 var beforeUpdate = (await sandbox.Files.GetFileInfoAsync(new[] { testFile1 }))[testFile1]; 848 var updatedContent1 = testContent + " Appended line."; 849 await Task.Delay(50); 850 await sandbox.Files.WriteFilesAsync(new[] 851 { 852 new WriteEntry { Path = testFile1, Data = updatedContent1, Mode = 644 } 853 }); 854 855 var newContent1 = await sandbox.Files.ReadFileAsync(testFile1, new ReadFileOptions { Encoding = "utf-8" }); 856 Assert.Equal(updatedContent1, newContent1); 857 var afterUpdate = (await sandbox.Files.GetFileInfoAsync(new[] { testFile1 }))[testFile1]; 858 AssertModifiedUpdated(beforeUpdate.ModifiedAt, afterUpdate.ModifiedAt, 1, 1000); 859 860 await Task.Delay(50); 861 await sandbox.Files.ReplaceContentsAsync(new[] 862 { 863 new ContentReplaceEntry 864 { 865 Path = testFile1, 866 OldContent = "Appended line.", 867 NewContent = "Replaced line." 868 } 869 }); 870 871 var replaced = await sandbox.Files.ReadFileAsync(testFile1, new ReadFileOptions { Encoding = "utf-8" }); 872 Assert.Contains("Replaced line.", replaced, StringComparison.Ordinal); 873 Assert.DoesNotContain("Appended line.", replaced, StringComparison.Ordinal); 874 875 var movedPath = $"{testDir2}/moved_file3.txt"; 876 await sandbox.Files.MoveFilesAsync(new[] { new MoveEntry { Src = testFile3, Dest = movedPath } }); 877 var movedBytes = await sandbox.Files.ReadBytesAsync(movedPath); 878 Assert.Equal(testContent, Encoding.UTF8.GetString(movedBytes)); 879 await Assert.ThrowsAnyAsync<Exception>(() => sandbox.Files.ReadBytesAsync(testFile3)); 880 881 await sandbox.Files.DeleteFilesAsync(new[] { testFile2 }); 882 await Assert.ThrowsAnyAsync<Exception>(() => sandbox.Files.ReadFileAsync(testFile2)); 883 884 await sandbox.Files.DeleteDirectoriesAsync(new[] { testDir1, testDir2 }); 885 var verify = await sandbox.Commands.RunAsync( 886 $"test ! -d {testDir1} && test ! -d {testDir2} && echo OK", 887 options: new RunCommandOptions { WorkingDirectory = "/tmp" }); 888 for (var attempt = 0; attempt < 3; attempt++) 889 { 890 var verified = 891 verify.Error is null && 892 verify.Logs.Stdout.Count == 1 && 893 verify.Logs.Stdout[0].Text == "OK"; 894 if (verified) 895 { 896 break; 897 } 898 899 await Task.Delay(1000); 900 verify = await sandbox.Commands.RunAsync( 901 $"test ! -d {testDir1} && test ! -d {testDir2} && echo OK", 902 options: new RunCommandOptions { WorkingDirectory = "/tmp" }); 903 } 904 Assert.Null(verify.Error); 905 Assert.Single(verify.Logs.Stdout); 906 Assert.Equal("OK", verify.Logs.Stdout[0].Text); 907 } 908 909 [Fact(Timeout = 2 * 60 * 1000)] 910 public async Task Command_Interrupt() 911 { 912 var sandbox = _fixture.Sandbox; 913 914 var initEvents = new ConcurrentBag<ExecutionInit>(); 915 var completedEvents = new ConcurrentBag<ExecutionComplete>(); 916 var errors = new ConcurrentBag<ExecutionError>(); 917 var initLatch = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); 918 919 var handlers = new ExecutionHandlers 920 { 921 OnInit = init => 922 { 923 initEvents.Add(init); 924 initLatch.TrySetResult(init.Id); 925 return Task.CompletedTask; 926 }, 927 OnExecutionComplete = complete => { completedEvents.Add(complete); return Task.CompletedTask; }, 928 OnError = err => { errors.Add(err); return Task.CompletedTask; } 929 }; 930 931 var executionTask = sandbox.Commands.RunAsync("sleep 30", handlers: handlers); 932 var id = await initLatch.Task.WaitAsync(TimeSpan.FromSeconds(15)); 933 934 await Task.Delay(2000); 935 await sandbox.Commands.InterruptAsync(id); 936 937 var result = await executionTask.WaitAsync(TimeSpan.FromSeconds(30)); 938 Assert.Equal(id, result.Id); 939 Assert.True((completedEvents.Count > 0) ^ (errors.Count > 0)); 940 Assert.True(result.Error != null || result.Logs.Stderr.Count > 0); 941 } 942 943 [Fact(Timeout = 5 * 60 * 1000)] 944 public async Task Sandbox_Pause_And_Resume() 945 { 946 var sandbox = _fixture.Sandbox; 947 948 await Task.Delay(5000); 949 await sandbox.PauseAsync(); 950 951 var pausedInfo = await WaitForStateAsync(sandbox, SandboxStates.Paused, TimeSpan.FromMinutes(5)); 952 Assert.Equal(SandboxStates.Paused, pausedInfo.Status.State); 953 954 var healthy = true; 955 for (var i = 0; i < 10; i++) 956 { 957 healthy = await sandbox.IsHealthyAsync(); 958 if (!healthy) 959 { 960 break; 961 } 962 await Task.Delay(500); 963 } 964 Assert.False(healthy, "Sandbox should be unhealthy after pause."); 965 966 var resumed = await sandbox.ResumeAsync(new SandboxResumeOptions 967 { 968 ReadyTimeoutSeconds = 60, 969 HealthCheckPollingInterval = 1000 970 }); 971 972 var resumedInfo = await WaitForStateAsync(resumed, SandboxStates.Running, TimeSpan.FromMinutes(3)); 973 Assert.Equal(SandboxStates.Running, resumedInfo.Status.State); 974 975 var isHealthy = false; 976 for (var i = 0; i < 30; i++) 977 { 978 isHealthy = await resumed.IsHealthyAsync(); 979 if (isHealthy) 980 { 981 break; 982 } 983 await Task.Delay(1000); 984 } 985 Assert.True(isHealthy, "Sandbox should be healthy after resume."); 986 987 // Smoke-check command path after resume to ensure execd adapter is usable. 988 var echo = await resumed.Commands.RunAsync("echo resume-ok"); 989 Assert.Null(echo.Error); 990 Assert.Single(echo.Logs.Stdout); 991 Assert.Equal("resume-ok", echo.Logs.Stdout[0].Text); 992 } 993 994 private static void AssertRecentTimestampMs(long ts, long toleranceMs) 995 { 996 Assert.True(ts > 0); 997 var delta = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ts); 998 Assert.True(delta <= toleranceMs, $"timestamp too far from now: delta={delta}ms (ts={ts})"); 999 } 1000 1001 private static void AssertEndpointHasPort(string endpoint, int expectedPort) 1002 { 1003 Assert.False(endpoint.Contains("://", StringComparison.Ordinal), $"unexpected scheme in endpoint: {endpoint}"); 1004 if (endpoint.Contains('/')) 1005 { 1006 Assert.EndsWith($"/{expectedPort}", endpoint, StringComparison.Ordinal); 1007 Assert.False(string.IsNullOrWhiteSpace(endpoint.Split('/', 2)[0])); 1008 return; 1009 } 1010 1011 var parts = endpoint.Split(':'); 1012 Assert.True(parts.Length >= 2, $"missing host:port in endpoint: {endpoint}"); 1013 var port = parts[^1]; 1014 Assert.True(int.TryParse(port, out var parsed)); 1015 Assert.Equal(expectedPort, parsed); 1016 } 1017 1018 private static void AssertTimesClose(DateTime? createdAt, DateTime? modifiedAt, double toleranceSeconds) 1019 { 1020 Assert.NotNull(createdAt); 1021 Assert.NotNull(modifiedAt); 1022 var delta = Math.Abs((modifiedAt!.Value - createdAt!.Value).TotalSeconds); 1023 Assert.True(delta <= toleranceSeconds, $"created/modified skew too large: {delta}s"); 1024 } 1025 1026 private static void AssertModifiedUpdated(DateTime? before, DateTime? after, int minDeltaMs, int allowSkewMs) 1027 { 1028 Assert.NotNull(before); 1029 Assert.NotNull(after); 1030 var deltaMs = (after!.Value - before!.Value).TotalMilliseconds; 1031 Assert.True(deltaMs >= minDeltaMs - allowSkewMs, $"modified_at did not update as expected: delta_ms={deltaMs}"); 1032 } 1033 1034 private static void AssertTerminalEventContract( 1035 IEnumerable<ExecutionInit> initEvents, 1036 IEnumerable<ExecutionComplete> completedEvents, 1037 IEnumerable<ExecutionError> errors, 1038 string executionId) 1039 { 1040 var initList = initEvents.ToList(); 1041 var completeList = completedEvents.ToList(); 1042 var errorList = errors.ToList(); 1043 1044 Assert.Single(initList); 1045 Assert.False(string.IsNullOrWhiteSpace(initList[0].Id)); 1046 Assert.Equal(executionId, initList[0].Id); 1047 AssertRecentTimestampMs(initList[0].Timestamp, 120_000); 1048 1049 var hasComplete = completeList.Count > 0; 1050 var hasError = errorList.Count > 0; 1051 Assert.True(hasComplete || hasError); 1052 1053 if (hasComplete) 1054 { 1055 Assert.Single(completeList); 1056 AssertRecentTimestampMs(completeList[0].Timestamp, 180_000); 1057 Assert.True(completeList[0].ExecutionTimeMs >= 0); 1058 } 1059 1060 if (hasError) 1061 { 1062 Assert.False(string.IsNullOrWhiteSpace(errorList[0].Name)); 1063 Assert.False(string.IsNullOrWhiteSpace(errorList[0].Value)); 1064 AssertRecentTimestampMs(errorList[0].Timestamp, 180_000); 1065 } 1066 } 1067 1068 private static async Task<SandboxInfo> WaitForStateAsync( 1069 Sandbox sandbox, 1070 string expectedState, 1071 TimeSpan timeout) 1072 { 1073 var deadline = DateTime.UtcNow + timeout; 1074 SandboxInfo info; 1075 while (true) 1076 { 1077 info = await sandbox.GetInfoAsync(); 1078 if (info.Status.State == expectedState) 1079 { 1080 return info; 1081 } 1082 1083 if (DateTime.UtcNow > deadline) 1084 { 1085 throw new TimeoutException($"Timed out waiting for state={expectedState}, last_state={info.Status.State}"); 1086 } 1087 1088 await Task.Delay(1000); 1089 } 1090 } 1091 } 1092 1093 public sealed class SandboxE2ETestFixture : IAsyncLifetime 1094 { 1095 private readonly E2ETestFixture _baseFixture = new(); 1096 private Sandbox? _sandbox; 1097 1098 public ConnectionConfig ConnectionConfig => _baseFixture.ConnectionConfig; 1099 public ConnectionConfig ServerProxyConnectionConfig => _baseFixture.ServerProxyConnectionConfig; 1100 public string DefaultImage => _baseFixture.DefaultImage; 1101 public int DefaultTimeoutSeconds => _baseFixture.DefaultTimeoutSeconds; 1102 public int DefaultReadyTimeoutSeconds => _baseFixture.DefaultReadyTimeoutSeconds; 1103 public Sandbox Sandbox => _sandbox ?? throw new InvalidOperationException("Sandbox is not initialized."); 1104 1105 public async Task InitializeAsync() 1106 { 1107 _sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions 1108 { 1109 ConnectionConfig = _baseFixture.ConnectionConfig, 1110 Image = _baseFixture.DefaultImage, 1111 TimeoutSeconds = _baseFixture.DefaultTimeoutSeconds, 1112 ReadyTimeoutSeconds = _baseFixture.DefaultReadyTimeoutSeconds, 1113 Metadata = new Dictionary<string, string> { ["tag"] = "csharp-e2e-test" }, 1114 Env = new Dictionary<string, string> { ["E2E_TEST"] = "true" }, 1115 HealthCheckPollingInterval = 500 1116 }); 1117 } 1118 1119 public async Task DisposeAsync() 1120 { 1121 if (_sandbox == null) 1122 { 1123 return; 1124 } 1125 1126 try 1127 { 1128 await _sandbox.KillAsync(); 1129 } 1130 catch 1131 { 1132 } 1133 1134 await _sandbox.DisposeAsync(); 1135 } 1136 }