Add Remote session foundations and feasibility checks

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.
This commit is contained in:
ak47 committed 2026-11-03 22:33:26 -05:00
1 parent a47253d697
commit 573d15d7b4
45 files changed
+3634 -80

No files matched your search

+301
View File
@@ -0,0 +1,301 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"strconv"
"sync"
"syscall"
"time"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
type SessionEvent = tentaclewire.SessionEvent
type RemoteOpen = tentaclewire.RemoteOpen
type RemoteLease = tentaclewire.RemoteLease
type RemoteOperation = tentaclewire.RemoteOperation
type SessionError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (err *SessionError) Error() string { return "tentacle session error " + strconv.Itoa(err.Code) }
type sessionReply struct {
value json.RawMessage
err error
}
// SessionClient is single-use. ServeSession owns its bounded writer and receive
// loop; command handlers and background workers may Call concurrently. Events
// must be drained without blocking on network I/O. Overflow retires the process
// session rather than dropping lifecycle events. No desktop access is implicit.
type SessionClient struct {
mu sync.Mutex
ctx context.Context
abort func()
active bool
started bool
sequence uint64
eventSequence uint64
pending map[string]chan sessionReply
events chan SessionEvent
outbound chan []byte
queuedBytes int
}
func NewSessionClient() *SessionClient {
return &SessionClient{events: make(chan SessionEvent, 32), outbound: make(chan []byte, 32), pending: make(map[string]chan sessionReply)}
}
func (client *SessionClient) Events() <-chan SessionEvent { return client.events }
// ServeSession opts into protocol 1.1; a protocol 1.0 host is rejected.
func ServeSession(input io.ReadCloser, output io.WriteCloser, commands []Command, extensions *Extensions, client *SessionClient) error {
if client == nil {
return ErrProtocol
}
if extensions != nil {
if err := tentaclewire.ValidateExtensions(*extensions); err != nil {
return err
}
}
// os.Stdin may be created before the runtime marks a pipe pollable. A
// blocking read on it cannot reliably be interrupted by Close from another
// goroutine. Adopt owned nonblocking duplicates so Go's poller can wake both
// pipe directions on timeout. Non-file closers (e.g. io.Pipe) retain their
// documented Close behavior.
if file, ok := input.(*os.File); ok {
pollable, err := sessionPipe(file)
if err != nil {
input.Close()
output.Close()
return err
}
input = pollable
}
if file, ok := output.(*os.File); ok {
pollable, err := sessionPipe(file)
if err != nil {
input.Close()
output.Close()
return err
}
output = pollable
}
return serve(input, output, commands, extensions, client)
}
func sessionPipe(file *os.File) (*os.File, error) {
fd, err := syscall.Dup(int(file.Fd()))
if err != nil {
return nil, err
}
syscall.CloseOnExec(fd)
if err := syscall.SetNonblock(fd, true); err != nil {
syscall.Close(fd)
return nil, err
}
owned := os.NewFile(uintptr(fd), file.Name())
file.Close()
return owned, nil
}
func (client *SessionClient) start(ctx context.Context, abort func()) bool {
client.mu.Lock()
defer client.mu.Unlock()
if client.started || client.events == nil {
return false
}
client.started, client.ctx, client.abort = true, ctx, abort
return true
}
func (client *SessionClient) enqueue(frame []byte) error {
client.mu.Lock()
defer client.mu.Unlock()
return client.enqueueLocked(frame)
}
func (client *SessionClient) enqueueLocked(frame []byte) error {
if client.ctx == nil || client.ctx.Err() != nil {
return io.ErrClosedPipe
}
if client.queuedBytes+len(frame) > tentaclewire.SessionQueueBytes {
client.abort()
return tentaclewire.ErrLimit
}
select {
case client.outbound <- frame:
client.queuedBytes += len(frame)
return nil
default:
client.abort()
return tentaclewire.ErrLimit
}
}
func (client *SessionClient) write(output io.Writer) {
for {
select {
case <-client.ctx.Done():
return
case frame := <-client.outbound:
if client.ctx.Err() != nil {
return
}
n, err := output.Write(frame)
if err == nil && n != len(frame) {
err = io.ErrShortWrite
}
client.mu.Lock()
client.queuedBytes -= len(frame)
client.mu.Unlock()
if err != nil {
client.abort()
return
}
}
}
}
// Call has a two-second maximum deadline. Timeout/cancellation retires the
// session, so an abandoned effect cannot leave unbounded pending calls behind.
// The host currently implements remote.open, remote.close and remote.heartbeat.
func (client *SessionClient) Call(ctx context.Context, method string, params any, result any) error {
if ctx == nil {
return errors.New("nil session call context")
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := ctx.Err(); err != nil {
return err
}
data, err := json.Marshal(params)
if err != nil || len(data) > 8192 || tentaclewire.ValidateJSON(data) != nil || len(method) == 0 || len(method) > 128 {
return ErrProtocol
}
client.mu.Lock()
if !client.active || client.ctx.Err() != nil {
client.mu.Unlock()
return io.ErrClosedPipe
}
if len(client.pending) >= 8 {
client.mu.Unlock()
return tentaclewire.ErrLimit
}
client.sequence++
id := "c:" + strconv.FormatUint(client.sequence, 10)
frame, err := json.Marshal(request{JSONRPC: "2.0", ID: id, Method: method, Params: data})
if err != nil {
client.mu.Unlock()
return ErrProtocol
}
response := make(chan sessionReply, 1)
client.pending[id] = response
err = client.enqueueLocked(append(frame, '\n'))
client.mu.Unlock()
defer func() { client.mu.Lock(); delete(client.pending, id); client.mu.Unlock() }()
if err != nil {
return err
}
select {
case <-ctx.Done():
client.abort()
return ctx.Err()
case <-client.ctx.Done():
return io.ErrClosedPipe
case reply := <-response:
if ctx.Err() != nil {
client.abort()
return ctx.Err()
}
if client.ctx.Err() != nil {
return io.ErrClosedPipe
}
if reply.err != nil {
return reply.err
}
if result != nil {
return tentaclewire.DecodeStrict(reply.value, result)
}
return nil
}
}
func (client *SessionClient) acceptReply(frame []byte) error {
var reply struct {
JSONRPC string `json:"jsonrpc"`
ID string `json:"id"`
Result json.RawMessage `json:"result"`
Error *SessionError `json:"error"`
}
if tentaclewire.DecodeStrict(frame, &reply) != nil || reply.JSONRPC != "2.0" {
return ErrProtocol
}
var fields map[string]json.RawMessage
_ = json.Unmarshal(frame, &fields)
_, hasResult := fields["result"]
_, hasError := fields["error"]
if hasResult == hasError || hasError && (reply.Error == nil || len(reply.Error.Message) > 1024) {
return ErrProtocol
}
if hasError {
var fields map[string]json.RawMessage
_ = json.Unmarshal(frame, &fields)
var detail map[string]json.RawMessage
_ = json.Unmarshal(fields["error"], &detail)
if len(detail["code"]) == 0 || string(detail["code"]) == "null" || len(detail["message"]) == 0 || string(detail["message"]) == "null" {
return ErrProtocol
}
}
client.mu.Lock()
defer client.mu.Unlock()
waiter := client.pending[reply.ID]
if !client.active || waiter == nil {
return ErrProtocol
}
delete(client.pending, reply.ID)
var err error
if reply.Error != nil {
err = reply.Error
}
waiter <- sessionReply{value: reply.Result, err: err}
return nil
}
func (client *SessionClient) acceptEvent(params json.RawMessage) error {
var event SessionEvent
var fields map[string]json.RawMessage
if json.Unmarshal(params, &fields) != nil {
return ErrProtocol
}
for _, value := range fields {
if string(value) == "null" {
return ErrProtocol
}
}
if tentaclewire.DecodeStrict(params, &event) != nil || !tentaclewire.ValidSessionEvent(event) {
return ErrProtocol
}
client.mu.Lock()
defer client.mu.Unlock()
if !client.active || event.Sequence != client.eventSequence+1 {
return ErrProtocol
}
client.eventSequence = event.Sequence
select {
case client.events <- event:
return nil
default:
return tentaclewire.ErrLimit
}
}
+68 -8
View File
@@ -12,6 +12,7 @@ import (
"errors"
"git.stormux.org/storm/cthulhu/internal/config"
"io"
"slices"
"strconv"
"sync"
@@ -46,6 +47,9 @@ type Interaction = tentaclewire.Interaction
type Submission = tentaclewire.Submission
type Item = tentaclewire.Item
type Form = interaction.Form
// Element.Secret masks transient Entry controls; it is not supported in
// persistent PreferencePage controls. Submitted Value.Text remains sensitive.
type Element = interaction.Element
type ListItem = interaction.Item
type Value = interaction.Value
@@ -90,7 +94,7 @@ type request struct {
// handlers and waits for them; the host kills a child that ignores cancellation.
// Never print to stdout. Command declarations must remain unchanged during Serve.
func Serve(input io.ReadCloser, output io.WriteCloser, commands []Command) error {
return serve(input, output, commands, nil)
return serve(input, output, commands, nil, nil)
}
// ServeWithExtensions declares session-owned preferences pages and quick settings.
@@ -98,14 +102,40 @@ func ServeWithExtensions(input io.ReadCloser, output io.WriteCloser, commands []
if err := tentaclewire.ValidateExtensions(extensions); err != nil {
return err
}
return serve(input, output, commands, &extensions)
return serve(input, output, commands, &extensions, nil)
}
func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, extensions *Extensions) error {
func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, extensions *Extensions, client *SessionClient) error {
ctx, cancel := context.WithCancel(context.Background())
var workers sync.WaitGroup
var writeMutex sync.Mutex
defer func() { cancel(); input.Close(); output.Close(); workers.Wait() }()
clientStarted := false
defer func() {
cancel()
input.Close()
output.Close()
if clientStarted {
client.mu.Lock()
client.active = false
client.pending = nil
close(client.events)
client.mu.Unlock()
}
workers.Wait()
if clientStarted {
for len(client.outbound) != 0 {
<-client.outbound
}
}
}()
if client != nil {
if !client.start(ctx, func() { cancel(); input.Close(); output.Close() }) {
return ErrProtocol
}
clientStarted = true
workers.Add(1)
go func() { defer workers.Done(); client.write(output) }()
}
handlers := make(map[string]Handler)
readerCommands := make(map[string]bool)
if len(commands) == 0 || len(commands) > 32 {
@@ -131,6 +161,12 @@ func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, exten
frame, _ = json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id,
"error": map[string]any{"code": -32603, "message": "Invalid or oversized result"}})
}
if client != nil {
if release != nil {
release()
}
return client.enqueue(append(frame, '\n'))
}
writeMutex.Lock()
defer writeMutex.Unlock()
// A completed reply may reach the host before Write returns. Release
@@ -157,6 +193,18 @@ func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, exten
if err != nil {
return err
}
if client != nil {
var fields map[string]json.RawMessage
if tentaclewire.ValidateJSON(frame) != nil || json.Unmarshal(frame, &fields) != nil {
return ErrProtocol
}
if _, reply := fields["method"]; !reply {
if err := client.acceptReply(frame); err != nil {
return err
}
continue
}
}
var message request
if tentaclewire.DecodeStrict(frame, &message) != nil || message.JSONRPC != "2.0" {
return ErrProtocol
@@ -190,16 +238,16 @@ func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, exten
Capabilities []string `json:"capabilities"`
}
if session != "" || message.ID == "" || tentaclewire.DecodeStrict(message.Params, &params) != nil ||
params.Protocol.Major == nil || params.Protocol.Minor == nil || *params.Protocol.Major != 1 || *params.Protocol.Minor != 0 || params.Session == "" || len(params.Session) > 128 ||
!tentaclewire.ValidAPIs(params.APIs, params.Capabilities) {
params.Protocol.Major == nil || params.Protocol.Minor == nil || *params.Protocol.Major != 1 || (*params.Protocol.Minor != 0 && *params.Protocol.Minor != 1) || params.Session == "" || len(params.Session) > 128 ||
!tentaclewire.ValidNegotiation(*params.Protocol.Minor, params.APIs, params.Capabilities) || *params.Protocol.Minor == 1 && params.Capabilities == nil || (client != nil) != (*params.Protocol.Minor == 1) {
return ErrProtocol
}
for _, command := range commands {
if command.Presentation && len(params.APIs) < 2 || command.Interaction && (!command.Presentation || len(params.APIs) < 3) || command.Reader && len(params.APIs) != 4 {
if command.Presentation && !slices.Contains(params.APIs, "presentation") || command.Interaction && (!command.Presentation || !slices.Contains(params.APIs, "interaction")) || command.Reader && !slices.Contains(params.APIs, "reader-settings") {
return ErrProtocol
}
}
if extensions != nil && len(params.APIs) != 4 {
if extensions != nil && !slices.Contains(params.APIs, "reader-settings") {
return ErrProtocol
}
session = params.Session
@@ -218,6 +266,18 @@ func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, exten
return ErrProtocol
}
active = true
if client != nil {
client.mu.Lock()
client.active = true
client.mu.Unlock()
}
case "session.event":
if !active || message.ID != "" || client == nil {
return ErrProtocol
}
if err := client.acceptEvent(message.Params); err != nil {
return err
}
case "command.invoke":
var params struct {
Command string `json:"command"`