feat: sync OpenClaw artifacts by scoped export

This commit is contained in:
Haitao Pan 2026-05-06 09:33:54 +08:00
parent 8fbc0e51be
commit f3f2e7464c
3 changed files with 310 additions and 57 deletions

View File

@ -46,6 +46,8 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R
query := r.URL.Query()
sessionKey := strings.TrimSpace(query.Get("sessionKey"))
runID := strings.TrimSpace(query.Get("runId"))
rawArtifactScope := strings.TrimSpace(query.Get("artifactScope"))
artifactScope := safeOpenClawArtifactDownloadArtifactScope(rawArtifactScope)
relativePath := safeOpenClawArtifactDownloadRelativePath(query.Get("relativePath"))
expires := strings.TrimSpace(query.Get("expires"))
signature := strings.TrimSpace(query.Get("sig"))
@ -53,6 +55,10 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R
shared.WriteJSONError(w, nil, http.StatusBadRequest, -32602, "missing artifact download parameters")
return
}
if rawArtifactScope != "" && artifactScope == "" {
shared.WriteJSONError(w, nil, http.StatusBadRequest, -32602, "invalid artifact scope")
return
}
expiresUnix, err := strconv.ParseInt(expires, 10, 64)
if err != nil || expiresUnix <= 0 {
shared.WriteJSONError(w, nil, http.StatusBadRequest, -32602, "invalid artifact download expiry")
@ -62,7 +68,7 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R
shared.WriteJSONError(w, nil, http.StatusGone, -32041, "artifact download link expired")
return
}
if !validOpenClawArtifactDownloadSignature(sessionKey, runID, relativePath, expires, signature) {
if !validOpenClawArtifactDownloadSignature(sessionKey, runID, artifactScope, relativePath, expires, signature) {
shared.WriteJSONError(w, nil, http.StatusForbidden, -32042, "invalid artifact download signature")
return
}
@ -71,15 +77,19 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R
shared.WriteJSONError(w, nil, http.StatusBadGateway, rpcErr.Code, rpcErr.Message)
return
}
readParams := map[string]any{
"sessionKey": sessionKey,
"runId": runID,
"relativePath": relativePath,
"maxInlineBytes": openClawArtifactDownloadMaxBytes,
}
if artifactScope != "" {
readParams["artifactScope"] = artifactScope
}
readResult := s.gateway.RequestByMode(
"openclaw",
"xworkmate.artifacts.read",
map[string]any{
"sessionKey": sessionKey,
"runId": runID,
"relativePath": relativePath,
"maxInlineBytes": openClawArtifactDownloadMaxBytes,
},
readParams,
time.Minute,
nil,
)
@ -95,7 +105,7 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R
payload := shared.AsMap(readResult.Payload)
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", ""))
artifact, ok := firstOpenClawArtifactForPath(payload, remoteWorkingDirectory, relativePath)
artifact, ok := firstOpenClawArtifactForPath(payload, remoteWorkingDirectory, artifactScope, relativePath)
if !ok {
shared.WriteJSONError(w, nil, http.StatusNotFound, -32044, "artifact_missing")
return
@ -144,6 +154,7 @@ func (s *Server) decorateOpenClawArtifactDownloadURLs(result map[string]any, ses
if result == nil || strings.TrimSpace(sessionKey) == "" || strings.TrimSpace(runID) == "" {
return
}
resultArtifactScope := safeOpenClawArtifactDownloadArtifactScope(shared.StringArg(result, "artifactScope", ""))
for _, key := range []string{"artifacts", "files", "attachments"} {
switch items := result[key].(type) {
case []any:
@ -152,19 +163,24 @@ func (s *Server) decorateOpenClawArtifactDownloadURLs(result map[string]any, ses
if len(mapped) == 0 {
continue
}
s.decorateOpenClawArtifactDownloadURL(mapped, sessionKey, runID)
s.decorateOpenClawArtifactDownloadURL(mapped, sessionKey, runID, resultArtifactScope)
items[index] = mapped
}
result[key] = items
case []map[string]any:
for _, mapped := range items {
s.decorateOpenClawArtifactDownloadURL(mapped, sessionKey, runID)
s.decorateOpenClawArtifactDownloadURL(mapped, sessionKey, runID, resultArtifactScope)
}
}
}
}
func (s *Server) decorateOpenClawArtifactDownloadURL(artifact map[string]any, sessionKey string, runID string) {
func (s *Server) decorateOpenClawArtifactDownloadURL(
artifact map[string]any,
sessionKey string,
runID string,
resultArtifactScope string,
) {
if artifact == nil {
return
}
@ -179,20 +195,38 @@ func (s *Server) decorateOpenClawArtifactDownloadURL(artifact map[string]any, se
if relativePath == "" {
return
}
downloadURL := s.openClawArtifactDownloadURL(sessionKey, runID, relativePath, time.Now())
artifactScope := safeOpenClawArtifactDownloadArtifactScope(shared.StringArg(artifact, "artifactScope", ""))
if artifactScope == "" {
artifactScope = resultArtifactScope
}
downloadURL := s.openClawArtifactDownloadURL(sessionKey, runID, artifactScope, relativePath, time.Now())
if downloadURL == "" {
return
}
artifact["relativePath"] = relativePath
if artifactScope != "" {
artifact["artifactScope"] = artifactScope
}
artifact["downloadUrl"] = downloadURL
delete(artifact, "downloadURL")
delete(artifact, "download_url")
}
func (s *Server) openClawArtifactDownloadURL(sessionKey string, runID string, relativePath string, now time.Time) string {
func (s *Server) openClawArtifactDownloadURL(
sessionKey string,
runID string,
artifactScope string,
relativePath string,
now time.Time,
) string {
sessionKey = strings.TrimSpace(sessionKey)
runID = strings.TrimSpace(runID)
rawArtifactScope := strings.TrimSpace(artifactScope)
artifactScope = safeOpenClawArtifactDownloadArtifactScope(artifactScope)
relativePath = safeOpenClawArtifactDownloadRelativePath(relativePath)
if rawArtifactScope != "" && artifactScope == "" {
return ""
}
if sessionKey == "" || runID == "" || relativePath == "" || openClawArtifactSigningSecret() == "" {
return ""
}
@ -216,9 +250,10 @@ func (s *Server) openClawArtifactDownloadURL(sessionKey string, runID string, re
query := parsed.Query()
query.Set("sessionKey", sessionKey)
query.Set("runId", runID)
query.Set("artifactScope", artifactScope)
query.Set("relativePath", relativePath)
query.Set("expires", expires)
query.Set("sig", signOpenClawArtifactDownload(sessionKey, runID, relativePath, expires))
query.Set("sig", signOpenClawArtifactDownload(sessionKey, runID, artifactScope, relativePath, expires))
parsed.RawQuery = query.Encode()
return parsed.String()
}
@ -226,10 +261,15 @@ func (s *Server) openClawArtifactDownloadURL(sessionKey string, runID string, re
func firstOpenClawArtifactForPath(
payload map[string]any,
remoteWorkingDirectory string,
artifactScope string,
relativePath string,
) (map[string]any, bool) {
artifacts := extractArtifactPayloads(payload, remoteWorkingDirectory)
for _, artifact := range artifacts {
if artifactScope != "" &&
safeOpenClawArtifactDownloadArtifactScope(shared.StringArg(artifact, "artifactScope", "")) != artifactScope {
continue
}
if safeOpenClawArtifactDownloadRelativePath(shared.StringArg(artifact, "relativePath", "")) == relativePath {
return artifact, true
}
@ -258,14 +298,26 @@ func safeOpenClawArtifactDownloadRelativePath(rawPath string) string {
return cleaned
}
func safeOpenClawArtifactDownloadArtifactScope(rawScope string) string {
scope := safeOpenClawArtifactDownloadRelativePath(rawScope)
if scope == "" {
return ""
}
if !strings.HasPrefix(scope, ".xworkmate/artifacts/tasks/") {
return ""
}
return scope
}
func validOpenClawArtifactDownloadSignature(
sessionKey string,
runID string,
artifactScope string,
relativePath string,
expires string,
signature string,
) bool {
expected := signOpenClawArtifactDownload(sessionKey, runID, relativePath, expires)
expected := signOpenClawArtifactDownload(sessionKey, runID, artifactScope, relativePath, expires)
if expected == "" || signature == "" {
return false
}
@ -280,15 +332,23 @@ func validOpenClawArtifactDownloadSignature(
return hmac.Equal(expectedBytes, actualBytes)
}
func signOpenClawArtifactDownload(sessionKey string, runID string, relativePath string, expires string) string {
func signOpenClawArtifactDownload(
sessionKey string,
runID string,
artifactScope string,
relativePath string,
expires string,
) string {
secret := openClawArtifactSigningSecret()
if secret == "" {
return ""
}
mac := hmac.New(sha256.New, []byte(secret))
artifactScope = safeOpenClawArtifactDownloadArtifactScope(artifactScope)
_, _ = mac.Write([]byte(strings.Join([]string{
strings.TrimSpace(sessionKey),
strings.TrimSpace(runID),
artifactScope,
safeOpenClawArtifactDownloadRelativePath(relativePath),
strings.TrimSpace(expires),
}, "\n")))

View File

@ -180,11 +180,26 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat(
notify(message)
}
}
chatParams, rpcErr := openClawChatSendParams(params, turnID)
artifactDeliveryRequired := openClawArtifactDeliveryRequired(params)
sessionKey := openClawSessionKey(params, turnID)
artifactRunID := turnID
var preparedArtifact *openClawPreparedArtifactScope
if artifactDeliveryRequired {
var rpcErr *shared.RPCError
preparedArtifact, rpcErr = o.openClawArtifactPrepare(
gatewayProvider,
sessionKey,
artifactRunID,
notifyWithCollection,
)
if rpcErr != nil {
return nil, rpcErr
}
}
chatParams, rpcErr := openClawChatSendParams(params, turnID, preparedArtifact)
if rpcErr != nil {
return nil, rpcErr
}
artifactDeliveryRequired := openClawArtifactDeliveryRequired(params)
artifactSinceUnixMs := time.Now().Add(-1 * time.Second).UnixMilli()
sendResult := o.server.gateway.RequestByMode(
gatewayProvider,
@ -234,11 +249,13 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat(
mergeOpenClawArtifactPayload(result, o.openClawArtifactExport(
gatewayProvider,
chatParams,
runID,
artifactRunID,
artifactSinceUnixMs,
preparedArtifact,
artifactDeliveryRequired,
notifyWithCollection,
))
o.server.decorateOpenClawArtifactDownloadURLs(result, shared.StringArg(chatParams, "sessionKey", ""), runID)
o.server.decorateOpenClawArtifactDownloadURLs(result, shared.StringArg(chatParams, "sessionKey", ""), artifactRunID)
guardOpenClawArtifactResult(result, artifactDeliveryRequired)
return result, nil
}
@ -252,13 +269,23 @@ func isSessionTaskMethod(method string) bool {
}
}
func openClawChatSendParams(params map[string]any, turnID string) (map[string]any, *shared.RPCError) {
type openClawPreparedArtifactScope struct {
ArtifactScope string
ArtifactDirectory string
ScopeKind string
}
func openClawChatSendParams(
params map[string]any,
turnID string,
preparedArtifact *openClawPreparedArtifactScope,
) (map[string]any, *shared.RPCError) {
message := firstNonEmptyString(params, "taskPrompt", "prompt", "message")
if message == "" {
return nil, &shared.RPCError{Code: -32602, Message: "OPENCLAW_TASK_PROMPT_REQUIRED"}
}
if openClawArtifactDeliveryRequired(params) {
message = withOpenClawArtifactDeliveryInstructions(message)
message = withOpenClawArtifactDeliveryInstructions(message, preparedArtifact)
}
sessionKey := openClawSessionKey(params, turnID)
chatParams := map[string]any{
@ -290,7 +317,7 @@ func openClawArtifactDeliveryRequired(params map[string]any) bool {
"视频", "音频", "压缩包", "数据集", "文档", "报告", "演示", "幻灯片", "表格", "代码",
}
actionSignals := []string{
"create", "generate", "build", "write", "export", "output", "deliver", "download",
"create", "generate", "build", "make", "write", "export", "output", "deliver", "download",
"save", "produce", "render", "attach", "return",
"生成", "制作", "输出", "导出", "下载", "交付", "收取", "保存", "渲染", "返回", "提供",
}
@ -312,18 +339,31 @@ func openClawArtifactDeliveryRequired(params map[string]any) bool {
return false
}
func withOpenClawArtifactDeliveryInstructions(message string) string {
func withOpenClawArtifactDeliveryInstructions(
message string,
preparedArtifact *openClawPreparedArtifactScope,
) string {
message = strings.TrimSpace(message)
if message == "" {
return message
}
return message + "\n\n" + strings.Join([]string{
lines := []string{
"XWorkmate artifact delivery requirements:",
"- Create the requested files in the current OpenClaw workspace as real files before finishing.",
"- Create the requested files as real files before finishing.",
}
if preparedArtifact != nil && strings.TrimSpace(preparedArtifact.ArtifactDirectory) != "" {
lines = append(lines,
"- Write every deliverable file into this exact directory:",
fmt.Sprintf(" `%s`", strings.TrimSpace(preparedArtifact.ArtifactDirectory)),
"- Do not write deliverable files outside that directory.",
)
}
lines = append(lines,
"- If multiple formats are requested, write each requested format as a separate file with the correct extension.",
"- Do not claim that files are ready, downloadable, or clickable unless the files actually exist on disk.",
"- In the final response, list only the real file names you created. Do not invent download links.",
}, "\n")
)
return message + "\n\n" + strings.Join(lines, "\n")
}
func openClawSessionKey(params map[string]any, turnID string) string {
@ -338,27 +378,72 @@ func openClawSessionKey(params map[string]any, turnID string) string {
return "main"
}
func (o *SessionOrchestrator) openClawArtifactPrepare(
gatewayProvider string,
sessionKey string,
runID string,
notify func(map[string]any),
) (*openClawPreparedArtifactScope, *shared.RPCError) {
sessionKey = strings.TrimSpace(sessionKey)
runID = strings.TrimSpace(runID)
if sessionKey == "" || runID == "" {
return nil, &shared.RPCError{Code: -32602, Message: "OPENCLAW_ARTIFACT_SCOPE_REQUIRED"}
}
prepareResult := o.server.gateway.RequestByMode(
gatewayProvider,
"xworkmate.artifacts.prepare",
map[string]any{
"sessionKey": sessionKey,
"runId": runID,
},
30*time.Second,
notify,
)
if !prepareResult.OK {
return nil, gatewayRPCError(prepareResult.Error, "openclaw artifact prepare failed")
}
payload := shared.AsMap(prepareResult.Payload)
prepared := &openClawPreparedArtifactScope{
ArtifactScope: strings.TrimSpace(shared.StringArg(payload, "artifactScope", "")),
ArtifactDirectory: strings.TrimSpace(shared.StringArg(payload, "artifactDirectory", "")),
ScopeKind: strings.TrimSpace(shared.StringArg(payload, "scopeKind", "")),
}
if prepared.ArtifactScope == "" || prepared.ArtifactDirectory == "" {
return nil, &shared.RPCError{Code: -32002, Message: "openclaw artifact prepare returned invalid scope"}
}
return prepared, nil
}
func (o *SessionOrchestrator) openClawArtifactExport(
gatewayProvider string,
chatParams map[string]any,
runID string,
sinceUnixMs int64,
preparedArtifact *openClawPreparedArtifactScope,
latestIfEmpty bool,
notify func(map[string]any),
) map[string]any {
sessionKey := strings.TrimSpace(shared.StringArg(chatParams, "sessionKey", ""))
if sessionKey == "" || strings.TrimSpace(runID) == "" {
return nil
}
exportParams := map[string]any{
"sessionKey": sessionKey,
"runId": strings.TrimSpace(runID),
"sinceUnixMs": sinceUnixMs,
"maxFiles": 64,
"maxInlineBytes": 10 * 1024 * 1024,
}
if preparedArtifact != nil && strings.TrimSpace(preparedArtifact.ArtifactScope) != "" {
exportParams["artifactScope"] = strings.TrimSpace(preparedArtifact.ArtifactScope)
}
if latestIfEmpty {
exportParams["latestIfEmpty"] = true
}
exportResult := o.server.gateway.RequestByMode(
gatewayProvider,
"xworkmate.artifacts.export",
map[string]any{
"sessionKey": sessionKey,
"runId": strings.TrimSpace(runID),
"sinceUnixMs": sinceUnixMs,
"maxFiles": 64,
"maxInlineBytes": 10 * 1024 * 1024,
},
exportParams,
30*time.Second,
notify,
)
@ -408,6 +493,16 @@ func mergeOpenClawArtifactPayload(result map[string]any, source map[string]any)
result["remoteWorkspaceRefKind"] = remoteWorkspaceRefKind
}
}
if strings.TrimSpace(shared.StringArg(result, "artifactScope", "")) == "" {
if artifactScope := strings.TrimSpace(shared.StringArg(source, "artifactScope", "")); artifactScope != "" {
result["artifactScope"] = artifactScope
}
}
if strings.TrimSpace(shared.StringArg(result, "scopeKind", "")) == "" {
if scopeKind := strings.TrimSpace(shared.StringArg(source, "scopeKind", "")); scopeKind != "" {
result["scopeKind"] = scopeKind
}
}
for _, key := range []string{"artifacts", "files", "attachments", "artifactWarnings", "warnings"} {
merged := appendArtifactList(result[key], source[key])
if len(merged) > 0 {

View File

@ -697,11 +697,15 @@ func TestExecuteSessionTaskGatewayExportsOpenClawArtifacts(t *testing.T) {
if got := parsedDownloadURL.Query().Get("relativePath"); got != "reports/final.md" {
t.Fatalf("expected artifact relativePath in downloadUrl, got %q", got)
}
artifactScope := parsedDownloadURL.Query().Get("artifactScope")
if !strings.HasPrefix(artifactScope, ".xworkmate/artifacts/tasks/") {
t.Fatalf("expected artifact scope in downloadUrl, got %q", artifactScope)
}
if parsedDownloadURL.Query().Get("sig") == "" {
t.Fatalf("expected signed downloadUrl, got %q", downloadURL)
}
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, chat.send, agent.wait, then artifact export, got %#v", got)
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got)
}
}
@ -713,7 +717,13 @@ func TestHTTPHandlerOpenClawArtifactDownloadReadsViaGateway(t *testing.T) {
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
server := NewServer()
downloadURL := server.openClawArtifactDownloadURL("thread-openclaw-artifact", "run-1", "reports/final.md", time.Now())
downloadURL := server.openClawArtifactDownloadURL(
"thread-openclaw-artifact",
"run-1",
".xworkmate/artifacts/tasks/thread-openclaw-artifact/run-1",
"reports/final.md",
time.Now(),
)
if downloadURL == "" {
t.Fatal("expected signed download URL")
}
@ -747,7 +757,13 @@ func TestHTTPHandlerOpenClawArtifactDownloadReturnsArtifactMissing(t *testing.T)
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
server := NewServer()
downloadURL := server.openClawArtifactDownloadURL("thread-openclaw-artifact", "run-1", "missing.txt", time.Now())
downloadURL := server.openClawArtifactDownloadURL(
"thread-openclaw-artifact",
"run-1",
".xworkmate/artifacts/tasks/thread-openclaw-artifact/run-1",
"missing.txt",
time.Now(),
)
if downloadURL == "" {
t.Fatal("expected signed download URL")
}
@ -771,7 +787,13 @@ func TestHTTPHandlerOpenClawArtifactDownloadRequiresBearer(t *testing.T) {
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
server := NewServer()
downloadURL := server.openClawArtifactDownloadURL("thread-openclaw-artifact", "run-1", "reports/final.md", time.Now())
downloadURL := server.openClawArtifactDownloadURL(
"thread-openclaw-artifact",
"run-1",
".xworkmate/artifacts/tasks/thread-openclaw-artifact/run-1",
"reports/final.md",
time.Now(),
)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, downloadURL, nil)
server.Handler().ServeHTTP(recorder, request)
@ -785,7 +807,13 @@ func TestHTTPHandlerOpenClawArtifactDownloadRejectsInvalidSignature(t *testing.T
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
server := NewServer()
downloadURL := server.openClawArtifactDownloadURL("thread-openclaw-artifact", "run-1", "reports/final.md", time.Now())
downloadURL := server.openClawArtifactDownloadURL(
"thread-openclaw-artifact",
"run-1",
".xworkmate/artifacts/tasks/thread-openclaw-artifact/run-1",
"reports/final.md",
time.Now(),
)
parsed, err := url.Parse(downloadURL)
if err != nil {
t.Fatalf("parse downloadUrl: %v", err)
@ -811,9 +839,16 @@ func TestHTTPHandlerOpenClawArtifactDownloadRejectsExpiredSignature(t *testing.T
expires := fmt.Sprintf("%d", time.Now().Add(-time.Minute).Unix())
values.Set("sessionKey", "thread-openclaw-artifact")
values.Set("runId", "run-1")
values.Set("artifactScope", ".xworkmate/artifacts/tasks/thread-openclaw-artifact/run-1")
values.Set("relativePath", "reports/final.md")
values.Set("expires", expires)
values.Set("sig", signOpenClawArtifactDownload("thread-openclaw-artifact", "run-1", "reports/final.md", expires))
values.Set("sig", signOpenClawArtifactDownload(
"thread-openclaw-artifact",
"run-1",
".xworkmate/artifacts/tasks/thread-openclaw-artifact/run-1",
"reports/final.md",
expires,
))
server := NewServer()
recorder := httptest.NewRecorder()
@ -847,6 +882,28 @@ func TestHTTPHandlerOpenClawArtifactDownloadRejectsTraversalPath(t *testing.T) {
}
}
func TestHTTPHandlerOpenClawArtifactDownloadRejectsInvalidArtifactScope(t *testing.T) {
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
values := url.Values{}
values.Set("sessionKey", "thread-openclaw-artifact")
values.Set("runId", "run-1")
values.Set("artifactScope", "../outside")
values.Set("relativePath", "reports/final.md")
values.Set("expires", fmt.Sprintf("%d", time.Now().Add(time.Hour).Unix()))
values.Set("sig", "irrelevant")
server := NewServer()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, openClawArtifactDownloadPath+"?"+values.Encode(), nil)
request.Header.Set("Authorization", "Bearer bridge-token")
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%q", recorder.Code, recorder.Body.String())
}
}
func TestOpenClawChatSendParamsAddsArtifactDeliveryInstructions(t *testing.T) {
for _, prompt := range []string{
"输出 PPT PDF docx 文件",
@ -858,7 +915,11 @@ func TestOpenClawChatSendParamsAddsArtifactDeliveryInstructions(t *testing.T) {
chatParams, rpcErr := openClawChatSendParams(map[string]any{
"threadId": "thread-artifact-instructions",
"taskPrompt": prompt,
}, "turn-artifact-instructions")
}, "turn-artifact-instructions", &openClawPreparedArtifactScope{
ArtifactScope: ".xworkmate/artifacts/tasks/thread-artifact-instructions/turn-artifact-instructions",
ArtifactDirectory: "/remote/openclaw/workspace/.xworkmate/artifacts/tasks/thread-artifact-instructions/turn-artifact-instructions",
ScopeKind: "task",
})
if rpcErr != nil {
t.Fatalf("expected chat params, got rpc error: %#v", rpcErr)
}
@ -866,9 +927,12 @@ func TestOpenClawChatSendParamsAddsArtifactDeliveryInstructions(t *testing.T) {
if !strings.Contains(message, prompt) {
t.Fatalf("expected original prompt to be preserved, got %q", message)
}
if !strings.Contains(message, "Create the requested files in the current OpenClaw workspace as real files") {
if !strings.Contains(message, "Create the requested files as real files") {
t.Fatalf("expected artifact delivery instructions, got %q", message)
}
if !strings.Contains(message, "/remote/openclaw/workspace/.xworkmate/artifacts/tasks/thread-artifact-instructions/turn-artifact-instructions") {
t.Fatalf("expected scoped artifact directory instruction, got %q", message)
}
if !strings.Contains(message, "Do not claim that files are ready") {
t.Fatalf("expected anti-hallucination download instruction, got %q", message)
}
@ -1232,6 +1296,27 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
"status": "started",
},
})
case "xworkmate.artifacts.prepare":
params := shared.AsMap(frame["params"])
runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run"))
sessionKey := strings.TrimSpace(shared.StringArg(params, "sessionKey", "main"))
artifactScope := ".xworkmate/artifacts/tasks/" + sessionKey + "/" + runID
_ = conn.WriteJSON(map[string]any{
"type": "res",
"id": id,
"ok": true,
"payload": map[string]any{
"runId": runID,
"sessionKey": sessionKey,
"remoteWorkingDirectory": "/remote/openclaw/workspace",
"remoteWorkspaceRefKind": "remotePath",
"artifactScope": artifactScope,
"scopeKind": "task",
"artifactDirectory": "/remote/openclaw/workspace/" + artifactScope,
"relativeArtifactDirectory": artifactScope,
"warnings": []any{},
},
})
case "agent.wait":
fake.agentWaitCount.Add(1)
params := shared.AsMap(frame["params"])
@ -1323,24 +1408,32 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
}
params := shared.AsMap(frame["params"])
runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run"))
artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
payload := map[string]any{
"runId": runID,
"sessionKey": strings.TrimSpace(shared.StringArg(params, "sessionKey", "")),
"remoteWorkingDirectory": "/remote/openclaw/workspace",
"remoteWorkspaceRefKind": "remotePath",
"scopeKind": "workspace",
"artifacts": []any{},
"warnings": []any{},
}
if artifactScope != "" {
payload["artifactScope"] = artifactScope
payload["scopeKind"] = "task"
}
if strings.Contains(fake.runMessage(runID), "make artifact") {
payload["artifacts"] = []any{
map[string]any{
"relativePath": "reports/final.md",
"label": "final.md",
"contentType": "text/markdown",
"sizeBytes": 12,
"sha256": "fake-sha256",
"encoding": "base64",
"content": "ZmluYWwgcmVwb3J0",
"relativePath": "reports/final.md",
"label": "final.md",
"contentType": "text/markdown",
"sizeBytes": 12,
"sha256": "fake-sha256",
"artifactScope": artifactScope,
"scopeKind": "task",
"encoding": "base64",
"content": "ZmluYWwgcmVwb3J0",
},
}
}
@ -1354,6 +1447,7 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
fake.artifactReadCount.Add(1)
params := shared.AsMap(frame["params"])
relativePath := strings.TrimSpace(shared.StringArg(params, "relativePath", ""))
artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
if relativePath != "reports/final.md" {
_ = conn.WriteJSON(map[string]any{
"type": "res",
@ -1377,15 +1471,19 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
"sessionKey": strings.TrimSpace(shared.StringArg(params, "sessionKey", "")),
"remoteWorkingDirectory": "/remote/openclaw/workspace",
"remoteWorkspaceRefKind": "remotePath",
"artifactScope": artifactScope,
"scopeKind": "task",
"artifacts": []any{
map[string]any{
"relativePath": "reports/final.md",
"label": "final.md",
"contentType": "text/markdown",
"sizeBytes": len(content),
"sha256": hex.EncodeToString(sum[:]),
"encoding": "base64",
"content": base64.StdEncoding.EncodeToString(content),
"relativePath": "reports/final.md",
"label": "final.md",
"contentType": "text/markdown",
"sizeBytes": len(content),
"sha256": hex.EncodeToString(sum[:]),
"artifactScope": artifactScope,
"scopeKind": "task",
"encoding": "base64",
"content": base64.StdEncoding.EncodeToString(content),
},
},
},