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

+248
View File
@@ -0,0 +1,248 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"slices"
"strconv"
"strings"
"time"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
const RemoteHeartbeatTimeout = 3 * time.Second
const remoteApprovalTimeout = 30 * time.Second
var ErrPermission = errors.New("tentacle permission denied")
// Owned by Session.mutex. No network identity or secret belongs here.
type remoteAccess struct {
action string
lease string
epoch uint64
sequence uint64
role string
grants []string
ctx context.Context
cancel context.CancelFunc
deadline time.Time
timer *time.Timer
}
// AuthorizeRemote is a core-only hook for a future verified local consent UI.
// It is intentionally not exposed by any child RPC or production command yet.
// Install grants, network readiness and injected shortcuts are not local consent.
// A fresh action revokes the previous lease and consumes a new host-owned epoch.
func (session *Session) AuthorizeRemote(role string, grants []string) (tentaclewire.SessionEvent, error) {
session.mutex.Lock()
defer session.mutex.Unlock()
if session.state != Active || session.ctx.Err() != nil || !session.spec.SessionEvents {
return tentaclewire.SessionEvent{}, ErrUnavailable
}
if !tentaclewire.ValidRoleGrants(role, grants) {
return tentaclewire.SessionEvent{}, ErrPermission
}
for _, grant := range grants {
if !slices.Contains(session.spec.RemoteGrants, grant) {
return tentaclewire.SessionEvent{}, ErrPermission
}
}
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
return tentaclewire.SessionEvent{}, err
}
session.manager.remoteMutex.Lock()
if owner := session.manager.remoteOwner; owner != nil && owner != session {
session.manager.remoteMutex.Unlock()
return tentaclewire.SessionEvent{}, ErrBusy
}
session.manager.remoteMutex.Unlock()
session.revoke_remote_locked("replaced", true)
// Claim and check atomically: a different session may have acquired the slot
// while the old lease was being revoked.
session.manager.remoteMutex.Lock()
if session.manager.remoteOwner != nil {
session.manager.remoteMutex.Unlock()
return tentaclewire.SessionEvent{}, ErrBusy
}
session.manager.remoteOwner = session
session.manager.remoteMutex.Unlock()
if session.state != Active {
session.release_remote_owner()
return tentaclewire.SessionEvent{}, ErrUnavailable
}
session.remoteEpoch++
ctx, cancel := context.WithCancel(session.outputContext)
access := &remoteAccess{action: hex.EncodeToString(token[:]), epoch: session.remoteEpoch,
role: role, grants: slices.Clone(grants), ctx: ctx, cancel: cancel}
session.remote = access
session.refresh_remote_locked(remoteApprovalTimeout)
event := tentaclewire.SessionEvent{Kind: "remote.action", Action: access.action, Epoch: access.epoch, Role: role, Grants: slices.Clone(grants)}
if !session.remote_event_locked(&event) {
session.fail_locked(ErrLimit)
return tentaclewire.SessionEvent{}, ErrLimit
}
return event, nil
}
// RevokeRemote invalidates buffered work synchronously. A later local consent
// action is required; heartbeat and remote.open cannot revive the old epoch.
func (session *Session) RevokeRemote() {
session.mutex.Lock()
defer session.mutex.Unlock()
session.revoke_remote_locked("local", true)
}
// RemoteContext is for future reader owners to check again at delivery. A live
// context is necessary, not sufficient: focus, lock and output policy still apply.
func (session *Session) RemoteContext(lease tentaclewire.RemoteLease, grant string) (context.Context, error) {
session.mutex.Lock()
defer session.mutex.Unlock()
access := session.remote
if session.state != Active || access == nil || access.lease == "" || access.lease != lease.Lease || access.epoch != lease.Epoch || !time.Now().Before(access.deadline) || access.ctx.Err() != nil || !slices.Contains(access.grants, grant) {
return nil, ErrPermission
}
return access.ctx, nil
}
func (session *Session) release_remote_owner() {
session.manager.remoteMutex.Lock()
if session.manager.remoteOwner == session {
session.manager.remoteOwner = nil
}
session.manager.remoteMutex.Unlock()
}
func (session *Session) revoke_remote_locked(reason string, notify bool) {
access := session.remote
if access == nil {
return
}
session.remote = nil
access.cancel()
if access.timer != nil {
access.timer.Stop()
}
session.release_remote_owner()
if notify && access.lease != "" {
event := tentaclewire.SessionEvent{Kind: "remote.revoked", Lease: access.lease, Epoch: access.epoch, Reason: reason}
if !session.remote_event_locked(&event) {
session.fail_locked(ErrLimit)
}
}
}
func (session *Session) refresh_remote_locked(duration time.Duration) {
access := session.remote
access.deadline = time.Now().Add(duration)
if access.timer != nil {
access.timer.Stop()
}
access.timer = time.AfterFunc(duration, func() {
session.mutex.Lock()
defer session.mutex.Unlock()
// An old timer may already be running when heartbeat resets it.
if session.remote == access && !time.Now().Before(access.deadline) {
session.revoke_remote_locked("expired", true)
}
})
}
func (session *Session) remote_event_locked(event *tentaclewire.SessionEvent) bool {
session.eventSequence++
event.Sequence = session.eventSequence
return session.notify_locked("session.event", event)
}
func (session *Session) accept_child_request(frame []byte) error {
var message struct {
JSONRPC string `json:"jsonrpc"`
ID string `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if decode_strict(frame, &message) != nil || message.JSONRPC != "2.0" || !strings.HasPrefix(message.ID, "c:") || message.Method == "" || len(message.Method) > 128 || len(message.Params) == 0 || len(message.Params) > 8192 {
return ErrProtocol
}
number, err := strconv.ParseUint(strings.TrimPrefix(message.ID, "c:"), 10, 64)
if err != nil || message.ID != "c:"+strconv.FormatUint(number, 10) {
return ErrProtocol
}
session.mutex.Lock()
defer session.mutex.Unlock()
if !session.spec.SessionEvents || session.state != Active || session.ctx.Err() != nil || number <= session.childSequence {
return ErrProtocol
}
session.childSequence = number
value, failure := session.remote_call_locked(message.Method, message.Params)
reply := map[string]any{"jsonrpc": "2.0", "id": message.ID}
if failure != nil {
reply["error"] = failure
} else {
reply["result"] = value
}
data, err := json.Marshal(reply)
if err != nil || !session.enqueue_locked(outgoing{frame: append(data, '\n')}) {
return ErrLimit
}
return nil
}
func (session *Session) remote_call_locked(method string, params json.RawMessage) (any, *RPCError) {
denied := &RPCError{Code: -32001, Message: "Session permission denied"}
invalid := &RPCError{Code: -32602, Message: "Invalid session parameters"}
switch method {
case "remote.open":
var request tentaclewire.RemoteOpen
if decode_strict(params, &request) != nil || len(request.Action) != 32 || request.Epoch == 0 || !tentaclewire.ValidRoleGrants(request.Role, request.Grants) {
return nil, invalid
}
access := session.remote
if access == nil || access.lease != "" || access.action != request.Action || access.epoch != request.Epoch || access.role != request.Role || !time.Now().Before(access.deadline) || access.ctx.Err() != nil {
return nil, denied
}
for _, grant := range request.Grants {
if !slices.Contains(access.grants, grant) {
return nil, denied
}
}
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
return nil, &RPCError{Code: -32603, Message: "Session unavailable"}
}
access.lease, access.action, access.grants = hex.EncodeToString(token[:]), "", slices.Clone(request.Grants)
session.refresh_remote_locked(RemoteHeartbeatTimeout)
return tentaclewire.RemoteLease{Lease: access.lease, Epoch: access.epoch}, nil
case "remote.close", "remote.heartbeat":
var request tentaclewire.RemoteOperation
if decode_strict(params, &request) != nil || len(request.Lease) != 32 || request.Epoch == 0 || request.Sequence == 0 {
return nil, invalid
}
access := session.remote
// Closing an already retired epoch has no effect on a newer lease.
if method == "remote.close" && request.Epoch <= session.remoteEpoch && (access == nil || access.epoch != request.Epoch) {
return struct{}{}, nil
}
if access == nil || access.lease == "" || access.lease != request.Lease || access.epoch != request.Epoch || request.Sequence <= access.sequence || !time.Now().Before(access.deadline) || access.ctx.Err() != nil {
return nil, denied
}
access.sequence = request.Sequence
if method == "remote.close" {
session.revoke_remote_locked("closed", true)
} else {
session.refresh_remote_locked(RemoteHeartbeatTimeout)
}
return struct{}{}, nil
default:
// No generic callback or desktop effect path exists. New operations need
// typed validation and owner integration, never arbitrary method dispatch.
return nil, &RPCError{Code: -32601, Message: "Unsupported session operation"}
}
}