From bf4a23645eafa3efbd4321e6f95fe13a011ced7a Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 6 Nov 2026 20:07:05 -0500 Subject: [PATCH] Support compact Remote setup with reader-owned access selection --- docs/tentacle-sessions.md | 16 +- docs/testing/remote-ui.md | 32 ++++ internal/app/tentacle_interaction.go | 12 +- .../app/tentacle_remote_connection_test.go | 161 +++++++++++++----- internal/app/tentacle_remote_consent.go | 31 +++- internal/app/tentacle_remote_consent_test.go | 46 ++++- internal/tentaclewire/interaction.go | 17 +- .../tentaclewire/remote_interaction_test.go | 17 ++ sdk/python/cthulhu_tentacle/__init__.py | 3 + 9 files changed, 274 insertions(+), 61 deletions(-) create mode 100644 docs/testing/remote-ui.md diff --git a/docs/tentacle-sessions.md b/docs/tentacle-sessions.md index 4d188b2..93ac384 100644 --- a/docs/tentacle-sessions.md +++ b/docs/tentacle-sessions.md @@ -99,6 +99,11 @@ 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 @@ -116,7 +121,7 @@ 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. -An explicit return-to-desktop action may instead finish with: +A return-to-desktop action or successful connection poll may instead finish with: ```json {"remote_background":{"lease":"0123456789abcdef0123456789abcdef","epoch":1},"done":true} @@ -126,7 +131,8 @@ 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 cannot detach a form. Go exposes +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 @@ -261,3 +267,9 @@ 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](testing/remote-ui.md). diff --git a/docs/testing/remote-ui.md b/docs/testing/remote-ui.md new file mode 100644 index 0000000..d798c65 --- /dev/null +++ b/docs/testing/remote-ui.md @@ -0,0 +1,32 @@ +# Remote 0.1.1 UI and interaction host + +The matching Tentacle uses a compact connection form and completes credentials +and certificate preflight before asking for reader approval. See the adjacent +Tentacles repository's `remote/command.md` for the complete workflow and live +acceptance procedure. This reader change must be present before installing +Remote 0.1.1; the Tentacle cannot upgrade the reader or its helpers. + +The wire request adds optional_grants. These are role-checked and offered only +within the session's installed grants. Required grants retain strict checking; +all consent boxes remain unchecked. The session binding, single-use action, +30-second expiry and local-origin checks are unchanged. + +Form polling may transition to another form, reader consent, or the existing +remote_background terminal result. Poll results are decoded normally; the main +interaction loop rechecks continuation registration and lease ownership after +closing the previous form. Sources and reader-settings effects remain rejected +from polling. Existing polling notices and ordinary interaction completion keep +their behavior. + +Private checks: Remote race tests, `make check` in cthulhu-tentacles, existing +interaction/consent/wire and Go/Python SDK conformance tests, plus the actual +source-command tests for both roles. They verify optional grant filtering, +forged selection rejection, key-free certificate preflight, cancellation before +lease creation, automatic desktop return, isolated speech and lifecycle cleanup. + +Live checks remain required: Tab/Shift+Tab, key masking in speech/Braille, +connect/cancel/status Close/Escape/Disconnect, Alt+Tab and F11, held-modifier +recovery, local speech priority, remote mute/unmute without replay, clipboard +acceptance, physical Braille and real NVDA/Orca peers. Recheck ordinary forms, +Wormhole polling/settings, Hello World, Battery Status, learn mode and remapped +shortcuts. This change does not replace the active reader or claim live acceptance. diff --git a/internal/app/tentacle_interaction.go b/internal/app/tentacle_interaction.go index 1a0cf88..42d0ff3 100644 --- a/internal/app/tentacle_interaction.go +++ b/internal/app/tentacle_interaction.go @@ -92,7 +92,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo } cleanupNext = value.Next if value.Remote != nil { - action, approvalErr := runtime.authorize_remote_session(ctx, command.session, value.Remote.Role, value.Remote.Grants) + action, approvalErr := runtime.authorize_remote_request(ctx, command.session, *value.Remote) submission := tentaclewire.Submission{} if approvalErr != nil { submission.Cancelled = true @@ -211,6 +211,12 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo } if submission.Cancelled { cleanupNext = "" + // A status form may close while retaining its already-open lease. + // Validate that terminal result in the ordinary ownership path above. + terminal, decodeErr := tentaclewire.DecodeInteraction(raw) + if decodeErr == nil && terminal.RemoteBackground != nil { + continue + } return } } @@ -269,11 +275,11 @@ func (runtime *tentacleRuntime) waitTentacleForm(ctx context.Context, command te return internalUIResult{Cancelled: true}, nil } status, err := tentaclewire.DecodeInteraction(raw) - if err != nil || status.Form != nil || status.Source != "" || status.Next != "" || status.Reader != nil || status.Remote != nil || status.RemoteBackground != nil { + if err != nil || status.Source != "" || status.Reader != nil || status.Form == nil && status.Remote == nil && !status.Done && status.Next != "" { command.session.Disable() return internalUIResult{Cancelled: true}, nil } - if status.Done { + if status.Done || status.Form != nil || status.Remote != nil { return internalUIResult{}, raw } if status.Clipboard != nil { diff --git a/internal/app/tentacle_remote_connection_test.go b/internal/app/tentacle_remote_connection_test.go index c3fd48a..c5370fb 100644 --- a/internal/app/tentacle_remote_connection_test.go +++ b/internal/app/tentacle_remote_connection_test.go @@ -105,56 +105,105 @@ func next_remote_form(t *testing.T, forms *consentTestForms) consentFormRequest func select_remote_relay(t *testing.T, forms *consentTestForms, address, role string) consentFormRequest { t.Helper() selection := next_remote_form(t, forms) - if !strings.Contains(selection.form.Title, "Remote relay and role") || selection.form.Elements[0].ID != "cancel" { - t.Fatal("missing safe endpoint/role selection") + if selection.form.Title != "Remote" || selection.form.Elements[0].ID != "cancel" { + t.Fatal("missing compact connection form") } - // Required entry validation applies to every button in native forms. An - // empty relay must not prevent the initially focused Cancel from working. selectionSession, _ := interaction.NewSession(selection.form) if effect := selectionSession.Handle(interaction.Event{Key: interaction.Enter}); effect.Activated != "cancel" { - t.Fatal("empty relay entries blocked the Cancel button") + t.Fatal("empty entries blocked Cancel") } - host, port, err := net.SplitHostPort(address) - if err != nil { - t.Fatal(err) + for _, element := range selection.form.Elements { + if element.ID == "channel-key" && (!element.Secret || element.Text != "") { + t.Fatal("key unmasked or prefilled") + } + if element.Kind == interaction.CheckBox { + t.Fatal("permissions duplicated") + } } - selection.reply <- internalUIResult{Activated: "review-access", Values: map[string]interaction.Value{"host": {Text: host}, "port": {Text: port}, "role": {Selected: role}}} - consent := next_remote_form(t, forms) - if !strings.Contains(consent.form.Title, "Remote access for remote") || consent.form.Elements[0].ID != "cancel" { - t.Fatal("connection skipped reader-owned consent") + selection.reply <- internalUIResult{Activated: "options", Values: map[string]interaction.Value{"host": {Text: address}, "role": {Selected: role}}} + options := next_remote_form(t, forms) + if options.form.Title != "Remote options" { + t.Fatal("missing options") + } + return options +} + +func approve_remote_form(t *testing.T, forms *consentTestForms, consent consentFormRequest, role string) consentFormRequest { + t.Helper() + if !strings.Contains(consent.form.Title, "Remote access for remote") { + t.Fatal("missing reader approval") } for _, element := range consent.form.Elements { if element.Checked { t.Fatal("permission preselected") } } - return consent -} - -func approve_remote_form(t *testing.T, forms *consentTestForms, consent consentFormRequest, role string) consentFormRequest { - t.Helper() grant := "remote-present" if role == "controlled" { grant = "output-observe" } consent.reply <- internalUIResult{Activated: "approve", Values: map[string]interaction.Value{grant: {Checked: true}}} - credentials := next_remote_form(t, forms) - if credentials.form.Title != "Connect Remote relay" { - t.Fatal("approval did not reach connection controls", credentials.form.Title) + progress := next_remote_form(t, forms) + if progress.form.Title != "Connecting Remote" { + t.Fatal("approval did not start connection", progress.form.Title) } - for _, element := range credentials.form.Elements { - if element.ID == "channel-key" && (!element.Secret || element.Text != "") { - t.Fatal("unmasked or prefilled channel key") + return progress +} + +func remote_consent_listener(t *testing.T) net.Listener { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, MinVersion: tls.VersionTLS12}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + go func() { + for { + connection, err := listener.Accept() + if err != nil { + return + } + go func() { + defer connection.Close() + connection.SetDeadline(time.Now().Add(5 * time.Second)) + io.Copy(io.Discard, connection) + }() } + }() + return listener +} + +func prepare_remote_consent(t *testing.T, forms *consentTestForms, address, role string) consentFormRequest { + t.Helper() + options := select_remote_relay(t, forms, address, role) + options.reply <- internalUIResult{Activated: "inspect-certificate"} + next_remote_form(t, forms) // Cancellable progress. + review := next_remote_form(t, forms) // Poll replaces progress automatically. + if review.form.Title != "Review Remote relay certificate" { + t.Fatal("inspection did not advance") } - return credentials + review.reply <- internalUIResult{Activated: "trust-once"} + options = next_remote_form(t, forms) + options.reply <- internalUIResult{Activated: "connect", Values: map[string]interaction.Value{"channel-key": {Text: "synthetic-fixture"}}} + next_remote_form(t, forms) // Verified preflight; no lease/key sent. + return next_remote_form(t, forms) } func TestRemoteConnectionCommandConsentDenialAndPolicyCancellation(t *testing.T) { for _, mode := range []string{"deny", "empty", "lock", "handoff"} { t.Run(mode, func(t *testing.T) { runtime, forms, command := remote_command_fixture(t) - consent := select_remote_relay(t, forms, "127.0.0.1:6837", "controller") + listener := remote_consent_listener(t) + consent := prepare_remote_consent(t, forms, listener.Addr().String(), "controller") switch mode { case "deny": consent.reply <- internalUIResult{Cancelled: true} @@ -174,7 +223,7 @@ func TestRemoteConnectionCommandConsentDenialAndPolicyCancellation(t *testing.T) // keeping an awaiting flag or reviving the previous approval. runtime.Invoke(t.Context(), command) form := next_remote_form(t, forms) - if !strings.Contains(form.form.Title, "Remote relay and role") { + if form.form.Title != "Remote" { t.Fatal("stale setup survived cancellation") } form.reply <- internalUIResult{Cancelled: true} @@ -203,8 +252,7 @@ func TestRemoteConnectionCommandCancelsInspectionOnHandoff(t *testing.T) { close(closed) }() runtime, forms, _ := remote_command_fixture(t) - consent := select_remote_relay(t, forms, listener.Addr().String(), "controller") - credentials := approve_remote_form(t, forms, consent, "controller") + credentials := select_remote_relay(t, forms, listener.Addr().String(), "controller") credentials.reply <- internalUIResult{Activated: "inspect-certificate"} inspection := next_remote_form(t, forms) select { @@ -276,6 +324,16 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { t.Error("certificate inspection sent application data") } close(probed) + preflight, err := listener.Accept() + if err != nil { + return + } + preflight.SetDeadline(time.Now().Add(5 * time.Second)) + preflightData, _ := io.ReadAll(preflight) + preflight.Close() + if len(preflightData) != 0 { + t.Error("preflight sent application data before approval") + } connection, err := listener.Accept() if err != nil { return @@ -382,8 +440,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { t.Fatal("Remote fixture session unavailable") } } - consent := select_remote_relay(t, forms, listener.Addr().String(), role) - credentials := approve_remote_form(t, forms, consent, role) + credentials := select_remote_relay(t, forms, listener.Addr().String(), role) credentials.reply <- internalUIResult{Activated: "inspect-certificate"} inspection := next_remote_form(t, forms) select { @@ -391,29 +448,49 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("certificate probe did not finish") } - for inspection.form.Title != "Review Remote relay certificate" { - inspection.reply <- internalUIResult{Activated: "refresh"} - inspection = next_remote_form(t, forms) + inspection = next_remote_form(t, forms) + if inspection.form.Title != "Review Remote relay certificate" { + t.Fatal("certificate result was not automatic") } inspection.reply <- internalUIResult{Activated: "trust-once"} credentials = next_remote_form(t, forms) credentials.reply <- internalUIResult{Activated: "connect", Values: map[string]interaction.Value{"channel-key": {Text: "synthetic-fixture"}}} - status := next_remote_form(t, forms) + next_remote_form(t, forms) // Verified certificate progress. + consent := next_remote_form(t, forms) + status := approve_remote_form(t, forms, consent, role) select { case <-joined: case <-time.After(5 * time.Second): t.Fatal("Remote did not join the private relay") } - if status.form.Title != "Remote relay status" { - t.Fatal("missing connection status") + if status.form.Title != "Connecting Remote" { + t.Fatal("missing connecting form") } // Open has already succeeded before the relay can join. Return to // ordinary desktop work without holding either runtime slot or form. - status.reply <- internalUIResult{Activated: "desktop"} + // Poll returns to the desktop after the verified join. waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 && len(runtime.interactionSlots) == 0 }) if status.ctx.Err() == nil { t.Fatal("background connection retained its form context") } + for _, closeMode := range []string{"close", "escape"} { + runtime.Invoke(t.Context(), shortcut) + reopened := next_remote_form(t, forms) + if reopened.form.Title != "Remote status" { + t.Fatal("status reopening requested new consent") + } + reply := internalUIResult{Activated: "desktop"} + if closeMode == "escape" { + reply = internalUIResult{Cancelled: true} + } + reopened.reply <- reply + waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 && len(runtime.interactionSlots) == 0 }) + select { + case <-closed: + t.Fatal(closeMode + " disconnected the live lease") + default: + } + } if test.playback { waitTentacle(t, func() bool { worker := runtime.output.speech @@ -434,7 +511,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { // mailbox owner remain usable for an explicit new connection. runtime.Invoke(t.Context(), shortcut) selection := next_remote_form(t, forms) - if selection.form.Title == "Remote relay status" { + if selection.form.Title == "Remote status" { // An ended attempt remains inspectable until explicitly // closed, as with ordinary transport failure. selection.reply <- internalUIResult{Activated: "cancel"} @@ -442,7 +519,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { runtime.Invoke(t.Context(), shortcut) selection = next_remote_form(t, forms) } - if !strings.Contains(selection.form.Title, "Remote relay and role") { + if selection.form.Title != "Remote" { t.Fatal("backend failure retained a connected workflow") } selection.reply <- internalUIResult{Cancelled: true} @@ -522,7 +599,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { deadline := time.Now().Add(5 * time.Second) for { encoded, _ := json.Marshal(status.form) - if strings.Contains(string(encoded), "One opposite-role peer has joined") { + if strings.Contains(string(encoded), "Connected to the other computer.") { break } if time.Now().After(deadline) { @@ -660,10 +737,10 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { case "disconnect": runtime.Invoke(t.Context(), shortcut) status = next_remote_form(t, forms) - if status.form.Title != "Remote relay status" { + if status.form.Title != "Remote status" { t.Fatal("reopening lost the approved connection") } - status.reply <- internalUIResult{Activated: "cancel"} + status.reply <- internalUIResult{Activated: "disconnect"} case "lock": runtime.output.gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{}) runtime.output.gate.SetLocked(false, nil, nil, nil, nil, &brailleWorker{}) diff --git a/internal/app/tentacle_remote_consent.go b/internal/app/tentacle_remote_consent.go index 93792da..2df8478 100644 --- a/internal/app/tentacle_remote_consent.go +++ b/internal/app/tentacle_remote_consent.go @@ -112,20 +112,33 @@ func remote_consent_form(id, role string, grants []string) interaction.Form { if role == "controlled" { roleText = "allow a helper to access this computer" } - form := interaction.Form{Title: "Remote access for " + id + ": " + roleText + - ". The relay can read the channel key and session contents. A peer ID does not verify a person's identity. Select access for this session using a local keyboard.", + form := interaction.Form{Title: "Remote access for " + id + ": " + roleText + ". The relay can read session contents. Select access for this session.", Elements: []interaction.Element{{ID: "cancel", Label: "Cancel", Kind: interaction.Button}}} labels := map[string]string{ - "output-observe": "Share local reader output with the remote controller", - "remote-present": "Present output from the controlled computer", - "keyboard-capture": "Forward local keyboard input to the controlled computer while remote focus is on. Press F11 to return to this computer", - "keyboard-inject": "Allow the remote controller to type on this computer", - "clipboard-exchange": "Allow receiving clipboard text; send only on explicit local request", - "braille-stream": "Exchange remote braille cells and supported display gestures", + "output-observe": "Share speech with the helper", + "remote-present": "Hear the other computer", + "keyboard-capture": "Keyboard control of the other computer. F11 returns here", + "keyboard-inject": "Allow the helper to type here", + "clipboard-exchange": "Clipboard text, transferred on request", + "braille-stream": "Braille display and routing", } for _, grant := range grants { form.Elements = append(form.Elements, interaction.Element{ID: grant, Label: labels[grant], Kind: interaction.CheckBox}) } - form.Elements = append(form.Elements, interaction.Element{ID: "approve", Label: "Approve selected access for this session", Kind: interaction.Button}) + form.Elements = append(form.Elements, interaction.Element{ID: "approve", Label: "Allow selected access and connect", Kind: interaction.Button}) return form } + +func (runtime *tentacleRuntime) authorize_remote_request(ctx context.Context, target *tentacle.Session, request tentaclewire.RemoteRequest) (tentaclewire.SessionEvent, error) { + if target == nil || !tentaclewire.ValidRemoteRequest(request) { + return tentaclewire.SessionEvent{}, tentacle.ErrPermission + } + grants := slices.Clone(request.Grants) + installed := target.RemoteGrants() + for _, grant := range request.OptionalGrants { + if slices.Contains(installed, grant) { + grants = append(grants, grant) + } + } + return runtime.authorize_remote_session(ctx, target, request.Role, grants) +} diff --git a/internal/app/tentacle_remote_consent_test.go b/internal/app/tentacle_remote_consent_test.go index 9a516ae..f5d1f2f 100644 --- a/internal/app/tentacle_remote_consent_test.go +++ b/internal/app/tentacle_remote_consent_test.go @@ -30,7 +30,16 @@ func (forms *consentTestForms) Notice(context.Context, tentaclewire.Presentation func (forms *consentTestForms) OpenLocal(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) { reply := make(chan internalUIResult, 1) forms.requests <- consentFormRequest{ctx, form, reply} - return reply, nil + output := make(chan internalUIResult, 1) + go func() { + select { + case value := <-reply: + output <- value + case <-ctx.Done(): + output <- internalUIResult{Cancelled: true} + } + }() + return output, nil } func consent_runtime_fixture(t *testing.T) (*tentacleRuntime, *tentacle.Session, *consentTestForms, *focusGate) { @@ -199,3 +208,38 @@ func TestRemotePolicyAttachedToRuntimeManagers(t *testing.T) { t.Fatal("reader manager omitted policy") } } + +func TestRemoteOptionalGrantsRespectInstallationAndLocalSelection(t *testing.T) { + runtime, session, forms, _ := consent_runtime_fixture(t) + type result struct { + action tentaclewire.SessionEvent + err error + } + completed := make(chan result, 1) + go func() { + action, err := runtime.authorize_remote_request(t.Context(), session, tentaclewire.RemoteRequest{Role: "controlled", Grants: []string{"output-observe"}, OptionalGrants: []string{"clipboard-exchange", "keyboard-inject", "braille-stream"}}) + completed <- result{action, err} + }() + request := next_remote_form(t, forms) + offered := map[string]bool{} + for _, element := range request.form.Elements { + if element.Kind == interaction.CheckBox { + offered[element.ID] = true + if element.Checked { + t.Fatal("grant preselected") + } + } + } + if !offered["output-observe"] || !offered["clipboard-exchange"] || offered["keyboard-inject"] || offered["braille-stream"] { + t.Fatal("uninstalled access offered", offered) + } + request.reply <- internalUIResult{Activated: "approve", Values: map[string]interaction.Value{"output-observe": {Checked: true}, "keyboard-inject": {Checked: true}}} + select { + case value := <-completed: + if value.err != nil || len(value.action.Grants) != 1 || value.action.Grants[0] != "output-observe" { + t.Fatal("unselected or uninstalled grant authorized", value) + } + case <-time.After(time.Second): + t.Fatal("approval did not finish") + } +} diff --git a/internal/tentaclewire/interaction.go b/internal/tentaclewire/interaction.go index 140df35..ac82251 100644 --- a/internal/tentaclewire/interaction.go +++ b/internal/tentaclewire/interaction.go @@ -6,6 +6,7 @@ package tentaclewire import ( "bytes" "encoding/json" + "slices" "unicode" "unicode/utf8" @@ -15,7 +16,8 @@ import ( // Interaction is an opt-in command result. The host runs forms outside the // bounded RPC deadline and invokes Next with the submitted values. Poll is a // registered command sampled once per second while the form remains open. -// A polling reply may announce a changed status or finish the form with Done. +// A polling reply may announce a changed status, replace the form, request +// reader-owned consent, or finish with a verified background lease. type Interaction struct { Presentation Form *interaction.Form `json:"form,omitempty"` @@ -34,8 +36,9 @@ type Interaction struct { // RemoteRequest asks the reader to show its own local consent form. It never // grants access by itself and can target only the requesting process session. type RemoteRequest struct { - Role string `json:"role"` - Grants []string `json:"grants"` + Role string `json:"role"` + Grants []string `json:"grants"` + OptionalGrants []string `json:"optional_grants,omitempty"` } type Submission struct { @@ -88,7 +91,7 @@ func DecodeInteraction(data []byte) (Interaction, error) { return value, ErrProtocol } } else if value.Remote != nil { - if !ValidRoleGrants(value.Remote.Role, value.Remote.Grants) || value.Next == "" || value.Reader != nil || value.Form != nil || value.Source != "" || value.Poll != "" || value.Clipboard != nil || value.Done { + if !ValidRemoteRequest(*value.Remote) || value.Next == "" || value.Reader != nil || value.Form != nil || value.Source != "" || value.Poll != "" || value.Clipboard != nil || value.Done { return value, ErrProtocol } } else if value.Reader != nil { @@ -132,3 +135,9 @@ func validFormText(text string) bool { } return true } + +// Optional grants are offered only when installed; none is approved implicitly. +func ValidRemoteRequest(request RemoteRequest) bool { + combined := slices.Concat(request.Grants, request.OptionalGrants) + return ValidRoleGrants(request.Role, request.Grants) && ValidRoleGrants(request.Role, combined) +} diff --git a/internal/tentaclewire/remote_interaction_test.go b/internal/tentaclewire/remote_interaction_test.go index ad6172f..f0384b8 100644 --- a/internal/tentaclewire/remote_interaction_test.go +++ b/internal/tentaclewire/remote_interaction_test.go @@ -59,3 +59,20 @@ func TestRemoteBackgroundInteractionValidation(t *testing.T) { } } } + +func TestRemoteOptionalGrantsValidation(t *testing.T) { + for _, test := range []struct { + optional []string + valid bool + }{ + {[]string{"keyboard-capture", "clipboard-exchange", "braille-stream"}, true}, + {[]string{"keyboard-inject"}, false}, {[]string{"remote-present"}, false}, + {[]string{"braille-stream", "braille-stream"}, false}, {[]string{"unknown"}, false}, + } { + data, _ := json.Marshal(Interaction{Remote: &RemoteRequest{Role: "controller", Grants: []string{"remote-present"}, OptionalGrants: test.optional}, Next: "approve"}) + _, err := DecodeInteraction(data) + if (err == nil) != test.valid { + t.Fatalf("optional grants %v: %v", test.optional, err) + } + } +} diff --git a/sdk/python/cthulhu_tentacle/__init__.py b/sdk/python/cthulhu_tentacle/__init__.py index 1172ee3..824bb4e 100644 --- a/sdk/python/cthulhu_tentacle/__init__.py +++ b/sdk/python/cthulhu_tentacle/__init__.py @@ -9,6 +9,9 @@ entries cannot be used in persistent preference pages; older hosts reject them. Protocol 1.1 interactive commands can request a reader-owned consent dialog with {"remote": {"role": "controller", "grants": ["remote-present"]}, "next": "approved"}. +The request may include "optional_grants" for installed, role-valid services +to offer in the same unchecked reader approval form. Missing optional grants +are omitted; missing required grants fail. No access is approved implicitly. The continuation receives "remote" metadata after explicit local approval, or "cancelled": True on denial. Match approval to the actual SessionClient event; neither a request dictionary nor submitted metadata is itself consent. This