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>
This commit is contained in:
1 parent
c1333c2fdb
commit
27017bef11
13 files changed
+362
-66
No files matched your search
+4
-3
@@ -13,7 +13,7 @@ usage() {
|
||||
cat <<'EOF'
|
||||
Usage: build-cthulhu.sh [-o DIR | --output-dir DIR] [-- GO_BUILD_FLAGS...]
|
||||
|
||||
Build cthulhu and cthulhu-braille, obtaining a suitable Go toolchain if needed.
|
||||
Build cthulhu, cthulhu-braille and cthulhu-input-helper, obtaining a suitable Go toolchain if needed.
|
||||
|
||||
-o, --output-dir DIR Write both binaries to DIR (default: checkout root).
|
||||
Relative paths start at the caller's directory.
|
||||
@@ -56,7 +56,7 @@ if [[ $outputDir != /* ]]; then
|
||||
outputDir="$PWD/$outputDir"
|
||||
fi
|
||||
[[ ! -e $outputDir || -d $outputDir ]] || fail "Output directory is an existing file: $outputDir"
|
||||
for binary in cthulhu cthulhu-braille; do
|
||||
for binary in cthulhu cthulhu-braille cthulhu-input-helper; do
|
||||
[[ ! -d $outputDir/$binary ]] || fail "Binary output path is a directory: $outputDir/$binary"
|
||||
done
|
||||
cd -- "$projectDir"
|
||||
@@ -129,4 +129,5 @@ echo "Using $goVersion"
|
||||
mkdir -p -- "$outputDir"
|
||||
"$goCommand" build "${buildFlags[@]}" -o "$outputDir/cthulhu" ./cmd/cthulhu
|
||||
"$goCommand" build "${buildFlags[@]}" -tags brlapi -o "$outputDir/cthulhu-braille" ./cmd/cthulhu-braille
|
||||
echo "Cthulhu has been built: $outputDir/cthulhu and $outputDir/cthulhu-braille"
|
||||
"$goCommand" build "${buildFlags[@]}" -o "$outputDir/cthulhu-input-helper" ./cmd/cthulhu-input-helper
|
||||
echo "Cthulhu has been built: $outputDir/cthulhu, $outputDir/cthulhu-braille and $outputDir/cthulhu-input-helper"
|
||||
@@ -84,6 +84,7 @@ build() {
|
||||
|
||||
go build -o build/cthulhu ./cmd/cthulhu
|
||||
go build -tags brlapi -o build/cthulhu-braille ./cmd/cthulhu-braille
|
||||
go build -o build/cthulhu-input-helper ./cmd/cthulhu-input-helper
|
||||
}
|
||||
|
||||
check() {
|
||||
@@ -98,6 +99,7 @@ package() {
|
||||
cd "${_pkgname}"
|
||||
install -Dm755 build/cthulhu "${pkgdir}/usr/bin/cthulhu"
|
||||
install -Dm755 build/cthulhu-braille "${pkgdir}/usr/bin/cthulhu-braille"
|
||||
install -Dm755 build/cthulhu-input-helper "${pkgdir}/usr/bin/cthulhu-input-helper"
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
install -Dm644 AUTHORS "${pkgdir}/usr/share/doc/${pkgname}/AUTHORS"
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ build() {
|
||||
|
||||
go build -o build/cthulhu ./cmd/cthulhu
|
||||
go build -tags brlapi -o build/cthulhu-braille ./cmd/cthulhu-braille
|
||||
go build -o build/cthulhu-input-helper ./cmd/cthulhu-input-helper
|
||||
}
|
||||
|
||||
check() {
|
||||
@@ -94,6 +95,7 @@ package() {
|
||||
cd "${pkgname}"
|
||||
install -Dm755 build/cthulhu "${pkgdir}/usr/bin/cthulhu"
|
||||
install -Dm755 build/cthulhu-braille "${pkgdir}/usr/bin/cthulhu-braille"
|
||||
install -Dm755 build/cthulhu-input-helper "${pkgdir}/usr/bin/cthulhu-input-helper"
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
install -Dm644 AUTHORS "${pkgdir}/usr/share/doc/${pkgname}/AUTHORS"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Experimental Remote keyboard IPC prerequisite
|
||||
|
||||
The protocol 1.1 host recognizes `remote.key`, with public Go types and matching
|
||||
Python validation. Reader code can explicitly attach a bounded incoming-key
|
||||
mailbox. No production input owner is attached: ordinary reader sessions return
|
||||
owner-unavailable, and the external Remote command still discards keyboard
|
||||
payloads. This is not desktop keyboard control.
|
||||
Python validation. A bounded incoming-key mailbox is attached in production
|
||||
for sessions installed with `keyboard-inject`, and the external Remote command
|
||||
forwards controller keys when keyboard control is approved for the session.
|
||||
See the production attachment section below; live acceptance is pending.
|
||||
|
||||
## Wire contract
|
||||
|
||||
@@ -116,7 +116,7 @@ original-epoch cleanup/process-exit barrier; unconfirmed retirement blocks
|
||||
replacement. The quarantined `app.run_remote_keyboard` worker now joins this
|
||||
supervisor to the authenticated mailbox with a required policy callback; see
|
||||
[its lifecycle and native evidence](testing/remote-input-helper.md#quarantined-leasemailbox-worker).
|
||||
No production reader launch or mailbox owner is attached.
|
||||
`start_remote_keyboard` is now its production caller; see below.
|
||||
|
||||
No capture event API, production Xorg input owner or supervised helper attachment,
|
||||
core escape/bypass integration or installed Remote keyboard command is
|
||||
@@ -130,3 +130,25 @@ existing speech regression harness, affected-package vet, a separate reader
|
||||
build and Tentacles `make check`. The lifecycle regression now checks malformed
|
||||
typed keys and keeps its unsupported-operation check on Braille. No full reader
|
||||
suite or native/hardware acceptance was performed; no active reader was replaced.
|
||||
|
||||
## Production attachment (2026-11-06)
|
||||
|
||||
Local launch and managed-session startup now call `start_remote_keyboard`
|
||||
for every session installed with `keyboard-inject`, next to the speech owner.
|
||||
It attaches the mailbox before the child becomes active and runs the existing
|
||||
lease/mailbox worker with one `remoteinput.Supervisor` retained for the reader
|
||||
lifetime, so the uncertainty latch survives session replacement. The helper is
|
||||
`cthulhu-input-helper` beside the reader executable; `build-cthulhu.sh` and both
|
||||
Arch packages now build and install it. A missing helper or a session without
|
||||
the grant leaves `remote.key` owner-unavailable and does not affect startup.
|
||||
|
||||
Delivery-time policy adds reader lock, XTerm-handoff suspension and active
|
||||
internal reader UI to the worker's lease, grant and deadline checks; a denied
|
||||
key revokes the original lease. A clean helper failure retires only that lease
|
||||
and is announced; unconfirmed cleanup is announced as requiring a reader
|
||||
restart. Real SDK-process tests cover delivery and lock-policy retirement,
|
||||
handoff denial before helper launch, and the no-grant and missing-helper cases.
|
||||
|
||||
This makes injection reachable from an approved controlled session. It does not
|
||||
add physical-collision safety, injection provenance, lock/AltGr policy or live
|
||||
acceptance, which remain required before keyboard control is declared ready.
|
||||
@@ -5,7 +5,7 @@ its own process. `internal/remoteinput` supplies bounded private IPC, a separate
|
||||
EOF lifeline, and a three-second heartbeat watchdog. The helper owns its X
|
||||
connection and stored destination-key ledger on one locked OS thread.
|
||||
|
||||
No production reader owner launches this helper or attaches `remote.key`.
|
||||
At this increment, no production reader owner launched this helper or attached `remote.key`. (Superseded 2026-11-06: see [production attachment](../tentacle-remote-keyboard.md#production-attachment-2026-11-06).)
|
||||
Remote's UI still offers experimental speech only and discards keyboard traffic.
|
||||
The ordinary build/install script still builds the reader and Braille helper;
|
||||
this additional executable is source-built by the private test harness only.
|
||||
@@ -164,12 +164,12 @@ and Control on a separate X connection before fresh-helper admission.
|
||||
This launcher is callable only as a quarantined prerequisite. Production reader
|
||||
startup does not construct it, the mailbox has no production native owner, and
|
||||
the normal build/install script still does not install the input helper. No native
|
||||
delivery-time policy or physical collision safety is implied.
|
||||
delivery-time policy or physical collision safety is implied. (Superseded 2026-11-06: see [production attachment](../tentacle-remote-keyboard.md#production-attachment-2026-11-06).)
|
||||
|
||||
### Quarantined lease/mailbox worker
|
||||
|
||||
`internal/app.run_remote_keyboard` now joins an explicitly enabled reader key
|
||||
mailbox to a retained Supervisor. It has no production caller. One worker owns
|
||||
mailbox to a retained Supervisor. At this increment it had no production caller. (Superseded 2026-11-06: see [production attachment](../tentacle-remote-keyboard.md#production-attachment-2026-11-06).) One worker owns
|
||||
one session's mailbox and serially sends its transitions; it creates no extra
|
||||
key queue. Host queued/in-flight budgets remain reserved until helper operation
|
||||
completion or the full cleanup barrier, including uncertain effects.
|
||||
|
||||
@@ -13,9 +13,10 @@ import (
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
// run_remote_keyboard is a quarantined mailbox owner, with no production caller.
|
||||
// The caller must retain one supervisor across sessions, including its permanent
|
||||
// uncertainty latch, and provide native focus/security policy without native I/O.
|
||||
// run_remote_keyboard is the incoming-key mailbox owner. start_remote_keyboard
|
||||
// is its production caller; it retains one supervisor across sessions,
|
||||
// including its permanent uncertainty latch, and provides native focus/security
|
||||
// policy without native I/O.
|
||||
// Policy invalidation must cancel ctx or the lease, including while idle. This
|
||||
// does not establish physical collision safety or trusted command routing.
|
||||
// Ending the owner disables its session, leaving no accepting orphan mailbox.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/remoteinput"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
const remoteInputHelperName = "cthulhu-input-helper"
|
||||
|
||||
// remote_input_supervisor returns the one Supervisor retained for the reader
|
||||
// lifetime, so its permanent uncertainty latch survives session replacement.
|
||||
// A missing helper leaves keyboard injection unavailable rather than failing
|
||||
// reader or Tentacle startup.
|
||||
func (runtime *tentacleRuntime) remote_input_supervisor() *remoteinput.Supervisor {
|
||||
runtime.mutex.Lock()
|
||||
defer runtime.mutex.Unlock()
|
||||
if runtime.remoteInput != nil || runtime.remoteInputChecked {
|
||||
return runtime.remoteInput
|
||||
}
|
||||
runtime.remoteInputChecked = true
|
||||
path := runtime.remoteInputHelper
|
||||
if path == "" {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
path = filepath.Join(filepath.Dir(executable), remoteInputHelperName)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 {
|
||||
runtime.logger.Info("remote keyboard helper unavailable", "helper", remoteInputHelperName)
|
||||
return nil
|
||||
}
|
||||
supervisor, err := remoteinput.NewSupervisor(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
runtime.remoteInput = supervisor
|
||||
return supervisor
|
||||
}
|
||||
|
||||
// start_remote_keyboard attaches the production incoming-key owner for a
|
||||
// session installed with keyboard-inject. Like speech, the mailbox exists
|
||||
// before the child becomes active; a session without the grant or a reader
|
||||
// without the helper keeps remote.key owner-unavailable. Delivery-time policy
|
||||
// rechecks lock, handoff suspension and internal reader UI, in addition to the
|
||||
// worker's lease, grant and deadline checks.
|
||||
func (runtime *tentacleRuntime) start_remote_keyboard(ctx context.Context, session *tentacle.Session) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
owner := runtime.output
|
||||
if owner == nil || owner.gate == nil || runtime.remotePolicy == nil ||
|
||||
!slices.Contains(session.RemoteGrants(), "keyboard-inject") {
|
||||
close(done)
|
||||
return done
|
||||
}
|
||||
supervisor := runtime.remote_input_supervisor()
|
||||
if supervisor == nil {
|
||||
close(done)
|
||||
return done
|
||||
}
|
||||
allow := func(delivery tentacle.RemoteKeyDelivery) bool {
|
||||
owner.gate.mutex.Lock()
|
||||
defer owner.gate.mutex.Unlock()
|
||||
return !owner.gate.Locked() && !owner.gate.Suspended() && !owner.gate.InternalUIActive()
|
||||
}
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := run_remote_keyboard(ctx, session, supervisor, allow)
|
||||
if err == nil || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, remoteinput.ErrUncertain) {
|
||||
runtime.logger.Warn("remote keyboard cleanup unconfirmed")
|
||||
runtime.report("Remote keyboard control stopped and cannot restart until Cthulhu restarts. Check for stuck keys.")
|
||||
return
|
||||
}
|
||||
runtime.report("Remote keyboard control stopped. Connect again to retry.")
|
||||
}()
|
||||
return done
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// 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"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
func keyboard_attach_runtime(policy *tentacle.RemotePolicy, helper string) *tentacleRuntime {
|
||||
return &tentacleRuntime{logger: slog.New(slog.DiscardHandler), remotePolicy: policy, remoteInputHelper: helper,
|
||||
output: &tentacleOutput{gate: &focusGate{}}, announcements: make(chan string, 1)}
|
||||
}
|
||||
|
||||
func keyboard_attach_key(t *testing.T, session *tentacle.Session, lease tentaclewire.RemoteLease, sequence uint64, name string, pressed bool) error {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
request := tentaclewire.RemoteKeyRequest{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence}, Key: tentaclewire.RemoteKey{Name: name, Pressed: pressed}}
|
||||
data, _ := json.Marshal(map[string]any{"Method": "remote.key", "Params": request})
|
||||
pending, err := session.Invoke(ctx, "rpc", data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case result := <-pending:
|
||||
return result.Err
|
||||
case <-ctx.Done():
|
||||
t.Fatal("keyboard peer call timed out")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func keyboard_attach_open(t *testing.T, session *tentacle.Session) tentaclewire.RemoteLease {
|
||||
t.Helper()
|
||||
approval, err := session.BeginRemoteApproval("controlled", []string{"keyboard-inject"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := session.AuthorizeRemoteWithApproval(approval, "controlled", []string{"keyboard-inject"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var lease tentaclewire.RemoteLease
|
||||
if json.Unmarshal(keyboard_owner_invoke(t, session, "open", nil), &lease) != nil || lease.Epoch == 0 {
|
||||
t.Fatal("keyboard peer failed to open lease")
|
||||
}
|
||||
return lease
|
||||
}
|
||||
|
||||
func TestRemoteKeyboardProductionAttachDeliversAndRetires(t *testing.T) {
|
||||
policy := tentacle.NewRemotePolicy()
|
||||
program, log := keyboard_owner_helper(t)
|
||||
session := keyboard_owner_session(t, policy, []string{"keyboard-inject", "output-observe"})
|
||||
runtime := keyboard_attach_runtime(policy, program)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := runtime.start_remote_keyboard(ctx, session)
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(6 * time.Second):
|
||||
t.Error("production keyboard owner did not join")
|
||||
}
|
||||
})
|
||||
lease := keyboard_attach_open(t, session)
|
||||
if err := keyboard_attach_key(t, session, lease, 1, "a", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := keyboard_attach_key(t, session, lease, 2, "a", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &keyboardOwnerFixture{log: log}
|
||||
prefix := strconv.FormatUint(lease.Epoch, 10) + " "
|
||||
f.wait(t, func() bool { return strings.Contains(f.effects(), prefix+"a false\n") })
|
||||
// Lock/handoff policy revokes the lease and releases the helper.
|
||||
policy.SetAvailable(false)
|
||||
f.wait(t, func() bool { return strings.Contains(f.effects(), prefix+"cleanup\n") })
|
||||
policy.SetAvailable(true)
|
||||
if runtime.remote_input_supervisor() != runtime.remoteInput || runtime.remoteInput == nil {
|
||||
t.Fatal("supervisor was not retained for the reader lifetime")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteKeyboardProductionAttachRechecksReaderUI(t *testing.T) {
|
||||
policy := tentacle.NewRemotePolicy()
|
||||
program, log := keyboard_owner_helper(t)
|
||||
session := keyboard_owner_session(t, policy, []string{"keyboard-inject"})
|
||||
runtime := keyboard_attach_runtime(policy, program)
|
||||
runtime.output.gate.suspended.Store(true) // XTerm handoff.
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := runtime.start_remote_keyboard(ctx, session)
|
||||
defer func() { cancel(); <-done }()
|
||||
lease := keyboard_attach_open(t, session)
|
||||
_ = keyboard_attach_key(t, session, lease, 1, "a", true)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := session.RemoteContext(lease, "keyboard-inject"); err != nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("suspended reader kept keyboard consent")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
f := &keyboardOwnerFixture{log: log}
|
||||
if strings.Contains(f.effects(), "a true") || strings.Contains(f.effects(), "starting") {
|
||||
t.Fatal("suspended reader delivered a remote key", f.effects())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteKeyboardProductionAttachRequiresGrantAndHelper(t *testing.T) {
|
||||
policy := tentacle.NewRemotePolicy()
|
||||
program, _ := keyboard_owner_helper(t)
|
||||
for name, test := range map[string]struct {
|
||||
grants []string
|
||||
helper string
|
||||
}{
|
||||
"no grant": {[]string{"output-observe"}, program},
|
||||
"missing helper": {[]string{"keyboard-inject"}, filepath.Join(t.TempDir(), "absent")},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
session := keyboard_owner_session(t, policy, test.grants)
|
||||
runtime := keyboard_attach_runtime(policy, test.helper)
|
||||
done := runtime.start_remote_keyboard(t.Context(), session)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("unavailable keyboard owner did not finish immediately")
|
||||
}
|
||||
if name == "missing helper" {
|
||||
lease := keyboard_attach_open(t, session)
|
||||
err := keyboard_attach_key(t, session, lease, 1, "a", true)
|
||||
if err == nil || errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("missing helper accepted a key: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -139,24 +139,29 @@ type keyboardOwnerFixture struct {
|
||||
log string
|
||||
}
|
||||
|
||||
func keyboard_owner_fixture(t *testing.T, configure ...func(*keyboardOwnerFixture)) *keyboardOwnerFixture {
|
||||
// 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()
|
||||
f := &keyboardOwnerFixture{log: filepath.Join(directory, "effects"), result: make(chan error, 1), policy: tentacle.NewRemotePolicy()}
|
||||
f.allowed.Store(true)
|
||||
t.Setenv("CTHULHU_KEY_OWNER_LOG", f.log)
|
||||
program := filepath.Join(directory, "helper")
|
||||
// The wrapper only adapts test-binary arguments. The real Supervisor still
|
||||
// owns launch, private pipes, heartbeats, ACK validation and process reaping.
|
||||
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)
|
||||
}
|
||||
f.supervisor, err = remoteinput.NewSupervisor(program)
|
||||
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)
|
||||
}
|
||||
@@ -171,21 +176,36 @@ func keyboard_owner_fixture(t *testing.T, configure ...func(*keyboardOwnerFixtur
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := manager.UseRemotePolicy(f.policy); err != nil {
|
||||
if err := manager.UseRemotePolicy(policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.session, err = manager.Start(t.Context(), tentacle.Spec{ID: "key-owner", Program: []string{executable, "-test.run=^TestRemoteKeyboardPeer$", "--", "remote-keyboard-peer"}, SessionEvents: true, RemoteGrants: []string{"keyboard-inject", "output-observe"}})
|
||||
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 <-f.session.Ready():
|
||||
case <-session.Ready():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("keyboard peer did not start")
|
||||
}
|
||||
if err := f.session.Activate(); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -136,9 +136,10 @@ func (owner *tentacleLifecycle) startPending() {
|
||||
})
|
||||
owner.watchers = append(owner.watchers, done)
|
||||
speechDone := owner.runtime.start_remote_speech(owner.ctx, session)
|
||||
keysDone := owner.runtime.start_remote_keyboard(owner.ctx, session)
|
||||
go func() {
|
||||
defer close(done)
|
||||
defer func() { <-speechDone }()
|
||||
defer func() { <-speechDone; <-keysDone }()
|
||||
select {
|
||||
case <-session.Revoked():
|
||||
case <-owner.ctx.Done():
|
||||
|
||||
+37
-32
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/remoteinput"
|
||||
"git.stormux.org/storm/cthulhu/internal/shortcutui"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
@@ -34,37 +35,40 @@ type tentacleCommand struct {
|
||||
// native publication; mutex protects dispatch snapshots and immediate disable.
|
||||
// Only explicitly granted presentation commands reach the core output owner.
|
||||
type tentacleRuntime struct {
|
||||
remotePolicy *tentacle.RemotePolicy
|
||||
readerSettings *preferencesWorker
|
||||
extensionChanges chan struct{}
|
||||
forms tentacleForms
|
||||
focusedItem func(context.Context) (tentaclewire.Item, error)
|
||||
interactionSlots chan struct{}
|
||||
registry *tentacle.Registry
|
||||
requests chan tentacleManagementCall
|
||||
snapshot tentacle.Snapshot
|
||||
guardianProgram []string // Private fixture seam; production uses this executable.
|
||||
output *tentacleOutput
|
||||
logger *slog.Logger
|
||||
input tentacleInput
|
||||
path, parent string
|
||||
newManager func() (*tentacle.Manager, error) // private fixture seam
|
||||
fetchRepository func(context.Context, string) (tentacle.Repository, error) // private fixture seam
|
||||
preparePackage func(context.Context, string, tentacle.Repository, string) (*tentacle.PreparedPackage, error) // private fixture seam
|
||||
repositoryBusy bool
|
||||
mutex sync.Mutex
|
||||
commands map[input.Command]tentacleCommand
|
||||
sessions []*tentacle.Session
|
||||
disabled bool
|
||||
cancel context.CancelFunc
|
||||
readerContext context.Context
|
||||
lifecycleContext context.Context
|
||||
done chan struct{}
|
||||
failures chan error
|
||||
announcements chan string
|
||||
stopError error
|
||||
results sync.WaitGroup
|
||||
resultSlots chan struct{}
|
||||
remotePolicy *tentacle.RemotePolicy
|
||||
remoteInput *remoteinput.Supervisor // Retained for the reader lifetime.
|
||||
remoteInputHelper string // Private fixture seam; production uses the reader's directory.
|
||||
remoteInputChecked bool
|
||||
readerSettings *preferencesWorker
|
||||
extensionChanges chan struct{}
|
||||
forms tentacleForms
|
||||
focusedItem func(context.Context) (tentaclewire.Item, error)
|
||||
interactionSlots chan struct{}
|
||||
registry *tentacle.Registry
|
||||
requests chan tentacleManagementCall
|
||||
snapshot tentacle.Snapshot
|
||||
guardianProgram []string // Private fixture seam; production uses this executable.
|
||||
output *tentacleOutput
|
||||
logger *slog.Logger
|
||||
input tentacleInput
|
||||
path, parent string
|
||||
newManager func() (*tentacle.Manager, error) // private fixture seam
|
||||
fetchRepository func(context.Context, string) (tentacle.Repository, error) // private fixture seam
|
||||
preparePackage func(context.Context, string, tentacle.Repository, string) (*tentacle.PreparedPackage, error) // private fixture seam
|
||||
repositoryBusy bool
|
||||
mutex sync.Mutex
|
||||
commands map[input.Command]tentacleCommand
|
||||
sessions []*tentacle.Session
|
||||
disabled bool
|
||||
cancel context.CancelFunc
|
||||
readerContext context.Context
|
||||
lifecycleContext context.Context
|
||||
done chan struct{}
|
||||
failures chan error
|
||||
announcements chan string
|
||||
stopError error
|
||||
results sync.WaitGroup
|
||||
resultSlots chan struct{}
|
||||
}
|
||||
|
||||
func newTentacleRuntime(logger *slog.Logger, commands commandListener, path, parent string) *tentacleRuntime {
|
||||
@@ -185,10 +189,11 @@ func (runtime *tentacleRuntime) run(ctx context.Context) error {
|
||||
runtime.mutex.Unlock()
|
||||
current, ready = session, session.Ready()
|
||||
speechDone := runtime.start_remote_speech(ctx, session)
|
||||
keysDone := runtime.start_remote_keyboard(ctx, session)
|
||||
watchers.Add(1)
|
||||
go func() {
|
||||
defer watchers.Done()
|
||||
defer func() { <-speechDone }()
|
||||
defer func() { <-speechDone; <-keysDone }()
|
||||
select {
|
||||
case <-session.Revoked():
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -26,10 +26,10 @@ var (
|
||||
const operationTimeout = time.Second
|
||||
const retirementTimeout = Watchdog + time.Second
|
||||
|
||||
// Supervisor is a quarantined reader-side process owner. It grants no consent
|
||||
// and is not attached to the production key mailbox. A future owner must bind
|
||||
// ctx/epoch to an approved lease and recheck native policy before every Apply.
|
||||
// Unconfirmed cleanup permanently blocks replacement on this Supervisor.
|
||||
// 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
|
||||
|
||||
@@ -99,16 +99,17 @@ class BuildScriptTests(unittest.TestCase):
|
||||
self.env["GOTOOLCHAIN"] = "local"
|
||||
result = self.run_build("--output-dir", "output directory", "--", "-trimpath")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
for binary in ("cthulhu", "cthulhu-braille"):
|
||||
for binary in ("cthulhu", "cthulhu-braille", "cthulhu-input-helper"):
|
||||
self.assertTrue((self.caller / "output directory" / binary).is_file())
|
||||
builds = [call for call in self.calls("go") if call[1][0] == "build"]
|
||||
self.assertEqual(len(builds), 2)
|
||||
self.assertEqual(len(builds), 3)
|
||||
for call in builds:
|
||||
self.assertIn("-trimpath", call[1])
|
||||
self.assertEqual(call[2], str(self.project))
|
||||
for name in ("GOTOOLCHAIN", "GOFLAGS", "CGO_CFLAGS", "GOMODCACHE"):
|
||||
self.assertEqual(call[3][name], self.env[name])
|
||||
self.assertIn("brlapi", builds[1][1])
|
||||
self.assertNotIn("brlapi", builds[2][1])
|
||||
self.assertFalse(self.calls("curl"))
|
||||
|
||||
def test_old_go_bootstrap_and_cache_reuse(self):
|
||||
@@ -120,7 +121,7 @@ class BuildScriptTests(unittest.TestCase):
|
||||
result = self.run_build()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(len(self.calls("curl")), 2)
|
||||
self.assertEqual(len([c for c in self.calls("bootstrap-go") if c[1][0] == "build"]), 4)
|
||||
self.assertEqual(len([c for c in self.calls("bootstrap-go") if c[1][0] == "build"]), 6)
|
||||
|
||||
def test_checksum_failure_never_executes_download(self):
|
||||
self.env.update(INSTALLED_GO="1.20.14", ARCHIVE_SHA="0" * 64)
|
||||
|
||||
Reference in new issue
Block a user