Allow Remote sessions to outlive connection forms
This commit is contained in:
1 parent
b60cf5d5f2
commit
b4896a2eb4
10 files changed
+233
-16
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user