Files

177 lines
5.4 KiB
Go

// 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
}