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.
367 lines
9.3 KiB
Go
367 lines
9.3 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package tentacle
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
func (session *Session) run() {
|
|
var cleanupError error
|
|
starting := true
|
|
ready := func() {
|
|
if starting {
|
|
starting = false
|
|
<-session.manager.starting
|
|
close(session.ready)
|
|
}
|
|
}
|
|
defer func() {
|
|
session.cancel()
|
|
session.mutex.Lock()
|
|
if cleanupError != nil {
|
|
session.fail_locked(cleanupError)
|
|
// Even a requested disable must report incomplete containment.
|
|
session.state, session.failure = Failed, cleanupError
|
|
} else if session.state == Stopping {
|
|
session.state = Stopped
|
|
} else if session.state != Failed {
|
|
session.fail_locked(ErrExited)
|
|
}
|
|
session.mutex.Unlock()
|
|
session.callbacks.Wait()
|
|
// A retained status/session handle must not retain unsent argument text.
|
|
for len(session.outbound) != 0 {
|
|
<-session.outbound
|
|
}
|
|
ready()
|
|
session.manager.mutex.Lock()
|
|
if cleanupError != nil {
|
|
// Keep the admission slot and reject further starts. Do not claim
|
|
// capacity was reclaimed when owned processes may remain alive.
|
|
session.manager.cleanupError = cleanupError
|
|
} else {
|
|
delete(session.manager.sessions, session.spec.ID)
|
|
}
|
|
close(session.done)
|
|
session.manager.mutex.Unlock()
|
|
}()
|
|
select {
|
|
case <-session.stop:
|
|
return
|
|
case <-session.ctx.Done():
|
|
session.fail(session.ctx.Err())
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Use owned files instead of exec's copier goroutines. Closing our ends
|
|
// unblocks workers even when a descendant retains an inherited pipe.
|
|
var pipes []*os.File
|
|
defer func() {
|
|
for _, pipe := range pipes {
|
|
pipe.Close()
|
|
}
|
|
}()
|
|
newPipe := func() (*os.File, *os.File, error) {
|
|
reader, writer, err := os.Pipe()
|
|
if err == nil {
|
|
pipes = append(pipes, reader, writer)
|
|
}
|
|
return reader, writer, err
|
|
}
|
|
childInput, input, err := newPipe()
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
output, childOutput, err := newPipe()
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
diagnostics, childDiagnostics, err := newPipe()
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
command := exec.Command(session.spec.Program[0], session.spec.Program[1:]...)
|
|
supervised := len(session.manager.supervisorProgram) != 0
|
|
var lifetime, childLifetime, configuration, childConfiguration *os.File
|
|
if supervised {
|
|
childLifetime, lifetime, err = newPipe()
|
|
if err != nil {
|
|
session.fail(ErrSupervision)
|
|
return
|
|
}
|
|
childConfiguration, configuration, err = newPipe()
|
|
if err != nil {
|
|
session.fail(ErrSupervision)
|
|
return
|
|
}
|
|
program := session.manager.supervisorProgram
|
|
command = exec.Command(program[0], program[1:]...)
|
|
command.ExtraFiles = []*os.File{childLifetime, childConfiguration}
|
|
} else {
|
|
command.Dir = session.spec.Directory
|
|
}
|
|
command.Stdin, command.Stdout, command.Stderr = childInput, childOutput, childDiagnostics
|
|
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
var group *cgroup
|
|
if containment := session.manager.containment; containment != nil {
|
|
group, err = create_cgroup(filepath.Join(containment.group.path, session.identity), containment.limits.PerSession, 0)
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
defer func() { cleanupError = group.kill_and_remove(containment.limits.CleanupTimeout) }()
|
|
// clone3(CLONE_INTO_CGROUP) applies limits before the child executes.
|
|
// Moving an already-running PID would leave a fork/allocation race.
|
|
command.SysProcAttr.UseCgroupFD = true
|
|
command.SysProcAttr.CgroupFD = int(group.file.Fd())
|
|
}
|
|
var waitError error
|
|
exited := make(chan struct{})
|
|
err = command.Start()
|
|
if err == nil {
|
|
go func() { waitError = command.Wait(); close(exited) }()
|
|
}
|
|
|
|
if err != nil {
|
|
// Do not retain the command line, environment or child-supplied text.
|
|
if group != nil {
|
|
var errno syscall.Errno
|
|
if errors.As(err, &errno) {
|
|
session.fail(fmt.Errorf("%w: child placement/exec: %w", ErrContainment, errno))
|
|
} else {
|
|
session.fail(ErrContainment)
|
|
}
|
|
} else {
|
|
session.fail(ErrExited)
|
|
}
|
|
return
|
|
}
|
|
childInput.Close()
|
|
childOutput.Close()
|
|
childDiagnostics.Close()
|
|
var workers sync.WaitGroup
|
|
if supervised {
|
|
childLifetime.Close()
|
|
childConfiguration.Close()
|
|
workers.Add(1)
|
|
go func() {
|
|
defer workers.Done()
|
|
_ = json.NewEncoder(configuration).Encode(session.spec)
|
|
configuration.Close()
|
|
}()
|
|
}
|
|
workers.Add(3)
|
|
go func() { defer workers.Done(); session.write_requests(input) }()
|
|
go func() { defer workers.Done(); session.read_replies(output) }()
|
|
go func() { defer workers.Done(); session.drain_diagnostics(diagnostics) }()
|
|
defer func() {
|
|
session.cancel()
|
|
if supervised {
|
|
lifetime.Close()
|
|
configuration.Close()
|
|
}
|
|
if group != nil {
|
|
_ = write_control(group.path, "cgroup.kill", "1")
|
|
if command.Process != nil {
|
|
_ = command.Process.Kill()
|
|
}
|
|
} else if !supervised {
|
|
// Explicit trusted-fixture mode only; detached descendants escape.
|
|
_ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
|
|
}
|
|
input.Close()
|
|
output.Close()
|
|
diagnostics.Close()
|
|
if supervised && group == nil {
|
|
select {
|
|
case <-exited:
|
|
if waitError != nil {
|
|
cleanupError = ErrSupervision
|
|
}
|
|
case <-time.After(DefaultResourceLimits().CleanupTimeout + time.Second):
|
|
cleanupError = ErrSupervision
|
|
_ = command.Process.Kill()
|
|
<-exited
|
|
}
|
|
} else {
|
|
<-exited
|
|
}
|
|
workers.Wait()
|
|
}()
|
|
|
|
session.mutex.Lock()
|
|
var initialized <-chan Result
|
|
if session.state == Starting && session.ctx.Err() == nil {
|
|
apis := []string{"commands"}
|
|
if session.spec.Presentation {
|
|
apis = append(apis, "presentation")
|
|
}
|
|
if session.spec.Interaction {
|
|
apis = append(apis, "interaction")
|
|
}
|
|
if session.spec.ReaderSettings {
|
|
apis = append(apis, "reader-settings")
|
|
}
|
|
initialized, err = session.request_locked(session.ctx, "initialize", Initialization{
|
|
Protocol: session.spec.protocol(), Session: session.identity,
|
|
APIs: session.spec.apis(apis), Capabilities: session.spec.capabilities(apis),
|
|
}, session.manager.limits.StartupTimeout)
|
|
} else {
|
|
err = ErrUnavailable
|
|
}
|
|
session.mutex.Unlock()
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
select {
|
|
case result := <-initialized:
|
|
if result.Err != nil {
|
|
session.fail(result.Err)
|
|
return
|
|
}
|
|
var registration Registration
|
|
if decode_strict(result.Value, ®istration) != nil || validate_registration_version(registration, session.identity, session.spec.protocol()) != nil {
|
|
session.fail(ErrProtocol)
|
|
return
|
|
}
|
|
for _, command := range registration.Commands {
|
|
if command.Reader && !session.spec.ReaderSettings || command.Presentation && !session.spec.Presentation || command.Interaction && (!command.Presentation || !session.spec.Interaction) {
|
|
session.fail(ErrProtocol)
|
|
return
|
|
}
|
|
}
|
|
if registration.Extensions != nil && !session.spec.ReaderSettings {
|
|
session.fail(ErrProtocol)
|
|
return
|
|
}
|
|
session.mutex.Lock()
|
|
if session.state == Starting && session.ctx.Err() == nil {
|
|
session.commands = registration.Commands
|
|
if registration.Extensions != nil {
|
|
session.extensions = *registration.Extensions
|
|
}
|
|
session.state = Ready
|
|
}
|
|
session.mutex.Unlock()
|
|
ready()
|
|
case <-session.ctx.Done():
|
|
session.fail(session.ctx.Err())
|
|
return
|
|
case <-exited:
|
|
session.fail(ErrExited)
|
|
return
|
|
case <-session.stop:
|
|
session.wait_shutdown(exited)
|
|
return
|
|
}
|
|
select {
|
|
case <-session.ctx.Done():
|
|
session.fail(session.ctx.Err())
|
|
case <-exited:
|
|
session.fail(ErrExited)
|
|
case <-session.stop:
|
|
session.wait_shutdown(exited)
|
|
}
|
|
}
|
|
|
|
func (session *Session) wait_shutdown(exited <-chan struct{}) {
|
|
timer := time.NewTimer(session.manager.limits.ShutdownTimeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-exited:
|
|
case <-timer.C:
|
|
case <-session.ctx.Done():
|
|
}
|
|
}
|
|
|
|
func (session *Session) write_requests(writer io.Writer) {
|
|
for {
|
|
select {
|
|
case <-session.ctx.Done():
|
|
return
|
|
case item := <-session.outbound:
|
|
if item.id != "" {
|
|
session.mutex.Lock()
|
|
call := session.pending[item.id]
|
|
eligible := call != nil && call.ctx.Err() == nil
|
|
session.mutex.Unlock()
|
|
if !eligible {
|
|
session.mutex.Lock()
|
|
session.queuedBytes -= len(item.frame)
|
|
session.mutex.Unlock()
|
|
continue
|
|
}
|
|
}
|
|
n, err := writer.Write(item.frame)
|
|
if err == nil && n != len(item.frame) {
|
|
err = io.ErrShortWrite
|
|
}
|
|
session.mutex.Lock()
|
|
session.queuedBytes -= len(item.frame)
|
|
session.mutex.Unlock()
|
|
if err != nil {
|
|
session.fail(ErrExited)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (session *Session) read_replies(reader io.Reader) {
|
|
buffer := bufio.NewReaderSize(reader, 4096)
|
|
for session.ctx.Err() == nil {
|
|
frame, err := read_frame(buffer)
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
err = ErrExited
|
|
}
|
|
session.fail(err)
|
|
return
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if validate_json(frame) != nil || json.Unmarshal(frame, &fields) != nil {
|
|
session.fail(ErrProtocol)
|
|
return
|
|
}
|
|
if _, request := fields["method"]; request {
|
|
if err := session.accept_child_request(frame); err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
reply, err := decode_response(frame)
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
session.accept_reply(reply)
|
|
}
|
|
}
|
|
|
|
func (session *Session) drain_diagnostics(reader io.Reader) {
|
|
count, err := io.Copy(io.Discard, io.LimitReader(reader, MaxDiagnosticBytes+1))
|
|
if count > MaxDiagnosticBytes {
|
|
session.fail(ErrLimit)
|
|
} else if err != nil && session.ctx.Err() == nil {
|
|
session.fail(ErrExited)
|
|
}
|
|
}
|