X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/d7bc0a6ac452bfc646610fdfd7c8eccb5c55218f..6c7859cfea123479aba046614ee81028651ddaf5:/notify.go diff --git a/notify.go b/notify.go index a892590..2071ce6 100644 --- a/notify.go +++ b/notify.go @@ -1,10 +1,15 @@ package main import ( + "context" "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" + "io" + "net" + "path/filepath" "strings" "time" "unicode/utf8" @@ -27,27 +32,188 @@ const ( // retry keys. A full store rejects new keys. Pending events and // receipts never expire. notifyReceipts = 1024 + + // notifyTimeout limits a local submission, not the agent's processing + // time. + notifyTimeout = 10 * time.Second + + notifyUsage = `Usage: onechat notify --source SOURCE --id ID --text TEXT + +Send untrusted external data to the root agent at its next turn boundary. +Success means durably accepted, not processed. Retry with the same source, +ID, and text after an uncertain result. Changed text for an existing key fails. +Text is limited to 32768 bytes. Source and ID are 1 to 128 ASCII letters, +digits, dots, underscores, or hyphens. The local test interface keeps up to +1024 receipts without expiry and rejects new keys when full. +Child targets, receipt expiry, and processing status are not supported. +` ) // NotifyInput is caller-supplied data. Its source label proves no identity or -// authority. Source and ID together identify a retry of the same text. +// authority. Source and ID together identify a retry of the same text. AgentID +// selects a permanent agent. An empty AgentID selects the root. type NotifyInput struct { - Source string `json:"source"` - ID string `json:"id"` - Text string `json:"text"` + AgentID string `json:"agent_id,omitempty"` + Source string `json:"source"` + ID string `json:"id"` + Text string `json:"text"` } // NotifyReceipt keeps a retry key after its queued delivery finishes. Digest -// binds the original text without retaining another copy of that text. +// binds the original text without retaining another copy of that text. TargetID +// binds the permanent agent identity. An empty TargetID selects the root. type NotifyReceipt struct { - Source string `json:"source"` - ID string `json:"id"` - Digest string `json:"digest"` - Number int `json:"number"` + TargetID string `json:"target_id,omitempty"` + Source string `json:"source"` + ID string `json:"id"` + Digest string `json:"digest"` + Number int `json:"number"` +} + +// notify submits a local event and prints only its acceptance receipt. The +// deadline and caller cancellation interrupt socket I/O. Losing the answer is +// an uncertain result, so the caller must retry the same key and text. +func notify(ctx context.Context, args []string, out io.Writer) (err error) { + if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { + _, err = io.WriteString(out, notifyUsage) + if err != nil { + return fmt.Errorf("write notification help: %w", err) + } + + return nil + } + + input, err := notifyParse(args) + if err != nil { + return fmt.Errorf("parse notification: %w", err) + } + + dir, err := dataDir() + if err != nil { + return fmt.Errorf("find notification data directory: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, notifyTimeout) + defer cancel() + + var dialer net.Dialer + + conn, err := dialer.DialContext(ctx, "unix", filepath.Join(dir, socketFileName)) + if err != nil { + return fmt.Errorf("connect notification to running daemon: %w", err) + } + + defer func() { + closeErr := conn.Close() + if closeErr != nil { + err = errors.Join(err, fmt.Errorf("close notification socket: %w", closeErr)) + } + }() + + interrupted := make(chan error, 1) + stop := context.AfterFunc(ctx, func() { + err := conn.SetDeadline(time.Now()) + if err != nil { + err = fmt.Errorf("interrupt notification I/O: %w", err) + } + + interrupted <- err + }) + + defer func() { + if !stop() { + err = errors.Join(err, <-interrupted) + } + }() + + err = json.NewEncoder(conn).Encode(SocketRequest{ + Type: "notify", + Notify: &input, + }) + if err != nil { + return fmt.Errorf("send notification: %w", err) + } + + decoder := json.NewDecoder(io.LimitReader(conn, socketRequestBytes)) + for { + var event Event + + err = decoder.Decode(&event) + if ctx.Err() != nil { + return ctx.Err() + } + + if err != nil { + return fmt.Errorf("read notification receipt: %w", err) + } + + switch event.Kind { + case "notify": + if event.ID <= 0 || (event.Status != "accepted" && event.Status != "duplicate") { + return errors.New("invalid notification receipt") + } + + err = json.NewEncoder(out).Encode(event) + if err != nil { + return fmt.Errorf("write notification receipt: %w", err) + } + + return nil + case "error": + return fmt.Errorf("notification rejected: %s", event.Text) + } + } +} + +// notifyParse accepts GNU long flags only and rejects repeated or unknown +// flags. +func notifyParse(args []string) (NotifyInput, error) { + var input NotifyInput + + fields := map[string]*string{ + "--source": &input.Source, + "--id": &input.ID, + "--text": &input.Text, + } + + seen := make(map[string]bool) + for i := 0; i < len(args); i++ { + name, value, equals := strings.Cut(args[i], "=") + field := fields[name] + if field == nil || seen[name] { + return input, errors.New("unknown or repeated notification flag") + } + + seen[name] = true + if !equals { + i++ + if i == len(args) { + return input, errors.New("notification flag needs a value") + } + + value = args[i] + } + + *field = value + } + + err := notifyValidate(input) + if err != nil { + return input, fmt.Errorf("validate notification: %w", err) + } + + return input, nil } // notifyValidate checks both CLI and direct socket inputs without echoing data. func notifyValidate(input NotifyInput) error { + if input.AgentID != "" { + id, err := hex.DecodeString(input.AgentID) + if err != nil || len(id) != agentIDBytes { + return errors.New("notification agent ID is invalid") + } + } + if !notifyIdentifier(input.Source) || !notifyIdentifier(input.ID) { return errors.New("notification source and ID must be 1 to 128 ASCII letters, digits, dots, underscores, or hyphens") }