add black-box debug harness
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ spike-report-*.txt
|
|||||||
/.zig-cache/
|
/.zig-cache/
|
||||||
/bin/
|
/bin/
|
||||||
/spike
|
/spike
|
||||||
|
/.worktrees/
|
||||||
|
internal/harness/.artifacts/
|
||||||
|
|||||||
47
cmd/patterm/debug_harness.go
Normal file
47
cmd/patterm/debug_harness.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/hjbdev/patterm/internal/harness"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runDebugHarness() {
|
||||||
|
var (
|
||||||
|
scenarioPath = flag.String("scenario", "", "JSON scenario path")
|
||||||
|
pattermBin = flag.String("patterm-bin", "", "patterm binary under test (default: this executable)")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
if *scenarioPath == "" {
|
||||||
|
die("debug-harness: --scenario is required")
|
||||||
|
}
|
||||||
|
if *pattermBin == "" {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
die("debug-harness: os.Executable: %v", err)
|
||||||
|
}
|
||||||
|
*pattermBin = exe
|
||||||
|
}
|
||||||
|
sc, err := harness.LoadScenario(*scenarioPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stdout, "{\"ok\":false,\"error\":%q}\n", err.Error())
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
res, err := harness.RunScenario(harness.Options{
|
||||||
|
Scenario: sc,
|
||||||
|
PattermBin: *pattermBin,
|
||||||
|
}, func(ev harness.Event) {
|
||||||
|
_ = enc.Encode(ev)
|
||||||
|
})
|
||||||
|
_ = enc.Encode(res)
|
||||||
|
if err != nil {
|
||||||
|
if res.Steps == 0 {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
// patterm mcp-stdio --socket S --identity I
|
// patterm mcp-stdio --socket S --identity I
|
||||||
// internal: stdio MCP proxy spawned for
|
// internal: stdio MCP proxy spawned for
|
||||||
// children, forwards JSON-RPC over S
|
// children, forwards JSON-RPC over S
|
||||||
|
// patterm debug-harness --scenario S
|
||||||
|
// internal: run a black-box harness scenario
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,6 +32,11 @@ func main() {
|
|||||||
runMCPProxy()
|
runMCPProxy()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(os.Args) >= 2 && os.Args[1] == "debug-harness" {
|
||||||
|
os.Args = append(os.Args[:1], os.Args[2:]...)
|
||||||
|
runDebugHarness()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var projectDir = flag.String("project", "", "project directory (default $PWD)")
|
var projectDir = flag.String("project", "", "project directory (default $PWD)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|||||||
209
internal/harness/env.go
Normal file
209
internal/harness/env.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hjbdev/patterm/internal/projectkey"
|
||||||
|
"github.com/hjbdev/patterm/internal/trust"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
PattermBin string
|
||||||
|
Scenario *Scenario
|
||||||
|
}
|
||||||
|
|
||||||
|
type testEnv struct {
|
||||||
|
Root string `json:"root"`
|
||||||
|
ConfigHome string `json:"xdg_config_home"`
|
||||||
|
DataHome string `json:"xdg_data_home"`
|
||||||
|
RuntimeDir string `json:"xdg_runtime_dir"`
|
||||||
|
BinDir string `json:"bin_dir"`
|
||||||
|
ProjectDir string `json:"project_dir"`
|
||||||
|
PattermBin string `json:"patterm_bin"`
|
||||||
|
Cols uint16 `json:"cols"`
|
||||||
|
Rows uint16 `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareEnv(opts Options) (*testEnv, []string, error) {
|
||||||
|
sc := opts.Scenario
|
||||||
|
root, err := os.MkdirTemp("", "patterm-harness-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
env := &testEnv{
|
||||||
|
Root: root,
|
||||||
|
ConfigHome: filepath.Join(root, "config"),
|
||||||
|
DataHome: filepath.Join(root, "data"),
|
||||||
|
RuntimeDir: filepath.Join(root, "runtime"),
|
||||||
|
BinDir: filepath.Join(root, "bin"),
|
||||||
|
Cols: sc.Cols,
|
||||||
|
Rows: sc.Rows,
|
||||||
|
PattermBin: opts.PattermBin,
|
||||||
|
}
|
||||||
|
if env.ProjectDir = sc.ProjectDir; env.ProjectDir == "" {
|
||||||
|
env.ProjectDir = filepath.Join(root, "project")
|
||||||
|
}
|
||||||
|
for _, dir := range []string{env.ConfigHome, env.DataHome, env.RuntimeDir, env.BinDir, env.ProjectDir} {
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if env.PattermBin == "" {
|
||||||
|
env.PattermBin, err = defaultPattermBin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeScripts(env.BinDir, sc.Scripts); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := writePresets(env.ConfigHome, sc.Presets); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := seedTrust(env, sc.Trust); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
childEnv := append(os.Environ(),
|
||||||
|
"XDG_CONFIG_HOME="+env.ConfigHome,
|
||||||
|
"XDG_DATA_HOME="+env.DataHome,
|
||||||
|
"XDG_RUNTIME_DIR="+env.RuntimeDir,
|
||||||
|
"PATTERM_HARNESS=1",
|
||||||
|
"PATH="+env.BinDir+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||||
|
)
|
||||||
|
for k, v := range sc.Env {
|
||||||
|
childEnv = append(childEnv, k+"="+v)
|
||||||
|
}
|
||||||
|
return env, childEnv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeScripts(bin string, scripts []ScenarioScript) error {
|
||||||
|
for _, script := range scripts {
|
||||||
|
if script.Name == "" {
|
||||||
|
return fmt.Errorf("script missing name")
|
||||||
|
}
|
||||||
|
if strings.Contains(script.Name, "/") {
|
||||||
|
return fmt.Errorf("script name %q must not contain /", script.Name)
|
||||||
|
}
|
||||||
|
path := filepath.Join(bin, script.Name)
|
||||||
|
if err := os.WriteFile(path, []byte(script.Body), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePresets(configHome string, presets ScenarioPresets) error {
|
||||||
|
base := filepath.Join(configHome, "patterm", "presets")
|
||||||
|
if err := os.MkdirAll(filepath.Join(base, "agents"), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(base, "processes"), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, p := range presets.Agents {
|
||||||
|
if err := writePreset(filepath.Join(base, "agents", p.Name+".json"), p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range presets.Processes {
|
||||||
|
if err := writePreset(filepath.Join(base, "processes", p.Name+".json"), p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePreset(path string, p ScenarioPreset) error {
|
||||||
|
if p.Name == "" {
|
||||||
|
return fmt.Errorf("preset missing name")
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(p, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b = append(b, '\n')
|
||||||
|
return os.WriteFile(path, b, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedTrust(env *testEnv, presets []string) error {
|
||||||
|
if len(presets) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key, err := projectkey.Key(env.ProjectDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
restore := setenv(map[string]string{"XDG_DATA_HOME": env.DataHome})
|
||||||
|
defer restore()
|
||||||
|
store, err := trust.Open(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, p := range presets {
|
||||||
|
if err := store.Grant(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setenv(vals map[string]string) func() {
|
||||||
|
type old struct {
|
||||||
|
v string
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
prev := map[string]old{}
|
||||||
|
for k, v := range vals {
|
||||||
|
ov, ok := os.LookupEnv(k)
|
||||||
|
prev[k] = old{v: ov, ok: ok}
|
||||||
|
_ = os.Setenv(k, v)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
for k, ov := range prev {
|
||||||
|
if ov.ok {
|
||||||
|
_ = os.Setenv(k, ov.v)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPattermBin() (string, error) {
|
||||||
|
if p := os.Getenv("PATTERM_BIN"); p != "" {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
return buildPattermBinary()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPattermBinary() (string, error) {
|
||||||
|
root, err := repoRoot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out := filepath.Join(os.TempDir(), "patterm-harness-bin", "patterm")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(out), 0o700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cmd := exec.Command("go", "build", "-o", out, "./cmd/patterm")
|
||||||
|
cmd.Dir = root
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
if b, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return "", fmt.Errorf("build patterm: %w\n%s", err, string(b))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func repoRoot() (string, error) {
|
||||||
|
_, file, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("runtime.Caller failed")
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")), nil
|
||||||
|
}
|
||||||
35
internal/harness/input.go
Normal file
35
internal/harness/input.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func EncodeChord(name string) ([]byte, error) {
|
||||||
|
switch name {
|
||||||
|
case "ctrl-k":
|
||||||
|
return []byte{0x0b}, nil
|
||||||
|
case "ctrl-k-kitty":
|
||||||
|
return []byte("\x1b[107;5u"), nil
|
||||||
|
case "ctrl-k-xterm":
|
||||||
|
return []byte("\x1b[27;5;107~"), nil
|
||||||
|
case "enter":
|
||||||
|
return []byte{'\r'}, nil
|
||||||
|
case "escape":
|
||||||
|
return []byte{0x1b}, nil
|
||||||
|
case "backspace":
|
||||||
|
return []byte{0x7f}, nil
|
||||||
|
case "up":
|
||||||
|
return []byte("\x1b[A"), nil
|
||||||
|
case "down":
|
||||||
|
return []byte("\x1b[B"), nil
|
||||||
|
case "left":
|
||||||
|
return []byte("\x1b[D"), nil
|
||||||
|
case "right":
|
||||||
|
return []byte("\x1b[C"), nil
|
||||||
|
case "ctrl-n":
|
||||||
|
return []byte{0x0e}, nil
|
||||||
|
case "ctrl-p":
|
||||||
|
return []byte{0x10}, nil
|
||||||
|
case "ctrl-u":
|
||||||
|
return []byte{0x15}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unknown chord %q", name)
|
||||||
|
}
|
||||||
22
internal/harness/input_test.go
Normal file
22
internal/harness/input_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEncodeChord(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"ctrl-k": "\x0b",
|
||||||
|
"ctrl-k-kitty": "\x1b[107;5u",
|
||||||
|
"ctrl-k-xterm": "\x1b[27;5;107~",
|
||||||
|
"enter": "\r",
|
||||||
|
"down": "\x1b[B",
|
||||||
|
}
|
||||||
|
for name, want := range tests {
|
||||||
|
got, err := EncodeChord(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatalf("%s = %q, want %q", name, string(got), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
internal/harness/mcp_client.go
Normal file
82
internal/harness/mcp_client.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MCPClient struct {
|
||||||
|
conn net.Conn
|
||||||
|
r *bufio.Reader
|
||||||
|
mu sync.Mutex
|
||||||
|
next int
|
||||||
|
}
|
||||||
|
|
||||||
|
type RPCError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RPCError) Error() string {
|
||||||
|
return fmt.Sprintf("json-rpc error %d: %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DialMCP(socket string) (*MCPClient, error) {
|
||||||
|
conn, err := net.Dial("unix", socket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := conn.Write([]byte("{\"patterm_identity\":\"harness\"}\n")); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &MCPClient{conn: conn, r: bufio.NewReader(conn)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MCPClient) Close() error {
|
||||||
|
if c == nil || c.conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MCPClient) Call(method string, params any) (json.RawMessage, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.next++
|
||||||
|
req := map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": c.next,
|
||||||
|
"method": method,
|
||||||
|
}
|
||||||
|
if params != nil {
|
||||||
|
req["params"] = params
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b = append(b, '\n')
|
||||||
|
if _, err := c.conn.Write(b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
line, err := c.r.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error *RPCError `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(line, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse response: %w: %s", err, string(line))
|
||||||
|
}
|
||||||
|
if resp.Error != nil {
|
||||||
|
return nil, resp.Error
|
||||||
|
}
|
||||||
|
return resp.Result, nil
|
||||||
|
}
|
||||||
92
internal/harness/observe.go
Normal file
92
internal/harness/observe.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func timeoutMS(ms int) time.Duration {
|
||||||
|
if ms <= 0 {
|
||||||
|
ms = 5000
|
||||||
|
}
|
||||||
|
return time.Duration(ms) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueAt(v any, path string) (any, bool) {
|
||||||
|
if path == "" {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
cur := v
|
||||||
|
for _, part := range strings.Split(path, ".") {
|
||||||
|
switch x := cur.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
next, ok := x[part]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
cur = next
|
||||||
|
case []any:
|
||||||
|
i, err := strconv.Atoi(part)
|
||||||
|
if err != nil || i < 0 || i >= len(x) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
cur = x[i]
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cur, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertJSONValue(raw json.RawMessage, path string, equals any, contains string, allowSubstring bool) error {
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
got, ok := valueAt(v, path)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("path %q not found in %s", path, string(raw))
|
||||||
|
}
|
||||||
|
if equals != nil {
|
||||||
|
if !reflect.DeepEqual(normalizeJSON(equals), normalizeJSON(got)) {
|
||||||
|
return fmt.Errorf("path %q = %#v, want %#v", path, got, equals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if contains != "" {
|
||||||
|
switch x := got.(type) {
|
||||||
|
case string:
|
||||||
|
if !strings.Contains(x, contains) {
|
||||||
|
return fmt.Errorf("path %q = %q, does not contain %q", path, x, contains)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
b, _ := json.Marshal(x)
|
||||||
|
if allowSubstring && strings.Contains(string(b), contains) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("path %q is not a string: %#v", path, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeJSON(v any) any {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case int:
|
||||||
|
return float64(x)
|
||||||
|
case int64:
|
||||||
|
return float64(x)
|
||||||
|
case []any:
|
||||||
|
for i := range x {
|
||||||
|
x[i] = normalizeJSON(x[i])
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
for k := range x {
|
||||||
|
x[k] = normalizeJSON(x[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
52
internal/harness/recorder.go
Normal file
52
internal/harness/recorder.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Artifact struct {
|
||||||
|
Dir string `json:"artifact_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) DumpArtifacts(sc *Scenario, failingStep int, cause error) (*Artifact, error) {
|
||||||
|
name := sc.Name
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSuffix(filepath.Base(sc.Path), filepath.Ext(sc.Path))
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "scenario"
|
||||||
|
}
|
||||||
|
dir := filepath.Join("internal", "harness", ".artifacts", fmt.Sprintf("%s-%d", name, time.Now().Unix()))
|
||||||
|
abs, _ := filepath.Abs(dir)
|
||||||
|
if err := os.MkdirAll(abs, 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
screen, _ := s.em.ScreenText()
|
||||||
|
_ = os.WriteFile(filepath.Join(abs, "grid.log"), []byte(screen), 0o600)
|
||||||
|
_ = os.WriteFile(filepath.Join(abs, "raw.bytes"), s.rawBytes(), 0o600)
|
||||||
|
if b, err := s.em.SerializeVT(); err == nil {
|
||||||
|
_ = os.WriteFile(filepath.Join(abs, "serialize.vt"), b, 0o600)
|
||||||
|
}
|
||||||
|
envBytes, _ := json.MarshalIndent(s.env, "", " ")
|
||||||
|
_ = os.WriteFile(filepath.Join(abs, "env.json"), append(envBytes, '\n'), 0o600)
|
||||||
|
annotated := map[string]any{"scenario": sc, "failing_step": failingStep}
|
||||||
|
if cause != nil {
|
||||||
|
annotated["error"] = cause.Error()
|
||||||
|
}
|
||||||
|
ab, _ := json.MarshalIndent(annotated, "", " ")
|
||||||
|
_ = os.WriteFile(filepath.Join(abs, "scenario.annotated.json"), append(ab, '\n'), 0o600)
|
||||||
|
snapshot := map[string]json.RawMessage{}
|
||||||
|
for _, method := range []string{"whoami", "list_processes", "get_project_status"} {
|
||||||
|
if raw, err := s.mcp.Call(method, map[string]any{}); err == nil {
|
||||||
|
snapshot[method] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb, _ := json.MarshalIndent(snapshot, "", " ")
|
||||||
|
_ = os.WriteFile(filepath.Join(abs, "mcp-snapshot.json"), append(sb, '\n'), 0o600)
|
||||||
|
return &Artifact{Dir: abs}, nil
|
||||||
|
}
|
||||||
205
internal/harness/runner.go
Normal file
205
internal/harness/runner.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Step int `json:"step"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Artifact string `json:"artifact_dir,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Steps int `json:"steps"`
|
||||||
|
Artifact string `json:"artifact_dir,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Events []Event `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunScenario(opts Options, emit func(Event)) (RunResult, error) {
|
||||||
|
sc := opts.Scenario
|
||||||
|
s, err := NewCLI(opts)
|
||||||
|
if err != nil {
|
||||||
|
return RunResult{OK: false, Error: err.Error()}, err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
results := map[string]json.RawMessage{}
|
||||||
|
for i, step := range sc.Steps {
|
||||||
|
ev := Event{Step: i + 1, Type: step.Type}
|
||||||
|
err := runStep(s, step, results)
|
||||||
|
if err != nil {
|
||||||
|
ev.OK = false
|
||||||
|
ev.Error = err.Error()
|
||||||
|
art, aerr := s.DumpArtifacts(sc, i, err)
|
||||||
|
if aerr == nil {
|
||||||
|
ev.Artifact = art.Dir
|
||||||
|
}
|
||||||
|
if emit != nil {
|
||||||
|
emit(ev)
|
||||||
|
}
|
||||||
|
return RunResult{OK: false, Steps: i + 1, Error: err.Error(), Artifact: ev.Artifact}, err
|
||||||
|
}
|
||||||
|
ev.OK = true
|
||||||
|
if step.SaveAs != "" {
|
||||||
|
ev.Result = results[step.SaveAs]
|
||||||
|
}
|
||||||
|
if emit != nil {
|
||||||
|
emit(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RunResult{OK: true, Steps: len(sc.Steps)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStep(s *Session, step Step, results map[string]json.RawMessage) error {
|
||||||
|
switch step.Type {
|
||||||
|
case "wait_stable":
|
||||||
|
return s.WaitForStable(timeoutMS(step.TimeoutMS))
|
||||||
|
case "send_chord":
|
||||||
|
return s.SendChord(step.Chord)
|
||||||
|
case "send_text":
|
||||||
|
return s.SendText(step.Text)
|
||||||
|
case "assert_contains":
|
||||||
|
screen, err := s.Screen()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.Contains(screen, step.Contains) {
|
||||||
|
return fmt.Errorf("screen does not contain %q:\n%s", step.Contains, screen)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "assert_regex":
|
||||||
|
return s.WaitForRegex(step.Regex, timeoutMS(step.TimeoutMS))
|
||||||
|
case "wait_text":
|
||||||
|
return s.WaitForText(step.Contains, timeoutMS(step.TimeoutMS))
|
||||||
|
case "assert_cursor":
|
||||||
|
cur, err := s.Cursor()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if step.CursorRow != nil && int(cur.Row) != *step.CursorRow {
|
||||||
|
return fmt.Errorf("cursor row = %d, want %d", cur.Row, *step.CursorRow)
|
||||||
|
}
|
||||||
|
if step.CursorCol != nil && int(cur.Col) != *step.CursorCol {
|
||||||
|
return fmt.Errorf("cursor col = %d, want %d", cur.Col, *step.CursorCol)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "mcp_call":
|
||||||
|
params, err := resolveParams(step.Params, results)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := s.MCPCall(step.Method, params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if step.SaveAs != "" {
|
||||||
|
results[step.SaveAs] = raw
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "assert_mcp":
|
||||||
|
params, perr := resolveParams(step.Params, results)
|
||||||
|
if perr != nil {
|
||||||
|
return perr
|
||||||
|
}
|
||||||
|
raw, err := s.MCPCall(step.Method, params)
|
||||||
|
if step.ErrorKind != "" {
|
||||||
|
if err == nil {
|
||||||
|
return fmt.Errorf("expected MCP error kind %q, got success %s", step.ErrorKind, string(raw))
|
||||||
|
}
|
||||||
|
rpcErr, ok := err.(*RPCError)
|
||||||
|
if !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rpcErr.Data != nil {
|
||||||
|
if data, ok := rpcErr.Data.(map[string]any); ok && data["kind"] == step.ErrorKind {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(rpcErr.Message, step.ErrorKind) || strings.Contains(rpcErr.Message, strings.ReplaceAll(step.ErrorKind, "_", " ")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("MCP error = %#v, want kind %q", rpcErr, step.ErrorKind)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring)
|
||||||
|
case "assert_saved":
|
||||||
|
raw, ok := results[step.From]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no saved result %q", step.From)
|
||||||
|
}
|
||||||
|
return assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown step type %q", step.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveParams(raw json.RawMessage, saved map[string]json.RawMessage) (json.RawMessage, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resolved, err := resolveValue(v, saved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveValue(v any, saved map[string]json.RawMessage) (any, error) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.HasPrefix(x, "{{") && strings.HasSuffix(x, "}}") {
|
||||||
|
ref := strings.TrimSuffix(strings.TrimPrefix(x, "{{"), "}}")
|
||||||
|
parts := strings.SplitN(ref, ".", 2)
|
||||||
|
raw, ok := saved[parts[0]]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown saved result %q", parts[0])
|
||||||
|
}
|
||||||
|
var sv any
|
||||||
|
if err := json.Unmarshal(raw, &sv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
path = parts[1]
|
||||||
|
}
|
||||||
|
got, ok := valueAt(sv, path)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("path %q not found in saved result %q", path, parts[0])
|
||||||
|
}
|
||||||
|
return got, nil
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
case []any:
|
||||||
|
for i := range x {
|
||||||
|
r, err := resolveValue(x[i], saved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x[i] = r
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
case map[string]any:
|
||||||
|
for k := range x {
|
||||||
|
r, err := resolveValue(x[k], saved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x[k] = r
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
default:
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
76
internal/harness/scenario.go
Normal file
76
internal/harness/scenario.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scenario struct {
|
||||||
|
Path string `json:"-"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
ProjectDir string `json:"project_dir,omitempty"`
|
||||||
|
Cols uint16 `json:"cols,omitempty"`
|
||||||
|
Rows uint16 `json:"rows,omitempty"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
Trust []string `json:"trust,omitempty"`
|
||||||
|
Presets ScenarioPresets `json:"presets,omitempty"`
|
||||||
|
Scripts []ScenarioScript `json:"scripts,omitempty"`
|
||||||
|
Steps []Step `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScenarioPresets struct {
|
||||||
|
Agents []ScenarioPreset `json:"agents,omitempty"`
|
||||||
|
Processes []ScenarioPreset `json:"processes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScenarioPreset struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
WorkingDir string `json:"working_dir,omitempty"`
|
||||||
|
Shell bool `json:"shell,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScenarioScript struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Chord string `json:"chord,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Contains string `json:"contains,omitempty"`
|
||||||
|
Regex string `json:"regex,omitempty"`
|
||||||
|
TimeoutMS int `json:"timeout_ms,omitempty"`
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
SaveAs string `json:"save_as,omitempty"`
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Equals any `json:"equals,omitempty"`
|
||||||
|
ErrorKind string `json:"error_kind,omitempty"`
|
||||||
|
CursorRow *int `json:"cursor_row,omitempty"`
|
||||||
|
CursorCol *int `json:"cursor_col,omitempty"`
|
||||||
|
AllowSubstring bool `json:"allow_substring,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadScenario(path string) (*Scenario, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var sc Scenario
|
||||||
|
if err := json.Unmarshal(b, &sc); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse scenario %s: %w", path, err)
|
||||||
|
}
|
||||||
|
sc.Path = path
|
||||||
|
if sc.Cols == 0 {
|
||||||
|
sc.Cols = 120
|
||||||
|
}
|
||||||
|
if sc.Rows == 0 {
|
||||||
|
sc.Rows = 40
|
||||||
|
}
|
||||||
|
return &sc, nil
|
||||||
|
}
|
||||||
49
internal/harness/scenario_test.go
Normal file
49
internal/harness/scenario_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadScenario(t *testing.T) {
|
||||||
|
sc, err := LoadScenario(filepath.Join("scenarios", "spawn_process_via_palette.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sc.Cols != 120 || sc.Rows != 40 {
|
||||||
|
t.Fatalf("default size = %dx%d, want 120x40", sc.Cols, sc.Rows)
|
||||||
|
}
|
||||||
|
if len(sc.Steps) == 0 {
|
||||||
|
t.Fatal("expected steps")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHarnessScenarios(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping end-to-end harness scenarios in short mode")
|
||||||
|
}
|
||||||
|
paths, err := filepath.Glob(filepath.Join("scenarios", "*.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
t.Fatal("no scenarios")
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
path := path
|
||||||
|
t.Run(filepath.Base(path), func(t *testing.T) {
|
||||||
|
sc, err := LoadScenario(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := RunScenario(Options{Scenario: sc, PattermBin: os.Getenv("PATTERM_BIN")}, nil)
|
||||||
|
if err != nil {
|
||||||
|
if res.Artifact != "" {
|
||||||
|
t.Fatalf("%v\nartifact: %s", err, res.Artifact)
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/harness/scenarios/child_exit.json
Normal file
23
internal/harness/scenarios/child_exit.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "child_exit",
|
||||||
|
"trust": ["exit0"],
|
||||||
|
"presets": {
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"name": "exit0",
|
||||||
|
"argv": ["sh", "-lc", "exit 0"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "mcp_call",
|
||||||
|
"method": "spawn_process",
|
||||||
|
"params": { "kind": "command", "preset": "exit0" },
|
||||||
|
"save_as": "proc"
|
||||||
|
},
|
||||||
|
{ "type": "wait_stable", "timeout_ms": 3000 },
|
||||||
|
{ "type": "assert_mcp", "method": "list_processes", "path": "0.status", "equals": "exited" },
|
||||||
|
{ "type": "assert_mcp", "method": "list_processes", "path": "0.exit_code", "equals": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
30
internal/harness/scenarios/mcp_send_input.json
Normal file
30
internal/harness/scenarios/mcp_send_input.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "mcp_send_input",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "mcp_call",
|
||||||
|
"method": "spawn_process",
|
||||||
|
"params": {
|
||||||
|
"kind": "command",
|
||||||
|
"argv": ["sh", "-lc", "read line; echo got:$line; sleep 5"],
|
||||||
|
"name": "reader"
|
||||||
|
},
|
||||||
|
"save_as": "proc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "mcp_call",
|
||||||
|
"method": "send_input",
|
||||||
|
"params": {
|
||||||
|
"process_id": "{{proc.process_id}}",
|
||||||
|
"kind": "text",
|
||||||
|
"text": "hello",
|
||||||
|
"submit": true,
|
||||||
|
"wait_ms": 250,
|
||||||
|
"tail_mode": "grid"
|
||||||
|
},
|
||||||
|
"save_as": "input"
|
||||||
|
},
|
||||||
|
{ "type": "assert_saved", "from": "input", "path": "tail.content", "contains": "got:hello" },
|
||||||
|
{ "type": "wait_text", "contains": "got:hello", "timeout_ms": 5000 }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
internal/harness/scenarios/spawn_process_via_palette.json
Normal file
26
internal/harness/scenarios/spawn_process_via_palette.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "spawn_process_via_palette",
|
||||||
|
"presets": {
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"name": "ready",
|
||||||
|
"argv": ["ready-script"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": [
|
||||||
|
{
|
||||||
|
"name": "ready-script",
|
||||||
|
"body": "#!/bin/sh\necho READY\nsleep 5\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{ "type": "wait_stable", "timeout_ms": 3000 },
|
||||||
|
{ "type": "send_chord", "chord": "ctrl-k" },
|
||||||
|
{ "type": "send_text", "text": "ready" },
|
||||||
|
{ "type": "send_chord", "chord": "enter" },
|
||||||
|
{ "type": "wait_text", "contains": "READY", "timeout_ms": 5000 },
|
||||||
|
{ "type": "assert_mcp", "method": "list_processes", "path": "0.name", "equals": "ready" },
|
||||||
|
{ "type": "assert_mcp", "method": "list_processes", "path": "0.status", "equals": "running" }
|
||||||
|
]
|
||||||
|
}
|
||||||
34
internal/harness/scenarios/switch_via_palette.json
Normal file
34
internal/harness/scenarios/switch_via_palette.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "switch_via_palette",
|
||||||
|
"scripts": [
|
||||||
|
{
|
||||||
|
"name": "named-loop",
|
||||||
|
"body": "#!/bin/sh\necho \"$1 READY\"\nsleep 5\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "mcp_call",
|
||||||
|
"method": "spawn_process",
|
||||||
|
"params": { "kind": "command", "argv": ["named-loop", "first"], "name": "first" },
|
||||||
|
"save_as": "first"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "mcp_call",
|
||||||
|
"method": "spawn_process",
|
||||||
|
"params": { "kind": "command", "argv": ["named-loop", "second"], "name": "second" },
|
||||||
|
"save_as": "second"
|
||||||
|
},
|
||||||
|
{ "type": "wait_text", "contains": "second READY", "timeout_ms": 5000 },
|
||||||
|
{ "type": "send_chord", "chord": "ctrl-k" },
|
||||||
|
{ "type": "send_text", "text": "Switch to first" },
|
||||||
|
{ "type": "send_chord", "chord": "enter" },
|
||||||
|
{ "type": "wait_text", "contains": "first READY", "timeout_ms": 5000 },
|
||||||
|
{
|
||||||
|
"type": "assert_mcp",
|
||||||
|
"method": "get_project_status",
|
||||||
|
"path": "processes.0.name",
|
||||||
|
"equals": "first"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
internal/harness/scenarios/trust_required.json
Normal file
19
internal/harness/scenarios/trust_required.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "trust_required",
|
||||||
|
"presets": {
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"name": "untrusted",
|
||||||
|
"argv": ["sh", "-lc", "echo should-not-run"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "assert_mcp",
|
||||||
|
"method": "spawn_process",
|
||||||
|
"params": { "kind": "command", "preset": "untrusted" },
|
||||||
|
"error_kind": "needs_trust"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
264
internal/harness/session.go
Normal file
264
internal/harness/session.go
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pkgpty "github.com/hjbdev/patterm/internal/pty"
|
||||||
|
"github.com/hjbdev/patterm/internal/vt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
pty *pkgpty.PTY
|
||||||
|
em *vt.GhosttyEmulator
|
||||||
|
mcp *MCPClient
|
||||||
|
env *testEnv
|
||||||
|
|
||||||
|
bytesMu sync.Mutex
|
||||||
|
bytes []byte
|
||||||
|
|
||||||
|
lastWriteNS atomic.Int64
|
||||||
|
readerDone chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
closeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(t testing.TB, opts Options) *Session {
|
||||||
|
t.Helper()
|
||||||
|
s, err := NewCLI(opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("harness New: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCLI(opts Options) (*Session, error) {
|
||||||
|
if opts.Scenario == nil {
|
||||||
|
return nil, fmt.Errorf("harness: Scenario required")
|
||||||
|
}
|
||||||
|
env, childEnv, err := prepareEnv(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
em, err := vt.NewGhosttyEmulator(env.Cols, env.Rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows)
|
||||||
|
if err != nil {
|
||||||
|
_ = em.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
em.OnWritePTY(func(b []byte) { _, _ = p.Write(b) })
|
||||||
|
s := &Session{pty: p, em: em, env: env, readerDone: make(chan struct{})}
|
||||||
|
go s.readLoop()
|
||||||
|
if err := s.bootstrapMCP(2 * time.Second); err != nil {
|
||||||
|
_ = s.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) readLoop() {
|
||||||
|
defer close(s.readerDone)
|
||||||
|
buf := make([]byte, 64*1024)
|
||||||
|
for {
|
||||||
|
n, err := s.pty.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunk := make([]byte, n)
|
||||||
|
copy(chunk, buf[:n])
|
||||||
|
_, _ = s.em.Write(chunk)
|
||||||
|
s.bytesMu.Lock()
|
||||||
|
s.bytes = append(s.bytes, chunk...)
|
||||||
|
s.bytesMu.Unlock()
|
||||||
|
s.lastWriteNS.Store(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) bootstrapMCP(timeout time.Duration) error {
|
||||||
|
socket := filepath.Join(s.env.RuntimeDir, "patterm", fmt.Sprintf("%d.sock", s.pty.Pid()))
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
var last error
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if _, err := os.Stat(socket); err != nil {
|
||||||
|
last = err
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c, err := DialMCP(socket)
|
||||||
|
if err != nil {
|
||||||
|
last = err
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err = c.Call("whoami", map[string]any{})
|
||||||
|
if err == nil {
|
||||||
|
s.mcp = c
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
last = err
|
||||||
|
_ = c.Close()
|
||||||
|
if strings.Contains(err.Error(), "tool host not initialized") {
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(string(s.rawBytes()))
|
||||||
|
if raw != "" {
|
||||||
|
return fmt.Errorf("mcp bootstrap timed out: %w; child output: %s", last, raw)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("mcp bootstrap timed out: %w", last)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Close() error {
|
||||||
|
s.closeOnce.Do(func() {
|
||||||
|
if s.mcp != nil {
|
||||||
|
_ = s.mcp.Close()
|
||||||
|
}
|
||||||
|
pid := s.pty.Pid()
|
||||||
|
if pid > 0 {
|
||||||
|
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
|
||||||
|
_ = syscall.Kill(pid, syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- s.pty.Wait() }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
if pid > 0 {
|
||||||
|
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
|
||||||
|
_ = syscall.Kill(pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-s.readerDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
if err := s.pty.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
|
||||||
|
s.closeErr = err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-s.readerDone:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = s.em.Close()
|
||||||
|
})
|
||||||
|
return s.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) SendChord(name string) error {
|
||||||
|
b, err := EncodeChord(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.pty.Write(b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) SendText(text string) error {
|
||||||
|
_, err := s.pty.Write([]byte(text))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Screen() (string, error) { return s.em.PlainText() }
|
||||||
|
|
||||||
|
func (s *Session) Cursor() (vt.CursorState, error) { return s.em.Cursor() }
|
||||||
|
|
||||||
|
func (s *Session) WaitForStable(timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
tick := time.NewTicker(25 * time.Millisecond)
|
||||||
|
defer tick.Stop()
|
||||||
|
confirmed := false
|
||||||
|
for {
|
||||||
|
last := s.lastWriteNS.Load()
|
||||||
|
idle := last == 0 || time.Since(time.Unix(0, last)) >= time.Second
|
||||||
|
if idle {
|
||||||
|
if confirmed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
confirmed = true
|
||||||
|
} else {
|
||||||
|
confirmed = false
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("screen did not stabilize within %s", timeout)
|
||||||
|
}
|
||||||
|
<-tick.C
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) WaitForText(text string, timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
screen, err := s.Screen()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.Contains(screen, text) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
screen, _ := s.Screen()
|
||||||
|
return fmt.Errorf("text %q not found before timeout; screen:\n%s", text, screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) WaitForRegex(pattern string, timeout time.Duration) error {
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
screen, err := s.Screen()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if re.MatchString(screen) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
screen, _ := s.Screen()
|
||||||
|
return fmt.Errorf("regex %q not found before timeout; screen:\n%s", pattern, screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) MCPCall(method string, params json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var v any = map[string]any{}
|
||||||
|
if len(params) > 0 {
|
||||||
|
if err := json.Unmarshal(params, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.mcp.Call(method, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) rawBytes() []byte {
|
||||||
|
s.bytesMu.Lock()
|
||||||
|
defer s.bytesMu.Unlock()
|
||||||
|
out := make([]byte, len(s.bytes))
|
||||||
|
copy(out, s.bytes)
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user