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:
1 parent
a47253d697
commit
573d15d7b4
45 files changed
+3634
-80
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms) != 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"`
|
||||
|
||||
Reference in new issue
Block a user