Remote /v1/run now emits additive result_v1; Go validates run_id, digest, and paths before materializing, and only accepts missing typed payloads with an explicit legacy handshake. Co-authored-by: Cursor <cursoragent@cursor.com>
109 lines
2.9 KiB
Go
109 lines
2.9 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
type remoteRunWire struct {
|
|
OK bool `json:"ok"`
|
|
RunID string `json:"run_id"`
|
|
FallbackReason string `json:"fallback_reason"`
|
|
Retryable bool `json:"retryable"`
|
|
RunnerProtocol string `json:"runner_protocol"`
|
|
Capabilities *struct {
|
|
TypedResult bool `json:"typed_result"`
|
|
} `json:"capabilities"`
|
|
ResultV1 *ResultV1 `json:"result_v1"`
|
|
Patches []struct {
|
|
RelPath string `json:"rel_path"`
|
|
Content string `json:"content"`
|
|
} `json:"patches"`
|
|
}
|
|
|
|
type decodedRemote struct {
|
|
OK bool
|
|
Retryable bool
|
|
Reason string
|
|
Incoming map[string][]byte
|
|
Typed *ResultV1
|
|
Legacy bool
|
|
}
|
|
|
|
func claimsTypedCapability(w remoteRunWire) bool {
|
|
if w.Capabilities != nil && w.Capabilities.TypedResult {
|
|
return true
|
|
}
|
|
return strings.EqualFold(strings.TrimSpace(w.RunnerProtocol), RunnerProtocolTypedV1)
|
|
}
|
|
|
|
func isLegacyHandshake(w remoteRunWire) bool {
|
|
return strings.EqualFold(strings.TrimSpace(w.RunnerProtocol), RunnerProtocolLegacy)
|
|
}
|
|
|
|
func DecodeRemoteRun(body []byte, expectedRunID string, typedRequested bool) (*decodedRemote, error) {
|
|
if len(body) == 0 {
|
|
return nil, fmt.Errorf("%w: empty body", ErrTypedResult)
|
|
}
|
|
var w remoteRunWire
|
|
if err := json.Unmarshal(body, &w); err != nil {
|
|
return nil, fmt.Errorf("failed to decode remote response")
|
|
}
|
|
incoming := make(map[string][]byte, len(w.Patches))
|
|
for _, pt := range w.Patches {
|
|
if len(pt.Content) > MaxResultMemberBytes {
|
|
return nil, fmt.Errorf("%w: member size", ErrTypedResult)
|
|
}
|
|
incoming[pt.RelPath] = []byte(pt.Content)
|
|
}
|
|
|
|
out := &decodedRemote{OK: w.OK, Incoming: incoming}
|
|
|
|
if w.ResultV1 != nil {
|
|
if err := w.ResultV1.Validate(expectedRunID, incoming); err != nil {
|
|
return nil, err
|
|
}
|
|
out.Typed = w.ResultV1
|
|
out.Retryable = TypedRetryable(*w.ResultV1)
|
|
if w.ResultV1.Outcome == "error" {
|
|
out.OK = false
|
|
out.Reason = w.ResultV1.ErrorCode
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// typed-present is handled above. Missing typed payload:
|
|
// handshake downgrade — claiming typed capability without result_v1 — fail closed.
|
|
if typedRequested && (claimsTypedCapability(w) || !isLegacyHandshake(w)) {
|
|
return nil, fmt.Errorf("%w: missing result_v1", ErrTypedResult)
|
|
}
|
|
|
|
out.Legacy = true
|
|
out.Retryable = w.Retryable
|
|
out.Reason = safeLegacyReason(w.FallbackReason)
|
|
return out, nil
|
|
}
|
|
|
|
func safeLegacyReason(reason string) string {
|
|
r := strings.TrimSpace(reason)
|
|
switch {
|
|
case r == "":
|
|
return "remote pi-agent reported task failure"
|
|
case strings.EqualFold(r, "prompt_empty"):
|
|
return "PROMPT_EMPTY"
|
|
case strings.Contains(strings.ToLower(r), "unauthorized"):
|
|
return "UNAUTHORIZED"
|
|
case strings.Contains(strings.ToLower(r), "timeout"):
|
|
return "TIMEOUT"
|
|
default:
|
|
if strings.ContainsAny(r, "/\\") || strings.Contains(strings.ToLower(r), "token") {
|
|
return "RUN_FAILED"
|
|
}
|
|
if len(r) > 64 {
|
|
return "RUN_FAILED"
|
|
}
|
|
return r
|
|
}
|
|
}
|