From 573d15d7b45f64277c4a0432f803859c7c1aaae3 Mon Sep 17 00:00:00 2001 From: Angus Kola Date: Tue, 3 Nov 2026 22:33:26 -0500 Subject: [PATCH] Add Remote session foundations and feasibility checks Add opt-in Tentacle protocol 1.1, explicit remote grants, lifecycle IPC and expiring core leases in both SDKs. Revoke access before update preparation, including failed updates. Add transient secret entries, shared Braille cell export and isolated Xorg recovery experiments with focused tests and documented limitations. Production consent, desktop services and live interoperability remain pending. --- cmd/tentacle-host/main.go | 9 +- docs/tentacle-secret-entries.md | 77 ++++ docs/tentacle-sessions.md | 181 ++++++++ docs/testing/remote-braille-feasibility.md | 122 +++++ docs/testing/remote-input-feasibility.md | 104 +++++ internal/app/internal_ui.go | 6 + internal/app/internal_ui_native_test.go | 50 +++ internal/app/internal_ui_secret_test.go | 93 ++++ internal/app/tentacle_interaction.go | 3 + internal/app/tentacle_management.go | 24 + .../app/tentacle_remote_management_test.go | 114 +++++ internal/braille/cells.go | 63 +++ internal/braille/cells_test.go | 127 ++++++ internal/braille/native.go | 17 +- .../braille/remote_braille_native_test.go | 116 +++++ internal/interaction/element.go | 39 ++ internal/interaction/secret_test.go | 93 ++++ internal/interaction/session.go | 65 ++- .../platform/xorg/remote_input_native_test.go | 80 ++++ internal/preferences/tentacle_confirmation.go | 26 +- .../preferences/tentacle_confirmation_test.go | 15 + internal/tentacle/install.go | 20 +- internal/tentacle/install_test.go | 60 +++ internal/tentacle/manager.go | 15 +- internal/tentacle/negotiation.go | 29 ++ internal/tentacle/process.go | 30 +- internal/tentacle/protocol.go | 6 +- internal/tentacle/registry.go | 5 +- internal/tentacle/remote.go | 248 +++++++++++ internal/tentacle/remote_test.go | 418 ++++++++++++++++++ internal/tentacle/sdk_test.go | 49 ++ internal/tentacle/session.go | 67 +-- internal/tentacle/testdata/sdk_peer.py | 11 + internal/tentacle/testdata/session_peer.py | 33 ++ internal/tentaclewire/interaction_test.go | 21 + internal/tentaclewire/reader.go | 3 + internal/tentaclewire/session.go | 122 +++++ internal/tentaclewire/session_test.go | 35 ++ sdk/go/tentacle/session.go | 301 +++++++++++++ sdk/go/tentacle/tentacle.go | 76 +++- sdk/python/cthulhu_tentacle/__init__.py | 64 ++- sdk/python/cthulhu_tentacle/session.py | 256 +++++++++++ tests/native_remote_input.py | 407 +++++++++++++++++ tests/native_tentacle_updates.py | 9 +- tests/native_tentacles.py | 5 +- 45 files changed, 3634 insertions(+), 80 deletions(-) create mode 100644 docs/tentacle-secret-entries.md create mode 100644 docs/tentacle-sessions.md create mode 100644 docs/testing/remote-braille-feasibility.md create mode 100644 docs/testing/remote-input-feasibility.md create mode 100644 internal/app/internal_ui_secret_test.go create mode 100644 internal/app/tentacle_remote_management_test.go create mode 100644 internal/braille/cells.go create mode 100644 internal/braille/cells_test.go create mode 100644 internal/braille/remote_braille_native_test.go create mode 100644 internal/interaction/element.go create mode 100644 internal/interaction/secret_test.go create mode 100644 internal/platform/xorg/remote_input_native_test.go create mode 100644 internal/tentacle/negotiation.go create mode 100644 internal/tentacle/remote.go create mode 100644 internal/tentacle/remote_test.go create mode 100644 internal/tentacle/testdata/session_peer.py create mode 100644 internal/tentaclewire/session.go create mode 100644 internal/tentaclewire/session_test.go create mode 100644 sdk/go/tentacle/session.go create mode 100644 sdk/python/cthulhu_tentacle/session.py create mode 100644 tests/native_remote_input.py 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/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..1123c9a --- /dev/null +++ b/docs/tentacle-sessions.md @@ -0,0 +1,181 @@ +# 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. There is no production consent issuer, connection dialog, +output observer, keyboard service, clipboard service, or Braille session service. +Only private fixtures call the core `Session.AuthorizeRemote` hook. 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 +lock, handoff, origin, focus and ordinary output policy at delivery; a context +alone does not implement those 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..23b79ca --- /dev/null +++ b/docs/testing/remote-input-feasibility.md @@ -0,0 +1,104 @@ +# 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. The existing 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/internal_ui.go b/internal/app/internal_ui.go index 6363274..42a67ce 100644 --- a/internal/app/internal_ui.go +++ b/internal/app/internal_ui.go @@ -149,6 +149,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 +165,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) } 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/tentacle_interaction.go b/internal/app/tentacle_interaction.go index ebb9f26..ab681ab 100644 --- a/internal/app/tentacle_interaction.go +++ b/internal/app/tentacle_interaction.go @@ -118,6 +118,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 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_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/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/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/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..796fb33 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,8 @@ type Manager struct { containment *containment supervisorProgram []string cleanupError error + remoteMutex sync.Mutex + remoteOwner *Session } // NewSupervisedManager launches a private lifetime helper for each Tentacle. @@ -144,12 +149,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 +206,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..794858d --- /dev/null +++ b/internal/tentacle/remote.go @@ -0,0 +1,248 @@ +// 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") + +// 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 +} + +// AuthorizeRemote is a core-only hook for a future verified local consent UI. +// It is intentionally not exposed by any child RPC or production command yet. +// Install grants, network readiness and injected shortcuts are not local consent. +// A fresh action revokes the previous lease and consumes a new host-owned epoch. +func (session *Session) AuthorizeRemote(role string, grants []string) (tentaclewire.SessionEvent, error) { + session.mutex.Lock() + defer session.mutex.Unlock() + 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(session.outputContext) + access := &remoteAccess{action: hex.EncodeToString(token[:]), epoch: session.remoteEpoch, + role: role, grants: slices.Clone(grants), ctx: ctx, cancel: cancel} + session.remote = access + 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) { + access := session.remote + if access == nil { + return + } + session.remote = nil + access.cancel() + 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_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..cce2135 100644 --- a/internal/tentacle/session.go +++ b/internal/tentacle/session.go @@ -51,25 +51,30 @@ 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 } func (session *Session) Ready() <-chan struct{} { return session.ready } @@ -196,13 +201,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 +216,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 +292,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 +319,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/native_remote_input.py b/tests/native_remote_input.py new file mode 100644 index 0000000..88fee7e --- /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)$", + "-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)