From 56800fbbd4488db20abd4f44f0d39e48599be0ab Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 07:37:51 +0800 Subject: Secure artifact serving: manifest/checkpoint membership, hash validation, link rejection --- README.md | 10 +- src/SomhairlesDream.Server/ArtifactRunApi.fs | 14 +- .../ArtifactRunCoordinator.fs | 217 ++++++++++++++++--- tests/SomhairlesDream.Server.Tests/ApiTests.fs | 236 +++++++++++++++++++++ 4 files changed, 442 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 977c984..0f126db 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,15 @@ Routes used by the frontend: `POST /api/runs/start`, `GET /api/artifacts/manifest`, `GET /api/artifacts/file`, and `GET /api/artifacts/steps?projectId=...&runId=...&path=steps/.glb` (serve a step GLB while a run is still active; `.glb` files under `steps/` -only, traversal-guarded). The server binds to the ASP.NET default (add +only, traversal-guarded). Artifact serving is integrity-checked: `file` +serves only paths published in the verified run manifest (logs, the +manifest itself, and stray files are never exposed), `steps` serves only +checkpointed GLBs (manifest members for completed runs), and every served +stream is hashed against its recorded SHA-256 and byte count at request +time. Filesystem links inside a run directory (file or intermediate +directory symlinks/junctions) are rejected with `400`; hash or byte-count +deviations return `409`, and manifest failures surface as `500`. The +server binds to the ASP.NET default (add `--urls http://127.0.0.1:8099` to match the legacy port). ## CLI diff --git a/src/SomhairlesDream.Server/ArtifactRunApi.fs b/src/SomhairlesDream.Server/ArtifactRunApi.fs index 8f63b99..48ecc08 100644 --- a/src/SomhairlesDream.Server/ArtifactRunApi.fs +++ b/src/SomhairlesDream.Server/ArtifactRunApi.fs @@ -93,6 +93,7 @@ module ArtifactRunApi = | Error(InvalidManifest message) -> error options 500 message | Error(InvalidArtifactPath message) -> error options 500 message | Error ArtifactNotFound -> error options 500 "manifest not found" + | Error(ArtifactMismatch message) -> error options 500 message let private artifactContentType (path: string) = match Path.GetExtension(path).ToLowerInvariant() with @@ -102,18 +103,26 @@ module ArtifactRunApi = | ".log" -> "text/plain" | _ -> "application/octet-stream" + let private streamArtifact (context: HttpContext) (file: ArtifactFile) = + context.Response.OnCompleted(Func(fun () -> + file.Stream.Dispose() + Task.CompletedTask)) + + Results.Stream(file.Stream, artifactContentType file.RelativePath) + let private artifact options (context: HttpContext) = match selector context, queryValue context "path" with | Error message, _ -> error options 400 message | _, None -> error options 400 "missing query parameter 'path'" | Ok(projectId, runId), Some relativePath -> match options.Coordinator.Artifact(projectId, runId, relativePath) with - | Ok path -> Results.File(path, artifactContentType path) + | Ok file -> streamArtifact context file | Error RunNotFound -> error options 404 "run not found" | Error(RunNotComplete snapshot) -> jsonWithStatus options 409 snapshot | Error(InvalidArtifactPath message) -> error options 400 message | Error ArtifactNotFound -> error options 404 "artifact not found" | Error(InvalidManifest message) -> error options 500 message + | Error(ArtifactMismatch message) -> error options 409 message let private stepArtifact options (context: HttpContext) = match selector context, queryValue context "path" with @@ -121,12 +130,13 @@ module ArtifactRunApi = | _, None -> error options 400 "missing query parameter 'path'" | Ok(projectId, runId), Some relativePath -> match options.Coordinator.StepArtifact(projectId, runId, relativePath) with - | Ok path -> Results.File(path, artifactContentType path) + | Ok file -> streamArtifact context file | Error RunNotFound -> error options 404 "run not found" | Error(RunNotComplete snapshot) -> jsonWithStatus options 409 snapshot | Error(InvalidArtifactPath message) -> error options 400 message | Error ArtifactNotFound -> error options 404 "artifact not found" | Error(InvalidManifest message) -> error options 500 message + | Error(ArtifactMismatch message) -> error options 409 message let private writeError options (context: HttpContext) statusCode message = task { diff --git a/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs b/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs index dbf87df..ae222d5 100644 --- a/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs +++ b/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs @@ -1,7 +1,10 @@ namespace SomhairlesDream.Server open System +open System.Collections.Concurrent +open System.Collections.Generic open System.IO +open System.Security.Cryptography open System.Threading.Tasks open SomhairlesDream.Modeling open SomhairlesDream.Shared @@ -16,6 +19,11 @@ type ArtifactLookupError = | InvalidArtifactPath of string | ArtifactNotFound | InvalidManifest of string + | ArtifactMismatch of string + +type ArtifactFile = + { Stream: Stream + RelativePath: string } type ArtifactRunCoordinator( artifactRoot: string, @@ -49,6 +57,149 @@ type ArtifactRunCoordinator( ) |> ignore + let safeArtifactPath (runDirectory: string) (relativePath: string) = + if String.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath) then + Error "artifact path must be relative" + else + let normalized = relativePath.Replace('\\', '/') + let segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries) + + if segments |> Array.exists (fun segment -> segment = ".." || segment = ".") then + Error "artifact path contains traversal" + else + let fullPath = Path.GetFullPath(Path.Combine(runDirectory, normalized.Replace('/', Path.DirectorySeparatorChar))) + let prefix = Path.GetFullPath(runDirectory).TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar + + if fullPath.StartsWith(prefix, StringComparison.Ordinal) then + Ok(fullPath, String.Join("/", segments)) + else + Error "artifact path escapes run directory" + + let checkpointDigests = + ConcurrentDictionary>() + + let recordCheckpointDigest (ids: RunIds) (relativePath: string) = + try + match safeArtifactPath (runDirectory ids.ProjectId ids.RunId) relativePath with + | Ok(fullPath, normalized) when File.Exists(fullPath) -> + use stream = File.OpenRead(fullPath) + use sha = SHA256.Create() + + let digest = + (Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant(), stream.Length) + + let digests = + checkpointDigests.GetOrAdd( + (ids.ProjectId, ids.RunId), + fun _ -> ConcurrentDictionary() + ) + + digests[normalized] <- digest + | _ -> () + with _ -> + () + + let rejectLinks (runDirectory: string) (fullPath: string) = + let root = Path.GetFullPath(runDirectory) + let relative = fullPath.Substring(root.Length + 1) + let mutable current = root + let mutable outcome = Ok () + + for segment in + relative.Split( + [| Path.DirectorySeparatorChar; Path.AltDirectorySeparatorChar |], + StringSplitOptions.RemoveEmptyEntries + ) do + match outcome with + | Error _ -> () + | Ok () -> + current <- Path.Combine(current, segment) + + let info: FileSystemInfo = + if Directory.Exists(current) && not (File.Exists(current)) then + DirectoryInfo(current) :> FileSystemInfo + else + FileInfo(current) :> FileSystemInfo + + if not (isNull info.LinkTarget) then + outcome <- Error "artifact path crosses a filesystem link" + + outcome + + let openValidated + (runDirectory: string) + (fullPath: string) + (normalized: string) + (expectedSha256: string) + (expectedBytes: int64) + : Result = + match rejectLinks runDirectory fullPath with + | Error message -> Error(InvalidArtifactPath message) + | Ok () -> + try + if not (File.Exists(fullPath)) then + Error ArtifactNotFound + else + let stream = + File.Open(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read) + + try + if stream.Length <> expectedBytes then + Error(ArtifactMismatch $"artifact byte count mismatch: {normalized}") + else + use sha = SHA256.Create() + + let sha256 = + Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant() + + if sha256 <> expectedSha256 then + Error(ArtifactMismatch $"artifact hash mismatch: {normalized}") + else + stream.Seek(0L, SeekOrigin.Begin) |> ignore + Ok { Stream = stream :> Stream; RelativePath = normalized } + with _ -> + stream.Dispose() + reraise () + with + | :? FileNotFoundException + | :? DirectoryNotFoundException -> Error ArtifactNotFound + | :? IOException as ex -> Error(InvalidArtifactPath $"artifact unavailable: {ex.Message}") + + let verifiedManifest (projectId: string) (runId: string) = + let path = Path.Combine(runDirectory projectId runId, "manifest.json") + + match ArtifactVerifier.verify path with + | Ok manifest -> Ok manifest + | Error message -> Error(InvalidManifest message) + + let memberDigests (manifest: ArtifactManifest) = + let map = Dictionary() + + let add (relativePath: string) (sha256: string) (bytes: int64) = + map[relativePath] <- (sha256, bytes) + + for step in manifest.Steps do + add step.ArtifactPath step.Sha256 step.Bytes + step.Render |> Option.iter (fun render -> add render.ArtifactPath render.Sha256 render.Bytes) + + map + + let completedDigest (projectId: string) (runId: string) (normalized: string) = + match verifiedManifest projectId runId with + | Error lookupError -> Error lookupError + | Ok manifest -> + match (memberDigests manifest).TryGetValue(normalized) with + | true, digest -> Ok digest + | false, _ -> Error ArtifactNotFound + + let checkpointDigest (projectId: string) (runId: string) (normalized: string) = + match checkpointDigests.TryGetValue((projectId, runId)) with + | true, digests -> + match digests.TryGetValue(normalized) with + | true, digest -> Ok digest + | false, _ -> Error ArtifactNotFound + | false, _ -> Error ArtifactNotFound + let runPipeline (store: ArtifactRunStore) (ids: RunIds) render = try let options : PipelineOptions = @@ -59,6 +210,10 @@ type ArtifactRunCoordinator( Clock = clock OnEvent = fun event -> + (match event with + | Checkpoint value -> recordCheckpointDigest value.Ids value.ArtifactPath + | _ -> ()) + match store.Apply event with | Ok _ -> () | Error message -> invalidOp message } @@ -69,24 +224,6 @@ type ArtifactRunCoordinator( with ex -> failIfNeeded store ids ex.Message - let safeArtifactPath (runDirectory: string) (relativePath: string) = - if String.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath) then - Error "artifact path must be relative" - else - let normalized = relativePath.Replace('\\', '/') - let segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries) - - if segments |> Array.exists (fun segment -> segment = ".." || segment = ".") then - Error "artifact path contains traversal" - else - let fullPath = Path.GetFullPath(Path.Combine(runDirectory, normalized.Replace('/', Path.DirectorySeparatorChar))) - let prefix = Path.GetFullPath(runDirectory).TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar - - if fullPath.StartsWith(prefix, StringComparison.Ordinal) then - Ok fullPath - else - Error "artifact path escapes run directory" - member _.ArtifactRoot = root member _.Start(ids: RunIds, render: bool) : Result = @@ -118,13 +255,9 @@ type ArtifactRunCoordinator( if snapshot.Status <> "complete" then Error(RunNotComplete snapshot) else - let path = Path.Combine(runDirectory projectId runId, "manifest.json") + verifiedManifest projectId runId - match ArtifactVerifier.verify path with - | Ok manifest -> Ok manifest - | Error message -> Error(InvalidManifest message) - - member _.Artifact(projectId: string, runId: string, relativePath: string) : Result = + member _.Artifact(projectId: string, runId: string, relativePath: string) : Result = match registry.TryFind(projectId, runId) with | None -> Error RunNotFound | Some store -> @@ -137,20 +270,40 @@ type ArtifactRunCoordinator( match safeArtifactPath directory relativePath with | Error message -> Error(InvalidArtifactPath message) - | Ok path when not (File.Exists(path)) -> Error ArtifactNotFound - | Ok path -> Ok path + | Ok(fullPath, normalized) -> + match rejectLinks directory fullPath with + | Error message -> Error(InvalidArtifactPath message) + | Ok () -> + match completedDigest projectId runId normalized with + | Error lookupError -> Error lookupError + | Ok(sha256, bytes) -> openValidated directory fullPath normalized sha256 bytes - member _.StepArtifact(projectId: string, runId: string, relativePath: string) : Result = + member _.StepArtifact(projectId: string, runId: string, relativePath: string) : Result = match registry.TryFind(projectId, runId) with | None -> Error RunNotFound - | Some _ -> + | Some store -> let directory = runDirectory projectId runId match safeArtifactPath directory relativePath with | Error message -> Error(InvalidArtifactPath message) - | Ok path when not (path.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)) -> + | Ok(_, normalized) when not (normalized.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)) -> Error(InvalidArtifactPath "step artifacts must be .glb files") - | Ok path when not (path.StartsWith(Path.Combine(directory, "steps") + string Path.DirectorySeparatorChar, StringComparison.Ordinal)) -> + | Ok(_, normalized) when not (normalized.StartsWith("steps/", StringComparison.Ordinal)) -> Error(InvalidArtifactPath "step artifacts must live under steps/") - | Ok path when not (File.Exists(path)) -> Error ArtifactNotFound - | Ok path -> Ok path + | Ok(fullPath, normalized) -> + let snapshot = store.Observe(clock ()) + + let digest = + match rejectLinks directory fullPath with + | Error message -> Error(InvalidArtifactPath message) + | Ok () -> + if snapshot.Status = "complete" then + completedDigest projectId runId normalized + elif snapshot.CompletedSteps |> Array.exists (fun recorded -> recorded = normalized) then + checkpointDigest projectId runId normalized + else + Error ArtifactNotFound + + match digest with + | Error lookupError -> Error lookupError + | Ok(sha256, bytes) -> openValidated directory fullPath normalized sha256 bytes diff --git a/tests/SomhairlesDream.Server.Tests/ApiTests.fs b/tests/SomhairlesDream.Server.Tests/ApiTests.fs index f51ca3d..be6a6cb 100644 --- a/tests/SomhairlesDream.Server.Tests/ApiTests.fs +++ b/tests/SomhairlesDream.Server.Tests/ApiTests.fs @@ -266,6 +266,242 @@ let ``step artifacts stream while a run is still active`` () = gate.Set() gate.Dispose() +let private glbBytes (index: byte) = [| 0x67uy; 0x6Cuy; 0x54uy; index |] +let private runDirectory (coordinator: ArtifactRunCoordinator) (runId: string) = + Path.Combine(coordinator.ArtifactRoot, ids.ProjectId, runId) + +let private getArtifact (client: HttpClient) endpoint runId relativePath = + let encoded = Uri.EscapeDataString(relativePath) + + client + .GetAsync($"/api/artifacts/{endpoint}?projectId={ids.ProjectId}&runId={runId}&path={encoded}") + .GetAwaiter() + .GetResult() + +let private waitForTerminal (coordinator: ArtifactRunCoordinator) runId = + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> + let status = store.Snapshot().Status + status = "complete" || status = "failed")) + ) + +[] +let ``file endpoint serves only published manifest members`` () = + withApp (fun client coordinator -> + let runId = "run-api-scope" + + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + ) + + let glbResponse = getArtifact client "file" runId "steps/01-foundation.glb" + + Assert.Equal(HttpStatusCode.OK, glbResponse.StatusCode) + Assert.Equal("model/gltf-binary", glbResponse.Content.Headers.ContentType.MediaType) + + let bytes = glbResponse.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() + Assert.True((glbBytes 0uy = bytes), "file endpoint should serve the published glb bytes") + + let stepResponse = getArtifact client "steps" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.OK, stepResponse.StatusCode) + + let logResponse = getArtifact client "file" runId "logs/01-foundation.log" + Assert.Equal(HttpStatusCode.NotFound, logResponse.StatusCode) + + let manifestResponse = getArtifact client "file" runId "manifest.json" + Assert.Equal(HttpStatusCode.NotFound, manifestResponse.StatusCode) + + File.WriteAllText(Path.Combine(runDirectory coordinator runId, "extra-secret.txt"), "hidden") + let extraResponse = getArtifact client "file" runId "extra-secret.txt" + Assert.Equal(HttpStatusCode.NotFound, extraResponse.StatusCode)) + +[] +let ``file endpoint rejects tampered published artifacts`` () = + withApp (fun client coordinator -> + let runId = "run-api-tampered" + + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + File.WriteAllBytes(glbPath, glbBytes 0x09uy) + + let response = getArtifact client "file" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode)) + +[] +let ``file endpoint rejects file symlink escapes`` () = + withApp (fun client coordinator -> + let runId = "run-api-filelink" + + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + let original = File.ReadAllBytes(glbPath) + let escapeTarget = Path.Combine(coordinator.ArtifactRoot, "escape-target.glb") + File.WriteAllBytes(escapeTarget, original) + + File.Delete(glbPath) + File.CreateSymbolicLink(glbPath, escapeTarget) |> ignore + + let response = getArtifact client "file" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode)) + +[] +let ``file endpoint rejects intermediate directory symlink escapes`` () = + withApp (fun client coordinator -> + let runId = "run-api-dirlink" + + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + let original = File.ReadAllBytes(glbPath) + let escapeDirectory = Path.Combine(coordinator.ArtifactRoot, "escape-steps") + Directory.CreateDirectory(escapeDirectory) |> ignore + File.WriteAllBytes(Path.Combine(escapeDirectory, "01-foundation.glb"), original) + + let stepsPath = Path.Combine(runDirectory coordinator runId, "steps") + Directory.Delete(stepsPath, true) + Directory.CreateSymbolicLink(stepsPath, escapeDirectory) |> ignore + + let response = getArtifact client "file" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode)) + +[] +let ``step endpoint rejects uncheckpointed glb files while running`` () = + let gate = new ManualResetEventSlim(false) + + try + withAppUsing + (fun () -> StepGatedBridge(gate) :> IArtifactBridge) + (fun client coordinator -> + let runId = "run-api-rogue" + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> + let snapshot = store.Snapshot() + snapshot.Status = "running" && snapshot.CompletedSteps.Length = 1)) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + File.Copy(glbPath, Path.Combine(runDirectory coordinator runId, "steps", "09-rogue.glb")) + + let response = getArtifact client "steps" runId "steps/09-rogue.glb" + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode) + + gate.Set() + waitForTerminal coordinator runId) + finally + gate.Set() + gate.Dispose() + +[] +let ``step endpoint rejects tampered live checkpoint artifacts`` () = + let gate = new ManualResetEventSlim(false) + + try + withAppUsing + (fun () -> StepGatedBridge(gate) :> IArtifactBridge) + (fun client coordinator -> + let runId = "run-api-livetamper" + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> + let snapshot = store.Snapshot() + snapshot.Status = "running" && snapshot.CompletedSteps.Length = 1)) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + File.WriteAllBytes(glbPath, glbBytes 0x09uy) + + let response = getArtifact client "steps" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode) + + gate.Set() + waitForTerminal coordinator runId) + finally + gate.Set() + gate.Dispose() + +[] +let ``step endpoint rejects symlink escapes while running`` () = + let gate = new ManualResetEventSlim(false) + + try + withAppUsing + (fun () -> StepGatedBridge(gate) :> IArtifactBridge) + (fun client coordinator -> + let runId = "run-api-steplink" + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> + let snapshot = store.Snapshot() + snapshot.Status = "running" && snapshot.CompletedSteps.Length = 1)) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + let original = File.ReadAllBytes(glbPath) + let escapeTarget = Path.Combine(coordinator.ArtifactRoot, "escape-step-target.glb") + File.WriteAllBytes(escapeTarget, original) + + File.Delete(glbPath) + File.CreateSymbolicLink(glbPath, escapeTarget) |> ignore + + let response = getArtifact client "steps" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode) + + gate.Set() + waitForTerminal coordinator runId) + finally + gate.Set() + gate.Dispose() + [] let ``events endpoint streams only the selected run`` () = withApp (fun client _ -> -- cgit v1.2.3