diff --git a/build-cthulhu.sh b/build-cthulhu.sh index 78f32aa..e08d258 100755 --- a/build-cthulhu.sh +++ b/build-cthulhu.sh @@ -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" diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index 09aa608..6e05c05 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -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" } diff --git a/distro-packages/Arch-Linux/cthulhu/PKGBUILD b/distro-packages/Arch-Linux/cthulhu/PKGBUILD index 6f1411b..3f81fc0 100644 --- a/distro-packages/Arch-Linux/cthulhu/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu/PKGBUILD @@ -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" } diff --git a/docs/tentacle-remote-keyboard.md b/docs/tentacle-remote-keyboard.md index 748d326..9883819 100644 --- a/docs/tentacle-remote-keyboard.md +++ b/docs/tentacle-remote-keyboard.md @@ -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. diff --git a/docs/testing/remote-input-helper.md b/docs/testing/remote-input-helper.md index cfe0178..6b96a25 100644 --- a/docs/testing/remote-input-helper.md +++ b/docs/testing/remote-input-helper.md @@ -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. diff --git a/internal/app/tentacle_remote_keyboard.go b/internal/app/tentacle_remote_keyboard.go index 52abc88..a6ef013 100644 --- a/internal/app/tentacle_remote_keyboard.go +++ b/internal/app/tentacle_remote_keyboard.go @@ -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. diff --git a/internal/app/tentacle_remote_keyboard_owner.go b/internal/app/tentacle_remote_keyboard_owner.go new file mode 100644 index 0000000..d64c9cb --- /dev/null +++ b/internal/app/tentacle_remote_keyboard_owner.go @@ -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 +} diff --git a/internal/app/tentacle_remote_keyboard_owner_test.go b/internal/app/tentacle_remote_keyboard_owner_test.go new file mode 100644 index 0000000..4b9b4fc --- /dev/null +++ b/internal/app/tentacle_remote_keyboard_owner_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/app/tentacle_remote_keyboard_test.go b/internal/app/tentacle_remote_keyboard_test.go index 942700d..e364a56 100644 --- a/internal/app/tentacle_remote_keyboard_test.go +++ b/internal/app/tentacle_remote_keyboard_test.go @@ -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) } diff --git a/internal/app/tentacle_startup.go b/internal/app/tentacle_startup.go index 20fc43a..1e0938f 100644 --- a/internal/app/tentacle_startup.go +++ b/internal/app/tentacle_startup.go @@ -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(): diff --git a/internal/app/tentacles.go b/internal/app/tentacles.go index b19d19f..9117d6e 100644 --- a/internal/app/tentacles.go +++ b/internal/app/tentacles.go @@ -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(): diff --git a/internal/remoteinput/supervisor.go b/internal/remoteinput/supervisor.go index b97e5fd..fbc64be 100644 --- a/internal/remoteinput/supervisor.go +++ b/internal/remoteinput/supervisor.go @@ -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 diff --git a/tests/test_build_script.py b/tests/test_build_script.py index 30ee2ab..1b2da6b 100644 --- a/tests/test_build_script.py +++ b/tests/test_build_script.py @@ -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)