diff --git a/README.md b/README.md index 5601a02..97fdee1 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ and caches it under `${XDG_CACHE_HOME:-$HOME/.cache}/cthulhu/toolchains`. It does not replace system Go or require root. Install a C compiler, `pkg-config`, and the development libraries for GTK3, -AT-SPI, GLib/GIO, GStreamer, X11, XTest, XFixes, XRes, xkbcommon, liblouis, -and BRLTTY/BrlAPI first. The script checks the pkg-config dependencies; +AT-SPI, GLib/GIO, GStreamer, X11, XTest, libXi, XFixes, XRes, xkbcommon, +liblouis, and BRLTTY/BrlAPI first. The script checks the pkg-config dependencies; BrlAPI headers and its library are checked by the compiler during the build. Bootstrapping Go additionally needs `curl`, `tar`, and `sha256sum`. See [Contributing](CONTRIBUTING.md#development-and-architecture) and the @@ -508,6 +508,17 @@ Stale native placement gets one refresh and retry at the same target and offset. Braille routing also recognizes the temporary caret produced by clearing a selection, so it cannot reset the routed reading position. +Browser Braille expands embedded links and preserves that text when native +caret events repair line offsets. If a descendant read fails or reaches its +expansion limit, a clean accessible name can recover a range covering the whole +object. Wrapped line fragments retain their own content. Names are also stripped +of embedded-object placeholders before calculating Braille label offsets. +After rebuilding, check H/Shift+H in Firefox and Chromium on linked headings, +headings containing linked images, and wrapped headings. Compare speech with +Braille, then check arrow reading, selection, panning and ordinary text routing. +Expanded text has no native cell routes because its offsets differ from the +browser's raw text. + Reading a final ellipsis avoids the provider's native line-boundary request: Chromium can calculate a boundary past truncated text and abort the browser. At that character, Cthulhu reads a bounded logical line using explicit line diff --git a/build-cthulhu.sh b/build-cthulhu.sh index e18e015..78f32aa 100755 --- a/build-cthulhu.sh +++ b/build-cthulhu.sh @@ -66,7 +66,7 @@ export CGO_ENABLED=${CGO_ENABLED:-1} [[ $CGO_ENABLED == 1 ]] || fail "Cthulhu requires CGO_ENABLED=1 and a C compiler." command -v pkg-config >/dev/null || fail "Install pkg-config and the development libraries listed in CONTRIBUTING.md." missingLibraries=() -for library in gtk+-3.0 atspi-2 gio-2.0 gstreamer-1.0 x11 xtst xfixes xres xkbcommon liblouis; do +for library in gtk+-3.0 atspi-2 gio-2.0 gstreamer-1.0 x11 xtst xi xfixes xres xkbcommon liblouis; do if ! pkg-config --exists "$library"; then missingLibraries+=("$library") fi diff --git a/cmd/tentacle-host/main.go b/cmd/tentacle-host/main.go index 4a50a6c..b30035e 100644 --- a/cmd/tentacle-host/main.go +++ b/cmd/tentacle-host/main.go @@ -11,6 +11,7 @@ import ( "flag" "fmt" "os" + "strings" "time" "git.stormux.org/storm/cthulhu/internal/tentacle" @@ -29,6 +30,8 @@ func run() (runError error) { presentation := options.Bool("presentation", false, "grant command speech/braille result capability (prints JSON only)") interactive := options.Bool("interaction", false, "grant internal form/clipboard/focused-item capability (prints initial JSON only)") readerSettings := options.Bool("reader-settings", false, "grant declarative reader settings capability (prints initial JSON only; does not modify a reader)") + sessionEvents := options.Bool("session-events", false, "negotiate protocol 1.1 lifecycle transport without desktop services") + remoteGrants := options.String("remote-grants", "", "comma-separated install grants for protocol 1.1; grants do not issue session consent") command := options.String("command", "echo", "registered command to invoke") if err := options.Parse(os.Args[1:]); err != nil { return err @@ -53,7 +56,11 @@ func run() (runError error) { }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := manager.Start(ctx, tentacle.Spec{ID: "local-example", Program: options.Args(), Presentation: *presentation, Interaction: *interactive, ReaderSettings: *readerSettings}) + var grants []string + if *remoteGrants != "" { + grants = strings.Split(*remoteGrants, ",") + } + session, err := manager.Start(ctx, tentacle.Spec{SessionEvents: *sessionEvents, RemoteGrants: grants, ID: "local-example", Program: options.Args(), Presentation: *presentation, Interaction: *interactive, ReaderSettings: *readerSettings}) if err != nil { return err } diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index 0569f03..09aa608 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -33,6 +33,7 @@ depends=( libx11 libxres libxtst + libxi libxfixes libxkbcommon xclip diff --git a/distro-packages/Arch-Linux/cthulhu/.SRCINFO b/distro-packages/Arch-Linux/cthulhu/.SRCINFO index 9569ee9..76ad8ed 100644 --- a/distro-packages/Arch-Linux/cthulhu/.SRCINFO +++ b/distro-packages/Arch-Linux/cthulhu/.SRCINFO @@ -24,6 +24,7 @@ pkgbase = cthulhu depends = libx11 depends = libxres depends = libxtst + depends = libxi depends = libxfixes depends = libxkbcommon depends = xclip diff --git a/distro-packages/Arch-Linux/cthulhu/PKGBUILD b/distro-packages/Arch-Linux/cthulhu/PKGBUILD index ee1fd11..6f1411b 100644 --- a/distro-packages/Arch-Linux/cthulhu/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu/PKGBUILD @@ -30,6 +30,7 @@ depends=( libx11 libxres libxtst + libxi libxfixes libxkbcommon xclip diff --git a/docs/tentacle-remote-authorization.md b/docs/tentacle-remote-authorization.md new file mode 100644 index 0000000..8e71ce6 --- /dev/null +++ b/docs/tentacle-remote-authorization.md @@ -0,0 +1,134 @@ +# Reader-owned Remote authorization + +Implementation increment: 2026-11-03. The reader now has a core consent path, +device-aware sensitive forms, and lock/handoff cancellation. There is still no +registered Remote connection command or reader desktop service. This is not a +usable Remote release, and physical-keyboard approval remains unverified. +Base revisions: reader `testing` at `573d15d`, Tentacles `master` at `63e84e9`; +both still matched their maintained remote tips when checked for this increment. + +## Core ownership and lifetime + +Every production Tentacle manager is bound to the focus gate's `RemotePolicy`. +An I38lock secure-input transition or XTerm handoff synchronously cancels the +current policy context. Restoring availability creates a new context. Neither +unlock nor returning from XTerm revives an old dialog, action or lease. + +The cancellation path takes no session, registry or output lock. Existing +delivery contexts become invalid before session event cleanup can acquire its +lock. Cleanup checks the original access object before retiring it; a delayed +callback cannot revoke a newer epoch. An already-running open/heartbeat can race +with a response, but it cannot restore the cancelled delivery context. Future +desktop owners must still recheck context and their own policy at delivery. + +The core `tentacleRuntime.authorize_remote` procedure resolves an active installed +session and role, checks its install grants, reserves the interaction slot, and +captures a session-bound `RemoteApproval` **before** opening the consent form. +The form focuses Cancel initially and leaves every permission checkbox unchecked. +It describes direction, relay visibility and the limits of peer identities. +Only explicit approval of a nonempty subset issues the existing `remote.action`. +The user can decline keyboard and clipboard permissions independently. + +Tickets are core objects, not RPC parameters. They bind the session, role, +requested grant ceiling and availability lifetime. Reuse, another session, +changed role, increased grants and an intervening lock/handoff are denied. +Opening a replacement consent dialog retires previous access. Cancel, timeout, +session failure/disable and package revocation cancel pending consent too. +In particular, failed update preparation must not leave an earlier dialog able +to issue a new action. The old unrestricted `AuthorizeRemote` hook is rejected +by policy-bound managers; plain development hosts retain it for protocol tests. + +No child RPC, install grant, certificate review, saved key, relay event or +injected shortcut is itself consent. Connection command registration and joining +the external workflow to this core procedure remain the next integration work. + +## Sensitive form input + +`internalUI.OpenLocal` uses `CommandListener.BeginLocalUI`, a separate XI2 +keyboard grab on the existing Xlib owner thread. Unsupported backends fail +without falling back to ordinary forms. Ordinary forms retain their core input +path, bindings, learn mode, text editor and clipboard behavior. + +Local capture derives the master keyboard from the client's pointer attachment; +it does not assume device IDs. It admits an event only when: + +- It is the actual XI2 key event for this capture's master keyboard, rather than + a core event, an XRecord observation, or a SendEvent event. +- Its enabled source is an attached slave keyboard with no `XTEST Device` + property. The atom must exist on the server; unknown capability is denied. +- Its `Device Node` is a bounded `/dev/input/event` path naming a character + device. Sources without a device node remain ineligible. +- It is a fresh transition in this capture, rather than repeat, duplicate down + or unmatched release. A key already held before capture prevents acquisition. + +Source metadata is checked on each transition. Device names and timestamp +matching play no role. Modifier state is reconstructed from admitted keys, +so an injected Shift cannot reverse a local Tab action. Rejected keys do not +enter the chord router, compose renderer, fields, clipboard actions or approval. +Hierarchy changes cancel capture and retire held-key bookkeeping, including +devices whose releases can no longer arrive. X errors during source queries, +capture acquisition or release deny access rather than terminating the reader. + +The X server marks XTEST devices using a non-deletable property whose setter +rejects changes; see the [server implementation](https://github.com/XQuartz/xorg-server/blob/master/Xext/xtest.c). +This protects routing from the planned core XTEST injection path. It is **not** +an OS security boundary against malicious same-user X clients, privileged device +creation, virtual evdev devices, or a compromised X server. A device node alone +does not prove physical hardware. Nested servers, Bluetooth keyboards, layouts, +accessibility input devices and actual hot unplug still require acceptance work. +No Wayland input provenance is claimed. +The production injection owner must retain XTEST virtual-device sourcing; it +must not retarget fake input to a physical source device. + +All transient forms from a Tentacle with remote grants use this path, including +certificate trust and Remember/Forget key forms. Their interaction lifetime is +bound to the original policy context, so a short lock/unlock transition cannot +preserve them even if native polling misses it. Ordinary Tentacle forms remain +unchanged. No public form schema flag lets a child choose its own trust level. + +## Verification and remaining acceptance + +From the reader checkout: + +```sh +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/tentacle ./internal/tentaclewire ./internal/interaction ./sdk/... -count=1 -timeout=240s +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/app -run 'Tentacle|InternalUI|ScreenLock|XTermHandoff|Remote|CommandLoop' -count=1 -timeout=180s +python3 -B tests/native_remote_input.py +python3 tests/native_internal_ui.py +python3 tests/test_build_script.py -v +``` + +The native input harness uses private Xvfb/D-Bus, ten recovery experiments and +six native Go race tests. It rejects injected navigation, submission, entry, +paste and Escape, rejects pre-held approval, cancels on hierarchy and secure-input +changes, restores ordinary forms, and retains learn-mode and XTerm handoff +regressions. It requires `xinput` as well as the existing X11 test tools. +The separate internal-form harness covers real ordinary/secret entry, action +menus, emoji and clipboard behavior. Neither harness uses the active reader. + +Both SDK process fixtures cover policy cancellation of pending actions and live +leases, stale open/heartbeat denial, revocation events, single-use tickets and +fresh approval after resuming. The app fixture verifies unchecked grants stay +denied and dialogs cannot survive revocation. A held session lock demonstrates +that effect-context cancellation does not wait for worker/event cleanup. + +Existing host/wire/interaction race suites, focused application regressions, +vet, reader build, shellcheck, build-script tests and the external Tentacles +repository's `make check` passed. The complete host race suite took about 124 +seconds. libXi is now an explicit build/package dependency; no package was +installed, published or reloaded. + +Before exposing the connection command, verify actual local keyboard navigation, +approval, cancellation and modifier handling through this capture path on Xorg. +Then exercise certificate review and Remember key with physical input. During +lock and XTerm handoff, verify immediate disconnect, no revival on return, and +no stale trust/approval after a failed update. Recheck ordinary entries, secret +masking/paste, learn mode, remapped commands and application key delivery. +Those manual checks need an exposed connection workflow first; there is still +no Remote desktop session to launch from this increment. + +The current lock integration follows the reader's existing I38lock secure mode. +Other lockers/session switches are not established by this work. Positive +hardware-origin acceptance, audible speech, physical Braille, input injection +recovery/collision handling, typed desktop services and two-desktop/NVDA/Orca +interoperability remain open. Do not mark Phase B or any release gate complete. diff --git a/docs/tentacle-secret-entries.md b/docs/tentacle-secret-entries.md new file mode 100644 index 0000000..b4e0d9f --- /dev/null +++ b/docs/tentacle-secret-entries.md @@ -0,0 +1,77 @@ +# Secret entries in Tentacle forms + +Transient interaction forms support `secret: true` on an entry (`kind: 2`). +The Go SDK exposes it as `tentacle.Element.Secret`; Python commands return the +same JSON property in their form dictionaries. For example: + +```go +tentacle.Element{ + ID: "key", Label: "Channel key", Kind: tentacle.Entry, + Secret: true, Required: true, MaxLength: 512, +} +``` + +```python +{"id": "key", "label": "Channel key", "kind": 2, + "secret": True, "required": True, "max_length": 512} +``` + +These entries use the existing host-owned, focus-free editor. They do not create +a GTK entry or publish a plaintext accessible text object. Keyboard capture +leaves application focus in place, and the reader presents the form directly. +Ordinary key echo and review commands remain suppressed during modal capture. +Only the masked presentation reaches the speech/Braille output paths, including +their debug-output logging. Field labels and titles are ordinary public text; +never put credentials in them, command descriptions, or status messages. + +On focus, speech announces the label, “password edit”, and character count. +Typing and pasting announce inserted character counts; deletion announces the +deleted count. Cursor movement says “Hidden character” or “End of text”. Braille +shows one asterisk per Unicode code point and a corresponding caret position. +Character counts, selection length and caret position are intentionally visible. +This is code-point editing, like the existing ordinary entry, not grapheme editing. + +Ctrl+A selects all. Ctrl+C and Ctrl+X announce that copy and cut are unavailable, +without changing the selection, value or clipboard. Ctrl+V explicitly reads the +clipboard and inserts valid single-line text within the existing length limit. +Tab and Shift+Tab traverse normally. Enter submits and Escape cancels. + +Successful submission delivers the real value in `values["key"].text` to the +registered continuation. Cancellation, capture revocation and failed capture +closure deliver no values. The editor drops its references to secret strings +when it closes; Go does not guarantee memory erasure. The receiving Tentacle is +responsible for using the submitted secret without logging or presenting it. +This feature does not sandbox the Tentacle or change the interaction grant. + +Secret fields are rejected on non-entry controls and on persistent preference +pages, including page-value validation. Generic reader settings storage is not +a credential store. Malformed flags (including null, non-booleans, duplicate +keys and case variants) fail validation. Omitting `secret` preserves ordinary +entry behavior. Older readers reject the unknown property rather than treating +the control as a normal entry. There is no protocol 1.1 negotiation or minimum +reader-version gate in this increment; Remote remains unpackageable until those +interfaces exist. Never retry a rejected secret form as an ordinary entry. + +## Verification + +Focused automated checks: + +```sh +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/interaction ./internal/tentaclewire ./internal/tentacle ./sdk/... -count=1 +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/app -run 'TestInternalUI|TestReaderTentacle' -count=1 +python3 tests/native_internal_ui.py +``` + +The private Xvfb/D-Bus harness tests actual key capture, masked speech requests, +copy/cut denial, paste, submission, unchanged application focus, and restored +typing. Unit checks cover masked Braille frames and caret positions, Unicode +editing, malformed flags, cancellation/revocation/error cleanup and both SDKs +across real child-process IPC. The existing ordinary entry, action menu, emoji +picker and clipboard tests remain part of the native harness. + +These checks do not establish audible output or physical Braille acceptance. +Remote has no connection UI to launch yet. When a consumer is integrated, its +live test must check the announced label/count, typing and deletion without +plaintext echo, masked Braille and caret, selection/copy/cut/paste, Tab/Shift+Tab, +Escape, and typing into the original application after the form closes. Existing +Wormhole ordinary entries must retain their text echo and copy/cut behavior. diff --git a/docs/tentacle-sessions.md b/docs/tentacle-sessions.md new file mode 100644 index 0000000..15504ec --- /dev/null +++ b/docs/tentacle-sessions.md @@ -0,0 +1,185 @@ +# Experimental Tentacle session protocol 1.1 + +Implemented on 2026-11-03 against reader `a47253d697c1aa44dea80646cad99a4fe163c7f1` +with local Remote prerequisite changes, and Tentacles +`bc305993b8ad5c2374c8fc2c8b886c9cd6697b52`. Both branches were synchronized before +this increment. These are base revisions, not commits containing the new work. + +This increment supplies negotiation, bounded bidirectional IPC, lifecycle events, +install grants, and an inert remote lease service. It does **not** supply desktop +remote control. A later [reader authorization increment](tentacle-remote-authorization.md) +adds the core consent procedure, sensitive XI2 forms and lock/handoff policy. +Its connection command remains unregistered and physical-keyboard approval is +unverified. There is no output observer, keyboard service, clipboard service, +or Braille session service. The unrestricted `Session.AuthorizeRemote` hook is +limited to development managers; reader managers require a core approval ticket. +Packaging Remote itself remains premature. + +## Negotiation and installation + +Existing protocol 1.0 packages, launch descriptions and SDK `Serve`/`serve` calls +keep the exact ordered API prefix and mirrored initialize capabilities. They do +not receive events and may not send requests to the host. + +Protocol 1.1 is explicit. A native package declares `major=1`, `minor=1` and APIs +containing `commands` and `session-events`. Optional APIs are `presentation`, +`interaction`, `reader-settings` and `remote-access`. API order does not matter; +duplicates, unknown entries and missing dependencies are rejected. Interaction +requires presentation; reader settings require interaction. Remote access requires +at least one remote grant, and every remote grant requires remote access. + +In 1.1 `capabilities` has the same meaning in the manifest, catalog and initialize +message: install permissions. It contains the requested presentation, interaction +and reader-settings grants, plus independent remote grants: + +| Grant | Direction and intended use | +| --- | --- | +| `output-observe` | Controlled reader shares local policy-approved output. | +| `remote-present` | Controller presents output received from the controlled reader. | +| `keyboard-capture` | Controller consumes and forwards its local keyboard. | +| `keyboard-inject` | Controlled reader accepts remote key transitions. | +| `braille-stream` | Exchange display cells and supported Braille gestures. | +| `clipboard-exchange` | Explicit send and separately consented receive. | + +`commands`, `session-events` and `remote-access` are API names, not capability +strings in 1.1. The installer checks manifest/catalog agreement and persists +`session-events` and `remote-grants` in the registry. Local launch `Spec` uses +`SessionEvents` and `RemoteGrants`. The existing presentation/interaction/reader +settings fields retain their meanings. Snapshot and launch copies own their grant +slices. Install and update confirmations disclose directions and the requirement +for fresh local session approval. Older readers reject the package protocol; +there is no fallback to 1.0. Grant changes still use the normal reviewed update +transaction and existing process disable/restart behavior. Remote access is revoked +before update download/preparation, so failed preparation cannot preserve a lease. +Disable/remove/retry/local-update requests also revoke access before persistence +or queueing; rollback never recreates consent. + +An install grant never creates session consent. Reader desktop services are not +implemented by this increment, even if all grants are declared. + +## Public SDK entry points + +Go provides `NewSessionClient`, `ServeSession`, `SessionClient.Events`, and +`SessionClient.Call(ctx, method, params, result)`. Public aliases expose +`SessionEvent`, `RemoteOpen`, `RemoteLease` and `RemoteOperation`; callers need no +reader-internal imports. `SessionError.Code` is available for handling failures; +its error string omits the host's message. Clients are single-use. + +Python exports `SessionClient` and `SessionError`. Pass a client as +`serve(commands, session_client=client)`, call +`client.call(method, params, timeout=2)`, and consume lifecycle events through +`client.next_event()`. The Python 1.1 transport requires owned binary streams with +file descriptors. The implementation uses nonblocking pipes and a wake pipe so +shutdown can interrupt a stalled read or write. Go adopts pollable duplicates of +owned file streams for the same reason. Both still support existing command +handlers alongside session calls. + +Use one event consumer, dispatch short bounded work, and keep network work off +the receive loop. Go event consumers must handle channel closure; Python returns +`None` on shutdown. Workers must stop when the serving context/session is +cancelled. Events already taken from a queue do not prove a lease remains valid. + +## Framing, ownership and limits + +Frames remain UTF-8 newline-delimited JSON-RPC 2.0, at most 64 KiB excluding LF. +Duplicate fields and excessive JSON depth remain invalid. Host request IDs retain +canonical increasing decimal strings. Child request IDs are canonical increasing +`c:` strings. Both directions correlate replies independently. Duplicate, +unknown or wrong-namespace replies and unnegotiated notifications retire the +process session. Child notifications are not supported. + +The host answers lifecycle requests directly with bounded work; it never waits +on a command handler or invokes an external service under the session mutex. +Each endpoint has one writer, a 32-frame queue and a 256 KiB aggregate byte limit +including the in-flight write. The SDK permits eight outstanding child calls; +the host retains its existing command-call limits. Each SDK has a 32-event queue. +Event/control overflow closes the process session, immediately cancelling leases. +It does not discard individual control messages to keep a session running. + +Session request parameters are limited to 8 KiB. Calls have a maximum two-second +deadline; timeout/cancellation retires the SDK session rather than accumulating +abandoned work. Go also respects a shorter caller context; Python accepts a +shorter explicit timeout. Shutdown/EOF wakes pending calls and event consumers. +The process supervisor still enforces cleanup for a child ignoring cancellation. + +These are lifecycle queues only. Separate key/speech queues, latest-frame Braille +coalescing, typed effect validation, and their delivery-time owner checks remain +unimplemented. Do not send those payloads through lifecycle events. + +## Implemented lifecycle operations + +`session.event` is a host notification whose parameters are a `SessionEvent`. +Its sequence starts at one and increases exactly once per event. Supported kinds: + +- `remote.action`: sequence, kind, action token, host-issued epoch, role and approved + grant subset. Created only by the internal core authorizer. Approval expires + after 30 seconds if not consumed. No network secret is included. +- `remote.revoked`: sequence, kind, lease, epoch and reason (`closed`, `expired`, + `local`, `replaced`). Failure to enqueue revocation retires the session; it + cannot block immediate cancellation. An unconsumed approval expires silently. + +Child calls: + +| Method | Parameters | Result | +| --- | --- | --- | +| `remote.open` | `action`, `epoch`, `role`, `grants` | `lease`, `epoch` | +| `remote.heartbeat` | `lease`, `epoch`, `sequence` | `{}` | +| `remote.close` | `lease`, `epoch`, `sequence` | `{}` | + +The action token must match the unconsumed core approval. Role must match exactly, +and grants must be a nonempty subset of the approved install grants. Controller +roles cannot obtain output-observe/keyboard-inject; controlled roles cannot +obtain remote-present/keyboard-capture. A role cannot be changed with heartbeat. + +The manager admits one remote owner, including pending approval. Opening consumes +the token and creates a random 128-bit lease ID. Operations use strictly increasing +positive sequence numbers. Heartbeat should be sent each second and expires after +three seconds without refresh. Expiry revokes the lease independently of command +workers and pipe writes. A late heartbeat cannot revive it. A new local action +revokes old access and advances the core epoch. Closing an already retired epoch +is idempotent and cannot revoke a newer one. + +Core `RemoteContext(lease, grant)` returns a cancellation context only for a live +matching lease with that grant. Close, expiry, process failure, disable and parent +cancellation invalidate buffered work. Future effect owners must also enforce +origin, focus and ordinary output policy at delivery; the reader's policy now +cancels these contexts on its existing lock/handoff transitions. A context alone +does not implement all effect-owner policies. No production effects currently use it. + +Errors are generic and contain no parameters: `-32001` permission denied, +`-32602` invalid parameters, `-32601` unsupported operation, `-32603` unavailable. +State/focus/presentation/key/Braille/clipboard methods deliberately return +unsupported-operation errors. Malformed envelopes, replayed IDs or frame/queue +violations terminate the process session instead of attempting recovery. + +## Verification + +Real child-process conformance fixtures cover both SDKs: bidirectional requests +while another command is blocked, action/event delivery, denied consent, wrong +role/grants, replay, stale epochs, close idempotence, heartbeat expiry, exclusive +ownership and disable. Host/SDK malformed-message tests cover unnegotiated events, +bad IDs, unknown methods, bad sequences and event overflow. Stalled-reader and +stalled-writer tests verify that call timeout exits the child without host stdin +closure. Queue tests cover the count and aggregate byte ceilings and synchronous +revocation on overflow. Package tests cover valid persistence and rejected API, +grant and version combinations. + +Focused checks: + +```sh +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/tentaclewire ./internal/tentacle ./internal/preferences ./sdk/... -count=1 -timeout=240s +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/app -run 'Tentacle|Upgrade|Update|RemoteLease' -count=1 -timeout=180s +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go vet ./internal/tentaclewire ./internal/tentacle ./internal/preferences ./sdk/... ./cmd/tentacle-host +``` + +Private GTK checks use `tests/native_preferences.py` with respectively +`CTHULHU_PREFS_TENTACLES_ONLY=1`, `CTHULHU_PREFS_TENTACLE_UPDATES_ONLY=1`, +`CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY=1`, and `CTHULHU_PREFS_EXTENSIONS_ONLY=1`. +The individual `native_tentacle_*.py` files are imported helpers, not standalone +harnesses. Tests use a separately built binary selected by `CTHULHU_BINARY`, +private Xvfb and private D-Bus; they do not replace the running reader. + +There is no Remote connection UI to test live yet. Physical keyboard recovery, +audible speech, physical Braille, real NVDA/Orca sessions and installed Remote +package behavior remain unverified. Human diff review and relevant live acceptance +are still required before submission. diff --git a/docs/testing/remote-braille-feasibility.md b/docs/testing/remote-braille-feasibility.md new file mode 100644 index 0000000..5214122 --- /dev/null +++ b/docs/testing/remote-braille-feasibility.md @@ -0,0 +1,122 @@ +# Remote Braille rendering feasibility + +Recorded 2026-11-03 against reader `testing` base +`a47253d697c1aa44dea80646cad99a4fe163c7f1` plus the working-tree changes; +liblouis 3.39.0. This is a Phase A prerequisite, not an available Remote feature. + +## Result and implementation + +The existing helper viewport can translate, pan and route a separate virtual +display without opening BrlAPI or requiring a controlled-side physical display. +`internal/braille/cells.go` adds an internal cell-export operation which shares +the physical writer's Unicode Braille and attribute-mask conversion. The +existing physical writer now calls that shared conversion with the same inputs +and masks as before. There is no new helper mode, reader command, session API, +network connection or installed binary in this increment. + +The native tests use real liblouis translation, the existing `viewport.setFrame`, +`pan` and `route` methods, and the new `rawCells` method. They never construct or +open a BrlAPI backend. Each viewport has its own width, offset, pan history, +cursor, frame identity and source-position map. One owner must serialize access +to each viewport; these tests do not establish concurrent liblouis safety. + +The eight new tests establish: + +- Exact mask conversion for all 256 dot patterns, including eight-dot cells; + attribute bits are ORed in without allowing BRLTTY to translate the raw cell. +- Existing ordinary text retains its BRLTTY translation masks. Invalid UTF-8 + and mismatched indicator lengths are rejected. +- A separate four-cell virtual viewport and eight-cell local viewport can share + source text while keeping independent wrapping, panning, routing and width. +- Real contracted output for `the cat`, source routing through the contraction, + partial selection attributes, rejection of routing on wrapped padding, and + pan-left restoration of the earlier page. +- Cursor mapping after contraction, cursor exclusion off the visible page, + and a routable synthetic end-of-line cell. Cursor shape in this test is an + explicitly supplied static `0xc0` mask. +- Replacement width/frame state does not modify the local viewport. Returned + cells are independent snapshots; replacement and clearing leave no old routes + in the viewport. This is not host rejection of a queued stale routing event. +- An explicitly selected computer-Braille table can use the same liblouis path. + Missing tables fail without replacing the existing viewport. +- Raw export rejects untranslated nonblank text and widths outside 1–4,096. + A peer advertising zero must disable its viewport in the future session owner; + zero is not replaced with an invented display width here. + +## Remaining boundaries and proposed helper approach + +Ordinary uncontracted output currently goes to BRLTTY as text. Its configured +translation table is not reproduced by this increment. The feasibility test sets +`Frame.Contract` to select the existing liblouis path and explicitly supplies +`en-us-comp8.ctb`; that flag does not itself make the chosen table contract. +Production integration needs an explicit virtual translation-table policy and +schema. Silently treating ordinary characters as dots, or assuming this table +matches a user's BRLTTY configuration, would be incorrect. + +Likewise, physical cursor shape and blinking are currently rendered by BRLTTY. +The virtual cell operation can apply an explicit cursor mask, but does not +discover or reproduce BRLTTY's cursor preferences. Resolve the virtual cursor +policy before describing these bytes as identical to a user's physical output. + +Keep translation and its native failure boundary in the disposable Braille +helper. The next proposed step is an explicit virtual-only helper mode which +does not call `openWithRetry`, with bounded typed requests for width, frame, +panning and routing, and bounded final-cell responses. A separately supervised +virtual helper would avoid coupling remote rendering to physical display +availability or blocking native calls; process overhead and lifecycle behavior +still need testing. No second translator or ASCII lookup table is needed. + +Before exposing this through a Tentacle, complete helper protocol/version +validation, cancellation and backpressure; independent physical/virtual helper +supervision; host-issued lease/epoch checks; lock/secure-input/handoff exclusion; +output arbitration; raw-cell receiving/restoration; and stale routing rejection +at delivery. Native AT-SPI routes must remain in the reader. The cell-export +operation alone provides none of those authorization or lifecycle guarantees. + +Actual physical displays, non-Latin translation behavior, cursor blinking, +unplug/replug, NVDA display negotiation and Braille input remain unverified. +Phase A and all release gates remain incomplete. + +## Automated verification + +From the reader checkout: + +```sh +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/braille -count=1 -timeout=60s +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race -tags brlapi ./internal/braille -count=1 -timeout=60s +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go vet -tags brlapi ./internal/braille +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go build -tags brlapi -o /tmp/cthulhu-remote-braille-check ./cmd/cthulhu-braille +``` + +Both package suites passed, including existing viewport, helper lifecycle, +translation, routing and table checks. Vet and the helper build passed. These +checks neither launch the helper nor connect to the active reader or display. +The adjacent Tentacles repository's `make check` also passed, including its +existing command-host fixtures. Its unchanged Go tests used cached results. + +## Pending local Braille regression check + +The only changed active path is mask construction in the physical writer. +Human diff review and this physical check remain necessary before submission. +There is no live Remote workflow to test yet. When a display is available: + +1. Quit Cthulhu with Cthulhu+Q. +2. Press Ctrl+Escape to enter i3 personal mode. Then press Shift+G to rebuild + and launch the reader and Braille helper. Ctrl+Escape and Shift+G are separate + key presses. The launcher opens a test terminal. +3. In an accessible editor, enter a harmless line longer than the display. + Confirm ordinary uncontracted Braille matches the previous behavior. Pan + right and left; expect the same text pages and no change to keyboard focus. +4. Move the caret, including to the end of the line, and select a few characters. + Expect the usual physical cursor and selection dots, with no extra or missing + dots. Route to a visible character and expect the editor caret at that source + character. Ordinary keyboard editing and speech should remain unchanged. +5. If contraction is normally used, repeat with `the cat` in the usual English + table and then a longer line. Expect the existing contracted cells, cursor + positioning, panning and routing behavior. Restore any test preference. +6. Report the table, display width, exact affected cells/indicator dots, caret + and focus position, speech, and keys that differed from those expectations. + For unexpected results, inspect `~/devel/cthulhu-go.log` before restarting. + +These steps check the shared physical writer only; they cannot establish remote +Braille acceptance before the session/helper integration exists. diff --git a/docs/testing/remote-input-feasibility.md b/docs/testing/remote-input-feasibility.md new file mode 100644 index 0000000..8e9a50d --- /dev/null +++ b/docs/testing/remote-input-feasibility.md @@ -0,0 +1,108 @@ +# Remote Xorg input feasibility + +This is an isolated prototype and a record of limitations, not a Remote input +implementation. No reader input behavior or installed executable changes. +The source baseline is `a47253d697c1aa44dea80646cad99a4fe163c7f1` on `testing`, +with the existing secret-entry work preserved. The branch was checked against +its upstream on 2026-11-03 before these tests were added. + +## Run the private checks + +From the reader checkout: + +```sh +python3 -B tests/native_remote_input.py +``` + +Requirements are Python 3, Xvfb/xvfb-run, D-Bus/dbus-run-session, Xlib, XTEST, +xdotool, xev, Go and the reader's usual native build dependencies. Missing tools +or failed checks fail the command; they do not count as skipped acceptance. +The launcher starts a 24-bit Xvfb display before private D-Bus, with disposable +runtime/config directories. It never connects the fixture to the desktop +display or the active reader. Invoke it without private fixture arguments. + +The Python tests create a focused X window, a capture process and independent +writer/helper processes. Only the writer retains the private control pipe's +write end. The harness kills or stops that writer to simulate a dead or hung +reader, while retaining the ability to reap both fixture children. This models +descriptor ownership; it does not test the real reader's process launch tree. +The prototype helper uses fixed two-byte records, a held-key set and a +three-second monotonic watchdog renewed only by explicit heartbeats. It releases +owned keys on EOF, timeout, malformed input or an already-held-key collision. + +The same private session runs three Go race tests against the real Xorg owners: +the new injected-key observation check and the existing internal-form capture +and learn-mode application-isolation tests. + +## Evidence recorded on 2026-11-03 + +All ten private Python experiments and the three native Go race tests passed. +The adjacent Tentacles repository's `make check` also passed, including existing +command-host checks; its unchanged Go package tests used cached results. + +- An exclusive `XGrabKeyboard` consumed the test press and release. The focused + application received neither. Killing the grab owner made the grab available + again, and a fresh key reached the application. +- The existing internal-form and learn-mode tests retained their capture and + restoration behavior with the race detector enabled. This does not establish + coexistence with a new Remote mode or a desktop window manager. +- Closing an XTEST client's X connection left its injected Shift down. X + connection lifetime alone is therefore insufficient for injected-key cleanup + on this server. +- Killing the writer released the helper's Shift and Control keys and restored + application input. The initial passing run observed EOF cleanup in about + 0.010 seconds. Stopping the writer without closing its descriptor triggered + cleanup in about 3.019 seconds. These are observations from a private Xvfb + run, not hard real-time or physical-desktop recovery guarantees; each run + prints its own timings and enforces a four-second test bound. +- Heartbeats extended the watchdog. Duplicate downs, unmatched ups, fragmented + input ending at EOF and invalid records did not leave fixture-owned keys held. +- A key already down through another client was not adopted or released. A + collision revoked the prototype and released its other owned keys. +- A second XTEST client pressing the same code **after** the helper's down was + not separately represented by the global key bitmap: helper cleanup cleared + that code. The corresponding test records this limitation, not safe collision + handling. No physical key device participates in this test. +- Injected events had `send_event == False`, and the reader's current XRecord + listener delivered them through the ordinary input event stream. Neither that + flag nor the existing event representation proves local physical origin. + +## Decisions and remaining gates + +An independent reader-owned helper with an exclusive inherited control +descriptor is a viable cleanup direction for the tested failures. It must not +be a child of the network Tentacle or be killed with that Tentacle's process +group before releasing keys. Reader shutdown must close/revoke the helper and +allow bounded cleanup before forced termination. Prevent inherited write ends +in unrelated descendants; otherwise reader death will not produce EOF. + +Do not promote this Python fixture to a production helper. It uses synchronous +Xlib calls and blocking test acknowledgements, and has no lease, epoch, sequence, +input-origin or reader-owner integration. A production Go/native helper needs +bounded IPC and X-server failure handling, stale-work invalidation, explicit +error reporting, restart policy and lifecycle tests. Simultaneous helper death +or a blocked X server is not recovered by this experiment. + +Trusted input origin remains a release blocker. A subsequent +[authorization increment](../tentacle-remote-authorization.md) now adds separate +XI2 capture for sensitive forms, XTEST/device-node filtering and private rejection +tests. Positive physical-keyboard acceptance and production command integration +remain pending. The ordinary reader event +schema contains keycode, transition and timestamp but no source device or +core-owned injection identity. Do not grant local consent based on an XRecord +event, `send_event`, or a timing-only match. Investigate device-aware events and +core-owned injection routing, including exact-once target reader commands, +echo suppression and exclusion from consent/clipboard/trust actions. + +Physical/injected same-key collisions also remain a blocker. Checking the +global bitmap before a down only handles an already-visible collision, with +a check/inject race; it does not resolve a physical press arriving while a +remote key is held. Do not clear all modifiers or assume the synthetic +two-client result establishes physical-device behavior. + +Still pending: Remote capture within the command owner; local escape and +balanced bypass chords; remapping, learn/UI/lock/handoff arbitration; production +helper parent-death and blocked-pipe tests; held-key collisions with actual +devices; Wayland capability behavior; virtual Braille feasibility; and all live +NVDA/Orca, audible-speech and physical-Braille acceptance. Phase A remains open. +There is no live Remote command to launch or user desktop test for this increment. diff --git a/internal/app/app.go b/internal/app/app.go index 2ded2c5..fdb6f7a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -295,6 +295,9 @@ func Run(ctx context.Context, options Options) error { } tentacles.output = &tentacleOutput{speech: speechMessages, braille: brailleOutput, gate: focusGate, live: live} focusGate.tentacleOutput = tentacles.output + focusGate.remotePolicy = tentacle.NewRemotePolicy() + focusGate.sync_remote_policy() + tentacles.remotePolicy = focusGate.remotePolicy services = append(services, lifecycle.Service{Name: "tentacles", Start: tentacles.Start, Stop: tentacles.Stop}) } preferences := newPreferencesWorker(logger, savedSettings, settingsUpdater) diff --git a/internal/app/braille_presentation.go b/internal/app/braille_presentation.go index 7a81a4d..24e4819 100644 --- a/internal/app/braille_presentation.go +++ b/internal/app/braille_presentation.go @@ -21,11 +21,12 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c if line == nil && assembled == nil || snapshot.Role == "password text" { frame := brailleFocusFrameWithOptions(snapshot, options, configured...) frame.Routes = nil - if snapshot.Role == "link" && snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" && !snapshot.Editable { + name := strings.TrimSpace(stripEmbeddedObjects(snapshot.Name)) + if snapshot.Role == "link" && snapshot.ExplicitName && name != "" && !snapshot.Editable { // Accessible-label review has no native text route. - frame.Cursor = min(result.DocumentPosition.Offset, utf8.RuneCountInString(strings.TrimSpace(snapshot.Name))) + frame.Cursor = min(result.DocumentPosition.Offset, utf8.RuneCountInString(name)) } - textLength := utf8.RuneCountInString(snapshot.Name) + textLength := utf8.RuneCountInString(name) if textLength == 0 { textLength = utf8.RuneCountInString(snapshot.Text) } @@ -220,7 +221,7 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi if frame, handled := libreOfficeBrailleFrame(s, options); handled { return frame } - name, role := strings.TrimSpace(s.Name), s.ControlRole() + name, role := strings.TrimSpace(stripEmbeddedObjects(s.Name)), s.ControlRole() if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown { label := name if role == "terminal" || role == "heading" || role == "static" { diff --git a/internal/app/braille_presentation_test.go b/internal/app/braille_presentation_test.go index 0e379c9..0e8adca 100644 --- a/internal/app/braille_presentation_test.go +++ b/internal/app/braille_presentation_test.go @@ -454,6 +454,26 @@ func TestBrailleFocusEmbeddedObjectLineUsesDisplayedText(t *testing.T) { } } +func TestBrailleHeadingNameCannotLeakEmbeddedObjects(t *testing.T) { + options := config.DefaultSettings().Braille + heading := atspi.NewSnapshot("/heading", "Before \ufffc after", "heading", "", "") + heading.HeadingLevel = 2 + result := review.Result{DocumentPosition: review.DocumentPosition{Snapshot: heading}} + frame := brailleDocumentFrame(result, options) + if strings.ContainsRune(frame.Text, '\ufffc') || frame.Text != "Before after h2" { + t.Fatalf("heading name fallback = %+v", frame) + } + // A label before a routable line must be cleaned before calculating + // cursor, indicator and routing offsets. + entry := atspi.NewSnapshot("/entry", "Label\ufffc", "entry", "", "猫") + entry.BusName, entry.TextCaretKnown = ":1.999", true + entry.BrailleLine, entry.BrailleCaret = "猫", 0 + frame = brailleFocusFrameWithOptions(entry, options) + if frame.Text != "Label 猫 $l" || frame.Cursor != 6 || len(frame.Routes) != 1 || frame.Routes[0].Start != 6 { + t.Fatalf("cleaned label offsets = %+v", frame) + } +} + func TestBrowseBrailleAssembledLineDropsEmbeddedObjectPlaceholders(t *testing.T) { heading := atspi.NewSnapshot("/heading", "Cool video title", "heading", "", "") heading.BusName = ":1.999" diff --git a/internal/app/focus_gate.go b/internal/app/focus_gate.go index b496d67..e7853c3 100644 --- a/internal/app/focus_gate.go +++ b/internal/app/focus_gate.go @@ -22,6 +22,7 @@ import ( "sync/atomic" "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/tentacle" ) type commandGrabController interface { @@ -30,6 +31,7 @@ type commandGrabController interface { type focusGate struct { tentacleOutput *tentacleOutput + remotePolicy *tentacle.RemotePolicy ocr *ocrSession mutex sync.Mutex @@ -64,12 +66,21 @@ func (gate *focusGate) SetSuspended(suspended bool) bool { return false } gate.retireTentacleOutput() + gate.sync_remote_policy() if gate.commands != nil { gate.commands.SetCommandGrabs(!suspended && !gate.secure.Load()) } return true } +// Called during startup or with gate.mutex held. Cancellation never calls back +// into the gate or runtime locks. Lock/unlock and handoff cannot restore consent. +func (gate *focusGate) sync_remote_policy() { + if gate.remotePolicy != nil { + gate.remotePolicy.SetAvailable(!gate.secure.Load() && !gate.suspended.Load()) + } +} + func (gate *focusGate) InternalUIActive() bool { return gate != nil && gate.internalUI != nil && gate.internalUI.InternalUIActive() } diff --git a/internal/app/internal_ui.go b/internal/app/internal_ui.go index 6363274..b4f489b 100644 --- a/internal/app/internal_ui.go +++ b/internal/app/internal_ui.go @@ -42,10 +42,11 @@ type internalUINotice struct { value interaction.Presentation } type internalUIRequest struct { - ctx context.Context - session *interaction.Session - result chan internalUIResult - ready chan error + localOnly bool + ctx context.Context + session *interaction.Session + result chan internalUIResult + ready chan error } // internalUI owns one modal session on its worker. The bounded event channel @@ -70,11 +71,21 @@ type internalUI struct { } func (ui *internalUI) Open(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) { + return ui.open_form(ctx, form, false) +} + +// OpenLocal is core-only. Unknown input backends cannot silently fall back to +// ordinary forms when authorizing remote access or handling its secrets/trust. +func (ui *internalUI) OpenLocal(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) { + return ui.open_form(ctx, form, true) +} + +func (ui *internalUI) open_form(ctx context.Context, form interaction.Form, localOnly bool) (<-chan internalUIResult, error) { session, err := interaction.NewSession(form) if err != nil { return nil, err } - request := internalUIRequest{ctx: ctx, session: session, result: make(chan internalUIResult, 1), ready: make(chan error, 1)} + request := internalUIRequest{ctx: ctx, session: session, result: make(chan internalUIResult, 1), ready: make(chan error, 1), localOnly: localOnly} select { case <-ctx.Done(): return nil, ctx.Err() @@ -149,6 +160,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 +176,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) } @@ -196,7 +213,17 @@ func (ui *internalUI) run(ctx context.Context) { continue } var err error - sessionID, err = ui.keys.BeginInternalUI(request.ctx) + if request.localOnly { + if keys, ok := ui.keys.(interface { + BeginLocalUI(context.Context) (uint64, error) + }); ok { + sessionID, err = keys.BeginLocalUI(request.ctx) + } else { + err = errors.New("local approval input unavailable") + } + } else { + sessionID, err = ui.keys.BeginInternalUI(request.ctx) + } if err != nil { request.ready <- err close(request.result) diff --git a/internal/app/internal_ui_native_test.go b/internal/app/internal_ui_native_test.go index 746091a..54cf77d 100644 --- a/internal/app/internal_ui_native_test.go +++ b/internal/app/internal_ui_native_test.go @@ -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() diff --git a/internal/app/internal_ui_secret_test.go b/internal/app/internal_ui_secret_test.go new file mode 100644 index 0000000..9adedfb --- /dev/null +++ b/internal/app/internal_ui_secret_test.go @@ -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") + } + }) + } +} diff --git a/internal/app/linked_headings_native_test.go b/internal/app/linked_headings_native_test.go index 256a451..88e2706 100644 --- a/internal/app/linked_headings_native_test.go +++ b/internal/app/linked_headings_native_test.go @@ -78,6 +78,54 @@ func TestNativeLinkedHeadings(t *testing.T) { t.Run("sound geometry after scrolling", func(t *testing.T) { testLinkedHeadingSoundGeometry(t, ctx, adapter, focus) }) + t.Run("linked image heading", func(t *testing.T) { + worker, err := review.NewWorker(adapter, 2*time.Second) + if err != nil { + t.Fatal(err) + } + worker.SetDocumentBrailleEnabled(true) + worker.SyncFocus(focus) + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + for _, name := range []string{"Ordinary heading", "Before Linked image title after"} { + if err := worker.MoveDocument(review.DocumentHeading, 4, false); err != nil { + t.Fatal(err) + } + select { + case result := <-worker.Results(): + frame := brailleDocumentFrame(result, config.DefaultSettings().Braille) + if result.Err != nil || !result.Moved || !strings.Contains(frame.Text, name) || strings.ContainsRune(frame.Text, '\ufffc') { + t.Fatalf("image heading braille=%q error=%v read error=%v", frame.Text, result.Err, result.DocumentBrailleErr) + } + if os.Getenv("CTHULHU_NATIVE_FIREFOX") == "1" && name == "Before Linked image title after" { + deadline := time.NewTimer(3 * time.Second) + defer deadline.Stop() + caret: + for { + select { + case event := <-adapter.Events(): + if event.Type != atspi.CaretEvent || event.Source.ID() != result.DocumentPosition.Snapshot.ID() { + continue + } + caretFrame := brailleFocusFrameWithOptions(event.Source, config.DefaultSettings().Braille) + if !strings.Contains(caretFrame.Text, name) || strings.ContainsRune(caretFrame.Text, '\ufffc') { + t.Fatalf("image heading caret braille=%q native=%q", caretFrame.Text, event.Source.BrailleLine) + } + break caret + case <-deadline.C: + t.Fatal("image heading native caret event missing") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + }) for _, tc := range []struct { level int name, title string diff --git a/internal/app/screen_lock.go b/internal/app/screen_lock.go index 508b561..65d8ca6 100644 --- a/internal/app/screen_lock.go +++ b/internal/app/screen_lock.go @@ -140,6 +140,7 @@ func (gate *focusGate) SetLocked(active bool, adapter Adapter, listener InputLis return } gate.retireTentacleOutput() + gate.sync_remote_policy() for _, target := range []any{adapter, listener, commands} { if controller, ok := target.(secureInputController); ok { controller.SetSecureInputActive(active) diff --git a/internal/app/tentacle_interaction.go b/internal/app/tentacle_interaction.go index ebb9f26..add864d 100644 --- a/internal/app/tentacle_interaction.go +++ b/internal/app/tentacle_interaction.go @@ -32,8 +32,15 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo return } defer func() { <-runtime.interactionSlots }() - ctx, cancel := context.WithCancel(ctx) + commandContext := ctx + parent := ctx + if len(command.session.RemoteGrants()) != 0 && runtime.remotePolicy != nil { + parent = runtime.remotePolicy.Context() + } + ctx, cancel := context.WithCancel(parent) defer cancel() + stopCommand := context.AfterFunc(commandContext, cancel) + defer stopCommand() stop := context.AfterFunc(command.session.OutputContext(), cancel) defer stop() var temporaryFiles []string @@ -95,7 +102,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo return } formCtx, closeForm := context.WithCancel(ctx) - replies, openErr := runtime.forms.Open(formCtx, *value.Form) + replies, openErr := runtime.open_tentacle_form(formCtx, command, *value.Form) if openErr != nil { closeForm() runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true}) @@ -118,6 +125,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 @@ -153,6 +163,17 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo } } +func (runtime *tentacleRuntime) open_tentacle_form(ctx context.Context, command tentacleCommand, form interaction.Form) (<-chan internalUIResult, error) { + if len(command.session.RemoteGrants()) != 0 { + local, ok := runtime.forms.(localTentacleForms) + if !ok { + return nil, errors.New("local approval input unavailable") + } + return local.OpenLocal(ctx, form) + } + return runtime.forms.Open(ctx, form) +} + func (runtime *tentacleRuntime) validContinuation(command tentacleCommand, id string) bool { if id == "" { return true diff --git a/internal/app/tentacle_management.go b/internal/app/tentacle_management.go index 65dd2ae..673b655 100644 --- a/internal/app/tentacle_management.go +++ b/internal/app/tentacle_management.go @@ -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() + } + } +} diff --git a/internal/app/tentacle_remote_consent.go b/internal/app/tentacle_remote_consent.go new file mode 100644 index 0000000..ea8f0d1 --- /dev/null +++ b/internal/app/tentacle_remote_consent.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "slices" + "time" + + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +type localTentacleForms interface { + OpenLocal(context.Context, interaction.Form) (<-chan internalUIResult, error) +} + +// authorize_remote is a core entry point, not a registered Tentacle command or +// child RPC. A future connection command must resolve the installed session and +// role before calling it. A synthetic shortcut may request a dialog but cannot +// select permissions or approve it through the local-only capture path. +func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role string, grants []string) (tentaclewire.SessionEvent, error) { + if runtime == nil || runtime.remotePolicy == nil || !tentaclewire.ValidRoleGrants(role, grants) { + return tentaclewire.SessionEvent{}, tentacle.ErrPermission + } + forms, ok := runtime.forms.(localTentacleForms) + if !ok { + return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable + } + grants = slices.Clone(grants) + runtime.mutex.Lock() + var target *tentacle.Session + if !runtime.disabled { + for _, session := range runtime.sessions { + if session.TentacleID() == id { + target = session + break + } + } + } + runtime.mutex.Unlock() + if target == nil { + return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable + } + installed := target.RemoteGrants() + for _, grant := range grants { + if !slices.Contains(installed, grant) { + return tentaclewire.SessionEvent{}, tentacle.ErrPermission + } + } + // Reserve the interaction before creating a ticket, so a second request + // rejected as busy cannot invalidate the dialog already on screen. + select { + case runtime.interactionSlots <- struct{}{}: + default: + return tentaclewire.SessionEvent{}, tentacle.ErrBusy + } + defer func() { <-runtime.interactionSlots }() + approval, err := target.BeginRemoteApproval(role, grants) + if err != nil { + return tentaclewire.SessionEvent{}, err + } + defer approval.Cancel() + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + stopPolicy := context.AfterFunc(approval.Context(), cancel) + defer stopPolicy() + stopSession := context.AfterFunc(target.OutputContext(), cancel) + defer stopSession() + form := remote_consent_form(id, role, grants) + replies, err := forms.OpenLocal(ctx, form) + if err != nil { + return tentaclewire.SessionEvent{}, err + } + select { + case reply, open := <-replies: + if !open || ctx.Err() != nil || reply.Cancelled || reply.Err != nil || reply.Activated != "approve" { + return tentaclewire.SessionEvent{}, tentacle.ErrPermission + } + var selected []string + for _, grant := range grants { + if reply.Values[grant].Checked { + selected = append(selected, grant) + } + } + if len(selected) == 0 { + return tentaclewire.SessionEvent{}, tentacle.ErrPermission + } + return target.AuthorizeRemoteWithApproval(approval, role, selected) + case <-ctx.Done(): + return tentaclewire.SessionEvent{}, tentacle.ErrPermission + } +} + +func remote_consent_form(id, role string, grants []string) interaction.Form { + roleText := "control another computer" + if role == "controlled" { + roleText = "allow a helper to access this computer" + } + form := interaction.Form{Title: "Remote access for " + id + ": " + roleText + + ". The relay can read the channel key and session contents. A peer ID does not verify a person's identity. Select access for this session using a local keyboard.", + Elements: []interaction.Element{{ID: "cancel", Label: "Cancel", Kind: interaction.Button}}} + labels := map[string]string{ + "output-observe": "Share local reader output with the remote controller", + "remote-present": "Present output from the controlled computer", + "keyboard-capture": "Forward local keyboard input to the controlled computer", + "keyboard-inject": "Allow the remote controller to type on this computer", + "clipboard-exchange": "Allow receiving clipboard text; send only on explicit local request", + "braille-stream": "Exchange remote braille cells and supported display gestures", + } + for _, grant := range grants { + form.Elements = append(form.Elements, interaction.Element{ID: grant, Label: labels[grant], Kind: interaction.CheckBox}) + } + form.Elements = append(form.Elements, interaction.Element{ID: "approve", Label: "Approve selected access for this session", Kind: interaction.Button}) + return form +} diff --git a/internal/app/tentacle_remote_consent_test.go b/internal/app/tentacle_remote_consent_test.go new file mode 100644 index 0000000..9a516ae --- /dev/null +++ b/internal/app/tentacle_remote_consent_test.go @@ -0,0 +1,201 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +type consentFormRequest struct { + ctx context.Context + form interaction.Form + reply chan internalUIResult +} +type consentTestForms struct{ requests chan consentFormRequest } + +func (forms *consentTestForms) Open(context.Context, interaction.Form) (<-chan internalUIResult, error) { + return nil, errors.New("ordinary form used for consent") +} +func (forms *consentTestForms) Notice(context.Context, tentaclewire.Presentation) {} +func (forms *consentTestForms) OpenLocal(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) { + reply := make(chan internalUIResult, 1) + forms.requests <- consentFormRequest{ctx, form, reply} + return reply, nil +} + +func consent_runtime_fixture(t *testing.T) (*tentacleRuntime, *tentacle.Session, *consentTestForms, *focusGate) { + 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) + } + }) + policy := tentacle.NewRemotePolicy() + if err := manager.UseRemotePolicy(policy); err != nil { + t.Fatal(err) + } + session, err := manager.Start(t.Context(), tentacle.Spec{ID: "consent-probe", Program: []string{executable, "-test.run=^TestRemoteManagementPeer$", "--", "remote-management-peer"}, SessionEvents: true, RemoteGrants: []string{"output-observe", "clipboard-exchange"}}) + 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) + } + forms := &consentTestForms{requests: make(chan consentFormRequest, 1)} + runtime := &tentacleRuntime{remotePolicy: policy, forms: forms, sessions: []*tentacle.Session{session}, interactionSlots: make(chan struct{}, 1)} + return runtime, session, forms, &focusGate{remotePolicy: policy} +} + +func TestRemoteConsentFormDefaultsAndRevocation(t *testing.T) { + for _, action := range []string{"approve", "cancel", "empty", "lock", "handoff", "revoke", "disable"} { + t.Run(action, func(t *testing.T) { + runtime, session, forms, gate := consent_runtime_fixture(t) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + result := make(chan error, 1) + go func() { + _, err := runtime.authorize_remote(ctx, "consent-probe", "controlled", []string{"output-observe", "clipboard-exchange"}) + result <- err + }() + var request consentFormRequest + select { + case request = <-forms.requests: + case <-ctx.Done(): + t.Fatal("no consent form") + } + if _, err := interaction.NewSession(request.form); err != nil { + t.Fatal("invalid consent form", err) + } + if request.form.Elements[0].ID != "cancel" { + t.Fatal("approval was default action") + } + for _, element := range request.form.Elements { + if element.Checked { + t.Fatal("permission preselected") + } + } + reply := internalUIResult{Activated: "approve", Values: map[string]interaction.Value{"output-observe": {Checked: true}}} + switch action { + case "cancel": + reply.Activated = "cancel" + case "empty": + reply.Values = nil + case "lock": + gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{}) + gate.SetLocked(false, nil, nil, nil, nil, &brailleWorker{}) + case "handoff": + gate.SetSuspended(true) + gate.SetSuspended(false) + case "revoke": + runtime.revokeManagedRemote("consent-probe") + case "disable": + session.Disable() + } + request.reply <- reply + select { + case err := <-result: + if action == "approve" && err != nil { + t.Fatal(err) + } + if action != "approve" && err == nil { + t.Fatal("stale/denied consent issued action") + } + case <-ctx.Done(): + t.Fatal("consent worker did not finish") + } + if action != "approve" { + return + } + pending, err := session.Invoke(ctx, "open", nil) + if err != nil { + t.Fatal(err) + } + var lease tentaclewire.RemoteLease + select { + case opened := <-pending: + if opened.Err != nil || json.Unmarshal(opened.Value, &lease) != nil { + t.Fatal("approved action failed", opened.Err) + } + case <-ctx.Done(): + t.Fatal("open timed out") + } + delivery, err := session.RemoteContext(lease, "output-observe") + if err != nil { + t.Fatal(err) + } + if _, err := session.RemoteContext(lease, "clipboard-exchange"); err == nil { + t.Fatal("unchecked permission granted") + } + gate.SetSuspended(true) + if delivery.Err() == nil { + t.Fatal("handoff did not synchronously cancel delivery") + } + gate.SetSuspended(false) + if _, err := session.RemoteContext(lease, "output-observe"); err == nil { + t.Fatal("handoff resume revived access") + } + }) + } +} + +func TestRemoteGrantedTentacleUsesLocalForms(t *testing.T) { + runtime, session, forms, _ := consent_runtime_fixture(t) + form := interaction.Form{Title: "Certificate review", Elements: []interaction.Element{{ID: "cancel", Label: "Cancel", Kind: interaction.Button}}} + if _, err := runtime.open_tentacle_form(t.Context(), tentacleCommand{session: session}, form); err != nil { + t.Fatal(err) + } + select { + case <-forms.requests: + default: + t.Fatal("remote form used ordinary input") + } +} + +type ordinaryOnlyForms struct{ tentacleForms } + +func TestRemoteConsentCannotFallBackToOrdinaryForms(t *testing.T) { + runtime, session, _, _ := consent_runtime_fixture(t) + runtime.forms = ordinaryOnlyForms{runtime.forms} + if _, err := runtime.authorize_remote(t.Context(), "consent-probe", "controlled", []string{"output-observe"}); err == nil { + t.Fatal("consent fell back to ordinary input") + } + if _, err := runtime.open_tentacle_form(t.Context(), tentacleCommand{session: session}, remote_consent_form("consent-probe", "controlled", []string{"output-observe"})); err == nil { + t.Fatal("sensitive form fell back to ordinary input") + } +} + +func TestRemotePolicyAttachedToRuntimeManagers(t *testing.T) { + runtime := &tentacleRuntime{remotePolicy: tentacle.NewRemotePolicy(), newManager: func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) }} + manager, err := runtime.makeManager() + if err != nil { + t.Fatal(err) + } + if err := manager.UseRemotePolicy(tentacle.NewRemotePolicy()); err == nil { + t.Fatal("reader manager omitted policy") + } +} diff --git a/internal/app/tentacle_remote_management_test.go b/internal/app/tentacle_remote_management_test.go new file mode 100644 index 0000000..a46271b --- /dev/null +++ b/internal/app/tentacle_remote_management_test.go @@ -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) + } +} diff --git a/internal/app/tentacles.go b/internal/app/tentacles.go index c501625..4f2a81f 100644 --- a/internal/app/tentacles.go +++ b/internal/app/tentacles.go @@ -34,6 +34,7 @@ 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 @@ -421,6 +422,14 @@ func (runtime *tentacleRuntime) report(message string) { // makeManager runs on the lifecycle worker, never on input or GTK callbacks. func (runtime *tentacleRuntime) makeManager() (*tentacle.Manager, error) { + manager, err := runtime.make_manager() + if err == nil && runtime.remotePolicy != nil { + err = manager.UseRemotePolicy(runtime.remotePolicy) + } + return manager, err +} + +func (runtime *tentacleRuntime) make_manager() (*tentacle.Manager, error) { if runtime.newManager != nil { return runtime.newManager() } diff --git a/internal/atspi/adapter_document_native.go b/internal/atspi/adapter_document_native.go index cbdd6a1..1e6795a 100644 --- a/internal/atspi/adapter_document_native.go +++ b/internal/atspi/adapter_document_native.go @@ -352,19 +352,12 @@ func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position Ob if err != nil { return DocumentCaretText{}, err } - line.RawText = line.Text // Braille must show the same descendant text speech expands, not // embedded-object placeholders. RawText and native offsets stay // unexpanded; Expanded tells consumers the mapping is degraded. - if unit == DocumentCaretBrailleLine && !snapshot.Editable && strings.ContainsRune(line.Text, '\ufffc') { - deadline := time.Now().Add(100 * time.Millisecond) - if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { - deadline = ctxDeadline - } - budget := embeddedTextBudget{nodes: 32, characters: maxSnapshotFieldLength, deadline: deadline} - if expanded := expandNativeEmbeddedText(source, line.Text, line.Start, 0, &budget); expanded != line.Text { - line.Text, line.Expanded = expanded, true - } + line.RawText = line.Text + if unit == DocumentCaretBrailleLine { + line = expandNativeDocumentText(ctx, source, snapshot, line) } if unit == DocumentCaretBrailleLine && adapter.captureTextAttributes.Load() { line.TextAttributeRuns = native_text_attribute_runs(text, line.Start, line.End, TextAttributeRun{}) diff --git a/internal/atspi/adapter_snapshot_native.go b/internal/atspi/adapter_snapshot_native.go index 4ea0aed..e2ccf60 100644 --- a/internal/atspi/adapter_snapshot_native.go +++ b/internal/atspi/adapter_snapshot_native.go @@ -10,9 +10,10 @@ package atspi import "C" import ( + "context" "fmt" "strings" - "time" + "unicode/utf8" ) func nativeError(operation string, nativeError *C.GError) error { @@ -61,6 +62,7 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact textContent, C.GoString(captured.caretCharacter), ) + characterCount := int(captured.characterCount) snapshot.TextCaretKnown = captured.text != nil && captured.textStartOffset >= 0 if text && snapshot.Role != "password text" { switch snapshot.Role { @@ -71,6 +73,12 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact if snapshot.TextCaretKnown { captureBrailleLine(&snapshot, textContent, C.GoString(captured.textBeforeCaret), int(captured.textStartOffset)) if text := C.atspi_accessible_get_text(source); text != nil { + // Caret captures omit whole-document metadata. An incomplete + // expansion still needs the native count to know whether a name + // can safely replace this entire line. + if caretCharacter && strings.ContainsRune(textContent, '\ufffc') && characterCount <= 0 { + characterCount = int(C.atspi_text_get_character_count(text, nil)) + } snapshot.TextAttributeRun = native_text_attribute_run(text, snapshot.BrailleCaret) snapshot.Misspelled = TextAttributesMisspelled(snapshot.TextAttributeRun.Values) if len(captureAttributes) != 0 && captureAttributes[0] { @@ -93,9 +101,12 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact } } if caretCharacter && captured.editable == 0 && snapshot.Role != "terminal" && strings.ContainsRune(textContent, '\ufffc') { - budget := embeddedTextBudget{nodes: 32, characters: maxSnapshotFieldLength, deadline: time.Now().Add(100 * time.Millisecond)} // Hypertext offsets refer to raw characters, before whitespace folding. - snapshot.Text = sanitizeSnapshotField(expandNativeEmbeddedText(source, textContent, int(captured.textStartOffset), 0, &budget)) + start := int(captured.textStartOffset) + line := expandNativeDocumentText(context.Background(), source, snapshot, DocumentCaretText{ + Start: start, End: start + utf8.RuneCountInString(textContent), Text: textContent, CharacterCount: characterCount, + }) + snapshot.Text = sanitizeSnapshotField(line.Text) } snapshot.BusName = sanitizeSnapshotField(C.GoString(captured.busName)) if snapshot.Role == "radio button" { @@ -148,7 +159,7 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact snapshot.TableColumnHeaders = sanitizeSnapshotField(C.GoString(captured.tableColumnHeaders)) C.g_free(C.gpointer(captured.tableRowHeaders)) C.g_free(C.gpointer(captured.tableColumnHeaders)) - snapshot.CharacterCount = int(captured.characterCount) + snapshot.CharacterCount = characterCount snapshot.EmbeddedObjectCount = int(captured.embeddedObjectCount) snapshot.XMLRoles = sanitizeSnapshotField(C.GoString(captured.xmlRoles)) snapshot.HTMLTag = sanitizeSnapshotField(C.GoString(captured.htmlTag)) diff --git a/internal/atspi/document_caret.go b/internal/atspi/document_caret.go index a39f425..caff9e2 100644 --- a/internal/atspi/document_caret.go +++ b/internal/atspi/document_caret.go @@ -1,6 +1,9 @@ package atspi -import "unicode/utf8" +import ( + "strings" + "unicode/utf8" +) // DocumentCaretUnit selects the AT-SPI text granularity for virtual browsing. type DocumentCaretUnit uint8 @@ -39,6 +42,28 @@ type DocumentCaretText struct { Bounds Rectangle } +func (line *DocumentCaretText) applyExpandedText(expanded, name string, incomplete bool) { + line.RawText = line.Text + // An accessible name describes the whole object. It can recover a failed + // descendant read only when this range covers that entire object; using it + // on a wrapped line would repeat unrelated text and move the reading point. + if incomplete && line.CharacterCount > 0 && line.Start == 0 && line.End == line.CharacterCount && + strings.TrimSpace(name) != "" && !strings.ContainsRune(name, '\ufffc') { + expanded = name + } + line.Text, line.Expanded = expanded, expanded != line.RawText +} + +func (snapshot *Snapshot) applyDocumentCaretLine(line DocumentCaretText, offset int) { + // A caret capture already expanded this exact native line. Preserve that + // text instead of replacing it with the raw line used for offset repair. + if snapshot.BrailleLine != line.RawText || snapshot.BrailleLineStart != line.Start || snapshot.CharacterCount != line.CharacterCount { + snapshot.Text = sanitizeSnapshotField(line.Text) + } + snapshot.BrailleLine, snapshot.BrailleLineStart, snapshot.BrailleCaret = line.RawText, line.Start, offset + snapshot.CharacterCount = line.CharacterCount +} + func documentCaretCharacter(value string) string { if value == "" { return "" diff --git a/internal/atspi/document_caret_test.go b/internal/atspi/document_caret_test.go index 596594c..66374fb 100644 --- a/internal/atspi/document_caret_test.go +++ b/internal/atspi/document_caret_test.go @@ -13,3 +13,58 @@ func TestDocumentCaretCharacterPreservesDelimiters(t *testing.T) { } } } + +func TestIncompleteEmbeddedTextRecoversOnlyWholeObject(t *testing.T) { + for _, test := range []struct { + name, label, expanded, want string + start, end, count int + incomplete bool + }{ + {"failed child", "Before Linked title after", "Before after", "Before Linked title after", 0, 14, 14, true}, + {"complete expansion", "Different author label", "Before Linked title after", "Before Linked title after", 0, 14, 14, false}, + {"wrapped first line", "Whole object label", "Before after", "Before after", 0, 14, 28, true}, + {"wrapped last line", "Whole object label", "Before after", "Before after", 14, 28, 28, true}, + {"unknown count", "Whole object label", "Before after", "Before after", 0, 14, 0, true}, + {"invalid name", "Before \ufffc after", "Before after", "Before after", 0, 14, 14, true}, + {"empty name", " ", "Before after", "Before after", 0, 14, 14, true}, + } { + t.Run(test.name, func(t *testing.T) { + line := DocumentCaretText{Text: "Before \ufffc after", Start: test.start, End: test.end, CharacterCount: test.count, + SelectionStart: 8, SelectionEnd: 10, TextAttributeRuns: []TextAttributeRun{{Start: 8, End: 10}}} + line.applyExpandedText(test.expanded, test.label, test.incomplete) + if line.Text != test.want || line.RawText != "Before \ufffc after" || !line.Expanded || + line.Start != test.start || line.End != test.end || line.CharacterCount != test.count || + line.SelectionStart != 8 || line.SelectionEnd != 10 || line.TextAttributeRuns[0].Start != 8 { + t.Fatalf("expanded line = %+v", line) + } + }) + } +} + +func TestCaretLineRepairPreservesOnlyMatchingExpansion(t *testing.T) { + snapshot := Snapshot{Text: "Before Linked title after", BrailleLine: "Before \ufffc after", BrailleLineStart: 10, CharacterCount: 24, TextCaretKnown: true} + line := DocumentCaretText{Text: "Before \ufffc after", RawText: "Before \ufffc after", Start: 10, End: 24, CharacterCount: 24} + snapshot.applyDocumentCaretLine(line, 18) + if snapshot.Text != "Before Linked title after" || snapshot.BrailleLine != line.RawText || snapshot.BrailleCaret != 18 || snapshot.CharacterCount != 24 { + t.Fatalf("matching expansion lost: %+v", snapshot) + } + // A newly available count permits whole-object recovery after the + // initial snapshot could not establish whether its line was complete. + snapshot.CharacterCount = 0 + line.Text = "Before Recovered title after" + snapshot.applyDocumentCaretLine(line, 18) + if snapshot.Text != "Before Recovered title after" { + t.Fatalf("newly recovered expansion ignored: %+v", snapshot) + } + // Equal raw strings on different lines must not reuse the old expansion. + line.Start, line.End, line.Text = 0, 14, "Before New title after" + snapshot.applyDocumentCaretLine(line, 0) + if snapshot.Text != "Before New title after" || snapshot.BrailleLineStart != 0 || snapshot.BrailleCaret != 0 { + t.Fatalf("new line inherited stale expansion: %+v", snapshot) + } + line.Text, line.RawText = "New plain 猫", "New plain 猫" + snapshot.applyDocumentCaretLine(line, 2) + if snapshot.Text != "New plain 猫" || snapshot.BrailleLine != "New plain 猫" || snapshot.BrailleCaret != 2 { + t.Fatalf("plain line repair lost text or spacing: %+v", snapshot) + } +} diff --git a/internal/atspi/document_lines_native.go b/internal/atspi/document_lines_native.go index eaceb0e..f656b61 100644 --- a/internal/atspi/document_lines_native.go +++ b/internal/atspi/document_lines_native.go @@ -112,7 +112,9 @@ func (adapter *Adapter) repairNativeCaretLine(source *C.AtspiAccessible, snapsho if err != nil || line.Start < 0 || line.End <= line.Start { return } - snapshot.Text = sanitizeSnapshotField(line.Text) - snapshot.BrailleLine, snapshot.BrailleLineStart, snapshot.BrailleCaret = line.Text, line.Start, offset - snapshot.CharacterCount = count + line.RawText = line.Text + if snapshot.BrailleLine != line.RawText || snapshot.BrailleLineStart != line.Start || snapshot.CharacterCount != count { + line = expandNativeDocumentText(ctx, source, *snapshot, line) + } + snapshot.applyDocumentCaretLine(line, offset) } diff --git a/internal/atspi/firefox_native_test.go b/internal/atspi/firefox_native_test.go index 9fc5ea7..f6c7429 100644 --- a/internal/atspi/firefox_native_test.go +++ b/internal/atspi/firefox_native_test.go @@ -169,6 +169,29 @@ chromeFocus: if !mixedLine.Expanded || strings.ContainsRune(mixedLine.Text, '\ufffc') || !strings.Contains(mixedLine.Text, "Inline message link") { t.Fatalf("mixed braille line not expanded: %+v", mixedLine) } + if err := adapter.SetDocumentCaretPosition(ctx, parent, event.Source.ID(), 0); err != nil { + t.Fatalf("place mixed-line caret: %v", err) + } + caretDeadline := time.NewTimer(3 * time.Second) + caretEvent: + for { + select { + case caret := <-adapter.Events(): + if caret.Type != CaretEvent || caret.Source.ID() != parent.ID() { + continue + } + caretDeadline.Stop() + if caret.Source.BrailleLine != mixedLine.RawText || !strings.Contains(caret.Source.Text, "Inline message link") || strings.ContainsRune(caret.Source.Text, '\ufffc') { + t.Fatalf("caret repair lost embedded text: text=%q raw=%q", caret.Source.Text, caret.Source.BrailleLine) + } + break caretEvent + case <-caretDeadline.C: + t.Fatal("mixed-line caret event missing") + case <-ctx.Done(): + caretDeadline.Stop() + t.Fatal(ctx.Err()) + } + } var paragraph ObjectReference captured, err := page.Document(ctx, ref) if err != nil { diff --git a/internal/atspi/text_embedded_native_test.go b/internal/atspi/text_embedded_native_test.go new file mode 100644 index 0000000..a14763e --- /dev/null +++ b/internal/atspi/text_embedded_native_test.go @@ -0,0 +1,41 @@ +package atspi + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestEmbeddedTextBudgetReportsIncompleteExpansion(t *testing.T) { + for _, test := range []struct { + name, raw, want string + depth int + budget embeddedTextBudget + incomplete bool + }{ + {"nodes", "Before \ufffc after", "Before after", 0, embeddedTextBudget{nodes: 0, characters: 100, deadline: time.Now().Add(time.Hour)}, true}, + {"deadline", "Before \ufffc after", "Before after", 0, embeddedTextBudget{nodes: 32, characters: 100, deadline: time.Time{}}, true}, + {"depth", "Before \ufffc after", "Before after", 8, embeddedTextBudget{nodes: 32, characters: 100, deadline: time.Now().Add(time.Hour)}, true}, + {"characters", "猫text", "猫", 0, embeddedTextBudget{nodes: 32, characters: 1}, true}, + {"complete plain text", "猫text", "猫text", 0, embeddedTextBudget{nodes: 32, characters: 5}, false}, + } { + t.Run(test.name, func(t *testing.T) { + // These budgets stop before any native child query. No provider + // reference is needed to exercise the actual expansion limits. + got := expandNativeEmbeddedText(nil, test.raw, 0, test.depth, &test.budget) + if got != test.want || test.budget.incomplete != test.incomplete || strings.ContainsRune(got, '\ufffc') { + t.Fatalf("text=%q incomplete=%t", got, test.budget.incomplete) + } + }) + } +} + +func TestEmbeddedDocumentTextSkipsProtectedAndEditableObjects(t *testing.T) { + for _, snapshot := range []Snapshot{{Role: "password text"}, {Role: "terminal"}, {Role: "entry", Editable: true}} { + line := expandNativeDocumentText(context.Background(), nil, snapshot, DocumentCaretText{Text: "\ufffc", Start: 0, End: 1, CharacterCount: 1}) + if line.Text != "\ufffc" || line.RawText != "\ufffc" || line.Expanded { + t.Fatalf("unexpected expansion for %s: %+v", snapshot.Role, line) + } + } +} diff --git a/internal/atspi/text_native.go b/internal/atspi/text_native.go index 153fbd3..8f6b154 100644 --- a/internal/atspi/text_native.go +++ b/internal/atspi/text_native.go @@ -43,6 +43,25 @@ import ( type embeddedTextBudget struct { nodes, characters int deadline time.Time + incomplete bool +} + +// The display text may expand, but every offset continues to describe RawText. +// Keep the same bounded expansion and recovery rules for browse reads and +// native caret snapshots. +func expandNativeDocumentText(ctx context.Context, source *C.AtspiAccessible, snapshot Snapshot, line DocumentCaretText) DocumentCaretText { + line.RawText = line.Text + if snapshot.Editable || snapshot.Role == "password text" || snapshot.Role == "terminal" || !strings.ContainsRune(line.Text, '\ufffc') { + return line + } + deadline := time.Now().Add(100 * time.Millisecond) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + budget := embeddedTextBudget{nodes: 32, characters: maxSnapshotFieldLength, deadline: deadline} + expanded := expandNativeEmbeddedText(source, line.Text, line.Start, 0, &budget) + line.applyExpandedText(expanded, snapshot.Name, budget.incomplete) + return line } func expandNativeEmbeddedText(source *C.AtspiAccessible, content string, start, depth int, budget *embeddedTextBudget) string { @@ -51,6 +70,7 @@ func expandNativeEmbeddedText(source *C.AtspiAccessible, content string, start, for _, char := range content { offset++ if budget.characters <= 0 { + budget.incomplete = true break } if char != '\ufffc' { @@ -59,6 +79,7 @@ func expandNativeEmbeddedText(source *C.AtspiAccessible, content string, start, continue } if depth >= 8 || budget.nodes <= 0 || time.Now().After(budget.deadline) { + budget.incomplete = true continue } budget.nodes-- @@ -70,6 +91,7 @@ func expandNativeEmbeddedText(source *C.AtspiAccessible, content string, start, func expandNativeEmbeddedChild(source *C.AtspiAccessible, offset, depth int, budget *embeddedTextBudget) string { hypertext := C.atspi_accessible_get_hypertext_iface(source) if hypertext == nil { + budget.incomplete = true return "" } defer C.g_object_unref(C.gpointer(hypertext)) @@ -77,18 +99,22 @@ func expandNativeEmbeddedChild(source *C.AtspiAccessible, offset, depth int, bud index := C.atspi_hypertext_get_link_index(hypertext, C.gint(offset), &nativeErr) if nativeErr != nil { C.g_error_free(nativeErr) + budget.incomplete = true return "" } if index < 0 { + budget.incomplete = true return "" } link := C.atspi_hypertext_get_link(hypertext, index, nil) if link == nil { + budget.incomplete = true return "" } defer C.g_object_unref(C.gpointer(link)) child := C.atspi_hyperlink_get_object(link, 0, nil) if child == nil { + budget.incomplete = true return "" } defer C.g_object_unref(C.gpointer(child)) @@ -98,23 +124,34 @@ func expandNativeEmbeddedChild(source *C.AtspiAccessible, offset, depth int, bud } text := C.atspi_accessible_get_text_iface(child) if text == nil { + budget.incomplete = true return "" } defer C.g_object_unref(C.gpointer(text)) count := C.atspi_text_get_character_count(text, nil) if count <= 0 || time.Now().After(budget.deadline) { + budget.incomplete = true return "" } + if count > C.gint(maxSnapshotFieldLength) { + budget.incomplete = true + } content := C.atspi_text_get_text(text, 0, min(count, C.gint(maxSnapshotFieldLength)), nil) if content == nil { + budget.incomplete = true return "" } defer C.g_free(C.gpointer(content)) length := int(C.review_text_length(content, C.int(maxSnapshotFieldLength*utf8.UTFMax))) if length < 0 || length > maxSnapshotFieldLength*utf8.UTFMax { + budget.incomplete = true return "" } - expanded := expandNativeEmbeddedText(child, C.GoStringN(content, C.int(length)), 0, depth, budget) + value := C.GoStringN(content, C.int(length)) + if !utf8.ValidString(value) || utf8.RuneCountInString(value) != min(int(count), maxSnapshotFieldLength) { + budget.incomplete = true + } + expanded := expandNativeEmbeddedText(child, value, 0, depth, budget) switch role { case C.ATSPI_ROLE_HEADING, C.ATSPI_ROLE_LIST, C.ATSPI_ROLE_LIST_ITEM, C.ATSPI_ROLE_PARAGRAPH, C.ATSPI_ROLE_SECTION, C.ATSPI_ROLE_TABLE, C.ATSPI_ROLE_TABLE_CELL, C.ATSPI_ROLE_TABLE_ROW: diff --git a/internal/braille/cells.go b/internal/braille/cells.go new file mode 100644 index 0000000..eb2fbd0 --- /dev/null +++ b/internal/braille/cells.go @@ -0,0 +1,63 @@ +package braille + +import ( + "errors" + "unicode/utf8" +) + +// displayMasks is shared by the BrlAPI writer and cell export. A zero AND mask +// makes a Unicode Braille cell independent of BRLTTY's text translation table. +// Dots are attributes to OR into the cell, not a replacement cell array. +func displayMasks(text string, dots []byte) ([]byte, []byte, error) { + if !utf8.ValidString(text) { + return nil, nil, errors.New("invalid braille text") + } + length := utf8.RuneCountInString(text) + if len(dots) != 0 && len(dots) != length { + return nil, nil, errors.New("invalid braille indicator length") + } + andMask, orMask := make([]byte, length), make([]byte, length) + for index, cell := range []rune(text) { + andMask[index] = 0xff + if len(dots) != 0 { + orMask[index] = dots[index] + } + if cell >= 0x2800 && cell <= 0x28ff { + andMask[index] = 0 + orMask[index] |= byte(cell - 0x2800) + } + } + return andMask, orMask, nil +} + +// rawCells exports a translated viewport without opening a display. It shares +// the physical writer's cell/attribute handling and the existing viewport's +// translation, wrapping and routing maps. The caller owns this viewport and +// serializes all access, independently of any physical viewport. +// +// Ordinary text needs BRLTTY translation and is deliberately rejected. Callers +// must first use the existing liblouis path with an explicitly chosen table. +// Spaces here are blank padding or the synthetic end-of-line cursor cell. +// cursorDots is an explicit, static overlay; this does not reproduce BRLTTY's +// configured cursor shape or blink timing. No native routing metadata is exported. +func (v *viewport) rawCells(cursorDots byte) ([]byte, error) { + if _, err := (Geometry{Columns: v.width, Rows: 1}).Cells(); err != nil { + return nil, err + } + text := []rune(v.frame()) + for index, cell := range text { + if cell == ' ' { + text[index] = '\u2800' + } else if cell < 0x2800 || cell > 0x28ff { + return nil, errors.New("raw braille export requires translated cells") + } + } + _, cells, err := displayMasks(string(text), v.displayDots()) + if err != nil { + return nil, err + } + if cursor := v.displayCursor(); cursor > 0 { + cells[cursor-1] |= cursorDots + } + return cells, nil +} diff --git a/internal/braille/cells_test.go b/internal/braille/cells_test.go new file mode 100644 index 0000000..8c45a24 --- /dev/null +++ b/internal/braille/cells_test.go @@ -0,0 +1,127 @@ +package braille + +import ( + "bytes" + "reflect" + "testing" +) + +func TestDisplayMasksPreserveEveryRawCell(t *testing.T) { + text := make([]rune, 256) + dots := make([]byte, 256) + for index := range text { + text[index] = rune(0x2800 + index) + dots[index] = 0x40 + } + andMask, orMask, err := displayMasks(string(text), nil) + if err != nil { + t.Fatal(err) + } + for index := range text { + if andMask[index] != 0 || orMask[index] != byte(index) { + t.Fatalf("cell %d: and=%x or=%x", index, andMask[index], orMask[index]) + } + } + andMask, orMask, err = displayMasks(string(text), dots) + if err != nil { + t.Fatal(err) + } + for index := range text { + if andMask[index] != 0 || orMask[index] != byte(index)|0x40 { + t.Fatalf("cell %d lost raw dots or attributes", index) + } + } +} + +func TestDisplayMasksRetainTextTranslation(t *testing.T) { + andMask, orMask, err := displayMasks("a界 ⠁", []byte{0x40, 0, 0, 0x80}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(andMask, []byte{0xff, 0xff, 0xff, 0}) || + !bytes.Equal(orMask, []byte{0x40, 0, 0, 0x81}) { + t.Fatalf("and=%x or=%x", andMask, orMask) + } + for _, tc := range []struct { + text string + dots []byte + }{{"\xff", nil}, {"界", []byte{1, 2}}, {"", []byte{1}}} { + if _, _, err := displayMasks(tc.text, tc.dots); err == nil { + t.Fatal("accepted invalid frame") + } + } +} + +func TestRawCellsCursorPaddingAndSnapshotOwnership(t *testing.T) { + v := viewport{width: 4} + if err := v.setFrame(Frame{ID: 8, Text: "⠁⠃", Cursor: 2, Dots: []byte{0x40, 0}}); err != nil { + t.Fatal(err) + } + cells, err := v.rawCells(0xc0) + if err != nil || !bytes.Equal(cells, []byte{0x41, 3, 0xc0, 0}) { + t.Fatalf("cells=%x error=%v", cells, err) + } + if route, ok := v.route(RouteBase + 2); !ok || route != (RoutingEvent{ID: 8, Offset: 2}) { + t.Fatalf("end-of-line route=%+v ok=%v", route, ok) + } + if _, ok := v.route(RouteBase + 3); ok { + t.Fatal("padding has a routing target") + } + cells[0] = 0xff + withoutCursor, err := v.rawCells(0) + if err != nil || !bytes.Equal(withoutCursor, []byte{0x41, 3, 0, 0}) { + t.Fatalf("snapshot or cursor mutated viewport: %x, %v", withoutCursor, err) + } + if err := v.setFrame(Frame{ID: 9, Cursor: -1}); err != nil { + t.Fatal(err) + } + cleared, err := v.rawCells(0xc0) + if err != nil || !bytes.Equal(cleared, make([]byte, 4)) { + t.Fatalf("empty replacement=%x, %v", cleared, err) + } + if _, ok := v.route(RouteBase); ok { + t.Fatal("empty replacement retained routing") + } + if cells[1] != 3 { + t.Fatal("replacement changed an earlier snapshot") + } +} + +func TestRawCellsRequireTranslationAndValidWidth(t *testing.T) { + v := viewport{width: 4} + if err := v.setFrame(Frame{Text: "cat", Cursor: -1}); err != nil { + t.Fatal(err) + } + if _, err := v.rawCells(0xc0); err == nil { + t.Fatal("ordinary text was exported as guessed dots") + } + for _, width := range []int{-1, 0, 4097} { + invalid := viewport{width: width} + if _, err := invalid.rawCells(0); err == nil { + t.Fatalf("accepted width %d", width) + } + } + v = viewport{width: 4096} + if err := v.setFrame(Frame{Cursor: -1}); err != nil { + t.Fatal(err) + } + cells, err := v.rawCells(0) + if err != nil || len(cells) != 4096 { + t.Fatalf("maximum width: %d, %v", len(cells), err) + } +} + +func TestRawCellsFailureDoesNotAlterViewport(t *testing.T) { + v := viewport{width: 2} + if err := v.setFrame(Frame{Text: "abcd", Cursor: -1}); err != nil { + t.Fatal(err) + } + v.pan(PanRight) + before := v + if _, err := v.rawCells(0xc0); err == nil { + t.Fatal("accepted untranslated text") + } + if !reflect.DeepEqual(v, before) { + t.Fatal("failed export changed local presentation") + } +} diff --git a/internal/braille/native.go b/internal/braille/native.go index 0581b33..d99a9cd 100644 --- a/internal/braille/native.go +++ b/internal/braille/native.go @@ -168,23 +168,12 @@ func (n *native) WriteWithDots(text string, cursor int, dots []byte) error { if !utf8.ValidString(text) || utf8.RuneCountInString(text) != n.cells || cursor < 0 || cursor > n.cells { return errors.New("invalid braille frame") } - if len(dots) != 0 && len(dots) != n.cells { - return errors.New("invalid braille indicator length") + andMask, orMask, err := displayMasks(text, dots) + if err != nil { + return err } value := C.CString(text) defer C.free(unsafe.Pointer(value)) - andMask := make([]byte, n.cells) - orMask := make([]byte, n.cells) - for index, cell := range []rune(text) { - andMask[index] = 0xff - if len(dots) != 0 { - orMask[index] = dots[index] - } - if cell >= 0x2800 && cell <= 0x28ff { - andMask[index] = 0 - orMask[index] |= byte(cell - 0x2800) - } - } if C.cth_write(n.session, value, C.int(len(text)), C.int(cursor), (*C.uchar)(unsafe.Pointer(&andMask[0])), (*C.uchar)(unsafe.Pointer(&orMask[0]))) < 0 { return errors.New("BrlAPI display write failed") diff --git a/internal/braille/remote_braille_native_test.go b/internal/braille/remote_braille_native_test.go new file mode 100644 index 0000000..5d9dc62 --- /dev/null +++ b/internal/braille/remote_braille_native_test.go @@ -0,0 +1,116 @@ +//go:build brlapi && cgo + +package braille + +import ( + "bytes" + "reflect" + "testing" +) + +// These feasibility checks use real liblouis translation and the helper's +// viewport, but never construct or open a BrlAPI backend. The two viewports +// model independently owned local and remote sessions, not a session API. +func TestVirtualBrailleIndependentWidthsAndRouting(t *testing.T) { + frame := Frame{ID: 11, Text: "the cat", Cursor: -1, Contract: true, + Table: "en-us-g2.ctb", WordWrap: true, Dots: []byte{0, 0x40, 0, 0, 0, 0, 0}} + local, remote := viewport{width: 8}, viewport{width: 4} + for _, v := range []*viewport{&local, &remote} { + if err := v.setFrame(frame); err != nil { + t.Fatal(err) + } + } + localCells, err := local.rawCells(0xc0) + if err != nil || !bytes.Equal(localCells, []byte{0x6e, 0, 9, 1, 0x1e, 0, 0, 0}) { + t.Fatalf("local=%x error=%v", localCells, err) + } + remoteCells, err := remote.rawCells(0xc0) + if err != nil || !bytes.Equal(remoteCells, []byte{0x6e, 0, 0, 0}) { + t.Fatalf("wrapped remote=%x error=%v", remoteCells, err) + } + if route, ok := remote.route(RouteBase + 1); !ok || route != (RoutingEvent{ID: 11, Offset: 3}) { + t.Fatalf("contracted route=%+v ok=%v", route, ok) + } + if _, ok := remote.route(RouteBase + 2); ok { + t.Fatal("wrapped padding routed into hidden text") + } + if !remote.pan(PanRight) { + t.Fatal("remote did not pan") + } + panned, err := remote.rawCells(0xc0) + if err != nil || !bytes.Equal(panned, []byte{9, 1, 0x1e, 0}) { + t.Fatalf("panned=%x error=%v", panned, err) + } + if route, ok := remote.route(RouteBase); !ok || route != (RoutingEvent{ID: 11, Offset: 4}) { + t.Fatalf("panned route=%+v ok=%v", route, ok) + } + if !remote.pan(PanLeft) { + t.Fatal("remote did not pan back") + } + restored, err := remote.rawCells(0xc0) + if err != nil || !bytes.Equal(restored, remoteCells) { + t.Fatalf("restored=%x error=%v", restored, err) + } + // A new width gets fresh state. No physical geometry is changed and no + // old pan offset or routing map is carried across the replacement. + remote = viewport{width: 2} + frame.ID = 12 + if err := remote.setFrame(frame); err != nil { + t.Fatal(err) + } + if route, ok := remote.route(RouteBase); !ok || route.ID != 12 { + t.Fatal("replacement retained an old frame identity") + } + unchanged, err := local.rawCells(0xc0) + if err != nil || !bytes.Equal(unchanged, localCells) || local.offset != 0 || local.width != 8 || local.id != 11 { + t.Fatal("remote pan/resize altered local viewport") + } +} + +func TestVirtualBrailleCursorUsesTranslatedPosition(t *testing.T) { + v := viewport{width: 4} + if err := v.setFrame(Frame{Text: "the cat", Cursor: 4, Contract: true, Table: "en-us-g2.ctb"}); err != nil { + t.Fatal(err) + } + cells, err := v.rawCells(0xc0) + if err != nil || !bytes.Equal(cells, []byte{0x2e, 0, 0xc9, 1}) { + t.Fatalf("translated cursor=%x error=%v", cells, err) + } + if !v.pan(PanRight) { + t.Fatal("did not pan past cursor") + } + cells, err = v.rawCells(0xc0) + if err != nil || !bytes.Equal(cells, []byte{0x1e, 0, 0, 0}) { + t.Fatalf("hidden cursor=%x error=%v", cells, err) + } + if err := v.setFrame(Frame{ID: 14, Text: "the cat", Cursor: 7, Contract: true, Table: "en-us-g2.ctb"}); err != nil { + t.Fatal(err) + } + cells, err = v.rawCells(0xc0) + if err != nil || !bytes.Equal(cells, []byte{0x1e, 0xc0, 0, 0}) { + t.Fatalf("end cursor=%x error=%v", cells, err) + } + if route, ok := v.route(RouteBase + 1); !ok || route != (RoutingEvent{ID: 14, Offset: 7}) { + t.Fatalf("end route=%+v ok=%v", route, ok) + } +} + +func TestVirtualBrailleExplicitComputerTableAndFailure(t *testing.T) { + v := viewport{width: 4} + // Contract selects the helper's liblouis path; the table determines whether + // it contracts. A future virtual-helper API must resolve this distinction. + if err := v.setFrame(Frame{ID: 15, Text: "cat", Cursor: -1, Contract: true, Table: "en-us-comp8.ctb"}); err != nil { + t.Fatal(err) + } + cells, err := v.rawCells(0) + if err != nil || !bytes.Equal(cells, []byte{9, 1, 0x1e, 0}) { + t.Fatalf("computer table=%x error=%v", cells, err) + } + before := v + if err := v.setFrame(Frame{Text: "cat", Cursor: -1, Contract: true, Table: "missing-remote-test-table.ctb"}); err == nil { + t.Fatal("missing table accepted") + } + if !reflect.DeepEqual(v, before) { + t.Fatal("failed translation replaced a valid viewport") + } +} diff --git a/internal/interaction/element.go b/internal/interaction/element.go new file mode 100644 index 0000000..259da6f --- /dev/null +++ b/internal/interaction/element.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package interaction + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +// UnmarshalJSON keeps a malformed secret flag from silently becoming an +// ordinary entry (encoding/json otherwise accepts null for a boolean). The +// wire decoder separately rejects duplicate keys and bounds nesting/framing. +func (element *Element) UnmarshalJSON(data []byte) error { + type plain Element + var value plain + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + for name, raw := range fields { + if strings.EqualFold(name, "secret") { + flag := string(bytes.TrimSpace(raw)) + if name != "secret" || flag != "true" && flag != "false" { + return errors.New("secret must be a boolean") + } + } + } + *element = Element(value) + return nil +} diff --git a/internal/interaction/secret_test.go b/internal/interaction/secret_test.go new file mode 100644 index 0000000..9525848 --- /dev/null +++ b/internal/interaction/secret_test.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package interaction + +import "testing" + +func secretForm(text string) Form { + return Form{Title: "Connection", Elements: []Element{ + {ID: "key", Label: "Channel key", Kind: Entry, Secret: true, Text: text, Required: true, MaxLength: 12}, + {ID: "connect", Label: "Connect", Kind: Button}, + }} +} + +func TestSecretEntryEditingAndPresentation(t *testing.T) { + session, err := NewSession(secretForm("猫é123")) + if err != nil { + t.Fatal(err) + } + if got := session.Opening(); got.Speech != "Connection. Channel key, password edit, 5 characters" || got.Braille != "Channel key, password edit: *****" || got.Cursor != 33 { + t.Fatalf("unexpected masked opening: %+v", got) + } + steps := []struct { + event Event + speech, text, braille string + }{ + {Event{Key: Home}, "Hidden character", "猫é123", "Channel key, password edit: *****"}, + {Event{Key: Right, Shift: true}, "Hidden character, selected", "猫é123", "Channel key, password edit: ***** (selected 1 characters)"}, + {Event{Text: "密"}, "Inserted 1 character", "密é123", "Channel key, password edit: *****"}, + {Event{Key: Delete}, "Deleted 1 character", "密123", "Channel key, password edit: ****"}, + {Event{Key: Backspace}, "Deleted 1 character", "123", "Channel key, password edit: ***"}, + {Event{Key: End}, "End of text", "123", "Channel key, password edit: ***"}, + {Event{Text: "a", Control: true}, "Selected all", "123", "Channel key, password edit: *** (selected 3 characters)"}, + {Event{Text: "c", Control: true}, "Copy and cut are unavailable for password fields", "123", "Channel key, password edit: *** (selected 3 characters)"}, + {Event{Text: "x", Control: true}, "Copy and cut are unavailable for password fields", "123", "Channel key, password edit: *** (selected 3 characters)"}, + {Event{Key: Backspace}, "Deleted 3 characters", "", "Channel key, password edit: "}, + {Event{Key: Enter}, "Channel key is required", "", "Channel key is required"}, + } + for index, step := range steps { + effect := session.Handle(step.event) + if effect.Speech != step.speech || effect.Braille != step.braille || effect.Copy != nil || effect.Activated != "" || session.Values()["key"].Text != step.text { + t.Fatalf("secret editing step %d failed", index) + } + } + if !session.Handle(Event{Text: "v", Control: true}).Paste { + t.Fatal("explicit paste not requested") + } + if effect := session.Insert("pasted猫"); effect.Speech != "Inserted 7 characters" || effect.Braille != "Channel key, password edit: *******" { + t.Fatal("paste was not masked") + } + for _, text := range []string{"oversized content", "bad\ntext", string([]byte{0xff})} { + session.Insert(text) + if session.Values()["key"].Text != "pasted猫" { + t.Fatal("invalid paste modified secret") + } + } + session.Handle(Event{Key: Tab}) + if got := session.Handle(Event{Key: Tab, Shift: true}); got.Speech != "Channel key, password edit, 7 characters" { + t.Fatal("returning to password did not mask it") + } + session.Handle(Event{Key: Tab}) + if effect := session.Handle(Event{Key: Enter}); effect.Activated != "connect" || session.Values()["key"].Text != "pasted猫" { + t.Fatal("submission lost the actual secret") + } +} + +func TestSecretEntryCloseAndValidation(t *testing.T) { + for _, escape := range []bool{false, true} { + session, _ := NewSession(secretForm("fixture")) + if escape { + if !session.Handle(Event{Key: Escape}).Cancelled { + t.Fatal("escape did not cancel") + } + } else { + session.Close() + } + session.Close() + if session.Values() != nil || session.elements[0].element.Text != "" || session.Presentation() != (Presentation{}) || session.Insert("more") != (Effect{}) { + t.Fatal("closed editor retained or accepted a secret") + } + } + for _, kind := range []Kind{List, CheckBox, Button} { + form := secretForm("") + form.Elements[0].Kind = kind + if _, err := NewSession(form); err == nil { + t.Fatal("secret accepted on non-entry") + } + } + if err := ValidatePreferences(secretForm("fixture")); err == nil { + t.Fatal("secret accepted in persistent preferences") + } +} diff --git a/internal/interaction/session.go b/internal/interaction/session.go index 70342a1..545e85a 100644 --- a/internal/interaction/session.go +++ b/internal/interaction/session.go @@ -38,6 +38,8 @@ type Element struct { Disabled bool `json:"disabled,omitempty"` Required bool `json:"required,omitempty"` MaxLength int `json:"max_length,omitempty"` + // Secret is supported only on transient entries, never preference pages. + Secret bool `json:"secret,omitempty"` } type Form struct { Title string `json:"title"` @@ -107,6 +109,11 @@ func NewSession(form Form) (*Session, error) { // ValidatePreferences shares control validation with internal forms, while // allowing a native, scrolling page to contain several full voice editors. func ValidatePreferences(form Form) error { + for _, element := range form.Elements { + if element.Secret { + return errors.New("secret entries are not supported in preferences") + } + } _, err := new_session(form, 256, 256) return err } @@ -132,6 +139,9 @@ func new_session(form Form, maxItems, maxElements int) (*Session, error) { return nil, errors.New("controls need unique IDs, labels and supported kinds") } ids[element.ID] = true + if element.Secret && element.Kind != Entry { + return nil, errors.New("only entries may be secret") + } if element.MaxLength == 0 { element.MaxLength = maxTextLength } @@ -190,8 +200,12 @@ func (session *Session) Presentation() Presentation { case List: text += fmt.Sprintf(": %s, %d of %d", element.Items[state.selected].Label, state.selected+1, len(element.Items)) case Entry: - text += ", edit: " + element.Text - cursor = len([]rune(element.Label+", edit: ")) + state.caret + prefix, value := ", edit: ", element.Text + if element.Secret { + prefix, value = ", password edit: ", strings.Repeat("*", utf8.RuneCountInString(element.Text)) + } + text += prefix + value + cursor = len([]rune(element.Label+prefix)) + state.caret if state.caret != state.anchor { text += fmt.Sprintf(" (selected %d characters)", abs(state.caret-state.anchor)) } @@ -205,6 +219,9 @@ func (session *Session) Presentation() Presentation { text += ", button" } speech := text + if element.Secret { + speech = element.Label + ", password edit, " + characterCount(utf8.RuneCountInString(element.Text)) + } if element.Kind == List { speech = element.Items[state.selected].Label } @@ -225,7 +242,12 @@ func (session *Session) Opening() Presentation { return output } +// Values includes plaintext secrets for the submitting caller. It must never +// be used for presentation, logging or generic settings persistence. func (session *Session) Values() map[string]Value { + if session.closed { + return nil + } if session.grid != nil { return session.grid.values() } @@ -240,6 +262,17 @@ func (session *Session) Values() map[string]Value { return values } +// Close drops the editor's references to secrets on submission, cancellation +// or capture revocation. Go strings do not provide guaranteed memory erasure. +func (session *Session) Close() { + session.closed = true + for index := range session.elements { + if session.elements[index].element.Secret { + session.elements[index].element.Text = "" + } + } +} + func (session *Session) Handle(event Event) Effect { if session.grid != nil { return session.grid.handle(event) @@ -248,7 +281,7 @@ func (session *Session) Handle(event Event) Effect { return Effect{} } if event.Key == Escape { - session.closed = true + session.Close() return Effect{Cancelled: true} } if event.Key == Tab { @@ -372,6 +405,13 @@ func abs(value int) int { return value } +func characterCount(count int) string { + if count == 1 { + return "1 character" + } + return fmt.Sprintf("%d characters", count) +} + func (session *Session) edit(event Event) Effect { state := &session.elements[session.focus] characters := []rune(state.element.Text) @@ -386,6 +426,11 @@ func (session *Session) edit(event Event) Effect { case "v": return Effect{Paste: true} case "c", "x": + if state.element.Secret { + output := session.Presentation() + output.Speech = "Copy and cut are unavailable for password fields" + return Effect{Presentation: output} + } if start == end { return Effect{} } @@ -439,6 +484,8 @@ func (session *Session) edit(event Event) Effect { output := session.Presentation() if next == len(characters) { output.Speech = "End of text" + } else if state.element.Secret { + output.Speech = "Hidden character" } else { output.Speech = string(characters[next]) } @@ -462,7 +509,11 @@ func (session *Session) edit(event Event) Effect { state.element.Text = string(append(characters[:start], characters[end:]...)) state.caret, state.anchor = start, start output := session.Presentation() - output.Speech = "Deleted " + removed + if state.element.Secret { + output.Speech = "Deleted " + characterCount(end-start) + } else { + output.Speech = "Deleted " + removed + } return Effect{Presentation: output} } if event.Key == Text && event.Text != "" && !event.Control { @@ -501,6 +552,10 @@ func (session *Session) Insert(text string) Effect { state.element.Text = string(characters[:start]) + text + string(characters[end:]) state.caret, state.anchor = start+len(inserted), start+len(inserted) output := session.Presentation() - output.Speech = text + if state.element.Secret { + output.Speech = "Inserted " + characterCount(len(inserted)) + } else { + output.Speech = text + } return Effect{Presentation: output} } diff --git a/internal/platform/xorg/commands_local.c b/internal/platform/xorg/commands_local.c new file mode 100644 index 0000000..b3ae015 --- /dev/null +++ b/internal/platform/xorg/commands_local.c @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "commands_native.h" +#include +#include +#include +#include +#include + +// Xorg marks XTEST devices with a non-deletable, read-only property. Device +// names and the core send_event flag are not provenance. Unknown/non-device +// sources stay ineligible; no timing match against XRecord is involved. +static int local_source(CommandDevice *device, int source) { + if (source <= 0 || source == device->localKeyboard) return 0; + int count = 0, allowed = 0; + cthulhu_x11_begin_read(device->display); + XIDeviceInfo *info = XIQueryDevice(device->display, source, &count); + if (info && count == 1 && info->use == XISlaveKeyboard && info->enabled && + info->attachment == device->localKeyboard) { + Atom type = None; + int format = 0; + unsigned long items = 0, remaining = 0; + unsigned char *data = NULL; + Atom xtest = XInternAtom(device->display, "XTEST Device", True); + int result = xtest == None ? BadAtom : XIGetProperty(device->display, + source, xtest, 0, 1, False, AnyPropertyType, &type, &format, + &items, &remaining, &data); + // The atom must exist on the server, but not on this source device. + int synthetic = result != Success || type != None; + if (data) XFree(data); + data = NULL; + Atom node = XInternAtom(device->display, "Device Node", True); + if (!synthetic && node != None && XIGetProperty(device->display, source, + node, 0, 64, False, XA_STRING, &type, &format, &items, + &remaining, &data) == Success && type == XA_STRING && format == 8 && + items > 16 && items < 256 && remaining == 0 && data && + memcmp(data, "/dev/input/event", 16) == 0) { + int valid = 1; + for (unsigned long i = 16; i < items; i++) + if (data[i] < '0' || data[i] > '9') valid = 0; + char path[256]; + memcpy(path, data, items); + path[items] = 0; + struct stat status; + if (valid && stat(path, &status) == 0 && S_ISCHR(status.st_mode)) + allowed = 1; + } + if (data) XFree(data); + } + if (info) XIFreeDeviceInfo(info); + if (cthulhu_x11_end_read(device->display)) allowed = 0; + return allowed; +} + +static int acquire_local_keyboard_unchecked(CommandDevice *device) { + int eventBase, errorBase, major = 2, minor = 0, pointer = 0; + if (device->active || !XQueryExtension(device->display, "XInputExtension", + &device->xiOpcode, &eventBase, &errorBase) || + XIQueryVersion(device->display, &major, &minor) != Success || major < 2 || + !XIGetClientPointer(device->display, None, &pointer)) return -1; + int count = 0, keyboard = 0; + XIDeviceInfo *info = XIQueryDevice(device->display, pointer, &count); + if (info && count == 1 && info->use == XIMasterPointer && info->enabled) + keyboard = info->attachment; + if (info) XIFreeDeviceInfo(info); + if (!keyboard) return -1; + // A key held before the dialog cannot submit it on repeat or provide an + // inherited modifier. The caller may retry after releasing that chord. + char held[32]; + XQueryKeymap(device->display, held); + for (int i = 0; i < 32; i++) if (held[i]) return -1; + unsigned char bits[XIMaskLen(XI_LASTEVENT)] = {0}; + XISetMask(bits, XI_KeyPress); + XISetMask(bits, XI_KeyRelease); + XIEventMask mask = {keyboard, sizeof(bits), bits}; + int result = XIGrabDevice(device->display, keyboard, device->root, CurrentTime, + None, GrabModeAsync, GrabModeAsync, False, &mask); + if (result != GrabSuccess) return result ? result : -1; + unsigned char changes[XIMaskLen(XI_LASTEVENT)] = {0}; + XISetMask(changes, XI_HierarchyChanged); + XIEventMask hierarchy = {XIAllDevices, sizeof(changes), changes}; + XISelectEvents(device->display, device->root, &hierarchy, 1); + device->localKeyboard = keyboard; + memset(device->localHeld, 0, sizeof(device->localHeld)); + // Initialize the ordinary text/compose renderer after acquiring XI2. Its + // core-grab branch is skipped because the device already owns capture. + device->active = 1; + acquire_internal_keyboard(device); + XFlush(device->display); + return 0; +} + +int acquire_local_keyboard(CommandDevice *device) { + // The selected master can disappear between query and grab. Like source + // validation, acquisition must turn BadDevice into denial, not reader exit. + cthulhu_x11_begin_read(device->display); + int result = acquire_local_keyboard_unchecked(device); + int error = cthulhu_x11_end_read(device->display); + if (error) { + release_local_keyboard(device); + device->active = 0; + reset_internal_compose(device); + return -1; + } + return result; +} + +// Convert the very same device event to the existing text renderer. Core and +// SendEvent traffic is discarded in this mode, as are XTEST and unknown sources. +int next_local_key(CommandDevice *device, XEvent *event) { + if (event->type != GenericEvent || event->xcookie.extension != device->xiOpcode || + !XGetEventData(device->display, &event->xcookie)) return 0; + int accepted = 0; + XKeyEvent key = {0}; + // Device removal/disable/reattachment invalidates this capture, including + // keys whose release can no longer arrive. Reopen against a fresh hierarchy. + if (!event->xcookie.send_event && event->xcookie.evtype == XI_HierarchyChanged) accepted = -1; + if (event->xcookie.evtype == XI_KeyPress || event->xcookie.evtype == XI_KeyRelease) { + XIDeviceEvent *input = event->xcookie.data; + if (!event->xcookie.send_event && !input->send_event && + input->deviceid == device->localKeyboard && input->detail >= 8 && + input->detail <= 255 && !(input->flags & XIKeyRepeat) && + local_source(device, input->sourceid)) { + int code = input->detail; + // Duplicate downs and releases without a down in this capture + // lifetime cannot navigate or activate a local form. + if ((input->evtype == XI_KeyPress) != !!device->localHeld[code]) { + key.type = input->evtype == XI_KeyPress ? KeyPress : KeyRelease; + key.display = device->display; + key.window = input->event; + key.root = input->root; + key.time = input->time; + key.keycode = code; + key.same_screen = True; + // Ignore modifiers held by an injected source. Build state + // solely from keys admitted during this local capture. + for (int i = 8; i < 256; i++) if (device->localHeld[i]) + key.state |= XkbKeysymToModifiers(device->display, + XkbKeycodeToKeysym(device->display, i, 0, 0)); + device->localHeld[code] = key.type == KeyPress; + accepted = 1; + } + } + } + XFreeEventData(device->display, &event->xcookie); + if (accepted > 0) { memset(event, 0, sizeof(*event)); event->xkey = key; } + return accepted; +} + +void release_local_keyboard(CommandDevice *device) { + if (device->localKeyboard) { + cthulhu_x11_begin_read(device->display); + XIUngrabDevice(device->display, device->localKeyboard, CurrentTime); + unsigned char bits[XIMaskLen(XI_LASTEVENT)] = {0}; + XIEventMask mask = {XIAllDevices, sizeof(bits), bits}; + XISelectEvents(device->display, device->root, &mask, 1); + // A removed master has already lost its grab. Still clear our local + // ownership and root event selection when XIUngrab reports BadDevice. + cthulhu_x11_end_read(device->display); + device->localKeyboard = 0; + memset(device->localHeld, 0, sizeof(device->localHeld)); + } +} diff --git a/internal/platform/xorg/commands_native.c b/internal/platform/xorg/commands_native.c index 9e64801..59aa6a4 100644 --- a/internal/platform/xorg/commands_native.c +++ b/internal/platform/xorg/commands_native.c @@ -588,6 +588,11 @@ int next_command_key(CommandDevice *device, CommandKey *key) { key->timestamp=((XkbEvent *)&event)->indicators.time; return 2; } + if (device->localKeyboard) { + int local = next_local_key(device, &event); + if (local < 0) return 3; + if (!local) return 0; + } if (event.type!=KeyPress && event.type!=KeyRelease) return 0; // Events captured before suspension can still be queued in Xlib after // the grabs are released. Discard that old reader input; it must neither @@ -654,6 +659,7 @@ int next_command_key(CommandDevice *device, CommandKey *key) { return 1; } void release_command_keyboard(CommandDevice *device) { + release_local_keyboard(device); XUngrabKeyboard(device->display,CurrentTime); XFlush(device->display); device->active=0; } int replay_command_keys(CommandDevice *device, CommandKey *keys, int count) { diff --git a/internal/platform/xorg/commands_native.h b/internal/platform/xorg/commands_native.h index 1c53fde..32b5a02 100644 --- a/internal/platform/xorg/commands_native.h +++ b/internal/platform/xorg/commands_native.h @@ -20,6 +20,8 @@ typedef struct { int commands; int active; int internalUI; + int localKeyboard, xiOpcode; + unsigned char localHeld[256]; int browse; int nativeButtonFocus; Window browseWindow; @@ -55,6 +57,9 @@ int command_lock_state(CommandDevice *device); void toggle_command_caps_lock(CommandDevice *device); int next_command_key(CommandDevice *device, CommandKey *key); int acquire_internal_keyboard(CommandDevice *device); +int acquire_local_keyboard(CommandDevice *device); +int next_local_key(CommandDevice *device, XEvent *event); +void release_local_keyboard(CommandDevice *device); void reset_internal_compose(CommandDevice *device); void release_command_keyboard(CommandDevice *device); int replay_command_keys(CommandDevice *device, CommandKey *keys, int count); diff --git a/internal/platform/xorg/commands_owner_loop.go b/internal/platform/xorg/commands_owner_loop.go index d213707..c1b498a 100644 --- a/internal/platform/xorg/commands_owner_loop.go +++ b/internal/platform/xorg/commands_owner_loop.go @@ -91,6 +91,21 @@ func (owner *commandOwner) run() { owner.publishLocks(key) continue } + if result == 3 { + // A changed XI2 hierarchy invalidates the local source set. A + // removed device may never release its held keys; retire that chord. + session := owner.uiSession + clear(owner.held) + owner.buffered = nil + owner.router.Reset() + owner.bound, owner.passThrough = false, false + owner.dismiss_internal_ui() + if session != 0 && !owner.listener.enqueueSecureCommand(input.CommandEvent{Command: input.InternalUIDismiss, UISession: session}) { + owner.report_ui_failure(errors.New("internal UI queue overflow")) + return + } + continue + } if !owner.handleKey(key) { return } diff --git a/internal/platform/xorg/commands_ui.go b/internal/platform/xorg/commands_ui.go index f0547f9..c300f96 100644 --- a/internal/platform/xorg/commands_ui.go +++ b/internal/platform/xorg/commands_ui.go @@ -6,6 +6,7 @@ package xorg /* #cgo pkg-config: xkbcommon +#cgo pkg-config: xi #include "commands_native.h" */ import "C" @@ -26,6 +27,7 @@ type uiUpdate struct { ctx context.Context closeSession uint64 result chan uiReply + localOnly bool } func (listener *CommandListener) InternalUISession() uint64 { return listener.uiSession.Load() } @@ -35,7 +37,14 @@ func (listener *CommandListener) InternalUIEchoActive() bool { return listener.u // BeginInternalUI acknowledges exclusive capture without changing X input // focus. EndInternalUI waits for physical releases, and cannot close a newer UI. func (listener *CommandListener) BeginInternalUI(ctx context.Context) (uint64, error) { - return listener.update_internal_ui(ctx, 0) + return listener.update_internal_ui(ctx, 0, false) +} + +// BeginLocalUI admits only device-backed XI2 events and excludes XTEST. This +// is a core API, never a Tentacle-controlled form flag. It fails on unsupported +// servers; ordinary forms keep their existing input path. +func (listener *CommandListener) BeginLocalUI(ctx context.Context) (uint64, error) { + return listener.update_internal_ui(ctx, 0, true) } func (listener *CommandListener) EndInternalUI(ctx context.Context, session uint64) error { if session == 0 { @@ -50,17 +59,17 @@ func (listener *CommandListener) EndInternalUI(ctx context.Context, session uint break } } - _, err := listener.update_internal_ui(ctx, session) + _, err := listener.update_internal_ui(ctx, session, false) return err } -func (listener *CommandListener) update_internal_ui(ctx context.Context, closeSession uint64) (uint64, error) { +func (listener *CommandListener) update_internal_ui(ctx context.Context, closeSession uint64, localOnly bool) (uint64, error) { listener.mutex.Lock() done := listener.done listener.mutex.Unlock() if done == nil { return 0, errors.New("command listener is not running") } - update := uiUpdate{ctx: ctx, closeSession: closeSession, result: make(chan uiReply, 1)} + update := uiUpdate{ctx: ctx, closeSession: closeSession, result: make(chan uiReply, 1), localOnly: localOnly} select { case listener.uiUpdates <- update: case <-ctx.Done(): @@ -76,7 +85,7 @@ func (listener *CommandListener) update_internal_ui(ctx context.Context, closeSe } } -func (owner *commandOwner) begin_internal_ui() (uint64, error) { +func (owner *commandOwner) begin_internal_ui_mode(localOnly bool) (uint64, error) { if !owner.commandGrabsAllowed() || owner.learnMode || owner.ocrReview || owner.uiSession != 0 { return 0, errors.New("internal UI input unavailable") } @@ -86,7 +95,13 @@ func (owner *commandOwner) begin_internal_ui() (uint64, error) { return 0, errors.New("no focused application") } owner.uiWindow = C.read_foreground(owner.device.display).window - if status := C.acquire_internal_keyboard(owner.device); status != 0 { + var status C.int + if localOnly { + status = C.acquire_local_keyboard(owner.device) + } else { + status = C.acquire_internal_keyboard(owner.device) + } + if status != 0 { return 0, fmt.Errorf("internal keyboard capture failed: %d", int(status)) } owner.uiSession = owner.listener.uiSequence.Add(1) @@ -96,6 +111,10 @@ func (owner *commandOwner) begin_internal_ui() (uint64, error) { return owner.uiSession, nil } +func (owner *commandOwner) begin_internal_ui() (uint64, error) { + return owner.begin_internal_ui_mode(false) +} + func (owner *commandOwner) dismiss_internal_ui() { owner.uiSession = 0 owner.listener.uiSession.Store(0) @@ -136,7 +155,7 @@ func (owner *commandOwner) sync_internal_ui() bool { owner.dismiss_internal_ui() } } else { - result.session, result.err = owner.begin_internal_ui() + result.session, result.err = owner.begin_internal_ui_mode(update.localOnly) } } update.result <- result diff --git a/internal/platform/xorg/local_ui_native_test.go b/internal/platform/xorg/local_ui_native_test.go new file mode 100644 index 0000000..d9f28df --- /dev/null +++ b/internal/platform/xorg/local_ui_native_test.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestNativeLocalUIRejectsInjectedApproval(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_REMOTE_INPUT") != "1" { + t.Skip("requires private Remote input harness") + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + probe := exec.CommandContext(ctx, "xev", "-name", "Local approval probe", "-event", "keyboard") + if err := probe.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = probe.Process.Kill(); _ = probe.Wait() }() + var window string + for window == "" { + data, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Local approval probe$").Output() + if err == nil { + window = strings.TrimSpace(string(data)) + } + if ctx.Err() != nil { + t.Fatal("probe unavailable") + } + time.Sleep(10 * time.Millisecond) + } + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { + t.Fatal(err) + } + listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + key := func(args ...string) { + t.Helper() + if data, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("private injection: %v %s", err, data) + } + } + key("keydown", "Return") + if id, err := listener.BeginLocalUI(ctx); err == nil { + _ = listener.EndInternalUI(ctx, id) + t.Fatal("capture admitted pre-held key") + } + key("keyup", "Return") + id, err := listener.BeginLocalUI(ctx) + if err != nil { + t.Fatal(err) + } + // XTEST has send_event=False. Neither navigation, approval, secret entry, + // clipboard shortcuts nor Escape may enter this local-only form. + key("key", "Tab", "space", "Return", "a", "ctrl+v", "Insert+space", "Insert+shift+a", "Escape") + select { + case event := <-listener.Commands(): + t.Fatalf("injected form command: %+v", event) + case err := <-listener.Failures(): + t.Fatal(err) + case <-time.After(150 * time.Millisecond): + } + if listener.InternalUISession() != id { + t.Fatal("injected Escape dismissed local form") + } + if err := listener.EndInternalUI(ctx, id); err != nil { + t.Fatal(err) + } + ordinary, err := listener.BeginInternalUI(ctx) + if err != nil { + t.Fatal(err) + } + key("key", "Return") + select { + case event := <-listener.Commands(): + if event.Command != input.InternalUIKey || event.UISession != ordinary { + t.Fatal(event) + } + case <-ctx.Done(): + t.Fatal("ordinary form stopped receiving keys") + } + if err := listener.EndInternalUI(ctx, ordinary); err != nil { + t.Fatal(err) + } + // Reopening local capture must not inherit the ordinary form's event path. + id, err = listener.BeginLocalUI(ctx) + if err != nil { + t.Fatal(err) + } + if data, err := exec.CommandContext(ctx, "xinput", "create-master", "CthulhuProbe").CombinedOutput(); err != nil { + t.Fatalf("private hierarchy change: %v %s", err, data) + } + defer func() { + data, err := exec.CommandContext(ctx, "xinput", "list", "--id-only", "CthulhuProbe pointer").Output() + if err == nil { + if out, err := exec.CommandContext(ctx, "xinput", "remove-master", strings.TrimSpace(string(data))).CombinedOutput(); err != nil { + t.Errorf("remove private master: %v %s", err, out) + } + } + }() + select { + case event := <-listener.Commands(): + if event.Command != input.InternalUIDismiss || event.UISession != id { + t.Fatal("hierarchy did not retire capture", event) + } + case <-ctx.Done(): + t.Fatal("hierarchy change left local capture active") + } + if err := listener.EndInternalUI(ctx, id); err != nil { + t.Fatal(err) + } + id, err = listener.BeginLocalUI(ctx) + if err != nil { + t.Fatal(err) + } + listener.SetSecureInputActive(true) + for listener.InternalUISession() != 0 { + if ctx.Err() != nil { + t.Fatal("secure input did not revoke local capture") + } + time.Sleep(time.Millisecond) + } + if err := listener.EndInternalUI(ctx, id); err != nil { + t.Fatal(err) + } + t.Log("XTEST rejected by XI2 source identity; genuine hardware approval remains unverified") +} diff --git a/internal/platform/xorg/remote_input_native_test.go b/internal/platform/xorg/remote_input_native_test.go new file mode 100644 index 0000000..8ea60d0 --- /dev/null +++ b/internal/platform/xorg/remote_input_native_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import ( + "context" + "os" + "os/exec" + "testing" + "time" +) + +// This records a prerequisite, not an injection-origin solution: the current +// XRecord observer receives XTEST traffic through the ordinary input path. +// Remote must add trusted provenance before these events can reach consent or +// forwarding decisions. Run only through tests/native_remote_input.py. +func TestNativeRemoteInjectedKeyObservation(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_REMOTE_INPUT") != "1" { + t.Skip("requires private Remote input harness") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + listener := New() + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer stopCancel() + if err := listener.Stop(stopCtx); err != nil { + t.Error(err) + } + }() + key := func(name string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", "key", name).CombinedOutput(); err != nil { + t.Fatalf("private key injection: %v %s", err, out) + } + } + // XRecord starts asynchronously. Wait for an observed marker instead of + // assuming a sleep is sufficient for server-side readiness. + tick := time.NewTicker(20 * time.Millisecond) + defer tick.Stop() +ready: + for { + select { + case <-tick.C: + key("F12") + case event := <-listener.Events(): + if event.Name == "F12" && !event.Pressed { + break ready + } + case <-ctx.Done(): + t.Fatal("XRecord did not observe readiness marker") + } + } + key("a") + for _, down := range []bool{true, false} { + for { + select { + case event := <-listener.Events(): + if event.Name != "a" { + continue + } + if event.Pressed != down || event.Keycode == 0 || event.Repeat { + t.Fatalf("injected transition = %+v; expected pressed=%v", event, down) + } + case <-ctx.Done(): + t.Fatal("injected transition missing from observer") + } + break + } + } + if listener.Overflow() != 0 { + t.Fatal("unexpected observer overflow") + } + t.Log("XTEST press/release enter the ordinary XRecord event stream; local provenance remains unresolved") +} diff --git a/internal/platform/xorg/xterm_handoff_native_test.go b/internal/platform/xorg/xterm_handoff_native_test.go index fada4b6..cc1d3e3 100644 --- a/internal/platform/xorg/xterm_handoff_native_test.go +++ b/internal/platform/xorg/xterm_handoff_native_test.go @@ -21,7 +21,7 @@ func TestNativeSuspendedQueuedKey(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() probe := filepath.Join(t.TempDir(), "suspension-probe") - if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "-o", probe, "-lX11", "-lXtst", "-lxkbcommon", "-lpthread").CombinedOutput(); err != nil { + if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "commands_local.c", "-o", probe, "-lX11", "-lXtst", "-lXi", "-lxkbcommon", "-lpthread").CombinedOutput(); err != nil { t.Fatalf("compile native probe: %v: %s", err, out) } for _, mode := range []string{"queued", "replay", "passive", "caps-handoff"} { diff --git a/internal/preferences/tentacle_confirmation.go b/internal/preferences/tentacle_confirmation.go index b3692fe..e8d77c3 100644 --- a/internal/preferences/tentacle_confirmation.go +++ b/internal/preferences/tentacle_confirmation.go @@ -23,6 +23,7 @@ func tentacleUpdateCapabilities(entry tentacle.Entry, manifest tentacle.Manifest if entry.ReaderSettings { previous = append(previous, "reader-settings") } + previous = append(previous, entry.RemoteGrants...) if len(previous) == 0 { previous = append(previous, "none") } @@ -45,10 +46,33 @@ func tentacleInstallConfirmation(entry tentacle.Entry, manifest tentacle.Manifes if slices.Contains(manifest.Capabilities, "reader-settings") { permissions += " It can also add preferences pages and quick settings, inspect speech voices, and temporarily change the reader voice configuration." } + permissions += remotePermissions(manifest.Capabilities) return fmt.Sprintf("%s %s version %s from %s?\n\n%s\n\nOnly install packages from publishers you trust. Tentacles run with your account's access; resource limits do not protect your files or network.\n\nTentacle management changes take effect immediately.", label, entry.Name, entry.Version, entry.Repository, permissions) } func tentacleUpdateConfirmation(entry tentacle.Entry, update tentacle.AvailableUpdate, manifest tentacle.Manifest) string { - permissions := tentacleUpdateCapabilities(entry, manifest) + permissions := tentacleUpdateCapabilities(entry, manifest) + remotePermissions(manifest.Capabilities) return fmt.Sprintf("Update %s from version %s to version %s?\n\n%s\n\nOnly update packages from publishers you trust. Tentacles run with your account's access.\n\nChanges take effect immediately.", update.Name, update.InstalledVersion, update.AvailableVersion, permissions) } + +// Install grants describe direction explicitly and never imply active consent. +func remotePermissions(grants []string) string { + descriptions := []struct{ grant, text string }{ + {"output-observe", "share local reader output with the remote controller"}, + {"remote-present", "present output received from the controlled computer"}, + {"keyboard-capture", "capture local keyboard input for the controlled computer"}, + {"keyboard-inject", "accept keyboard input from the remote controller"}, + {"braille-stream", "exchange remote braille cells and supported display gestures"}, + {"clipboard-exchange", "send clipboard text explicitly and receive text with session permission"}, + } + var requested []string + for _, item := range descriptions { + if slices.Contains(grants, item.grant) { + requested = append(requested, item.text) + } + } + if len(requested) == 0 { + return "" + } + return " Requested remote access: " + strings.Join(requested, "; ") + ". Installation does not start a connection or authorize control. Each session requires local approval, and unavailable reader operations remain disabled." +} diff --git a/internal/preferences/tentacle_confirmation_test.go b/internal/preferences/tentacle_confirmation_test.go index e4e4e62..2f16b13 100644 --- a/internal/preferences/tentacle_confirmation_test.go +++ b/internal/preferences/tentacle_confirmation_test.go @@ -51,3 +51,18 @@ func TestTentacleInstallConfirmationDescribesInteractionAndSettings(t *testing.T t.Fatalf("command-only installation described incorrectly: %s", message) } } + +func TestRemoteGrantConfirmationExplainsDirectionAndConsent(t *testing.T) { + entry := tentacle.Entry{RemoteGrants: []string{"output-observe"}} + manifest := tentacle.Manifest{Capabilities: []string{"output-observe", "keyboard-inject", "remote-present", "keyboard-capture", "braille-stream", "clipboard-exchange"}} + for _, message := range []string{tentacleInstallConfirmation(entry, manifest, "Install"), tentacleUpdateConfirmation(entry, tentacle.AvailableUpdate{}, manifest)} { + for _, want := range []string{"share local reader output", "accept keyboard input from the remote controller", "capture local keyboard input", "present output received", "Each session requires local approval", "receive text with session permission", "Installation does not start a connection"} { + if !strings.Contains(message, want) { + t.Fatalf("missing %q in %s", want, message) + } + } + } + if !strings.Contains(tentacleUpdateCapabilities(entry, manifest), "Current capabilities: output-observe.") { + t.Fatal("existing grants hidden") + } +} diff --git a/internal/tentacle/install.go b/internal/tentacle/install.go index 3c15c30..118381e 100644 --- a/internal/tentacle/install.go +++ b/internal/tentacle/install.go @@ -24,6 +24,8 @@ import ( "time" "github.com/pelletier/go-toml/v2" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) const maxPackageBytes = 32 << 20 @@ -197,7 +199,7 @@ func prepare_package(ctx context.Context, root string, repository Repository, lo if packageManifest.FormatVersion != manifest.FormatVersion || packageManifest.ID != manifest.ID || packageManifest.Version != manifest.Version || packageManifest.Name != manifest.Name || !slices.Equal(packageManifest.Capabilities, manifest.Capabilities) { return nil, errors.New("Package identity, version or permissions disagree with the catalog; refresh the repository.") } - if packageManifest.Runtime != "native" || !slices.Contains(packageManifest.Platforms, runtime.GOOS) || packageManifest.Protocol.Major != 1 || packageManifest.Protocol.Minor != 0 { + if packageManifest.Runtime != "native" || !slices.Contains(packageManifest.Platforms, runtime.GOOS) || packageManifest.Protocol.Major != 1 || (packageManifest.Protocol.Minor != 0 && packageManifest.Protocol.Minor != 1) { return nil, errors.New("Package runtime, platform or protocol is unsupported by this reader.") } presentation := slices.Contains(packageManifest.Capabilities, "presentation") @@ -213,7 +215,13 @@ func prepare_package(ctx context.Context, root string, repository Repository, lo if readerSettings { expectedAPIs = append(expectedAPIs, "reader-settings") } - if readerSettings && !interactive || interactive && !presentation || len(packageManifest.Capabilities) != len(expectedAPIs)-1 || !slices.Equal(packageManifest.APIs, expectedAPIs) { + valid := false + if packageManifest.Protocol.Minor == 0 { + valid = !(readerSettings && !interactive || interactive && !presentation) && len(packageManifest.Capabilities) == len(expectedAPIs)-1 && slices.Equal(packageManifest.APIs, expectedAPIs) + } else { + valid = tentaclewire.ValidNegotiation(1, packageManifest.APIs, packageManifest.Capabilities) + } + if !valid { return nil, errors.New("Package requires unsupported APIs or permissions.") } if len(packageManifest.EntryPoint) == 0 || len(packageManifest.EntryPoint) > 32 || !package_path(packageManifest.EntryPoint[0]) { @@ -246,7 +254,13 @@ func prepare_package(ctx context.Context, root string, repository Repository, lo prepared.directory = final program := slices.Clone(packageManifest.EntryPoint) program[0] = filepath.Join(final, filepath.FromSlash(program[0])) - prepared.Entry = Entry{ID: PackageID(repository, localID), LocalID: localID, Name: manifest.Name, Version: manifest.Version, + remoteGrants := []string{} + for _, grant := range tentaclewire.RemoteGrants() { + if slices.Contains(packageManifest.Capabilities, grant) { + remoteGrants = append(remoteGrants, grant) + } + } + prepared.Entry = Entry{SessionEvents: packageManifest.Protocol.Minor == 1, RemoteGrants: remoteGrants, ID: PackageID(repository, localID), LocalID: localID, Name: manifest.Name, Version: manifest.Version, Program: program, Directory: final, Presentation: presentation, Interaction: interactive, ReaderSettings: readerSettings, Repository: repository.URL, RepositoryID: repository.ID, RepositoryRevision: repository.Revision, PackageDirectory: final, PackageSHA256: release.SHA256} if err := ValidateEntries([]Entry{prepared.Entry}); err != nil { diff --git a/internal/tentacle/install_test.go b/internal/tentacle/install_test.go index 93df7dc..766b848 100644 --- a/internal/tentacle/install_test.go +++ b/internal/tentacle/install_test.go @@ -440,3 +440,63 @@ func TestInstallReaderSettingsPackageGrantAndDependencies(t *testing.T) { }) } } + +func TestSessionPackageGrantsPersistAndRejectMismatch(t *testing.T) { + for _, variant := range []string{"valid", "missing API", "unknown grant", "duplicate grant", "old version"} { + t.Run(variant, func(t *testing.T) { + members := native_members(t) + manifest := strings.Replace(package_manifest(), "minor=0", "minor=1", 1) + manifest = strings.Replace(manifest, "apis=['commands','presentation']", "apis=['remote-access','session-events','presentation','commands']", 1) + capabilities := []string{"presentation", "output-observe"} + grantText := "capabilities=['presentation','output-observe']" + switch variant { + case "missing API": + manifest = strings.Replace(manifest, "'remote-access',", "", 1) + case "unknown grant": + capabilities = append(capabilities, "execute") + grantText = "capabilities=['presentation','output-observe','execute']" + case "duplicate grant": + capabilities = append(capabilities, "output-observe") + grantText = "capabilities=['presentation','output-observe','output-observe']" + case "old version": + manifest = strings.Replace(manifest, "minor=1", "minor=0", 1) + } + manifest = strings.Replace(manifest, "capabilities=['presentation']", grantText, 1) + members[0].data = []byte(manifest) + repository, client := package_fixture(t, package_archive(t, members)) + repository.Tentacles[0].Capabilities = capabilities + prepared, err := prepare_package(t.Context(), filepath.Join(t.TempDir(), "packages"), repository, "echo", client) + if variant != "valid" { + if err == nil { + prepared.Discard() + t.Fatal("unsupported access installed") + } + return + } + if err != nil { + t.Fatal(err) + } + defer prepared.Discard() + entry := prepared.Entry + if !entry.SessionEvents || len(entry.RemoteGrants) != 1 || entry.RemoteGrants[0] != "output-observe" { + t.Fatal("lost grants", entry) + } + registry := &Registry{Path: filepath.Join(t.TempDir(), "registry.toml")} + if _, err := registry.Load(); err != nil { + t.Fatal(err) + } + if err := registry.Save([]Entry{entry}); err != nil { + t.Fatal(err) + } + entries, err := registry.Load() + if err != nil || len(entries) != 1 || !entries[0].Spec().SessionEvents || len(entries[0].Spec().RemoteGrants) != 1 { + t.Fatal("restart lost grants", err) + } + clone := CloneEntries(entries) + clone[0].RemoteGrants[0] = "keyboard-inject" + if entries[0].RemoteGrants[0] != "output-observe" { + t.Fatal("shared grant slice") + } + }) + } +} diff --git a/internal/tentacle/manager.go b/internal/tentacle/manager.go index 487938c..f6b60eb 100644 --- a/internal/tentacle/manager.go +++ b/internal/tentacle/manager.go @@ -15,6 +15,7 @@ import ( "time" "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) type Limits struct { @@ -35,6 +36,8 @@ func DefaultLimits() Limits { // Spec is a validated launch description, independent of package metadata. // Program[0] must be absolute; arguments are never interpreted by a shell. type Spec struct { + SessionEvents bool // Opt in to protocol 1.1 lifecycle calls and events. + RemoteGrants []string // Install grants only; do not authorize desktop access. ID string Program []string Directory string @@ -60,6 +63,9 @@ type Manager struct { containment *containment supervisorProgram []string cleanupError error + remoteMutex sync.Mutex + remoteOwner *Session + remotePolicy *RemotePolicy // immutable after the first child is started } // NewSupervisedManager launches a private lifetime helper for each Tentacle. @@ -144,12 +150,17 @@ func (manager *Manager) Start(ctx context.Context, spec Spec) (*Session, error) return nil, err } spec.Program = append([]string{}, spec.Program...) + spec.RemoteGrants = append([]string(nil), spec.RemoteGrants...) + queueSize := manager.limits.PendingPerProcess + 2 + if spec.SessionEvents { + queueSize = tentaclewire.SessionQueueSize + } sessionContext, cancel := context.WithCancel(ctx) outputContext, revokeOutput := context.WithCancel(sessionContext) session := &Session{manager: manager, spec: spec, identity: hex.EncodeToString(identity[:]), outputContext: outputContext, revokeOutput: revokeOutput, ctx: sessionContext, cancel: cancel, state: Starting, ready: make(chan struct{}), done: make(chan struct{}), - stop: make(chan struct{}), revoked: make(chan struct{}), outbound: make(chan outgoing, manager.limits.PendingPerProcess+2), + stop: make(chan struct{}), revoked: make(chan struct{}), outbound: make(chan outgoing, queueSize), pending: make(map[string]*pendingCall)} manager.sessions[spec.ID] = session go session.run() @@ -196,6 +207,9 @@ func (manager *Manager) Shutdown(ctx context.Context) error { } func validate_spec(spec Spec) error { + if !tentaclewire.ValidRemoteGrants(spec.RemoteGrants) || len(spec.RemoteGrants) > 0 && !spec.SessionEvents { + return errors.New("invalid remote grants") + } if spec.ReaderSettings && !spec.Interaction || spec.Interaction && !spec.Presentation || !valid_id(spec.ID) || len(spec.Program) == 0 || len(spec.Program) > 32 || !filepath.IsAbs(spec.Program[0]) || (spec.Directory != "" && !filepath.IsAbs(spec.Directory)) { return errors.New("invalid local tentacle launch") diff --git a/internal/tentacle/negotiation.go b/internal/tentacle/negotiation.go new file mode 100644 index 0000000..51715cd --- /dev/null +++ b/internal/tentacle/negotiation.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +func (spec Spec) protocol() Version { + if spec.SessionEvents { + return Version{Major: 1, Minor: 1} + } + return Version{Major: 1, Minor: 0} +} + +func (spec Spec) apis(legacy []string) []string { + apis := append([]string(nil), legacy...) + if spec.SessionEvents { + apis = append(apis, "session-events") + } + if len(spec.RemoteGrants) > 0 { + apis = append(apis, "remote-access") + } + return apis +} + +func (spec Spec) capabilities(legacy []string) []string { + if !spec.SessionEvents { + return legacy + } + return append(append([]string{}, legacy[1:]...), spec.RemoteGrants...) +} diff --git a/internal/tentacle/process.go b/internal/tentacle/process.go index 0327647..b606638 100644 --- a/internal/tentacle/process.go +++ b/internal/tentacle/process.go @@ -219,8 +219,8 @@ func (session *Session) run() { apis = append(apis, "reader-settings") } initialized, err = session.request_locked(session.ctx, "initialize", Initialization{ - Protocol: Version{Major: 1, Minor: 0}, Session: session.identity, - APIs: apis, Capabilities: apis, + Protocol: session.spec.protocol(), Session: session.identity, + APIs: session.spec.apis(apis), Capabilities: session.spec.capabilities(apis), }, session.manager.limits.StartupTimeout) } else { err = ErrUnavailable @@ -237,7 +237,7 @@ func (session *Session) run() { return } var registration Registration - if decode_strict(result.Value, ®istration) != nil || validate_registration(registration, session.identity) != nil { + if decode_strict(result.Value, ®istration) != nil || validate_registration_version(registration, session.identity, session.spec.protocol()) != nil { session.fail(ErrProtocol) return } @@ -303,10 +303,20 @@ func (session *Session) write_requests(writer io.Writer) { eligible := call != nil && call.ctx.Err() == nil session.mutex.Unlock() if !eligible { + session.mutex.Lock() + session.queuedBytes -= len(item.frame) + session.mutex.Unlock() continue } } - if _, err := writer.Write(item.frame); err != nil { + n, err := writer.Write(item.frame) + if err == nil && n != len(item.frame) { + err = io.ErrShortWrite + } + session.mutex.Lock() + session.queuedBytes -= len(item.frame) + session.mutex.Unlock() + if err != nil { session.fail(ErrExited) return } @@ -325,6 +335,18 @@ func (session *Session) read_replies(reader io.Reader) { session.fail(err) return } + var fields map[string]json.RawMessage + if validate_json(frame) != nil || json.Unmarshal(frame, &fields) != nil { + session.fail(ErrProtocol) + return + } + if _, request := fields["method"]; request { + if err := session.accept_child_request(frame); err != nil { + session.fail(err) + return + } + continue + } reply, err := decode_response(frame) if err != nil { session.fail(err) diff --git a/internal/tentacle/protocol.go b/internal/tentacle/protocol.go index 6b0527e..4b61360 100644 --- a/internal/tentacle/protocol.go +++ b/internal/tentacle/protocol.go @@ -145,7 +145,11 @@ func valid_id(value string) bool { } func validate_registration(registration Registration, session string) error { - if registration.Protocol != (Version{Major: 1, Minor: 0}) || registration.Session != session || + return validate_registration_version(registration, session, Version{Major: 1, Minor: 0}) +} + +func validate_registration_version(registration Registration, session string, version Version) error { + if registration.Protocol != version || registration.Session != session || len(registration.Commands) == 0 || len(registration.Commands) > MaxCommands { return ErrProtocol } diff --git a/internal/tentacle/registry.go b/internal/tentacle/registry.go index 1550cb6..e1081c4 100644 --- a/internal/tentacle/registry.go +++ b/internal/tentacle/registry.go @@ -18,6 +18,8 @@ import ( // Entry records an explicitly added local program or a verified package. // Discovery never adds entries. Enablement is desired state; failures retain it. type Entry struct { + SessionEvents bool `toml:"session-events,omitempty"` + RemoteGrants []string `toml:"remote-grants,omitempty"` ID string `toml:"id"` Program []string `toml:"program"` Directory string `toml:"directory,omitempty"` @@ -36,7 +38,7 @@ type Entry struct { } func (entry Entry) Spec() Spec { - return Spec{ID: entry.ID, Program: append([]string(nil), entry.Program...), Directory: entry.Directory, Presentation: entry.Presentation, Interaction: entry.Interaction, ReaderSettings: entry.ReaderSettings} + return Spec{SessionEvents: entry.SessionEvents, RemoteGrants: append([]string(nil), entry.RemoteGrants...), ID: entry.ID, Program: append([]string(nil), entry.Program...), Directory: entry.Directory, Presentation: entry.Presentation, Interaction: entry.Interaction, ReaderSettings: entry.ReaderSettings} } type registryDocument struct { @@ -64,6 +66,7 @@ func CloneEntries(entries []Entry) []Entry { result := append([]Entry(nil), entries...) for index := range result { result[index].Program = append([]string(nil), result[index].Program...) + result[index].RemoteGrants = append([]string(nil), result[index].RemoteGrants...) } return result } diff --git a/internal/tentacle/remote.go b/internal/tentacle/remote.go new file mode 100644 index 0000000..92538eb --- /dev/null +++ b/internal/tentacle/remote.go @@ -0,0 +1,274 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "slices" + "strconv" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +const RemoteHeartbeatTimeout = 3 * time.Second +const remoteApprovalTimeout = 30 * time.Second + +var ErrPermission = errors.New("tentacle permission denied") + +// RemoteGrants returns install permissions, never current consent. +func (session *Session) RemoteGrants() []string { return slices.Clone(session.spec.RemoteGrants) } + +// Owned by Session.mutex. No network identity or secret belongs here. +type remoteAccess struct { + action string + lease string + epoch uint64 + sequence uint64 + role string + grants []string + ctx context.Context + cancel context.CancelFunc + deadline time.Time + timer *time.Timer + stopPolicy func() bool +} + +// AuthorizeRemote is the unrestricted protocol-fixture hook. Reader managers +// use RemotePolicy and AuthorizeRemoteWithApproval instead; child RPCs cannot +// call either authorizer. +func (session *Session) AuthorizeRemote(role string, grants []string) (tentaclewire.SessionEvent, error) { + session.mutex.Lock() + defer session.mutex.Unlock() + if session.manager.remotePolicy != nil { + return tentaclewire.SessionEvent{}, ErrPermission + } + return session.authorize_remote_locked(role, grants, session.outputContext) +} + +func (session *Session) authorize_remote_locked(role string, grants []string, parent context.Context) (tentaclewire.SessionEvent, error) { + if session.state != Active || session.ctx.Err() != nil || !session.spec.SessionEvents { + return tentaclewire.SessionEvent{}, ErrUnavailable + } + if !tentaclewire.ValidRoleGrants(role, grants) { + return tentaclewire.SessionEvent{}, ErrPermission + } + for _, grant := range grants { + if !slices.Contains(session.spec.RemoteGrants, grant) { + return tentaclewire.SessionEvent{}, ErrPermission + } + } + var token [16]byte + if _, err := rand.Read(token[:]); err != nil { + return tentaclewire.SessionEvent{}, err + } + session.manager.remoteMutex.Lock() + if owner := session.manager.remoteOwner; owner != nil && owner != session { + session.manager.remoteMutex.Unlock() + return tentaclewire.SessionEvent{}, ErrBusy + } + session.manager.remoteMutex.Unlock() + session.revoke_remote_locked("replaced", true) + // Claim and check atomically: a different session may have acquired the slot + // while the old lease was being revoked. + session.manager.remoteMutex.Lock() + if session.manager.remoteOwner != nil { + session.manager.remoteMutex.Unlock() + return tentaclewire.SessionEvent{}, ErrBusy + } + session.manager.remoteOwner = session + session.manager.remoteMutex.Unlock() + if session.state != Active { + session.release_remote_owner() + return tentaclewire.SessionEvent{}, ErrUnavailable + } + session.remoteEpoch++ + ctx, cancel := context.WithCancel(parent) + access := &remoteAccess{action: hex.EncodeToString(token[:]), epoch: session.remoteEpoch, + role: role, grants: slices.Clone(grants), ctx: ctx, cancel: cancel} + session.remote = access + // Parent cancellation invalidates delivery immediately. Retirement/event + // cleanup acquires the session lock afterwards and cannot retire a newer epoch. + access.stopPolicy = context.AfterFunc(parent, func() { + session.mutex.Lock() + defer session.mutex.Unlock() + if session.remote == access { + session.revoke_remote_locked("local", true) + } + }) + session.refresh_remote_locked(remoteApprovalTimeout) + event := tentaclewire.SessionEvent{Kind: "remote.action", Action: access.action, Epoch: access.epoch, Role: role, Grants: slices.Clone(grants)} + if !session.remote_event_locked(&event) { + session.fail_locked(ErrLimit) + return tentaclewire.SessionEvent{}, ErrLimit + } + return event, nil +} + +// RevokeRemote invalidates buffered work synchronously. A later local consent +// action is required; heartbeat and remote.open cannot revive the old epoch. +func (session *Session) RevokeRemote() { + session.mutex.Lock() + defer session.mutex.Unlock() + session.revoke_remote_locked("local", true) +} + +// RemoteContext is for future reader owners to check again at delivery. A live +// context is necessary, not sufficient: focus, lock and output policy still apply. +func (session *Session) RemoteContext(lease tentaclewire.RemoteLease, grant string) (context.Context, error) { + session.mutex.Lock() + defer session.mutex.Unlock() + access := session.remote + if session.state != Active || access == nil || access.lease == "" || access.lease != lease.Lease || access.epoch != lease.Epoch || !time.Now().Before(access.deadline) || access.ctx.Err() != nil || !slices.Contains(access.grants, grant) { + return nil, ErrPermission + } + return access.ctx, nil +} + +func (session *Session) release_remote_owner() { + session.manager.remoteMutex.Lock() + if session.manager.remoteOwner == session { + session.manager.remoteOwner = nil + } + session.manager.remoteMutex.Unlock() +} + +func (session *Session) revoke_remote_locked(reason string, notify bool) { + if session.remoteApproval != nil { + session.remoteApproval.cancel() + session.remoteApproval = nil + } + access := session.remote + if access == nil { + return + } + session.remote = nil + access.cancel() + if access.stopPolicy != nil { + access.stopPolicy() + } + if access.timer != nil { + access.timer.Stop() + } + session.release_remote_owner() + if notify && access.lease != "" { + event := tentaclewire.SessionEvent{Kind: "remote.revoked", Lease: access.lease, Epoch: access.epoch, Reason: reason} + if !session.remote_event_locked(&event) { + session.fail_locked(ErrLimit) + } + } +} + +func (session *Session) refresh_remote_locked(duration time.Duration) { + access := session.remote + access.deadline = time.Now().Add(duration) + if access.timer != nil { + access.timer.Stop() + } + access.timer = time.AfterFunc(duration, func() { + session.mutex.Lock() + defer session.mutex.Unlock() + // An old timer may already be running when heartbeat resets it. + if session.remote == access && !time.Now().Before(access.deadline) { + session.revoke_remote_locked("expired", true) + } + }) +} + +func (session *Session) remote_event_locked(event *tentaclewire.SessionEvent) bool { + session.eventSequence++ + event.Sequence = session.eventSequence + return session.notify_locked("session.event", event) +} + +func (session *Session) accept_child_request(frame []byte) error { + var message struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if decode_strict(frame, &message) != nil || message.JSONRPC != "2.0" || !strings.HasPrefix(message.ID, "c:") || message.Method == "" || len(message.Method) > 128 || len(message.Params) == 0 || len(message.Params) > 8192 { + return ErrProtocol + } + number, err := strconv.ParseUint(strings.TrimPrefix(message.ID, "c:"), 10, 64) + if err != nil || message.ID != "c:"+strconv.FormatUint(number, 10) { + return ErrProtocol + } + session.mutex.Lock() + defer session.mutex.Unlock() + if !session.spec.SessionEvents || session.state != Active || session.ctx.Err() != nil || number <= session.childSequence { + return ErrProtocol + } + session.childSequence = number + value, failure := session.remote_call_locked(message.Method, message.Params) + reply := map[string]any{"jsonrpc": "2.0", "id": message.ID} + if failure != nil { + reply["error"] = failure + } else { + reply["result"] = value + } + data, err := json.Marshal(reply) + if err != nil || !session.enqueue_locked(outgoing{frame: append(data, '\n')}) { + return ErrLimit + } + return nil +} + +func (session *Session) remote_call_locked(method string, params json.RawMessage) (any, *RPCError) { + denied := &RPCError{Code: -32001, Message: "Session permission denied"} + invalid := &RPCError{Code: -32602, Message: "Invalid session parameters"} + switch method { + case "remote.open": + var request tentaclewire.RemoteOpen + if decode_strict(params, &request) != nil || len(request.Action) != 32 || request.Epoch == 0 || !tentaclewire.ValidRoleGrants(request.Role, request.Grants) { + return nil, invalid + } + access := session.remote + if access == nil || access.lease != "" || access.action != request.Action || access.epoch != request.Epoch || access.role != request.Role || !time.Now().Before(access.deadline) || access.ctx.Err() != nil { + return nil, denied + } + for _, grant := range request.Grants { + if !slices.Contains(access.grants, grant) { + return nil, denied + } + } + var token [16]byte + if _, err := rand.Read(token[:]); err != nil { + return nil, &RPCError{Code: -32603, Message: "Session unavailable"} + } + access.lease, access.action, access.grants = hex.EncodeToString(token[:]), "", slices.Clone(request.Grants) + session.refresh_remote_locked(RemoteHeartbeatTimeout) + return tentaclewire.RemoteLease{Lease: access.lease, Epoch: access.epoch}, nil + case "remote.close", "remote.heartbeat": + var request tentaclewire.RemoteOperation + if decode_strict(params, &request) != nil || len(request.Lease) != 32 || request.Epoch == 0 || request.Sequence == 0 { + return nil, invalid + } + access := session.remote + // Closing an already retired epoch has no effect on a newer lease. + if method == "remote.close" && request.Epoch <= session.remoteEpoch && (access == nil || access.epoch != request.Epoch) { + return struct{}{}, nil + } + if access == nil || access.lease == "" || access.lease != request.Lease || access.epoch != request.Epoch || request.Sequence <= access.sequence || !time.Now().Before(access.deadline) || access.ctx.Err() != nil { + return nil, denied + } + access.sequence = request.Sequence + if method == "remote.close" { + session.revoke_remote_locked("closed", true) + } else { + session.refresh_remote_locked(RemoteHeartbeatTimeout) + } + return struct{}{}, nil + default: + // No generic callback or desktop effect path exists. New operations need + // typed validation and owner integration, never arbitrary method dispatch. + return nil, &RPCError{Code: -32601, Message: "Unsupported session operation"} + } +} diff --git a/internal/tentacle/remote_policy.go b/internal/tentacle/remote_policy.go new file mode 100644 index 0000000..4224054 --- /dev/null +++ b/internal/tentacle/remote_policy.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "context" + "slices" + "sync" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +// RemotePolicy belongs to the reader's focus/lock owner. Its cancellation is +// synchronous and does not acquire a session, command, output or registry lock. +// Restoring availability creates a fresh lifetime; it never revives consent. +type RemotePolicy struct { + mutex sync.Mutex + ctx context.Context + cancel context.CancelFunc + available bool +} + +// RemoteApproval is a single-use core ticket captured BEFORE showing local +// consent. It is not serializable and cannot be supplied by a Tentacle RPC. +type RemoteApproval struct { + policy *RemotePolicy + ctx context.Context + lifetime context.Context + cancel context.CancelFunc + session *Session + role string + grants []string +} + +func NewRemotePolicy() *RemotePolicy { + ctx, cancel := context.WithCancel(context.Background()) + return &RemotePolicy{ctx: ctx, cancel: cancel, available: true} +} + +func (policy *RemotePolicy) SetAvailable(available bool) { + policy.mutex.Lock() + defer policy.mutex.Unlock() + if policy.available == available { + return + } + policy.cancel() + policy.available = available + if available { + policy.ctx, policy.cancel = context.WithCancel(context.Background()) + } +} + +// Context captures the current availability lifetime for sensitive forms. A +// lock/handoff round trip cancels that lifetime even if native polling misses +// the short transition; callers must never replace it with the resumed one. +func (policy *RemotePolicy) Context() context.Context { + policy.mutex.Lock() + defer policy.mutex.Unlock() + return policy.ctx +} + +func (session *Session) BeginRemoteApproval(role string, grants []string) (*RemoteApproval, error) { + session.mutex.Lock() + defer session.mutex.Unlock() + policy := session.manager.remotePolicy + if policy == nil || session.state != Active || session.ctx.Err() != nil || !session.spec.SessionEvents || !tentaclewire.ValidRoleGrants(role, grants) { + return nil, ErrPermission + } + for _, grant := range grants { + if !slices.Contains(session.spec.RemoteGrants, grant) { + return nil, ErrPermission + } + } + policy.mutex.Lock() + defer policy.mutex.Unlock() + if !policy.available { + return nil, ErrPermission + } + session.revoke_remote_locked("replaced", true) + if session.state != Active { + return nil, ErrUnavailable + } + ctx, cancel := context.WithCancel(policy.ctx) + approval := &RemoteApproval{policy: policy, ctx: ctx, lifetime: policy.ctx, cancel: cancel, session: session, role: role, grants: slices.Clone(grants)} + session.remoteApproval = approval + return approval, nil +} + +func (approval *RemoteApproval) Context() context.Context { return approval.ctx } + +// Cancel only retires this pending dialog, never an issued lease or a newer +// dialog. Package changes and session shutdown also cancel pending approvals. +func (approval *RemoteApproval) Cancel() { + approval.session.mutex.Lock() + defer approval.session.mutex.Unlock() + if approval.session.remoteApproval == approval { + approval.session.remoteApproval = nil + } + approval.cancel() +} + +// UseRemotePolicy must run before starting any children. A production manager +// with a policy rejects the unrestricted authorizer used by protocol fixtures. +func (manager *Manager) UseRemotePolicy(policy *RemotePolicy) error { + manager.mutex.Lock() + defer manager.mutex.Unlock() + if policy == nil || manager.remotePolicy != nil || len(manager.sessions) != 0 || manager.closed { + return ErrUnavailable + } + manager.remotePolicy = policy + return nil +} + +func (session *Session) AuthorizeRemoteWithApproval(approval *RemoteApproval, role string, grants []string) (tentaclewire.SessionEvent, error) { + session.mutex.Lock() + defer session.mutex.Unlock() + policy := session.manager.remotePolicy + if policy == nil || approval == nil || approval.policy != policy || session.remoteApproval != approval || approval.session != session { + return tentaclewire.SessionEvent{}, ErrPermission + } + policy.mutex.Lock() + defer policy.mutex.Unlock() + if !policy.available || approval.lifetime != policy.ctx || approval.ctx.Err() != nil || role != approval.role || !tentaclewire.ValidRoleGrants(role, grants) { + return tentaclewire.SessionEvent{}, ErrPermission + } + for _, grant := range grants { + if !slices.Contains(approval.grants, grant) { + return tentaclewire.SessionEvent{}, ErrPermission + } + } + session.remoteApproval = nil + defer approval.cancel() + return session.authorize_remote_locked(role, grants, approval.lifetime) +} diff --git a/internal/tentacle/remote_policy_test.go b/internal/tentacle/remote_policy_test.go new file mode 100644 index 0000000..b780cf8 --- /dev/null +++ b/internal/tentacle/remote_policy_test.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "encoding/json" + "errors" + "testing" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +func TestRemotePolicyRevokesPendingAndLiveSDKAccess(t *testing.T) { + for _, language := range []string{"go", "python"} { + t.Run(language, func(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + policy := NewRemotePolicy() + if err := manager.UseRemotePolicy(policy); err != nil { + t.Fatal(err) + } + session := startSessionSDK(t, manager, language, "policy-probe") + if _, err := session.AuthorizeRemote("controlled", []string{"output-observe"}); !errors.Is(err, ErrPermission) { + t.Fatal("fixture authorizer bypassed policy", err) + } + stale, _ := session.BeginRemoteApproval("controlled", []string{"output-observe"}) + policy.SetAvailable(false) + if _, err := session.BeginRemoteApproval("controlled", []string{"output-observe"}); !errors.Is(err, ErrPermission) { + t.Fatal("unavailable policy issued ticket", err) + } + policy.SetAvailable(true) + if _, err := session.AuthorizeRemoteWithApproval(stale, "controlled", []string{"output-observe"}); !errors.Is(err, ErrPermission) { + t.Fatal("lock round trip revived ticket", err) + } + foreign := &RemoteApproval{policy: NewRemotePolicy()} + if _, err := session.AuthorizeRemoteWithApproval(foreign, "controlled", []string{"output-observe"}); !errors.Is(err, ErrPermission) { + t.Fatal("foreign ticket accepted", err) + } + for _, opened := range []bool{false, true} { + approval, _ := session.BeginRemoteApproval("controlled", []string{"output-observe"}) + action, err := session.AuthorizeRemoteWithApproval(approval, "controlled", []string{"output-observe"}) + if err != nil { + t.Fatal(err) + } + if _, err := session.AuthorizeRemoteWithApproval(approval, "controlled", []string{"output-observe"}); !errors.Is(err, ErrPermission) { + t.Fatal("replayed approval", err) + } + invokeSessionSDK(t, session, "event", nil) + var lease tentaclewire.RemoteLease + request := tentaclewire.RemoteOpen{Action: action.Action, Epoch: action.Epoch, Role: action.Role, Grants: action.Grants} + if opened { + value, code := remoteCall(t, session, "remote.open", request) + if code != 0 || json.Unmarshal(value, &lease) != nil { + t.Fatal("open failed", code) + } + } + // Hold the session lock so asynchronous event cleanup cannot run. + // Policy cancellation must already invalidate every delivery context. + session.mutex.Lock() + access := session.remote + policy.SetAvailable(false) + cancelled := access.ctx.Err() != nil + session.mutex.Unlock() + if !cancelled { + t.Fatal("revocation waited for session worker") + } + policy.SetAvailable(true) + if _, code := remoteCall(t, session, "remote.open", request); code != -32001 { + t.Fatal("stale open revived approval", code) + } + if opened { + if _, err := session.RemoteContext(lease, "output-observe"); !errors.Is(err, ErrPermission) { + t.Fatal("delivery survived policy", err) + } + if _, code := remoteCall(t, session, "remote.heartbeat", tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: 1}); code != -32001 { + t.Fatal("heartbeat revived lease", code) + } + var event tentaclewire.SessionEvent + if json.Unmarshal(invokeSessionSDK(t, session, "event", nil), &event) != nil || event.Kind != "remote.revoked" || event.Lease != lease.Lease { + t.Fatal("revocation event", event) + } + } + } + // A fresh approval can replace the cancelled epoch even if its + // asynchronous policy callback has not acquired the session lock yet. + fresh, _ := session.BeginRemoteApproval("controlled", []string{"output-observe"}) + if _, err := session.AuthorizeRemoteWithApproval(fresh, "controlled", []string{"output-observe"}); err != nil { + t.Fatal("fresh approval failed", err) + } + }) + } +} diff --git a/internal/tentacle/remote_test.go b/internal/tentacle/remote_test.go new file mode 100644 index 0000000..d8741a6 --- /dev/null +++ b/internal/tentacle/remote_test.go @@ -0,0 +1,418 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" + sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle" +) + +func TestSessionSDKChild(t *testing.T) { + if len(os.Args) < 2 || os.Args[len(os.Args)-1] != "session-sdk-child" { + return + } + client := sdk.NewSessionClient() + commands := []sdk.Command{ + {ID: "large", Title: "Large", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { return strings.Repeat("x", 48*1024), nil }}, + {ID: "request", Title: "Request", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { + var request struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(args, &request); err != nil { + return nil, err + } + var value json.RawMessage + err := client.Call(ctx, request.Method, request.Params, &value) + var failure *sdk.SessionError + if errors.As(err, &failure) { + return map[string]any{"code": failure.Code}, nil + } + return map[string]any{"value": value}, err + }}, + {ID: "event", Title: "Event", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { + select { + case event := <-client.Events(): + return event, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }}, + {ID: "wait", Title: "Wait", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { <-ctx.Done(); return nil, ctx.Err() }}, + } + if sdk.ServeSession(os.Stdin, os.Stdout, commands, nil, client) != nil { + os.Exit(1) + } + os.Exit(0) +} + +func sessionSDKProgram(t *testing.T, language string) []string { + t.Helper() + if language == "go" { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return []string{executable, "-test.run=^TestSessionSDKChild$", "--", "session-sdk-child"} + } + python, err := exec.LookPath("python3") + if err != nil { + t.Fatal("Python is required for session SDK conformance") + } + peer, err := filepath.Abs("testdata/session_peer.py") + if err != nil { + t.Fatal(err) + } + return []string{python, "-B", peer} +} + +func startSessionSDK(t *testing.T, manager *Manager, language, id string) *Session { + t.Helper() + session, err := manager.Start(t.Context(), Spec{ID: id, Program: sessionSDKProgram(t, language), SessionEvents: true, RemoteGrants: []string{"output-observe", "remote-present", "clipboard-exchange"}}) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Ready()) + if err := session.Activate(); err != nil { + state, reason := session.Status() + t.Fatalf("activate: %v, %s %v", err, state, reason) + } + return session +} + +func invokeSessionSDK(t *testing.T, session *Session, command string, params any) json.RawMessage { + t.Helper() + data, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + pending, err := session.Invoke(t.Context(), command, data) + if err != nil { + t.Fatal(err) + } + result := wait_result(t, pending) + if result.Err != nil { + t.Fatal(result.Err) + } + return result.Value +} + +func remoteCall(t *testing.T, session *Session, method string, params any) (json.RawMessage, int) { + t.Helper() + raw := invokeSessionSDK(t, session, "request", map[string]any{"method": method, "params": params}) + var result struct { + Value json.RawMessage `json:"value"` + Code int `json:"code"` + } + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatal(err) + } + return result.Value, result.Code +} + +func openRemote(t *testing.T, session *Session) tentaclewire.RemoteLease { + t.Helper() + action, err := session.AuthorizeRemote("controlled", []string{"output-observe"}) + if err != nil { + t.Fatal(err) + } + raw := invokeSessionSDK(t, session, "event", nil) + var event tentaclewire.SessionEvent + if json.Unmarshal(raw, &event) != nil || event.Action != action.Action || event.Kind != "remote.action" { + t.Fatal("lost local action", string(raw)) + } + value, code := remoteCall(t, session, "remote.open", tentaclewire.RemoteOpen{Action: event.Action, Epoch: event.Epoch, Role: event.Role, Grants: event.Grants}) + var lease tentaclewire.RemoteLease + if code != 0 || json.Unmarshal(value, &lease) != nil || lease.Lease == "" || lease.Epoch != event.Epoch { + t.Fatal("open", code, string(value)) + } + return lease +} + +func TestSessionSDKLifecycleConformance(t *testing.T) { + for _, language := range []string{"go", "python"} { + t.Run(language, func(t *testing.T) { + limits := DefaultLimits() + limits.RequestTimeout = 5 * time.Second + manager := test_manager(t, limits, nil) + session := startSessionSDK(t, manager, language, "session-sdk") + if _, code := remoteCall(t, session, "remote.open", tentaclewire.RemoteOpen{Action: strings.Repeat("a", 32), Epoch: 1, Role: "controlled", Grants: []string{"output-observe"}}); code != -32001 { + t.Fatal("child minted consent", code) + } + if _, err := session.AuthorizeRemote("controller", []string{"keyboard-capture"}); !errors.Is(err, ErrPermission) { + t.Fatal("ungranted approval", err) + } + if _, err := session.AuthorizeRemote("controller", []string{"output-observe"}); !errors.Is(err, ErrPermission) { + t.Fatal("wrong role", err) + } + lease := openRemote(t, session) + delivery, err := session.RemoteContext(lease, "output-observe") + if err != nil { + t.Fatal(err) + } + if _, err := session.RemoteContext(lease, "clipboard-exchange"); !errors.Is(err, ErrPermission) { + t.Fatal("install grant became session grant") + } + if _, code := remoteCall(t, session, "remote.key", map[string]any{}); code != -32601 { + t.Fatal("unimplemented desktop operation accepted") + } + // One blocked handler cannot block the endpoint receive loop or lifecycle calls. + waiting, err := session.Invoke(t.Context(), "wait", nil) + if err != nil { + t.Fatal(err) + } + heartbeat := tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: 1} + if _, code := remoteCall(t, session, "remote.heartbeat", heartbeat); code != 0 { + t.Fatal("heartbeat", code) + } + if _, code := remoteCall(t, session, "remote.heartbeat", heartbeat); code != -32001 { + t.Fatal("duplicate operation accepted") + } + heartbeat.Sequence++ + if _, code := remoteCall(t, session, "remote.close", heartbeat); code != 0 { + t.Fatal("close", code) + } + if delivery.Err() == nil { + t.Fatal("buffered delivery survived close") + } + var event tentaclewire.SessionEvent + raw := invokeSessionSDK(t, session, "event", nil) + if json.Unmarshal(raw, &event) != nil || event.Kind != "remote.revoked" || event.Reason != "closed" { + t.Fatal("missing revocation", string(raw)) + } + if _, code := remoteCall(t, session, "remote.close", heartbeat); code != 0 { + t.Fatal("close not idempotent") + } + newLease := openRemote(t, session) + if newLease.Epoch <= lease.Epoch { + t.Fatal("epoch reused") + } + if _, code := remoteCall(t, session, "remote.heartbeat", heartbeat); code != -32001 { + t.Fatal("stale heartbeat accepted") + } + if _, code := remoteCall(t, session, "remote.close", heartbeat); code != 0 { + t.Fatal("stale close failed") + } + current, err := session.RemoteContext(newLease, "output-observe") + if err != nil { + t.Fatal("stale close revoked new lease") + } + session.Disable() + if current.Err() == nil { + t.Fatal("disable left lease live") + } + if result := wait_result(t, waiting); result.Err == nil { + t.Fatal("blocked handler survived disable") + } + wait_signal(t, session.Done()) + }) + } +} + +func TestSessionLeaseExpiryAndExclusiveOwner(t *testing.T) { + limits := DefaultLimits() + limits.RequestTimeout = 5 * time.Second + manager := test_manager(t, limits, nil) + first := startSessionSDK(t, manager, "go", "first") + second := startSessionSDK(t, manager, "python", "second") + lease := openRemote(t, first) + delivery, err := first.RemoteContext(lease, "output-observe") + if err != nil { + t.Fatal(err) + } + if _, err := second.AuthorizeRemote("controlled", []string{"output-observe"}); !errors.Is(err, ErrBusy) { + t.Fatal("two owners", err) + } + select { + case <-delivery.Done(): + case <-time.After(RemoteHeartbeatTimeout + time.Second): + t.Fatal("heartbeat did not expire") + } + if _, code := remoteCall(t, first, "remote.heartbeat", tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: 1}); code != -32001 { + t.Fatal("expired lease revived") + } + openRemote(t, second) +} + +func TestSessionSDKCallTimeout(t *testing.T) { + for _, blockedWriter := range []bool{false, true} { + for _, language := range []string{"go", "python"} { + t.Run(language, func(t *testing.T) { + program := sessionSDKProgram(t, language) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, program[0], program[1:]...) + input, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + output, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer input.Close() + writer := json.NewEncoder(input) + reader := bufio.NewReader(output) + writer.Encode(map[string]any{"jsonrpc": "2.0", "id": "1", "method": "initialize", "params": Initialization{Protocol: Version{1, 1}, Session: "test", APIs: []string{"commands", "session-events"}, Capabilities: []string{}}}) + if _, err := read_frame(reader); err != nil { + t.Fatal(err) + } + writer.Encode(map[string]any{"jsonrpc": "2.0", "method": "initialized", "params": map[string]string{"session": "test"}}) + requestID := "2" + if blockedWriter { + writer.Encode(map[string]any{"jsonrpc": "2.0", "id": "2", "method": "command.invoke", "params": map[string]any{"command": "large", "arguments": nil}}) + requestID = "3" + } + writer.Encode(map[string]any{"jsonrpc": "2.0", "id": requestID, "method": "command.invoke", "params": map[string]any{"command": "request", "arguments": map[string]any{"method": "remote.heartbeat", "params": map[string]any{}}}}) + if !blockedWriter { + frame, err := read_frame(reader) + if err != nil || !strings.Contains(string(frame), `"id":"c:1"`) { + t.Fatal("missing child request", string(frame), err) + } + } + // Keep stdin open and never reply: SDK must wake a blocked receive loop. + _ = command.Wait() + if ctx.Err() != nil { + t.Fatal("SDK stayed blocked after request timeout") + } + }) + } + } + +} + +func TestSessionQueueOverflowRevokesLease(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + output, revoke := context.WithCancel(ctx) + session := &Session{manager: manager, spec: Spec{SessionEvents: true, RemoteGrants: []string{"output-observe"}}, ctx: ctx, cancel: cancel, outputContext: output, revokeOutput: revoke, state: Active, outbound: make(chan outgoing, 1), revoked: make(chan struct{})} + action, err := session.AuthorizeRemote("controlled", []string{"output-observe"}) + if err != nil { + t.Fatal(err) + } + params, _ := json.Marshal(tentaclewire.RemoteOpen{Action: action.Action, Epoch: action.Epoch, Role: action.Role, Grants: action.Grants}) + session.mutex.Lock() + value, failure := session.remote_call_locked("remote.open", params) + session.mutex.Unlock() + if failure != nil { + t.Fatal(failure) + } + delivery, err := session.RemoteContext(value.(tentaclewire.RemoteLease), "output-observe") + if err != nil { + t.Fatal(err) + } + session.RevokeRemote() // Revocation cannot be queued behind the unread action. + if delivery.Err() == nil { + t.Fatal("queue blocked immediate revocation") + } + if state, _ := session.Status(); state != Failed { + t.Fatal("overflow did not retire session", state) + } +} + +func TestSessionHostRejectsMalformedChildRequests(t *testing.T) { + for _, frame := range []string{ + `{"jsonrpc":"2.0","id":"1","method":"remote.close","params":{}}`, + `{"jsonrpc":"2.0","id":"c:01","method":"remote.close","params":{}}`, + `{"jsonrpc":"2.0","id":"c:0","method":"remote.close","params":{}}`, + `{"jsonrpc":"2.0","id":null,"method":"remote.close","params":{}}`, + `{"jsonrpc":"2.0","id":"c:1","method":"remote.close","method":"remote.open","params":{}}`, + `{"jsonrpc":"2.0","id":"c:1","method":"remote.close","params":{},"result":{}}`, + `{"jsonrpc":"2.0","id":"c:1","method":"remote.close"}`, + } { + t.Run(frame, func(t *testing.T) { + session := &Session{spec: Spec{SessionEvents: true}, ctx: t.Context(), state: Active, outbound: make(chan outgoing, 32)} + if err := session.accept_child_request([]byte(frame)); err == nil { + t.Fatal("malformed request accepted") + } + }) + } + session := &Session{spec: Spec{SessionEvents: true}, ctx: t.Context(), state: Active, outbound: make(chan outgoing, 32)} + frame := []byte(`{"jsonrpc":"2.0","id":"c:1","method":"remote.unknown","params":{}}`) + if err := session.accept_child_request(frame); err != nil { + t.Fatal(err) + } + reply, err := decode_response((<-session.outbound).frame) + if err != nil || reply.Error == nil || reply.Error.Code != -32601 { + t.Fatal("unknown method response", reply, err) + } + if err := session.accept_child_request(frame); err == nil { + t.Fatal("duplicate child ID accepted") + } + session.spec.SessionEvents = false + if err := session.accept_child_request([]byte(`{"jsonrpc":"2.0","id":"c:2","method":"remote.close","params":{}}`)); err == nil { + t.Fatal("1.0 accepted child request") + } +} + +func TestSessionSDKRejectsMalformedHostMessages(t *testing.T) { + initialize := `{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocol":{"major":1,"minor":1},"session":"test","apis":["commands","session-events"],"capabilities":[]}}` + "\n" + activate := `{"jsonrpc":"2.0","method":"initialized","params":{"session":"test"}}` + "\n" + event := `{"jsonrpc":"2.0","method":"session.event","params":{"sequence":1,"kind":"remote.action","action":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","epoch":1,"role":"controlled","grants":["output-observe"]}}` + "\n" + cases := map[string]string{ + "null capabilities": strings.Replace(initialize, `"capabilities":[]`, `"capabilities":null`, 1), + "null event field": initialize + activate + strings.Replace(event, `"epoch":1`, `"epoch":1,"lease":null`, 1), + "old host": strings.Replace(initialize, `"minor":1`, `"minor":0`, 1), + "missing event API": strings.Replace(initialize, `"commands","session-events"`, `"commands"`, 1), + "legacy capabilities": strings.Replace(initialize, `"capabilities":[]`, `"capabilities":["commands"]`, 1), + "unrequested response": initialize + activate + `{"jsonrpc":"2.0","id":"c:1","result":null}` + "\n", + "event before activation": initialize + event, + "event with request ID": initialize + activate + strings.Replace(event, `"method":`, `"id":"2","method":`, 1), + "duplicate event": initialize + activate + event + event, + "skipped sequence": initialize + activate + strings.Replace(event, `"sequence":1`, `"sequence":2`, 1), + "event role mismatch": initialize + activate + strings.Replace(event, `"controlled"`, `"controller"`, 1), + "unknown event": initialize + activate + strings.Replace(event, `"remote.action"`, `"remote.execute"`, 1), + } + overflow := initialize + activate + for sequence := 1; sequence <= 33; sequence++ { + overflow += strings.Replace(event, `"sequence":1`, `"sequence":`+strconv.Itoa(sequence), 1) + } + cases["event overflow"] = overflow + for _, language := range []string{"go", "python"} { + for name, input := range cases { + t.Run(language+"/"+name, func(t *testing.T) { + program := sessionSDKProgram(t, language) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, program[0], program[1:]...) + command.Stdin = strings.NewReader(input) + if err := command.Run(); err == nil { + t.Fatal("invalid host message accepted") + } + if ctx.Err() != nil { + t.Fatal("SDK hung on invalid message") + } + }) + } + } +} + +func TestSessionAggregateQueueBound(t *testing.T) { + session := &Session{spec: Spec{SessionEvents: true}, outbound: make(chan outgoing, 32)} + frame := make([]byte, MaxFrameBytes) + for range 4 { + if !session.enqueue_locked(outgoing{frame: frame}) { + t.Fatal("queue rejected boundary") + } + } + if session.enqueue_locked(outgoing{frame: []byte{1}}) { + t.Fatal("aggregate byte ceiling exceeded") + } +} diff --git a/internal/tentacle/sdk_test.go b/internal/tentacle/sdk_test.go index 05ea778..b329731 100644 --- a/internal/tentacle/sdk_test.go +++ b/internal/tentacle/sdk_test.go @@ -50,6 +50,22 @@ func TestSDKChild(t *testing.T) { return sdk.Presentation{Speech: "Hello, é猫😀é", Braille: "Display é猫😀é", Uninterruptible: true}, nil }}) } + if filepath.Base(cancelFile) == "secret" { + commands = append(commands, + sdk.Command{ID: "secret", Title: "Secret", Presentation: true, Interaction: true, + Handle: func(ctx context.Context, args json.RawMessage) (any, error) { + return sdk.Interaction{Form: &sdk.Form{Title: "Connection", Elements: []sdk.Element{ + {ID: "key", Label: "Channel key", Kind: sdk.Entry, Secret: true, Required: true}, + }}, Next: "submit"}, nil + }}, + sdk.Command{ID: "submit", Title: "Submit", Presentation: true, Interaction: true, + Handle: func(ctx context.Context, args json.RawMessage) (any, error) { + var submission sdk.Submission + err := json.Unmarshal(args, &submission) + return submission.Values["key"].Text == "fixture猫", err + }}, + ) + } if sdk.Serve(os.Stdin, os.Stdout, commands) != nil { os.Exit(1) } @@ -250,3 +266,36 @@ func TestSDKRejectsInvalidHostMessages(t *testing.T) { } } } + +func TestSDKSecretFormConformance(t *testing.T) { + for _, language := range []string{"go", "python"} { + t.Run(language, func(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + session, err := manager.Start(context.Background(), Spec{ID: "sdk", Program: sdk_program(t, language, filepath.Join(t.TempDir(), "secret")), Presentation: true, Interaction: true}) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Ready()) + if err := session.Activate(); err != nil { + t.Fatal(err) + } + invoke := func(command string, args json.RawMessage) Result { + t.Helper() + pending, err := session.Invoke(context.Background(), command, args) + if err != nil { + t.Fatal(err) + } + return wait_result(t, pending) + } + result := invoke("secret", nil) + value, err := tentaclewire.DecodeInteraction(result.Value) + if result.Err != nil || err != nil || value.Form == nil || !value.Form.Elements[0].Secret { + t.Fatal("SDK did not preserve the secret declaration") + } + result = invoke(value.Next, json.RawMessage(`{"activated":"key","values":{"key":{"text":"fixture猫"}}}`)) + if result.Err != nil || string(result.Value) != "true" { + t.Fatal("SDK did not deliver the secret to the continuation") + } + }) + } +} diff --git a/internal/tentacle/session.go b/internal/tentacle/session.go index 87ab374..f7635a4 100644 --- a/internal/tentacle/session.go +++ b/internal/tentacle/session.go @@ -51,25 +51,31 @@ type Session struct { outputContext context.Context revokeOutput context.CancelFunc - mutex sync.Mutex - manager *Manager - spec Spec - identity string - ctx context.Context - cancel context.CancelFunc - state State - failure error - extensions tentaclewire.Extensions - commands []Command - bindings []BindingStatus - sequence uint64 - pending map[string]*pendingCall - outbound chan outgoing - ready chan struct{} - done chan struct{} - stop chan struct{} - revoked chan struct{} - callbacks sync.WaitGroup + mutex sync.Mutex + manager *Manager + spec Spec + identity string + ctx context.Context + cancel context.CancelFunc + state State + failure error + extensions tentaclewire.Extensions + commands []Command + bindings []BindingStatus + sequence uint64 + pending map[string]*pendingCall + outbound chan outgoing + ready chan struct{} + done chan struct{} + stop chan struct{} + revoked chan struct{} + callbacks sync.WaitGroup + queuedBytes int + childSequence uint64 + eventSequence uint64 + remoteEpoch uint64 + remote *remoteAccess + remoteApproval *RemoteApproval } func (session *Session) Ready() <-chan struct{} { return session.ready } @@ -196,13 +202,14 @@ func (session *Session) request_locked(parent context.Context, method string, pa defer session.callbacks.Done() session.cancel_call(id) }) - select { - case session.outbound <- outgoing{id: id, frame: frame}: + if session.enqueue_locked(outgoing{id: id, frame: frame}) { return call.result, nil - default: - session.finish_locked(id, nil, ErrBusy) - return nil, ErrBusy } + session.finish_locked(id, nil, ErrBusy) + if session.spec.SessionEvents { + session.fail_locked(ErrLimit) + } + return nil, ErrBusy } func (session *Session) notify_locked(method string, params any) bool { @@ -210,8 +217,17 @@ func (session *Session) notify_locked(method string, params any) bool { if err != nil { return false } + return session.enqueue_locked(outgoing{frame: frame}) +} + +// Session.mutex owns byte accounting, including the in-flight write. +func (session *Session) enqueue_locked(item outgoing) bool { + if session.spec.SessionEvents && session.queuedBytes+len(item.frame) > tentaclewire.SessionQueueBytes { + return false + } select { - case session.outbound <- outgoing{frame: frame}: + case session.outbound <- item: + session.queuedBytes += len(item.frame) return true default: return false @@ -277,6 +293,7 @@ func (session *Session) fail_locked(err error) { session.state, session.failure = Failed, err close(session.revoked) session.revokeOutput() + session.revoke_remote_locked("local", false) session.commands = nil session.extensions = tentaclewire.Extensions{} session.bindings = nil @@ -303,6 +320,7 @@ func (session *Session) Disable() { session.state = Stopping close(session.revoked) session.revokeOutput() + session.revoke_remote_locked("local", false) session.commands = nil session.extensions = tentaclewire.Extensions{} session.bindings = nil diff --git a/internal/tentacle/testdata/sdk_peer.py b/internal/tentacle/testdata/sdk_peer.py index f8128d1..8a766bd 100644 --- a/internal/tentacle/testdata/sdk_peer.py +++ b/internal/tentacle/testdata/sdk_peer.py @@ -38,4 +38,15 @@ if pathlib.Path(sys.argv[1]).name == "presentation": commands.append(Command("present", "Present", "", lambda cancelled, arguments: present(speech="Hello, é猫😀é", braille="Display é猫😀é", uninterruptible=True), presentation=True)) +if pathlib.Path(sys.argv[1]).name == "secret": + commands.extend([ + Command("secret", "Secret", "", lambda cancelled, arguments: { + "form": {"title": "Connection", "elements": [ + {"id": "key", "label": "Channel key", "kind": 2, + "secret": True, "required": True}]}, "next": "submit"}, + presentation=True, interaction=True), + Command("submit", "Submit", "", lambda cancelled, arguments: + arguments["values"]["key"]["text"] == "fixture猫", + presentation=True, interaction=True), + ]) serve(commands) diff --git a/internal/tentacle/testdata/session_peer.py b/internal/tentacle/testdata/session_peer.py new file mode 100644 index 0000000..da358a9 --- /dev/null +++ b/internal/tentacle/testdata/session_peer.py @@ -0,0 +1,33 @@ +# Copyright (c) 2026 Storm Dragon +# SPDX-License-Identifier: GPL-3.0-or-later +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3] / 'sdk' / 'python')) +from cthulhu_tentacle import Command, SessionClient, SessionError, serve + +client = SessionClient() + + +def request(cancelled, arguments): + try: + return {'value': client.call(arguments['method'], arguments['params'])} + except SessionError as error: + return {'code': error.code} + + +def event(cancelled, arguments): + return client.next_event() + + +def wait(cancelled, arguments): + cancelled.wait() + return None + + +try: + serve([Command('request', 'Request', '', request), Command('event', 'Event', '', event), + Command('wait', 'Wait', '', wait), + Command('large', 'Large', '', lambda cancelled, args: 'x' * (48 * 1024))], session_client=client) +except Exception: + sys.exit(1) diff --git a/internal/tentaclewire/interaction_test.go b/internal/tentaclewire/interaction_test.go index 5a95704..d4db7e8 100644 --- a/internal/tentaclewire/interaction_test.go +++ b/internal/tentaclewire/interaction_test.go @@ -55,3 +55,24 @@ func TestInteractionGrantsRemainExplicit(t *testing.T) { t.Fatal("missing grant accepted") } } + +func TestSecretInteractionContract(t *testing.T) { + base := `{"form":{"title":"Connection","elements":[{"id":"key","label":"Channel key","kind":2,%s}]},"next":"connect"}` + for _, flag := range []string{`"secret":true`, `"secret":false`} { + value, err := DecodeInteraction([]byte(strings.Replace(base, "%s", flag, 1))) + if err != nil || value.Form.Elements[0].Secret != (flag == `"secret":true`) { + t.Fatal("valid secret flag rejected or changed") + } + } + for _, flag := range []string{`"secret":null`, `"secret":1`, `"secret":"true"`, `"Secret":true`, `"secret":true,"secret":false`, `"secret":true,"Secret":false`, `"secret":true,"unknown":true`} { + if _, err := DecodeInteraction([]byte(strings.Replace(base, "%s", flag, 1))); err == nil { + t.Fatal("malformed secret flag accepted", flag) + } + } + page := PreferencePage{ID: "connection", Title: "Connection", Command: "connect", + Elements: []interaction.Element{{ID: "key", Label: "Channel key", Kind: interaction.Entry, Secret: true}}, + Values: map[string]interaction.Value{"key": {Text: "fixture"}}} + if ValidateExtensions(Extensions{Pages: []PreferencePage{page}}) == nil || ValidatePageValues(page, page.Values) == nil { + t.Fatal("secret permitted in persistent page values") + } +} diff --git a/internal/tentaclewire/reader.go b/internal/tentaclewire/reader.go index d559585..99e8f91 100644 --- a/internal/tentaclewire/reader.go +++ b/internal/tentaclewire/reader.go @@ -196,6 +196,9 @@ func ValidatePageValues(page PreferencePage, values map[string]interaction.Value return errors.New("page values do not match its controls") } for _, element := range page.Elements { + if element.Secret { + return ErrProtocol + } value, found := values[element.ID] if !found { return ErrProtocol diff --git a/internal/tentaclewire/session.go b/internal/tentaclewire/session.go new file mode 100644 index 0000000..656af62 --- /dev/null +++ b/internal/tentaclewire/session.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentaclewire + +import "slices" + +const ( + SessionQueueSize = 32 + SessionQueueBytes = 256 * 1024 +) + +// RemoteGrants are independent install-time permissions, never session consent. +func RemoteGrants() []string { + return []string{"output-observe", "remote-present", "keyboard-capture", "keyboard-inject", "braille-stream", "clipboard-exchange"} +} + +func ValidRemoteGrants(grants []string) bool { + seen := map[string]bool{} + for _, grant := range grants { + if seen[grant] || !slices.Contains(RemoteGrants(), grant) { + return false + } + seen[grant] = true + } + return true +} + +// ValidNegotiation keeps the 1.0 prefix contract. In 1.1 APIs are sets and +// capabilities are permissions: commands and transport APIs confer no access. +func ValidNegotiation(minor int, apis, capabilities []string) bool { + if minor == 0 { + return ValidAPIs(apis, capabilities) + } + if minor != 1 { + return false + } + a, c := map[string]bool{}, map[string]bool{} + for _, api := range apis { + if a[api] || !slices.Contains([]string{"commands", "presentation", "interaction", "reader-settings", "session-events", "remote-access"}, api) { + return false + } + a[api] = true + } + for _, capability := range capabilities { + if c[capability] || !(slices.Contains(RemoteGrants(), capability) || slices.Contains([]string{"presentation", "interaction", "reader-settings"}, capability)) { + return false + } + c[capability] = true + } + if !a["commands"] || !a["session-events"] || a["interaction"] && !a["presentation"] || a["reader-settings"] && !a["interaction"] { + return false + } + for _, api := range []string{"presentation", "interaction", "reader-settings"} { + if a[api] != c[api] { + return false + } + } + hasRemote := false + for _, grant := range RemoteGrants() { + hasRemote = hasRemote || c[grant] + } + return a["remote-access"] == hasRemote +} + +// SessionEvent is deliberately limited to lifecycle metadata. Output/input +// payloads will require their own typed validators before they can be enabled. +type SessionEvent struct { + Sequence uint64 `json:"sequence"` + Kind string `json:"kind"` + Action string `json:"action,omitempty"` + Lease string `json:"lease,omitempty"` + Epoch uint64 `json:"epoch"` + Role string `json:"role,omitempty"` + Grants []string `json:"grants,omitempty"` + Reason string `json:"reason,omitempty"` +} + +func ValidRole(role string) bool { return role == "controller" || role == "controlled" } + +func ValidRoleGrants(role string, grants []string) bool { + if !ValidRole(role) || !ValidRemoteGrants(grants) || len(grants) == 0 { + return false + } + for _, grant := range grants { + if role == "controller" && (grant == "output-observe" || grant == "keyboard-inject") || role == "controlled" && (grant == "remote-present" || grant == "keyboard-capture") { + return false + } + } + return true +} + +func ValidSessionEvent(event SessionEvent) bool { + if event.Sequence == 0 || event.Epoch == 0 { + return false + } + switch event.Kind { + case "remote.action": + return len(event.Action) == 32 && event.Lease == "" && event.Reason == "" && ValidRoleGrants(event.Role, event.Grants) + case "remote.revoked": + return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && slices.Contains([]string{"closed", "expired", "local", "replaced"}, event.Reason) + } + return false +} + +type RemoteOpen struct { + Action string `json:"action"` + Epoch uint64 `json:"epoch"` + Role string `json:"role"` + Grants []string `json:"grants"` +} + +type RemoteLease struct { + Lease string `json:"lease"` + Epoch uint64 `json:"epoch"` +} + +type RemoteOperation struct { + Lease string `json:"lease"` + Epoch uint64 `json:"epoch"` + Sequence uint64 `json:"sequence"` +} diff --git a/internal/tentaclewire/session_test.go b/internal/tentaclewire/session_test.go new file mode 100644 index 0000000..0451bf9 --- /dev/null +++ b/internal/tentaclewire/session_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package tentaclewire + +import "testing" + +func TestSessionNegotiationRequiresIndependentGrants(t *testing.T) { + cases := []struct { + name string + minor int + apis, grants []string + valid bool + }{ + {"legacy", 0, []string{"commands", "presentation"}, []string{"commands", "presentation"}, true}, + {"legacy order", 0, []string{"presentation", "commands"}, []string{"presentation", "commands"}, false}, + {"events only", 1, []string{"session-events", "commands"}, nil, true}, + {"explicit sets", 1, []string{"remote-access", "interaction", "session-events", "commands", "presentation"}, []string{"output-observe", "interaction", "presentation"}, true}, + {"missing events", 1, []string{"remote-access", "commands"}, []string{"output-observe"}, false}, + {"grant without API", 1, []string{"session-events", "commands"}, []string{"output-observe"}, false}, + {"API without grant", 1, []string{"session-events", "commands", "remote-access"}, nil, false}, + {"unknown grant", 1, []string{"session-events", "commands", "remote-access"}, []string{"execute"}, false}, + {"duplicate grant", 1, []string{"session-events", "commands", "remote-access"}, []string{"output-observe", "output-observe"}, false}, + {"duplicate API", 1, []string{"session-events", "commands", "commands"}, nil, false}, + {"missing prerequisite", 1, []string{"session-events", "commands", "interaction"}, []string{"interaction"}, false}, + {"legacy capability in 1.1", 1, []string{"session-events", "commands"}, []string{"commands"}, false}, + {"future version", 2, []string{"session-events", "commands"}, nil, false}, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + if ValidNegotiation(item.minor, item.apis, item.grants) != item.valid { + t.Fatal("wrong negotiation result") + } + }) + } +} diff --git a/sdk/go/tentacle/session.go b/sdk/go/tentacle/session.go new file mode 100644 index 0000000..cd4baea --- /dev/null +++ b/sdk/go/tentacle/session.go @@ -0,0 +1,301 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "strconv" + "sync" + "syscall" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +type SessionEvent = tentaclewire.SessionEvent +type RemoteOpen = tentaclewire.RemoteOpen +type RemoteLease = tentaclewire.RemoteLease +type RemoteOperation = tentaclewire.RemoteOperation + +type SessionError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (err *SessionError) Error() string { return "tentacle session error " + strconv.Itoa(err.Code) } + +type sessionReply struct { + value json.RawMessage + err error +} + +// SessionClient is single-use. ServeSession owns its bounded writer and receive +// loop; command handlers and background workers may Call concurrently. Events +// must be drained without blocking on network I/O. Overflow retires the process +// session rather than dropping lifecycle events. No desktop access is implicit. +type SessionClient struct { + mu sync.Mutex + ctx context.Context + abort func() + active bool + started bool + sequence uint64 + eventSequence uint64 + pending map[string]chan sessionReply + events chan SessionEvent + outbound chan []byte + queuedBytes int +} + +func NewSessionClient() *SessionClient { + return &SessionClient{events: make(chan SessionEvent, 32), outbound: make(chan []byte, 32), pending: make(map[string]chan sessionReply)} +} + +func (client *SessionClient) Events() <-chan SessionEvent { return client.events } + +// ServeSession opts into protocol 1.1; a protocol 1.0 host is rejected. +func ServeSession(input io.ReadCloser, output io.WriteCloser, commands []Command, extensions *Extensions, client *SessionClient) error { + if client == nil { + return ErrProtocol + } + if extensions != nil { + if err := tentaclewire.ValidateExtensions(*extensions); err != nil { + return err + } + } + // os.Stdin may be created before the runtime marks a pipe pollable. A + // blocking read on it cannot reliably be interrupted by Close from another + // goroutine. Adopt owned nonblocking duplicates so Go's poller can wake both + // pipe directions on timeout. Non-file closers (e.g. io.Pipe) retain their + // documented Close behavior. + if file, ok := input.(*os.File); ok { + pollable, err := sessionPipe(file) + if err != nil { + input.Close() + output.Close() + return err + } + input = pollable + } + if file, ok := output.(*os.File); ok { + pollable, err := sessionPipe(file) + if err != nil { + input.Close() + output.Close() + return err + } + output = pollable + } + return serve(input, output, commands, extensions, client) +} + +func sessionPipe(file *os.File) (*os.File, error) { + fd, err := syscall.Dup(int(file.Fd())) + if err != nil { + return nil, err + } + syscall.CloseOnExec(fd) + if err := syscall.SetNonblock(fd, true); err != nil { + syscall.Close(fd) + return nil, err + } + owned := os.NewFile(uintptr(fd), file.Name()) + file.Close() + return owned, nil +} + +func (client *SessionClient) start(ctx context.Context, abort func()) bool { + client.mu.Lock() + defer client.mu.Unlock() + if client.started || client.events == nil { + return false + } + client.started, client.ctx, client.abort = true, ctx, abort + return true +} + +func (client *SessionClient) enqueue(frame []byte) error { + client.mu.Lock() + defer client.mu.Unlock() + return client.enqueueLocked(frame) +} + +func (client *SessionClient) enqueueLocked(frame []byte) error { + if client.ctx == nil || client.ctx.Err() != nil { + return io.ErrClosedPipe + } + if client.queuedBytes+len(frame) > tentaclewire.SessionQueueBytes { + client.abort() + return tentaclewire.ErrLimit + } + select { + case client.outbound <- frame: + client.queuedBytes += len(frame) + return nil + default: + client.abort() + return tentaclewire.ErrLimit + } +} + +func (client *SessionClient) write(output io.Writer) { + for { + select { + case <-client.ctx.Done(): + return + case frame := <-client.outbound: + if client.ctx.Err() != nil { + return + } + n, err := output.Write(frame) + if err == nil && n != len(frame) { + err = io.ErrShortWrite + } + client.mu.Lock() + client.queuedBytes -= len(frame) + client.mu.Unlock() + if err != nil { + client.abort() + return + } + } + } +} + +// Call has a two-second maximum deadline. Timeout/cancellation retires the +// session, so an abandoned effect cannot leave unbounded pending calls behind. +// The host currently implements remote.open, remote.close and remote.heartbeat. +func (client *SessionClient) Call(ctx context.Context, method string, params any, result any) error { + if ctx == nil { + return errors.New("nil session call context") + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + if err := ctx.Err(); err != nil { + return err + } + data, err := json.Marshal(params) + if err != nil || len(data) > 8192 || tentaclewire.ValidateJSON(data) != nil || len(method) == 0 || len(method) > 128 { + return ErrProtocol + } + client.mu.Lock() + if !client.active || client.ctx.Err() != nil { + client.mu.Unlock() + return io.ErrClosedPipe + } + if len(client.pending) >= 8 { + client.mu.Unlock() + return tentaclewire.ErrLimit + } + client.sequence++ + id := "c:" + strconv.FormatUint(client.sequence, 10) + frame, err := json.Marshal(request{JSONRPC: "2.0", ID: id, Method: method, Params: data}) + if err != nil { + client.mu.Unlock() + return ErrProtocol + } + response := make(chan sessionReply, 1) + client.pending[id] = response + err = client.enqueueLocked(append(frame, '\n')) + client.mu.Unlock() + defer func() { client.mu.Lock(); delete(client.pending, id); client.mu.Unlock() }() + if err != nil { + return err + } + select { + case <-ctx.Done(): + client.abort() + return ctx.Err() + case <-client.ctx.Done(): + return io.ErrClosedPipe + case reply := <-response: + if ctx.Err() != nil { + client.abort() + return ctx.Err() + } + if client.ctx.Err() != nil { + return io.ErrClosedPipe + } + if reply.err != nil { + return reply.err + } + if result != nil { + return tentaclewire.DecodeStrict(reply.value, result) + } + return nil + } +} + +func (client *SessionClient) acceptReply(frame []byte) error { + var reply struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` + Result json.RawMessage `json:"result"` + Error *SessionError `json:"error"` + } + if tentaclewire.DecodeStrict(frame, &reply) != nil || reply.JSONRPC != "2.0" { + return ErrProtocol + } + var fields map[string]json.RawMessage + _ = json.Unmarshal(frame, &fields) + _, hasResult := fields["result"] + _, hasError := fields["error"] + if hasResult == hasError || hasError && (reply.Error == nil || len(reply.Error.Message) > 1024) { + return ErrProtocol + } + if hasError { + var fields map[string]json.RawMessage + _ = json.Unmarshal(frame, &fields) + var detail map[string]json.RawMessage + _ = json.Unmarshal(fields["error"], &detail) + if len(detail["code"]) == 0 || string(detail["code"]) == "null" || len(detail["message"]) == 0 || string(detail["message"]) == "null" { + return ErrProtocol + } + } + client.mu.Lock() + defer client.mu.Unlock() + waiter := client.pending[reply.ID] + if !client.active || waiter == nil { + return ErrProtocol + } + delete(client.pending, reply.ID) + var err error + if reply.Error != nil { + err = reply.Error + } + waiter <- sessionReply{value: reply.Result, err: err} + return nil +} + +func (client *SessionClient) acceptEvent(params json.RawMessage) error { + var event SessionEvent + var fields map[string]json.RawMessage + if json.Unmarshal(params, &fields) != nil { + return ErrProtocol + } + for _, value := range fields { + if string(value) == "null" { + return ErrProtocol + } + } + if tentaclewire.DecodeStrict(params, &event) != nil || !tentaclewire.ValidSessionEvent(event) { + return ErrProtocol + } + client.mu.Lock() + defer client.mu.Unlock() + if !client.active || event.Sequence != client.eventSequence+1 { + return ErrProtocol + } + client.eventSequence = event.Sequence + select { + case client.events <- event: + return nil + default: + return tentaclewire.ErrLimit + } +} diff --git a/sdk/go/tentacle/tentacle.go b/sdk/go/tentacle/tentacle.go index 6c1c3d9..7e6087c 100644 --- a/sdk/go/tentacle/tentacle.go +++ b/sdk/go/tentacle/tentacle.go @@ -12,6 +12,7 @@ import ( "errors" "git.stormux.org/storm/cthulhu/internal/config" "io" + "slices" "strconv" "sync" @@ -46,6 +47,9 @@ type Interaction = tentaclewire.Interaction type Submission = tentaclewire.Submission type Item = tentaclewire.Item type Form = interaction.Form + +// Element.Secret masks transient Entry controls; it is not supported in +// persistent PreferencePage controls. Submitted Value.Text remains sensitive. type Element = interaction.Element type ListItem = interaction.Item type Value = interaction.Value @@ -90,7 +94,7 @@ type request struct { // handlers and waits for them; the host kills a child that ignores cancellation. // Never print to stdout. Command declarations must remain unchanged during Serve. func Serve(input io.ReadCloser, output io.WriteCloser, commands []Command) error { - return serve(input, output, commands, nil) + return serve(input, output, commands, nil, nil) } // ServeWithExtensions declares session-owned preferences pages and quick settings. @@ -98,14 +102,40 @@ func ServeWithExtensions(input io.ReadCloser, output io.WriteCloser, commands [] if err := tentaclewire.ValidateExtensions(extensions); err != nil { return err } - return serve(input, output, commands, &extensions) + return serve(input, output, commands, &extensions, nil) } -func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, extensions *Extensions) error { +func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, extensions *Extensions, client *SessionClient) error { ctx, cancel := context.WithCancel(context.Background()) var workers sync.WaitGroup var writeMutex sync.Mutex - defer func() { cancel(); input.Close(); output.Close(); workers.Wait() }() + clientStarted := false + defer func() { + cancel() + input.Close() + output.Close() + if clientStarted { + client.mu.Lock() + client.active = false + client.pending = nil + close(client.events) + client.mu.Unlock() + } + workers.Wait() + if clientStarted { + for len(client.outbound) != 0 { + <-client.outbound + } + } + }() + if client != nil { + if !client.start(ctx, func() { cancel(); input.Close(); output.Close() }) { + return ErrProtocol + } + clientStarted = true + workers.Add(1) + go func() { defer workers.Done(); client.write(output) }() + } handlers := make(map[string]Handler) readerCommands := make(map[string]bool) if len(commands) == 0 || len(commands) > 32 { @@ -131,6 +161,12 @@ func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, exten frame, _ = json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32603, "message": "Invalid or oversized result"}}) } + if client != nil { + if release != nil { + release() + } + return client.enqueue(append(frame, '\n')) + } writeMutex.Lock() defer writeMutex.Unlock() // A completed reply may reach the host before Write returns. Release @@ -157,6 +193,18 @@ func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, exten if err != nil { return err } + if client != nil { + var fields map[string]json.RawMessage + if tentaclewire.ValidateJSON(frame) != nil || json.Unmarshal(frame, &fields) != nil { + return ErrProtocol + } + if _, reply := fields["method"]; !reply { + if err := client.acceptReply(frame); err != nil { + return err + } + continue + } + } var message request if tentaclewire.DecodeStrict(frame, &message) != nil || message.JSONRPC != "2.0" { return ErrProtocol @@ -190,16 +238,16 @@ func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, exten Capabilities []string `json:"capabilities"` } if session != "" || message.ID == "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil || - params.Protocol.Major == nil || params.Protocol.Minor == nil || *params.Protocol.Major != 1 || *params.Protocol.Minor != 0 || params.Session == "" || len(params.Session) > 128 || - !tentaclewire.ValidAPIs(params.APIs, params.Capabilities) { + params.Protocol.Major == nil || params.Protocol.Minor == nil || *params.Protocol.Major != 1 || (*params.Protocol.Minor != 0 && *params.Protocol.Minor != 1) || params.Session == "" || len(params.Session) > 128 || + !tentaclewire.ValidNegotiation(*params.Protocol.Minor, params.APIs, params.Capabilities) || *params.Protocol.Minor == 1 && params.Capabilities == nil || (client != nil) != (*params.Protocol.Minor == 1) { return ErrProtocol } for _, command := range commands { - if command.Presentation && len(params.APIs) < 2 || command.Interaction && (!command.Presentation || len(params.APIs) < 3) || command.Reader && len(params.APIs) != 4 { + if command.Presentation && !slices.Contains(params.APIs, "presentation") || command.Interaction && (!command.Presentation || !slices.Contains(params.APIs, "interaction")) || command.Reader && !slices.Contains(params.APIs, "reader-settings") { return ErrProtocol } } - if extensions != nil && len(params.APIs) != 4 { + if extensions != nil && !slices.Contains(params.APIs, "reader-settings") { return ErrProtocol } session = params.Session @@ -218,6 +266,18 @@ func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, exten return ErrProtocol } active = true + if client != nil { + client.mu.Lock() + client.active = true + client.mu.Unlock() + } + case "session.event": + if !active || message.ID != "" || client == nil { + return ErrProtocol + } + if err := client.acceptEvent(message.Params); err != nil { + return err + } case "command.invoke": var params struct { Command string `json:"command"` diff --git a/sdk/python/cthulhu_tentacle/__init__.py b/sdk/python/cthulhu_tentacle/__init__.py index e5f29e9..498dfd6 100644 --- a/sdk/python/cthulhu_tentacle/__init__.py +++ b/sdk/python/cthulhu_tentacle/__init__.py @@ -1,6 +1,12 @@ # Copyright (c) 2026 Storm Dragon # SPDX-License-Identifier: GPL-3.0-or-later -"""Experimental command/presentation SDK. stdout is reserved for the protocol.""" +"""Experimental command/presentation SDK. stdout is reserved for the protocol. + +Interaction form dictionaries may set secret=True on entries (kind=2). The host +masks editing output and disallows copy/cut; successful submissions still carry +plaintext values to the continuation. Never log or present those values. Secret +entries cannot be used in persistent preference pages; older hosts reject them. +""" import json import sys @@ -80,7 +86,7 @@ def _fields(value, required, optional=()): raise ProtocolError("Invalid fields") -def serve(commands, inputStream=None, outputStream=None, extensions=None): +def serve(commands, inputStream=None, outputStream=None, extensions=None, session_client=None): """Run in a dedicated child, then exit. Handlers receive (cancelled, arguments). cancelled is a threading.Event shared by the session. Up to eight handlers @@ -94,6 +100,9 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None): writeLock = threading.Lock() workers = [] slots = threading.BoundedSemaphore(8) + client_started = False + if session_client is not None: + from .session import valid_negotiation def write_reply(requestId, value=None, failed=False, release=None): reply = {"jsonrpc": "2.0", "id": requestId} @@ -108,6 +117,11 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None): except (ValueError, TypeError, UnicodeError, RecursionError, ProtocolError): frame = _encode({"jsonrpc": "2.0", "id": requestId, "error": { "code": -32603, "message": "Invalid or oversized result"}}) + if session_client is not None: + if release is not None: + release() + session_client._enqueue(frame + b"\n") + return with writeLock: # Bound eight handlers/waiting replies plus one serialized writer. if release is not None: @@ -133,12 +147,17 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None): failed = False try: write_reply(requestId, value, failed, release_slot) - except (OSError, ValueError): + except (OSError, ValueError, ProtocolError, SessionError): cancelled.set() + if session_client is not None: + session_client._abort() finally: release_slot() try: + if session_client is not None: + session_client._start(cancelled, inputStream, outputStream) + client_started = True commands = list(commands) handlers = {command.id: command.handle for command in commands} if (not 1 <= len(commands) <= 32 or len(handlers) != len(commands) @@ -151,7 +170,7 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None): raise ProtocolError("Invalid command capabilities") session, active, sequence = None, False, 0 while not cancelled.is_set(): - frame = inputStream.readline(MAX_FRAME + 2) + frame = session_client._readline() if session_client is not None else inputStream.readline(MAX_FRAME + 2) if not frame: return if not frame.endswith(b"\n") or len(frame) > MAX_FRAME + 1: @@ -162,6 +181,9 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None): _depth(message) except (ValueError, UnicodeError, RecursionError) as error: raise ProtocolError("Invalid JSON") from error + if session_client is not None and isinstance(message, dict) and "method" not in message: + session_client._reply(message) + continue _fields(message, ("jsonrpc", "method"), ("id", "params")) if message["jsonrpc"] != "2.0": raise ProtocolError("Invalid JSON-RPC version") @@ -179,19 +201,23 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None): if method == "initialize": _fields(params, ("protocol", "session", "apis", "capabilities")) version = params["protocol"] - if (session is not None or not requestId or version != {"major": 1, "minor": 0} + expected_minor = 1 if session_client is not None else 0 + apis, capabilities = params["apis"], params["capabilities"] + valid_apis = (valid_negotiation(apis, capabilities) if session_client is not None else + apis in (["commands"], ["commands", "presentation"], + ["commands", "presentation", "interaction"], + ["commands", "presentation", "interaction", "reader-settings"]) + and capabilities == apis) + if (session is not None or not requestId or version != {"major": 1, "minor": expected_minor} or any(type(value) is not int for value in version.values()) or not isinstance(params["session"], str) or not params["session"] or len(params["session"].encode("utf-8")) > 128 - or params["apis"] not in (["commands"], ["commands", "presentation"], - ["commands", "presentation", "interaction"], - ["commands", "presentation", "interaction", "reader-settings"]) - or params["capabilities"] != params["apis"] + or not valid_apis or (any(command.presentation for command in commands) - and len(params["apis"]) < 2) - or (any(command.interaction for command in commands) and len(params["apis"]) < 3) + and "presentation" not in apis) + or (any(command.interaction for command in commands) and "interaction" not in apis) or ((extensions is not None or any(command.reader for command in commands)) - and len(params["apis"]) != 4)): + and "reader-settings" not in apis)): raise ProtocolError("Invalid initialization") session = params["session"] registration = {"protocol": version, "session": session, @@ -204,6 +230,13 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None): if session is None or active or requestId or params["session"] != session: raise ProtocolError("Invalid activation") active = True + if session_client is not None: + with session_client._lock: + session_client._active = True + elif method == "session.event": + if not active or requestId or session_client is None: + raise ProtocolError("Unnegotiated event") + session_client._event(params) elif method == "command.invoke": _fields(params, ("command", "arguments")) if (not active or not requestId or not isinstance(params["command"], str) @@ -226,9 +259,16 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None): raise ProtocolError("Unknown method") finally: cancelled.set() + if client_started: + session_client._finish() # Join before closing buffered stdout: a blocked writer must remain # subject to the host deadline rather than race a Python buffer close. for worker in workers: worker.join() + if client_started: + session_client._close() inputStream.close() outputStream.close() + +# Imported after helpers to keep the protocol 1.0 API compatible. +from .session import SessionClient, SessionError diff --git a/sdk/python/cthulhu_tentacle/session.py b/sdk/python/cthulhu_tentacle/session.py new file mode 100644 index 0000000..5767c44 --- /dev/null +++ b/sdk/python/cthulhu_tentacle/session.py @@ -0,0 +1,256 @@ +# Copyright (c) 2026 Storm Dragon +# SPDX-License-Identifier: GPL-3.0-or-later +"""Opt-in protocol 1.1 lifecycle transport. No desktop effects are provided.""" + +import os +import queue +import select +import threading + +from . import MAX_FRAME, ProtocolError, _encode, _fields + +REMOTE_GRANTS = frozenset(("output-observe", "remote-present", "keyboard-capture", + "keyboard-inject", "braille-stream", "clipboard-exchange")) +BASE_APIS = frozenset(("commands", "presentation", "interaction", "reader-settings")) + + +def valid_negotiation(apis, capabilities): + if (not isinstance(apis, list) or not isinstance(capabilities, list) + or any(not isinstance(value, str) for value in apis + capabilities)): + return False + a, c = set(apis), set(capabilities) + return (len(a) == len(apis) and len(c) == len(capabilities) + and {"commands", "session-events"} <= a + and a <= BASE_APIS | {"session-events", "remote-access"} + and c <= (BASE_APIS - {"commands"}) | REMOTE_GRANTS + and a & (BASE_APIS - {"commands"}) == c & (BASE_APIS - {"commands"}) + and ("interaction" not in a or "presentation" in a) + and ("reader-settings" not in a or "interaction" in a) + and ("remote-access" in a) == bool(c & REMOTE_GRANTS)) + + +def _positive(value): + return type(value) is int and 0 < value < 2**64 + + +def _token(value): + return isinstance(value, str) and len(value.encode("utf-8")) == 32 + + +class SessionError(Exception): + def __init__(self, code, message): + super().__init__("Tentacle session error " + str(code)) + self.code, self.message = code, message + + +class SessionClient: + """Single-use client passed as session_client= to serve. + + Call from handlers or owned background workers, never from the receive loop. + next_event blocks until an event or session shutdown. Call timeout retires + the session; the host revokes its lease. Binary streams must have pipe FDs. + """ + def __init__(self): + self._lock = threading.Lock() + self._events = queue.Queue(32) + self._outbound = queue.Queue(32) + self._pending = {} + self._bytes = self._sequence = self._event_sequence = 0 + self._started = self._active = False + self._stopped = threading.Event() + self._wake_read = self._wake_write = None + self._buffer = bytearray() + + def _start(self, cancelled, input_stream, output_stream): + with self._lock: + if self._started: + raise ProtocolError("Session client already used") + self._started = True + self._cancelled = cancelled + self._input, self._output = input_stream.fileno(), output_stream.fileno() + self._wake_read, self._wake_write = os.pipe() + os.set_blocking(self._wake_write, False) + os.set_blocking(self._input, False) + os.set_blocking(self._output, False) + self._writer = threading.Thread(target=self._write_loop) + self._writer.start() + + def _abort(self): + # Called under _lock too; never acquire it here. + self._stopped.set() + self._cancelled.set() + if self._wake_write is not None: + try: + os.write(self._wake_write, b"x") + except OSError: + pass + try: + self._outbound.put_nowait(None) + except queue.Full: + pass + + def _finish(self): + self._abort() + self._writer.join() + with self._lock: + self._active = False + for waiter in self._pending.values(): + waiter.put_nowait(SessionError(-32000, "Session closed")) + self._pending.clear() + while not self._events.empty(): + self._events.get_nowait() + self._events.put_nowait(None) + def _close(self): + os.close(self._wake_read) + os.close(self._wake_write) + self._wake_read = self._wake_write = None + + def _readline(self): + while not self._stopped.is_set(): + end = self._buffer.find(b"\n") + if end >= 0: + frame = bytes(self._buffer[:end + 1]) + del self._buffer[:end + 1] + if len(frame) > MAX_FRAME + 1: + raise ProtocolError("Frame limit") + return frame + if len(self._buffer) > MAX_FRAME: + raise ProtocolError("Frame limit") + ready, _, _ = select.select([self._input, self._wake_read], [], []) + if self._wake_read in ready: + return b"" + try: + data = os.read(self._input, 4096) + except BlockingIOError: + continue + if not data: + if self._buffer: + raise ProtocolError("Unterminated frame") + return b"" + self._buffer.extend(data) + return b"" + + def _enqueue_locked(self, frame): + if self._stopped.is_set(): + raise SessionError(-32000, "Session closed") + if self._bytes + len(frame) > 256 * 1024: + self._abort() + raise ProtocolError("Session queue limit") + try: + self._outbound.put_nowait(frame) + except queue.Full: + self._abort() + raise ProtocolError("Session queue limit") from None + self._bytes += len(frame) + + def _enqueue(self, frame): + with self._lock: + self._enqueue_locked(frame) + + def _write_loop(self): + try: + while not self._stopped.is_set(): + frame = self._outbound.get() + if frame is None: + return + position = 0 + while position < len(frame) and not self._stopped.is_set(): + ready, writable, _ = select.select([self._wake_read], [self._output], []) + if ready: + return + if writable: + try: + position += os.write(self._output, frame[position:]) + except BlockingIOError: + continue + with self._lock: + self._bytes -= len(frame) + except OSError: + self._abort() + + def call(self, method, params, timeout=2): + if not isinstance(method, str) or not 1 <= len(method.encode("utf-8")) <= 128 or len(_encode(params)) > 8192: + raise ProtocolError("Invalid session request") + if not 0 < timeout <= 2: + raise ValueError("Session call timeout must be in (0, 2]") + with self._lock: + if not self._active or self._stopped.is_set(): + raise SessionError(-32000, "Session inactive") + if len(self._pending) >= 8: + raise ProtocolError("Pending call limit") + self._sequence += 1 + request_id = "c:" + str(self._sequence) + waiter = queue.Queue(1) + self._pending[request_id] = waiter + try: + self._enqueue_locked(_encode({"jsonrpc": "2.0", "id": request_id, + "method": method, "params": params}) + b"\n") + except BaseException: + del self._pending[request_id] + raise + try: + value = waiter.get(timeout=timeout) + if isinstance(value, SessionError): + raise value + if self._stopped.is_set(): + raise SessionError(-32000, "Session closed") + return value + except queue.Empty: + self._abort() + raise TimeoutError("Session call expired") from None + finally: + with self._lock: + self._pending.pop(request_id, None) + + def next_event(self, timeout=None): + if self._stopped.is_set(): + return None + return self._events.get(timeout=timeout) + + def _reply(self, message): + _fields(message, ("jsonrpc", "id"), ("result", "error")) + if (message["jsonrpc"] != "2.0" or not isinstance(message["id"], str) + or ("result" in message) == ("error" in message)): + raise ProtocolError("Invalid session reply") + value = message.get("result") + if "error" in message: + error = message["error"] + _fields(error, ("code", "message")) + if type(error["code"]) is not int or not isinstance(error["message"], str) or len(error["message"].encode("utf-8")) > 1024: + raise ProtocolError("Invalid session error") + value = SessionError(error["code"], error["message"]) + with self._lock: + waiter = self._pending.pop(message["id"], None) + if not self._active or waiter is None: + raise ProtocolError("Unexpected session reply") + waiter.put_nowait(value) + + def _event(self, event): + _fields(event, ("sequence", "kind", "epoch"), ("action", "lease", "role", "grants", "reason")) + if not _positive(event["sequence"]) or not _positive(event["epoch"]): + raise ProtocolError("Invalid event sequence") + if event["kind"] == "remote.action": + grants = event.get("grants") + role = event.get("role") + if (not _token(event.get("action")) or event.get("lease", "") != "" or event.get("reason", "") != "" + or role not in ("controller", "controlled") or not isinstance(grants, list) + or not grants or any(not isinstance(grant, str) for grant in grants) + or len(set(grants)) != len(grants) or not set(grants) <= REMOTE_GRANTS + or role == "controller" and set(grants) & {"output-observe", "keyboard-inject"} + or role == "controlled" and set(grants) & {"remote-present", "keyboard-capture"}): + raise ProtocolError("Invalid action event") + elif event["kind"] == "remote.revoked": + if (not _token(event.get("lease")) or event.get("action", "") != "" or event.get("role", "") != "" + or event.get("grants", []) != [] or event.get("reason") not in ("closed", "expired", "local", "replaced")): + raise ProtocolError("Invalid revoke event") + else: + raise ProtocolError("Unknown session event") + with self._lock: + if not self._active or event["sequence"] != self._event_sequence + 1: + raise ProtocolError("Out-of-order session event") + self._event_sequence = event["sequence"] + try: + self._events.put_nowait(event) + except queue.Full: + self._abort() + raise ProtocolError("Event queue limit") from None diff --git a/tests/browser-linked-headings.html b/tests/browser-linked-headings.html index 6be5b96..c0de098 100644 --- a/tests/browser-linked-headings.html +++ b/tests/browser-linked-headings.html @@ -9,6 +9,8 @@

#1 Username Example date

Post destination

Date destination

Ordinary heading

+

Before Linked image title after

+

Image destination

Choose: First link and Second link

First destination

Second destination

Offscreen first topic
diff --git a/tests/native_remote_input.py b/tests/native_remote_input.py new file mode 100644 index 0000000..69a8c4f --- /dev/null +++ b/tests/native_remote_input.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Private Xorg Remote feasibility experiments, not a production input helper. + +Always run this file without arguments. The launcher replaces DISPLAY with Xvfb +before creating any X connection. All keys in these experiments are synthetic; +they cannot establish physical/injected collision or hardware recovery safety. +""" +import ctypes as C +import os +from pathlib import Path +import select +import signal +import subprocess +import sys +import tempfile +import time +import unittest + +from native_test_session import run_private_session + + +class KeyEvent(C.Structure): + _fields_ = [("type", C.c_int), ("serial", C.c_ulong), ("send_event", C.c_int), + ("display", C.c_void_p), ("window", C.c_ulong), ("root", C.c_ulong), + ("subwindow", C.c_ulong), ("time", C.c_ulong), ("x", C.c_int), + ("y", C.c_int), ("x_root", C.c_int), ("y_root", C.c_int), + ("state", C.c_uint), ("keycode", C.c_uint), ("same_screen", C.c_int)] + + +class Event(C.Union): + _fields_ = [("type", C.c_int), ("key", KeyEvent), ("pad", C.c_long * 24)] + + +class X11: + def __init__(self): + self.x = C.CDLL("libX11.so.6") + self.xtst = C.CDLL("libXtst.so.6") + signatures = { + "XOpenDisplay": ([C.c_char_p], C.c_void_p), + "XCloseDisplay": ([C.c_void_p], C.c_int), + "XDefaultRootWindow": ([C.c_void_p], C.c_ulong), + "XStringToKeysym": ([C.c_char_p], C.c_ulong), + "XKeysymToKeycode": ([C.c_void_p, C.c_ulong], C.c_ubyte), + "XSync": ([C.c_void_p, C.c_int], C.c_int), + "XQueryKeymap": ([C.c_void_p, C.POINTER(C.c_ubyte)], C.c_int), + "XCreateSimpleWindow": ([C.c_void_p, C.c_ulong, C.c_int, C.c_int, + C.c_uint, C.c_uint, C.c_uint, C.c_ulong, + C.c_ulong], C.c_ulong), + "XSelectInput": ([C.c_void_p, C.c_ulong, C.c_long], C.c_int), + "XMapWindow": ([C.c_void_p, C.c_ulong], C.c_int), + "XSetInputFocus": ([C.c_void_p, C.c_ulong, C.c_int, C.c_ulong], C.c_int), + "XGrabKeyboard": ([C.c_void_p, C.c_ulong, C.c_int, C.c_int, + C.c_int, C.c_ulong], C.c_int), + "XUngrabKeyboard": ([C.c_void_p, C.c_ulong], C.c_int), + "XPending": ([C.c_void_p], C.c_int), + "XNextEvent": ([C.c_void_p, C.POINTER(Event)], C.c_int), + } + for name, (arguments, result) in signatures.items(): + function = getattr(self.x, name) + function.argtypes, function.restype = arguments, result + self.xtst.XTestFakeKeyEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong] + self.xtst.XTestFakeKeyEvent.restype = C.c_int + self.display = self.x.XOpenDisplay(None) + if not self.display: + raise RuntimeError("private X display unavailable") + self.root = self.x.XDefaultRootWindow(self.display) + + def close(self): + if self.display: + self.x.XCloseDisplay(self.display) + self.display = None + + def sync(self): + self.x.XSync(self.display, False) + + def code(self, name): + code = self.x.XKeysymToKeycode(self.display, self.x.XStringToKeysym(name.encode())) + if not code: + raise ValueError("fixture keysym unavailable") + return code + + def key(self, code, down): + if not self.xtst.XTestFakeKeyEvent(self.display, code, down, 0): + raise RuntimeError("XTEST failed") + self.sync() + + def held(self, code): + keys = (C.c_ubyte * 32)() + self.x.XQueryKeymap(self.display, keys) + return bool(keys[code // 8] & (1 << (code % 8))) + + def focus_window(self): + window = self.x.XCreateSimpleWindow(self.display, self.root, 0, 0, 200, 100, 0, 0, 0) + self.x.XSelectInput(self.display, window, 3) # KeyPressMask | KeyReleaseMask + self.x.XMapWindow(self.display, window) + self.x.XSetInputFocus(self.display, window, 1, 0) + self.sync() + + def grab(self): + return self.x.XGrabKeyboard(self.display, self.root, False, 1, 1, 0) + + def events(self): + self.sync() + result = [] + while self.x.XPending(self.display): + event = Event() + self.x.XNextEvent(self.display, C.byref(event)) + if event.type in (2, 3): + result.append((event.type, event.key.keycode, bool(event.key.send_event))) + return result + + +def helper(): + """One private descriptor, fixed two-byte records, bounded heartbeat lifetime. + + This deliberately has no network, lease API, production entry point or + parent-death signal. EOF must allow the independent owner to release keys. + Stdout acknowledgements are test synchronization, not a production protocol. + """ + x = X11() + held = set() + pending = b"" + deadline = time.monotonic() + 3 + reason = "eof" + print("ready", flush=True) + try: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0 or not select.select([0], [], [], max(0, remaining))[0]: + reason = "expired" + break + data = os.read(0, 2 - len(pending)) + if not data: + break + pending += data + if len(pending) != 2: + continue + if time.monotonic() >= deadline: + reason = "expired" + break + operation, code = pending + pending = b"" + # Only an explicit heartbeat renews the watchdog, not key traffic. + if operation == ord("H") and code == 0: + deadline = time.monotonic() + 3 + elif operation in (ord("D"), ord("U")) and 8 <= code <= 255: + if operation == ord("D"): + if code not in held: + if x.held(code): + reason = "collision" + break + held.add(code) + x.key(code, True) + elif code in held: + x.key(code, False) + held.remove(code) + else: + reason = "invalid" + break + print("ok", flush=True) + finally: + for code in sorted(held): + x.key(code, False) + x.close() + print(reason, flush=True) + + +def owner(write_fd): + # Simulates the reader's exclusive write end. Killing/stopping this process + # must not kill the helper before it has performed cleanup. + try: + while data := os.read(0, 256): + os.write(write_fd, data) # <= PIPE_BUF; blocking pipe, one writer. + finally: + os.close(write_fd) + + +class RemoteInputTests(unittest.TestCase): + def setUp(self): + self.x = X11() + self.addCleanup(self.x.close) + self.x.focus_window() + self.shift = self.x.code("Shift_L") + self.control = self.x.code("Control_L") + self.a = self.x.code("a") + # The fixture alone owns these synthetic keys, even after failed tests. + self.addCleanup(self.release_fixture_keys) + + def release_fixture_keys(self): + for code in (self.shift, self.control, self.a): + self.x.key(code, False) + + def spawn(self, *arguments, **kwargs): + process = subprocess.Popen([sys.executable, "-B", __file__, *arguments], **kwargs) + def stop(): + if process.poll() is None: + process.kill() + process.wait(timeout=5) + for stream in (process.stdin, process.stdout, process.stderr): + if stream: + stream.close() + self.addCleanup(stop) + return process + + def line(self, process): + self.assertTrue(select.select([process.stdout], [], [], 5)[0], "helper response timed out") + line = process.stdout.readline() + self.assertTrue(line, "helper exited without response") + return line.rstrip().decode() + + def start_helper(self): + read_fd, write_fd = os.pipe() + try: + # The test owns neither pipe end after launch. The two children are + # siblings so the harness can reap both after killing the owner. + helper_process = self.spawn("--helper", stdin=read_fd, stdout=subprocess.PIPE, bufsize=0) + owner_process = self.spawn("--owner", str(write_fd), pass_fds=(write_fd,), + stdin=subprocess.PIPE, bufsize=0) + finally: + os.close(read_fd) + os.close(write_fd) + self.assertEqual(self.line(helper_process), "ready") + return owner_process, helper_process + + def send(self, owner_process, helper_process, operation, code=0): + owner_process.stdin.write(bytes((ord(operation), code))) + self.assertEqual(self.line(helper_process), "ok") + + def assert_recovered(self, helper_process, reason, started): + self.assertEqual(self.line(helper_process), reason) + self.assertEqual(helper_process.wait(timeout=5), 0) + elapsed = time.monotonic() - started + self.assertLess(elapsed, 4, "cleanup exceeded watchdog plus scheduling allowance") + self.assertFalse(self.x.held(self.shift)) + self.assertFalse(self.x.held(self.control)) + self.x.events() + self.x.key(self.a, True) + self.x.key(self.a, False) + self.assertEqual(self.x.events(), [(2, self.a, False), (3, self.a, False)]) + print(f"{reason} cleanup observed within {elapsed:.3f}s", flush=True) + + def test_reader_sigkill_releases_owned_modifiers(self): + owner_process, helper_process = self.start_helper() + self.send(owner_process, helper_process, "D", self.shift) + self.send(owner_process, helper_process, "D", self.control) + self.assertTrue(self.x.held(self.shift)) + self.assertTrue(self.x.held(self.control)) + started = time.monotonic() + owner_process.kill() + owner_process.wait(timeout=5) + self.assert_recovered(helper_process, "eof", started) + + def test_stopped_reader_expires_without_eof(self): + owner_process, helper_process = self.start_helper() + self.send(owner_process, helper_process, "D", self.shift) + self.send(owner_process, helper_process, "H") + started = time.monotonic() + owner_process.send_signal(signal.SIGSTOP) + self.assert_recovered(helper_process, "expired", started) + + def test_heartbeat_and_balanced_owned_keys(self): + owner_process, helper_process = self.start_helper() + self.send(owner_process, helper_process, "D", self.shift) + self.send(owner_process, helper_process, "D", self.shift) # Duplicate down. + self.send(owner_process, helper_process, "U", self.control) # Not owned. + self.send(owner_process, helper_process, "H") + self.assertTrue(self.x.held(self.shift)) + self.send(owner_process, helper_process, "U", self.shift) + self.send(owner_process, helper_process, "U", self.shift) + self.assertFalse(self.x.held(self.shift)) + owner_process.stdin.close() + self.assert_recovered(helper_process, "eof", time.monotonic()) + + def test_heartbeat_extends_expiry(self): + owner_process, helper_process = self.start_helper() + self.send(owner_process, helper_process, "D", self.shift) + # This is a real watchdog timing experiment. Cross the original expiry + # while keeping the refreshed deadline comfortably in the future. + time.sleep(2) + self.send(owner_process, helper_process, "H") + time.sleep(1.2) + self.assertIsNone(helper_process.poll()) + self.assertTrue(self.x.held(self.shift)) + self.send(owner_process, helper_process, "U", self.shift) + owner_process.stdin.close() + self.assert_recovered(helper_process, "eof", time.monotonic()) + + def test_existing_external_key_is_not_released(self): + owner_process, helper_process = self.start_helper() + self.x.key(self.shift, True) + self.send(owner_process, helper_process, "U", self.shift) + self.assertTrue(self.x.held(self.shift)) + self.send(owner_process, helper_process, "D", self.control) + owner_process.stdin.write(bytes((ord("D"), self.shift))) + self.assertEqual(self.line(helper_process), "collision") + self.assertEqual(helper_process.wait(timeout=5), 0) + self.assertTrue(self.x.held(self.shift), "cleanup released an unowned external key") + self.assertFalse(self.x.held(self.control), "collision failed to clean owned keys") + + def test_later_same_code_xtest_collision_remains_unresolved(self): + owner_process, helper_process = self.start_helper() + self.send(owner_process, helper_process, "D", self.shift) + # Both clients use the server's XTEST keyboard. The global bitmap cannot + # represent separate ownership when a second source presses this code. + self.x.key(self.shift, True) + owner_process.stdin.close() + self.assertEqual(self.line(helper_process), "eof") + self.assertEqual(helper_process.wait(timeout=5), 0) + self.assertFalse(self.x.held(self.shift)) + print("later same-code XTEST press is not preserved by ledger cleanup; " + "physical-device collision safety remains unproven", flush=True) + + def test_fragmented_record_eof_cleans_owned_keys(self): + owner_process, helper_process = self.start_helper() + self.send(owner_process, helper_process, "D", self.shift) + owner_process.stdin.write(b"D") + owner_process.stdin.close() + self.assert_recovered(helper_process, "eof", time.monotonic()) + + def test_invalid_record_cleans_owned_keys(self): + owner_process, helper_process = self.start_helper() + self.send(owner_process, helper_process, "D", self.shift) + owner_process.stdin.write(b"?\x00") + self.assert_recovered(helper_process, "invalid", time.monotonic()) + + def test_exclusive_capture_and_grab_owner_sigkill(self): + capture = self.spawn("--capture", stdin=subprocess.PIPE, stdout=subprocess.PIPE, bufsize=0) + self.assertEqual(self.line(capture), "ready") + self.x.key(self.a, True) + self.x.key(self.a, False) + capture.stdin.write(b"e") + self.assertEqual(self.line(capture), f"[(2, {self.a}, False), (3, {self.a}, False)]") + self.assertEqual(self.x.events(), [], "captured keys reached the focused application") + capture.kill() + capture.wait(timeout=5) + # Synchronize with server teardown by acquiring the released grab. + deadline = time.monotonic() + 3 + while self.x.grab() != 0: + self.assertLess(time.monotonic(), deadline, "dead owner's grab survived") + time.sleep(0.005) + self.x.x.XUngrabKeyboard(self.x.display, 0) + self.x.sync() + self.x.key(self.a, True) + self.x.key(self.a, False) + self.assertEqual(self.x.events(), [(2, self.a, False), (3, self.a, False)]) + + def test_xtest_connection_close_does_not_release_key(self): + source = X11() + try: + source.key(self.shift, True) + finally: + source.close() + self.assertTrue(self.x.held(self.shift), "X server behavior changed; revisit recovery design") + # XTEST's send_event is false, so that bit cannot authorize local consent. + self.assertIn((2, self.shift, False), self.x.events()) + + +def capture(): + x = X11() + try: + if x.grab() != 0: + raise RuntimeError("private keyboard grab failed") + print("ready", flush=True) + while os.read(0, 1): + print(x.events(), flush=True) + finally: + x.close() + + +def main(): + if len(sys.argv) == 1: + with tempfile.TemporaryDirectory(prefix="cthulhu-remote-input-") as temporary: + environment = dict(os.environ, CTHULHU_REMOTE_INPUT_PRIVATE=temporary, + XDG_CONFIG_HOME=temporary, GOCACHE="/tmp/cthulhu-cache", + GOPROXY="off") + return run_private_session( + ["xvfb-run", "-a", "-s", "-screen 0 1024x768x24", "dbus-run-session", "--", + sys.executable, "-B", __file__, "--inside"], env=environment, check=True).returncode + if not os.environ.get("CTHULHU_REMOTE_INPUT_PRIVATE"): + sys.exit("Use native_remote_input.py without arguments to create a private display") + if sys.argv[1] == "--helper": + helper() + elif sys.argv[1] == "--owner": + owner(int(sys.argv[2])) + elif sys.argv[1] == "--capture": + capture() + elif sys.argv[1] == "--inside": + suite = unittest.defaultTestLoader.loadTestsFromTestCase(RemoteInputTests) + if not unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful(): + return 1 + # Check the real command owner's existing exclusive-capture and restore + # paths as well as the standalone recovery experiment. + subprocess.run([ + "go", "test", "-race", "./internal/platform/xorg", "-count=1", "-timeout=60s", + "-run", "^TestNative(InternalUICapturesAndReleases|LearnModeKeepsKeysFromFocusedApplication|RemoteInjectedKeyObservation|LocalUIRejectsInjectedApproval|SuspendedQueuedKey|XTermHandoffKeepsKeypadReleased)$", + "-v"], cwd=Path(__file__).resolve().parents[1], check=True, + env=dict(os.environ, CTHULHU_NATIVE_X11="1", CTHULHU_NATIVE_REMOTE_INPUT="1")) + else: + sys.exit("unknown private fixture mode") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/native_tentacle_updates.py b/tests/native_tentacle_updates.py index aed3200..64dca11 100644 --- a/tests/native_tentacle_updates.py +++ b/tests/native_tentacle_updates.py @@ -11,10 +11,11 @@ def update_snapshot(): "ID": "probe", "LocalID": "echo", "Name": "Update probe", "Repository": repositoryUrl, "RepositoryID": "example.source", "Version": "1.0", "Enabled": True, "Presentation": True, + "SessionEvents": True, "RemoteGrants": ["remote-present"], }, "State": "active"}], "Repositories": [{"URL": repositoryUrl, "ID": "example.source", "Name": "Example", "Tentacles": [{"ID": "echo", "Name": "Update probe", "Version": "1.1", - "Capabilities": ["presentation", "interaction"]}]}], + "Capabilities": ["presentation", "interaction", "output-observe", "keyboard-inject"]}]}], "Updates": [{"ID": "probe", "LocalID": "echo", "Name": "Update probe", "InstalledVersion": "1.0", "AvailableVersion": "1.1", "RepositoryURL": repositoryUrl, "RepositoryID": "example.source"}], @@ -51,8 +52,10 @@ def check_page(pyatspi, process, window, named, walk, focus, key, request, send, dialog = wait_for(confirmation, "keyboard activation did not open confirmation") message = " ".join(node.name for node in walk(dialog)) assert "version 1.0 to version 1.1" in message, message - assert "Current capabilities: presentation." in message, message - assert "Repository declares: presentation, interaction." in message, message + assert "Current capabilities: presentation, remote-present." in message, message + assert "Repository declares: presentation, interaction, output-observe, keyboard-inject." in message, message + assert "accept keyboard input from the remote controller" in message, message + assert "Each session requires local approval" in message, message cancel = next(node for node in walk(dialog) if node.name == "Cancel") focus(cancel) key("space") diff --git a/tests/native_tentacles.py b/tests/native_tentacles.py index af5b66d..b5c7910 100644 --- a/tests/native_tentacles.py +++ b/tests/native_tentacles.py @@ -15,7 +15,7 @@ def check_page(pyatspi, process, window, nodes, named, walk, focus, key, origin = "https://example.org/tentacles" repository = {"URL": origin, "ID": "example.source", "Name": "Example", "Tentacles": [{"ID": "echo", "Name": "Example package", "Version": "1.0", - "Capabilities": ["presentation"], "Releases": [{}]}]} + "Capabilities": ["presentation", "output-observe", "keyboard-inject"], "Releases": [{}]}]} snapshot = {"Entries": [], "Repositories": [repository]} table = next(node for node in walk(window) if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_TABLE) @@ -68,6 +68,9 @@ def check_page(pyatspi, process, window, nodes, named, walk, focus, key, message = " ".join(node.name for node in walk(dialog)) assert "Example package version 1.0" in message, message assert "send speech and braille messages" in message, message + assert "share local reader output with the remote controller" in message, message + assert "accept keyboard input from the remote controller" in message, message + assert "Each session requires local approval" in message, message button = next(node for node in walk(dialog) if node.name == label and node.getRole() == pyatspi.ROLE_PUSH_BUTTON) focus(button)