// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // Package tentacle implements the experimental process host used by explicit // local reader launches. Start and Invoke never wait for child pipe I/O. package tentacle import ( "bufio" "bytes" "encoding/json" "errors" "fmt" "git.stormux.org/storm/cthulhu/internal/tentaclewire" "strings" ) const ( MaxFrameBytes = tentaclewire.MaxFrameBytes // Excludes the mandatory newline. MaxArgumentBytes = 16 * 1024 MaxCommands = 32 MaxDiagnosticBytes = 64 * 1024 // Lifetime budget; diagnostics are discarded. ) var ( ErrBusy = errors.New("tentacle host capacity exhausted") ErrUnavailable = errors.New("tentacle is not active") ErrDisabled = errors.New("tentacle disabled") ErrProtocol = tentaclewire.ErrProtocol ErrLimit = tentaclewire.ErrLimit ErrExited = errors.New("tentacle process exited") ) type Version struct { Major int `json:"major"` Minor int `json:"minor"` } type Command struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description"` Bindings []Binding `json:"bindings,omitempty"` Presentation bool `json:"presentation,omitempty"` Interaction bool `json:"interaction,omitempty"` Settings bool `json:"settings,omitempty"` // Opens settings; never a continuation handler. } type Initialization struct { Protocol Version `json:"protocol"` Session string `json:"session"` APIs []string `json:"apis"` Capabilities []string `json:"capabilities"` } type Registration struct { Protocol Version `json:"protocol"` Session string `json:"session"` Commands []Command `json:"commands"` } // RPCError retains the bounded remote message for a future accessible details // view. Error deliberately omits child-supplied text from normal diagnostics. type RPCError struct { Code int `json:"code"` Message string `json:"message"` } func (failure *RPCError) Error() string { return fmt.Sprintf("tentacle RPC error %d", failure.Code) } type response struct { JSONRPC string `json:"jsonrpc"` ID string `json:"id"` Result json.RawMessage `json:"result"` Error *RPCError `json:"error"` } type request struct { JSONRPC string `json:"jsonrpc"` ID string `json:"id,omitempty"` Method string `json:"method"` Params any `json:"params,omitempty"` } func encode_request(id, method string, params any) ([]byte, error) { data, err := json.Marshal(request{JSONRPC: "2.0", ID: id, Method: method, Params: params}) if err != nil { return nil, ErrProtocol } if len(data) > MaxFrameBytes { return nil, ErrLimit } return append(data, '\n'), nil } func read_frame(reader *bufio.Reader) ([]byte, error) { return tentaclewire.ReadFrame(reader) } func validate_json(data []byte) error { return tentaclewire.ValidateJSON(data) } func decode_strict(data []byte, target any) error { return tentaclewire.DecodeStrict(data, target) } func decode_response(data []byte) (response, error) { var reply response if err := decode_strict(data, &reply); err != nil { return reply, err } // Presence matters: a null result is valid; a null error is not. var fields map[string]json.RawMessage _ = json.Unmarshal(data, &fields) for key := range fields { if key != "jsonrpc" && key != "id" && key != "result" && key != "error" { return reply, ErrProtocol } } _, hasResult := fields["result"] _, hasError := fields["error"] if reply.JSONRPC != "2.0" || reply.ID == "" || len(reply.ID) > 32 || hasResult == hasError || (hasError && (reply.Error == nil || len(reply.Error.Message) > 1024)) { return reply, ErrProtocol } if hasError { var errorFields map[string]json.RawMessage _ = json.Unmarshal(fields["error"], &errorFields) if errorFields["code"] == nil || bytes.Equal(errorFields["code"], []byte("null")) || errorFields["message"] == nil || bytes.Equal(errorFields["message"], []byte("null")) { return reply, ErrProtocol } } return reply, nil } func valid_id(value string) bool { if len(value) == 0 || len(value) > 128 || value[0] < 'a' || value[0] > 'z' { return false } for _, char := range value { if !(char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '-' || char == '.') { return false } } return true } func validate_registration(registration Registration, session string) error { if registration.Protocol != (Version{Major: 1, Minor: 0}) || registration.Session != session || len(registration.Commands) == 0 || len(registration.Commands) > MaxCommands { return ErrProtocol } seen := make(map[string]bool) settings := false for _, command := range registration.Commands { if command.Settings && (!command.Interaction || settings) || command.Interaction && !command.Presentation || !valid_id(command.ID) || seen[command.ID] || strings.TrimSpace(command.Title) == "" || len(command.Title) > 256 || len(command.Description) > 1024 { return ErrProtocol } seen[command.ID] = true settings = settings || command.Settings if len(command.Bindings) > 4 { return ErrLimit } for _, binding := range command.Bindings { if err := validate_binding(binding); err != nil { return err } } } return nil } func clone_commands(commands []Command) []Command { result := append([]Command(nil), commands...) for index := range result { result[index].Bindings = append([]Binding(nil), result[index].Bindings...) } return result }