fix: support openclaw artifact range downloads

This commit is contained in:
Haitao Pan 2026-05-06 21:05:59 +08:00
parent 6e00173022
commit 3932c6bd8f
2 changed files with 140 additions and 3 deletions

View File

@ -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 {

View File

@ -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)