Sync MCP surface to SPEC §7 process model
Rename list_children/read_output/kill/send_message_to to their SPEC §7 process_id-shaped names; drop report_to_parent (direction inferred by send_message) and policy_check (replaced by per-project trust gating). Add the SPEC's missing tools: start_process, restart_process, close_process, rename_process, select_process, get_process_status, get_project_status, get_process_raw_output, search_output, get_process_ports, whoami, help. Process model now distinguishes agent/terminal/command kinds with opaque p_<6hex> IDs. Command entries are session-persistent so they survive PTY exit and can be Restart'd. Status enum gains starting and stopped. screen_version, port detection, and bracketed-paste send_input land alongside. Trust gating (internal/trust) replaces the regex policy: command-preset spawns return needs_trust on first use; the user confirms in a status-line modal and the grant persists to \$XDG_DATA_HOME/patterm/projects/<key>/trust.json. Tests cover send_message direction inference (parent↔child, sibling rejection, nil caller paths) and trust grant persistence across reopen.
This commit is contained in:
@@ -16,9 +16,9 @@ import (
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/harrybrwn/patterm/internal/mcp"
|
||||
"github.com/harrybrwn/patterm/internal/policy"
|
||||
"github.com/harrybrwn/patterm/internal/preset"
|
||||
"github.com/harrybrwn/patterm/internal/scratchpad"
|
||||
"github.com/harrybrwn/patterm/internal/trust"
|
||||
)
|
||||
|
||||
// Options configures a patterm run.
|
||||
@@ -41,11 +41,6 @@ func Run(ctx context.Context, opts Options) error {
|
||||
return fmt.Errorf("app: load presets: %w", err)
|
||||
}
|
||||
|
||||
pol, err := policy.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: load policy: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the per-project scratchpad dir exists so MCP and the UI
|
||||
// can read/write into it. SPEC §3.
|
||||
pads, err := scratchpad.Open(opts.ProjectKey)
|
||||
@@ -53,6 +48,12 @@ func Run(ctx context.Context, opts Options) error {
|
||||
return fmt.Errorf("app: scratchpad init: %w", err)
|
||||
}
|
||||
|
||||
// Per-project trust store for command-preset trust gating (SPEC §7).
|
||||
trustStore, err := trust.Open(opts.ProjectKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: trust init: %w", err)
|
||||
}
|
||||
|
||||
// In-process MCP server bound to the per-PID socket. Children that
|
||||
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
|
||||
// SPEC §10.
|
||||
@@ -76,7 +77,7 @@ func Run(ctx context.Context, opts Options) error {
|
||||
// Wire the tool host into MCP. Spawns through MCP use the host
|
||||
// terminal's viewport grid for their initial PTY size; SIGWINCH paths
|
||||
// resize them later.
|
||||
host := newToolHost(sess, pads, launcher, presets, pol, layout.childCols(), layout.childRows())
|
||||
host := newToolHost(sess, pads, launcher, presets, trustStore, layout.childCols(), layout.childRows())
|
||||
mcpSrv.SetHost(host)
|
||||
|
||||
var restoreState *term.State
|
||||
@@ -96,11 +97,14 @@ func Run(ctx context.Context, opts Options) error {
|
||||
presets: presets,
|
||||
launcher: launcher,
|
||||
pads: pads,
|
||||
trust: trustStore,
|
||||
hostCols: cols,
|
||||
hostRows: rows,
|
||||
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
}
|
||||
host.attention = st
|
||||
host.focus = st
|
||||
host.prompter = st
|
||||
st.lastExit.Store(-1)
|
||||
sess.Subscribe(st)
|
||||
|
||||
@@ -200,6 +204,7 @@ type uiState struct {
|
||||
presets preset.Set
|
||||
launcher *Launcher
|
||||
pads *scratchpad.Store
|
||||
trust *trust.Store
|
||||
|
||||
outMu sync.Mutex
|
||||
|
||||
@@ -220,6 +225,13 @@ type uiState struct {
|
||||
attentionText string
|
||||
attentionAt string
|
||||
|
||||
// pendingTrust is the most recent trust prompt — surfaced in the
|
||||
// status line until the user resolves it with Ctrl-K. v1 keeps the
|
||||
// confirmation modal minimal: the user opens the palette and picks
|
||||
// "Trust preset <name>" / "Deny preset <name>". A future iteration
|
||||
// can promote this to a dedicated inline modal.
|
||||
pendingTrust *trustRequest
|
||||
|
||||
dimsMu sync.Mutex
|
||||
hostCols, hostRows uint16
|
||||
stdinTTY bool
|
||||
@@ -231,6 +243,42 @@ func (st *uiState) dbgf(format string, args ...any) {
|
||||
logf(format, args...)
|
||||
}
|
||||
|
||||
// trustRequest is one outstanding SPEC §7 trust prompt: an agent tried
|
||||
// to spawn / start / restart against an untrusted command preset and
|
||||
// the host wants user confirmation before the next attempt succeeds.
|
||||
type trustRequest struct {
|
||||
processID string
|
||||
presetName string
|
||||
reason string
|
||||
}
|
||||
|
||||
// promptTrust is the SPEC §7 trust gate UI hook. Replaces any prior
|
||||
// pending request — the most recent prompt wins.
|
||||
func (st *uiState) promptTrust(processID, presetName, reason string) {
|
||||
st.mu.Lock()
|
||||
st.pendingTrust = &trustRequest{processID: processID, presetName: presetName, reason: reason}
|
||||
st.mu.Unlock()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// focusProcess is the SPEC §7 select_process hook. Routes through the
|
||||
// normal focus-change path; only takes effect if the process exists.
|
||||
func (st *uiState) focusProcess(processID string) {
|
||||
c := st.sess.FindChild(processID)
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.focusedID = c.ID
|
||||
st.focusedName = c.DisplayName()
|
||||
st.renderer = newViewportRenderer(st.layoutSnapshot())
|
||||
st.mu.Unlock()
|
||||
st.repaintFocused()
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// notifyAttention is the request_human_attention sink (SPEC §7). We
|
||||
// surface a one-line toast in the status row and remember the most
|
||||
// recent ask so the status line keeps showing it. The sidebar-blink is
|
||||
@@ -370,6 +418,10 @@ func (st *uiState) drawStatusLine() {
|
||||
focusName := st.focusedName
|
||||
attention := st.attentionText
|
||||
attentionAt := st.attentionAt
|
||||
var trustMsg string
|
||||
if st.pendingTrust != nil {
|
||||
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
|
||||
}
|
||||
st.mu.Unlock()
|
||||
if palOpen {
|
||||
return
|
||||
@@ -403,6 +455,13 @@ func (st *uiState) drawStatusLine() {
|
||||
if attention != "" && attentionAt == focusID {
|
||||
left = "[!] " + attention
|
||||
}
|
||||
if attention != "" && attentionAt == "" {
|
||||
// Sticky attention/flash from somewhere outside the focused pane.
|
||||
left = "[!] " + attention
|
||||
}
|
||||
if trustMsg != "" {
|
||||
left = "[trust] " + trustMsg
|
||||
}
|
||||
right := "Ctrl-K · palette"
|
||||
|
||||
pad := int(cols) - len(left) - len(right)
|
||||
@@ -490,6 +549,48 @@ func (st *uiState) stdinLoop() error {
|
||||
func (st *uiState) processStdin(chunk []byte) {
|
||||
st.mu.Lock()
|
||||
|
||||
// Trust modal is modal: y/Y accepts, n/N or ESC denies. Everything
|
||||
// else is ignored so a typo doesn't leak into the focused PTY while
|
||||
// the prompt is up. SPEC §7 trust gate.
|
||||
if st.pendingTrust != nil {
|
||||
req := *st.pendingTrust
|
||||
consumed := 0
|
||||
var resolved string
|
||||
for _, b := range chunk {
|
||||
consumed++
|
||||
switch b {
|
||||
case 'y', 'Y':
|
||||
resolved = "accept"
|
||||
case 'n', 'N', 0x1b: // ESC
|
||||
resolved = "deny"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if resolved != "" {
|
||||
st.pendingTrust = nil
|
||||
st.mu.Unlock()
|
||||
if resolved == "accept" {
|
||||
if err := st.trust.Grant(req.presetName); err != nil {
|
||||
st.flashError(fmt.Sprintf("trust grant: %v", err))
|
||||
} else {
|
||||
st.flashTransient(fmt.Sprintf("trusted preset %q (retry the call)", req.presetName))
|
||||
}
|
||||
} else {
|
||||
st.flashTransient(fmt.Sprintf("denied trust for preset %q", req.presetName))
|
||||
}
|
||||
st.drawStatusLine()
|
||||
// Discard the rest of the chunk; we intentionally don't
|
||||
// recurse into the regular handler so a stray Enter doesn't
|
||||
// submit anything to the focused PTY.
|
||||
_ = consumed
|
||||
return
|
||||
}
|
||||
st.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
forward := make([]byte, 0, len(chunk))
|
||||
flushForward := func() {
|
||||
if len(forward) == 0 {
|
||||
@@ -641,7 +742,7 @@ func (st *uiState) closePalette(action paletteAction) {
|
||||
}
|
||||
l := st.layoutSnapshot()
|
||||
st.launcher.SetSize(l.childCols(), l.childRows())
|
||||
if _, err := st.launcher.LaunchProcess(action.preset, action.preset.Name); err != nil {
|
||||
if _, err := st.launcher.LaunchCommandPreset(action.preset, action.preset.Name, ""); err != nil {
|
||||
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
||||
}
|
||||
|
||||
@@ -687,6 +788,16 @@ func (st *uiState) flashError(msg string) {
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// flashTransient is the softer cousin of flashError used for
|
||||
// trust-prompt resolutions. Same status-line surface; the prefix differs.
|
||||
func (st *uiState) flashTransient(msg string) {
|
||||
st.mu.Lock()
|
||||
st.attentionText = msg
|
||||
st.attentionAt = ""
|
||||
st.mu.Unlock()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// repaintFocused redraws the current focused child's screen snapshot.
|
||||
// Callers must NOT hold st.mu — repaintFocused takes it
|
||||
// briefly itself.
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
@@ -15,20 +18,34 @@ import (
|
||||
"github.com/harrybrwn/patterm/internal/vt"
|
||||
)
|
||||
|
||||
// portRegex matches dev-server URLs of the form `http(s)://host:NNNN[/path]`
|
||||
// and reports the port. SPEC §7 get_process_ports is best-effort; we
|
||||
// stick to URL-form sightings because bare `:NNNN` produces too many
|
||||
// false positives (timestamps, exit codes, etc.).
|
||||
var portRegex = regexp.MustCompile(`https?://[^\s:/]+:(\d{2,5})(?:/[^\s]*)?`)
|
||||
|
||||
type ChildStatus string
|
||||
|
||||
const (
|
||||
StatusStarting ChildStatus = "starting"
|
||||
StatusRunning ChildStatus = "running"
|
||||
StatusStopped ChildStatus = "stopped"
|
||||
StatusExited ChildStatus = "exited"
|
||||
StatusErrored ChildStatus = "errored"
|
||||
)
|
||||
|
||||
// ChildKind matches the two preset flavours in SPEC §10.
|
||||
// ChildKind matches the three process kinds in SPEC §7.
|
||||
// - agent: vendor LLM CLI launched from an agent preset (MCP-wired,
|
||||
// ephemeral — lost when the PTY exits).
|
||||
// - terminal: a bare interactive shell (ephemeral).
|
||||
// - command: a process preset or freeform argv (session-persistent —
|
||||
// survives PTY exit so it can be restart_process'd).
|
||||
type ChildKind string
|
||||
|
||||
const (
|
||||
KindAgent ChildKind = "agent"
|
||||
KindProcess ChildKind = "process"
|
||||
KindTerminal ChildKind = "terminal"
|
||||
KindCommand ChildKind = "command"
|
||||
)
|
||||
|
||||
// Owner reflects the SPEC §6 input-ownership flag.
|
||||
@@ -39,19 +56,36 @@ const (
|
||||
OwnerOrchestrator Owner = "orchestrator"
|
||||
)
|
||||
|
||||
// Child is one PTY-backed process plus its emulator. The same struct
|
||||
// represents both agent presets (with MCP) and process presets (raw).
|
||||
// Child is one entry in the session — a PTY-backed process plus its
|
||||
// emulator. Covers all three kinds (agent / terminal / command).
|
||||
//
|
||||
// For KindCommand the entry is session-persistent: argv/env/workingDir
|
||||
// stay populated across stop/restart so Restart() can rebuild the PTY
|
||||
// against the same spec.
|
||||
type Child struct {
|
||||
ID string
|
||||
Name string
|
||||
Argv []string
|
||||
Env []string
|
||||
WorkDir string
|
||||
Kind ChildKind
|
||||
ParentID string // empty for top-level sessions
|
||||
|
||||
// PresetRef names the source preset (when known). Used by trust
|
||||
// gating to re-check on restart_process. Empty for freeform-argv
|
||||
// command entries and for ephemeral terminals.
|
||||
PresetRef string
|
||||
|
||||
// Identity is the per-spawn token the mcp-stdio proxy uses to
|
||||
// identify itself when calling tools. Empty for process presets.
|
||||
// identify itself when calling tools. Empty for non-agent entries.
|
||||
Identity string
|
||||
|
||||
// nameMu guards Name (rename_process).
|
||||
nameMu sync.RWMutex
|
||||
|
||||
// ptyMu guards pty + em so Restart can swap them while pumpChild /
|
||||
// reapChild loops detect the swap by observing nil/closed PTY.
|
||||
ptyMu sync.RWMutex
|
||||
pty *pkgpty.PTY
|
||||
em *vt.GhosttyEmulator
|
||||
|
||||
@@ -60,65 +94,154 @@ type Child struct {
|
||||
|
||||
owner atomic.Pointer[Owner]
|
||||
|
||||
// lastWrite is the wall time of the most recent PTY-master write.
|
||||
// lastWriteNS is the wall time of the most recent PTY-master write.
|
||||
// SPEC §11 idle heuristic: a pane is idle once nothing has been
|
||||
// written for the preset's threshold (default 1s).
|
||||
lastWriteNS atomic.Int64
|
||||
|
||||
// screenVersion increments on every PTY-out chunk. SPEC §7
|
||||
// get_process_output exposes it so orchestrators can detect changes
|
||||
// without diffing content.
|
||||
screenVersion atomic.Int64
|
||||
|
||||
// ringMu guards ring. The ring buffer carries the last `ringCap`
|
||||
// bytes the PTY produced, used by SPEC §7 read_output stream mode.
|
||||
// bytes the PTY produced, used by SPEC §7 get_process_output stream
|
||||
// mode and search_output scrollback.
|
||||
ringMu sync.Mutex
|
||||
ring []byte
|
||||
ringStart int64 // absolute offset of ring[0]
|
||||
ringWrites int64 // cumulative bytes written
|
||||
|
||||
// portsMu guards ports. Best-effort port detection: regex on stream.
|
||||
portsMu sync.Mutex
|
||||
ports []PortSighting
|
||||
}
|
||||
|
||||
// PortSighting is one entry returned by get_process_ports.
|
||||
type PortSighting struct {
|
||||
Port int `json:"port"`
|
||||
URL string `json:"url,omitempty"`
|
||||
FirstSeenAt string `json:"first_seen_at"`
|
||||
}
|
||||
|
||||
const ringCap = 1 << 20 // 1 MiB per SPEC §5
|
||||
|
||||
func newChild(id, name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, errors.New("child: empty argv")
|
||||
}
|
||||
em, err := vt.NewGhosttyEmulator(cols, rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("child %s emulator: %w", id, err)
|
||||
}
|
||||
p, err := pkgpty.Start(argv, env, cols, rows)
|
||||
if err != nil {
|
||||
em.Close()
|
||||
return nil, fmt.Errorf("child %s pty: %w", id, err)
|
||||
}
|
||||
// newChildEntry builds the in-memory Child record but does NOT start a
|
||||
// PTY. Used so command entries can exist in the `stopped` state from the
|
||||
// moment they're created. Agents and terminals call newChild() which
|
||||
// chains newChildEntry + startPTY for the initial run.
|
||||
func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID, workDir, presetRef string) *Child {
|
||||
c := &Child{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Argv: argv,
|
||||
Env: env,
|
||||
WorkDir: workDir,
|
||||
Kind: kind,
|
||||
ParentID: parentID,
|
||||
pty: p,
|
||||
em: em,
|
||||
PresetRef: presetRef,
|
||||
ring: make([]byte, 0, ringCap),
|
||||
}
|
||||
st := StatusRunning
|
||||
st := StatusStopped
|
||||
c.status.Store(&st)
|
||||
c.exitCode.Store(-1)
|
||||
// Agents spawned by an orchestrator default to orchestrator-owned;
|
||||
// everything else (top-level, processes) defaults to user. SPEC §6.
|
||||
def := OwnerUser
|
||||
if kind == KindAgent && parentID != "" {
|
||||
def = OwnerOrchestrator
|
||||
}
|
||||
c.owner.Store(&def)
|
||||
|
||||
if kind == KindAgent {
|
||||
c.Identity = mintIdentity()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func newChild(id, name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID, workDir, presetRef string) (*Child, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, errors.New("child: empty argv")
|
||||
}
|
||||
c := newChildEntry(id, name, kind, argv, env, parentID, workDir, presetRef)
|
||||
if err := c.startPTY(cols, rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// startPTY (re)builds the emulator + PTY for this entry. Called by
|
||||
// newChild on initial spawn and by Restart on subsequent runs. The
|
||||
// status transitions stopped/exited → starting → running. On error the
|
||||
// entry returns to errored.
|
||||
func (c *Child) startPTY(cols, rows uint16) error {
|
||||
em, err := vt.NewGhosttyEmulator(cols, rows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("child %s emulator: %w", c.ID, err)
|
||||
}
|
||||
starting := StatusStarting
|
||||
c.status.Store(&starting)
|
||||
p, err := pkgpty.Start(c.Argv, c.Env, cols, rows)
|
||||
if err != nil {
|
||||
em.Close()
|
||||
errored := StatusErrored
|
||||
c.status.Store(&errored)
|
||||
return fmt.Errorf("child %s pty: %w", c.ID, err)
|
||||
}
|
||||
em.OnWritePTY(func(b []byte) {
|
||||
_, _ = p.Write(b)
|
||||
})
|
||||
return c, nil
|
||||
c.ptyMu.Lock()
|
||||
c.pty = p
|
||||
c.em = em
|
||||
c.ptyMu.Unlock()
|
||||
running := StatusRunning
|
||||
c.status.Store(&running)
|
||||
c.exitCode.Store(-1)
|
||||
c.lastWriteNS.Store(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsLive reports whether the PTY is currently attached and running.
|
||||
// Used by callers that need to gate input on a live PTY (vs. a stopped
|
||||
// command entry).
|
||||
func (c *Child) IsLive() bool {
|
||||
st := c.Status()
|
||||
return st == StatusStarting || st == StatusRunning
|
||||
}
|
||||
|
||||
// PTY returns the current PTY pointer under read-lock. May be nil for a
|
||||
// stopped command entry.
|
||||
func (c *Child) PTY() *pkgpty.PTY {
|
||||
c.ptyMu.RLock()
|
||||
defer c.ptyMu.RUnlock()
|
||||
return c.pty
|
||||
}
|
||||
|
||||
// Emulator returns the current emulator pointer under read-lock.
|
||||
func (c *Child) Emulator() *vt.GhosttyEmulator {
|
||||
c.ptyMu.RLock()
|
||||
defer c.ptyMu.RUnlock()
|
||||
return c.em
|
||||
}
|
||||
|
||||
// DisplayName is the rename_process-aware accessor for Name. Callers
|
||||
// that read Name directly skip the lock; the field is still safe to
|
||||
// read because Go strings are immutable, but DisplayName signals intent.
|
||||
func (c *Child) DisplayName() string {
|
||||
c.nameMu.RLock()
|
||||
defer c.nameMu.RUnlock()
|
||||
return c.Name
|
||||
}
|
||||
|
||||
// SetName updates the display name (rename_process).
|
||||
func (c *Child) SetName(name string) {
|
||||
c.nameMu.Lock()
|
||||
c.Name = name
|
||||
c.nameMu.Unlock()
|
||||
}
|
||||
|
||||
// ScreenVersion returns the current emulator snapshot version, bumped
|
||||
// on every PTY-out chunk.
|
||||
func (c *Child) ScreenVersion() int64 { return c.screenVersion.Load() }
|
||||
|
||||
func (c *Child) Status() ChildStatus {
|
||||
st := c.status.Load()
|
||||
if st == nil {
|
||||
@@ -129,7 +252,13 @@ func (c *Child) Status() ChildStatus {
|
||||
|
||||
func (c *Child) ExitCode() int { return int(c.exitCode.Load()) }
|
||||
|
||||
func (c *Child) PID() int { return c.pty.Pid() }
|
||||
func (c *Child) PID() int {
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return 0
|
||||
}
|
||||
return pty.Pid()
|
||||
}
|
||||
|
||||
func (c *Child) Owner() Owner {
|
||||
o := c.owner.Load()
|
||||
@@ -153,8 +282,8 @@ func (c *Child) IdleMS() int64 {
|
||||
|
||||
func (c *Child) recordWrite(chunk []byte) {
|
||||
c.lastWriteNS.Store(time.Now().UnixNano())
|
||||
c.screenVersion.Add(1)
|
||||
c.ringMu.Lock()
|
||||
defer c.ringMu.Unlock()
|
||||
c.ring = append(c.ring, chunk...)
|
||||
c.ringWrites += int64(len(chunk))
|
||||
if len(c.ring) > ringCap {
|
||||
@@ -162,6 +291,52 @@ func (c *Child) recordWrite(chunk []byte) {
|
||||
c.ring = c.ring[drop:]
|
||||
c.ringStart += int64(drop)
|
||||
}
|
||||
c.ringMu.Unlock()
|
||||
c.scanPortsFromChunk(chunk)
|
||||
}
|
||||
|
||||
// scanPortsFromChunk does best-effort port detection on a PTY chunk.
|
||||
// SPEC §7 get_process_ports — no probing, just stream scanning.
|
||||
func (c *Child) scanPortsFromChunk(chunk []byte) {
|
||||
matches := portRegex.FindAllSubmatch(chunk, -1)
|
||||
if len(matches) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
c.portsMu.Lock()
|
||||
defer c.portsMu.Unlock()
|
||||
for _, m := range matches {
|
||||
urlForm := string(m[0])
|
||||
portStr := string(m[1])
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
continue
|
||||
}
|
||||
seen := false
|
||||
for _, p := range c.ports {
|
||||
if p.Port == port {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if seen {
|
||||
continue
|
||||
}
|
||||
ent := PortSighting{Port: port, FirstSeenAt: now}
|
||||
if strings.HasPrefix(urlForm, "http") {
|
||||
ent.URL = urlForm
|
||||
}
|
||||
c.ports = append(c.ports, ent)
|
||||
}
|
||||
}
|
||||
|
||||
// Ports returns a snapshot of detected port sightings.
|
||||
func (c *Child) Ports() []PortSighting {
|
||||
c.portsMu.Lock()
|
||||
defer c.portsMu.Unlock()
|
||||
out := make([]PortSighting, len(c.ports))
|
||||
copy(out, c.ports)
|
||||
return out
|
||||
}
|
||||
|
||||
// StreamRead returns ring bytes from `since` to the current write head,
|
||||
@@ -185,7 +360,11 @@ func (c *Child) StreamRead(since int64) ([]byte, int64) {
|
||||
}
|
||||
|
||||
func (c *Child) signal(sig syscall.Signal) error {
|
||||
pid := c.pty.Pid()
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return errors.New("child has no pty")
|
||||
}
|
||||
pid := pty.Pid()
|
||||
if pid <= 0 {
|
||||
return errors.New("child has no pid")
|
||||
}
|
||||
@@ -211,20 +390,43 @@ func (c *Child) markExited(err error) {
|
||||
c.status.Store(&st)
|
||||
}
|
||||
|
||||
// teardownPTY closes the current PTY/emulator and nils them out. Used
|
||||
// by Restart so the new PTY can take their place. Safe to call when
|
||||
// they're already nil.
|
||||
func (c *Child) teardownPTY() {
|
||||
c.ptyMu.Lock()
|
||||
p, em := c.pty, c.em
|
||||
c.pty, c.em = nil, nil
|
||||
c.ptyMu.Unlock()
|
||||
if p != nil {
|
||||
_ = p.Close()
|
||||
}
|
||||
if em != nil {
|
||||
_ = em.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// InjectAsUser is the path the human takes when typing in the focused
|
||||
// pane. SPEC §6: the user's first keystroke flips ownership.
|
||||
func (c *Child) InjectAsUser(b []byte) error {
|
||||
c.SetOwner(OwnerUser)
|
||||
_, err := c.pty.Write(b)
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return errors.New("child has no pty")
|
||||
}
|
||||
_, err := pty.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
// InjectAsOrchestrator is the path send_message_to / report_to_parent /
|
||||
// initial_prompt / timer_wait writes take. Ownership flips back to
|
||||
// orchestrator. SPEC §6.
|
||||
// InjectAsOrchestrator is the path send_message / initial_prompt /
|
||||
// timer_wait writes take. Ownership flips back to orchestrator. SPEC §6.
|
||||
func (c *Child) InjectAsOrchestrator(b []byte) error {
|
||||
c.SetOwner(OwnerOrchestrator)
|
||||
_, err := c.pty.Write(b)
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return errors.New("child has no pty")
|
||||
}
|
||||
_, err := pty.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -233,3 +435,12 @@ func mintIdentity() string {
|
||||
_, _ = rand.Read(buf[:])
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
// mintProcessID generates the opaque short token SPEC §7 calls a
|
||||
// process_id: lowercase `p_` followed by 6 hex chars. Collisions inside
|
||||
// one session are checked by the caller (session.go).
|
||||
func mintProcessID() string {
|
||||
var buf [3]byte
|
||||
_, _ = rand.Read(buf[:])
|
||||
return "p_" + hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
1006
internal/app/host.go
1006
internal/app/host.go
File diff suppressed because it is too large
Load Diff
99
internal/app/host_test.go
Normal file
99
internal/app/host_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mkChild builds a Child without starting a PTY. Use sparingly — the
|
||||
// resulting child has no emulator / ring buffer / status pointer set
|
||||
// the way newChild would.
|
||||
func mkChild(id, name, parent string) *Child {
|
||||
return &Child{ID: id, Name: name, ParentID: parent}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageOrchestratorToChild(t *testing.T) {
|
||||
parent := mkChild("p_aaa", "claude-1", "")
|
||||
child := mkChild("p_bbb", "codex-1", "p_aaa")
|
||||
|
||||
line, err := classifySendMessage(parent, child, parent.ID, "hi child")
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "[orchestrator] hi child") {
|
||||
t.Fatalf("parent→child should tag [orchestrator], got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageChildToParent(t *testing.T) {
|
||||
parent := mkChild("p_aaa", "claude-1", "")
|
||||
child := mkChild("p_bbb", "codex-1", "p_aaa")
|
||||
|
||||
line, err := classifySendMessage(child, parent, child.ID, "status update")
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if !strings.Contains(line, "[sub-agent:codex-1]") {
|
||||
t.Fatalf("child→parent should tag [sub-agent:<name>], got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageSiblingRejected(t *testing.T) {
|
||||
parent := mkChild("p_aaa", "claude-1", "")
|
||||
sibA := mkChild("p_bbb", "codex-1", "p_aaa")
|
||||
sibB := mkChild("p_ccc", "codex-2", "p_aaa")
|
||||
_ = parent
|
||||
|
||||
_, err := classifySendMessage(sibA, sibB, sibA.ID, "hey")
|
||||
if err == nil {
|
||||
t.Fatalf("sibling send_message should fail with not_related")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "neither parent nor child") {
|
||||
t.Fatalf("error should mention sibling routing rule, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageUnrelatedRejected(t *testing.T) {
|
||||
// Two unrelated top-level processes — they have no shared lineage.
|
||||
a := mkChild("p_aaa", "claude-1", "")
|
||||
b := mkChild("p_bbb", "codex-1", "")
|
||||
|
||||
_, err := classifySendMessage(a, b, a.ID, "hi")
|
||||
if err == nil {
|
||||
t.Fatalf("unrelated top-level send_message should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageCannotSendToSelf(t *testing.T) {
|
||||
c := mkChild("p_aaa", "claude-1", "")
|
||||
_, err := classifySendMessage(c, c, c.ID, "talking to myself")
|
||||
if err == nil {
|
||||
t.Fatalf("self-send should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageNilCallerAcceptsTopLevel(t *testing.T) {
|
||||
// Caller arrived over an MCP connection without a resolved patterm
|
||||
// identity (top-level tool client). Target is top-level; should
|
||||
// land as [orchestrator].
|
||||
target := mkChild("p_aaa", "claude-1", "")
|
||||
line, err := classifySendMessage(nil, target, "" /* unknown caller id */, "go")
|
||||
if err != nil {
|
||||
t.Fatalf("expected success for nil-caller → top-level target, got %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "[orchestrator] go") {
|
||||
t.Fatalf("expected [orchestrator] tag, got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySendMessageNilCallerRejectsNonTopLevelTarget(t *testing.T) {
|
||||
parent := mkChild("p_aaa", "claude-1", "")
|
||||
child := mkChild("p_bbb", "codex-1", "p_aaa")
|
||||
_ = parent
|
||||
|
||||
_, err := classifySendMessage(nil, child, "", "hi")
|
||||
if err == nil {
|
||||
t.Fatalf("nil caller → non-top-level should fail")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,15 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
|
||||
|
||||
// Spawn with the chosen identity.
|
||||
cols, rows := l.size()
|
||||
c, err := l.sess.spawnWithIdentity(displayName, KindAgent, argv, env, cols, rows, parentID, identity)
|
||||
c, err := l.sess.Spawn(SpawnSpec{
|
||||
Kind: KindAgent,
|
||||
Argv: argv,
|
||||
Env: env,
|
||||
Name: displayName,
|
||||
ParentID: parentID,
|
||||
PresetRef: p.Name,
|
||||
Identity: identity,
|
||||
}, cols, rows)
|
||||
if err != nil {
|
||||
_ = os.Remove(mcpConfigPath)
|
||||
return nil, err
|
||||
@@ -111,17 +119,72 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// LaunchProcess spawns a process preset. No MCP injection; just argv.
|
||||
func (l *Launcher) LaunchProcess(p *preset.Preset, displayName string) (*Child, error) {
|
||||
if p.Kind != preset.KindProcess {
|
||||
return nil, fmt.Errorf("launch: %q is not a process preset", p.Name)
|
||||
// LaunchCommandPreset spawns a process preset as a SPEC §7 command
|
||||
// entry. No MCP injection; just argv. The entry is session-persistent
|
||||
// (survives PTY exit so it can be Restart'd).
|
||||
func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID string) (*Child, error) {
|
||||
if p.Kind != preset.KindCommand {
|
||||
return nil, fmt.Errorf("launch: %q is not a command preset", p.Name)
|
||||
}
|
||||
env := l.sess.ChildEnv()
|
||||
for k, v := range p.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
cols, rows := l.size()
|
||||
return l.sess.Spawn(displayName, KindProcess, p.ResolvedArgv(), env, cols, rows, "")
|
||||
return l.sess.Spawn(SpawnSpec{
|
||||
Kind: KindCommand,
|
||||
Argv: p.ResolvedArgv(),
|
||||
Env: env,
|
||||
Name: displayName,
|
||||
ParentID: parentID,
|
||||
WorkDir: p.WorkingDir,
|
||||
PresetRef: p.Name,
|
||||
}, cols, rows)
|
||||
}
|
||||
|
||||
// LaunchCommandArgv spawns a freeform-argv command entry. Trust gating
|
||||
// (SPEC §7) lives one level up in toolHost — by the time we get here
|
||||
// trust is settled (freeform argv is implicitly trusted).
|
||||
func (l *Launcher) LaunchCommandArgv(argv []string, displayName, parentID, workDir string, env []string, shell bool) (*Child, error) {
|
||||
if shell && len(argv) > 0 {
|
||||
argv = []string{"sh", "-lc", strings.Join(argv, " ")}
|
||||
}
|
||||
if env == nil {
|
||||
env = l.sess.ChildEnv()
|
||||
}
|
||||
cols, rows := l.size()
|
||||
return l.sess.Spawn(SpawnSpec{
|
||||
Kind: KindCommand,
|
||||
Argv: argv,
|
||||
Env: env,
|
||||
Name: displayName,
|
||||
ParentID: parentID,
|
||||
WorkDir: workDir,
|
||||
}, cols, rows)
|
||||
}
|
||||
|
||||
// LaunchTerminal spawns a bare interactive shell. SPEC §7 kind=terminal.
|
||||
// argv defaults to $SHELL -i when empty.
|
||||
func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir string, env []string) (*Child, error) {
|
||||
if len(argv) == 0 {
|
||||
sh := os.Getenv("SHELL")
|
||||
if sh == "" {
|
||||
sh = "/bin/sh"
|
||||
}
|
||||
argv = []string{sh, "-i"}
|
||||
}
|
||||
if env == nil {
|
||||
env = l.sess.ChildEnv()
|
||||
}
|
||||
cols, rows := l.size()
|
||||
return l.sess.Spawn(SpawnSpec{
|
||||
Kind: KindTerminal,
|
||||
Argv: argv,
|
||||
Env: env,
|
||||
Name: displayName,
|
||||
ParentID: parentID,
|
||||
WorkDir: workDir,
|
||||
}, cols, rows)
|
||||
}
|
||||
|
||||
func (l *Launcher) writeMCPConfig() (identity, path string, err error) {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/harrybrwn/patterm/internal/vt"
|
||||
)
|
||||
@@ -29,7 +29,10 @@ type Session struct {
|
||||
children map[string]*Child
|
||||
order []string
|
||||
|
||||
nextChildSeq atomic.Int64
|
||||
// nameSeq tracks the default-name counter per kind (agent-1,
|
||||
// command-2, terminal-3, …). Reset is a non-goal: counters are
|
||||
// monotonic across the session lifetime.
|
||||
nameSeq map[ChildKind]int
|
||||
|
||||
// listeners is the set of UI listeners that want to hear about child
|
||||
// lifecycle events (spawn/exit) — exactly one (the TUI) in v1.
|
||||
@@ -53,6 +56,7 @@ func NewSession(projectDir, projectKey string) *Session {
|
||||
projectDir: projectDir,
|
||||
projectKey: projectKey,
|
||||
children: make(map[string]*Child),
|
||||
nameSeq: make(map[ChildKind]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,24 +106,45 @@ func (s *Session) ChildEnv() []string {
|
||||
return env
|
||||
}
|
||||
|
||||
// Spawn launches a new child with the given argv. kind is "agent" or
|
||||
// "process". parentID names the calling session/child for orchestrator
|
||||
// trees ("" for top-level). env is the full child environment; the
|
||||
// caller is responsible for adding preset-specific overrides.
|
||||
func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) {
|
||||
// SpawnSpec is the argument record for Session.Spawn — the new
|
||||
// argv-shaped spawn API matching SPEC §7 spawn_process.
|
||||
type SpawnSpec struct {
|
||||
Kind ChildKind
|
||||
Argv []string
|
||||
Env []string
|
||||
WorkDir string
|
||||
Name string
|
||||
ParentID string
|
||||
PresetRef string
|
||||
Identity string // pre-minted; otherwise the constructor mints one for agents
|
||||
}
|
||||
|
||||
// Spawn creates a new entry and starts its PTY. For Kind = command the
|
||||
// entry remains in the session after PTY exit (it can be Restart'd).
|
||||
// For agent/terminal the entry's lifetime equals the PTY's: reapChild
|
||||
// fires emitExit and the entry stays in `exited` status until the
|
||||
// caller `close_process`'s it.
|
||||
func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
|
||||
if len(spec.Argv) == 0 {
|
||||
return nil, errors.New("session.Spawn: empty argv")
|
||||
}
|
||||
if spec.Env == nil {
|
||||
spec.Env = s.ChildEnv()
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
id := fmt.Sprintf("c%d", s.nextChildSeq.Add(1))
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s-%s", kind, id)
|
||||
id := s.mintUniqueIDLocked()
|
||||
s.nameSeq[spec.Kind]++
|
||||
if spec.Name == "" {
|
||||
spec.Name = fmt.Sprintf("%s-%d", spec.Kind, s.nameSeq[spec.Kind])
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if env == nil {
|
||||
env = s.ChildEnv()
|
||||
c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.WorkDir, spec.PresetRef)
|
||||
if spec.Identity != "" {
|
||||
c.Identity = spec.Identity
|
||||
}
|
||||
|
||||
c, err := newChild(id, name, kind, argv, env, cols, rows, parentID)
|
||||
if err != nil {
|
||||
if err := c.startPTY(cols, rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -134,28 +159,149 @@ func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, r
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// spawnWithIdentity is like Spawn but lets the launcher pre-mint the
|
||||
// MCP identity so the config file can be written before the process
|
||||
// starts.
|
||||
func (s *Session) spawnWithIdentity(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID, identity string) (*Child, error) {
|
||||
c, err := s.Spawn(name, kind, argv, env, cols, rows, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// AddCommandEntry registers a command entry without starting it. Used
|
||||
// by spawn_process(kind: command) when SPEC §7 needs the entry to exist
|
||||
// in `stopped` state first (we always start it after; the indirection
|
||||
// is here so future versions can support deferred starts).
|
||||
func (s *Session) AddCommandEntry(spec SpawnSpec) *Child {
|
||||
s.mu.Lock()
|
||||
id := s.mintUniqueIDLocked()
|
||||
s.nameSeq[spec.Kind]++
|
||||
if spec.Name == "" {
|
||||
spec.Name = fmt.Sprintf("%s-%d", spec.Kind, s.nameSeq[spec.Kind])
|
||||
}
|
||||
if spec.Env == nil {
|
||||
spec.Env = s.ChildEnv()
|
||||
}
|
||||
c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.WorkDir, spec.PresetRef)
|
||||
s.children[id] = c
|
||||
s.order = append(s.order, id)
|
||||
s.mu.Unlock()
|
||||
s.emitSpawn(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// Start (re)attaches a PTY to an entry that is currently stopped or
|
||||
// exited. Errors if the entry is already live.
|
||||
func (s *Session) Start(id string, cols, rows uint16) error {
|
||||
c := s.FindChild(id)
|
||||
if c == nil {
|
||||
return fmt.Errorf("no such process %q", id)
|
||||
}
|
||||
if c.IsLive() {
|
||||
return nil // SPEC §7 start_process is a no-op on a running entry
|
||||
}
|
||||
if err := c.startPTY(cols, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
go s.pumpChild(c)
|
||||
go s.reapChild(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart stops the entry (if live) then starts it again with the same
|
||||
// argv/env/workdir. Per SPEC §7: valid for command entries; valid for
|
||||
// agent/terminal only while their PTY is still live.
|
||||
func (s *Session) Restart(id string, sig syscall.Signal, cols, rows uint16) error {
|
||||
c := s.FindChild(id)
|
||||
if c == nil {
|
||||
return fmt.Errorf("no such process %q", id)
|
||||
}
|
||||
if c.Kind != KindCommand && !c.IsLive() {
|
||||
return fmt.Errorf("restart: %s entries can only be restarted while live", c.Kind)
|
||||
}
|
||||
if c.IsLive() {
|
||||
if sig == 0 {
|
||||
sig = syscall.SIGTERM
|
||||
}
|
||||
_ = c.signal(sig)
|
||||
// Wait briefly for the reaper to mark exited. We don't need
|
||||
// strict synchronization — the reaper will run regardless; we
|
||||
// just want startPTY to land after teardown.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for c.IsLive() && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if c.IsLive() {
|
||||
// Force.
|
||||
_ = c.signal(syscall.SIGKILL)
|
||||
for c.IsLive() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.teardownPTY()
|
||||
if err := c.startPTY(cols, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
go s.pumpChild(c)
|
||||
go s.reapChild(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close removes an entry from the session entirely. If still live,
|
||||
// stops it first. SPEC §7 close_process.
|
||||
func (s *Session) Close(id string, sig syscall.Signal) error {
|
||||
c := s.FindChild(id)
|
||||
if c == nil {
|
||||
return fmt.Errorf("no such process %q", id)
|
||||
}
|
||||
if c.IsLive() {
|
||||
if sig == 0 {
|
||||
sig = syscall.SIGTERM
|
||||
}
|
||||
_ = c.signal(sig)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for c.IsLive() && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if c.IsLive() {
|
||||
_ = c.signal(syscall.SIGKILL)
|
||||
for c.IsLive() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.teardownPTY()
|
||||
s.mu.Lock()
|
||||
delete(s.children, id)
|
||||
for i, oid := range s.order {
|
||||
if oid == id {
|
||||
s.order = append(s.order[:i], s.order[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// mintUniqueIDLocked mints an opaque process_id (SPEC §7) and retries
|
||||
// if it collides with an existing entry. Caller holds s.mu.
|
||||
func (s *Session) mintUniqueIDLocked() string {
|
||||
for {
|
||||
id := mintProcessID()
|
||||
if _, exists := s.children[id]; !exists {
|
||||
return id
|
||||
}
|
||||
}
|
||||
c.Identity = identity
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Session) pumpChild(c *Child) {
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
n, err := c.pty.Read(buf)
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return
|
||||
}
|
||||
n, err := pty.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
if _, werr := c.em.Write(chunk); werr != nil {
|
||||
if em := c.Emulator(); em != nil {
|
||||
if _, werr := em.Write(chunk); werr != nil {
|
||||
logf("emulator.Write(child %s): %v", c.ID, werr)
|
||||
}
|
||||
}
|
||||
c.recordWrite(chunk)
|
||||
s.emitPTYOut(c.ID, chunk)
|
||||
}
|
||||
@@ -169,7 +315,11 @@ func (s *Session) pumpChild(c *Child) {
|
||||
}
|
||||
|
||||
func (s *Session) reapChild(c *Child) {
|
||||
err := c.pty.Wait()
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return
|
||||
}
|
||||
err := pty.Wait()
|
||||
c.markExited(err)
|
||||
logf("child %s exited (err=%v)", c.ID, err)
|
||||
s.emitExit(c)
|
||||
@@ -233,7 +383,11 @@ func (s *Session) WriteInput(id string, b []byte) error {
|
||||
if c.Status() != StatusRunning {
|
||||
return fmt.Errorf("child %q is %s", id, c.Status())
|
||||
}
|
||||
_, err := c.pty.Write(b)
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return fmt.Errorf("child %q has no pty", id)
|
||||
}
|
||||
_, err := pty.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -250,8 +404,12 @@ func (s *Session) ResizeAll(cols, rows uint16) {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, c := range cs {
|
||||
_ = c.pty.Resize(cols, rows)
|
||||
_ = c.em.Resize(cols, rows)
|
||||
if pty := c.PTY(); pty != nil {
|
||||
_ = pty.Resize(cols, rows)
|
||||
}
|
||||
if em := c.Emulator(); em != nil {
|
||||
_ = em.Resize(cols, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +421,11 @@ func (s *Session) SerializeChild(id string) ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("no such child %q", id)
|
||||
}
|
||||
return c.em.SerializeVT()
|
||||
em := c.Emulator()
|
||||
if em == nil {
|
||||
return nil, fmt.Errorf("child %q has no emulator", id)
|
||||
}
|
||||
return em.SerializeVT()
|
||||
}
|
||||
|
||||
func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
|
||||
@@ -271,11 +433,15 @@ func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
|
||||
if c == nil {
|
||||
return "", vt.CursorState{}, fmt.Errorf("no such child %q", id)
|
||||
}
|
||||
text, err := c.em.ScreenText()
|
||||
em := c.Emulator()
|
||||
if em == nil {
|
||||
return "", vt.CursorState{}, fmt.Errorf("child %q has no emulator", id)
|
||||
}
|
||||
text, err := em.ScreenText()
|
||||
if err != nil {
|
||||
return "", vt.CursorState{}, err
|
||||
}
|
||||
cursor, err := c.em.Cursor()
|
||||
cursor, err := em.Cursor()
|
||||
if err != nil {
|
||||
return "", vt.CursorState{}, err
|
||||
}
|
||||
@@ -297,8 +463,7 @@ func (s *Session) Shutdown() {
|
||||
// Close emulators and PTY masters. The reaper goroutines will fire
|
||||
// emitExit as Wait() returns.
|
||||
for _, c := range cs {
|
||||
_ = c.pty.Close()
|
||||
_ = c.em.Close()
|
||||
c.teardownPTY()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,42 +9,224 @@ import (
|
||||
"github.com/harrybrwn/patterm/internal/scratchpad"
|
||||
)
|
||||
|
||||
// ToolHost is the interface the in-process server uses to reach the
|
||||
// running patterm process's state. The app package implements this so
|
||||
// internal/mcp doesn't import internal/app (which would be a cycle).
|
||||
type ToolHost interface {
|
||||
Children() []ChildInfo
|
||||
Spawn(callerID, name string, argv []string, shell bool) (ChildInfo, error)
|
||||
SpawnAgent(callerID, presetName, displayName, initialPrompt string) (ChildInfo, error)
|
||||
ReadOutput(callerID, childID, mode string, sinceOffset int) (content string, newOffset int, err error)
|
||||
SendInput(callerID, childID string, payload []byte, appendNewline bool) error
|
||||
Kill(callerID, childID string, sig syscall.Signal) error
|
||||
SendMessageTo(callerID, targetID, message string) error
|
||||
ReportToParent(callerID, message string) error
|
||||
TimerWait(callerID string, seconds float64, label string) (string, error)
|
||||
WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (matched bool, snippet string, err error)
|
||||
RequestHumanAttention(callerID, childID, reason string) error
|
||||
Scratchpads() *scratchpad.Store
|
||||
// JSON-RPC error codes used by the patterm MCP surface. -32700..-32600
|
||||
// are the standard parse/invalid-request codes; the SPEC-defined error
|
||||
// names live in the -32000 range with a structured `data.kind` so the
|
||||
// caller can branch on the error type rather than parsing strings.
|
||||
const (
|
||||
codeParseError = -32700
|
||||
codeInvalidRequest = -32600
|
||||
codeMethodNotFound = -32601
|
||||
codeInvalidParams = -32602
|
||||
codeInternal = -32000
|
||||
codeNeedsTrust = -32010
|
||||
codeRoleForbidden = -32011
|
||||
codeNotRelated = -32012
|
||||
codeNotFound = -32013
|
||||
codeWrongKind = -32014
|
||||
codeUnknownAgent = -32015
|
||||
)
|
||||
|
||||
// ResolveCallerIdentity translates a per-spawn identity token into
|
||||
// the child ID the server stores in its connection state.
|
||||
// CallerRole is one of "orchestrator" or "sub-agent". SPEC §7 caller
|
||||
// role: orchestrators sit at the root of a session tree; sub-agents
|
||||
// were spawned by another agent.
|
||||
type CallerRole string
|
||||
|
||||
const (
|
||||
RoleOrchestrator CallerRole = "orchestrator"
|
||||
RoleSubAgent CallerRole = "sub-agent"
|
||||
)
|
||||
|
||||
// ToolHost is the interface the in-process server uses to reach the
|
||||
// running patterm process's state. internal/app implements this; the
|
||||
// split keeps internal/mcp free of an internal/app import (which would
|
||||
// be cyclic).
|
||||
type ToolHost interface {
|
||||
// Identity resolution. The mcp-stdio greeting carries a per-spawn
|
||||
// token; the server resolves it to a process_id before dispatching
|
||||
// the rest of the connection's calls.
|
||||
ResolveCallerIdentity(identity string) string
|
||||
|
||||
// PolicyCheck — SPEC §9. Returns "allow" / "punt" / "unknown" for
|
||||
// a candidate auto-answer prompt the orchestrator is reading.
|
||||
PolicyCheck(prompt string) string
|
||||
// CallerRole returns the role for the given process_id. Unknown
|
||||
// callers default to RoleOrchestrator (treated as a top-level peer)
|
||||
// so they don't get silently denied.
|
||||
CallerRole(processID string) CallerRole
|
||||
|
||||
// Lifecycle (SPEC §7).
|
||||
SpawnAgent(callerID string, args SpawnAgentArgs) (ProcessInfo, error)
|
||||
SpawnProcess(callerID string, args SpawnProcessArgs) (ProcessInfo, error)
|
||||
StartProcess(callerID, processID string) (ProcessInfo, error)
|
||||
RestartProcess(callerID, processID string, signal syscall.Signal) (ProcessInfo, error)
|
||||
StopProcess(callerID, processID string, signal syscall.Signal) (ProcessInfo, error)
|
||||
CloseProcess(callerID, processID string) error
|
||||
RenameProcess(callerID, processID, name string) error
|
||||
SelectProcess(callerID, processID string) error
|
||||
|
||||
// Inspection.
|
||||
ListProcesses(callerID, kindFilter string) []ProcessInfo
|
||||
GetProcessStatus(callerID, processID string) (ProcessStatus, error)
|
||||
GetProjectStatus(callerID string) (ProjectStatus, error)
|
||||
GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (ProcessOutput, error)
|
||||
GetProcessRawOutput(callerID, processID string, sinceOffset int64) (RawOutput, error)
|
||||
SearchOutput(callerID, processID, pattern, kind string, limit int) (SearchResult, error)
|
||||
WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (matched bool, snippet string, err error)
|
||||
GetProcessPorts(callerID, processID string) ([]PortSighting, error)
|
||||
|
||||
// I/O.
|
||||
SendInput(callerID string, args SendInputArgs) (SendInputResult, error)
|
||||
|
||||
// Coordination.
|
||||
SendMessage(callerID, targetID, message string) error
|
||||
RequestHumanAttention(callerID, processID, reason string) error
|
||||
TimerWait(callerID string, seconds float64, label string) (string, error)
|
||||
|
||||
// Scratchpads.
|
||||
Scratchpads() *scratchpad.Store
|
||||
|
||||
// Meta.
|
||||
WhoAmI(callerID string) WhoAmI
|
||||
Help(callerID, topic string) HelpResponse
|
||||
}
|
||||
|
||||
// ChildInfo is what list_children / spawn_process / spawn_agent return.
|
||||
// Matches SPEC §7 shape plus the §11 idle exposure.
|
||||
type ChildInfo struct {
|
||||
ID string `json:"child_id"`
|
||||
// ProcessInfo is the entry shape returned by list_processes, spawn_*,
|
||||
// stop_process, restart_process, start_process. SPEC §7.
|
||||
type ProcessInfo struct {
|
||||
ID string `json:"process_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
ExitCode int `json:"exit_code,omitempty"`
|
||||
ParentProcessID string `json:"parent_process_id,omitempty"`
|
||||
ExitCode *int `json:"exit_code,omitempty"`
|
||||
IdleMS int64 `json:"idle_ms,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Trusted *bool `json:"trusted,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessStatus is what get_process_status returns. Richer than
|
||||
// ProcessInfo: includes pane geometry, cursor, and active screen.
|
||||
type ProcessStatus struct {
|
||||
ProcessInfo
|
||||
WorkingDir string `json:"working_dir,omitempty"`
|
||||
Argv []string `json:"argv,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
ActiveScreen string `json:"active_screen,omitempty"`
|
||||
Rows int `json:"rows,omitempty"`
|
||||
Cols int `json:"cols,omitempty"`
|
||||
Cursor Cursor `json:"cursor"`
|
||||
ScreenVersion int64 `json:"screen_version,omitempty"`
|
||||
}
|
||||
|
||||
// Cursor matches SPEC §7's `{x, y}` payload.
|
||||
type Cursor struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
// ProjectStatus is what get_project_status returns — everything an
|
||||
// agent needs to orient itself in one call.
|
||||
type ProjectStatus struct {
|
||||
Project ProjectMeta `json:"project"`
|
||||
Caller WhoAmI `json:"caller"`
|
||||
Processes []ProcessInfo `json:"processes"`
|
||||
Scratchpads []scratchpad.Entry `json:"scratchpads"`
|
||||
}
|
||||
|
||||
// ProjectMeta is the project root info echoed in many payloads.
|
||||
type ProjectMeta struct {
|
||||
Path string `json:"path"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// ProcessOutput is the get_process_output payload. SPEC §7 enriches
|
||||
// the old read_output result with screen geometry + version.
|
||||
type ProcessOutput struct {
|
||||
Content string `json:"content"`
|
||||
Mode string `json:"mode"`
|
||||
NewOffset int64 `json:"new_offset,omitempty"`
|
||||
ActiveScreen string `json:"active_screen,omitempty"`
|
||||
Rows int `json:"rows,omitempty"`
|
||||
Cols int `json:"cols,omitempty"`
|
||||
Cursor Cursor `json:"cursor"`
|
||||
IdleMS int64 `json:"idle_ms,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ScreenVersion int64 `json:"screen_version,omitempty"`
|
||||
}
|
||||
|
||||
// RawOutput is the get_process_raw_output payload — ANSI preserved.
|
||||
type RawOutput struct {
|
||||
Content string `json:"content"`
|
||||
NewOffset int64 `json:"new_offset"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// SearchResult is search_output's payload.
|
||||
type SearchResult struct {
|
||||
Matches []SearchMatch `json:"matches"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
type SearchMatch struct {
|
||||
LineNo int `json:"line_no"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// PortSighting matches the per-child store in internal/app.
|
||||
type PortSighting struct {
|
||||
Port int `json:"port"`
|
||||
URL string `json:"url,omitempty"`
|
||||
FirstSeenAt string `json:"first_seen_at"`
|
||||
}
|
||||
|
||||
// SpawnAgentArgs is the input shape for spawn_agent.
|
||||
type SpawnAgentArgs struct {
|
||||
Agent string `json:"agent"`
|
||||
AgentInstructions string `json:"agent_instructions"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SpawnProcessArgs is the input shape for spawn_process.
|
||||
type SpawnProcessArgs struct {
|
||||
Kind string `json:"kind"` // "terminal" | "command"
|
||||
Preset string `json:"preset"`
|
||||
Argv []string `json:"argv"`
|
||||
Name string `json:"name"`
|
||||
WorkingDir string `json:"working_dir"`
|
||||
Env map[string]string `json:"env"`
|
||||
Shell bool `json:"shell"`
|
||||
}
|
||||
|
||||
// SendInputArgs is the input shape for send_input — covers text /
|
||||
// paste / key with the optional wait+tail tail-after-send.
|
||||
type SendInputArgs struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Kind string `json:"kind"` // "text" | "paste" | "key"
|
||||
Text string `json:"text"`
|
||||
Key string `json:"key"`
|
||||
Submit *bool `json:"submit"`
|
||||
WaitMS int `json:"wait_ms"`
|
||||
TailMode string `json:"tail_mode"` // "none" | "stream" | "grid"
|
||||
}
|
||||
|
||||
// SendInputResult is the return shape of send_input.
|
||||
type SendInputResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Tail *ProcessOutput `json:"tail,omitempty"`
|
||||
}
|
||||
|
||||
// WhoAmI is the whoami return shape.
|
||||
type WhoAmI struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Name string `json:"name"`
|
||||
Role CallerRole `json:"role"`
|
||||
ParentProcessID string `json:"parent_process_id,omitempty"`
|
||||
Project ProjectMeta `json:"project"`
|
||||
AvailableTools []string `json:"available_tools"`
|
||||
}
|
||||
|
||||
// HelpResponse is the help return shape.
|
||||
type HelpResponse struct {
|
||||
Topic string `json:"topic"`
|
||||
Content string `json:"content"`
|
||||
RelatedTools []string `json:"related_tools,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) SetHost(h ToolHost) {
|
||||
@@ -54,7 +236,7 @@ func (s *Server) SetHost(h ToolHost) {
|
||||
}
|
||||
|
||||
// dispatch routes a single JSON-RPC request. callerID is the ID of the
|
||||
// child that owns this connection (resolved at greeting time).
|
||||
// process that owns this connection (resolved at greeting time).
|
||||
func (s *Server) dispatch(callerID string, req []byte) []byte {
|
||||
var msg struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
@@ -63,152 +245,271 @@ func (s *Server) dispatch(callerID string, req []byte) []byte {
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(req, &msg); err != nil {
|
||||
return jsonRPCError(nil, -32700, "parse error: "+err.Error())
|
||||
return jsonRPCError(nil, codeParseError, "parse error: "+err.Error(), nil)
|
||||
}
|
||||
s.mu.Lock()
|
||||
host := s.host
|
||||
s.mu.Unlock()
|
||||
if host == nil {
|
||||
return jsonRPCError(msg.ID, -32000, "patterm: tool host not initialized")
|
||||
return jsonRPCError(msg.ID, codeInternal, "patterm: tool host not initialized", nil)
|
||||
}
|
||||
|
||||
result, code, errMsg := callTool(host, callerID, msg.Method, msg.Params)
|
||||
result, code, errMsg, data := callTool(host, callerID, msg.Method, msg.Params)
|
||||
if errMsg != "" {
|
||||
return jsonRPCError(msg.ID, code, errMsg)
|
||||
return jsonRPCError(msg.ID, code, errMsg, data)
|
||||
}
|
||||
return jsonRPCResult(msg.ID, result)
|
||||
}
|
||||
|
||||
func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, int, string) {
|
||||
// toolError lets host implementations attach a structured `kind` to a
|
||||
// JSON-RPC error so callers can branch on error type without parsing
|
||||
// the message. The MCP layer maps recognized error kinds to their SPEC
|
||||
// error codes; unknown errors fall through to codeInternal.
|
||||
type toolError struct {
|
||||
Kind string `json:"kind"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *toolError) Error() string { return e.Message }
|
||||
|
||||
// Errorf is a convenience for host implementations to return typed
|
||||
// errors that map to JSON-RPC codes (needs_trust, role_forbidden, …).
|
||||
func Errorf(kind, format string, a ...any) error {
|
||||
return &toolError{Kind: kind, Message: fmt.Sprintf(format, a...)}
|
||||
}
|
||||
|
||||
// callTool is the central dispatch. Returns (result, code, errMsg,
|
||||
// errData). On success errMsg is empty.
|
||||
func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, int, string, any) {
|
||||
switch method {
|
||||
case "list_children":
|
||||
return h.Children(), 0, ""
|
||||
case "spawn_agent":
|
||||
var p SpawnAgentArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Agent == "" {
|
||||
return nil, codeInvalidParams, "spawn_agent: agent required", nil
|
||||
}
|
||||
// Role gate: only orchestrators may call spawn_agent (SPEC §8).
|
||||
if h.CallerRole(callerID) == RoleSubAgent {
|
||||
return nil, codeRoleForbidden, "spawn_agent: sub-agents cannot spawn agents; use vendor-native subagent tooling or ask your parent", structuredKind("role_forbidden")
|
||||
}
|
||||
info, err := h.SpawnAgent(callerID, p)
|
||||
return mapToolResult(info, err)
|
||||
|
||||
case "spawn_process":
|
||||
var p SpawnProcessArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
info, err := h.SpawnProcess(callerID, p)
|
||||
return mapToolResult(info, err)
|
||||
|
||||
case "start_process":
|
||||
var p struct{ ProcessID string `json:"process_id"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
info, err := h.StartProcess(callerID, p.ProcessID)
|
||||
return mapToolResult(info, err)
|
||||
|
||||
case "restart_process":
|
||||
var p struct {
|
||||
Preset string `json:"preset"`
|
||||
Argv []string `json:"argv"`
|
||||
Shell bool `json:"shell"`
|
||||
Name string `json:"name"`
|
||||
WorkingDir string `json:"working_dir"`
|
||||
ProcessID string `json:"process_id"`
|
||||
Signal int `json:"signal"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
// Preset-by-name is the preferred path per SPEC §7; argv is the
|
||||
// escape hatch. We don't load process presets here — the host
|
||||
// is the source of truth — so a named preset call is rejected
|
||||
// unless the caller also supplied argv. (Wiring full preset
|
||||
// resolution into MCP is a small follow-up; the host's palette
|
||||
// path covers the named case today.)
|
||||
if len(p.Argv) == 0 {
|
||||
return nil, -32602, "spawn_process: argv required"
|
||||
}
|
||||
ci, err := h.Spawn(callerID, p.Name, p.Argv, p.Shell)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return ci, 0, ""
|
||||
sig := syscall.Signal(p.Signal)
|
||||
info, err := h.RestartProcess(callerID, p.ProcessID, sig)
|
||||
return mapToolResult(info, err)
|
||||
|
||||
case "spawn_agent":
|
||||
case "stop_process":
|
||||
var p struct {
|
||||
Preset string `json:"preset"`
|
||||
InitialPrompt string `json:"initial_prompt"`
|
||||
ProcessID string `json:"process_id"`
|
||||
Signal int `json:"signal"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
sig := syscall.Signal(p.Signal)
|
||||
info, err := h.StopProcess(callerID, p.ProcessID, sig)
|
||||
return mapToolResult(info, err)
|
||||
|
||||
case "close_process":
|
||||
var p struct{ ProcessID string `json:"process_id"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if err := h.CloseProcess(callerID, p.ProcessID); err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return "ok", 0, "", nil
|
||||
|
||||
case "rename_process":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Preset == "" {
|
||||
return nil, -32602, "spawn_agent: preset required"
|
||||
if err := h.RenameProcess(callerID, p.ProcessID, p.Name); err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
ci, err := h.SpawnAgent(callerID, p.Preset, p.Name, p.InitialPrompt)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return ci, 0, ""
|
||||
return "ok", 0, "", nil
|
||||
|
||||
case "read_output":
|
||||
case "select_process":
|
||||
var p struct{ ProcessID string `json:"process_id"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if err := h.SelectProcess(callerID, p.ProcessID); err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return "ok", 0, "", nil
|
||||
|
||||
case "list_processes":
|
||||
var p struct{ Kind string `json:"kind"` }
|
||||
_ = unmarshalParamsOptional(params, &p)
|
||||
return h.ListProcesses(callerID, p.Kind), 0, "", nil
|
||||
|
||||
case "get_process_status":
|
||||
var p struct{ ProcessID string `json:"process_id"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
st, err := h.GetProcessStatus(callerID, p.ProcessID)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return st, 0, "", nil
|
||||
|
||||
case "get_project_status":
|
||||
ps, err := h.GetProjectStatus(callerID)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return ps, 0, "", nil
|
||||
|
||||
case "get_process_output":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
ProcessID string `json:"process_id"`
|
||||
Mode string `json:"mode"`
|
||||
SinceOffset int `json:"since_offset"`
|
||||
SinceOffset int64 `json:"since_offset"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Mode == "" {
|
||||
p.Mode = "grid"
|
||||
}
|
||||
content, newOff, err := h.ReadOutput(callerID, p.ChildID, p.Mode, p.SinceOffset)
|
||||
out, err := h.GetProcessOutput(callerID, p.ProcessID, p.Mode, p.SinceOffset)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
return mapToolError(err)
|
||||
}
|
||||
return map[string]any{
|
||||
"content": content,
|
||||
"new_offset": newOff,
|
||||
"mode": p.Mode,
|
||||
}, 0, ""
|
||||
return out, 0, "", nil
|
||||
|
||||
case "get_process_raw_output":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
SinceOffset int64 `json:"since_offset"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
out, err := h.GetProcessRawOutput(callerID, p.ProcessID, p.SinceOffset)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return out, 0, "", nil
|
||||
|
||||
case "search_output":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Pattern string `json:"pattern"`
|
||||
Kind string `json:"kind"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Limit <= 0 {
|
||||
p.Limit = 20
|
||||
}
|
||||
if p.Kind == "" {
|
||||
p.Kind = "rendered"
|
||||
}
|
||||
res, err := h.SearchOutput(callerID, p.ProcessID, p.Pattern, p.Kind, p.Limit)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return res, 0, "", nil
|
||||
|
||||
case "wait_for_pattern":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Pattern string `json:"pattern"`
|
||||
TimeoutSeconds float64 `json:"timeout_seconds"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
matched, snippet, err := h.WaitForPattern(callerID, p.ProcessID, p.Pattern, p.TimeoutSeconds, p.Scope)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return map[string]any{"matched": matched, "snippet": snippet}, 0, "", nil
|
||||
|
||||
case "get_process_ports":
|
||||
var p struct{ ProcessID string `json:"process_id"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
ports, err := h.GetProcessPorts(callerID, p.ProcessID)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return map[string]any{"ports": ports}, 0, "", nil
|
||||
|
||||
case "send_input":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Input string `json:"input"`
|
||||
AppendNewline *bool `json:"append_newline"`
|
||||
}
|
||||
var p SendInputArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
appendNL := true
|
||||
if p.AppendNewline != nil {
|
||||
appendNL = *p.AppendNewline
|
||||
res, err := h.SendInput(callerID, p)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
if err := h.SendInput(callerID, p.ChildID, []byte(p.Input), appendNL); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
return res, 0, "", nil
|
||||
|
||||
case "kill":
|
||||
case "send_message":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Signal int `json:"signal"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
sig := syscall.Signal(p.Signal)
|
||||
if sig == 0 {
|
||||
sig = syscall.SIGTERM
|
||||
}
|
||||
if err := h.Kill(callerID, p.ChildID, sig); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
|
||||
case "send_message_to":
|
||||
var p struct {
|
||||
Target string `json:"target"`
|
||||
TargetProcessID string `json:"target_process_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if err := h.SendMessageTo(callerID, p.Target, p.Message); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
if err := h.SendMessage(callerID, p.TargetProcessID, p.Message); err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return "ok", 0, ""
|
||||
return "ok", 0, "", nil
|
||||
|
||||
case "report_to_parent":
|
||||
case "request_human_attention":
|
||||
var p struct {
|
||||
Message string `json:"message"`
|
||||
ProcessID string `json:"process_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if err := h.ReportToParent(callerID, p.Message); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
if err := h.RequestHumanAttention(callerID, p.ProcessID, p.Reason); err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return "ok", 0, ""
|
||||
return "ok", 0, "", nil
|
||||
|
||||
case "timer_wait":
|
||||
var p struct {
|
||||
@@ -216,61 +517,31 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
Label string `json:"label"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
id, err := h.TimerWait(callerID, p.Seconds, p.Label)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
return mapToolError(err)
|
||||
}
|
||||
return map[string]string{"timer_id": id}, 0, ""
|
||||
|
||||
case "wait_for_pattern":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Pattern string `json:"pattern"`
|
||||
TimeoutSeconds float64 `json:"timeout_seconds"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
matched, snippet, err := h.WaitForPattern(callerID, p.ChildID, p.Pattern, p.TimeoutSeconds)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return map[string]any{"matched": matched, "snippet": snippet}, 0, ""
|
||||
|
||||
case "request_human_attention":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
if err := h.RequestHumanAttention(callerID, p.ChildID, p.Reason); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
return map[string]string{"timer_id": id}, 0, "", nil
|
||||
|
||||
case "scratchpad_list":
|
||||
entries, err := h.Scratchpads().List()
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
return entries, 0, ""
|
||||
return entries, 0, "", nil
|
||||
|
||||
case "scratchpad_read":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var p struct{ Name string `json:"name"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
content, rev, err := h.Scratchpads().Read(p.Name)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
return map[string]any{"content": content, "revision": rev}, 0, ""
|
||||
return map[string]any{"content": content, "revision": rev}, 0, "", nil
|
||||
|
||||
case "scratchpad_write":
|
||||
var p struct {
|
||||
@@ -279,22 +550,19 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
ExpectedRevision string `json:"expected_revision"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
rev, err := h.Scratchpads().Write(p.Name, p.Content, p.ExpectedRevision)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
// Optimistic-concurrency miss returns ok:false + current_revision
|
||||
// rather than a JSON-RPC error so callers can re-read + merge.
|
||||
var rme *scratchpad.RevisionMismatchError
|
||||
if errors.As(err, &rme) {
|
||||
return map[string]any{"ok": false, "current_revision": rme.CurrentRevision}, 0, "", nil
|
||||
}
|
||||
return map[string]any{"revision": rev}, 0, ""
|
||||
|
||||
case "policy_check":
|
||||
var p struct {
|
||||
Prompt string `json:"prompt"`
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
return map[string]string{"decision": h.PolicyCheck(p.Prompt)}, 0, ""
|
||||
return map[string]any{"ok": true, "revision": rev}, 0, "", nil
|
||||
|
||||
case "scratchpad_append":
|
||||
var p struct {
|
||||
@@ -302,14 +570,62 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if err := h.Scratchpads().Append(p.Name, p.Content); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
return "ok", 0, ""
|
||||
return map[string]any{"ok": true}, 0, "", nil
|
||||
|
||||
case "whoami":
|
||||
return h.WhoAmI(callerID), 0, "", nil
|
||||
|
||||
case "help":
|
||||
var p struct{ Topic string `json:"topic"` }
|
||||
_ = unmarshalParamsOptional(params, &p)
|
||||
return h.Help(callerID, p.Topic), 0, "", nil
|
||||
}
|
||||
return nil, -32601, "method not found: " + method
|
||||
return nil, codeMethodNotFound, "method not found: " + method, nil
|
||||
}
|
||||
|
||||
// mapToolResult is the (info, err) → JSON-RPC reply helper for the
|
||||
// many handlers that return a ProcessInfo-ish struct.
|
||||
func mapToolResult(v any, err error) (any, int, string, any) {
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return v, 0, "", nil
|
||||
}
|
||||
|
||||
// mapToolError translates a host error to (code, message, data). If the
|
||||
// error is a *toolError its Kind names the SPEC-defined category and we
|
||||
// pick a stable JSON-RPC code for that category; otherwise we return a
|
||||
// generic internal error.
|
||||
func mapToolError(err error) (any, int, string, any) {
|
||||
var te *toolError
|
||||
if errors.As(err, &te) {
|
||||
code := codeInternal
|
||||
switch te.Kind {
|
||||
case "needs_trust":
|
||||
code = codeNeedsTrust
|
||||
case "role_forbidden":
|
||||
code = codeRoleForbidden
|
||||
case "not_related":
|
||||
code = codeNotRelated
|
||||
case "not_found":
|
||||
code = codeNotFound
|
||||
case "wrong_kind":
|
||||
code = codeWrongKind
|
||||
case "unknown_agent":
|
||||
code = codeUnknownAgent
|
||||
}
|
||||
return nil, code, te.Message, structuredKind(te.Kind)
|
||||
}
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
|
||||
func structuredKind(kind string) map[string]string {
|
||||
return map[string]string{"kind": kind}
|
||||
}
|
||||
|
||||
func unmarshalParams(raw json.RawMessage, out any) error {
|
||||
@@ -319,6 +635,13 @@ func unmarshalParams(raw json.RawMessage, out any) error {
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
func unmarshalParamsOptional(raw json.RawMessage, out any) error {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
func jsonRPCResult(id json.RawMessage, result any) []byte {
|
||||
resp := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
@@ -329,19 +652,19 @@ func jsonRPCResult(id json.RawMessage, result any) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
func jsonRPCError(id json.RawMessage, code int, message string) []byte {
|
||||
func jsonRPCError(id json.RawMessage, code int, message string, data any) []byte {
|
||||
errBody := map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
}
|
||||
if data != nil {
|
||||
errBody["data"] = data
|
||||
}
|
||||
resp := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
"error": errBody,
|
||||
}
|
||||
b, _ := json.Marshal(resp)
|
||||
return b
|
||||
}
|
||||
|
||||
// Compile-time guard: every dispatch path is covered. fmt is imported
|
||||
// only so future error wrapping can land without re-adding the import.
|
||||
var _ = fmt.Sprintf
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
// Package policy implements SPEC §9 permissions hooks.
|
||||
//
|
||||
// patterm doesn't enforce permissions on the agent's behalf — the
|
||||
// orchestrator is the policy actor. But patterm ships a config that
|
||||
// surfaces the project's deny-list to the orchestrator (via
|
||||
// scratchpad_read("policy.md")) and exposes a Should() helper future
|
||||
// MCP middleware can call to short-circuit obviously-dangerous prompts.
|
||||
//
|
||||
// File location: $XDG_CONFIG_HOME/patterm/policy.json (global default;
|
||||
// per-project override at <project>/.patterm/policy.json is a v2
|
||||
// follow-up).
|
||||
package policy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Decision is what Should() returns for a candidate auto-answer.
|
||||
type Decision string
|
||||
|
||||
const (
|
||||
// Allow: the prompt is in the always-safe allowlist; auto-answer.
|
||||
Allow Decision = "allow"
|
||||
// PuntToHuman: the prompt matches the deny-list; the orchestrator
|
||||
// MUST call request_human_attention instead of auto-answering.
|
||||
PuntToHuman Decision = "punt"
|
||||
// Unknown: no rule applies. SPEC §9 says default is to punt; we
|
||||
// keep this distinct so callers know it's a default, not a match.
|
||||
Unknown Decision = "unknown"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
// Allowlist patterns: prompts matching ANY of these are safe to
|
||||
// auto-answer.
|
||||
AllowPatterns []string `json:"allow_patterns"`
|
||||
// Deny patterns: prompts matching ANY of these MUST be punted to
|
||||
// the human. Default seed below covers SPEC §9 examples (writes,
|
||||
// deletes, sudo, package install, broad shell).
|
||||
DenyPatterns []string `json:"deny_patterns"`
|
||||
|
||||
mu sync.Mutex
|
||||
compiledAOK bool
|
||||
allowRE []*regexp.Regexp
|
||||
denyRE []*regexp.Regexp
|
||||
}
|
||||
|
||||
// Default returns the seeded policy that ships out of the box.
|
||||
func Default() *Policy {
|
||||
return &Policy{
|
||||
AllowPatterns: []string{
|
||||
`(?i)read.* from .*\?`,
|
||||
`(?i)open .* in editor\?`,
|
||||
`(?i)show diff\?`,
|
||||
},
|
||||
DenyPatterns: []string{
|
||||
`(?i)sudo`,
|
||||
`(?i)rm -rf`,
|
||||
`(?i)delete .*permanently`,
|
||||
`(?i)install package`,
|
||||
`(?i)pip install`,
|
||||
`(?i)npm install -g`,
|
||||
`(?i)curl .* \| .*sh`,
|
||||
`(?i)wget .* \| .*sh`,
|
||||
`(?i)force.push`,
|
||||
`(?i)git push --force`,
|
||||
`(?i)drop (table|database)`,
|
||||
`(?i)\.ssh/`,
|
||||
`(?i)\.aws/credentials`,
|
||||
`(?i)\.env\b`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the user's policy, falling back to Default if absent.
|
||||
// Errors other than ENOENT are returned.
|
||||
func Load() (*Policy, error) {
|
||||
p, err := PathFor()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
pol := Default()
|
||||
_ = Save(pol)
|
||||
return pol, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var pol Policy
|
||||
if err := json.Unmarshal(body, &pol); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pol, nil
|
||||
}
|
||||
|
||||
// Save writes p to the standard location, creating directories.
|
||||
func Save(p *Policy) error {
|
||||
path, err := PathFor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = append(body, '\n')
|
||||
return os.WriteFile(path, body, 0o600)
|
||||
}
|
||||
|
||||
func PathFor() (string, error) {
|
||||
if h := os.Getenv("XDG_CONFIG_HOME"); h != "" {
|
||||
return filepath.Join(h, "patterm", "policy.json"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "patterm", "policy.json"), nil
|
||||
}
|
||||
|
||||
// Should classifies a candidate auto-answer prompt the orchestrator is
|
||||
// reading from a sub-agent's grid.
|
||||
func (p *Policy) Should(promptText string) Decision {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.ensureCompiledLocked()
|
||||
for _, re := range p.denyRE {
|
||||
if re.MatchString(promptText) {
|
||||
return PuntToHuman
|
||||
}
|
||||
}
|
||||
for _, re := range p.allowRE {
|
||||
if re.MatchString(promptText) {
|
||||
return Allow
|
||||
}
|
||||
}
|
||||
return Unknown
|
||||
}
|
||||
|
||||
func (p *Policy) ensureCompiledLocked() {
|
||||
if p.compiledAOK {
|
||||
return
|
||||
}
|
||||
p.allowRE = make([]*regexp.Regexp, 0, len(p.AllowPatterns))
|
||||
for _, s := range p.AllowPatterns {
|
||||
if re, err := regexp.Compile(s); err == nil {
|
||||
p.allowRE = append(p.allowRE, re)
|
||||
}
|
||||
}
|
||||
p.denyRE = make([]*regexp.Regexp, 0, len(p.DenyPatterns))
|
||||
for _, s := range p.DenyPatterns {
|
||||
if re, err := regexp.Compile(s); err == nil {
|
||||
p.denyRE = append(p.denyRE, re)
|
||||
}
|
||||
}
|
||||
p.compiledAOK = true
|
||||
}
|
||||
@@ -13,12 +13,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Kind is "agent" or "process".
|
||||
// Kind is "agent" or "command". Process presets are SPEC §7's
|
||||
// `command` kind (session-persistent). Terminal entries don't have
|
||||
// presets — they're created freeform with `kind: terminal` via
|
||||
// spawn_process.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindAgent Kind = "agent"
|
||||
KindProcess Kind = "process"
|
||||
KindCommand Kind = "command"
|
||||
)
|
||||
|
||||
// Preset is one loaded preset file. Agent-only fields stay zero on
|
||||
@@ -87,7 +90,7 @@ func Load() (Set, error) {
|
||||
if err != nil {
|
||||
return Set{}, err
|
||||
}
|
||||
procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindProcess)
|
||||
procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindCommand)
|
||||
if err != nil {
|
||||
return Set{}, err
|
||||
}
|
||||
|
||||
@@ -90,9 +90,21 @@ func (s *Store) Read(name string) (content string, revision string, err error) {
|
||||
return string(b), revisionOf(b), nil
|
||||
}
|
||||
|
||||
// RevisionMismatchError is returned by Write when expectedRevision
|
||||
// doesn't match the on-disk revision. The MCP scratchpad_write tool
|
||||
// surfaces CurrentRevision as `current_revision` in the response so
|
||||
// the caller can re-read and merge (SPEC §7 / §14).
|
||||
type RevisionMismatchError struct {
|
||||
CurrentRevision string
|
||||
}
|
||||
|
||||
func (e *RevisionMismatchError) Error() string {
|
||||
return "scratchpad: revision mismatch (current=" + e.CurrentRevision + ")"
|
||||
}
|
||||
|
||||
// Write replaces the file's contents. expectedRevision, if non-empty,
|
||||
// must match the current revision or the write is rejected (SPEC §14
|
||||
// last-write-wins-with-token).
|
||||
// must match the current revision or the write is rejected with a
|
||||
// *RevisionMismatchError (SPEC §14 last-write-wins-with-token).
|
||||
func (s *Store) Write(name, content, expectedRevision string) (string, error) {
|
||||
p, err := s.safePath(name)
|
||||
if err != nil {
|
||||
@@ -100,8 +112,9 @@ func (s *Store) Write(name, content, expectedRevision string) (string, error) {
|
||||
}
|
||||
if expectedRevision != "" {
|
||||
if cur, err := os.ReadFile(p); err == nil {
|
||||
if revisionOf(cur) != expectedRevision {
|
||||
return "", fmt.Errorf("scratchpad: revision mismatch")
|
||||
curRev := revisionOf(cur)
|
||||
if curRev != expectedRevision {
|
||||
return "", &RevisionMismatchError{CurrentRevision: curRev}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
164
internal/trust/trust.go
Normal file
164
internal/trust/trust.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Package trust implements SPEC §7's per-project command-preset trust
|
||||
// gating. Command presets are not trusted by default; the first time
|
||||
// an agent attempts to spawn / start / restart a process tied to one,
|
||||
// the MCP tool returns a `needs_trust` error and patterm surfaces a UI
|
||||
// confirmation. The user's acceptance is persisted to disk so the
|
||||
// confirmation isn't repeated every run.
|
||||
//
|
||||
// Trust is keyed by `(project, preset name)` in v1. Freeform-argv
|
||||
// command spawns bypass entirely (the agent had to compose the argv,
|
||||
// so the trust decision is already implicit).
|
||||
package trust
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Store is one project's trust file. Safe for concurrent use.
|
||||
type Store struct {
|
||||
path string
|
||||
|
||||
mu sync.RWMutex
|
||||
granted map[string]bool
|
||||
}
|
||||
|
||||
// Open loads (or creates) the trust file for projectKey. The file is
|
||||
// stored at $XDG_DATA_HOME/patterm/projects/<projectKey>/trust.json
|
||||
// (SPEC §3). Missing-file is not an error — it simply means no presets
|
||||
// are trusted yet.
|
||||
func Open(projectKey string) (*Store, error) {
|
||||
if projectKey == "" {
|
||||
return nil, errors.New("trust.Open: empty project key")
|
||||
}
|
||||
base, err := dataDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(base, "projects", projectKey)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("trust: mkdir %s: %w", dir, err)
|
||||
}
|
||||
path := filepath.Join(dir, "trust.json")
|
||||
s := &Store{path: path, granted: make(map[string]bool)}
|
||||
if err := s.loadLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func dataDir() (string, error) {
|
||||
if h := os.Getenv("XDG_DATA_HOME"); h != "" {
|
||||
return filepath.Join(h, "patterm"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".local", "share", "patterm"), nil
|
||||
}
|
||||
|
||||
// IsTrusted reports whether preset is granted.
|
||||
func (s *Store) IsTrusted(preset string) bool {
|
||||
if preset == "" {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.granted[preset]
|
||||
}
|
||||
|
||||
// Grant records that preset is trusted and persists the file.
|
||||
func (s *Store) Grant(preset string) error {
|
||||
if preset == "" {
|
||||
return errors.New("trust.Grant: empty preset")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.granted[preset] {
|
||||
return nil
|
||||
}
|
||||
s.granted[preset] = true
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// Revoke removes a trust grant. Not used by the SPEC v1 flow but
|
||||
// useful for tests and future "untrust this" UI.
|
||||
func (s *Store) Revoke(preset string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.granted[preset] {
|
||||
return nil
|
||||
}
|
||||
delete(s.granted, preset)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// List returns the trusted presets in sorted order. For UI debugging.
|
||||
func (s *Store) List() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]string, 0, len(s.granted))
|
||||
for k := range s.granted {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Path returns the trust file path. Used by tests / diagnostics.
|
||||
func (s *Store) Path() string { return s.path }
|
||||
|
||||
type fileShape struct {
|
||||
// Presets is the JSON shape on disk: a list of granted preset names.
|
||||
// Using a list (not a map) keeps the file diff-friendly and ordering
|
||||
// stable across re-saves.
|
||||
Presets []string `json:"presets"`
|
||||
}
|
||||
|
||||
func (s *Store) loadLocked() error {
|
||||
b, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("trust: read %s: %w", s.path, err)
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var f fileShape
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return fmt.Errorf("trust: parse %s: %w", s.path, err)
|
||||
}
|
||||
for _, p := range f.Presets {
|
||||
s.granted[p] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked() error {
|
||||
out := make([]string, 0, len(s.granted))
|
||||
for k := range s.granted {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
body, err := json.MarshalIndent(fileShape{Presets: out}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = append(body, '\n')
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, body, 0o600); err != nil {
|
||||
return fmt.Errorf("trust: write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
return fmt.Errorf("trust: rename %s: %w", s.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
61
internal/trust/trust_test.go
Normal file
61
internal/trust/trust_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStoreGrantPersists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dir)
|
||||
|
||||
s1, err := Open("projkey")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if s1.IsTrusted("vitest") {
|
||||
t.Fatalf("fresh store should not trust anything")
|
||||
}
|
||||
if err := s1.Grant("vitest"); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
if !s1.IsTrusted("vitest") {
|
||||
t.Fatalf("grant didn't take effect")
|
||||
}
|
||||
|
||||
// Re-open and confirm persistence.
|
||||
s2, err := Open("projkey")
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
if !s2.IsTrusted("vitest") {
|
||||
t.Fatalf("grant did not persist across reopen")
|
||||
}
|
||||
if s2.IsTrusted("bun-dev") {
|
||||
t.Fatalf("unrelated preset should not be trusted")
|
||||
}
|
||||
|
||||
// Verify the file exists at the expected path.
|
||||
if _, err := os.Stat(s2.Path()); err != nil {
|
||||
t.Fatalf("stat trust.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRevokeWithoutGrantIsNoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dir)
|
||||
|
||||
s, err := Open("projkey")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := s.Revoke("never-granted"); err != nil {
|
||||
t.Fatalf("revoke noop should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreOpenRequiresProjectKey(t *testing.T) {
|
||||
if _, err := Open(""); err == nil {
|
||||
t.Fatalf("open with empty project key should fail")
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,14 @@ func (e *GhosttyEmulator) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Size returns the current grid (cols, rows). SPEC §7 get_process_output
|
||||
// and get_process_status surface these.
|
||||
func (e *GhosttyEmulator) Size() (uint16, uint16) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.cols, e.rows
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) Resize(cols, rows uint16) error {
|
||||
if cols == 0 || rows == 0 {
|
||||
return fmt.Errorf("vt: cols and rows must be > 0 (got %dx%d)", cols, rows)
|
||||
|
||||
@@ -17,6 +17,7 @@ func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
|
||||
|
||||
func (e *GhosttyEmulator) Write(p []byte) (int, error) { return 0, errStub }
|
||||
func (e *GhosttyEmulator) Resize(cols, rows uint16) error { return errStub }
|
||||
func (e *GhosttyEmulator) Size() (uint16, uint16) { return 0, 0 }
|
||||
func (e *GhosttyEmulator) PlainText() (string, error) { return "", errStub }
|
||||
func (e *GhosttyEmulator) ScreenText() (string, error) { return "", errStub }
|
||||
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub }
|
||||
|
||||
Reference in New Issue
Block a user