Add opt-in Tentacle protocol 1.1, explicit remote grants, lifecycle IPC and expiring core leases in both SDKs. Revoke access before update preparation, including failed updates. Add transient secret entries, shared Braille cell export and isolated Xorg recovery experiments with focused tests and documented limitations. Production consent, desktop services and live interoperability remain pending.
302 lines
11 KiB
Go
302 lines
11 KiB
Go
package tentacle
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle"
|
|
)
|
|
|
|
func TestSDKChild(t *testing.T) {
|
|
if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "sdk-child" {
|
|
return
|
|
}
|
|
cancelFile := os.Args[len(os.Args)-1]
|
|
started := make(chan struct{})
|
|
var once sync.Once
|
|
commands := []sdk.Command{
|
|
{ID: "echo", Title: "Echo", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { return args, nil }},
|
|
{ID: "fail", Title: "Fail", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { panic("private handler details") }},
|
|
{ID: "large", Title: "Large", Handle: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
return strings.Repeat("x", MaxFrameBytes), nil
|
|
}},
|
|
{ID: "wait", Title: "Wait", Handle: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
once.Do(func() { close(started) })
|
|
<-ctx.Done()
|
|
return nil, os.WriteFile(cancelFile, []byte("cancelled"), 0600)
|
|
}},
|
|
{ID: "started", Title: "Started", Handle: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
select {
|
|
case <-started:
|
|
return true, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}},
|
|
}
|
|
if filepath.Base(cancelFile) == "presentation" {
|
|
commands = append(commands, sdk.Command{ID: "present", Title: "Present", Presentation: true,
|
|
Handle: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
return sdk.Presentation{Speech: "Hello, é猫😀é", Braille: "Display é猫😀é", Uninterruptible: true}, nil
|
|
}})
|
|
}
|
|
if filepath.Base(cancelFile) == "secret" {
|
|
commands = append(commands,
|
|
sdk.Command{ID: "secret", Title: "Secret", Presentation: true, Interaction: true,
|
|
Handle: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
return sdk.Interaction{Form: &sdk.Form{Title: "Connection", Elements: []sdk.Element{
|
|
{ID: "key", Label: "Channel key", Kind: sdk.Entry, Secret: true, Required: true},
|
|
}}, Next: "submit"}, nil
|
|
}},
|
|
sdk.Command{ID: "submit", Title: "Submit", Presentation: true, Interaction: true,
|
|
Handle: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var submission sdk.Submission
|
|
err := json.Unmarshal(args, &submission)
|
|
return submission.Values["key"].Text == "fixture猫", err
|
|
}},
|
|
)
|
|
}
|
|
if sdk.Serve(os.Stdin, os.Stdout, commands) != nil {
|
|
os.Exit(1)
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
|
|
func sdk_program(t *testing.T, language, cancelFile string) []string {
|
|
t.Helper()
|
|
if language == "go" {
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return []string{executable, "-test.run=^TestSDKChild$", "--", "sdk-child", cancelFile}
|
|
}
|
|
python, err := exec.LookPath("python3")
|
|
if err != nil {
|
|
t.Skip("Python 3 not installed")
|
|
}
|
|
peer, err := filepath.Abs("testdata/sdk_peer.py")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return []string{python, "-B", peer, cancelFile}
|
|
}
|
|
|
|
func TestSDKHostConformance(t *testing.T) {
|
|
for _, language := range []string{"go", "python"} {
|
|
for _, ending := range []string{"disable", "cancel"} {
|
|
t.Run(language+"/"+ending, func(t *testing.T) {
|
|
cancelFile := filepath.Join(t.TempDir(), "cancelled")
|
|
limits := DefaultLimits()
|
|
limits.RequestTimeout = 5 * time.Second
|
|
manager := test_manager(t, limits, nil)
|
|
session, err := manager.Start(context.Background(), Spec{ID: "sdk", Program: sdk_program(t, language, cancelFile)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wait_signal(t, session.Ready())
|
|
if state, err := session.Status(); state != Ready || err != nil {
|
|
t.Fatalf("%s: %v", state, err)
|
|
}
|
|
if _, err := session.Invoke(context.Background(), "echo", nil); !errors.Is(err, ErrUnavailable) {
|
|
t.Fatal("preactivation dispatch", err)
|
|
}
|
|
if err := session.Activate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
invoke := func(command string, args json.RawMessage) Result {
|
|
t.Helper()
|
|
pending, err := session.Invoke(context.Background(), command, args)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return wait_result(t, pending)
|
|
}
|
|
for _, raw := range []string{`{"text":"é猫😀é","nested":[true,null,42]}`, `null`, `[1,"two"]`} {
|
|
result := invoke("echo", json.RawMessage(raw))
|
|
if result.Err != nil || string(result.Value) != raw || result.Session != session.Identity() {
|
|
t.Fatalf("round trip: %+v", result)
|
|
}
|
|
}
|
|
// Fill the default host window repeatedly. Responses can arrive
|
|
// out of order; each caller must receive its own arguments.
|
|
for round := 0; round < 20; round++ {
|
|
replies := make([]<-chan Result, 8)
|
|
for index := range replies {
|
|
args, _ := json.Marshal(map[string]int{"index": index})
|
|
replies[index], err = session.Invoke(context.Background(), "echo", args)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for index, reply := range replies {
|
|
result := wait_result(t, reply)
|
|
var value struct {
|
|
Index int `json:"index"`
|
|
}
|
|
if result.Err != nil || json.Unmarshal(result.Value, &value) != nil || value.Index != index {
|
|
t.Fatalf("correlation: %+v", result)
|
|
}
|
|
}
|
|
}
|
|
for _, command := range []string{"fail", "large"} {
|
|
result := invoke(command, nil)
|
|
var rpcError *RPCError
|
|
if !errors.As(result.Err, &rpcError) || rpcError.Code != -32603 || strings.Contains(rpcError.Message, "private") {
|
|
t.Fatalf("unsafe error: %+v", result)
|
|
}
|
|
}
|
|
if result := invoke("echo", nil); result.Err != nil {
|
|
t.Fatal("session failed after handler error", result.Err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
pending, err := session.Invoke(ctx, "wait", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result := invoke("started", nil); result.Err != nil || string(result.Value) != "true" {
|
|
t.Fatal("concurrent handler failed", result)
|
|
}
|
|
if ending == "disable" {
|
|
session.Disable()
|
|
} else {
|
|
cancel()
|
|
}
|
|
if result := wait_result(t, pending); result.Err == nil {
|
|
t.Fatal("late success", result)
|
|
}
|
|
wait_signal(t, session.Done())
|
|
if ending == "disable" {
|
|
if data, err := os.ReadFile(cancelFile); err != nil || string(data) != "cancelled" {
|
|
t.Fatalf("SDK did not cancel handler: %q, %v", data, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSDKPresentationGrantAndRevocation(t *testing.T) {
|
|
for _, language := range []string{"go", "python"} {
|
|
for _, grant := range []bool{false, true} {
|
|
t.Run(language+"/grant="+strconv.FormatBool(grant), func(t *testing.T) {
|
|
manager := test_manager(t, DefaultLimits(), nil)
|
|
program := sdk_program(t, language, filepath.Join(t.TempDir(), "presentation"))
|
|
session, err := manager.Start(context.Background(), Spec{ID: "sdk", Program: program, Presentation: grant})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wait_signal(t, session.Ready())
|
|
state, err := session.Status()
|
|
if !grant {
|
|
if state != Failed || err == nil {
|
|
t.Fatal("ungranted presentation registration succeeded")
|
|
}
|
|
return
|
|
}
|
|
if state != Ready || err != nil {
|
|
t.Fatalf("%s: %v", state, err)
|
|
}
|
|
if err := session.Activate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pending, err := session.Invoke(context.Background(), "present", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result := wait_result(t, pending)
|
|
value, err := tentaclewire.DecodePresentation(result.Value)
|
|
if result.Err != nil || err != nil || value.Speech != "Hello, é猫😀é" || value.Braille != "Display é猫😀é" || !value.Uninterruptible {
|
|
t.Fatalf("presentation: %+v, %v", result, err)
|
|
}
|
|
if session.OutputContext().Err() != nil {
|
|
t.Fatal("reply completion revoked output")
|
|
}
|
|
session.Disable()
|
|
if session.OutputContext().Err() == nil {
|
|
t.Fatal("disable left buffered output live")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSDKRejectsInvalidHostMessages(t *testing.T) {
|
|
initialize := `{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocol":{"major":1,"minor":0},"session":"test","apis":["commands"],"capabilities":["commands"]}}` + "\n"
|
|
activate := `{"jsonrpc":"2.0","method":"initialized","params":{"session":"test"}}` + "\n"
|
|
invoke := `{"jsonrpc":"2.0","id":"2","method":"command.invoke","params":{"command":"echo","arguments":{}}}` + "\n"
|
|
cases := map[string]string{
|
|
"before activation": initialize + invoke,
|
|
"wrong session": initialize + strings.Replace(activate, "test", "other", 1),
|
|
"null version": strings.Replace(initialize, `"minor":0`, `"minor":null`, 1),
|
|
"null ID": `{"jsonrpc":"2.0","id":null,"method":"shutdown"}` + "\n",
|
|
"wrong version": strings.Replace(initialize, `"major":1`, `"major":2`, 1),
|
|
"duplicate registration": initialize + initialize,
|
|
"duplicate request": initialize + activate + invoke + invoke,
|
|
"duplicate key": `{"jsonrpc":"2.0","jsonrpc":"2.0","method":"shutdown"}` + "\n",
|
|
"oversize": strings.Repeat("x", MaxFrameBytes+1) + "\n",
|
|
"unterminated": strings.TrimSuffix(initialize, "\n"),
|
|
}
|
|
for _, language := range []string{"go", "python"} {
|
|
for name, input := range cases {
|
|
t.Run(language+"/"+name, func(t *testing.T) {
|
|
program := sdk_program(t, language, filepath.Join(t.TempDir(), "cancelled"))
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
command := exec.CommandContext(ctx, program[0], program[1:]...)
|
|
command.Stdin = strings.NewReader(input)
|
|
if err := command.Run(); err == nil {
|
|
t.Fatal("invalid input accepted")
|
|
}
|
|
if ctx.Err() != nil {
|
|
t.Fatal("SDK hung on invalid input")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSDKSecretFormConformance(t *testing.T) {
|
|
for _, language := range []string{"go", "python"} {
|
|
t.Run(language, func(t *testing.T) {
|
|
manager := test_manager(t, DefaultLimits(), nil)
|
|
session, err := manager.Start(context.Background(), Spec{ID: "sdk", Program: sdk_program(t, language, filepath.Join(t.TempDir(), "secret")), Presentation: true, Interaction: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wait_signal(t, session.Ready())
|
|
if err := session.Activate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
invoke := func(command string, args json.RawMessage) Result {
|
|
t.Helper()
|
|
pending, err := session.Invoke(context.Background(), command, args)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return wait_result(t, pending)
|
|
}
|
|
result := invoke("secret", nil)
|
|
value, err := tentaclewire.DecodeInteraction(result.Value)
|
|
if result.Err != nil || err != nil || value.Form == nil || !value.Form.Elements[0].Secret {
|
|
t.Fatal("SDK did not preserve the secret declaration")
|
|
}
|
|
result = invoke(value.Next, json.RawMessage(`{"activated":"key","values":{"key":{"text":"fixture猫"}}}`))
|
|
if result.Err != nil || string(result.Value) != "true" {
|
|
t.Fatal("SDK did not deliver the secret to the continuation")
|
|
}
|
|
})
|
|
}
|
|
}
|