Files
Chase CichorzandClaude Opus 5.5 27017bef11 Attach Remote keyboard injection to approved controlled sessions
Local launch and managed startup now start the incoming-key owner for
sessions installed with keyboard-inject, using one input-helper
Supervisor retained for the reader lifetime. Delivery rechecks lock,
XTerm handoff and internal reader UI in addition to lease, grant and
deadline. build-cthulhu.sh and both Arch packages build and install
cthulhu-input-helper; a missing helper leaves keyboard input
unavailable without affecting startup.

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

366 lines
8.9 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package remoteinput
import (
"context"
"errors"
"io"
"math"
"os"
"os/exec"
"path/filepath"
"strconv"
"sync"
"syscall"
"time"
)
var (
ErrUnavailable = errors.New("remote input helper unavailable")
ErrUncertain = errors.New("remote input helper retirement unconfirmed")
)
const operationTimeout = time.Second
const retirementTimeout = Watchdog + time.Second
// Supervisor is the reader-side helper process owner. It grants no consent.
// Its production owner binds ctx/epoch to an approved lease and rechecks
// native policy before every Apply. Unconfirmed cleanup permanently blocks
// replacement on this Supervisor.
type Supervisor struct {
mu sync.Mutex
active bool
uncertain bool
command func(uint64) *exec.Cmd
}
func NewSupervisor(executable string) (*Supervisor, error) {
if !filepath.IsAbs(executable) {
return nil, ErrUnavailable
}
return &Supervisor{command: func(epoch uint64) *exec.Cmd {
return exec.Command(executable, "--epoch", strconv.FormatUint(epoch, 10))
}}, nil
}
type keyCall struct {
name string
pressed bool
result chan error
}
// Client owns one fixed epoch. Cancel closes its independent lifetime pipe;
// Done closes only after retirement was checked or its bounded wait expired.
// Cancellation never kills the helper: it must remain alive to release keys.
type Client struct {
cancel context.CancelFunc
ready chan struct{}
done chan struct{}
calls chan keyCall
err error // published by closing done
}
func (supervisor *Supervisor) Start(ctx context.Context, epoch uint64) (*Client, error) {
if ctx == nil || ctx.Err() != nil || epoch == 0 {
return nil, ErrUnavailable
}
supervisor.mu.Lock()
if supervisor.uncertain || supervisor.active {
supervisor.mu.Unlock()
return nil, ErrUnavailable
}
supervisor.active = true
supervisor.mu.Unlock()
owner, cancel := context.WithCancel(ctx)
client := &Client{cancel: cancel, ready: make(chan struct{}), done: make(chan struct{}), calls: make(chan keyCall)}
go func() {
err := supervisor.serve(owner, epoch, client)
cancel()
supervisor.mu.Lock()
supervisor.active = false
supervisor.uncertain = errors.Is(err, ErrUncertain)
supervisor.mu.Unlock()
client.err = err
close(client.done)
}()
select {
case <-client.ready:
if owner.Err() == nil {
return client, nil
}
case <-client.done:
return nil, client.err
case <-owner.Done():
}
cancel()
<-client.done
if client.err != nil {
return nil, client.err
}
return nil, ErrUnavailable
}
func (client *Client) Cancel() { client.cancel() }
func (client *Client) Done() <-chan struct{} { return client.done }
// Wait reports cleanup certainty, not whether the last key reached an app.
func (client *Client) Wait() error {
<-client.done
return client.err
}
// Apply has no retained queue. Cancellation after admission retires the entire
// client rather than allowing an uncertain key transition to outlive its call.
func (client *Client) Apply(ctx context.Context, name string, pressed bool) error {
if ctx == nil || ctx.Err() != nil || !validKey(name) {
return ErrUnavailable
}
call := keyCall{name: name, pressed: pressed, result: make(chan error, 1)}
select {
case <-client.done:
return ErrUnavailable
case <-ctx.Done():
return ErrUnavailable
case client.calls <- call:
}
select {
case err := <-call.result:
return err
case <-client.done:
return ErrUnavailable
case <-ctx.Done():
client.Cancel()
return ErrUnavailable
}
}
func validKey(name string) bool {
_, err := EncodeOperation(Operation{Kind: Down, Epoch: 1, Sequence: 1, Name: name})
return err == nil
}
type ackResult struct {
ack Ack
err error
}
func (supervisor *Supervisor) serve(ctx context.Context, epoch uint64, client *Client) error {
var files []*os.File
defer func() {
for _, file := range files {
file.Close()
}
}()
pipe := func() (*os.File, *os.File, error) {
read, write, err := os.Pipe()
if err == nil {
files = append(files, read, write)
}
return read, write, err
}
controlRead, control, err := pipe()
if err != nil {
return ErrUnavailable
}
output, outputWrite, err := pipe()
if err != nil {
return ErrUnavailable
}
lifetimeRead, lifetime, err := pipe()
if err != nil {
return ErrUnavailable
}
cmd := supervisor.command(epoch)
cmd.Stdin, cmd.Stdout, cmd.Stderr = controlRead, outputWrite, io.Discard
cmd.ExtraFiles = []*os.File{lifetimeRead}
// No CommandContext/Pdeathsig: writer death must allow native retirement.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if ctx.Err() != nil || cmd.Start() != nil {
return ErrUnavailable
}
controlRead.Close()
outputWrite.Close()
lifetimeRead.Close()
process := make(chan error, 1)
go func() { process <- cmd.Wait() }()
stopWorkers := make(chan struct{})
var workers sync.WaitGroup
workers.Add(2)
defer func() {
close(stopWorkers)
output.Close()
workers.Wait()
}()
go func() {
defer workers.Done()
select {
case <-ctx.Done():
lifetime.Close()
case <-stopWorkers:
}
}()
acks := make(chan ackResult, 1)
go func() {
defer workers.Done()
for {
var frame [AckSize]byte
_, err := io.ReadFull(output, frame[:])
var ack Ack
if err == nil {
ack, err = DecodeAck(frame[:])
}
select {
case acks <- ackResult{ack, err}:
case <-stopWorkers:
return
}
if err != nil {
return
}
}
}()
var sent, accepted uint64
var terminal *Ack
protocolFailed := false
streamEnded, readySeen := false, false
// Every return after launch goes through the independent cleanup barrier.
retire := func() error {
lifetime.Close()
control.Close()
timer := time.NewTimer(retirementTimeout)
defer timer.Stop()
var exited bool
var exitError error
for !exited || !streamEnded {
select {
case exitError = <-process:
exited = true
case result := <-acks:
if result.err != nil {
streamEnded = true
if result.err != io.EOF {
protocolFailed = true
}
continue
}
ack := result.ack
if terminal != nil || ack.Epoch != epoch {
protocolFailed = true
} else if !readySeen && ack == (Ack{Kind: Ready, Epoch: epoch}) {
readySeen = true
} else if ack.Kind == Accepted && ack.Sequence == sent && accepted < sent {
accepted = sent
} else if ack.Kind == Closed && ack.Sequence >= accepted && ack.Sequence <= sent {
terminal = &ack
} else {
protocolFailed = true
}
case <-timer.C:
// A blocked native server may leave a living helper. Never kill
// it or permit replacement; its eventual exit is still reaped.
return ErrUncertain
}
}
if protocolFailed || terminal == nil || terminal.CleanupFailed || exitError != nil {
return ErrUncertain
}
return nil
}
readAck := func() (Ack, error) {
timer := time.NewTimer(operationTimeout)
defer timer.Stop()
select {
case result := <-acks:
if result.err != nil {
streamEnded = true
if result.err != io.EOF {
protocolFailed = true
}
}
return result.ack, result.err
case <-ctx.Done():
return Ack{}, ErrUnavailable
case <-timer.C:
return Ack{}, ErrUnavailable
}
}
ready, err := readAck()
if err != nil || ready != (Ack{Kind: Ready, Epoch: epoch}) {
// An ACK consumed here cannot be reinterpreted as a valid terminal.
if err == nil {
protocolFailed = true
}
if cleanup := retire(); cleanup != nil {
return cleanup
}
return ErrUnavailable
}
readySeen = true
close(client.ready)
transact := func(kind byte, name string) error {
if ctx.Err() != nil || sent == math.MaxUint64 {
return ErrUnavailable
}
sent++
frame, err := EncodeOperation(Operation{Kind: kind, Epoch: epoch, Sequence: sent, Name: name})
if err != nil {
return ErrUnavailable
}
if err = control.SetWriteDeadline(time.Now().Add(operationTimeout)); err == nil {
err = writeAll(control, frame)
}
if err != nil {
return ErrUnavailable
}
ack, err := readAck()
if err != nil {
return ErrUnavailable
}
if ack == (Ack{Kind: Accepted, Epoch: epoch, Sequence: sent}) {
accepted = sent
return nil
}
if ack.Kind == Closed && ack.Epoch == epoch && ack.Sequence >= accepted && ack.Sequence <= sent {
terminal = &ack
} else {
protocolFailed = true
}
return ErrUnavailable
}
ticker := time.NewTicker(Watchdog / 3)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return retire()
case <-ticker.C:
if transact(Heartbeat, "") != nil {
return retire()
}
case call := <-client.calls:
kind := Up
if call.pressed {
kind = Down
}
err := transact(kind, call.name)
call.result <- err
if err != nil {
return retire()
}
case result := <-acks:
if result.err != nil {
streamEnded = true
protocolFailed = true
} else if result.ack.Kind == Closed && result.ack.Epoch == epoch && result.ack.Sequence == accepted {
terminal = &result.ack
} else {
protocolFailed = true
}
return retire()
}
}
}