161 lines
5.5 KiB
Go
161 lines
5.5 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// laneSock stands the machine's bastion up on a real unix socket and points
|
|
// this process's reads at it, as an agent beside the system service sees it.
|
|
func laneSock(t *testing.T) {
|
|
t.Helper()
|
|
sock := filepath.Join(t.TempDir(), "ask.sock")
|
|
ln, err := net.Listen("unix", sock)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The globals are set before the server goroutine starts and restored only
|
|
// after it and every connection it served have returned: a client's
|
|
// connect is no happens-before edge the race detector sees, so a server
|
|
// that read them unordered with these writes is a data race under -race.
|
|
prevPath, prevLane, prevID := readSockPath, readsGoThroughLane, askPeerIdentityFn
|
|
t.Cleanup(func() { readSockPath, readsGoThroughLane, askPeerIdentityFn = prevPath, prevLane, prevID })
|
|
readSockPath = func() string { return sock }
|
|
readsGoThroughLane = func() bool { return true }
|
|
// The kernel's answer, with this process's own uid read as laneTestUID:
|
|
// the suite runs as root, which is never a machine's owner account.
|
|
askPeerIdentityFn = func(c net.Conn) (uint32, string, error) {
|
|
uid, who, err := contextPeerIdentity(c)
|
|
if err == nil && uid == uint32(os.Getuid()) {
|
|
uid = laneTestUID
|
|
}
|
|
return uid, who, err
|
|
}
|
|
var served sync.WaitGroup
|
|
served.Add(1)
|
|
t.Cleanup(func() { _ = ln.Close(); served.Wait() })
|
|
go func() {
|
|
defer served.Done()
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
served.Add(1)
|
|
go func() { defer served.Done(); askSockServe(c) }()
|
|
}
|
|
}()
|
|
}
|
|
|
|
const laneTestUID = 1000
|
|
|
|
// The owner's agent, with no sudo and no browser, reads the machine's status,
|
|
// agents and activity from its bastion; the kernel's account decides it.
|
|
func TestTheOwnersAgentReadsTheMachineThroughTheLane(t *testing.T) {
|
|
askFlowWorld(t)
|
|
laneOwner(t, laneTestUID)
|
|
laneSock(t)
|
|
out := captureStdout(t)
|
|
|
|
if err := cmdStatus([]string{"--json"}); err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
var s statusRead
|
|
if err := json.Unmarshal([]byte(printedSince(out, 0, "}\n")), &s); err != nil || s.Version != clientVersion {
|
|
t.Fatalf("status --json printed %q (%v)", out(), err)
|
|
}
|
|
before := len(out())
|
|
if err := cmdAgents([]string{"--json"}); err != nil {
|
|
t.Fatalf("agents: %v", err)
|
|
}
|
|
got := printedSince(out, before, "untrusted text")
|
|
if !strings.Contains(got, `"signature": "aider"`) || !strings.Contains(got, `"signature": "claude-code"`) {
|
|
t.Errorf("agents through the lane printed %q", got)
|
|
}
|
|
before = len(out())
|
|
if err := cmdActivity(nil); err != nil {
|
|
t.Fatalf("activity: %v", err)
|
|
}
|
|
if got := printedSince(out, before, "\n"); got == "" {
|
|
t.Error("activity through the lane printed nothing")
|
|
}
|
|
}
|
|
|
|
// Another account is told why and how the administrator reads it; the reads
|
|
// that mean something with its own permissions fall back to those.
|
|
func TestAnotherAccountIsRefusedTheMachinesReads(t *testing.T) {
|
|
askFlowWorld(t)
|
|
laneOwner(t, laneTestUID+1)
|
|
laneSock(t)
|
|
err := cmdStatus(nil)
|
|
if err == nil || !strings.Contains(err.Error(), "answers reads only for its owner's account") ||
|
|
!strings.Contains(err.Error(), "sudo -H privatae-bastion status") {
|
|
t.Errorf("status from another account: %v", err)
|
|
}
|
|
if err := cmdReport([]string{"--out", "-"}); err == nil || !strings.Contains(err.Error(), "sudo -H privatae-bastion report") {
|
|
t.Errorf("report from another account: %v", err)
|
|
}
|
|
out := captureStdout(t)
|
|
if err := cmdSchedules(nil); err != nil {
|
|
t.Errorf("schedules from another account did not fall back to its own: %v", err)
|
|
}
|
|
if printedSince(out, 0, "\n") == "" {
|
|
t.Error("schedules printed nothing")
|
|
}
|
|
}
|
|
|
|
// Installed for the whole machine, with nothing answering on its socket:
|
|
// status says so instead of failing.
|
|
func TestStatusSaysTheServiceIsNotAnswering(t *testing.T) {
|
|
uiTestConfigDir(t)
|
|
prevPath, prevLane := readSockPath, readsGoThroughLane
|
|
t.Cleanup(func() { readSockPath, readsGoThroughLane = prevPath, prevLane })
|
|
readSockPath = func() string { return filepath.Join(t.TempDir(), "nothing.sock") }
|
|
readsGoThroughLane = func() bool { return true }
|
|
out := captureStdout(t)
|
|
if err := cmdStatus(nil); err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
if got := printedSince(out, 0, "status privatae-bastion"); !strings.Contains(got, "NOT ANSWERING") ||
|
|
!strings.Contains(got, "systemctl status privatae-bastion") {
|
|
t.Errorf("status printed %q", got)
|
|
}
|
|
}
|
|
|
|
// report --out - writes the report to standard output.
|
|
func TestReportToStandardOutput(t *testing.T) {
|
|
homeTestWorld(t, true)
|
|
out := captureStdout(t)
|
|
if err := cmdReport([]string{"--out", "-"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := printedSince(out, 0, "No AI model wrote any of this report."); !strings.Contains(got, "# AI agents on ") {
|
|
t.Errorf("report --out - printed %q", got)
|
|
}
|
|
}
|
|
|
|
// An answer larger than the asking side reads is refused by the daemon with
|
|
// the reason, not cut off mid-answer, which the asker read as a bastion that
|
|
// does not answer at all (and status as NOT ANSWERING).
|
|
func TestAnAnswerTooLargeToReadIsRefusedWithTheReason(t *testing.T) {
|
|
askFlowWorld(t)
|
|
laneOwner(t, laneTestUID)
|
|
laneSock(t)
|
|
machineReads["test-huge"] = machineRead{build: func(readOpts) (any, error) {
|
|
return strings.Repeat("x", readMaxBytes), nil
|
|
}}
|
|
t.Cleanup(func() { delete(machineReads, "test-huge") })
|
|
var v string
|
|
err := readViaSock("test-huge", readOpts{}, &v)
|
|
var le *laneError
|
|
if !errors.As(err, &le) || le.down || !strings.Contains(le.why, "--since") {
|
|
t.Errorf("a read too large for the asker: %#v", err)
|
|
}
|
|
}
|