Merge remote-tracking branch 'origin/testing' into hunter
This commit is contained in:
commit
8c26aad128
12 files changed
+560
-30
No files matched your search
@@ -1,9 +1,10 @@
|
|||||||
# Reader-owned Remote authorization
|
# Reader-owned Remote authorization
|
||||||
|
|
||||||
Implementation increment: 2026-11-03. The reader now has a core consent path,
|
Implementation increment: 2026-11-03. The reader now has a core consent path,
|
||||||
device-aware sensitive forms, and lock/handoff cancellation. There is still no
|
device-aware sensitive forms, and lock/handoff cancellation. A subsequent
|
||||||
registered Remote connection command or reader desktop service. This is not a
|
2026-11-04 increment registers the external source-built Remote connection
|
||||||
usable Remote release, and physical-keyboard approval remains unverified.
|
command and typed consent interactions. There is still no reader desktop service.
|
||||||
|
This is not a usable Remote release, and physical-keyboard approval remains unverified.
|
||||||
Base revisions: reader `testing` at `573d15d`, Tentacles `master` at `63e84e9`;
|
Base revisions: reader `testing` at `573d15d`, Tentacles `master` at `63e84e9`;
|
||||||
both still matched their maintained remote tips when checked for this increment.
|
both still matched their maintained remote tips when checked for this increment.
|
||||||
|
|
||||||
@@ -39,8 +40,13 @@ to issue a new action. The old unrestricted `AuthorizeRemote` hook is rejected
|
|||||||
by policy-bound managers; plain development hosts retain it for protocol tests.
|
by policy-bound managers; plain development hosts retain it for protocol tests.
|
||||||
|
|
||||||
No child RPC, install grant, certificate review, saved key, relay event or
|
No child RPC, install grant, certificate review, saved key, relay event or
|
||||||
injected shortcut is itself consent. Connection command registration and joining
|
injected shortcut is itself consent. A typed `Interaction.remote` can now request
|
||||||
the external workflow to this core procedure remain the next integration work.
|
this core procedure for the exact requesting process session while retaining its
|
||||||
|
already-held interaction slot. Next receives the matching `Submission.remote`
|
||||||
|
only after explicit core approval; the external source command also matches the
|
||||||
|
SDK event stream before opening its connection controls. Interaction completion
|
||||||
|
revokes only its approved epoch, and bounded child cancellation cleans up pending
|
||||||
|
inspection before a lease exists. See [the public interaction contract](tentacle-sessions.md#reader-owned-consent-interactions).
|
||||||
|
|
||||||
## Sensitive form input
|
## Sensitive form input
|
||||||
|
|
||||||
@@ -118,14 +124,15 @@ repository's `make check` passed. The complete host race suite took about 124
|
|||||||
seconds. libXi is now an explicit build/package dependency; no package was
|
seconds. libXi is now an explicit build/package dependency; no package was
|
||||||
installed, published or reloaded.
|
installed, published or reloaded.
|
||||||
|
|
||||||
Before exposing the connection command, verify actual local keyboard navigation,
|
The source command is now exposed for disposable-session acceptance. Verify actual local keyboard navigation,
|
||||||
approval, cancellation and modifier handling through this capture path on Xorg.
|
approval, cancellation and modifier handling through this capture path on Xorg.
|
||||||
Then exercise certificate review and Remember key with physical input. During
|
Then exercise certificate review and Remember key with physical input. During
|
||||||
lock and XTerm handoff, verify immediate disconnect, no revival on return, and
|
lock and XTerm handoff, verify immediate disconnect, no revival on return, and
|
||||||
no stale trust/approval after a failed update. Recheck ordinary entries, secret
|
no stale trust/approval after a failed update. Recheck ordinary entries, secret
|
||||||
masking/paste, learn mode, remapped commands and application key delivery.
|
masking/paste, learn mode, remapped commands and application key delivery.
|
||||||
Those manual checks need an exposed connection workflow first; there is still
|
The [source-command procedure](../../cthulhu-tentacles/remote/command.md) supplies
|
||||||
no Remote desktop session to launch from this increment.
|
the development launch list and focused physical-input checks. There is still
|
||||||
|
no Remote desktop session to launch.
|
||||||
|
|
||||||
The current lock integration follows the reader's existing I38lock secure mode.
|
The current lock integration follows the reader's existing I38lock secure mode.
|
||||||
Other lockers/session switches are not established by this work. Positive
|
Other lockers/session switches are not established by this work. Positive
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ This increment supplies negotiation, bounded bidirectional IPC, lifecycle events
|
|||||||
install grants, and an inert remote lease service. It does **not** supply desktop
|
install grants, and an inert remote lease service. It does **not** supply desktop
|
||||||
remote control. A later [reader authorization increment](tentacle-remote-authorization.md)
|
remote control. A later [reader authorization increment](tentacle-remote-authorization.md)
|
||||||
adds the core consent procedure, sensitive XI2 forms and lock/handoff policy.
|
adds the core consent procedure, sensitive XI2 forms and lock/handoff policy.
|
||||||
Its connection command remains unregistered and physical-keyboard approval is
|
The external source-built Remote command now requests that consent through a
|
||||||
unverified. There is no output observer, keyboard service, clipboard service,
|
typed interaction; physical-keyboard approval remains unverified. There is no
|
||||||
|
output observer, keyboard service, clipboard service,
|
||||||
or Braille session service. The unrestricted `Session.AuthorizeRemote` hook is
|
or Braille session service. The unrestricted `Session.AuthorizeRemote` hook is
|
||||||
limited to development managers; reader managers require a core approval ticket.
|
limited to development managers; reader managers require a core approval ticket.
|
||||||
Packaging Remote itself remains premature.
|
Packaging Remote itself remains premature.
|
||||||
@@ -79,6 +80,44 @@ the receive loop. Go event consumers must handle channel closure; Python returns
|
|||||||
`None` on shutdown. Workers must stop when the serving context/session is
|
`None` on shutdown. Workers must stop when the serving context/session is
|
||||||
cancelled. Events already taken from a queue do not prove a lease remains valid.
|
cancelled. Events already taken from a queue do not prove a lease remains valid.
|
||||||
|
|
||||||
|
## Reader-owned consent interactions
|
||||||
|
|
||||||
|
An interactive protocol 1.1 command may return this request, using Go's public
|
||||||
|
`RemoteRequest` alias or an ordinary Python interaction dictionary:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"remote":{"role":"controller","grants":["remote-present"]},"next":"remote-approved"}
|
||||||
|
```
|
||||||
|
|
||||||
|
It asks for a core-owned local consent dialog; it does not authorize anything.
|
||||||
|
There is no target-session parameter. The host binds the exact requesting
|
||||||
|
process session, checks its installed grants and reserves the existing
|
||||||
|
interaction lifetime. The request requires a registered interactive continuation
|
||||||
|
and cannot be combined with a form, reader request, source, clipboard effect,
|
||||||
|
polling or Done. Unknown roles, duplicate/unknown grants and role-direction
|
||||||
|
mismatches are rejected. A child cannot supply the core approval ticket.
|
||||||
|
|
||||||
|
The reader's local-only consent form starts on Cancel with all requested grants
|
||||||
|
unchecked. A nonempty explicit approval produces the ordinary SDK
|
||||||
|
`remote.action` event and invokes Next with a `Submission.remote` containing the
|
||||||
|
same event. Denial invokes Next with `cancelled: true` and a generic error, then
|
||||||
|
ends the interaction. The continuation must match its reply against the actual
|
||||||
|
SDK event stream before consuming approval; command arguments alone are not
|
||||||
|
authority. The 30-second pending-action expiry still applies.
|
||||||
|
|
||||||
|
The host reuses its already-held interaction slot, avoiding nested admission.
|
||||||
|
Lock/handoff cancels the original policy lifetime. Finishing the interaction
|
||||||
|
revokes its original approved epoch, including an unused action; stale cleanup
|
||||||
|
cannot revoke replacement consent. On an unfinished exit the host also invokes
|
||||||
|
the outstanding child continuation with cancellation and a separate bounded
|
||||||
|
cleanup context, so pre-lease inspection does not depend on a revocation event.
|
||||||
|
No form values accompany that cleanup. Session failure/disable still uses
|
||||||
|
normal process supervision. Existing non-remote interactions keep their path.
|
||||||
|
|
||||||
|
This addition requires the matching updated reader; protocol 1.1 by itself does
|
||||||
|
not imply that an older reader accepts the new interaction field. Remote still
|
||||||
|
has no release manifest advertising a minimum supported reader version.
|
||||||
|
|
||||||
## Framing, ownership and limits
|
## Framing, ownership and limits
|
||||||
|
|
||||||
Frames remain UTF-8 newline-delimited JSON-RPC 2.0, at most 64 KiB excluding LF.
|
Frames remain UTF-8 newline-delimited JSON-RPC 2.0, at most 64 KiB excluding LF.
|
||||||
@@ -179,7 +218,9 @@ The individual `native_tentacle_*.py` files are imported helpers, not standalone
|
|||||||
harnesses. Tests use a separately built binary selected by `CTHULHU_BINARY`,
|
harnesses. Tests use a separately built binary selected by `CTHULHU_BINARY`,
|
||||||
private Xvfb and private D-Bus; they do not replace the running reader.
|
private Xvfb and private D-Bus; they do not replace the running reader.
|
||||||
|
|
||||||
There is no Remote connection UI to test live yet. Physical keyboard recovery,
|
The external source command now exposes connection UI for disposable-session
|
||||||
|
acceptance; see [its procedure](../../cthulhu-tentacles/remote/command.md).
|
||||||
|
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.
|
||||||
@@ -43,6 +43,22 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
|
|||||||
defer stopCommand()
|
defer stopCommand()
|
||||||
stop := context.AfterFunc(command.session.OutputContext(), cancel)
|
stop := context.AfterFunc(command.session.OutputContext(), cancel)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
// Policy cancellation can interrupt setup before a lease exists, when no
|
||||||
|
// remote.revoked event is emitted. Tell the original continuation to stop its
|
||||||
|
// inspection/setup workers before releasing the interaction slot. Never pass
|
||||||
|
// cancelled form values or reuse the cancelled interaction context.
|
||||||
|
cleanupNext := ""
|
||||||
|
var approvedEpoch uint64
|
||||||
|
defer func() {
|
||||||
|
if approvedEpoch != 0 {
|
||||||
|
command.session.RevokeRemoteEpoch(approvedEpoch)
|
||||||
|
}
|
||||||
|
if len(command.session.RemoteGrants()) != 0 && cleanupNext != "" && command.session.OutputContext().Err() == nil {
|
||||||
|
cleanupCtx, stopCleanup := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer stopCleanup()
|
||||||
|
runtime.callInteraction(cleanupCtx, command, cleanupNext, tentaclewire.Submission{Cancelled: true})
|
||||||
|
}
|
||||||
|
}()
|
||||||
var temporaryFiles []string
|
var temporaryFiles []string
|
||||||
defer func() {
|
defer func() {
|
||||||
for _, path := range temporaryFiles {
|
for _, path := range temporaryFiles {
|
||||||
@@ -60,6 +76,26 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
|
|||||||
if runtime.output.gate.Locked() || runtime.output.gate.Suspended() {
|
if runtime.output.gate.Locked() || runtime.output.gate.Suspended() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cleanupNext = value.Next
|
||||||
|
if value.Remote != nil {
|
||||||
|
action, approvalErr := runtime.authorize_remote_session(ctx, command.session, value.Remote.Role, value.Remote.Grants)
|
||||||
|
submission := tentaclewire.Submission{}
|
||||||
|
if approvalErr != nil {
|
||||||
|
submission.Cancelled = true
|
||||||
|
submission.Error = "Remote access was not approved."
|
||||||
|
} else {
|
||||||
|
approvedEpoch = action.Epoch
|
||||||
|
submission.Remote = &action
|
||||||
|
}
|
||||||
|
raw, err = runtime.callInteraction(ctx, command, value.Next, submission)
|
||||||
|
if err == nil && submission.Cancelled {
|
||||||
|
cleanupNext = ""
|
||||||
|
}
|
||||||
|
if err != nil || approvalErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if value.Clipboard != nil {
|
if value.Clipboard != nil {
|
||||||
copyCtx, stopCopy := context.WithTimeout(ctx, time.Second)
|
copyCtx, stopCopy := context.WithTimeout(ctx, time.Second)
|
||||||
err = writeClipboardText(copyCtx, *value.Clipboard)
|
err = writeClipboardText(copyCtx, *value.Clipboard)
|
||||||
@@ -98,6 +134,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
|
|||||||
if value.Form != nil {
|
if value.Form != nil {
|
||||||
if runtime.forms == nil {
|
if runtime.forms == nil {
|
||||||
runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true})
|
runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true})
|
||||||
|
cleanupNext = ""
|
||||||
runtime.report("Internal UI unavailable.")
|
runtime.report("Internal UI unavailable.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -106,6 +143,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
|
|||||||
if openErr != nil {
|
if openErr != nil {
|
||||||
closeForm()
|
closeForm()
|
||||||
runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true})
|
runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true})
|
||||||
|
cleanupNext = ""
|
||||||
runtime.report("Internal UI unavailable.")
|
runtime.report("Internal UI unavailable.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -158,6 +196,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if submission.Cancelled {
|
if submission.Cancelled {
|
||||||
|
cleanupNext = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,7 +255,7 @@ 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 {
|
if err != nil || status.Form != nil || status.Source != "" || status.Next != "" || status.Reader != nil || status.Remote != nil {
|
||||||
command.session.Disable()
|
command.session.Disable()
|
||||||
return internalUIResult{Cancelled: true}, nil
|
return internalUIResult{Cancelled: true}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/input"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/interaction"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This uses the source-built external executable and real host/SDK IPC. Only
|
||||||
|
// OpenLocal submissions are synthetic; it cannot prove physical-source input.
|
||||||
|
func remote_command_fixture(t *testing.T) (*tentacleRuntime, *consentTestForms, input.Command) {
|
||||||
|
t.Helper()
|
||||||
|
program := os.Getenv("CTHULHU_REMOTE_BINARY")
|
||||||
|
if program == "" {
|
||||||
|
t.Skip("requires the separately built Remote executable")
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
spec := tentacle.Spec{ID: "remote", Program: []string{program, "--data-dir", filepath.Join(directory, "state")}, Presentation: true, Interaction: true, SessionEvents: true, RemoteGrants: []string{"output-observe", "remote-present"}}
|
||||||
|
data, _ := json.Marshal([]tentacle.Spec{spec})
|
||||||
|
launchPath := filepath.Join(directory, "launch.json")
|
||||||
|
if err := os.WriteFile(launchPath, data, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bindings := &tentacleBindingRecorder{}
|
||||||
|
runtime := newTentacleRuntime(slog.New(slog.DiscardHandler), bindings, launchPath, "")
|
||||||
|
runtime.output = tentacleOutputFixture(t)
|
||||||
|
runtime.remotePolicy = tentacle.NewRemotePolicy()
|
||||||
|
runtime.output.gate.remotePolicy = runtime.remotePolicy
|
||||||
|
runtime.newManager = func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) }
|
||||||
|
forms := &consentTestForms{requests: make(chan consentFormRequest, 1)}
|
||||||
|
runtime.forms = forms
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer stopCancel()
|
||||||
|
if err := runtime.Stop(stopCtx); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := runtime.Start(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var command input.Command
|
||||||
|
waitTentacle(t, func() bool {
|
||||||
|
runtime.mutex.Lock()
|
||||||
|
defer runtime.mutex.Unlock()
|
||||||
|
for id, registered := range runtime.commands {
|
||||||
|
if registered.id == "remote" {
|
||||||
|
command = id
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
runtime.Invoke(ctx, command)
|
||||||
|
return runtime, forms, command
|
||||||
|
}
|
||||||
|
|
||||||
|
func next_remote_form(t *testing.T, forms *consentTestForms) consentFormRequest {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case request := <-forms.requests:
|
||||||
|
if request.ctx.Err() != nil {
|
||||||
|
t.Fatal("form already cancelled")
|
||||||
|
}
|
||||||
|
if _, err := interaction.NewSession(request.form); err != nil {
|
||||||
|
t.Fatal("invalid form", err)
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Remote did not present its next local form")
|
||||||
|
return 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")
|
||||||
|
}
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
host, port, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
for _, element := range credentials.form.Elements {
|
||||||
|
if element.ID == "channel-key" && (!element.Secret || element.Text != "") {
|
||||||
|
t.Fatal("unmasked or prefilled channel key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
switch mode {
|
||||||
|
case "deny":
|
||||||
|
consent.reply <- internalUIResult{Cancelled: true}
|
||||||
|
case "empty":
|
||||||
|
consent.reply <- internalUIResult{Activated: "approve"}
|
||||||
|
case "lock", "handoff":
|
||||||
|
runtime.remotePolicy.SetAvailable(false)
|
||||||
|
runtime.remotePolicy.SetAvailable(true)
|
||||||
|
}
|
||||||
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
||||||
|
select {
|
||||||
|
case form := <-forms.requests:
|
||||||
|
t.Fatal("denied consent opened connection controls", form.form.Title)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
// The command must recover after a denied/cancelled review, without
|
||||||
|
// 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") {
|
||||||
|
t.Fatal("stale setup survived cancellation")
|
||||||
|
}
|
||||||
|
form.reply <- internalUIResult{Cancelled: true}
|
||||||
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteConnectionCommandCancelsInspectionOnHandoff(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
accepted := make(chan struct{})
|
||||||
|
closed := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
connection, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
connection.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
close(accepted)
|
||||||
|
io.Copy(io.Discard, connection)
|
||||||
|
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.reply <- internalUIResult{Activated: "inspect-certificate"}
|
||||||
|
inspection := next_remote_form(t, forms)
|
||||||
|
select {
|
||||||
|
case <-accepted:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("inspection did not open its socket")
|
||||||
|
}
|
||||||
|
runtime.remotePolicy.SetAvailable(false)
|
||||||
|
runtime.remotePolicy.SetAvailable(true)
|
||||||
|
select {
|
||||||
|
case <-inspection.ctx.Done():
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("handoff retained the inspection form")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-closed:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("handoff retained a pre-lease inspection socket")
|
||||||
|
}
|
||||||
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||||
|
for _, role := range []string{"controller", "controlled"} {
|
||||||
|
t.Run(role, func(t *testing.T) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
probed, joined, closed := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
probe, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
probe.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
data, _ := io.ReadAll(probe)
|
||||||
|
probe.Close()
|
||||||
|
if len(data) != 0 {
|
||||||
|
t.Error("certificate inspection sent application data")
|
||||||
|
}
|
||||||
|
close(probed)
|
||||||
|
connection, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
connection.SetDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
reader := bufio.NewReader(connection)
|
||||||
|
if _, err := reader.ReadBytes('\n'); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line, err := reader.ReadBytes('\n')
|
||||||
|
var join struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ConnectionType string `json:"connection_type"`
|
||||||
|
}
|
||||||
|
wantRole := "master"
|
||||||
|
if role == "controlled" {
|
||||||
|
wantRole = "slave"
|
||||||
|
}
|
||||||
|
if err != nil || json.Unmarshal(line, &join) != nil || join.Type != "join" || join.Channel != "synthetic-fixture" || join.ConnectionType != wantRole {
|
||||||
|
t.Error("invalid relay join or wrong selected role")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.WriteString(connection, `{"type":"channel_joined","channel":"synthetic-fixture","clients":[]}`+"\n")
|
||||||
|
close(joined)
|
||||||
|
io.Copy(io.Discard, reader)
|
||||||
|
close(closed)
|
||||||
|
}()
|
||||||
|
runtime, forms, _ := remote_command_fixture(t)
|
||||||
|
consent := select_remote_relay(t, forms, listener.Addr().String(), role)
|
||||||
|
credentials := approve_remote_form(t, forms, consent, role)
|
||||||
|
credentials.reply <- internalUIResult{Activated: "inspect-certificate"}
|
||||||
|
inspection := next_remote_form(t, forms)
|
||||||
|
select {
|
||||||
|
case <-probed:
|
||||||
|
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.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)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
status.reply <- internalUIResult{Activated: "cancel"}
|
||||||
|
select {
|
||||||
|
case <-closed:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Disconnect and close retained the relay")
|
||||||
|
}
|
||||||
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,19 +17,12 @@ type localTentacleForms interface {
|
|||||||
OpenLocal(context.Context, interaction.Form) (<-chan internalUIResult, error)
|
OpenLocal(context.Context, interaction.Form) (<-chan internalUIResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authorize_remote is a core entry point, not a registered Tentacle command or
|
// authorize_remote reserves a standalone core dialog. Interactive commands use
|
||||||
// child RPC. A future connection command must resolve the installed session and
|
// authorize_remote_session with their already-reserved slot and exact session.
|
||||||
// role before calling it. A synthetic shortcut may request a dialog but cannot
|
|
||||||
// select permissions or approve it through the local-only capture path.
|
|
||||||
func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role string, grants []string) (tentaclewire.SessionEvent, error) {
|
func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role string, grants []string) (tentaclewire.SessionEvent, error) {
|
||||||
if runtime == nil || runtime.remotePolicy == nil || !tentaclewire.ValidRoleGrants(role, grants) {
|
if runtime == nil || runtime.remotePolicy == nil || !tentaclewire.ValidRoleGrants(role, grants) {
|
||||||
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
|
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
|
||||||
}
|
}
|
||||||
forms, ok := runtime.forms.(localTentacleForms)
|
|
||||||
if !ok {
|
|
||||||
return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable
|
|
||||||
}
|
|
||||||
grants = slices.Clone(grants)
|
|
||||||
runtime.mutex.Lock()
|
runtime.mutex.Lock()
|
||||||
var target *tentacle.Session
|
var target *tentacle.Session
|
||||||
if !runtime.disabled {
|
if !runtime.disabled {
|
||||||
@@ -44,12 +37,6 @@ func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role s
|
|||||||
if target == nil {
|
if target == nil {
|
||||||
return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable
|
return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable
|
||||||
}
|
}
|
||||||
installed := target.RemoteGrants()
|
|
||||||
for _, grant := range grants {
|
|
||||||
if !slices.Contains(installed, grant) {
|
|
||||||
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reserve the interaction before creating a ticket, so a second request
|
// Reserve the interaction before creating a ticket, so a second request
|
||||||
// rejected as busy cannot invalidate the dialog already on screen.
|
// rejected as busy cannot invalidate the dialog already on screen.
|
||||||
select {
|
select {
|
||||||
@@ -58,6 +45,32 @@ func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role s
|
|||||||
return tentaclewire.SessionEvent{}, tentacle.ErrBusy
|
return tentaclewire.SessionEvent{}, tentacle.ErrBusy
|
||||||
}
|
}
|
||||||
defer func() { <-runtime.interactionSlots }()
|
defer func() { <-runtime.interactionSlots }()
|
||||||
|
return runtime.authorize_remote_session(ctx, target, role, grants)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller owns interactionSlots. A child request may open a dialog, but only
|
||||||
|
// local capture and the core-issued ticket can authorize the requesting session.
|
||||||
|
func (runtime *tentacleRuntime) authorize_remote_session(ctx context.Context, target *tentacle.Session, role string, grants []string) (tentaclewire.SessionEvent, error) {
|
||||||
|
if runtime == nil || runtime.remotePolicy == nil || target == nil || !tentaclewire.ValidRoleGrants(role, grants) {
|
||||||
|
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
|
||||||
|
}
|
||||||
|
forms, ok := runtime.forms.(localTentacleForms)
|
||||||
|
if !ok {
|
||||||
|
return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable
|
||||||
|
}
|
||||||
|
runtime.mutex.Lock()
|
||||||
|
current := !runtime.disabled && slices.Contains(runtime.sessions, target)
|
||||||
|
runtime.mutex.Unlock()
|
||||||
|
if !current {
|
||||||
|
return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable
|
||||||
|
}
|
||||||
|
grants = slices.Clone(grants)
|
||||||
|
installed := target.RemoteGrants()
|
||||||
|
for _, grant := range grants {
|
||||||
|
if !slices.Contains(installed, grant) {
|
||||||
|
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
|
||||||
|
}
|
||||||
|
}
|
||||||
approval, err := target.BeginRemoteApproval(role, grants)
|
approval, err := target.BeginRemoteApproval(role, grants)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tentaclewire.SessionEvent{}, err
|
return tentaclewire.SessionEvent{}, err
|
||||||
@@ -69,7 +82,7 @@ func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role s
|
|||||||
defer stopPolicy()
|
defer stopPolicy()
|
||||||
stopSession := context.AfterFunc(target.OutputContext(), cancel)
|
stopSession := context.AfterFunc(target.OutputContext(), cancel)
|
||||||
defer stopSession()
|
defer stopSession()
|
||||||
form := remote_consent_form(id, role, grants)
|
form := remote_consent_form(target.TentacleID(), role, grants)
|
||||||
replies, err := forms.OpenLocal(ctx, form)
|
replies, err := forms.OpenLocal(ctx, form)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tentaclewire.SessionEvent{}, err
|
return tentaclewire.SessionEvent{}, err
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ func (session *Session) RevokeRemote() {
|
|||||||
session.revoke_remote_locked("local", true)
|
session.revoke_remote_locked("local", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevokeRemoteEpoch retires only the action/lease owned by a completed core
|
||||||
|
// interaction. Closing an old workflow cannot revoke a replacement approval.
|
||||||
|
func (session *Session) RevokeRemoteEpoch(epoch uint64) {
|
||||||
|
session.mutex.Lock()
|
||||||
|
defer session.mutex.Unlock()
|
||||||
|
if session.remote != nil && session.remote.epoch == epoch {
|
||||||
|
session.revoke_remote_locked("local", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RemoteContext is for future reader owners to check again at delivery. A live
|
// RemoteContext is for future reader owners to check again at delivery. A live
|
||||||
// context is necessary, not sufficient: focus, lock and output policy still apply.
|
// context is necessary, not sufficient: focus, lock and output policy still apply.
|
||||||
func (session *Session) RemoteContext(lease tentaclewire.RemoteLease, grant string) (context.Context, error) {
|
func (session *Session) RemoteContext(lease tentaclewire.RemoteLease, grant string) (context.Context, error) {
|
||||||
|
|||||||
@@ -123,6 +123,54 @@ func remoteCall(t *testing.T, session *Session, method string, params any) (json
|
|||||||
return result.Value, result.Code
|
return result.Value, result.Code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteWorkflowEpochCleanup(t *testing.T) {
|
||||||
|
manager, err := NewManager(DefaultLimits(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := manager.Shutdown(ctx); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
session := startSessionSDK(t, manager, "go", "workflow")
|
||||||
|
first, err := session.AuthorizeRemote("controller", []string{"remote-present"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := session.AuthorizeRemote("controller", []string{"remote-present"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session.RevokeRemoteEpoch(first.Epoch)
|
||||||
|
raw, code := remoteCall(t, session, "remote.open", tentaclewire.RemoteOpen{Action: second.Action, Epoch: second.Epoch, Role: second.Role, Grants: second.Grants})
|
||||||
|
var lease tentaclewire.RemoteLease
|
||||||
|
if code != 0 || json.Unmarshal(raw, &lease) != nil {
|
||||||
|
t.Fatal("old workflow revoked its replacement")
|
||||||
|
}
|
||||||
|
delivery, err := session.RemoteContext(lease, "remote-present")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session.RevokeRemoteEpoch(second.Epoch)
|
||||||
|
if delivery.Err() == nil {
|
||||||
|
t.Fatal("workflow close retained its live lease")
|
||||||
|
}
|
||||||
|
// Pending actions also release exclusive ownership on close, before their
|
||||||
|
// timeout and without requiring a remote.open/remote.close pair.
|
||||||
|
pending, err := session.AuthorizeRemote("controller", []string{"remote-present"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session.RevokeRemoteEpoch(pending.Epoch)
|
||||||
|
other := startSessionSDK(t, manager, "go", "other-workflow")
|
||||||
|
if _, err := other.AuthorizeRemote("controlled", []string{"output-observe"}); err != nil {
|
||||||
|
t.Fatal("closed setup retained exclusive ownership", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func openRemote(t *testing.T, session *Session) tentaclewire.RemoteLease {
|
func openRemote(t *testing.T, session *Session) tentaclewire.RemoteLease {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
action, err := session.AuthorizeRemote("controlled", []string{"output-observe"})
|
action, err := session.AuthorizeRemote("controlled", []string{"output-observe"})
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ type Interaction struct {
|
|||||||
Clipboard *string `json:"clipboard,omitempty"`
|
Clipboard *string `json:"clipboard,omitempty"`
|
||||||
Done bool `json:"done,omitempty"`
|
Done bool `json:"done,omitempty"`
|
||||||
Reader *ReaderRequest `json:"reader,omitempty"`
|
Reader *ReaderRequest `json:"reader,omitempty"`
|
||||||
|
Remote *RemoteRequest `json:"remote,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Submission struct {
|
type Submission struct {
|
||||||
@@ -34,6 +42,7 @@ type Submission struct {
|
|||||||
Item *Item `json:"item,omitempty"`
|
Item *Item `json:"item,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Reader *ReaderState `json:"reader,omitempty"`
|
Reader *ReaderState `json:"reader,omitempty"`
|
||||||
|
Remote *SessionEvent `json:"remote,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Item is a single copied value, without accessible identities or native handles.
|
// Item is a single copied value, without accessible identities or native handles.
|
||||||
@@ -71,7 +80,11 @@ func DecodeInteraction(data []byte) (Interaction, error) {
|
|||||||
if value.Source != "" && value.Source != "focus" && value.Source != "clipboard" {
|
if value.Source != "" && value.Source != "focus" && value.Source != "clipboard" {
|
||||||
return value, ErrProtocol
|
return value, ErrProtocol
|
||||||
}
|
}
|
||||||
if value.Reader != nil {
|
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 {
|
||||||
|
return value, ErrProtocol
|
||||||
|
}
|
||||||
|
} else if value.Reader != nil {
|
||||||
if ValidateReaderRequest(*value.Reader) != nil || value.Form != nil || value.Source != "" || value.Poll != "" || value.Clipboard != nil || value.Done || value.Reader.Snapshot && value.Next == "" {
|
if ValidateReaderRequest(*value.Reader) != nil || value.Form != nil || value.Source != "" || value.Poll != "" || value.Clipboard != nil || value.Done || value.Reader.Snapshot && value.Next == "" {
|
||||||
return value, ErrProtocol
|
return value, ErrProtocol
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package tentaclewire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRemoteConsentInteractionValidation(t *testing.T) {
|
||||||
|
valid := Interaction{Remote: &RemoteRequest{Role: "controller", Grants: []string{"remote-present"}}, Next: "approved"}
|
||||||
|
data, _ := json.Marshal(valid)
|
||||||
|
if _, err := DecodeInteraction(data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, raw := range []string{
|
||||||
|
`{"remote":null,"next":"approved"}`,
|
||||||
|
`{"remote":{"role":"controller","grants":["remote-present"]}}`,
|
||||||
|
`{"remote":{"role":"controller","grants":["keyboard-inject"]},"next":"approved"}`,
|
||||||
|
`{"remote":{"role":"controlled","grants":[]},"next":"approved"}`,
|
||||||
|
`{"remote":{"role":"controlled","grants":["output-observe","output-observe"]},"next":"approved"}`,
|
||||||
|
`{"remote":{"role":"controlled","grants":["output-observe"],"session":"other"},"next":"approved"}`,
|
||||||
|
} {
|
||||||
|
if _, err := DecodeInteraction([]byte(raw)); err == nil {
|
||||||
|
t.Fatal("invalid remote interaction accepted", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{`"clipboard":"text"`, `"source":"clipboard"`, `"done":true`, `"poll":"poll"`, `"reader":{"snapshot":true}`} {
|
||||||
|
raw := `{"remote":{"role":"controller","grants":["remote-present"]},"next":"approved",` + field + `}`
|
||||||
|
if _, err := DecodeInteraction([]byte(raw)); err == nil {
|
||||||
|
t.Fatal("remote consent mixed with another effect", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ import "strings"
|
|||||||
|
|
||||||
const Name = "Cthulhu"
|
const Name = "Cthulhu"
|
||||||
|
|
||||||
const Version = "2026.11.03"
|
const Version = "2026.11.04"
|
||||||
const Channel = "testing"
|
const Channel = "testing"
|
||||||
|
|
||||||
var Build = Version
|
var Build = Version
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Extensions = tentaclewire.Extensions
|
|||||||
type PreferencePage = tentaclewire.PreferencePage
|
type PreferencePage = tentaclewire.PreferencePage
|
||||||
type QuickSetting = tentaclewire.QuickSetting
|
type QuickSetting = tentaclewire.QuickSetting
|
||||||
type ReaderRequest = tentaclewire.ReaderRequest
|
type ReaderRequest = tentaclewire.ReaderRequest
|
||||||
|
type RemoteRequest = tentaclewire.RemoteRequest
|
||||||
type ReaderState = tentaclewire.ReaderState
|
type ReaderState = tentaclewire.ReaderState
|
||||||
type ExtensionEvent = tentaclewire.ExtensionEvent
|
type ExtensionEvent = tentaclewire.ExtensionEvent
|
||||||
type SpeechConfiguration = tentaclewire.SpeechConfiguration
|
type SpeechConfiguration = tentaclewire.SpeechConfiguration
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ Interaction form dictionaries may set secret=True on entries (kind=2). The host
|
|||||||
masks editing output and disallows copy/cut; successful submissions still carry
|
masks editing output and disallows copy/cut; successful submissions still carry
|
||||||
plaintext values to the continuation. Never log or present those values. Secret
|
plaintext values to the continuation. Never log or present those values. Secret
|
||||||
entries cannot be used in persistent preference pages; older hosts reject them.
|
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 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
|
||||||
|
interaction addition requires the matching updated reader.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|||||||
Reference in new issue
Block a user