More work on tentacles.

This commit is contained in:
Storm Dragon committed 2026-11-03 23:48:31 -05:00
1 parent 573d15d7b4
commit 4cf3335017
29 files changed
+1194 -66

No files matched your search

+3
View File
@@ -295,6 +295,9 @@ func Run(ctx context.Context, options Options) error {
}
tentacles.output = &tentacleOutput{speech: speechMessages, braille: brailleOutput, gate: focusGate, live: live}
focusGate.tentacleOutput = tentacles.output
focusGate.remotePolicy = tentacle.NewRemotePolicy()
focusGate.sync_remote_policy()
tentacles.remotePolicy = focusGate.remotePolicy
services = append(services, lifecycle.Service{Name: "tentacles", Start: tentacles.Start, Stop: tentacles.Stop})
}
preferences := newPreferencesWorker(logger, savedSettings, settingsUpdater)
+11
View File
@@ -22,6 +22,7 @@ import (
"sync/atomic"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/tentacle"
)
type commandGrabController interface {
@@ -30,6 +31,7 @@ type commandGrabController interface {
type focusGate struct {
tentacleOutput *tentacleOutput
remotePolicy *tentacle.RemotePolicy
ocr *ocrSession
mutex sync.Mutex
@@ -64,12 +66,21 @@ func (gate *focusGate) SetSuspended(suspended bool) bool {
return false
}
gate.retireTentacleOutput()
gate.sync_remote_policy()
if gate.commands != nil {
gate.commands.SetCommandGrabs(!suspended && !gate.secure.Load())
}
return true
}
// Called during startup or with gate.mutex held. Cancellation never calls back
// into the gate or runtime locks. Lock/unlock and handoff cannot restore consent.
func (gate *focusGate) sync_remote_policy() {
if gate.remotePolicy != nil {
gate.remotePolicy.SetAvailable(!gate.secure.Load() && !gate.suspended.Load())
}
}
func (gate *focusGate) InternalUIActive() bool {
return gate != nil && gate.internalUI != nil && gate.internalUI.InternalUIActive()
}
+27 -6
View File
@@ -42,10 +42,11 @@ type internalUINotice struct {
value interaction.Presentation
}
type internalUIRequest struct {
ctx context.Context
session *interaction.Session
result chan internalUIResult
ready chan error
localOnly bool
ctx context.Context
session *interaction.Session
result chan internalUIResult
ready chan error
}
// internalUI owns one modal session on its worker. The bounded event channel
@@ -70,11 +71,21 @@ type internalUI struct {
}
func (ui *internalUI) Open(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) {
return ui.open_form(ctx, form, false)
}
// OpenLocal is core-only. Unknown input backends cannot silently fall back to
// ordinary forms when authorizing remote access or handling its secrets/trust.
func (ui *internalUI) OpenLocal(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) {
return ui.open_form(ctx, form, true)
}
func (ui *internalUI) open_form(ctx context.Context, form interaction.Form, localOnly bool) (<-chan internalUIResult, error) {
session, err := interaction.NewSession(form)
if err != nil {
return nil, err
}
request := internalUIRequest{ctx: ctx, session: session, result: make(chan internalUIResult, 1), ready: make(chan error, 1)}
request := internalUIRequest{ctx: ctx, session: session, result: make(chan internalUIResult, 1), ready: make(chan error, 1), localOnly: localOnly}
select {
case <-ctx.Done():
return nil, ctx.Err()
@@ -202,7 +213,17 @@ func (ui *internalUI) run(ctx context.Context) {
continue
}
var err error
sessionID, err = ui.keys.BeginInternalUI(request.ctx)
if request.localOnly {
if keys, ok := ui.keys.(interface {
BeginLocalUI(context.Context) (uint64, error)
}); ok {
sessionID, err = keys.BeginLocalUI(request.ctx)
} else {
err = errors.New("local approval input unavailable")
}
} else {
sessionID, err = ui.keys.BeginInternalUI(request.ctx)
}
if err != nil {
request.ready <- err
close(request.result)
+1
View File
@@ -140,6 +140,7 @@ func (gate *focusGate) SetLocked(active bool, adapter Adapter, listener InputLis
return
}
gate.retireTentacleOutput()
gate.sync_remote_policy()
for _, target := range []any{adapter, listener, commands} {
if controller, ok := target.(secureInputController); ok {
controller.SetSecureInputActive(active)
+20 -2
View File
@@ -32,8 +32,15 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
return
}
defer func() { <-runtime.interactionSlots }()
ctx, cancel := context.WithCancel(ctx)
commandContext := ctx
parent := ctx
if len(command.session.RemoteGrants()) != 0 && runtime.remotePolicy != nil {
parent = runtime.remotePolicy.Context()
}
ctx, cancel := context.WithCancel(parent)
defer cancel()
stopCommand := context.AfterFunc(commandContext, cancel)
defer stopCommand()
stop := context.AfterFunc(command.session.OutputContext(), cancel)
defer stop()
var temporaryFiles []string
@@ -95,7 +102,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
return
}
formCtx, closeForm := context.WithCancel(ctx)
replies, openErr := runtime.forms.Open(formCtx, *value.Form)
replies, openErr := runtime.open_tentacle_form(formCtx, command, *value.Form)
if openErr != nil {
closeForm()
runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true})
@@ -156,6 +163,17 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
}
}
func (runtime *tentacleRuntime) open_tentacle_form(ctx context.Context, command tentacleCommand, form interaction.Form) (<-chan internalUIResult, error) {
if len(command.session.RemoteGrants()) != 0 {
local, ok := runtime.forms.(localTentacleForms)
if !ok {
return nil, errors.New("local approval input unavailable")
}
return local.OpenLocal(ctx, form)
}
return runtime.forms.Open(ctx, form)
}
func (runtime *tentacleRuntime) validContinuation(command tentacleCommand, id string) bool {
if id == "" {
return true
+118
View File
@@ -0,0 +1,118 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"slices"
"time"
"git.stormux.org/storm/cthulhu/internal/interaction"
"git.stormux.org/storm/cthulhu/internal/tentacle"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
type localTentacleForms interface {
OpenLocal(context.Context, interaction.Form) (<-chan internalUIResult, error)
}
// authorize_remote is a core entry point, not a registered Tentacle command or
// child RPC. A future connection command must resolve the installed session and
// role before calling it. A synthetic shortcut may request a dialog but cannot
// select permissions or approve it through the local-only capture path.
func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role string, grants []string) (tentaclewire.SessionEvent, error) {
if runtime == nil || runtime.remotePolicy == nil || !tentaclewire.ValidRoleGrants(role, grants) {
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
}
forms, ok := runtime.forms.(localTentacleForms)
if !ok {
return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable
}
grants = slices.Clone(grants)
runtime.mutex.Lock()
var target *tentacle.Session
if !runtime.disabled {
for _, session := range runtime.sessions {
if session.TentacleID() == id {
target = session
break
}
}
}
runtime.mutex.Unlock()
if target == nil {
return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable
}
installed := target.RemoteGrants()
for _, grant := range grants {
if !slices.Contains(installed, grant) {
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
}
}
// Reserve the interaction before creating a ticket, so a second request
// rejected as busy cannot invalidate the dialog already on screen.
select {
case runtime.interactionSlots <- struct{}{}:
default:
return tentaclewire.SessionEvent{}, tentacle.ErrBusy
}
defer func() { <-runtime.interactionSlots }()
approval, err := target.BeginRemoteApproval(role, grants)
if err != nil {
return tentaclewire.SessionEvent{}, err
}
defer approval.Cancel()
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
stopPolicy := context.AfterFunc(approval.Context(), cancel)
defer stopPolicy()
stopSession := context.AfterFunc(target.OutputContext(), cancel)
defer stopSession()
form := remote_consent_form(id, role, grants)
replies, err := forms.OpenLocal(ctx, form)
if err != nil {
return tentaclewire.SessionEvent{}, err
}
select {
case reply, open := <-replies:
if !open || ctx.Err() != nil || reply.Cancelled || reply.Err != nil || reply.Activated != "approve" {
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
}
var selected []string
for _, grant := range grants {
if reply.Values[grant].Checked {
selected = append(selected, grant)
}
}
if len(selected) == 0 {
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
}
return target.AuthorizeRemoteWithApproval(approval, role, selected)
case <-ctx.Done():
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
}
}
func remote_consent_form(id, role string, grants []string) interaction.Form {
roleText := "control another computer"
if role == "controlled" {
roleText = "allow a helper to access this computer"
}
form := interaction.Form{Title: "Remote access for " + id + ": " + roleText +
". The relay can read the channel key and session contents. A peer ID does not verify a person's identity. Select access for this session using a local keyboard.",
Elements: []interaction.Element{{ID: "cancel", Label: "Cancel", Kind: interaction.Button}}}
labels := map[string]string{
"output-observe": "Share local reader output with the remote controller",
"remote-present": "Present output from the controlled computer",
"keyboard-capture": "Forward local keyboard input to the controlled computer",
"keyboard-inject": "Allow the remote controller to type on this computer",
"clipboard-exchange": "Allow receiving clipboard text; send only on explicit local request",
"braille-stream": "Exchange remote braille cells and supported display gestures",
}
for _, grant := range grants {
form.Elements = append(form.Elements, interaction.Element{ID: grant, Label: labels[grant], Kind: interaction.CheckBox})
}
form.Elements = append(form.Elements, interaction.Element{ID: "approve", Label: "Approve selected access for this session", Kind: interaction.Button})
return form
}
@@ -0,0 +1,201 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/interaction"
"git.stormux.org/storm/cthulhu/internal/tentacle"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
type consentFormRequest struct {
ctx context.Context
form interaction.Form
reply chan internalUIResult
}
type consentTestForms struct{ requests chan consentFormRequest }
func (forms *consentTestForms) Open(context.Context, interaction.Form) (<-chan internalUIResult, error) {
return nil, errors.New("ordinary form used for consent")
}
func (forms *consentTestForms) Notice(context.Context, tentaclewire.Presentation) {}
func (forms *consentTestForms) OpenLocal(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) {
reply := make(chan internalUIResult, 1)
forms.requests <- consentFormRequest{ctx, form, reply}
return reply, nil
}
func consent_runtime_fixture(t *testing.T) (*tentacleRuntime, *tentacle.Session, *consentTestForms, *focusGate) {
t.Helper()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
manager, err := tentacle.NewManager(tentacle.DefaultLimits(), nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := manager.Shutdown(ctx); err != nil {
t.Error(err)
}
})
policy := tentacle.NewRemotePolicy()
if err := manager.UseRemotePolicy(policy); err != nil {
t.Fatal(err)
}
session, err := manager.Start(t.Context(), tentacle.Spec{ID: "consent-probe", Program: []string{executable, "-test.run=^TestRemoteManagementPeer$", "--", "remote-management-peer"}, SessionEvents: true, RemoteGrants: []string{"output-observe", "clipboard-exchange"}})
if err != nil {
t.Fatal(err)
}
select {
case <-session.Ready():
case <-time.After(5 * time.Second):
t.Fatal("peer not ready")
}
if err := session.Activate(); err != nil {
t.Fatal(err)
}
forms := &consentTestForms{requests: make(chan consentFormRequest, 1)}
runtime := &tentacleRuntime{remotePolicy: policy, forms: forms, sessions: []*tentacle.Session{session}, interactionSlots: make(chan struct{}, 1)}
return runtime, session, forms, &focusGate{remotePolicy: policy}
}
func TestRemoteConsentFormDefaultsAndRevocation(t *testing.T) {
for _, action := range []string{"approve", "cancel", "empty", "lock", "handoff", "revoke", "disable"} {
t.Run(action, func(t *testing.T) {
runtime, session, forms, gate := consent_runtime_fixture(t)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
result := make(chan error, 1)
go func() {
_, err := runtime.authorize_remote(ctx, "consent-probe", "controlled", []string{"output-observe", "clipboard-exchange"})
result <- err
}()
var request consentFormRequest
select {
case request = <-forms.requests:
case <-ctx.Done():
t.Fatal("no consent form")
}
if _, err := interaction.NewSession(request.form); err != nil {
t.Fatal("invalid consent form", err)
}
if request.form.Elements[0].ID != "cancel" {
t.Fatal("approval was default action")
}
for _, element := range request.form.Elements {
if element.Checked {
t.Fatal("permission preselected")
}
}
reply := internalUIResult{Activated: "approve", Values: map[string]interaction.Value{"output-observe": {Checked: true}}}
switch action {
case "cancel":
reply.Activated = "cancel"
case "empty":
reply.Values = nil
case "lock":
gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{})
gate.SetLocked(false, nil, nil, nil, nil, &brailleWorker{})
case "handoff":
gate.SetSuspended(true)
gate.SetSuspended(false)
case "revoke":
runtime.revokeManagedRemote("consent-probe")
case "disable":
session.Disable()
}
request.reply <- reply
select {
case err := <-result:
if action == "approve" && err != nil {
t.Fatal(err)
}
if action != "approve" && err == nil {
t.Fatal("stale/denied consent issued action")
}
case <-ctx.Done():
t.Fatal("consent worker did not finish")
}
if action != "approve" {
return
}
pending, err := session.Invoke(ctx, "open", nil)
if err != nil {
t.Fatal(err)
}
var lease tentaclewire.RemoteLease
select {
case opened := <-pending:
if opened.Err != nil || json.Unmarshal(opened.Value, &lease) != nil {
t.Fatal("approved action failed", opened.Err)
}
case <-ctx.Done():
t.Fatal("open timed out")
}
delivery, err := session.RemoteContext(lease, "output-observe")
if err != nil {
t.Fatal(err)
}
if _, err := session.RemoteContext(lease, "clipboard-exchange"); err == nil {
t.Fatal("unchecked permission granted")
}
gate.SetSuspended(true)
if delivery.Err() == nil {
t.Fatal("handoff did not synchronously cancel delivery")
}
gate.SetSuspended(false)
if _, err := session.RemoteContext(lease, "output-observe"); err == nil {
t.Fatal("handoff resume revived access")
}
})
}
}
func TestRemoteGrantedTentacleUsesLocalForms(t *testing.T) {
runtime, session, forms, _ := consent_runtime_fixture(t)
form := interaction.Form{Title: "Certificate review", Elements: []interaction.Element{{ID: "cancel", Label: "Cancel", Kind: interaction.Button}}}
if _, err := runtime.open_tentacle_form(t.Context(), tentacleCommand{session: session}, form); err != nil {
t.Fatal(err)
}
select {
case <-forms.requests:
default:
t.Fatal("remote form used ordinary input")
}
}
type ordinaryOnlyForms struct{ tentacleForms }
func TestRemoteConsentCannotFallBackToOrdinaryForms(t *testing.T) {
runtime, session, _, _ := consent_runtime_fixture(t)
runtime.forms = ordinaryOnlyForms{runtime.forms}
if _, err := runtime.authorize_remote(t.Context(), "consent-probe", "controlled", []string{"output-observe"}); err == nil {
t.Fatal("consent fell back to ordinary input")
}
if _, err := runtime.open_tentacle_form(t.Context(), tentacleCommand{session: session}, remote_consent_form("consent-probe", "controlled", []string{"output-observe"})); err == nil {
t.Fatal("sensitive form fell back to ordinary input")
}
}
func TestRemotePolicyAttachedToRuntimeManagers(t *testing.T) {
runtime := &tentacleRuntime{remotePolicy: tentacle.NewRemotePolicy(), newManager: func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) }}
manager, err := runtime.makeManager()
if err != nil {
t.Fatal(err)
}
if err := manager.UseRemotePolicy(tentacle.NewRemotePolicy()); err == nil {
t.Fatal("reader manager omitted policy")
}
}
+9
View File
@@ -34,6 +34,7 @@ type tentacleCommand struct {
// native publication; mutex protects dispatch snapshots and immediate disable.
// Only explicitly granted presentation commands reach the core output owner.
type tentacleRuntime struct {
remotePolicy *tentacle.RemotePolicy
readerSettings *preferencesWorker
extensionChanges chan struct{}
forms tentacleForms
@@ -421,6 +422,14 @@ func (runtime *tentacleRuntime) report(message string) {
// makeManager runs on the lifecycle worker, never on input or GTK callbacks.
func (runtime *tentacleRuntime) makeManager() (*tentacle.Manager, error) {
manager, err := runtime.make_manager()
if err == nil && runtime.remotePolicy != nil {
err = manager.UseRemotePolicy(runtime.remotePolicy)
}
return manager, err
}
func (runtime *tentacleRuntime) make_manager() (*tentacle.Manager, error) {
if runtime.newManager != nil {
return runtime.newManager()
}