Merge remote-tracking branch 'origin/testing' into hunter

This commit is contained in:
Storm Dragon committed 2026-11-04 12:13:00 -05:00
commit 8c26aad128
12 files changed
+560 -30

No files matched your search

+15 -8
View File
@@ -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
+44 -3
View File
@@ -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.
+40 -1
View File
@@ -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 })
})
}
}
+29 -16
View File
@@ -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
+10
View File
@@ -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) {
+48
View File
@@ -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"})
+14 -1
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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
+1
View File
@@ -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
+7
View File
@@ -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