16 KiB
Experimental Tentacle session protocol 1.1
Implemented on 2026-11-03 against reader a47253d697c1aa44dea80646cad99a4fe163c7f1
with local Remote prerequisite changes, and Tentacles
bc305993b8ad5c2374c8fc2c8b886c9cd6697b52. Both branches were synchronized before
this increment. These are base revisions, not commits containing the new work.
This increment supplies negotiation, bounded bidirectional IPC, lifecycle events,
install grants, and an inert remote lease service. It does not supply desktop
remote control. A later reader authorization increment
adds the core consent procedure, sensitive XI2 forms and lock/handoff policy.
The external source-built Remote command now requests that consent through a
typed interaction; physical-keyboard approval remains unverified. There is no
production keyboard, clipboard or Braille session service. Subsequent speech
integration supplies limited observation and isolated incoming software speech;
the keyboard IPC prerequisite adds a typed mailbox
without a native input owner. The unrestricted Session.AuthorizeRemote hook is
limited to development managers; reader managers require a core approval ticket.
Packaging Remote itself remains premature.
Negotiation and installation
Existing protocol 1.0 packages, launch descriptions and SDK Serve/serve calls
keep the exact ordered API prefix and mirrored initialize capabilities. They do
not receive events and may not send requests to the host.
Protocol 1.1 is explicit. A native package declares major=1, minor=1 and APIs
containing commands and session-events. Optional APIs are presentation,
interaction, reader-settings and remote-access. API order does not matter;
duplicates, unknown entries and missing dependencies are rejected. Interaction
requires presentation; reader settings require interaction. Remote access requires
at least one remote grant, and every remote grant requires remote access.
In 1.1 capabilities has the same meaning in the manifest, catalog and initialize
message: install permissions. It contains the requested presentation, interaction
and reader-settings grants, plus independent remote grants:
| Grant | Direction and intended use |
|---|---|
output-observe |
Controlled reader shares local policy-approved output. |
remote-present |
Controller presents output received from the controlled reader. |
keyboard-capture |
Controller consumes and forwards its local keyboard. |
keyboard-inject |
Controlled reader accepts remote key transitions. |
braille-stream |
Exchange display cells and supported Braille gestures. |
clipboard-exchange |
Explicit send and separately consented receive. |
commands, session-events and remote-access are API names, not capability
strings in 1.1. The installer checks manifest/catalog agreement and persists
session-events and remote-grants in the registry. Local launch Spec uses
SessionEvents and RemoteGrants. The existing presentation/interaction/reader
settings fields retain their meanings. Snapshot and launch copies own their grant
slices. Install and update confirmations disclose directions and the requirement
for fresh local session approval. Older readers reject the package protocol;
there is no fallback to 1.0. Grant changes still use the normal reviewed update
transaction and existing process disable/restart behavior. Remote access is revoked
before update download/preparation, so failed preparation cannot preserve a lease.
Disable/remove/retry/local-update requests also revoke access before persistence
or queueing; rollback never recreates consent.
An install grant never creates session consent. Reader desktop services are not implemented by this increment, even if all grants are declared.
Public SDK entry points
Go provides NewSessionClient, ServeSession, SessionClient.Events, and
SessionClient.Call(ctx, method, params, result). Public aliases expose
SessionEvent, RemoteOpen, RemoteLease and RemoteOperation; callers need no
reader-internal imports. SessionError.Code is available for handling failures;
its error string omits the host's message. Clients are single-use.
Python exports SessionClient and SessionError. Pass a client as
serve(commands, session_client=client), call
client.call(method, params, timeout=2), and consume lifecycle events through
client.next_event(). The Python 1.1 transport requires owned binary streams with
file descriptors. The implementation uses nonblocking pipes and a wake pipe so
shutdown can interrupt a stalled read or write. Go adopts pollable duplicates of
owned file streams for the same reason. Both still support existing command
handlers alongside session calls.
Use one event consumer, dispatch short bounded work, and keep network work off
the receive loop. Go event consumers must handle channel closure; Python returns
None on shutdown. Workers must stop when the serving context/session is
cancelled. Events already taken from a queue do not prove a lease remains valid.
Reader-owned consent interactions
An interactive protocol 1.1 command may return this request, using Go's public
RemoteRequest alias or an ordinary Python interaction dictionary:
{"remote":{"role":"controller","grants":["remote-present"]},"next":"remote-approved"}
It asks for a core-owned local consent dialog; it does not authorize anything. There is no target-session parameter. The host binds the exact requesting process session, checks its installed grants and reserves the existing interaction lifetime. The request requires a registered interactive continuation and cannot be combined with a form, reader request, source, clipboard effect, polling or Done. Unknown roles, duplicate/unknown grants and role-direction mismatches are rejected. A child cannot supply the core approval ticket.
optional_grants may list additional role-valid permissions. The reader offers
only those also present in installation grants; it neither installs nor approves
them. Required grants still fail if unavailable. Duplicate grants across both
lists and role-direction mismatches are rejected.
The reader's local-only consent form starts on Cancel with all requested grants
unchecked. A nonempty explicit approval produces the ordinary SDK
remote.action event and invokes Next with a Submission.remote containing the
same event. Denial invokes Next with cancelled: true and a generic error, then
ends the interaction. The continuation must match its reply against the actual
SDK event stream before consuming approval; command arguments alone are not
authority. The 30-second pending-action expiry still applies.
The host reuses its already-held interaction slot, avoiding nested admission. Lock/handoff cancels the original policy lifetime. Finishing the interaction revokes its original approved epoch, including an unused action; stale cleanup cannot revoke replacement consent. On an unfinished exit the host also invokes the outstanding child continuation with cancellation and a separate bounded cleanup context, so pre-lease inspection does not depend on a revocation event. No form values accompany that cleanup. Session failure/disable still uses normal process supervision. Existing non-remote interactions keep their path.
A return-to-desktop action or successful connection poll may instead finish with:
{"remote_background":{"lease":"0123456789abcdef0123456789abcdef","epoch":1},"done":true}
This terminal result requires an already-open, unexpired lease belonging to the
requesting process session. If this interaction approved an epoch, it must match
the requested lease. Forms, continuations, polling, clipboard, reader/consent
requests and uninterruptible speech cannot accompany it; ordinary speech/Braille
presentation is allowed. A polling response may return this terminal result; core closes the progress form
and validates its lease before releasing capture. Go exposes
Interaction.RemoteBackground; Python uses the dictionary above.
On success, core releases the interaction/result slots without invoking child
cancellation or revoking that epoch. The original policy/session lease remains
its owner: heartbeats, expiry, lock, handoff, disconnect, replacement, disable,
update and process loss retain their existing behavior. This result neither
renews authority nor starts networking. The Tentacle must offer an explicit
status/disconnect command; reopening status needs no new consent, while a new
connection still does. Ordinary cancellation and form closure retain their
existing cleanup behavior. RemoteLeaseContext checks identity/lifetime only;
desktop effects still require RemoteContext with their specific grant.
This addition requires the matching updated reader; protocol 1.1 by itself does not imply that an older reader accepts the new interaction field. Remote still has no release manifest advertising a minimum supported reader version.
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:<decimal> 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.
Typed speech IPC now extends this transport; see
the speech contract. It uses the same ordered event
queue for exported speech and a separate 64-item incoming mailbox. Limited
production speech observation and isolated incoming software speech are connected.
The keyboard IPC prerequisite adds remote.key
and a separate bounded mailbox, unavailable without a reader input owner.
Production keyboard/Braille/clipboard effects remain unsupported.
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, epoch, exactly one of action or lease, and reason (closed,expired,local,replaced). Failure to enqueue revocation retires the session; it cannot block immediate cancellation. Pending approval expiry/revocation carries its action token; an opened lease carries its lease token. Both retire external setup workers through the same epoch-bound notification.
Child calls:
| Method | Parameters | Result |
|---|---|---|
remote.open |
action, epoch, role, grants |
lease, epoch |
remote.heartbeat |
lease, epoch, sequence |
{} |
remote.close |
lease, epoch, sequence |
{} |
remote.present |
lease, epoch, sequence, typed speech |
{} after mailbox admission; unavailable without reader owner |
remote.key |
lease, epoch, sequence, typed key |
{} after mailbox admission; controlled keyboard-inject only; unavailable without reader input owner |
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. An authenticated close also succeeds when expiry or policy cancellation has invalidated the current lease but its retirement callback has not yet acquired the session lock. It still checks the lease token, epoch and operation sequence; it never renews authority.
Core RemoteContext(lease, grant) returns a cancellation context only for a live
matching lease with that grant. Close, expiry, process failure, disable and parent
cancellation invalidate buffered work. Future effect owners must also enforce
origin, focus and ordinary output policy at delivery; the reader's policy now
cancels these contexts on its existing lock/handoff transitions. A context alone
does not implement all effect-owner policies. No production effects currently use it.
Errors are generic and contain no parameters: -32001 permission denied,
-32602 invalid parameters, -32601 unsupported operation, -32603 unavailable.
State/focus/key/Braille/clipboard methods deliberately return
unsupported-operation errors. Speech mailbox overflow returns -32002 and
revokes its lease. 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:
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.
The external source command now exposes connection UI for disposable-session acceptance; see its procedure. Physical-source approval, 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.
Progress polling may also return a replacement form or a reader-owned consent request. The host closes the previous form, revalidates continuations and uses the ordinary interaction path. It does not authorize polling replies directly. Polling still cannot read a source or apply reader settings. Empty unchanged replies produce no repeated notice. See Remote UI checks.