From 24261edb3deb06a43b0c7ec6a0d9fb110dc7a382 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 4 Nov 2026 12:10:41 -0500 Subject: [PATCH] Tentacle support enhancements. --- docs/tentacle-remote-authorization.md | 23 +- docs/tentacle-sessions.md | 47 ++- internal/app/tentacle_interaction.go | 41 ++- .../app/tentacle_remote_connection_test.go | 317 ++++++++++++++++++ internal/app/tentacle_remote_consent.go | 45 ++- internal/tentacle/remote.go | 10 + internal/tentacle/remote_test.go | 48 +++ internal/tentaclewire/interaction.go | 15 +- .../tentaclewire/remote_interaction_test.go | 34 ++ internal/version/version.go | 2 +- sdk/go/tentacle/tentacle.go | 1 + sdk/python/cthulhu_tentacle/__init__.py | 7 + 12 files changed, 560 insertions(+), 30 deletions(-) create mode 100644 internal/app/tentacle_remote_connection_test.go create mode 100644 internal/tentaclewire/remote_interaction_test.go diff --git a/docs/tentacle-remote-authorization.md b/docs/tentacle-remote-authorization.md index 8e71ce6..125eb69 100644 --- a/docs/tentacle-remote-authorization.md +++ b/docs/tentacle-remote-authorization.md @@ -1,9 +1,10 @@ # Reader-owned Remote authorization 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 -registered Remote connection command or reader desktop service. This is not a -usable Remote release, and physical-keyboard approval remains unverified. +device-aware sensitive forms, and lock/handoff cancellation. A subsequent +2026-11-04 increment registers the external source-built Remote connection +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`; 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. No child RPC, install grant, certificate review, saved key, relay event or -injected shortcut is itself consent. Connection command registration and joining -the external workflow to this core procedure remain the next integration work. +injected shortcut is itself consent. A typed `Interaction.remote` can now request +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 @@ -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 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. Then exercise certificate review and Remember key with physical input. During lock and XTerm handoff, verify immediate disconnect, no revival on return, and no stale trust/approval after a failed update. Recheck ordinary entries, secret masking/paste, learn mode, remapped commands and application key delivery. -Those manual checks need an exposed connection workflow first; there is still -no Remote desktop session to launch from this increment. +The [source-command procedure](../../cthulhu-tentacles/remote/command.md) supplies +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. Other lockers/session switches are not established by this work. Positive diff --git a/docs/tentacle-sessions.md b/docs/tentacle-sessions.md index 15504ec..3ff6d81 100644 --- a/docs/tentacle-sessions.md +++ b/docs/tentacle-sessions.md @@ -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 remote control. A later [reader authorization increment](tentacle-remote-authorization.md) adds the core consent procedure, sensitive XI2 forms and lock/handoff policy. -Its connection command remains unregistered and physical-keyboard approval is -unverified. There is no output observer, keyboard service, clipboard service, +The external source-built Remote command now requests that consent through a +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 limited to development managers; reader managers require a core approval ticket. 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 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 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`, 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 package behavior remain unverified. Human diff review and relevant live acceptance are still required before submission. diff --git a/internal/app/tentacle_interaction.go b/internal/app/tentacle_interaction.go index add864d..bf112cc 100644 --- a/internal/app/tentacle_interaction.go +++ b/internal/app/tentacle_interaction.go @@ -43,6 +43,22 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo defer stopCommand() stop := context.AfterFunc(command.session.OutputContext(), cancel) 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 defer func() { 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() { 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 { copyCtx, stopCopy := context.WithTimeout(ctx, time.Second) err = writeClipboardText(copyCtx, *value.Clipboard) @@ -98,6 +134,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo if value.Form != nil { if runtime.forms == nil { runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true}) + cleanupNext = "" runtime.report("Internal UI unavailable.") return } @@ -106,6 +143,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo if openErr != nil { closeForm() runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true}) + cleanupNext = "" runtime.report("Internal UI unavailable.") return } @@ -158,6 +196,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo return } if submission.Cancelled { + cleanupNext = "" return } } @@ -216,7 +255,7 @@ func (runtime *tentacleRuntime) waitTentacleForm(ctx context.Context, command te return internalUIResult{Cancelled: true}, nil } status, err := tentaclewire.DecodeInteraction(raw) - if err != nil || status.Form != nil || status.Source != "" || status.Next != "" || status.Reader != nil { + if err != nil || status.Form != nil || status.Source != "" || status.Next != "" || status.Reader != nil || status.Remote != nil { command.session.Disable() return internalUIResult{Cancelled: true}, nil } diff --git a/internal/app/tentacle_remote_connection_test.go b/internal/app/tentacle_remote_connection_test.go new file mode 100644 index 0000000..8a476b8 --- /dev/null +++ b/internal/app/tentacle_remote_connection_test.go @@ -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 }) + }) + } +} diff --git a/internal/app/tentacle_remote_consent.go b/internal/app/tentacle_remote_consent.go index ea8f0d1..977c5fb 100644 --- a/internal/app/tentacle_remote_consent.go +++ b/internal/app/tentacle_remote_consent.go @@ -17,19 +17,12 @@ type localTentacleForms interface { OpenLocal(context.Context, interaction.Form) (<-chan internalUIResult, error) } -// authorize_remote is a core entry point, not a registered Tentacle command or -// child RPC. A future connection command must resolve the installed session and -// role before calling it. A synthetic shortcut may request a dialog but cannot -// select permissions or approve it through the local-only capture path. +// authorize_remote reserves a standalone core dialog. Interactive commands use +// authorize_remote_session with their already-reserved slot and exact session. 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) { return tentaclewire.SessionEvent{}, tentacle.ErrPermission } - forms, ok := runtime.forms.(localTentacleForms) - if !ok { - return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable - } - grants = slices.Clone(grants) runtime.mutex.Lock() var target *tentacle.Session if !runtime.disabled { @@ -44,12 +37,6 @@ func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role s if target == nil { 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 // rejected as busy cannot invalidate the dialog already on screen. select { @@ -58,6 +45,32 @@ func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role s return tentaclewire.SessionEvent{}, tentacle.ErrBusy } 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) if err != nil { return tentaclewire.SessionEvent{}, err @@ -69,7 +82,7 @@ func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role s defer stopPolicy() stopSession := context.AfterFunc(target.OutputContext(), cancel) defer stopSession() - form := remote_consent_form(id, role, grants) + form := remote_consent_form(target.TentacleID(), role, grants) replies, err := forms.OpenLocal(ctx, form) if err != nil { return tentaclewire.SessionEvent{}, err diff --git a/internal/tentacle/remote.go b/internal/tentacle/remote.go index 92538eb..1e2a353 100644 --- a/internal/tentacle/remote.go +++ b/internal/tentacle/remote.go @@ -119,6 +119,16 @@ func (session *Session) RevokeRemote() { 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 // context is necessary, not sufficient: focus, lock and output policy still apply. func (session *Session) RemoteContext(lease tentaclewire.RemoteLease, grant string) (context.Context, error) { diff --git a/internal/tentacle/remote_test.go b/internal/tentacle/remote_test.go index d8741a6..58640cf 100644 --- a/internal/tentacle/remote_test.go +++ b/internal/tentacle/remote_test.go @@ -123,6 +123,54 @@ func remoteCall(t *testing.T, session *Session, method string, params any) (json 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 { t.Helper() action, err := session.AuthorizeRemote("controlled", []string{"output-observe"}) diff --git a/internal/tentaclewire/interaction.go b/internal/tentaclewire/interaction.go index 89b9549..30c0b3c 100644 --- a/internal/tentaclewire/interaction.go +++ b/internal/tentaclewire/interaction.go @@ -25,6 +25,14 @@ type Interaction struct { Clipboard *string `json:"clipboard,omitempty"` Done bool `json:"done,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 { @@ -34,6 +42,7 @@ type Submission struct { Item *Item `json:"item,omitempty"` Error string `json:"error,omitempty"` Reader *ReaderState `json:"reader,omitempty"` + Remote *SessionEvent `json:"remote,omitempty"` } // 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" { 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 == "" { return value, ErrProtocol } diff --git a/internal/tentaclewire/remote_interaction_test.go b/internal/tentaclewire/remote_interaction_test.go new file mode 100644 index 0000000..170ad99 --- /dev/null +++ b/internal/tentaclewire/remote_interaction_test.go @@ -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) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 24ae986..472ee4b 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -21,7 +21,7 @@ import "strings" const Name = "Cthulhu" -const Version = "2026.11.03" +const Version = "2026.11.04" const Channel = "testing" var Build = Version diff --git a/sdk/go/tentacle/tentacle.go b/sdk/go/tentacle/tentacle.go index 7e6087c..5d8959b 100644 --- a/sdk/go/tentacle/tentacle.go +++ b/sdk/go/tentacle/tentacle.go @@ -34,6 +34,7 @@ type Extensions = tentaclewire.Extensions type PreferencePage = tentaclewire.PreferencePage type QuickSetting = tentaclewire.QuickSetting type ReaderRequest = tentaclewire.ReaderRequest +type RemoteRequest = tentaclewire.RemoteRequest type ReaderState = tentaclewire.ReaderState type ExtensionEvent = tentaclewire.ExtensionEvent type SpeechConfiguration = tentaclewire.SpeechConfiguration diff --git a/sdk/python/cthulhu_tentacle/__init__.py b/sdk/python/cthulhu_tentacle/__init__.py index 498dfd6..1e897f1 100644 --- a/sdk/python/cthulhu_tentacle/__init__.py +++ b/sdk/python/cthulhu_tentacle/__init__.py @@ -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 plaintext values to the continuation. Never log or present those values. Secret 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