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
@@ -149,6 +149,9 @@ func (ui *internalUI) run(ctx context.Context) {
|
||||
copyEmoji = session.Values()["copy"].Checked
|
||||
}
|
||||
emojiActive = false
|
||||
if session != nil {
|
||||
session.Close()
|
||||
}
|
||||
var closeErr error
|
||||
if sessionID != 0 {
|
||||
closeCtx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
@@ -162,6 +165,9 @@ func (ui *internalUI) run(ctx context.Context) {
|
||||
if reply.Err == nil {
|
||||
reply.Err = closeErr
|
||||
}
|
||||
if reply.Cancelled || reply.Err != nil {
|
||||
reply.Activated, reply.Values = "", nil
|
||||
}
|
||||
result <- reply
|
||||
close(result)
|
||||
}
|
||||
|
||||
@@ -165,6 +165,56 @@ copied:
|
||||
if clicks != 1 || text != "" {
|
||||
t.Fatalf("UI keys reached GTK: clicks=%d text=%q", clicks, text)
|
||||
}
|
||||
// The same real X11 capture must protect a secret entry. The existing
|
||||
// clipboard remains available for explicit paste, but copy/cut cannot replace it.
|
||||
result, err = ui.Open(ctx, interaction.Form{Title: "Secret fixture", Elements: []interaction.Element{
|
||||
{ID: "key", Label: "Channel key", Kind: interaction.Entry, Secret: true},
|
||||
{ID: "submit", Label: "Connect", Kind: interaction.Button},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case message := <-output.speech:
|
||||
if message.Text == "Secret fixture. Channel key, password edit, 0 characters" {
|
||||
goto secretOpened
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("secret form did not open")
|
||||
}
|
||||
}
|
||||
secretOpened:
|
||||
key("s", "e", "c", "r", "e", "t", "Home", "Right", "Delete", "BackSpace", "ctrl+a", "ctrl+c", "ctrl+x", "ctrl+v", "Tab", "shift+Tab", "Return")
|
||||
select {
|
||||
case submitted := <-result:
|
||||
if submitted.Err != nil || submitted.Cancelled || submitted.Values["key"].Text != "aB!" {
|
||||
t.Fatal("secret submission failed")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("secret form did not submit")
|
||||
}
|
||||
if clipboard, err := read_internal_clipboard(ctx); err != nil || clipboard != "aB!" {
|
||||
t.Fatal("secret copy/cut changed the clipboard")
|
||||
}
|
||||
allowedSecretSpeech := map[string]bool{
|
||||
"Inserted 1 character": true, "Inserted 3 characters": true,
|
||||
"Hidden character": true, "Deleted 1 character": true,
|
||||
"Selected all": true, "Copy and cut are unavailable for password fields": true,
|
||||
"Connect, button": true, "Channel key, password edit, 3 characters": true,
|
||||
}
|
||||
for len(output.speech) > 0 {
|
||||
if !allowedSecretSpeech[(<-output.speech).Text] {
|
||||
t.Fatal("unexpected secret field speech")
|
||||
}
|
||||
}
|
||||
if clicks, text := read(); clicks != 1 || text != "" {
|
||||
t.Fatal("secret form keys reached the background application")
|
||||
}
|
||||
current, _ = exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output()
|
||||
if string(current) != string(original) {
|
||||
t.Fatal("secret form changed application focus")
|
||||
}
|
||||
key("Tab", "q")
|
||||
for {
|
||||
_, text = read()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/braille"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/interaction"
|
||||
)
|
||||
|
||||
type secretTestKeys struct {
|
||||
uiTestKeys
|
||||
failClose bool
|
||||
}
|
||||
|
||||
func (keys *secretTestKeys) EndInternalUI(ctx context.Context, id uint64) error {
|
||||
keys.uiTestKeys.EndInternalUI(ctx, id)
|
||||
if keys.failClose {
|
||||
return errors.New("fixture capture close failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInternalUISecretOutputAndLifetime(t *testing.T) {
|
||||
for _, ending := range []string{"submit", "escape", "revoke", "cancel", "close-error"} {
|
||||
t.Run(ending, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
keys := &secretTestKeys{failClose: ending == "close-error"}
|
||||
output := &ocrTestOutput{speech: make(chan speechRequest, 16)}
|
||||
display := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true}
|
||||
display.EnqueueFrame(braille.Frame{Text: "Application"})
|
||||
ui := &internalUI{keys: keys, speech: output, braille: display, gate: &focusGate{}, events: make(chan input.CommandEvent, 16), requests: make(chan internalUIRequest, 1), done: make(chan struct{})}
|
||||
go ui.run(ctx)
|
||||
defer func() { cancel(); <-ui.done }()
|
||||
formCtx, closeForm := context.WithCancel(ctx)
|
||||
defer closeForm()
|
||||
result, err := ui.Open(formCtx, interaction.Form{Title: "Connection", Elements: []interaction.Element{
|
||||
{ID: "key", Label: "Channel key", Kind: interaction.Entry, Secret: true},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitSpeech := func(want string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-output.speech:
|
||||
if got.Text != want || got.Cause != "internal-ui" {
|
||||
t.Fatal("unexpected secret field output")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("secret field output timed out")
|
||||
}
|
||||
}
|
||||
waitSpeech("Connection. Channel key, password edit, 0 characters")
|
||||
ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Text: "fixture猫"})
|
||||
waitSpeech("Inserted 8 characters")
|
||||
waitTentacle(t, func() bool { return display.current().Text == "Channel key, password edit: ********" })
|
||||
switch ending {
|
||||
case "submit", "close-error":
|
||||
ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Chord: input.CommandChord{Symbol: 0xff0d}})
|
||||
case "escape":
|
||||
ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Chord: input.CommandChord{Symbol: 0xff1b}})
|
||||
case "revoke":
|
||||
keys.session.Store(0)
|
||||
case "cancel":
|
||||
closeForm()
|
||||
}
|
||||
select {
|
||||
case got := <-result:
|
||||
if ending == "submit" {
|
||||
if got.Err != nil || got.Cancelled || got.Activated != "key" || got.Values["key"].Text != "fixture猫" {
|
||||
t.Fatal("secret not delivered on successful submission")
|
||||
}
|
||||
} else if got.Values != nil || got.Activated != "" || !got.Cancelled && got.Err == nil {
|
||||
t.Fatal("failed or cancelled form delivered secret values")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("secret form did not close")
|
||||
}
|
||||
if keys.InternalUISession() != 0 || display.current().Text != "Application" {
|
||||
t.Fatal("secret form did not restore local input and Braille")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,9 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
|
||||
continue
|
||||
}
|
||||
submission = tentaclewire.Submission{Activated: reply.Activated, Values: reply.Values, Cancelled: reply.Cancelled || reply.Err != nil}
|
||||
if submission.Cancelled {
|
||||
submission.Activated, submission.Values = "", nil
|
||||
}
|
||||
} else {
|
||||
itemCtx, stopItem := context.WithTimeout(ctx, time.Second)
|
||||
var item tentaclewire.Item
|
||||
|
||||
@@ -40,6 +40,7 @@ func (runtime *tentacleRuntime) Snapshot() tentacle.Snapshot {
|
||||
}
|
||||
for index := range result.Entries {
|
||||
result.Entries[index].Entry.Program = append([]string(nil), result.Entries[index].Entry.Program...)
|
||||
result.Entries[index].Entry.RemoteGrants = append([]string(nil), result.Entries[index].Entry.RemoteGrants...)
|
||||
if runtime.disabled {
|
||||
result.Entries[index].State = "suppressed"
|
||||
}
|
||||
@@ -105,6 +106,7 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
|
||||
if request.Entry != nil {
|
||||
entry := *request.Entry
|
||||
entry.Program = append([]string(nil), entry.Program...)
|
||||
entry.RemoteGrants = append([]string(nil), entry.RemoteGrants...)
|
||||
request.Entry = &entry
|
||||
}
|
||||
call := tentacleManagementCall{ctx: ctx, request: request, result: make(chan error, 1)}
|
||||
@@ -114,11 +116,20 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
|
||||
if disabled {
|
||||
return tentacle.ErrDisabled
|
||||
}
|
||||
switch request.Action {
|
||||
case "update", "disable", "remove", "retry":
|
||||
runtime.revokeManagedRemote(request.ID)
|
||||
}
|
||||
if request.Action == "install" || request.Action == "install-enable" || request.Action == "upgrade" {
|
||||
repository, err := repositoryForInstallation(runtime.Snapshot(), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if request.Action == "upgrade" {
|
||||
// Revoke before download/preparation, including a failed or cancelled
|
||||
// update. Rolling back the package must never restore access consent.
|
||||
runtime.revokeManagedRemote(tentacle.PackageID(repository, request.ID))
|
||||
}
|
||||
if !runtime.beginRepositoryWork() {
|
||||
return tentacle.ErrBusy
|
||||
}
|
||||
@@ -214,3 +225,16 @@ func change_tentacle_entries(entries []tentacle.Entry, request tentacle.Manageme
|
||||
}
|
||||
return next, tentacle.ValidateEntries(next)
|
||||
}
|
||||
|
||||
// Revoke outside the runtime lock; no session event/cancellation path may call
|
||||
// back into the reader while its registry/command mutex is held.
|
||||
func (runtime *tentacleRuntime) revokeManagedRemote(id string) {
|
||||
runtime.mutex.Lock()
|
||||
sessions := slices.Clone(runtime.sessions)
|
||||
runtime.mutex.Unlock()
|
||||
for _, session := range sessions {
|
||||
if session.TentacleID() == id {
|
||||
session.RevokeRemote()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle"
|
||||
)
|
||||
|
||||
func TestRemoteManagementPeer(t *testing.T) {
|
||||
if len(os.Args) < 2 || os.Args[len(os.Args)-1] != "remote-management-peer" {
|
||||
return
|
||||
}
|
||||
client := sdk.NewSessionClient()
|
||||
command := sdk.Command{ID: "open", Title: "Open", Handle: func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
var event sdk.SessionEvent
|
||||
select {
|
||||
case event = <-client.Events():
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
var lease sdk.RemoteLease
|
||||
err := client.Call(ctx, "remote.open", sdk.RemoteOpen{Action: event.Action, Epoch: event.Epoch, Role: event.Role, Grants: event.Grants}, &lease)
|
||||
return lease, err
|
||||
}}
|
||||
if sdk.ServeSession(os.Stdin, os.Stdout, []sdk.Command{command}, nil, client) != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func TestRemoteLeaseRevokedBeforeFailedUpdatePreparation(t *testing.T) {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := tentacle.NewManager(tentacle.DefaultLimits(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := manager.Shutdown(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
repository := tentacle.Repository{URL: "https://example.org/tentacles", ID: "example.source", Tentacles: []tentacle.Manifest{{ID: "probe", Version: "2.0"}}}
|
||||
id := tentacle.PackageID(repository, "probe")
|
||||
session, err := manager.Start(t.Context(), tentacle.Spec{ID: id, Program: []string{executable, "-test.run=^TestRemoteManagementPeer$", "--", "remote-management-peer"}, SessionEvents: true, RemoteGrants: []string{"output-observe"}})
|
||||
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)
|
||||
}
|
||||
if _, err := session.AuthorizeRemote("controlled", []string{"output-observe"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err := session.Invoke(t.Context(), "open", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var lease tentaclewire.RemoteLease
|
||||
select {
|
||||
case result := <-pending:
|
||||
if result.Err != nil || json.Unmarshal(result.Value, &lease) != nil {
|
||||
t.Fatal("open failed", result.Err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("open timed out")
|
||||
}
|
||||
delivery, err := session.RemoteContext(lease, "output-observe")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner := &tentacleRuntime{registry: &tentacle.Registry{Path: filepath.Join(t.TempDir(), "registry.toml")}, lifecycleContext: t.Context(), sessions: []*tentacle.Session{session},
|
||||
snapshot: tentacle.Snapshot{Repositories: []tentacle.Repository{repository}, Entries: []tentacle.EntryStatus{{Entry: tentacle.Entry{ID: id, Version: "1.0", Enabled: true}}}}}
|
||||
failure := errors.New("fixture preparation failure")
|
||||
called := false
|
||||
owner.preparePackage = func(context.Context, string, tentacle.Repository, string) (*tentacle.PreparedPackage, error) {
|
||||
called = true
|
||||
if delivery.Err() == nil {
|
||||
t.Error("preparation began with live remote access")
|
||||
}
|
||||
return nil, failure
|
||||
}
|
||||
if err := owner.Manage(t.Context(), tentacle.ManagementRequest{Action: "upgrade", URL: repository.URL, ID: "probe"}); !errors.Is(err, failure) {
|
||||
t.Fatal("wrong update result", err)
|
||||
}
|
||||
if !called || delivery.Err() == nil {
|
||||
t.Fatal("failed update retained access")
|
||||
}
|
||||
if _, err := session.RemoteContext(lease, "output-observe"); err == nil {
|
||||
t.Fatal("failed update restored consent")
|
||||
}
|
||||
if state, err := session.Status(); state != tentacle.Active || err != nil {
|
||||
t.Fatal("failed preparation unnecessarily stopped ordinary commands", state, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user