Add opt-in Tentacle protocol 1.1, explicit remote grants, lifecycle IPC and expiring core leases in both SDKs. Revoke access before update preparation, including failed updates. Add transient secret entries, shared Braille cell export and isolated Xorg recovery experiments with focused tests and documented limitations. Production consent, desktop services and live interoperability remain pending.
208 lines
6.5 KiB
Go
208 lines
6.5 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.
|
|
MaxReaderArgumentBytes = 48 * 1024
|
|
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"`
|
|
Reader bool `json:"reader,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"`
|
|
Extensions *tentaclewire.Extensions `json:"extensions,omitempty"`
|
|
}
|
|
|
|
// 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 {
|
|
return validate_registration_version(registration, session, Version{Major: 1, Minor: 0})
|
|
}
|
|
|
|
func validate_registration_version(registration Registration, session string, version Version) error {
|
|
if registration.Protocol != version || 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.Reader && !command.Interaction || 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
|
|
}
|
|
}
|
|
}
|
|
if registration.Extensions != nil {
|
|
if err := tentaclewire.ValidateExtensions(*registration.Extensions); err != nil {
|
|
return err
|
|
}
|
|
for _, page := range registration.Extensions.Pages {
|
|
if !reader_command(registration.Commands, page.Command) {
|
|
return ErrProtocol
|
|
}
|
|
}
|
|
for _, setting := range registration.Extensions.QuickSettings {
|
|
if !reader_command(registration.Commands, setting.Command) {
|
|
return ErrProtocol
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func reader_command(commands []Command, id string) bool {
|
|
for _, command := range commands {
|
|
if command.ID == id {
|
|
return command.Reader && !command.Settings
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|