Tentacle support enhancements.
This commit is contained in:
1 parent
4cf3335017
commit
24261edb3d
12 files changed
+560
-30
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 Version = "2026.11.03"
|
||||
const Version = "2026.11.04"
|
||||
const Channel = "testing"
|
||||
|
||||
var Build = Version
|
||||
|
||||
Reference in new issue
Block a user