Files
cthulhu/internal/app/tentacle_remote_keyboard_test.go
Chase CichorzandClaude Opus 5.5 c234cb82a8 Add Xorg Remote controller capture with a core F11 escape
The Xorg command owner gains an exclusive Remote capture mode. Native
transitions pass through the repeat normalizer and capture router;
forwarded keys leave as remote.input events, Remote's own shortcuts are
dispatched locally, and F11 ends capture on the X thread. Reader policy,
overflow or inconsistent input also end it. Ending releases forwarded
keys first and keeps the grab until held keys are released. Local key
echo is suppressed during capture. The app attaches the owner to
sessions installed with keyboard-capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:06:38 -05:00

464 lines
15 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/remoteinput"
"git.stormux.org/storm/cthulhu/internal/tentacle"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle"
)
// This peer exercises the actual SDK/host path. Consent and the helper target
// are synthetic; no X connection or desktop input is opened by these tests.
func TestRemoteKeyboardPeer(t *testing.T) {
if os.Args[len(os.Args)-1] != "remote-keyboard-peer" {
return
}
client := sdk.NewSessionClient()
commands := []sdk.Command{
{ID: "open", Title: "Open", Handle: func(ctx context.Context, _ json.RawMessage) (any, error) {
for {
select {
case event := <-client.Events():
if event.Kind != "remote.action" {
continue
}
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
case <-ctx.Done():
return nil, ctx.Err()
}
}
}},
{ID: "event", Title: "Event", Handle: func(ctx context.Context, _ json.RawMessage) (any, error) {
select {
case event := <-client.Events():
return event, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}},
{ID: "rpc", Title: "RPC", Handle: func(ctx context.Context, params json.RawMessage) (any, error) {
var request struct {
Method string
Params json.RawMessage
}
if err := json.Unmarshal(params, &request); err != nil {
return nil, err
}
var result json.RawMessage
err := client.Call(ctx, request.Method, request.Params, &result)
return result, err
}},
}
if sdk.ServeSession(os.Stdin, os.Stdout, commands, nil, client) != nil {
os.Exit(1)
}
os.Exit(0)
}
type keyboardOwnerTestTarget struct{ epoch uint64 }
func (target keyboardOwnerTestTarget) record(event string) {
file, err := os.OpenFile(os.Getenv("CTHULHU_KEY_OWNER_LOG"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
os.Exit(2)
}
_, err = file.WriteString(strconv.FormatUint(target.epoch, 10) + " " + event + "\n")
file.Close()
if err != nil {
os.Exit(2)
}
}
func keyboard_owner_test_gate(name string) {
if path := os.Getenv(name); path != "" {
for {
if _, err := os.Stat(path); err == nil {
return
}
time.Sleep(2 * time.Millisecond)
}
}
}
func (target keyboardOwnerTestTarget) Apply(name string, pressed bool) error {
target.record(name + " " + strconv.FormatBool(pressed))
keyboard_owner_test_gate("CTHULHU_KEY_OWNER_APPLY_GATE")
return nil
}
func (target keyboardOwnerTestTarget) Close() error {
target.record("cleanup")
if os.Getenv("CTHULHU_KEY_OWNER_FAIL_CLEANUP") != "" {
return remoteinput.ErrCleanup
}
return nil
}
func TestRemoteKeyboardHelper(t *testing.T) {
if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "--epoch" {
return
}
epoch, _ := strconv.ParseUint(os.Args[len(os.Args)-1], 10, 64)
control, err := remoteinput.AdoptPipe(os.Stdin, false)
if err != nil {
os.Exit(2)
}
output, err := remoteinput.AdoptPipe(os.Stdout, true)
if err != nil {
os.Exit(2)
}
lifetime, err := remoteinput.AdoptPipe(os.NewFile(3, "lifetime"), false)
if err != nil {
os.Exit(2)
}
target := keyboardOwnerTestTarget{epoch}
target.record("starting")
keyboard_owner_test_gate("CTHULHU_KEY_OWNER_READY_GATE")
if remoteinput.Run(context.Background(), epoch, control, lifetime, output, target) != nil {
os.Exit(1)
}
os.Exit(0)
}
type keyboardOwnerFixture struct {
session *tentacle.Session
policy *tentacle.RemotePolicy
supervisor *remoteinput.Supervisor
allowed atomic.Bool
cancel context.CancelFunc
result chan error
log string
}
// keyboard_owner_helper writes the wrapper the real Supervisor launches. The
// wrapper only adapts test-binary arguments; the Supervisor still owns launch,
// private pipes, heartbeats, ACK validation and process reaping.
func keyboard_owner_helper(t *testing.T) (program, log string) {
t.Helper()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
directory := t.TempDir()
log = filepath.Join(directory, "effects")
t.Setenv("CTHULHU_KEY_OWNER_LOG", log)
program = filepath.Join(directory, "helper")
script := "#!/bin/sh\nexec '" + strings.ReplaceAll(executable, "'", "'\\''") + "' -test.run=^TestRemoteKeyboardHelper$ -- \"$@\"\n"
if err := os.WriteFile(program, []byte(script), 0700); err != nil {
t.Fatal(err)
}
return program, log
}
func keyboard_owner_session(t *testing.T, policy *tentacle.RemotePolicy, grants []string) *tentacle.Session {
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)
}
})
if err := manager.UseRemotePolicy(policy); err != nil {
t.Fatal(err)
}
session, err := manager.Start(t.Context(), tentacle.Spec{ID: "key-owner", Program: []string{executable, "-test.run=^TestRemoteKeyboardPeer$", "--", "remote-keyboard-peer"}, SessionEvents: true, RemoteGrants: grants})
if err != nil {
t.Fatal(err)
}
select {
case <-session.Ready():
case <-time.After(5 * time.Second):
t.Fatal("keyboard peer did not start")
}
if err := session.Activate(); err != nil {
t.Fatal(err)
}
return session
}
func keyboard_owner_fixture(t *testing.T, configure ...func(*keyboardOwnerFixture)) *keyboardOwnerFixture {
t.Helper()
f := &keyboardOwnerFixture{result: make(chan error, 1), policy: tentacle.NewRemotePolicy()}
f.allowed.Store(true)
program, log := keyboard_owner_helper(t)
f.log = log
var err error
f.supervisor, err = remoteinput.NewSupervisor(program)
if err != nil {
t.Fatal(err)
}
f.session = keyboard_owner_session(t, f.policy, []string{"keyboard-inject", "output-observe"})
for _, setup := range configure {
setup(f)
}
ctx, cancel := context.WithCancel(t.Context())
f.cancel = cancel
// Attach synchronously, as a future launcher must do before advertising
// keyboard service availability. This call grants no consent.
f.session.EnableRemoteKeys()
go func() {
f.result <- run_remote_keyboard(ctx, f.session, f.supervisor, func(tentacle.RemoteKeyDelivery) bool { return f.allowed.Load() })
}()
t.Cleanup(func() {
// Unblock only these synthetic targets if a test fails before releasing
// its gate. The production supervisor deliberately never kills a helper.
for _, name := range []string{"CTHULHU_KEY_OWNER_READY_GATE", "CTHULHU_KEY_OWNER_APPLY_GATE"} {
if path := os.Getenv(name); path != "" {
_ = os.WriteFile(path, nil, 0600)
}
}
cancel()
select {
case <-f.result:
case <-time.After(6 * time.Second):
t.Error("keyboard owner did not join cleanup")
}
})
return f
}
func keyboard_owner_invoke(t *testing.T, session *tentacle.Session, command string, params any) json.RawMessage {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
data, _ := json.Marshal(params)
pending, err := session.Invoke(ctx, command, data)
if err != nil {
t.Fatal(err)
}
select {
case result := <-pending:
if result.Err != nil {
t.Fatal(result.Err)
}
return result.Value
case <-ctx.Done():
t.Fatal("keyboard peer call timed out")
return nil
}
}
func (f *keyboardOwnerFixture) open(t *testing.T, grant string) tentaclewire.RemoteLease {
t.Helper()
approval, err := f.session.BeginRemoteApproval("controlled", []string{grant})
if err != nil {
t.Fatal(err)
}
if _, err := f.session.AuthorizeRemoteWithApproval(approval, "controlled", []string{grant}); err != nil {
t.Fatal(err)
}
var lease tentaclewire.RemoteLease
if json.Unmarshal(keyboard_owner_invoke(t, f.session, "open", nil), &lease) != nil || lease.Epoch == 0 {
t.Fatal("keyboard peer failed to open lease")
}
return lease
}
func (f *keyboardOwnerFixture) key(t *testing.T, lease tentaclewire.RemoteLease, sequence uint64, name string, pressed bool) {
t.Helper()
request := tentaclewire.RemoteKeyRequest{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence}, Key: tentaclewire.RemoteKey{Name: name, Pressed: pressed}}
keyboard_owner_invoke(t, f.session, "rpc", map[string]any{"Method": "remote.key", "Params": request})
}
func (f *keyboardOwnerFixture) effects() string {
data, _ := os.ReadFile(f.log)
return string(data)
}
func (f *keyboardOwnerFixture) wait(t *testing.T, predicate func() bool) {
t.Helper()
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
tick := time.NewTicker(2 * time.Millisecond)
defer tick.Stop()
for !predicate() {
select {
case <-tick.C:
case <-deadline.C:
t.Fatal("keyboard owner transition timed out", f.effects())
}
}
}
func TestRemoteKeyboardOwnerOrdersKeysAndReplacementCleanup(t *testing.T) {
f := keyboard_owner_fixture(t)
first := f.open(t, "keyboard-inject")
for index, pressed := range []bool{true, true, false} {
f.key(t, first, uint64(index+1), "Shift_L", pressed)
}
prefix := strconv.FormatUint(first.Epoch, 10) + " "
f.wait(t, func() bool { return strings.Contains(f.effects(), prefix+"Shift_L false\n") })
second := f.open(t, "keyboard-inject") // Replacement may precede old process exit.
f.key(t, second, 1, "Control_R", true)
secondPrefix := strconv.FormatUint(second.Epoch, 10) + " "
f.wait(t, func() bool { return strings.Contains(f.effects(), secondPrefix+"Control_R true\n") })
expected := prefix + "starting\n" + prefix + "Shift_L true\n" + prefix + "Shift_L true\n" + prefix + "Shift_L false\n" + prefix + "cleanup\n" + secondPrefix + "starting\n" + secondPrefix + "Control_R true\n"
if f.effects() != expected {
t.Fatal("key/replacement ordering changed", f.effects())
}
if _, err := f.session.RemoteContext(second, "keyboard-inject"); err != nil {
t.Fatal("old retirement cancelled replacement consent", err)
}
f.policy.SetAvailable(false)
f.policy.SetAvailable(true)
f.wait(t, func() bool { return strings.Contains(f.effects(), secondPrefix+"cleanup\n") })
}
func TestRemoteKeyboardOwnerRechecksPolicyAfterStartup(t *testing.T) {
gate := filepath.Join(t.TempDir(), "ready")
t.Setenv("CTHULHU_KEY_OWNER_READY_GATE", gate)
f := keyboard_owner_fixture(t)
lease := f.open(t, "keyboard-inject")
f.key(t, lease, 1, "a", true)
f.wait(t, func() bool { return strings.Contains(f.effects(), "starting\n") })
f.allowed.Store(false)
if err := os.WriteFile(gate, nil, 0600); err != nil {
t.Fatal(err)
}
f.wait(t, func() bool { return strings.Contains(f.effects(), "cleanup\n") })
if strings.Contains(f.effects(), "a true") {
t.Fatal("startup admission survived changed native policy")
}
if _, err := f.session.RemoteContext(lease, "keyboard-inject"); err == nil {
t.Fatal("policy denial retained the input lease")
}
f.allowed.Store(true)
fresh := f.open(t, "keyboard-inject")
f.key(t, fresh, 1, "b", true)
f.wait(t, func() bool { return strings.Contains(f.effects(), "b true\n") })
}
func TestRemoteKeyboardOwnerDeniedWorkDoesNotLaunchHelper(t *testing.T) {
f := keyboard_owner_fixture(t)
f.allowed.Store(false)
lease := f.open(t, "keyboard-inject")
f.key(t, lease, 1, "a", true)
f.wait(t, func() bool { _, err := f.session.RemoteContext(lease, "keyboard-inject"); return err != nil })
if f.effects() != "" {
t.Fatal("policy-denied work launched a native helper")
}
}
func TestRemoteKeyboardOwnerCancelledStartupAllowsFreshLease(t *testing.T) {
gate := filepath.Join(t.TempDir(), "ready")
t.Setenv("CTHULHU_KEY_OWNER_READY_GATE", gate)
f := keyboard_owner_fixture(t)
old := f.open(t, "keyboard-inject")
f.key(t, old, 1, "a", true)
f.wait(t, func() bool { return strings.Contains(f.effects(), "starting\n") })
fresh := f.open(t, "keyboard-inject")
f.key(t, fresh, 1, "b", true)
if err := os.WriteFile(gate, nil, 0600); err != nil {
t.Fatal(err)
}
f.wait(t, func() bool { return strings.Contains(f.effects(), "b true\n") })
if strings.Contains(f.effects(), "a true") {
t.Fatal("retired startup delivered its old key")
}
if _, err := f.session.RemoteContext(fresh, "keyboard-inject"); err != nil {
t.Fatal("old startup failure revoked fresh consent", err)
}
}
func TestRemoteKeyboardOwnerIdleLeaseExpiryRetiresHelper(t *testing.T) {
f := keyboard_owner_fixture(t)
lease := f.open(t, "keyboard-inject")
f.key(t, lease, 1, "Control_R", true)
f.wait(t, func() bool { return strings.Contains(f.effects(), "Control_R true\n") })
// The Supervisor keeps its private watchdog alive; those heartbeats must
// never extend the authenticated reader lease's independent deadline.
f.wait(t, func() bool { return strings.Contains(f.effects(), "cleanup\n") })
if _, err := f.session.RemoteContext(lease, "keyboard-inject"); err == nil {
t.Fatal("private helper heartbeats renewed reader consent")
}
}
func TestRemoteKeyboardOwnerShutdownJoinsCleanupAndDisablesMailbox(t *testing.T) {
f := keyboard_owner_fixture(t)
lease := f.open(t, "keyboard-inject")
f.key(t, lease, 1, "Control_R", true)
f.wait(t, func() bool { return strings.Contains(f.effects(), "Control_R true\n") })
f.cancel()
select {
case err := <-f.result:
f.result <- err
if err != nil || !strings.Contains(f.effects(), "cleanup\n") {
t.Fatal("owner shutdown did not confirm cleanup", err)
}
case <-time.After(6 * time.Second):
t.Fatal("owner shutdown did not complete")
}
if f.session.OutputContext().Err() == nil {
t.Fatal("stopped owner retained an accepting session")
}
}
func TestRemoteKeyboardOwnerRevokesBlockedAndQueuedKeys(t *testing.T) {
gate := filepath.Join(t.TempDir(), "apply")
t.Setenv("CTHULHU_KEY_OWNER_APPLY_GATE", gate)
f := keyboard_owner_fixture(t)
lease := f.open(t, "keyboard-inject")
f.key(t, lease, 1, "Shift_L", true)
f.wait(t, func() bool { return strings.Contains(f.effects(), "Shift_L true\n") })
f.key(t, lease, 2, "a", true)
f.key(t, lease, 3, "Shift_L", false)
f.session.RevokeRemote()
if err := os.WriteFile(gate, nil, 0600); err != nil {
t.Fatal(err)
}
f.wait(t, func() bool { return strings.Contains(f.effects(), "cleanup\n") })
if strings.Contains(f.effects(), "a true") || strings.Contains(f.effects(), "Shift_L false") {
t.Fatal("revoked queued keys reached the helper", f.effects())
}
}
func TestRemoteKeyboardOwnerUncertainCleanupStopsSession(t *testing.T) {
t.Setenv("CTHULHU_KEY_OWNER_FAIL_CLEANUP", "1")
f := keyboard_owner_fixture(t)
lease := f.open(t, "keyboard-inject")
f.key(t, lease, 1, "Control_L", true)
f.wait(t, func() bool { return strings.Contains(f.effects(), "Control_L true\n") })
f.session.RevokeRemote()
select {
case err := <-f.result:
f.result <- err // Preserve cleanup's join signal.
if !errors.Is(err, remoteinput.ErrUncertain) {
t.Fatal("failed native cleanup was reported as clean", err)
}
case <-time.After(6 * time.Second):
t.Fatal("failed cleanup did not stop the owner")
}
if state, _ := f.session.Status(); state != tentacle.Stopping && state != tentacle.Stopped {
t.Fatal("orphan mailbox session remained enabled", state)
}
if _, err := f.supervisor.Start(t.Context(), lease.Epoch+1); !errors.Is(err, remoteinput.ErrUnavailable) {
t.Fatal("uncertain helper was replaced", err)
}
}