177 lines
5.4 KiB
Go
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
|
|
}
|