mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* perf(tui): disable duplicate composer cursor blink * perf(tui): stop composer wakeups after idle * refactor(tools): publish immutable registry snapshots * perf(startup): defer optional tool discovery * perf(provider): reuse compatible response sessions * feat(provider): add native patch tool input * fix(provider): recognize saved chat sessions * perf(tui): render only on screen changes * fix(runtime): address performance review findings * fix(usage): expose cache efficiency details * perf(agent): reduce redundant bounded-task turns * fix: harden runtime review edge cases * fix: address runtime review findings * build: restore official Bubble Tea dependency * fix(cli): redact MCP registration errors * fix: harden turn session regressions * fix: keep run tools and patch roots consistent * fix: bind freeform patch writes to scoped roots * test: keep patch fixtures out of worktree * fix(tools): reject mixed-root freeform patches
399 lines
13 KiB
Go
399 lines
13 KiB
Go
package streamjson
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
const SchemaVersion = 2
|
|
|
|
type EventType string
|
|
|
|
const (
|
|
EventRunStart EventType = "run_start"
|
|
EventText EventType = "text"
|
|
EventReasoning EventType = "reasoning"
|
|
EventToolCall EventType = "tool_call"
|
|
EventPermission EventType = "permission"
|
|
EventPermissionRequest EventType = "permission_request"
|
|
EventPermissionDecision EventType = "permission_decision"
|
|
EventToolResult EventType = "tool_result"
|
|
EventCheckpoint EventType = "checkpoint"
|
|
EventRestore EventType = "restore"
|
|
EventUsage EventType = "usage"
|
|
EventFinal EventType = "final"
|
|
EventWarning EventType = "warning"
|
|
EventError EventType = "error"
|
|
EventRunEnd EventType = "run_end"
|
|
)
|
|
|
|
type InputType string
|
|
|
|
const (
|
|
InputPrompt InputType = "prompt"
|
|
InputMessage InputType = "message"
|
|
)
|
|
|
|
// Display is a compact structured summary of a tool result.
|
|
type Display struct {
|
|
Summary string `json:"summary,omitempty"`
|
|
Kind string `json:"kind,omitempty"`
|
|
}
|
|
|
|
// CheckpointInfo describes a captured file checkpoint or an applied restore.
|
|
type CheckpointInfo struct {
|
|
Sequence int `json:"sequence,omitempty"`
|
|
Tool string `json:"tool,omitempty"`
|
|
Files []string `json:"files,omitempty"`
|
|
FilesRestored int `json:"filesRestored,omitempty"`
|
|
FilesDeleted int `json:"filesDeleted,omitempty"`
|
|
Skipped []string `json:"skipped,omitempty"`
|
|
}
|
|
|
|
type Event struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Type EventType `json:"type"`
|
|
RunID string `json:"runId"`
|
|
SessionID string `json:"sessionId,omitempty"`
|
|
Cwd string `json:"cwd,omitempty"`
|
|
Provider string `json:"provider,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
APIModel string `json:"apiModel,omitempty"`
|
|
Delta string `json:"delta,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Args any `json:"args,omitempty"`
|
|
Action string `json:"action,omitempty"`
|
|
Permission string `json:"permission,omitempty"`
|
|
PermissionGranted *bool `json:"permissionGranted,omitempty"`
|
|
PermissionMode string `json:"permissionMode,omitempty"`
|
|
Autonomy string `json:"autonomy,omitempty"`
|
|
SideEffect string `json:"sideEffect,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
DecisionReason string `json:"decisionReason,omitempty"`
|
|
Risk *sandbox.Risk `json:"risk,omitempty"`
|
|
Block *sandbox.Block `json:"block,omitempty"`
|
|
GrantMatched bool `json:"grantMatched,omitempty"`
|
|
Grant *sandbox.Grant `json:"grant,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
Output string `json:"output,omitempty"`
|
|
Truncated *bool `json:"truncated,omitempty"`
|
|
Redacted *bool `json:"redacted,omitempty"`
|
|
ChangedFiles []string `json:"changedFiles,omitempty"`
|
|
Display *Display `json:"display,omitempty"`
|
|
Checkpoint *CheckpointInfo `json:"checkpoint,omitempty"`
|
|
Meta map[string]string `json:"meta,omitempty"`
|
|
PromptTokens *int `json:"promptTokens,omitempty"`
|
|
CompletionTokens *int `json:"completionTokens,omitempty"`
|
|
CachedInputTokens *int `json:"cachedInputTokens,omitempty"`
|
|
CacheWriteTokens *int `json:"cacheWriteTokens,omitempty"`
|
|
TotalTokens *int `json:"totalTokens,omitempty"`
|
|
CostUSD *float64 `json:"costUsd,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Code string `json:"code,omitempty"`
|
|
Recoverable *bool `json:"recoverable,omitempty"`
|
|
ExitCode *int `json:"exitCode,omitempty"`
|
|
}
|
|
|
|
type InputEvent struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Type InputType `json:"type"`
|
|
Role string `json:"role,omitempty"`
|
|
Content string `json:"content"`
|
|
Images []InputImage `json:"images,omitempty"`
|
|
}
|
|
|
|
// InputImage carries a base64-encoded image attached to a stream-json
|
|
// message event. MediaType is a MIME type (e.g. "image/png"); Data is the
|
|
// standard base64 encoding of the raw image bytes (no data: URI prefix).
|
|
type InputImage struct {
|
|
MediaType string `json:"mediaType"`
|
|
Data string `json:"data"`
|
|
}
|
|
|
|
type ProtocolError struct {
|
|
message string
|
|
}
|
|
|
|
func (err ProtocolError) Error() string {
|
|
return err.message
|
|
}
|
|
|
|
func CreateRunID(now time.Time) (string, error) {
|
|
random := make([]byte, 3)
|
|
if _, err := rand.Read(random); err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("run_%s_%s", now.UTC().Format("20060102150405"), hex.EncodeToString(random)), nil
|
|
}
|
|
|
|
func FormatEvent(event Event) (string, error) {
|
|
if event.SchemaVersion == 0 {
|
|
event.SchemaVersion = SchemaVersion
|
|
}
|
|
if err := validateOutputEvent(event); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
data, err := json.Marshal(event)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var payload any
|
|
if err := json.Unmarshal(data, &payload); err != nil {
|
|
return "", err
|
|
}
|
|
redacted := redactValue(payload)
|
|
output, err := json.Marshal(redacted)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(output), nil
|
|
}
|
|
|
|
func ParseInput(input string) ([]InputEvent, error) {
|
|
lines := strings.Split(input, "\n")
|
|
events := []InputEvent{}
|
|
for index, line := range lines {
|
|
line = strings.TrimSpace(strings.TrimSuffix(line, "\r"))
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(line), &raw); err != nil {
|
|
return nil, ProtocolError{fmt.Sprintf("Invalid stream-json input at line %d: expected a JSON object.", index+1)}
|
|
}
|
|
if err := validateInputFields(raw); err != nil {
|
|
return nil, ProtocolError{fmt.Sprintf("Invalid stream-json input at line %d: %s", index+1, err.Error())}
|
|
}
|
|
var event InputEvent
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
return nil, ProtocolError{fmt.Sprintf("Invalid stream-json input at line %d: expected a JSON object.", index+1)}
|
|
}
|
|
if err := validateInputEvent(event); err != nil {
|
|
return nil, ProtocolError{fmt.Sprintf("Invalid stream-json input at line %d: %s", index+1, err.Error())}
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
func ResolvePrompt(events []InputEvent) (string, error) {
|
|
parts := []string{}
|
|
for _, event := range events {
|
|
content := strings.TrimSpace(event.Content)
|
|
if content != "" {
|
|
parts = append(parts, content)
|
|
}
|
|
}
|
|
if len(parts) == 0 {
|
|
return "", ProtocolError{"Stream-json input must include at least one prompt or user message event."}
|
|
}
|
|
return strings.Join(parts, "\n\n"), nil
|
|
}
|
|
|
|
// maxStreamImageBytes caps a single decoded image at 10 MiB to bound request bodies.
|
|
const maxStreamImageBytes = 10 << 20
|
|
|
|
// ResolveImages decodes every base64 image attached to the input events into
|
|
// raw-byte ImageBlocks. Each image's media type is normalized and validated
|
|
// against the supported allow-list, and its decoded size is capped. Returns nil
|
|
// when no events carry images.
|
|
func ResolveImages(events []InputEvent) ([]zeroruntime.ImageBlock, error) {
|
|
var images []zeroruntime.ImageBlock
|
|
for _, event := range events {
|
|
for _, image := range event.Images {
|
|
// Reject an oversized payload from the ENCODED length BEFORE decoding,
|
|
// so a multi-gigabyte base64 blob is never allocated just to be capped
|
|
// after the fact. DecodedLen is the upper bound on the decoded size.
|
|
if base64.StdEncoding.DecodedLen(len(image.Data)) > maxStreamImageBytes {
|
|
return nil, ProtocolError{fmt.Sprintf("Stream-json image exceeds the %d byte limit.", maxStreamImageBytes)}
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(image.Data)
|
|
if err != nil {
|
|
return nil, ProtocolError{fmt.Sprintf("Stream-json image data is not valid base64: %s", err.Error())}
|
|
}
|
|
// Backstop: the exact decoded length (padding makes DecodedLen an upper
|
|
// bound) must also stay within the cap.
|
|
if len(data) > maxStreamImageBytes {
|
|
return nil, ProtocolError{fmt.Sprintf("Stream-json image exceeds the %d byte limit.", maxStreamImageBytes)}
|
|
}
|
|
mediaType := zeroruntime.NormalizeImageMediaType(image.MediaType)
|
|
if mediaType == "" {
|
|
return nil, ProtocolError{fmt.Sprintf("Stream-json image has an unsupported image media type %q.", image.MediaType)}
|
|
}
|
|
images = append(images, zeroruntime.ImageBlock{MediaType: mediaType, Data: data})
|
|
}
|
|
}
|
|
return images, nil
|
|
}
|
|
|
|
func validateOutputEvent(event Event) error {
|
|
if event.SchemaVersion != SchemaVersion {
|
|
return fmt.Errorf("schemaVersion must be %d", SchemaVersion)
|
|
}
|
|
if !isValidID(event.RunID) {
|
|
return fmt.Errorf("runId is required")
|
|
}
|
|
if event.Type == "" {
|
|
return fmt.Errorf("type is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateInputEvent(event InputEvent) error {
|
|
if event.SchemaVersion != SchemaVersion {
|
|
return fmt.Errorf("schemaVersion must be %d", SchemaVersion)
|
|
}
|
|
if strings.TrimSpace(event.Content) == "" && len(event.Images) == 0 {
|
|
return fmt.Errorf("content is required")
|
|
}
|
|
switch event.Type {
|
|
case InputPrompt:
|
|
return nil
|
|
case InputMessage:
|
|
if event.Role != "user" {
|
|
return fmt.Errorf("role must be user")
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("type must be prompt or message")
|
|
}
|
|
}
|
|
|
|
func validateInputFields(raw map[string]json.RawMessage) error {
|
|
var inputType string
|
|
if rawType, ok := raw["type"]; ok {
|
|
_ = json.Unmarshal(rawType, &inputType)
|
|
}
|
|
allowed := map[string]bool{
|
|
"schemaVersion": true,
|
|
"type": true,
|
|
"content": true,
|
|
}
|
|
if inputType == string(InputMessage) {
|
|
allowed["role"] = true
|
|
allowed["images"] = true
|
|
}
|
|
for key := range raw {
|
|
if !allowed[key] {
|
|
return fmt.Errorf("unknown field %s", key)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
|
|
|
func isValidID(value string) bool {
|
|
return idPattern.MatchString(value)
|
|
}
|
|
|
|
var secretPatterns = []*regexp.Regexp{
|
|
// OpenAI-style keys: anchor on a word boundary and require a long key body so
|
|
// "sk-" inside ordinary words (e.g. "task-list") is never redacted while real
|
|
// keys ("sk-proj-…", "sk-…") still are.
|
|
regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{16,}`),
|
|
// api_key=/api-key:/apiKey "…": require a real assignment delimiter (= or :) and
|
|
// a credential-length body so ordinary prose ("the api_key: setting", "apiKey
|
|
// value spans…") is not mangled — a bare space after the marker no longer counts
|
|
// as a delimiter. (AUDIT-M1)
|
|
regexp.MustCompile(`(?i)(api[_-]?key)\s*[=:]\s*["']?[A-Za-z0-9._-]{12,}`),
|
|
// Bearer tokens: require a credential-length token so prose such as
|
|
// "bearer of bad news" or "bearer token" is not mistaken for a secret.
|
|
regexp.MustCompile(`(?i)(bearer\s+)[A-Za-z0-9._-]{16,}`),
|
|
}
|
|
|
|
func redactValue(value any) any {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return redactString(typed)
|
|
case []any:
|
|
next := make([]any, len(typed))
|
|
for index, item := range typed {
|
|
next[index] = redactValue(item)
|
|
}
|
|
return next
|
|
case map[string]any:
|
|
next := make(map[string]any, len(typed))
|
|
for key, item := range typed {
|
|
if isSensitiveKey(key) {
|
|
next[key] = "[REDACTED]"
|
|
continue
|
|
}
|
|
next[key] = redactValue(item)
|
|
}
|
|
return next
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
var sensitiveKeyNames = map[string]bool{
|
|
"accesstoken": true,
|
|
"apikey": true,
|
|
"authorization": true,
|
|
"bearer": true,
|
|
"clientsecret": true,
|
|
"credential": true,
|
|
"credentials": true,
|
|
"idtoken": true,
|
|
"password": true,
|
|
"passwd": true,
|
|
"privatekey": true,
|
|
"pwd": true,
|
|
"refreshtoken": true,
|
|
"secret": true,
|
|
"token": true,
|
|
}
|
|
|
|
func isSensitiveKey(key string) bool {
|
|
normalized := normalizeSensitiveKey(key)
|
|
if sensitiveKeyNames[normalized] {
|
|
return true
|
|
}
|
|
for _, suffix := range []string{"apikey", "clientsecret", "accesstoken", "refreshtoken", "idtoken", "privatekey"} {
|
|
if strings.HasSuffix(normalized, suffix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeSensitiveKey(key string) string {
|
|
var normalized strings.Builder
|
|
for _, char := range strings.ToLower(key) {
|
|
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') {
|
|
normalized.WriteRune(char)
|
|
}
|
|
}
|
|
return normalized.String()
|
|
}
|
|
|
|
func redactString(value string) string {
|
|
redacted := value
|
|
for _, pattern := range secretPatterns {
|
|
redacted = pattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
|
for _, prefix := range []string{"apiKey=", "api_key=", "api-key=", "Bearer "} {
|
|
if strings.HasPrefix(strings.ToLower(match), strings.ToLower(prefix)) {
|
|
return match[:len(prefix)] + "[REDACTED]"
|
|
}
|
|
}
|
|
return "[REDACTED]"
|
|
})
|
|
}
|
|
return redacted
|
|
}
|