From 3932c6bd8f5982736d49b364376357c6cb997275 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 6 May 2026 21:05:59 +0800 Subject: [PATCH] fix: support openclaw artifact range downloads --- internal/acp/openclaw_artifact_download.go | 70 ++++++++++++++++++++- internal/acp/routing_test.go | 73 ++++++++++++++++++++++ 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/internal/acp/openclaw_artifact_download.go b/internal/acp/openclaw_artifact_download.go index e797492..b451d80 100644 --- a/internal/acp/openclaw_artifact_download.go +++ b/internal/acp/openclaw_artifact_download.go @@ -141,13 +141,31 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R if contentType == "" { contentType = artifactContentType(relativePath) } + rangeStart, rangeEnd, partialContent, rangeOK := openClawArtifactContentRange( + r.Header.Get("Range"), + len(content), + ) + if !rangeOK { + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", len(content))) + shared.WriteJSONError(w, nil, http.StatusRequestedRangeNotSatisfiable, -32049, "invalid artifact range") + return + } + body := content + statusCode := http.StatusOK + if partialContent { + body = content[rangeStart : rangeEnd+1] + statusCode = http.StatusPartialContent + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", rangeStart, rangeEnd, len(content))) + } filename := filepath.Base(relativePath) + w.Header().Set("Accept-Ranges", "bytes") w.Header().Set("Content-Type", contentType) - w.Header().Set("Content-Length", strconv.Itoa(len(content))) + w.Header().Set("Content-Length", strconv.Itoa(len(body))) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, strings.ReplaceAll(filename, `"`, ""))) w.Header().Set("X-Content-Type-Options", "nosniff") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(content) + w.WriteHeader(statusCode) + _, _ = w.Write(body) } func (s *Server) readOpenClawArtifactWithRetry( @@ -426,6 +444,52 @@ func openClawArtifactSigningSecret() string { return "" } +func openClawArtifactContentRange(rawRange string, contentLength int) (int, int, bool, bool) { + if strings.TrimSpace(rawRange) == "" { + return 0, contentLength - 1, false, true + } + if contentLength <= 0 { + return 0, 0, false, false + } + rawRange = strings.TrimSpace(rawRange) + if !strings.HasPrefix(rawRange, "bytes=") || strings.Contains(rawRange, ",") { + return 0, 0, false, false + } + spec := strings.TrimSpace(strings.TrimPrefix(rawRange, "bytes=")) + parts := strings.SplitN(spec, "-", 2) + if len(parts) != 2 { + return 0, 0, false, false + } + startRaw := strings.TrimSpace(parts[0]) + endRaw := strings.TrimSpace(parts[1]) + if startRaw == "" { + suffixLength, err := strconv.Atoi(endRaw) + if err != nil || suffixLength <= 0 { + return 0, 0, false, false + } + start := contentLength - suffixLength + if start < 0 { + start = 0 + } + return start, contentLength - 1, true, true + } + start, err := strconv.Atoi(startRaw) + if err != nil || start < 0 || start >= contentLength { + return 0, 0, false, false + } + end := contentLength - 1 + if endRaw != "" { + end, err = strconv.Atoi(endRaw) + if err != nil || end < start { + return 0, 0, false, false + } + if end >= contentLength { + end = contentLength - 1 + } + } + return start, end, true, true +} + func artifactSHA256Matches(content []byte, expected string) bool { expected = strings.TrimSpace(strings.ToLower(expected)) if expected == "" || len(expected) != 64 { diff --git a/internal/acp/routing_test.go b/internal/acp/routing_test.go index c4f7cab..d62d544 100644 --- a/internal/acp/routing_test.go +++ b/internal/acp/routing_test.go @@ -915,6 +915,79 @@ func TestHTTPHandlerOpenClawArtifactDownloadReadsViaGateway(t *testing.T) { } } +func TestHTTPHandlerOpenClawArtifactDownloadSupportsRangeResume(t *testing.T) { + gateway := newAcpFakeOpenClawGateway(t) + defer gateway.Close() + + t.Setenv("GATEWAY_RPC_URL", gateway.URL()) + t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") + + server := NewServer() + downloadURL := server.openClawArtifactDownloadURL( + "thread-openclaw-artifact", + "run-1", + "tasks/thread-openclaw-artifact/run-1", + "reports/final.md", + time.Now(), + ) + if downloadURL == "" { + t.Fatal("expected signed download URL") + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, downloadURL, nil) + request.Header.Set("Authorization", "Bearer bridge-token") + request.Header.Set("Range", "bytes=6-") + server.Handler().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusPartialContent { + t.Fatalf("expected 206, got %d body=%q", recorder.Code, recorder.Body.String()) + } + if got := recorder.Body.String(); got != "report" { + t.Fatalf("expected resumed artifact content, got %q", got) + } + if got := recorder.Header().Get("Accept-Ranges"); got != "bytes" { + t.Fatalf("expected byte ranges to be advertised, got %q", got) + } + if got := recorder.Header().Get("Content-Range"); got != "bytes 6-11/12" { + t.Fatalf("expected content range, got %q", got) + } + if got := recorder.Header().Get("Content-Length"); got != "6" { + t.Fatalf("expected partial content length, got %q", got) + } +} + +func TestHTTPHandlerOpenClawArtifactDownloadRejectsInvalidRange(t *testing.T) { + gateway := newAcpFakeOpenClawGateway(t) + defer gateway.Close() + + t.Setenv("GATEWAY_RPC_URL", gateway.URL()) + t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") + + server := NewServer() + downloadURL := server.openClawArtifactDownloadURL( + "thread-openclaw-artifact", + "run-1", + "tasks/thread-openclaw-artifact/run-1", + "reports/final.md", + time.Now(), + ) + if downloadURL == "" { + t.Fatal("expected signed download URL") + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, downloadURL, nil) + request.Header.Set("Authorization", "Bearer bridge-token") + request.Header.Set("Range", "bytes=99-") + server.Handler().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusRequestedRangeNotSatisfiable { + t.Fatalf("expected 416, got %d body=%q", recorder.Code, recorder.Body.String()) + } + if got := recorder.Header().Get("Content-Range"); got != "bytes */12" { + t.Fatalf("expected unsatisfied content range, got %q", got) + } +} + func TestHTTPHandlerOpenClawArtifactDownloadRetriesTransientReadFailure(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.FailNextArtifactReads(1)