Files
ak47 573d15d7b4 Add Remote session foundations and feasibility checks
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.
2026-11-03 22:33:26 -05:00

503 lines
18 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
type packageTransport func(*http.Request) (*http.Response, error)
func (transport packageTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport(request)
}
type archiveMember struct {
name string
data []byte
kind byte
mode int64
}
func package_archive(t *testing.T, members []archiveMember) []byte {
t.Helper()
var output bytes.Buffer
compressed := gzip.NewWriter(&output)
archive := tar.NewWriter(compressed)
for _, member := range members {
if member.kind == 0 {
member.kind = tar.TypeReg
}
header := &tar.Header{Name: member.name, Mode: member.mode, Size: int64(len(member.data)), Typeflag: member.kind}
if member.kind == tar.TypeSymlink || member.kind == tar.TypeLink {
header.Linkname = "../../outside"
}
if err := archive.WriteHeader(header); err != nil {
t.Fatal(err)
}
if _, err := archive.Write(member.data); err != nil {
t.Fatal(err)
}
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
if err := compressed.Close(); err != nil {
t.Fatal(err)
}
return output.Bytes()
}
func package_manifest() string {
return "format-version=1\nid='echo'\nname='Echo'\nversion='0.1'\nruntime='native'\nplatforms=['linux']\nentry-point=['bin/echo']\napis=['commands','presentation']\ncapabilities=['presentation']\n[protocol]\nmajor=1\nminor=0\n"
}
func package_fixture(t *testing.T, data []byte) (Repository, *http.Client) {
t.Helper()
digest := sha256.Sum256(data)
repository := Repository{URL: "https://example.org/team/tentacles", ID: "example.tentacles", Name: "Example", Revision: strings.Repeat("a", 40),
Tentacles: []Manifest{{FormatVersion: 1, ID: "echo", Name: "Echo", Version: "0.1", Capabilities: []string{"presentation"},
Releases: []Release{{Platform: runtime.GOOS, Architecture: runtime.GOARCH, URL: "https://example.org/releases/echo.tar.gz", SHA256: fmt.Sprintf("%x", digest)}}}}}
client := &http.Client{Transport: packageTransport(func(request *http.Request) (*http.Response, error) {
if err := request.Context().Err(); err != nil {
return nil, err
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(data)), ContentLength: int64(len(data)), Header: make(http.Header)}, nil
})}
return repository, client
}
func native_members(t *testing.T) []archiveMember {
t.Helper()
filename, err := os.Executable()
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filename)
if err != nil {
t.Fatal(err)
}
return []archiveMember{{name: "tentacle.toml", data: []byte(package_manifest()), mode: 0644}, {name: "bin/echo", data: data, mode: 0755}}
}
func TestPackageInstallPinsIdentityPermissionsAndSurvivesRestart(t *testing.T) {
data := package_archive(t, native_members(t))
repository, client := package_fixture(t, data)
root := filepath.Join(t.TempDir(), "packages")
prepared, err := prepare_package(context.Background(), root, repository, "echo", client)
if err != nil {
t.Fatal(err)
}
defer prepared.Discard()
entry := prepared.Entry
if entry.Enabled || !entry.Presentation || entry.ID != PackageID(repository, "echo") || entry.RepositoryRevision != repository.Revision || entry.PackageSHA256 != repository.Tentacles[0].Releases[0].SHA256 || filepath.Dir(entry.Program[0]) != filepath.Join(entry.PackageDirectory, "bin") {
t.Fatal(entry)
}
other := repository
other.URL = "https://other.example.org/team/tentacles"
if PackageID(other, "echo") == entry.ID {
t.Fatal("different origins share identity")
}
registry := &Registry{Path: filepath.Join(t.TempDir(), "tentacles.toml")}
if _, err := registry.Load(); err != nil {
t.Fatal(err)
}
if err := registry.SaveState([]Entry{entry}, []Repository{repository}); err != nil {
t.Fatal(err)
}
prepared.Retain()
reopened := &Registry{Path: registry.Path}
entries, err := reopened.Load()
if err != nil || len(entries) != 1 || entries[0].PackageSHA256 != entry.PackageSHA256 {
t.Fatalf("restart %v %v", entries, err)
}
if err := RemovePackage(root, entries[0]); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(entry.PackageDirectory); !os.IsNotExist(err) {
t.Fatal("package files retained", err)
}
}
func TestPackageRejectsBadDownloadsAndManifestsWithoutFiles(t *testing.T) {
for _, problem := range []string{"digest", "status", "unsupported architecture", "version", "grant", "runtime", "protocol", "api", "entry traversal", "missing executable", "not executable", "not ELF", "cancelled", "corrupt gzip"} {
t.Run(problem, func(t *testing.T) {
members := native_members(t)
switch problem {
case "version":
members[0].data = []byte(strings.Replace(package_manifest(), "version='0.1'", "version='0.2'", 1))
case "grant":
members[0].data = []byte(strings.Replace(package_manifest(), "capabilities=['presentation']", "capabilities=[]", 1))
case "runtime":
members[0].data = []byte(strings.Replace(package_manifest(), "runtime='native'", "runtime='python'", 1))
case "protocol":
members[0].data = []byte(strings.Replace(package_manifest(), "major=1", "major=2", 1))
case "api":
members[0].data = []byte(strings.Replace(package_manifest(), "'commands','presentation'", "'commands','focused-text'", 1))
case "entry traversal":
members[0].data = []byte(strings.Replace(package_manifest(), "bin/echo", "../echo", 1))
case "missing executable":
members = members[:1]
case "not executable":
members[1].mode = 0644
case "not ELF":
members[1].data = []byte("#!/bin/sh\nexit 0\n")
}
data := package_archive(t, members)
if problem == "corrupt gzip" {
data[len(data)-8] ^= 0xff
}
repository, client := package_fixture(t, data)
if problem == "digest" {
repository.Tentacles[0].Releases[0].SHA256 = strings.Repeat("0", 64)
}
if problem == "unsupported architecture" {
repository.Tentacles[0].Releases[0].Architecture = "unknown"
}
if problem == "status" {
client.Transport = packageTransport(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("missing"))}, nil
})
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if problem == "cancelled" {
cancel()
}
root := filepath.Join(t.TempDir(), "packages")
if prepared, err := prepare_package(ctx, root, repository, "echo", client); err == nil {
prepared.Discard()
t.Fatal("accepted", problem)
}
files, err := os.ReadDir(root)
if !os.IsNotExist(err) && err != nil {
t.Fatal(err)
}
if len(files) != 0 {
t.Fatal("failed install left files", files)
}
})
}
}
func TestPackageExtractionRejectsEscapesLinksConflictsAndExpansion(t *testing.T) {
cases := [][]archiveMember{
{{name: "../outside", data: []byte("bad")}}, {{name: "/absolute", data: []byte("bad")}},
{{name: "bin/link", kind: tar.TypeSymlink}}, {{name: "bin/link", kind: tar.TypeLink}},
{{name: "device", kind: tar.TypeFifo}}, {{name: "same"}, {name: "same"}},
{{name: "bin", data: []byte("file")}, {name: "bin/child"}},
{{name: "bin\\escape"}}, {{name: "./relative"}},
{{name: "huge", data: bytes.Repeat([]byte{0}, maxExpandedBytes+1)}},
}
for index, members := range cases {
t.Run(fmt.Sprint(index), func(t *testing.T) {
if err := extract_package(context.Background(), bytes.NewReader(package_archive(t, members)), t.TempDir()); err == nil {
t.Fatal("unsafe archive accepted")
}
})
}
}
func TestPackageRemovalPreservesLocalFilesAndRejectsForeignDirectories(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
if err := RemovePackage(root, Entry{Program: []string{filepath.Join(outside, "local")}}); err != nil {
t.Fatal(err)
}
if err := RemovePackage(root, Entry{PackageDirectory: outside}); err == nil {
t.Fatal("outside path accepted")
}
link := filepath.Join(root, "package-link")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
if err := RemovePackage(root, Entry{PackageDirectory: link}); err == nil {
t.Fatal("symlink accepted")
}
if _, err := os.Stat(outside); err != nil {
t.Fatal("outside removed", err)
}
}
func TestReleaseCatalogValidationAndDeepClone(t *testing.T) {
files := repository_files()
files["catalog.toml"] += "[[tentacles.releases]]\nplatform='linux'\narchitecture='amd64'\nurl='https://example.org/release.tar.gz'\nsha256='" + strings.Repeat("a", 64) + "'\n"
repository, err := read_repository_fixture(files)
if err != nil {
t.Fatal(err)
}
clone := CloneRepositories([]Repository{repository})
clone[0].Tentacles[0].Releases[0].SHA256 = "changed"
clone[0].Tentacles[0].Capabilities[0] = "changed"
if repository.Tentacles[0].Releases[0].SHA256 == "changed" || repository.Tentacles[0].Capabilities[0] == "changed" {
t.Fatal("shared catalog storage")
}
for _, value := range []string{"http://example.org/archive", "https://Username@example.org/archive", "https://example.org/archive#fragment"} {
repository.Tentacles[0].Releases[0].URL = value
if ValidateRepositories([]Repository{repository}) == nil {
t.Fatal("unsafe URL accepted", value)
}
}
}
func TestPackageRecoveryKeepsSelectedFilesAndLocalReferences(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"package-123", "package-456", ".staging-789", "package-user", "package-321"} {
if err := os.Mkdir(filepath.Join(root, name), 0700); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(root, ".download-567"), []byte("partial"), 0600); err != nil {
t.Fatal(err)
}
entries := []Entry{{PackageDirectory: filepath.Join(root, "package-123")}, {Program: []string{filepath.Join(root, "package-321", "bin", "local")}}}
if err := RecoverPackages(root, entries); err != nil {
t.Fatal(err)
}
for _, name := range []string{"package-123", "package-user", "package-321"} {
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
t.Fatal("selected/local files lost", err)
}
}
for _, name := range []string{"package-456", ".staging-789", ".download-567"} {
if _, err := os.Stat(filepath.Join(root, name)); !os.IsNotExist(err) {
t.Fatal("orphan retained", name, err)
}
}
}
func TestPackageRedirectsRejectHTTPAndCredentials(t *testing.T) {
for _, location := range []string{"http://example.org/archive", "https://Username@example.org/archive"} {
t.Run(location, func(t *testing.T) {
client := new_package_client()
calls := 0
client.Transport = packageTransport(func(request *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 302, Header: http.Header{"Location": []string{location}}, Body: io.NopCloser(strings.NewReader("")), Request: request}, nil
})
if response, err := client.Get("https://example.org/release"); err == nil {
response.Body.Close()
t.Fatal("unsafe redirect accepted")
}
if calls != 1 {
t.Fatal("followed unsafe redirect", calls)
}
})
}
}
func TestBuiltHelloWorldPackageThroughHost(t *testing.T) {
root := os.Getenv("CTHULHU_TENTACLE_RELEASE_ROOT")
if root == "" {
t.Skip("set CTHULHU_TENTACLE_RELEASE_ROOT to validate prepared release artifacts")
}
repository, err := read_repository("https://example.org/team/tentacles", strings.Repeat("a", 40), func(filename string) ([]byte, error) { return os.ReadFile(filepath.Join(root, filename)) })
if err != nil {
t.Fatal(err)
}
var manifest Manifest
for _, value := range repository.Tentacles {
if value.ID == "hello-world" {
manifest = value
}
}
index := -1
for item, release := range manifest.Releases {
if release.Architecture == runtime.GOARCH && release.Platform == runtime.GOOS {
index = item
}
}
if index < 0 {
t.Fatal("no release for host")
}
data, err := os.ReadFile(filepath.Join(root, "dist", "hello-world-"+manifest.Version+"-linux-"+runtime.GOARCH+".tar.gz"))
if err != nil {
t.Fatal(err)
}
client := new_package_client()
client.Transport = packageTransport(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(data)), Header: make(http.Header)}, nil
})
prepared, err := prepare_package(context.Background(), filepath.Join(t.TempDir(), "packages"), repository, "hello-world", client)
if err != nil {
t.Fatal(err)
}
defer prepared.Discard()
manager, err := NewManager(DefaultLimits(), nil)
if err != nil {
t.Fatal(err)
}
defer manager.Shutdown(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := manager.Start(ctx, prepared.Entry.Spec())
if err != nil {
t.Fatal(err)
}
select {
case <-session.Ready():
case <-ctx.Done():
t.Fatal(ctx.Err())
}
if err := session.Activate(); err != nil {
t.Fatal(err)
}
pending, err := session.Invoke(ctx, "hello-world", nil)
if err != nil {
t.Fatal(err)
}
select {
case result := <-pending:
if result.Err != nil {
t.Fatal(result.Err)
}
presentation, err := tentaclewire.DecodePresentation(result.Value)
if err != nil || presentation.Speech != "Hello world!" || presentation.Braille != "Hello world!" || !presentation.Uninterruptible {
t.Fatal(presentation, err)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
func TestInstallInteractivePackageGrant(t *testing.T) {
members := native_members(t)
for index := range members {
if members[index].name == "tentacle.toml" {
members[index].data = []byte(strings.ReplaceAll(strings.ReplaceAll(package_manifest(), "apis=['commands','presentation']", "apis=['commands','presentation','interaction']"), "capabilities=['presentation']", "capabilities=['presentation','interaction']"))
}
}
data := package_archive(t, members)
repository, client := package_fixture(t, data)
repository.Tentacles[0].Capabilities = []string{"presentation", "interaction"}
prepared, err := prepare_package(context.Background(), t.TempDir(), repository, "echo", client)
if err != nil {
t.Fatal(err)
}
defer prepared.Discard()
if !prepared.Entry.Interaction || !prepared.Entry.Spec().Interaction || !prepared.Entry.Presentation {
t.Fatal(prepared.Entry)
}
}
func TestInstallReaderSettingsPackageGrantAndDependencies(t *testing.T) {
for _, interactive := range []bool{true, false} {
t.Run(fmt.Sprintf("interaction=%t", interactive), func(t *testing.T) {
capabilities := "'presentation','reader-settings'"
apis := "'commands','presentation','reader-settings'"
granted := []string{"presentation", "reader-settings"}
if interactive {
capabilities = "'presentation','interaction','reader-settings'"
apis = "'commands','presentation','interaction','reader-settings'"
granted = []string{"presentation", "interaction", "reader-settings"}
}
members := native_members(t)
manifest := strings.ReplaceAll(package_manifest(), "apis=['commands','presentation']", "apis=["+apis+"]")
manifest = strings.ReplaceAll(manifest, "capabilities=['presentation']", "capabilities=["+capabilities+"]")
members[0].data = []byte(manifest)
repository, client := package_fixture(t, package_archive(t, members))
repository.Tentacles[0].Capabilities = granted
prepared, err := prepare_package(t.Context(), t.TempDir(), repository, "echo", client)
if !interactive {
if err == nil {
prepared.Discard()
t.Fatal("reader settings installed without interaction permission")
}
return
}
if err != nil {
t.Fatal(err)
}
defer prepared.Discard()
if !prepared.Entry.ReaderSettings || !prepared.Entry.Spec().ReaderSettings {
t.Fatal("reader grant not carried into runtime spec")
}
registry := &Registry{Path: filepath.Join(t.TempDir(), "tentacles.toml")}
if _, err := registry.Load(); err != nil {
t.Fatal(err)
}
if err := registry.Save([]Entry{prepared.Entry}); err != nil {
t.Fatal(err)
}
loaded, err := (&Registry{Path: registry.Path}).Load()
if err != nil || len(loaded) != 1 || !loaded[0].ReaderSettings {
t.Fatal("reader grant lost across restart", loaded, err)
}
})
}
}
func TestSessionPackageGrantsPersistAndRejectMismatch(t *testing.T) {
for _, variant := range []string{"valid", "missing API", "unknown grant", "duplicate grant", "old version"} {
t.Run(variant, func(t *testing.T) {
members := native_members(t)
manifest := strings.Replace(package_manifest(), "minor=0", "minor=1", 1)
manifest = strings.Replace(manifest, "apis=['commands','presentation']", "apis=['remote-access','session-events','presentation','commands']", 1)
capabilities := []string{"presentation", "output-observe"}
grantText := "capabilities=['presentation','output-observe']"
switch variant {
case "missing API":
manifest = strings.Replace(manifest, "'remote-access',", "", 1)
case "unknown grant":
capabilities = append(capabilities, "execute")
grantText = "capabilities=['presentation','output-observe','execute']"
case "duplicate grant":
capabilities = append(capabilities, "output-observe")
grantText = "capabilities=['presentation','output-observe','output-observe']"
case "old version":
manifest = strings.Replace(manifest, "minor=1", "minor=0", 1)
}
manifest = strings.Replace(manifest, "capabilities=['presentation']", grantText, 1)
members[0].data = []byte(manifest)
repository, client := package_fixture(t, package_archive(t, members))
repository.Tentacles[0].Capabilities = capabilities
prepared, err := prepare_package(t.Context(), filepath.Join(t.TempDir(), "packages"), repository, "echo", client)
if variant != "valid" {
if err == nil {
prepared.Discard()
t.Fatal("unsupported access installed")
}
return
}
if err != nil {
t.Fatal(err)
}
defer prepared.Discard()
entry := prepared.Entry
if !entry.SessionEvents || len(entry.RemoteGrants) != 1 || entry.RemoteGrants[0] != "output-observe" {
t.Fatal("lost grants", entry)
}
registry := &Registry{Path: filepath.Join(t.TempDir(), "registry.toml")}
if _, err := registry.Load(); err != nil {
t.Fatal(err)
}
if err := registry.Save([]Entry{entry}); err != nil {
t.Fatal(err)
}
entries, err := registry.Load()
if err != nil || len(entries) != 1 || !entries[0].Spec().SessionEvents || len(entries[0].Spec().RemoteGrants) != 1 {
t.Fatal("restart lost grants", err)
}
clone := CloneEntries(entries)
clone[0].RemoteGrants[0] = "keyboard-inject"
if entries[0].RemoteGrants[0] != "output-observe" {
t.Fatal("shared grant slice")
}
})
}
}