Support compact Remote setup with reader-owned access selection

This commit is contained in:
Storm Dragon committed 2026-11-06 20:07:05 -05:00
1 parent 952d3c8204
commit bf4a23645e
9 files changed
+274 -61

No files matched your search

+14 -2
View File
@@ -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 polling or Done. Unknown roles, duplicate/unknown grants and role-direction
mismatches are rejected. A child cannot supply the core approval ticket. 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 The reader's local-only consent form starts on Cancel with all requested grants
unchecked. A nonempty explicit approval produces the ordinary SDK unchecked. A nonempty explicit approval produces the ordinary SDK
`remote.action` event and invokes Next with a `Submission.remote` containing the `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 No form values accompany that cleanup. Session failure/disable still uses
normal process supervision. Existing non-remote interactions keep their path. 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 ```json
{"remote_background":{"lease":"0123456789abcdef0123456789abcdef","epoch":1},"done":true} {"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 requesting process session. If this interaction approved an epoch, it must match
the requested lease. Forms, continuations, polling, clipboard, reader/consent the requested lease. Forms, continuations, polling, clipboard, reader/consent
requests and uninterruptible speech cannot accompany it; ordinary speech/Braille 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. `Interaction.RemoteBackground`; Python uses the dictionary above.
On success, core releases the interaction/result slots without invoking child 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 audible speech, physical Braille, real NVDA/Orca sessions and installed Remote
package behavior remain unverified. Human diff review and relevant live acceptance package behavior remain unverified. Human diff review and relevant live acceptance
are still required before submission. 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).
+32
View File
@@ -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.
+9 -3
View File
@@ -92,7 +92,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
} }
cleanupNext = value.Next cleanupNext = value.Next
if value.Remote != nil { 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{} submission := tentaclewire.Submission{}
if approvalErr != nil { if approvalErr != nil {
submission.Cancelled = true submission.Cancelled = true
@@ -211,6 +211,12 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
} }
if submission.Cancelled { if submission.Cancelled {
cleanupNext = "" 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 return
} }
} }
@@ -269,11 +275,11 @@ func (runtime *tentacleRuntime) waitTentacleForm(ctx context.Context, command te
return internalUIResult{Cancelled: true}, nil return internalUIResult{Cancelled: true}, nil
} }
status, err := tentaclewire.DecodeInteraction(raw) 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() command.session.Disable()
return internalUIResult{Cancelled: true}, nil return internalUIResult{Cancelled: true}, nil
} }
if status.Done { if status.Done || status.Form != nil || status.Remote != nil {
return internalUIResult{}, raw return internalUIResult{}, raw
} }
if status.Clipboard != nil { if status.Clipboard != nil {
+119 -42
View File
@@ -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 { func select_remote_relay(t *testing.T, forms *consentTestForms, address, role string) consentFormRequest {
t.Helper() t.Helper()
selection := next_remote_form(t, forms) selection := next_remote_form(t, forms)
if !strings.Contains(selection.form.Title, "Remote relay and role") || selection.form.Elements[0].ID != "cancel" { if selection.form.Title != "Remote" || selection.form.Elements[0].ID != "cancel" {
t.Fatal("missing safe endpoint/role selection") 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) selectionSession, _ := interaction.NewSession(selection.form)
if effect := selectionSession.Handle(interaction.Event{Key: interaction.Enter}); effect.Activated != "cancel" { 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) for _, element := range selection.form.Elements {
if err != nil { if element.ID == "channel-key" && (!element.Secret || element.Text != "") {
t.Fatal(err) 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}}} selection.reply <- internalUIResult{Activated: "options", Values: map[string]interaction.Value{"host": {Text: address}, "role": {Selected: role}}}
consent := next_remote_form(t, forms) options := next_remote_form(t, forms)
if !strings.Contains(consent.form.Title, "Remote access for remote") || consent.form.Elements[0].ID != "cancel" { if options.form.Title != "Remote options" {
t.Fatal("connection skipped reader-owned consent") 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 { for _, element := range consent.form.Elements {
if element.Checked { if element.Checked {
t.Fatal("permission preselected") 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" grant := "remote-present"
if role == "controlled" { if role == "controlled" {
grant = "output-observe" grant = "output-observe"
} }
consent.reply <- internalUIResult{Activated: "approve", Values: map[string]interaction.Value{grant: {Checked: true}}} consent.reply <- internalUIResult{Activated: "approve", Values: map[string]interaction.Value{grant: {Checked: true}}}
credentials := next_remote_form(t, forms) progress := next_remote_form(t, forms)
if credentials.form.Title != "Connect Remote relay" { if progress.form.Title != "Connecting Remote" {
t.Fatal("approval did not reach connection controls", credentials.form.Title) t.Fatal("approval did not start connection", progress.form.Title)
} }
for _, element := range credentials.form.Elements { return progress
if element.ID == "channel-key" && (!element.Secret || element.Text != "") { }
t.Fatal("unmasked or prefilled channel key")
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) { func TestRemoteConnectionCommandConsentDenialAndPolicyCancellation(t *testing.T) {
for _, mode := range []string{"deny", "empty", "lock", "handoff"} { for _, mode := range []string{"deny", "empty", "lock", "handoff"} {
t.Run(mode, func(t *testing.T) { t.Run(mode, func(t *testing.T) {
runtime, forms, command := remote_command_fixture(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 { switch mode {
case "deny": case "deny":
consent.reply <- internalUIResult{Cancelled: true} consent.reply <- internalUIResult{Cancelled: true}
@@ -174,7 +223,7 @@ func TestRemoteConnectionCommandConsentDenialAndPolicyCancellation(t *testing.T)
// keeping an awaiting flag or reviving the previous approval. // keeping an awaiting flag or reviving the previous approval.
runtime.Invoke(t.Context(), command) runtime.Invoke(t.Context(), command)
form := next_remote_form(t, forms) 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") t.Fatal("stale setup survived cancellation")
} }
form.reply <- internalUIResult{Cancelled: true} form.reply <- internalUIResult{Cancelled: true}
@@ -203,8 +252,7 @@ func TestRemoteConnectionCommandCancelsInspectionOnHandoff(t *testing.T) {
close(closed) close(closed)
}() }()
runtime, forms, _ := remote_command_fixture(t) runtime, forms, _ := remote_command_fixture(t)
consent := select_remote_relay(t, forms, listener.Addr().String(), "controller") credentials := select_remote_relay(t, forms, listener.Addr().String(), "controller")
credentials := approve_remote_form(t, forms, consent, "controller")
credentials.reply <- internalUIResult{Activated: "inspect-certificate"} credentials.reply <- internalUIResult{Activated: "inspect-certificate"}
inspection := next_remote_form(t, forms) inspection := next_remote_form(t, forms)
select { select {
@@ -276,6 +324,16 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
t.Error("certificate inspection sent application data") t.Error("certificate inspection sent application data")
} }
close(probed) 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() connection, err := listener.Accept()
if err != nil { if err != nil {
return return
@@ -382,8 +440,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
t.Fatal("Remote fixture session unavailable") t.Fatal("Remote fixture session unavailable")
} }
} }
consent := select_remote_relay(t, forms, listener.Addr().String(), role) credentials := select_remote_relay(t, forms, listener.Addr().String(), role)
credentials := approve_remote_form(t, forms, consent, role)
credentials.reply <- internalUIResult{Activated: "inspect-certificate"} credentials.reply <- internalUIResult{Activated: "inspect-certificate"}
inspection := next_remote_form(t, forms) inspection := next_remote_form(t, forms)
select { select {
@@ -391,29 +448,49 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
t.Fatal("certificate probe did not finish") t.Fatal("certificate probe did not finish")
} }
for inspection.form.Title != "Review Remote relay certificate" { inspection = next_remote_form(t, forms)
inspection.reply <- internalUIResult{Activated: "refresh"} if inspection.form.Title != "Review Remote relay certificate" {
inspection = next_remote_form(t, forms) t.Fatal("certificate result was not automatic")
} }
inspection.reply <- internalUIResult{Activated: "trust-once"} inspection.reply <- internalUIResult{Activated: "trust-once"}
credentials = next_remote_form(t, forms) credentials = next_remote_form(t, forms)
credentials.reply <- internalUIResult{Activated: "connect", Values: map[string]interaction.Value{"channel-key": {Text: "synthetic-fixture"}}} 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 { select {
case <-joined: case <-joined:
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
t.Fatal("Remote did not join the private relay") t.Fatal("Remote did not join the private relay")
} }
if status.form.Title != "Remote relay status" { if status.form.Title != "Connecting Remote" {
t.Fatal("missing connection status") t.Fatal("missing connecting form")
} }
// Open has already succeeded before the relay can join. Return to // Open has already succeeded before the relay can join. Return to
// ordinary desktop work without holding either runtime slot or form. // 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 }) waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 && len(runtime.interactionSlots) == 0 })
if status.ctx.Err() == nil { if status.ctx.Err() == nil {
t.Fatal("background connection retained its form context") 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 { if test.playback {
waitTentacle(t, func() bool { waitTentacle(t, func() bool {
worker := runtime.output.speech worker := runtime.output.speech
@@ -434,7 +511,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
// mailbox owner remain usable for an explicit new connection. // mailbox owner remain usable for an explicit new connection.
runtime.Invoke(t.Context(), shortcut) runtime.Invoke(t.Context(), shortcut)
selection := next_remote_form(t, forms) 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 // An ended attempt remains inspectable until explicitly
// closed, as with ordinary transport failure. // closed, as with ordinary transport failure.
selection.reply <- internalUIResult{Activated: "cancel"} selection.reply <- internalUIResult{Activated: "cancel"}
@@ -442,7 +519,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
runtime.Invoke(t.Context(), shortcut) runtime.Invoke(t.Context(), shortcut)
selection = next_remote_form(t, forms) 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") t.Fatal("backend failure retained a connected workflow")
} }
selection.reply <- internalUIResult{Cancelled: true} selection.reply <- internalUIResult{Cancelled: true}
@@ -522,7 +599,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
deadline := time.Now().Add(5 * time.Second) deadline := time.Now().Add(5 * time.Second)
for { for {
encoded, _ := json.Marshal(status.form) 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 break
} }
if time.Now().After(deadline) { if time.Now().After(deadline) {
@@ -660,10 +737,10 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
case "disconnect": case "disconnect":
runtime.Invoke(t.Context(), shortcut) runtime.Invoke(t.Context(), shortcut)
status = next_remote_form(t, forms) 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") t.Fatal("reopening lost the approved connection")
} }
status.reply <- internalUIResult{Activated: "cancel"} status.reply <- internalUIResult{Activated: "disconnect"}
case "lock": case "lock":
runtime.output.gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{}) runtime.output.gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{})
runtime.output.gate.SetLocked(false, nil, nil, nil, nil, &brailleWorker{}) runtime.output.gate.SetLocked(false, nil, nil, nil, nil, &brailleWorker{})
+22 -9
View File
@@ -112,20 +112,33 @@ func remote_consent_form(id, role string, grants []string) interaction.Form {
if role == "controlled" { if role == "controlled" {
roleText = "allow a helper to access this computer" roleText = "allow a helper to access this computer"
} }
form := interaction.Form{Title: "Remote access for " + id + ": " + roleText + form := interaction.Form{Title: "Remote access for " + id + ": " + roleText + ". The relay can read session contents. Select access for this session.",
". The relay can read the channel key and session contents. A peer ID does not verify a person's identity. Select access for this session using a local keyboard.",
Elements: []interaction.Element{{ID: "cancel", Label: "Cancel", Kind: interaction.Button}}} Elements: []interaction.Element{{ID: "cancel", Label: "Cancel", Kind: interaction.Button}}}
labels := map[string]string{ labels := map[string]string{
"output-observe": "Share local reader output with the remote controller", "output-observe": "Share speech with the helper",
"remote-present": "Present output from the controlled computer", "remote-present": "Hear the other computer",
"keyboard-capture": "Forward local keyboard input to the controlled computer while remote focus is on. Press F11 to return to this computer", "keyboard-capture": "Keyboard control of the other computer. F11 returns here",
"keyboard-inject": "Allow the remote controller to type on this computer", "keyboard-inject": "Allow the helper to type here",
"clipboard-exchange": "Allow receiving clipboard text; send only on explicit local request", "clipboard-exchange": "Clipboard text, transferred on request",
"braille-stream": "Exchange remote braille cells and supported display gestures", "braille-stream": "Braille display and routing",
} }
for _, grant := range grants { 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: 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 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)
}
+45 -1
View File
@@ -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) { func (forms *consentTestForms) OpenLocal(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) {
reply := make(chan internalUIResult, 1) reply := make(chan internalUIResult, 1)
forms.requests <- consentFormRequest{ctx, form, reply} 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) { 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") 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")
}
}
+13 -4
View File
@@ -6,6 +6,7 @@ package tentaclewire
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"slices"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
@@ -15,7 +16,8 @@ import (
// Interaction is an opt-in command result. The host runs forms outside the // 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 // bounded RPC deadline and invokes Next with the submitted values. Poll is a
// registered command sampled once per second while the form remains open. // 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 { type Interaction struct {
Presentation Presentation
Form *interaction.Form `json:"form,omitempty"` 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 // 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. // grants access by itself and can target only the requesting process session.
type RemoteRequest struct { type RemoteRequest struct {
Role string `json:"role"` Role string `json:"role"`
Grants []string `json:"grants"` Grants []string `json:"grants"`
OptionalGrants []string `json:"optional_grants,omitempty"`
} }
type Submission struct { type Submission struct {
@@ -88,7 +91,7 @@ func DecodeInteraction(data []byte) (Interaction, error) {
return value, ErrProtocol return value, ErrProtocol
} }
} else if value.Remote != nil { } 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 return value, ErrProtocol
} }
} else if value.Reader != nil { } else if value.Reader != nil {
@@ -132,3 +135,9 @@ func validFormText(text string) bool {
} }
return true 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)
}
@@ -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)
}
}
}
+3
View File
@@ -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 Protocol 1.1 interactive commands can request a reader-owned consent dialog with
{"remote": {"role": "controller", "grants": ["remote-present"]}, "next": "approved"}. {"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 The continuation receives "remote" metadata after explicit local approval, or
"cancelled": True on denial. Match approval to the actual SessionClient event; "cancelled": True on denial. Match approval to the actual SessionClient event;
neither a request dictionary nor submitted metadata is itself consent. This neither a request dictionary nor submitted metadata is itself consent. This