Support compact Remote setup with reader-owned access selection
This commit is contained in:
1 parent
952d3c8204
commit
bf4a23645e
9 files changed
+274
-61
No files matched your search
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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{})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user