From b4896a2eb40662f64baf4b6ac73588afa5946893 Mon Sep 17 00:00:00 2001 From: Angus Kola Date: Wed, 4 Nov 2026 17:19:19 -0500 Subject: [PATCH] Allow Remote sessions to outlive connection forms --- docs/tentacle-remote-speech.md | 15 +++-- docs/tentacle-sessions.md | 23 +++++++ internal/app/tentacle_interaction.go | 16 ++++- .../app/tentacle_remote_connection_test.go | 61 +++++++++++++++-- internal/tentacle/remote.go | 13 ++++ internal/tentacle/remote_background_test.go | 67 +++++++++++++++++++ internal/tentacle/remote_speech.go | 10 ++- internal/tentaclewire/interaction.go | 9 ++- .../tentaclewire/remote_interaction_test.go | 27 ++++++++ sdk/python/cthulhu_tentacle/__init__.py | 8 +++ 10 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 internal/tentacle/remote_background_test.go diff --git a/docs/tentacle-remote-speech.md b/docs/tentacle-remote-speech.md index 4d231dd..e06a643 100644 --- a/docs/tentacle-remote-speech.md +++ b/docs/tentacle-remote-speech.md @@ -117,11 +117,13 @@ speech-owner mutex as observed utterances. An older delivery generation cannot publish behind that cancel. Remote publication never performs pipe, relay or backend I/O in the input loop. Incoming cancellation ownership remains pending. -The source command still holds a modal status form for its connection lifetime. -That form captures local keys and suppresses ordinary application presentation; -this increment does not detach it or provide an ordinary desktop workflow. -Keep relay-testing warnings until that UI lifetime and incoming speech ownership -are implemented and live acceptance is recorded. +The source command now offers an explicit Return to desktop action after its +reader lease opens. It closes status and releases local input capture while the +original lease and heartbeat worker remain active. The Remote shortcut reopens +status/Disconnect. Lock/handoff, expiry and process loss still revoke the lease. +See [the terminal interaction contract](tentacle-sessions.md#reader-owned-consent-interactions). +Incoming speech ownership and live acceptance remain pending; relay-testing +warnings remain in place. ## Verification and remaining work @@ -160,7 +162,8 @@ speech/Remote/Tentacle race checks, affected-package vet, a separate reader build, the existing speech regression harness and Tentacles `make check`. Next: add incoming speech ownership, delivery-time policy, local priority and -remote-only cancellation, and detach connection status from desktop capture. +remote-only cancellation. Explicit status detachment is implemented; physical +input capture/release and ordinary reading while connected still need acceptance. Review remaining output producers before assigning exportable origins. Atomic IPC assembly is implemented; it does not establish backend priority/cancel behavior or audible delivery. diff --git a/docs/tentacle-sessions.md b/docs/tentacle-sessions.md index f1929c3..ffb8de4 100644 --- a/docs/tentacle-sessions.md +++ b/docs/tentacle-sessions.md @@ -114,6 +114,29 @@ cleanup context, so pre-lease inspection does not depend on a revocation event. No form values accompany that cleanup. Session failure/disable still uses normal process supervision. Existing non-remote interactions keep their path. +An explicit return-to-desktop action may instead finish with: + +```json +{"remote_background":{"lease":"0123456789abcdef0123456789abcdef","epoch":1},"done":true} +``` + +This terminal result requires an already-open, unexpired lease belonging to the +requesting process session. If this interaction approved an epoch, it must match +the requested lease. Forms, continuations, polling, clipboard, reader/consent +requests and uninterruptible speech cannot accompany it; ordinary speech/Braille +presentation is allowed. A polling response cannot detach a form. Go exposes +`Interaction.RemoteBackground`; Python uses the dictionary above. + +On success, core releases the interaction/result slots without invoking child +cancellation or revoking that epoch. The original policy/session lease remains +its owner: heartbeats, expiry, lock, handoff, disconnect, replacement, disable, +update and process loss retain their existing behavior. This result neither +renews authority nor starts networking. The Tentacle must offer an explicit +status/disconnect command; reopening status needs no new consent, while a new +connection still does. Ordinary cancellation and form closure retain their +existing cleanup behavior. `RemoteLeaseContext` checks identity/lifetime only; +desktop effects still require `RemoteContext` with their specific grant. + This addition requires the matching updated reader; protocol 1.1 by itself does not imply that an older reader accepts the new interaction field. Remote still has no release manifest advertising a minimum supported reader version. diff --git a/internal/app/tentacle_interaction.go b/internal/app/tentacle_interaction.go index abbfbe3..1a0cf88 100644 --- a/internal/app/tentacle_interaction.go +++ b/internal/app/tentacle_interaction.go @@ -76,6 +76,20 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo if runtime.output.gate.Locked() || runtime.output.gate.Suspended() { return } + if value.RemoteBackground != nil { + lease := *value.RemoteBackground + lifetime, leaseErr := command.session.RemoteLeaseContext(lease) + if leaseErr != nil || ctx.Err() != nil || lifetime.Err() != nil || approvedEpoch != 0 && approvedEpoch != lease.Epoch { + runtime.report("Remote connection ended. Start a fresh access review.") + return + } + // The reader lease already belongs to the policy/session owner. + // Returning to the desktop releases form and interaction capture, + // without extending consent or running the cancellation continuation. + approvedEpoch, cleanupNext = 0, "" + runtime.output.Present(runtime.output.Capture(command.session.OutputContext()), value.Presentation, "tentacle/"+command.id) + return + } cleanupNext = value.Next if value.Remote != nil { action, approvalErr := runtime.authorize_remote_session(ctx, command.session, value.Remote.Role, value.Remote.Grants) @@ -255,7 +269,7 @@ func (runtime *tentacleRuntime) waitTentacleForm(ctx context.Context, command te return internalUIResult{Cancelled: true}, nil } status, err := tentaclewire.DecodeInteraction(raw) - if err != nil || status.Form != nil || status.Source != "" || status.Next != "" || status.Reader != nil || status.Remote != nil { + if err != nil || status.Form != nil || status.Source != "" || status.Next != "" || status.Reader != nil || status.Remote != nil || status.RemoteBackground != nil { command.session.Disable() return internalUIResult{Cancelled: true}, nil } diff --git a/internal/app/tentacle_remote_connection_test.go b/internal/app/tentacle_remote_connection_test.go index dd7037c..96a6668 100644 --- a/internal/app/tentacle_remote_connection_test.go +++ b/internal/app/tentacle_remote_connection_test.go @@ -222,8 +222,14 @@ func TestRemoteConnectionCommandCancelsInspectionOnHandoff(t *testing.T) { } func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { - for _, role := range []string{"controller", "controlled"} { - t.Run(role, func(t *testing.T) { + for _, test := range []struct{ role, ending string }{ + {"controller", "disconnect"}, {"controlled", "disconnect"}, + {"controller", "lock"}, {"controlled", "lock"}, + {"controller", "handoff"}, {"controlled", "handoff"}, + {"controller", "disable"}, {"controlled", "disable"}, + } { + role, ending := test.role, test.ending + t.Run(role+"/"+ending, func(t *testing.T) { remoteText := " " + strings.Repeat("é\x00世界 ", 1500) + " " key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -299,7 +305,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { } close(closed) }() - runtime, forms, _ := remote_command_fixture(t) + runtime, forms, shortcut := remote_command_fixture(t) if role == "controlled" { worker := runtime.output.speech worker.speaker = &fakeSpeaker{spoken: make(chan string, 16)} @@ -356,6 +362,18 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { if status.form.Title != "Remote relay status" { t.Fatal("missing connection status") } + // Open has already succeeded before the relay can join. Return to + // ordinary desktop work without holding either runtime slot or form. + status.reply <- internalUIResult{Activated: "desktop"} + waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 && len(runtime.interactionSlots) == 0 }) + if status.ctx.Err() == nil { + t.Fatal("background connection retained its form context") + } + select { + case <-closed: + t.Fatal("return to desktop disconnected the relay") + default: + } var delivery tentacle.RemoteSpeechDelivery if role == "controller" { select { @@ -393,6 +411,8 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { // Joining at the relay is earlier than the Tentacle admitting the // peer. Poll status before testing actual output rather than // depending on a scheduling delay or replaying pre-peer output. + runtime.Invoke(t.Context(), shortcut) + status = next_remote_form(t, forms) deadline := time.Now().Add(5 * time.Second) for { encoded, _ := json.Marshal(status.form) @@ -405,6 +425,8 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { status.reply <- internalUIResult{Activated: "refresh"} status = next_remote_form(t, forms) } + status.reply <- internalUIResult{Activated: "desktop"} + waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 }) worker := runtime.output.speech for _, request := range []speechRequest{ {Text: "private form", Cause: "internal-ui"}, @@ -455,15 +477,40 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { t.Fatal("observation leaked private text or lost resolved output/boundaries") } } - status.reply <- internalUIResult{Activated: "cancel"} + // Reopening returns to this attempt, with no second consent or + // saved-key read. Lifecycle retirement must work with no form open. + switch ending { + case "disconnect": + runtime.Invoke(t.Context(), shortcut) + status = next_remote_form(t, forms) + if status.form.Title != "Remote relay status" { + t.Fatal("reopening lost the approved connection") + } + status.reply <- internalUIResult{Activated: "cancel"} + case "lock": + runtime.output.gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{}) + runtime.output.gate.SetLocked(false, nil, nil, nil, nil, &brailleWorker{}) + case "handoff": + runtime.output.gate.SetSuspended(true) + runtime.output.gate.SetSuspended(false) + case "disable": + runtime.Disable() + } select { case <-closed: case <-time.After(5 * time.Second): - t.Fatal("Disconnect and close retained the relay") + t.Fatal("background connection retained the relay after " + ending) } waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 }) - if role == "controller" && delivery.Context.Err() == nil { - t.Fatal("Disconnect retained remote delivery authority") + if role == "controller" { + // Socket cancellation precedes the final sequenced lease close. + // A reopened status interaction did not issue the original consent, + // so its result-slot completion is not the lease-close barrier. + select { + case <-delivery.Context.Done(): + case <-time.After(3 * time.Second): + t.Fatal("Disconnect retained remote delivery authority") + } } }) } diff --git a/internal/tentacle/remote.go b/internal/tentacle/remote.go index 8ecf021..218ccb5 100644 --- a/internal/tentacle/remote.go +++ b/internal/tentacle/remote.go @@ -144,6 +144,19 @@ func (session *Session) RemoteContext(lease tentaclewire.RemoteLease, grant stri return access.ctx, nil } +// RemoteLeaseContext checks process ownership and lifetime, without authorizing +// any desktop effect. Core uses it when returning from a connection form to the +// desktop; effect owners must still use RemoteContext with their specific grant. +func (session *Session) RemoteLeaseContext(lease tentaclewire.RemoteLease) (context.Context, error) { + session.mutex.Lock() + defer session.mutex.Unlock() + access := session.remote_lease_locked(lease) + if access == nil { + return nil, ErrPermission + } + return access.ctx, nil +} + func (session *Session) release_remote_owner() { session.manager.remoteMutex.Lock() if session.manager.remoteOwner == session { diff --git a/internal/tentacle/remote_background_test.go b/internal/tentacle/remote_background_test.go new file mode 100644 index 0000000..8b48d5c --- /dev/null +++ b/internal/tentacle/remote_background_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +func TestRemoteBackgroundLeaseOwnershipAndLifetime(t *testing.T) { + for _, language := range []string{"go", "python"} { + t.Run(language, func(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + policy := NewRemotePolicy() + if err := manager.UseRemotePolicy(policy); err != nil { + t.Fatal(err) + } + session := startSessionSDK(t, manager, language, "speech") + lease := speech_fixture_lease(t, session, "controlled", []string{"output-observe"}) + lifetime, err := session.RemoteLeaseContext(lease) + if err != nil || lifetime.Err() != nil { + t.Fatal("live owned lease unavailable", err) + } + for _, invalid := range []tentaclewire.RemoteLease{{}, {Lease: lease.Lease, Epoch: lease.Epoch + 1}, {Lease: strings.Repeat("b", 32), Epoch: lease.Epoch}} { + if _, err := session.RemoteLeaseContext(invalid); err == nil { + t.Fatal("invalid ownership accepted") + } + } + if _, err := session.RemoteContext(lease, ""); err == nil { + t.Fatal("ownership check bypassed desktop grant requirement") + } + other := startSessionSDK(t, manager, language, "speech-other") + if _, err := other.RemoteLeaseContext(lease); err == nil { + t.Fatal("another process session acquired lease ownership") + } + policy.SetAvailable(false) + policy.SetAvailable(true) + if lifetime.Err() == nil { + t.Fatal("lock/handoff round trip retained background lifetime") + } + if _, err := session.RemoteLeaseContext(lease); err == nil { + t.Fatal("background ownership survived policy retirement") + } + // A replacement remains distinct, even while old forms finish. + session.RevokeRemote() + invokeSessionSDK(t, session, "event", nil) + replacement := speech_fixture_lease(t, session, "controlled", []string{"output-observe"}) + if _, err := session.RemoteLeaseContext(lease); err == nil { + t.Fatal("old controls followed replacement consent") + } + session.RevokeRemoteEpoch(lease.Epoch) + if _, err := session.RemoteLeaseContext(replacement); err != nil { + t.Fatal("old cleanup retired replacement", err) + } + session.mutex.Lock() + session.remote.deadline = time.Now().Add(-time.Second) + session.mutex.Unlock() + if _, err := session.RemoteLeaseContext(replacement); err == nil { + t.Fatal("expired lease accepted for background ownership") + } + }) + } +} diff --git a/internal/tentacle/remote_speech.go b/internal/tentacle/remote_speech.go index 637b436..78ca371 100644 --- a/internal/tentacle/remote_speech.go +++ b/internal/tentacle/remote_speech.go @@ -47,8 +47,16 @@ func (session *Session) EnableRemoteSpeech() <-chan RemoteSpeechDelivery { } func (session *Session) remote_access_locked(lease tentaclewire.RemoteLease, grant string) *remoteAccess { + access := session.remote_lease_locked(lease) + if access == nil || !slices.Contains(access.grants, grant) { + return nil + } + return access +} + +func (session *Session) remote_lease_locked(lease tentaclewire.RemoteLease) *remoteAccess { 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) { + 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 { return nil } return access diff --git a/internal/tentaclewire/interaction.go b/internal/tentaclewire/interaction.go index 30c0b3c..140df35 100644 --- a/internal/tentaclewire/interaction.go +++ b/internal/tentaclewire/interaction.go @@ -26,6 +26,9 @@ type Interaction struct { Done bool `json:"done,omitempty"` Reader *ReaderRequest `json:"reader,omitempty"` Remote *RemoteRequest `json:"remote,omitempty"` + // RemoteBackground ends this interaction while retaining an already-open + // lease owned by this process session. It grants no new access or lifetime. + RemoteBackground *RemoteLease `json:"remote_background,omitempty"` } // RemoteRequest asks the reader to show its own local consent form. It never @@ -80,7 +83,11 @@ func DecodeInteraction(data []byte) (Interaction, error) { if value.Source != "" && value.Source != "focus" && value.Source != "clipboard" { return value, ErrProtocol } - if value.Remote != nil { + if value.RemoteBackground != nil { + if len(value.RemoteBackground.Lease) != 32 || value.RemoteBackground.Epoch == 0 || !value.Done || value.Next != "" || value.Poll != "" || value.Form != nil || value.Source != "" || value.Clipboard != nil || value.Reader != nil || value.Remote != nil || value.Uninterruptible { + return value, ErrProtocol + } + } else if value.Remote != nil { if !ValidRoleGrants(value.Remote.Role, value.Remote.Grants) || value.Next == "" || value.Reader != nil || value.Form != nil || value.Source != "" || value.Poll != "" || value.Clipboard != nil || value.Done { return value, ErrProtocol } diff --git a/internal/tentaclewire/remote_interaction_test.go b/internal/tentaclewire/remote_interaction_test.go index 170ad99..ad6172f 100644 --- a/internal/tentaclewire/remote_interaction_test.go +++ b/internal/tentaclewire/remote_interaction_test.go @@ -4,6 +4,7 @@ package tentaclewire import ( "encoding/json" + "strings" "testing" ) @@ -32,3 +33,29 @@ func TestRemoteConsentInteractionValidation(t *testing.T) { } } } + +func TestRemoteBackgroundInteractionValidation(t *testing.T) { + lease := RemoteLease{Lease: strings.Repeat("a", 32), Epoch: 1} + valid := Interaction{RemoteBackground: &lease, Done: true, Presentation: Presentation{Speech: "Connection controls closed"}} + data, _ := json.Marshal(valid) + if _, err := DecodeInteraction(data); err != nil { + t.Fatal(err) + } + for _, raw := range []string{ + `{"remote_background":null,"done":true}`, + `{"remote_background":{"lease":"short","epoch":1},"done":true}`, + `{"remote_background":{"lease":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","epoch":0},"done":true}`, + `{"remote_background":{"lease":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","epoch":1,"session":"other"},"done":true}`, + `{"remote_background":{"lease":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","epoch":1}}`, + } { + if _, err := DecodeInteraction([]byte(raw)); err == nil { + t.Fatal("invalid background interaction accepted", raw) + } + } + for _, field := range []string{`"form":{"title":"Menu","elements":[]}`, `"next":"submit"`, `"poll":"poll"`, `"source":"focus"`, `"clipboard":"text"`, `"reader":{"snapshot":true}`, `"remote":{"role":"controller","grants":["remote-present"]}`, `"uninterruptible":true`} { + raw := `{"remote_background":{"lease":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","epoch":1},"done":true,"speech":"Closed",` + field + `}` + if _, err := DecodeInteraction([]byte(raw)); err == nil { + t.Fatal("background interaction mixed with another effect", field) + } + } +} diff --git a/sdk/python/cthulhu_tentacle/__init__.py b/sdk/python/cthulhu_tentacle/__init__.py index 1e897f1..1172ee3 100644 --- a/sdk/python/cthulhu_tentacle/__init__.py +++ b/sdk/python/cthulhu_tentacle/__init__.py @@ -13,6 +13,14 @@ The continuation receives "remote" metadata after explicit local approval, or "cancelled": True on denial. Match approval to the actual SessionClient event; neither a request dictionary nor submitted metadata is itself consent. This interaction addition requires the matching updated reader. + +After remote.open, an explicit return-to-desktop action can finish with +{"remote_background": {"lease": lease["lease"], "epoch": lease["epoch"]}, + "done": True}. The host checks an already-open lease belonging to this process; +this neither grants consent nor renews it. Keep heartbeating and provide an +explicit status/disconnect command. Other interaction effects cannot accompany +this terminal result, except ordinary speech/Braille presentation. Cancellation +and normal form closure still end setup. Older hosts reject this addition. """ import json