From 873542c135a47da87f4775410538d23d4582849c Mon Sep 17 00:00:00 2001 From: tundak Date: Sun, 4 Oct 2026 15:49:19 +0530 Subject: [PATCH 1/3] feat: agents support --- PLAN.md | 8 +- cmd/cometcli/extra.go | 3 +- docs/COMMANDS.md | 51 +++- internal/agent/anthropic.go | 117 ++++++++- internal/agent/commands.go | 195 +++++++++++++++ internal/agent/eval_test.go | 4 +- internal/agent/events.go | 78 ++++++ internal/agent/loop.go | 289 +++++++++++++++------ internal/agent/loop_test.go | 3 +- internal/agent/openai.go | 125 ++++++++- internal/agent/policy.go | 130 ++++++++++ internal/agent/prompt.go | 70 +++++- internal/agent/provider.go | 75 ++++++ internal/agent/repl.go | 162 +++++++----- internal/agent/replay.go | 166 ++++++++++++ internal/agent/session_test.go | 390 +++++++++++++++++++++++++++++ internal/audit/audit.go | 120 ++++++++- internal/cli/agent.go | 5 +- internal/cli/agent_impl.go | 52 +++- internal/cli/audit_replay.go | 89 +++++++ internal/cli/profile.go | 3 +- internal/cli/root.go | 43 +++- internal/cli/serve.go | 77 ++++++ internal/cli/ui.go | 30 ++- internal/client/host/host.go | 10 +- internal/config/config.go | 11 + internal/redact/redact.go | 139 +++++++++- internal/redact/redact_test.go | 58 +++++ internal/serve/serve.go | 382 ++++++++++++++++++++++++++++ internal/serve/serve_test.go | 252 +++++++++++++++++++ internal/serve/web/index.html | 389 ++++++++++++++++++++++++++++ internal/toolkit/context.go | 12 +- internal/toolkit/diff.go | 88 +++++++ internal/toolkit/diff_test.go | 23 ++ internal/tools/montool/mon.go | 2 +- internal/tools/nettool/net.go | 6 +- internal/tools/sectool/sec.go | 150 ++++++++--- internal/tools/sectool/sec_test.go | 55 ++++ internal/tools/snaptool/snap.go | 9 +- internal/tui/app.go | 73 +++--- internal/tui/app_test.go | 22 +- internal/tui/chat.go | 142 ++++++----- internal/tui/preview_test.go | 8 +- internal/tx/tx.go | 4 +- 44 files changed, 3755 insertions(+), 365 deletions(-) create mode 100644 internal/agent/commands.go create mode 100644 internal/agent/events.go create mode 100644 internal/agent/policy.go create mode 100644 internal/agent/replay.go create mode 100644 internal/agent/session_test.go create mode 100644 internal/cli/audit_replay.go create mode 100644 internal/cli/serve.go create mode 100644 internal/serve/serve.go create mode 100644 internal/serve/serve_test.go create mode 100644 internal/serve/web/index.html create mode 100644 internal/toolkit/diff.go create mode 100644 internal/toolkit/diff_test.go create mode 100644 internal/tools/sectool/sec_test.go diff --git a/PLAN.md b/PLAN.md index 165f95b..b58273f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -187,9 +187,11 @@ pre/post-conditions checked via tools; aborts loudly on guard failure. └──────────────────────────────────────────────────────────────────────┘ ``` -- **Entry**: bare `cometcli` opens the REPL; `cometcli ask "..."` for one-shot. - Slash commands: `/profile`, `/mode readonly|ops`, `/approve`, `/audit`, - `/runbook`, `/model`. +- **Entry**: bare `cometcli` opens the REPL; `cometcli ask "..."` for one-shot; + `cometcli serve` puts the same session behind a loopback, token-protected web + chat. All front-ends render one `agent.Event` stream (deltas, tool cards, + approvals). Slash commands: `/profile`, `/mode readonly|ops`, `/approve`, + `/audit`, `/runbook`, `/model`. - **Context snapshot**: on session start, inject a *bounded* digest — chain-id, evm chain-id, node version, validator status, height, peers, disk — refreshed lazily, never key material. diff --git a/cmd/cometcli/extra.go b/cmd/cometcli/extra.go index 255500b..bc253c6 100644 --- a/cmd/cometcli/extra.go +++ b/cmd/cometcli/extra.go @@ -8,7 +8,7 @@ import ( ) // extraCommands returns hand-written commands that aren't schema-generated -// tools: doctor, agent, ask, ui, mcp. +// tools: doctor, agent, ask, ui, mcp, serve. func extraCommands(reg *toolkit.Registry) []*cobra.Command { return []*cobra.Command{ cli.DoctorCmd(reg), @@ -16,5 +16,6 @@ func extraCommands(reg *toolkit.Registry) []*cobra.Command { cli.AgentCmd(reg), cli.UICmd(reg), cli.MCPCmd(reg), + cli.ServeCmd(reg), } } diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index b0a1251..a4b33fb 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -184,16 +184,55 @@ cometcli runbook run coordinated-upgrade ## Interfaces ```bash -cometcli ui # chat-first TUI (Claude Code style): ask in natural - # language, /run tools, approvals in-app, txs broadcast +cometcli # bare command = chat TUI (when a profile exists) +cometcli ui # chat-first TUI (Claude Code style): streamed answers, + # /run tools, approval modals with diffs, txs broadcast # from the chat. Overview/fleet/logs/send panes on tabs -cometcli agent # interactive AI SRE (needs ANTHROPIC_API_KEY or Ollama) +cometcli serve --open # same agent in a local web chat (127.0.0.1 only, + # token link printed at start; approvals as modals) +cometcli agent # line-mode REPL (needs ANTHROPIC_API_KEY, Groq, or Ollama) cometcli agent --task "check fleet" # headless one-shot task (cron/CI) -cometcli agent --task "audit exposure" --safe --budget 6 --max-iter 4 -cometcli ask "is my validator healthy" # one-shot agent question +cometcli agent --task "audit exposure" --mode readonly --budget 6 --max-iter 4 +cometcli ask "is my validator healthy" # one-shot agent question (streams) cometcli ask "why is disk at 88%" # chains tools: doctor → df → verdict +cometcli ask "add peer …" --autopilot local-change # skip local-change confirms (never on-chain) cometcli mcp # serve all tools over MCP (stdio) for external agents -cometcli audit # today's audit log (tools, shells, txs, approvals) +cometcli audit # today's audit log (prompts, LLM turns, tools, txs, approvals) +cometcli audit sessions [--all] # agent sessions recorded in the log +cometcli audit replay # reproduce a session offline: recorded LLM turns + + # tool output re-driven through the agent loop cometcli completion zsh # shell completion cometcli version ``` + +Agent flags (`agent`, `ask`, `serve`): `--mode ops|readonly`, `--safe` (=readonly), +`--autopilot local-change`, `--budget N` tool calls per turn, `--max-iter N`, `--no-stream`. + +### Agent session commands (TUI, REPL, web) + +| Command | Effect | +|---|---| +| `/mode [ops\|readonly]` | show or set the approval posture; readonly hides mutating tools from the model | +| `/approve local-change on\|off` | autopilot local-change tools; on-chain can never be autopiloted | +| `/model [name \| provider name]` | show or switch the LLM mid-conversation | +| `/runbook [name]` | list runbooks, or have the agent run one through the normal approval gate | +| `/tools` | every tool with its tier and how this session treats it (auto/confirm/deny) | +| `/profile`, `/audit`, `/reset` | profile + policy, audit path + session id, new session | + +### Agent config (per profile) + +```yaml +agent: + provider: anthropic # anthropic | openai | groq | openai-compat/ollama | off + model: claude-sonnet-4-5 + mode: ops # ops (default) | readonly + autopilot: [local-change] # optional; on-chain is rejected + redact_hosts: [val.internal] # masked before any text reaches the LLM + redact_endpoints: true # also mask this profile's endpoint/SSH hosts + no_stream: false # set true for endpoints that mishandle streaming +``` + +`COMETCLI_OFFLINE=1` disables the agent entirely (ask/agent/serve chat refuse); every +tool subcommand and `cometcli mcp` keep working. Mnemonics (checked against the BIP-39 +wordlist), hex/base64 key material, tokens and URL credentials are scrubbed from user +input, the live snapshot, and tool output before they reach the model. diff --git a/internal/agent/anthropic.go b/internal/agent/anthropic.go index 6805f28..1c1a2bc 100644 --- a/internal/agent/anthropic.go +++ b/internal/agent/anthropic.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "net/http" + "sort" + "strings" "time" ) @@ -19,20 +21,19 @@ func (a *anthropic) Name() string { return "anthropic" } func (a *anthropic) client() *http.Client { if a.hc == nil { - a.hc = &http.Client{Timeout: 120 * time.Second} + a.hc = &http.Client{Timeout: 300 * time.Second} } return a.hc } type anthBlock map[string]any -func (a *anthropic) Chat(ctx context.Context, r *Request) (*Response, error) { - msgs := a.convMessages(r.Messages) +func (a *anthropic) body(r *Request) map[string]any { body := map[string]any{ "model": r.Model, "max_tokens": r.MaxTok, "system": r.System, - "messages": msgs, + "messages": a.convMessages(r.Messages), } if len(r.Tools) > 0 { var tools []map[string]any @@ -45,15 +46,23 @@ func (a *anthropic) Chat(ctx context.Context, r *Request) (*Response, error) { } body["tools"] = tools } + return body +} + +func (a *anthropic) post(ctx context.Context, body map[string]any) (*http.Response, error) { raw, _ := json.Marshal(body) - req, err := http.NewRequestWithContext(ctx, "POST", a.base+"/v1/messages", bytes.NewReader(raw)) + req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimSuffix(a.base, "/")+"/v1/messages", bytes.NewReader(raw)) if err != nil { return nil, err } req.Header.Set("x-api-key", a.key) req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("content-type", "application/json") - resp, err := a.client().Do(req) + return a.client().Do(req) +} + +func (a *anthropic) Chat(ctx context.Context, r *Request) (*Response, error) { + resp, err := a.post(ctx, a.body(r)) if err != nil { return nil, err } @@ -89,6 +98,102 @@ func (a *anthropic) Chat(ctx context.Context, r *Request) (*Response, error) { return res, nil } +// Stream consumes the Messages API SSE stream: text_delta chunks are +// forwarded to onText as they arrive; tool_use input arrives as +// input_json_delta fragments and is assembled per content-block index. +func (a *anthropic) Stream(ctx context.Context, r *Request, onText func(string)) (*Response, error) { + body := a.body(r) + body["stream"] = true + resp, err := a.post(ctx, body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return nil, apiError("anthropic", resp) + } + type block struct { + typ, id, name string + input strings.Builder + } + blocks := map[int]*block{} + var text strings.Builder + stop := "" + err = readSSE(resp.Body, func(data string) error { + var ev struct { + Type string `json:"type"` + Index int `json:"index"` + ContentBlock struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Text string `json:"text"` + } `json:"content_block"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + PartialJSON string `json:"partial_json"` + StopReason string `json:"stop_reason"` + } `json:"delta"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal([]byte(data), &ev) != nil { + return nil // tolerate keepalives / unknown payloads + } + switch ev.Type { + case "content_block_start": + blocks[ev.Index] = &block{typ: ev.ContentBlock.Type, id: ev.ContentBlock.ID, name: ev.ContentBlock.Name} + if ev.ContentBlock.Text != "" { + text.WriteString(ev.ContentBlock.Text) + onText(ev.ContentBlock.Text) + } + case "content_block_delta": + switch ev.Delta.Type { + case "text_delta": + text.WriteString(ev.Delta.Text) + onText(ev.Delta.Text) + case "input_json_delta": + if b := blocks[ev.Index]; b != nil { + b.input.WriteString(ev.Delta.PartialJSON) + } + } + case "message_delta": + if ev.Delta.StopReason != "" { + stop = ev.Delta.StopReason + } + case "error": + if ev.Error != nil { + return fmt.Errorf("anthropic: %s", ev.Error.Message) + } + return fmt.Errorf("anthropic: stream error") + } + return nil + }) + if err != nil { + return nil, err + } + res := &Response{Text: text.String(), Done: stop == "end_turn"} + idx := make([]int, 0, len(blocks)) + for i := range blocks { + idx = append(idx, i) + } + sort.Ints(idx) + for _, i := range idx { + b := blocks[i] + if b.typ != "tool_use" { + continue + } + args := b.input.String() + if strings.TrimSpace(args) == "" { + args = "{}" + } + res.Calls = append(res.Calls, Call{ID: b.id, Name: b.name, Args: json.RawMessage(args)}) + } + return res, nil +} + func (a *anthropic) convMessages(in []Msg) []map[string]any { var out []map[string]any for _, m := range in { diff --git a/internal/agent/commands.go b/internal/agent/commands.go new file mode 100644 index 0000000..c524d2e --- /dev/null +++ b/internal/agent/commands.go @@ -0,0 +1,195 @@ +package agent + +import ( + "errors" + "fmt" + "strings" + + "github.com/abhijitkrm/cometcli/internal/runbook" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// ErrUnknownCommand means the slash command isn't a shared one; the front- +// end may handle it itself (/exit, /run, …) or report it. +var ErrUnknownCommand = errors.New("unknown command") + +// CmdResult is the outcome of a shared slash command. +type CmdResult struct { + // Text is shown to the operator. + Text string + // Prompt, when set, is submitted to the agent as a user turn — used by + // /runbook so the run goes through the normal approval gate and + // streams like any other request. + Prompt string +} + +// CommandHelp lists the shared slash commands; front-ends append their own. +const CommandHelp = ` /mode [ops|readonly] show or set the approval posture + /approve [local-change on|off] show or toggle autopilot (on-chain can never be autopiloted) + /model [name | provider name] show or switch the LLM + /runbook [name] list runbooks, or have the agent run one + /tools list tools with how this session treats each + /profile active profile + /audit audit log path + session id + /reset clear conversation, start a new audit session` + +// RunCommand executes a shared slash command. a may be nil when no LLM +// provider is configured — commands that need the agent say so. +func RunCommand(a *Agent, c *toolkit.Context, reg *toolkit.Registry, line string) (CmdResult, error) { + f := strings.Fields(strings.TrimSpace(line)) + if len(f) == 0 || !strings.HasPrefix(f[0], "/") { + return CmdResult{}, ErrUnknownCommand + } + needAgent := func() error { + if a == nil { + if Offline() { + return ErrOffline + } + return fmt.Errorf("no agent configured — set agent.provider in the profile") + } + return nil + } + switch f[0] { + case "/profile": + if c == nil || c.Profile == nil { + return CmdResult{Text: "no active profile"}, nil + } + p := c.Profile + txt := fmt.Sprintf("%s (role %s, chain %s, transport %s)", p.Name, p.Role, p.ChainID, p.Transport.Type) + if a != nil { + txt += fmt.Sprintf("\nagent: %s/%s · %s", a.Provider.Name(), a.Model, a.Policy) + } + return CmdResult{Text: txt}, nil + + case "/tools": + var b strings.Builder + for _, t := range reg.All() { + dec := "—" + if a != nil { + dec = a.Policy.Decision(t.Tier()) + } + fmt.Fprintf(&b, "%-22s %-13s %-8s %s\n", t.Name(), "["+t.Tier().String()+"]", dec, t.Desc()) + } + return CmdResult{Text: strings.TrimRight(b.String(), "\n")}, nil + + case "/audit": + var lgPath, sess string + if a != nil && a.Audit() != nil { + lgPath, sess = a.Audit().Path(), a.Audit().Session() + } else if c != nil && c.Audit != nil { + lgPath = c.Audit.Path() + } + if lgPath == "" { + return CmdResult{Text: "audit disabled"}, nil + } + txt := lgPath + if sess != "" { + txt += "\nsession " + sess + " — replay with: cometcli audit replay " + sess + } + return CmdResult{Text: txt}, nil + + case "/reset": + if a != nil { + a.Reset() + return CmdResult{Text: "cleared — new session " + a.ID()}, nil + } + return CmdResult{Text: "cleared"}, nil + + case "/mode", "/safe": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + if f[0] == "/safe" { // legacy toggle + if a.Policy.ReadOnly() { + a.Policy.Mode = ModeOps + } else { + a.Policy.Mode = ModeReadOnly + } + return CmdResult{Text: a.Policy.String()}, nil + } + if len(f) > 1 { + m, err := ParseMode(f[1]) + if err != nil { + return CmdResult{}, err + } + a.Policy.Mode = m + } + return CmdResult{Text: a.Policy.String()}, nil + + case "/approve": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + if len(f) == 1 { + return CmdResult{Text: a.Policy.String() + "\nusage: /approve local-change on|off"}, nil + } + on := true + if len(f) > 2 { + switch strings.ToLower(f[2]) { + case "on", "true", "yes": + case "off", "false", "no": + on = false + default: + return CmdResult{}, fmt.Errorf("want on|off, got %q", f[2]) + } + } + if err := a.Policy.SetAutopilot(f[1], on); err != nil { + return CmdResult{}, err + } + return CmdResult{Text: a.Policy.String()}, nil + + case "/model": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + if len(f) == 1 { + return CmdResult{Text: fmt.Sprintf("%s/%s", a.Provider.Name(), a.Model)}, nil + } + conf := a.conf + if conf.Provider == "" { + conf.Provider = a.Provider.Name() + } + if len(f) > 2 { + if conf.Provider != f[1] { + conf.BaseURL, conf.APIKeyEnv = "", "" // endpoint/key belong to the old provider + } + conf.Provider, conf.Model = f[1], f[2] + } else { + conf.Model = f[1] + } + p, err := NewProvider(conf) + if err != nil { + return CmdResult{}, err + } + a.Provider, a.Model, a.conf = p, modelOf(p), conf + return CmdResult{Text: fmt.Sprintf("switched to %s/%s (conversation kept)", p.Name(), a.Model)}, nil + + case "/runbook", "/runbooks": + if len(f) == 1 { + var b strings.Builder + for _, rb := range runbook.All() { + fmt.Fprintf(&b, "%-20s %s (%d steps)\n", rb.Name, rb.Desc, len(rb.Steps)) + } + b.WriteString("run one with /runbook ") + return CmdResult{Text: b.String()}, nil + } + if err := needAgent(); err != nil { + return CmdResult{}, err + } + rb, err := runbook.GetAll(f[1]) + if err != nil { + return CmdResult{}, err + } + if a.Policy.ReadOnly() { + return CmdResult{ + Text: "readonly mode — the agent will walk the read-only steps and report instead of executing the runbook", + Prompt: fmt.Sprintf("Walk through runbook %q step by step using only read-only tools (runbook.show first), and report what executing it would do and whether it is safe right now.", rb.Name), + }, nil + } + return CmdResult{ + Text: fmt.Sprintf("running runbook %s — %s", rb.Name, rb.Desc), + Prompt: fmt.Sprintf("Run runbook %q with runbook.run, then summarize each step's outcome and anything that needs my attention.", rb.Name), + }, nil + } + return CmdResult{}, ErrUnknownCommand +} diff --git a/internal/agent/eval_test.go b/internal/agent/eval_test.go index 2a0edee..51ef99d 100644 --- a/internal/agent/eval_test.go +++ b/internal/agent/eval_test.go @@ -45,7 +45,9 @@ func runScenario(t *testing.T, sc evalScenario, tools ...toolkit.Tool) *Agent { } prov := &mockProvider{responses: sc.script} a := newTestAgent(t, prov, wrapped...) - a.Safe = sc.safe + if sc.safe { + a.Policy.Mode = ModeReadOnly + } a.MaxCalls = sc.maxCalls a.MaxIter = 8 diff --git a/internal/agent/events.go b/internal/agent/events.go new file mode 100644 index 0000000..e42eb17 --- /dev/null +++ b/internal/agent/events.go @@ -0,0 +1,78 @@ +package agent + +import ( + "fmt" + "sync/atomic" + + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// EventKind enumerates what a front-end can render from an agent turn. +type EventKind string + +const ( + // EvDelta is a streamed assistant text chunk (best-effort, may be + // followed by an EvText that supersedes it). + EvDelta EventKind = "delta" + // EvText is the complete, redacted assistant text of one model round. + // Front-ends that rendered deltas replace them with this. + EvText EventKind = "text" + // EvToolStart fires before a tool runs. + EvToolStart EventKind = "tool_start" + // EvToolResult fires after a tool runs (Err set on failure). + EvToolResult EventKind = "tool_result" + // EvApproval asks the human to approve a gated action; answer it via + // Approval.Answer. Only emitted when the context uses EventApprover. + EvApproval EventKind = "approval" +) + +// Event is one renderable step of an agent turn. It is JSON-serializable +// so `cometcli serve` can forward it over SSE verbatim. +type Event struct { + Kind EventKind `json:"kind"` + Text string `json:"text,omitempty"` + Tool string `json:"tool,omitempty"` + Tier string `json:"tier,omitempty"` + Args map[string]any `json:"args,omitempty"` + Err string `json:"error,omitempty"` + Approval *Approval `json:"approval,omitempty"` +} + +// Approval is a pending human decision. Answer it exactly once. +type Approval struct { + ID string `json:"id"` + Prompt string `json:"prompt"` + Tier string `json:"tier"` + Detail map[string]any `json:"detail,omitempty"` + reply chan bool +} + +// Answer delivers the decision; extra calls are ignored. +func (a *Approval) Answer(ok bool) { + select { + case a.reply <- ok: + default: + } +} + +var approvalSeq atomic.Int64 + +// EventApprover returns a toolkit.Approver that surfaces each request as an +// EvApproval event via emit and blocks until it is answered or the tool's +// context is cancelled (cancellation denies). +func EventApprover(emit func(Event)) toolkit.Approver { + return func(c *toolkit.Context, prompt string, tier toolkit.Tier, detail map[string]any) (bool, error) { + ap := &Approval{ + ID: fmt.Sprintf("ap-%d", approvalSeq.Add(1)), + Prompt: prompt, Tier: tier.String(), Detail: detail, + reply: make(chan bool, 1), + } + emit(Event{Kind: EvApproval, Tier: ap.Tier, Approval: ap}) + select { + case ok := <-ap.reply: + return ok, nil + case <-c.Done(): + return false, c.Err() + } + } +} diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 1766188..460127b 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -2,17 +2,23 @@ package agent import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" + "regexp" "strings" "time" "github.com/abhijitkrm/cometcli/internal/audit" + "github.com/abhijitkrm/cometcli/internal/config" "github.com/abhijitkrm/cometcli/internal/redact" "github.com/abhijitkrm/cometcli/internal/toolkit" ) -// Agent runs the LLM ↔ tool loop over the shared registry. +// Agent runs the LLM ↔ tool loop over the shared registry. One Agent is one +// session: it owns the conversation, the approval policy, the redactor, and +// a session id stamped on every audit event. type Agent struct { Provider Provider Model string @@ -22,40 +28,75 @@ type Agent struct { // MaxCalls bounds tool invocations per Run (0 = unlimited). Bounds // spend, not just rounds — MaxIter counts model turns. MaxCalls int - // Safe refuses local-change/on-chain tools entirely: they're filtered - // from the advertised tool set and hard-blocked in execCall. - Safe bool - - // UI hooks — the REPL renders these. - OnText func(text string) // assistant text chunk - OnToolCall func(name string, args map[string]any) // before a tool runs - OnToolResult func(name, summary string, err error) // after a tool runs + // Policy decides which tiers are advertised, auto-run, confirmed, or + // refused. Read-only mode filters mutating tools out entirely. + Policy Policy + // Redact scrubs everything entering the LLM context (user input, + // snapshot, tool output) and everything echoed to UIs/logs. + Redact *redact.Redactor + // Stream enables token streaming when the provider supports it. + Stream bool + // OnEvent receives renderable steps of each turn (may be nil). + OnEvent func(Event) + // SnapshotFn builds the live node digest for the system prompt; + // defaults to SnapshotText. Refreshed at most every SnapshotTTL. + SnapshotFn func(*toolkit.Context) string + SnapshotTTL time.Duration + conf config.AgentConf + id string + audit *audit.Logger history []Msg - sysPrompt string // cached per Run — snapshot probed once per turn + snap string + snapAt time.Time calls int + toolTrunc int } -// New builds an agent for a context. +// New builds an agent for a context from the profile's agent config. func New(c *toolkit.Context, reg *toolkit.Registry) (*Agent, error) { - prov, err := NewProvider(c.Profile.Agent) + ac := c.Profile.Agent + prov, err := NewProvider(ac) + if err != nil { + return nil, err + } + pol, err := PolicyFrom(ac) if err != nil { return nil, err } a := &Agent{ Provider: prov, - Model: def(c.Profile.Agent.Model, ""), + Model: def(ac.Model, ""), Reg: reg, Ctx: c, MaxIter: 16, + Policy: pol, + Redact: RedactorFor(c.Profile), + Stream: !ac.NoStream, + conf: ac, } if a.Model == "" { - // provider defaults already applied inside New(); read back a.Model = modelOf(prov) } return a, nil } +// RedactorFor builds the profile's redactor: base rules plus agent +// redact_hosts, and the profile's own endpoint/SSH hosts when +// redact_endpoints is set. +func RedactorFor(p *config.Profile) *redact.Redactor { + if p == nil { + return redact.NewRedactor() + } + hosts := append([]string{}, p.Agent.RedactHosts...) + if p.Agent.RedactEndpoints { + hosts = append(hosts, p.Transport.Host, + redact.HostOf(p.Endpoints.Comet), redact.HostOf(p.Endpoints.GRPC), + redact.HostOf(p.Endpoints.LCD), redact.HostOf(p.Endpoints.EVM)) + } + return redact.NewRedactor(hosts...) +} + func modelOf(p Provider) string { switch t := p.(type) { case *anthropic: @@ -66,11 +107,50 @@ func modelOf(p Provider) string { return "" } -// toolDefs converts the registry to provider tool schemas. +// ID returns the session id (assigned lazily on first use). +func (a *Agent) ID() string { + if a.id == "" { + var b [6]byte + _, _ = rand.Read(b[:]) + a.id = time.Now().UTC().Format("20060102-150405") + "-" + hex.EncodeToString(b[:]) + } + return a.id +} + +// Audit returns the session-stamped audit logger (nil when auditing is off). +func (a *Agent) Audit() *audit.Logger { + if a.audit == nil && a.Ctx != nil && a.Ctx.Audit != nil { + a.audit = a.Ctx.Audit.WithSession(a.ID()) + } + return a.audit +} + +// Reset clears conversation history and starts a new audit session. +func (a *Agent) Reset() { + a.history = nil + a.id, a.audit = "", nil + a.snap = "" +} + +func (a *Agent) emit(e Event) { + if a.OnEvent != nil { + a.OnEvent(e) + } +} + +func (a *Agent) profileName() string { + if a.Ctx != nil && a.Ctx.Profile != nil { + return a.Ctx.Profile.Name + } + return "" +} + +// toolDefs converts the registry to provider tool schemas, hiding tiers +// the policy refuses. func (a *Agent) toolDefs() []ToolDef { var out []ToolDef for _, t := range a.Reg.All() { - if a.Safe && t.Tier() >= toolkit.TierLocalChange { + if !a.Policy.Allows(t.Tier()) { continue } out = append(out, ToolDef{ @@ -87,118 +167,181 @@ func toolFnName(n string) string { return strings.ReplaceAll(n, ".", "__") } // Run processes one user turn, executing tools until the model finishes. func (a *Agent) Run(ctx context.Context, input string) (string, error) { a.calls = 0 - a.sysPrompt = "" // refresh the live snapshot once per turn + input = a.Redact.Text(input) a.history = append(a.history, Msg{Role: "user", Text: input}) - if a.Audit() != nil { - _ = a.Audit().Log(audit.KindPrompt, a.Ctx.Profile.Name, map[string]any{"text": redact.Text(input)}) + if lg := a.Audit(); lg != nil { + _ = lg.Log(audit.KindPrompt, a.profileName(), map[string]any{"text": input}) } for i := 0; i < a.MaxIter; i++ { - resp, err := a.Provider.Chat(ctx, &Request{ + req := &Request{ Model: a.Model, System: a.system(), Messages: a.history, Tools: a.toolDefs(), MaxTok: 4096, - }) + } + resp, err := a.chat(ctx, req) if err != nil { return "", err } - // record assistant turn a.history = append(a.history, Msg{Role: "assistant", Text: resp.Text, Calls: resp.Calls}) - if resp.Text != "" && a.OnText != nil { - a.OnText(resp.Text) + a.logLLM(i, resp) + shown := a.Redact.Text(resp.Text) + if shown != "" { + a.emit(Event{Kind: EvText, Text: shown}) } if resp.Done || len(resp.Calls) == 0 { - return resp.Text, nil + return shown, nil } for _, call := range resp.Calls { - result := a.execCall(ctx, call) - a.history = append(a.history, result) + if err := ctx.Err(); err != nil { + return "", err + } + a.history = append(a.history, a.execCall(ctx, call)) } } return "", fmt.Errorf("agent exceeded %d iterations", a.MaxIter) } +// chat performs one model round, streaming text deltas when possible. +func (a *Agent) chat(ctx context.Context, req *Request) (*Response, error) { + if s, ok := a.Provider.(Streamer); ok && a.Stream && a.OnEvent != nil { + return s.Stream(ctx, req, func(chunk string) { + a.emit(Event{Kind: EvDelta, Text: chunk}) + }) + } + return a.Provider.Chat(ctx, req) +} + +// logLLM records one model round — enough to replay the session. +func (a *Agent) logLLM(round int, resp *Response) { + lg := a.Audit() + if lg == nil { + return + } + var calls []map[string]any + for _, c := range resp.Calls { + calls = append(calls, map[string]any{"id": c.ID, "name": c.Name, "args": a.Redact.Text(string(c.Args))}) + } + _ = lg.Log(audit.KindLLM, a.profileName(), map[string]any{ + "round": round, "provider": a.Provider.Name(), "model": a.Model, + "text": a.Redact.Text(resp.Text), "calls": calls, "done": resp.Done, + }) +} + +func (a *Agent) toolErr(call Call, name, msg string) Msg { + msg = a.Redact.Text(msg) + a.emit(Event{Kind: EvToolResult, Tool: name, Err: msg}) + return Msg{Role: "tool", CallID: call.ID, ToolName: name, Text: msg, IsError: true} +} + func (a *Agent) execCall(ctx context.Context, call Call) Msg { name := toolkit.ResolveName(call.Name) var args map[string]any if err := json.Unmarshal(call.Args, &args); err != nil { - return Msg{Role: "tool", CallID: call.ID, ToolName: name, - Text: "bad args: " + err.Error(), IsError: true} - } - if a.OnToolCall != nil { - a.OnToolCall(name, args) + return a.toolErr(call, name, "bad args: "+err.Error()) } t, ok := a.Reg.Get(name) if !ok { - return Msg{Role: "tool", CallID: call.ID, ToolName: name, - Text: "no such tool: " + name, IsError: true} + return a.toolErr(call, name, "no such tool: "+name) } - if a.Safe && t.Tier() >= toolkit.TierLocalChange { - return Msg{Role: "tool", CallID: call.ID, ToolName: name, - Text: fmt.Sprintf("blocked: %s is a %s tool and the agent is in safe mode — recommend it to the operator instead", name, t.Tier()), IsError: true} + shownArgs := a.Redact.Args(args) + a.emit(Event{Kind: EvToolStart, Tool: name, Tier: t.Tier().String(), Args: shownArgs}) + if !a.Policy.Allows(t.Tier()) { + msg := fmt.Sprintf("blocked: %s is a %s tool and the session is in readonly mode — recommend it to the operator instead", name, t.Tier()) + a.Audit().ToolSeen(a.profileName(), name, t.Tier().String(), shownArgs, nil, fmt.Errorf("%s", msg), "") + return a.toolErr(call, name, msg) } a.calls++ if a.MaxCalls > 0 && a.calls > a.MaxCalls { - return Msg{Role: "tool", CallID: call.ID, ToolName: name, - Text: fmt.Sprintf("tool-call budget exhausted (%d) — stop calling tools and summarize findings so far", a.MaxCalls), IsError: true} - } - runCtx := a.Ctx - var cancel context.CancelFunc - if !toolkit.IsLongRunning(t) { - runCtx, cancel = toolkit.WithDeadline(a.Ctx, 90*time.Second) + return a.toolErr(call, name, fmt.Sprintf("tool-call budget exhausted (%d) — stop calling tools and summarize findings so far", a.MaxCalls)) } + + runCtx, cancel := a.toolCtx(ctx, t) res, err := t.Run(runCtx, args) - if cancel != nil { - runCtx.Close() - cancel() + runCtx.Close() + cancel() + + var text string + if err == nil { + text = res.Text + if text == "" && res.Data != nil { + text = res.JSON() + } + limit := a.toolTrunc + if limit == 0 { + limit = 8192 + } + text = ansiRe.ReplaceAllString(text, "") // colored logs waste tokens and confuse models + if len(text) > limit { // bound context growth + text = text[:limit] + "\n…[truncated]" + } + text = a.Redact.Text(text) } - profile := a.Ctx.Profile.Name - if a.Audit() != nil { + if lg := a.Audit(); lg != nil { var data map[string]any if res != nil { - data = res.Data + data = a.Redact.Args(res.Data) } - a.Audit().Tool(profile, name, t.Tier().String(), redact.Args(args), data, err) + lg.ToolSeen(a.profileName(), name, t.Tier().String(), shownArgs, data, err, text) } if err != nil { - if a.OnToolResult != nil { - a.OnToolResult(name, "", err) - } - return Msg{Role: "tool", CallID: call.ID, ToolName: name, - Text: "error: " + err.Error(), IsError: true} + return a.toolErr(call, name, "error: "+err.Error()) } - text := res.Text - if text == "" && res.Data != nil { - text = res.JSON() + a.emit(Event{Kind: EvToolResult, Tool: name, Tier: t.Tier().String(), Text: firstLine(text)}) + return Msg{Role: "tool", CallID: call.ID, ToolName: name, Text: text} +} + +// toolCtx derives the per-call tool context: the turn's cancellation, a +// 90s deadline for short tools, the session audit logger, and the policy's +// approval threshold. Read-only sessions hard-deny any approval request +// that slips past filtering. +func (a *Agent) toolCtx(ctx context.Context, t toolkit.Tool) (*toolkit.Context, context.CancelFunc) { + parent := &toolkit.Context{ + Context: ctx, Profile: a.Ctx.Profile, Cfg: a.Ctx.Cfg, Out: a.Ctx.Out, + Audit: a.Audit(), Approver: a.Ctx.Approver, } - if len(text) > 8192 { // bound context growth - text = text[:8192] + "\n…[truncated]" + var sub *toolkit.Context + var cancel context.CancelFunc + if toolkit.IsLongRunning(t) { + sub, cancel = toolkit.WithCancel(parent) + } else { + sub, cancel = toolkit.WithDeadline(parent, 90*time.Second) } - if a.OnToolResult != nil { - a.OnToolResult(name, firstLine(text), nil) + sub.AutoApproveBelow = a.Policy.AutoApproveBelow() + if a.Policy.ReadOnly() { + sub.Approver = toolkit.DenyApprover } - return Msg{Role: "tool", CallID: call.ID, ToolName: name, - Text: redact.Text(text)} + return sub, cancel } +// system returns the system prompt plus a cached, redacted live snapshot. func (a *Agent) system() string { - if a.sysPrompt == "" { - a.sysPrompt = SystemPrompt(a.Ctx) + SnapshotText(a.Ctx) + ttl := a.SnapshotTTL + if ttl == 0 { + ttl = 60 * time.Second + } + if a.snap == "" || time.Since(a.snapAt) > ttl { + fn := a.SnapshotFn + if fn == nil { + fn = SnapshotText + } + a.snap = a.Redact.Text(fn(a.Ctx)) + a.snapAt = time.Now() } - return a.sysPrompt + return a.Redact.Text(SystemPrompt(a.Ctx)) + a.snap } -// Audit exposes the context audit logger. -func (a *Agent) Audit() *audit.Logger { return a.Ctx.Audit } +// History returns a copy of the conversation (for tests and replay). +func (a *Agent) History() []Msg { return append([]Msg(nil), a.history...) } -// Reset clears conversation history. -func (a *Agent) Reset() { a.history = nil } +// ansiRe matches terminal escape sequences (colors, cursor moves). +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { - return s[:i] + s = s[:i] } if len(s) > 120 { return s[:120] + "…" diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index b00479a..63e02e0 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -70,7 +70,8 @@ func newTestAgent(t *testing.T, prov Provider, tools ...toolkit.Tool) *Agent { Audit: aud, AutoApproveBelow: toolkit.TierOnChain, // observe/diagnose/local-change auto-run } - return &Agent{Provider: prov, Model: "mock-1", Reg: reg, Ctx: ctx, MaxIter: 4} + return &Agent{Provider: prov, Model: "mock-1", Reg: reg, Ctx: ctx, MaxIter: 4, + SnapshotFn: func(*toolkit.Context) string { return "LIVE: height=42" }} } func TestLoop_ToolCallRoundTrip(t *testing.T) { diff --git a/internal/agent/openai.go b/internal/agent/openai.go index f85af11..c147309 100644 --- a/internal/agent/openai.go +++ b/internal/agent/openai.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "sort" "strings" "time" ) @@ -26,7 +27,7 @@ func (o *openai) Name() string { func (o *openai) client() *http.Client { if o.hc == nil { - o.hc = &http.Client{Timeout: 120 * time.Second} + o.hc = &http.Client{Timeout: 300 * time.Second} } return o.hc } @@ -40,7 +41,7 @@ type oaiToolCall struct { } `json:"function"` } -func (o *openai) Chat(ctx context.Context, r *Request) (*Response, error) { +func (o *openai) body(r *Request) map[string]any { var msgs []map[string]any if r.System != "" { msgs = append(msgs, map[string]any{"role": "system", "content": r.System}) @@ -56,11 +57,9 @@ func (o *openai) Chat(ctx context.Context, r *Request) (*Response, error) { } var tcs []oaiToolCall for _, cl := range m.Calls { - tcs = append(tcs, oaiToolCall{ID: cl.ID, Type: "function", - Function: struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - }{Name: cl.Name, Arguments: string(cl.Args)}}) + tc := oaiToolCall{ID: cl.ID, Type: "function"} + tc.Function.Name, tc.Function.Arguments = cl.Name, string(cl.Args) + tcs = append(tcs, tc) } if len(tcs) > 0 { mm["tool_calls"] = tcs @@ -88,6 +87,10 @@ func (o *openai) Chat(ctx context.Context, r *Request) (*Response, error) { if len(tools) > 0 { body["tools"] = tools } + return body +} + +func (o *openai) post(ctx context.Context, body map[string]any) (*http.Response, error) { raw, _ := json.Marshal(body) url := strings.TrimSuffix(o.base, "/") + "/v1/chat/completions" req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(raw)) @@ -98,7 +101,11 @@ func (o *openai) Chat(ctx context.Context, r *Request) (*Response, error) { if o.key != "" { req.Header.Set("authorization", "Bearer "+o.key) } - resp, err := o.client().Do(req) + return o.client().Do(req) +} + +func (o *openai) Chat(ctx context.Context, r *Request) (*Response, error) { + resp, err := o.post(ctx, o.body(r)) if err != nil { return nil, err } @@ -119,10 +126,10 @@ func (o *openai) Chat(ctx context.Context, r *Request) (*Response, error) { return nil, err } if out.Error != nil { - return nil, fmt.Errorf("openai: %s", out.Error.Message) + return nil, fmt.Errorf("%s: %s", o.Name(), out.Error.Message) } if len(out.Choices) == 0 { - return nil, fmt.Errorf("openai: no choices") + return nil, fmt.Errorf("%s: no choices", o.Name()) } ch := out.Choices[0].Message res := &Response{Text: ch.Content, Done: len(ch.ToolCalls) == 0} @@ -133,3 +140,101 @@ func (o *openai) Chat(ctx context.Context, r *Request) (*Response, error) { } return res, nil } + +// Stream consumes chat-completions SSE chunks. Text deltas go to onText; +// tool calls arrive as fragments keyed by index (id/name first, then +// argument pieces) and are assembled here. Servers that send a whole tool +// call in one chunk (Ollama) work the same way. +func (o *openai) Stream(ctx context.Context, r *Request, onText func(string)) (*Response, error) { + body := o.body(r) + body["stream"] = true + resp, err := o.post(ctx, body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return nil, apiError(o.Name(), resp) + } + type partial struct { + id, name string + args strings.Builder + } + calls := map[int]*partial{} + var text strings.Builder + err = readSSE(resp.Body, func(data string) error { + if data == "[DONE]" { + return nil + } + var ch struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + ToolCalls []struct { + Index int `json:"index"` + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"delta"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal([]byte(data), &ch) != nil { + return nil + } + if ch.Error != nil { + return fmt.Errorf("%s: %s", o.Name(), ch.Error.Message) + } + if len(ch.Choices) == 0 { + return nil + } + d := ch.Choices[0].Delta + if d.Content != "" { + text.WriteString(d.Content) + onText(d.Content) + } + for _, tc := range d.ToolCalls { + p := calls[tc.Index] + if p == nil { + p = &partial{} + calls[tc.Index] = p + } + if tc.ID != "" { + p.id = tc.ID + } + if tc.Function.Name != "" { + p.name = tc.Function.Name + } + p.args.WriteString(tc.Function.Arguments) + } + return nil + }) + if err != nil { + return nil, err + } + res := &Response{Text: text.String()} + idx := make([]int, 0, len(calls)) + for i := range calls { + idx = append(idx, i) + } + sort.Ints(idx) + for _, i := range idx { + p := calls[i] + args := p.args.String() + if strings.TrimSpace(args) == "" { + args = "{}" + } + id := p.id + if id == "" { + id = fmt.Sprintf("call_%d", i) + } + res.Calls = append(res.Calls, Call{ID: id, Name: p.name, Args: json.RawMessage(args)}) + } + res.Done = len(res.Calls) == 0 + return res, nil +} diff --git a/internal/agent/policy.go b/internal/agent/policy.go new file mode 100644 index 0000000..c146662 --- /dev/null +++ b/internal/agent/policy.go @@ -0,0 +1,130 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// Mode is the session's approval posture. +type Mode string + +const ( + // ModeOps: observe/diagnose auto-run, local-change confirms (or + // autopilots if enabled), on-chain always confirms. + ModeOps Mode = "ops" + // ModeReadOnly: only observe/diagnose tools exist for the model; + // anything mutating is neither advertised nor runnable. + ModeReadOnly Mode = "readonly" +) + +// ParseMode accepts ops | readonly (plus "safe"/"ro" aliases). +func ParseMode(s string) (Mode, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "ops", "rw": + return ModeOps, nil + case "readonly", "read-only", "ro", "safe": + return ModeReadOnly, nil + } + return "", fmt.Errorf("unknown mode %q — want ops or readonly", s) +} + +// Policy maps tool tiers to auto | confirm | deny for one session. +// The zero value is ModeOps with no autopilot. +type Policy struct { + Mode Mode + // AutoLocal autopilots local-change tools (no confirm prompt). + // There is deliberately no equivalent for on-chain. + AutoLocal bool +} + +// PolicyFrom reads the profile's agent defaults. +func PolicyFrom(ac config.AgentConf) (Policy, error) { + m, err := ParseMode(ac.Mode) + if err != nil { + return Policy{}, err + } + p := Policy{Mode: m} + for _, t := range ac.Autopilot { + if err := p.SetAutopilot(t, true); err != nil { + return Policy{}, err + } + } + return p, nil +} + +// ReadOnly reports whether mutating tiers are refused. +func (p Policy) ReadOnly() bool { return p.Mode == ModeReadOnly } + +// Allows reports whether a tool of tier t may be advertised and run. +func (p Policy) Allows(t toolkit.Tier) bool { + return !p.ReadOnly() || t < toolkit.TierLocalChange +} + +// AutoApproveBelow is the toolkit threshold this policy implies. on-chain +// is never below it, so a tx always reaches the human. +func (p Policy) AutoApproveBelow() toolkit.Tier { + if p.AutoLocal && !p.ReadOnly() { + return toolkit.TierOnChain + } + return toolkit.TierLocalChange +} + +// Decision describes how a tier is handled: auto | confirm | deny. +func (p Policy) Decision(t toolkit.Tier) string { + switch { + case !p.Allows(t): + return "deny" + case t < p.AutoApproveBelow(): + return "auto" + default: + return "confirm" + } +} + +// SetAutopilot toggles autopilot for a tier. Only local-change can be +// autopiloted; observe/diagnose are always auto and on-chain never is. +func (p *Policy) SetAutopilot(tier string, on bool) error { + switch strings.ToLower(strings.TrimSpace(tier)) { + case "local-change", "local", "localchange": + p.AutoLocal = on + return nil + case "observe", "diagnose": + if !on { + return fmt.Errorf("%s tools are read-only and always auto-run — use /mode readonly to restrict writes instead", tier) + } + return nil + case "on-chain", "onchain", "tx": + if on { + return fmt.Errorf("on-chain actions can never be autopiloted — every transaction needs explicit approval") + } + return nil + } + return fmt.Errorf("unknown tier %q — want local-change", tier) +} + +// String renders the per-tier table, e.g. for /mode. +func (p Policy) String() string { + mode := p.Mode + if mode == "" { + mode = ModeOps + } + var parts []string + for _, t := range []toolkit.Tier{toolkit.TierObserve, toolkit.TierDiagnose, toolkit.TierLocalChange, toolkit.TierOnChain} { + parts = append(parts, fmt.Sprintf("%s=%s", t, p.Decision(t))) + } + return fmt.Sprintf("mode %s · %s", mode, strings.Join(parts, " · ")) +} + +// parseTier maps a Tier.String() back to the Tier (unknown → on-chain, the +// most restrictive, so a corrupt record can never relax a gate). +func parseTier(s string) toolkit.Tier { + for _, t := range []toolkit.Tier{toolkit.TierObserve, toolkit.TierDiagnose, toolkit.TierLocalChange, toolkit.TierOnChain} { + if t.String() == s { + return t + } + } + return toolkit.TierOnChain +} diff --git a/internal/agent/prompt.go b/internal/agent/prompt.go index 155518d..dc26e40 100644 --- a/internal/agent/prompt.go +++ b/internal/agent/prompt.go @@ -3,7 +3,10 @@ package agent import ( "fmt" "strings" + "time" + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/monitor" "github.com/abhijitkrm/cometcli/internal/toolkit" ) @@ -33,20 +36,67 @@ You operate ONE node via tools. Rules you must follow: return b.String() } -// SnapshotText renders a one-paragraph live status line for prompt context. +// snapshotMax bounds the live digest injected into every system prompt. +const snapshotMax = 1024 + +// SnapshotText renders a bounded live digest (chain, node, validator, +// host) for prompt context, gathered with the same collector as the +// dashboards. Never touches key material — monitor.Collect only reads RPC +// status, signing info, and host df/free. func SnapshotText(c *toolkit.Context) string { - var parts []string - if cc, err := c.Comet(); err == nil { - if st, err := cc.Status(c); err == nil { - parts = append(parts, - fmt.Sprintf("height=%d catching_up=%v", st.SyncInfo.LatestBlockHeight, st.SyncInfo.CatchingUp), - fmt.Sprintf("voting_power=%d", st.ValidatorInfo.VotingPower)) + sub, cancel := toolkit.WithDeadline(c, 8*time.Second) + defer cancel() + defer sub.Close() + return formatSnapshot(c.Profile, monitor.Collect(sub)) +} + +func formatSnapshot(p *config.Profile, s *monitor.Snapshot) string { + if s == nil || !s.Reachable { + msg := "(no live snapshot — node unreachable or unconfigured" + if s != nil && len(s.Errors) > 0 { + msg += ": " + s.Errors[0] + } + return truncate(msg+")", snapshotMax) + } + var b strings.Builder + fmt.Fprintf(&b, "LIVE SNAPSHOT (%s UTC):\n", s.TS.UTC().Format("15:04:05")) + if p != nil { + fmt.Fprintf(&b, "- chain: chain-id=%s evm-chain-id=%d\n", orNone(p.ChainID), p.EVMChainID) + } + fmt.Fprintf(&b, "- node: version=%s height=%d catching_up=%v peers=%d voting_power=%d\n", + orNone(s.Version), s.Height, s.CatchingUp, s.Peers, s.VotingPower) + if s.Window > 0 || s.Jailed || s.Tombstoned { + fmt.Fprintf(&b, "- validator: missed=%d/%d uptime=%.2f%% jailed=%v tombstoned=%v\n", + s.Missed, s.Window, s.UptimePct, s.Jailed, s.Tombstoned) + } + if s.DiskUsedPct > 0 || s.MemUsedPct > 0 || s.ServiceUp != nil { + svc := "unknown" + if s.ServiceUp != nil { + svc = fmt.Sprint(*s.ServiceUp) + } + host := []string{fmt.Sprintf("disk=%.0f%%", s.DiskUsedPct)} + if s.MemUsedPct > 0 { // 0 means the probe isn't available (e.g. macOS) + host = append(host, fmt.Sprintf("mem=%.0f%%", s.MemUsedPct)) } + fmt.Fprintf(&b, "- host: %s service_up=%s\n", strings.Join(host, " "), svc) } - if len(parts) == 0 { - return "(no live snapshot — node unreachable or unconfigured)" + if s.EVMHeight > 0 { + fmt.Fprintf(&b, "- evm: height=%d drift=%d\n", s.EVMHeight, s.EVMDrift) + } + for i, e := range s.Errors { + if i == 3 { + break + } + fmt.Fprintf(&b, "- probe error: %s\n", truncate(e, 160)) + } + return truncate(b.String(), snapshotMax) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s } - return "LIVE: " + strings.Join(parts, " ") + return s[:n] + "…" } func orNone(s string) string { diff --git a/internal/agent/provider.go b/internal/agent/provider.go index 81703a5..198d5c2 100644 --- a/internal/agent/provider.go +++ b/internal/agent/provider.go @@ -4,9 +4,13 @@ package agent import ( + "bufio" "context" "encoding/json" + "errors" "fmt" + "io" + "net/http" "os" "strings" @@ -60,9 +64,31 @@ type Provider interface { Name() string } +// Streamer is implemented by providers that can stream assistant text as +// it is generated. onText receives each text chunk; the returned Response +// is the same as Chat's (full text + tool calls). +type Streamer interface { + Stream(ctx context.Context, r *Request, onText func(string)) (*Response, error) +} + +// ErrOffline is returned when COMETCLI_OFFLINE disables the agent. +var ErrOffline = errors.New("agent disabled by COMETCLI_OFFLINE — the CLI, `cometcli mcp`, and all tool subcommands still work") + +// Offline reports whether COMETCLI_OFFLINE is set to a truthy value. +func Offline() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("COMETCLI_OFFLINE"))) { + case "1", "true", "yes", "on": + return true + } + return false +} + // NewProvider builds the provider from profile agent config. // Env fallback: COMETCLI_LLM_API_KEY, then provider-specific vars. func NewProvider(ac config.AgentConf) (Provider, error) { + if Offline() { + return nil, ErrOffline + } key := func(env string) string { if ac.APIKeyEnv != "" { return os.Getenv(ac.APIKeyEnv) @@ -116,3 +142,52 @@ func def(v, d string) string { } return v } + +// readSSE parses a text/event-stream body, invoking fn with each event's +// joined data payload. Comment and event-name lines are ignored. +func readSSE(r io.Reader, fn func(data string) error) error { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 64<<10), 8<<20) + var data []string + dispatch := func() error { + if len(data) == 0 { + return nil + } + d := strings.Join(data, "\n") + data = data[:0] + return fn(d) + } + for sc.Scan() { + line := sc.Text() + switch { + case line == "": + if err := dispatch(); err != nil { + return err + } + case strings.HasPrefix(line, "data:"): + data = append(data, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + } + } + if err := sc.Err(); err != nil { + return err + } + return dispatch() +} + +// apiError extracts {"error":{"message":…}} from a non-2xx response body. +func apiError(prefix string, resp *http.Response) error { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + var e struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(raw, &e) == nil && e.Error != nil && e.Error.Message != "" { + return fmt.Errorf("%s: %s (HTTP %d)", prefix, e.Error.Message, resp.StatusCode) + } + msg := strings.TrimSpace(string(raw)) + if len(msg) > 300 { + msg = msg[:300] + "…" + } + return fmt.Errorf("%s: HTTP %d %s", prefix, resp.StatusCode, msg) +} diff --git a/internal/agent/repl.go b/internal/agent/repl.go index 8a1f07e..7cfb5ac 100644 --- a/internal/agent/repl.go +++ b/internal/agent/repl.go @@ -2,8 +2,13 @@ package agent import ( "bufio" + "context" + "errors" "fmt" "io" + "os" + "os/signal" + "sort" "strings" "github.com/charmbracelet/glamour" @@ -17,7 +22,8 @@ var ( errSt = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) ) -// REPL is the interactive agent terminal. +// REPL is the line-oriented agent terminal (`cometcli agent`). Approvals +// use whatever Approver the agent's context carries (stdin y/N here). type REPL struct { Agent *Agent Out io.Writer @@ -31,24 +37,54 @@ func NewREPL(a *Agent, in io.Reader, out io.Writer) *REPL { return &REPL{Agent: a, Out: out, In: in, md: r} } -// Run starts the loop until /exit or EOF. -func (r *REPL) Run() error { - a := r.Agent - a.OnText = func(t string) { r.renderMD(t) } - a.OnToolCall = func(name string, args map[string]any) { - fmt.Fprintf(r.Out, "%s %s %s\n", toolSt.Render("◐ tool:"), callSt.Render(name), toolSt.Render(compactArgs(args))) - } - a.OnToolResult = func(name, summary string, err error) { - if err != nil { - fmt.Fprintf(r.Out, "%s %s %s\n", toolSt.Render("✗"), callSt.Render(name), errSt.Render(err.Error())) +// Printer renders agent events as plain terminal lines. Streamed deltas +// print as they arrive; when nothing streamed, the round's full text is +// rendered as markdown (when md is non-nil). +type Printer struct { + Out io.Writer + MD *glamour.TermRenderer + streamed bool +} + +// Handle renders one event. +func (p *Printer) Handle(e Event) { + switch e.Kind { + case EvDelta: + p.streamed = true + fmt.Fprint(p.Out, e.Text) + case EvText: + if p.streamed { + fmt.Fprintln(p.Out) + p.streamed = false + return + } + if p.MD != nil { + if out, err := p.MD.Render(e.Text); err == nil { + fmt.Fprint(p.Out, out) + return + } + } + fmt.Fprintln(p.Out, e.Text) + case EvToolStart: + fmt.Fprintf(p.Out, "%s %s %s\n", toolSt.Render("◐"), callSt.Render(e.Tool), toolSt.Render(CompactArgs(e.Args))) + case EvToolResult: + if e.Err != "" { + fmt.Fprintf(p.Out, " %s %s\n", toolSt.Render("✗"), errSt.Render(e.Err)) } else { - fmt.Fprintf(r.Out, "%s %s %s\n", toolSt.Render("✓"), callSt.Render(name), toolSt.Render(summary)) + fmt.Fprintf(p.Out, " %s %s\n", toolSt.Render("✓"), toolSt.Render(e.Text)) } } +} + +// Run starts the loop until /exit or EOF. +func (r *REPL) Run() error { + a := r.Agent + pr := &Printer{Out: r.Out, MD: r.md} + a.OnEvent = pr.Handle - fmt.Fprintf(r.Out, "%s — %s on %s (%s)\n%s\n\n", + fmt.Fprintf(r.Out, "%s — %s/%s on %s · %s\n%s\n\n", promptSt.Render("cometcli agent"), a.Provider.Name(), a.Model, - a.Ctx.Profile.Name, "type /help, /reset, /profile, /audit, /exit") + a.Ctx.Profile.Name, a.Policy, toolSt.Render("type /help for commands, ctrl+c cancels a running turn, /exit quits")) sc := bufio.NewScanner(r.In) sc.Buffer(make([]byte, 1<<20), 1<<20) @@ -62,70 +98,80 @@ func (r *REPL) Run() error { continue } if strings.HasPrefix(line, "/") { - if done := r.slash(line); done { + prompt, done := r.slash(line) + if done { return nil } - continue - } - if _, err := a.Run(r.Agent.Ctx, line); err != nil { - fmt.Fprintf(r.Out, "%s %v\n", errSt.Render("error:"), err) + if prompt == "" { + continue + } + line = prompt } + r.turn(line) fmt.Fprintln(r.Out) } } -func (r *REPL) slash(cmd string) bool { - fields := strings.Fields(cmd) - switch fields[0] { - case "/exit", "/quit", "/q": - return true - case "/help": - fmt.Fprintln(r.Out, `Commands: - /profile show active profile - /reset clear conversation - /tools list available tools - /audit show audit log path - /mode show approval mode - /exit quit`) - case "/profile": - p := r.Agent.Ctx.Profile - fmt.Fprintf(r.Out, "%s (%s, chain %s, role %s)\n", p.Name, p.Transport.Type, p.ChainID, p.Role) - case "/reset": - r.Agent.Reset() - fmt.Fprintln(r.Out, "conversation cleared") - case "/tools": - for _, t := range r.Agent.Reg.All() { - fmt.Fprintf(r.Out, " %-24s [%s] %s\n", t.Name(), t.Tier(), t.Desc()) +// turn runs one agent turn; ctrl+c cancels the turn, not the REPL. +func (r *REPL) turn(line string) { + ctx, cancel := context.WithCancel(r.Agent.Ctx) + defer cancel() + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + defer signal.Stop(sig) + go func() { + select { + case <-sig: + cancel() + case <-ctx.Done(): } - case "/audit": - if r.Agent.Audit() != nil { - fmt.Fprintln(r.Out, r.Agent.Audit().Path()) + }() + if _, err := r.Agent.Run(ctx, line); err != nil { + if errors.Is(err, context.Canceled) { + fmt.Fprintln(r.Out, toolSt.Render("\n(cancelled)")) + return } - case "/mode": - fmt.Fprintln(r.Out, "approvals: observe/diagnose auto · local-change prompts · on-chain always prompts") - default: - fmt.Fprintln(r.Out, "unknown command: "+fields[0]) + fmt.Fprintf(r.Out, "%s %v\n", errSt.Render("error:"), err) } - return false } -func (r *REPL) renderMD(text string) { - if r.md != nil { - if out, err := r.md.Render(text); err == nil { - fmt.Fprint(r.Out, out) - return +// slash handles a command; returns a prompt to submit (from /runbook) and +// whether to quit. +func (r *REPL) slash(cmd string) (prompt string, quit bool) { + switch strings.Fields(cmd)[0] { + case "/exit", "/quit", "/q": + return "", true + case "/help": + fmt.Fprintln(r.Out, "Commands:\n"+CommandHelp+"\n /exit quit") + return "", false + } + res, err := RunCommand(r.Agent, r.Agent.Ctx, r.Agent.Reg, cmd) + switch { + case errors.Is(err, ErrUnknownCommand): + fmt.Fprintln(r.Out, "unknown command — /help") + case err != nil: + fmt.Fprintln(r.Out, errSt.Render(err.Error())) + default: + if res.Text != "" { + fmt.Fprintln(r.Out, toolSt.Render(res.Text)) } } - fmt.Fprintln(r.Out, text) + return res.Prompt, false } -func compactArgs(args map[string]any) string { +// CompactArgs renders tool args on one bounded line. +func CompactArgs(args map[string]any) string { if len(args) == 0 { return "" } + keys := make([]string, 0, len(args)) + for k := range args { + keys = append(keys, k) + } + sort.Strings(keys) var parts []string - for k, v := range args { - parts = append(parts, fmt.Sprintf("%s=%v", k, v)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%v", k, args[k])) } s := strings.Join(parts, " ") if len(s) > 100 { diff --git a/internal/agent/replay.go b/internal/agent/replay.go new file mode 100644 index 0000000..12faf19 --- /dev/null +++ b/internal/agent/replay.go @@ -0,0 +1,166 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/abhijitkrm/cometcli/internal/audit" + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// ReplayReport summarizes a replayed session. +type ReplayReport struct { + Session string + Profile string + Prompts int + Rounds int + Tools int + Problems []string // divergences between recording and replay +} + +// Replay re-runs a recorded session through the real agent loop, with a +// provider that returns the recorded LLM turns and tools that return the +// recorded output the model saw. Nothing reaches an LLM or the node, and +// the same events a live session emits are delivered to onEvent — so the +// transcript is reproduced deterministically. +func Replay(ctx context.Context, events []audit.Event, onEvent func(Event)) (*ReplayReport, error) { + if len(events) == 0 { + return nil, fmt.Errorf("no events for that session") + } + rep := &ReplayReport{Session: events[0].Session, Profile: events[0].Profile} + prov := &replayProvider{} + reg := toolkit.NewRegistry() + recorded := map[string]*recordedTool{} + var prompts []string + + for _, e := range events { + switch e.Kind { + case audit.KindPrompt: + s, _ := e.Detail["text"].(string) + prompts = append(prompts, s) + case audit.KindLLM: + prov.turns = append(prov.turns, responseFromAudit(e.Detail)) + case audit.KindTool: + name, _ := e.Detail["name"].(string) + tier, _ := e.Detail["tier"].(string) + rt := recorded[name] + if rt == nil { + rt = &recordedTool{name: name, tier: parseTier(tier)} + recorded[name] = rt + reg.Register(rt) + } + out := recordedOut{} + out.text, _ = e.Detail["seen"].(string) + out.err, _ = e.Detail["error"].(string) + rt.outs = append(rt.outs, out) + } + } + if len(prompts) == 0 { + return nil, fmt.Errorf("session %s has no prompts (not an agent session?)", rep.Session) + } + + a := &Agent{ + Provider: prov, Model: "replay", Reg: reg, + Ctx: &toolkit.Context{ + Context: ctx, Profile: &config.Profile{Name: rep.Profile}, + Approver: toolkit.DenyApprover, + }, + MaxIter: 1 << 10, + // recorded tools never call Approve; autopilot keeps the gate quiet + Policy: Policy{Mode: ModeOps, AutoLocal: true}, + SnapshotFn: func(*toolkit.Context) string { return "" }, + toolTrunc: 1 << 30, // recorded text was already truncated live + } + a.OnEvent = func(e Event) { + if e.Kind == EvToolStart { + rep.Tools++ + } + if onEvent != nil { + onEvent(e) + } + } + for _, p := range prompts { + if onEvent != nil { + onEvent(Event{Kind: "prompt", Text: p}) + } + rep.Prompts++ + if _, err := a.Run(ctx, p); err != nil { + rep.Problems = append(rep.Problems, fmt.Sprintf("prompt %d: %v", rep.Prompts, err)) + } + } + rep.Rounds = prov.next + if prov.next < len(prov.turns) { + rep.Problems = append(rep.Problems, fmt.Sprintf("%d recorded LLM turn(s) never replayed", len(prov.turns)-prov.next)) + } + for _, rt := range recorded { + if rt.next < len(rt.outs) { + rep.Problems = append(rep.Problems, fmt.Sprintf("%s: %d recorded result(s) never consumed", rt.name, len(rt.outs)-rt.next)) + } + rep.Problems = append(rep.Problems, rt.problems...) + } + return rep, nil +} + +func responseFromAudit(d map[string]any) *Response { + r := &Response{} + r.Text, _ = d["text"].(string) + r.Done, _ = d["done"].(bool) + calls, _ := d["calls"].([]any) + for _, c := range calls { + m, _ := c.(map[string]any) + id, _ := m["id"].(string) + name, _ := m["name"].(string) + args, _ := m["args"].(string) + if !json.Valid([]byte(args)) { + args = "{}" + } + r.Calls = append(r.Calls, Call{ID: id, Name: name, Args: json.RawMessage(args)}) + } + return r +} + +type replayProvider struct { + turns []*Response + next int +} + +func (p *replayProvider) Name() string { return "replay" } + +func (p *replayProvider) Chat(context.Context, *Request) (*Response, error) { + if p.next >= len(p.turns) { + return nil, fmt.Errorf("recording exhausted after %d LLM turn(s)", p.next) + } + r := p.turns[p.next] + p.next++ + return r, nil +} + +type recordedOut struct{ text, err string } + +type recordedTool struct { + name string + tier toolkit.Tier + outs []recordedOut + next int + problems []string +} + +func (t *recordedTool) Name() string { return t.name } +func (t *recordedTool) Desc() string { return "recorded " + t.name } +func (t *recordedTool) Tier() toolkit.Tier { return t.tier } +func (t *recordedTool) Schema() map[string]any { return toolkit.ObjSchema(map[string]any{}) } + +func (t *recordedTool) Run(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { + if t.next >= len(t.outs) { + t.problems = append(t.problems, t.name+": called more times than recorded") + return nil, fmt.Errorf("no recorded result") + } + o := t.outs[t.next] + t.next++ + if o.err != "" { + return nil, fmt.Errorf("%s", o.err) + } + return &toolkit.Result{Text: o.text}, nil +} diff --git a/internal/agent/session_test.go b/internal/agent/session_test.go new file mode 100644 index 0000000..a85b7fc --- /dev/null +++ b/internal/agent/session_test.go @@ -0,0 +1,390 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/audit" + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/redact" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +const testMnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow" + +// streamProvider wraps mockProvider with a Streamer that emits each +// response's text in two chunks. +type streamProvider struct{ *mockProvider } + +func (s streamProvider) Stream(ctx context.Context, r *Request, onText func(string)) (*Response, error) { + resp, err := s.Chat(ctx, r) + if err != nil { + return nil, err + } + if n := len(resp.Text); n > 0 { + onText(resp.Text[:n/2]) + onText(resp.Text[n/2:]) + } + return resp, nil +} + +func TestUserInputRedactedBeforeLLM(t *testing.T) { + prov := &mockProvider{responses: []*Response{{Text: "ok", Done: true}}} + a := newTestAgent(t, prov) + a.Redact = redact.NewRedactor("val.internal") + if _, err := a.Run(context.Background(), "my seed is "+testMnemonic+" and host val.internal is down"); err != nil { + t.Fatal(err) + } + sent := prov.lastReq.Messages[0].Text + if strings.Contains(sent, "sausage") || strings.Contains(sent, "val.internal") { + t.Fatalf("secret reached the provider: %q", sent) + } + if !strings.Contains(sent, "[REDACTED_MNEMONIC]") || !strings.Contains(sent, "[REDACTED_HOST]") { + t.Fatalf("missing redaction markers: %q", sent) + } +} + +func TestSnapshotRedactedAndCached(t *testing.T) { + prov := &mockProvider{responses: []*Response{{Text: "a", Done: true}, {Text: "b", Done: true}}} + a := newTestAgent(t, prov) + n := 0 + a.SnapshotFn = func(*toolkit.Context) string { + n++ + return "LIVE: peer 10.9.9.9 token=abcd1234secret" + } + a.Redact = redact.NewRedactor("10.9.9.9") + a.Run(context.Background(), "one") + a.Run(context.Background(), "two") + if n != 1 { + t.Fatalf("snapshot probed %d times, want 1 (TTL cache)", n) + } + sys := prov.lastReq.System + if strings.Contains(sys, "10.9.9.9") || strings.Contains(sys, "abcd1234secret") { + t.Fatalf("snapshot not redacted: %q", sys) + } +} + +func TestStreamingEmitsDeltasThenText(t *testing.T) { + prov := streamProvider{&mockProvider{responses: []*Response{{Text: "node is healthy", Done: true}}}} + a := newTestAgent(t, prov) + a.Stream = true + var kinds []EventKind + var deltas string + a.OnEvent = func(e Event) { + kinds = append(kinds, e.Kind) + if e.Kind == EvDelta { + deltas += e.Text + } + } + out, err := a.Run(context.Background(), "status?") + if err != nil { + t.Fatal(err) + } + if deltas != "node is healthy" || out != "node is healthy" { + t.Fatalf("deltas=%q out=%q", deltas, out) + } + if len(kinds) != 3 || kinds[0] != EvDelta || kinds[2] != EvText { + t.Fatalf("event order = %v", kinds) + } +} + +func TestEventsForToolCalls(t *testing.T) { + tool := stubTool{name: "node.status", tier: toolkit.TierObserve, run: func(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + return &toolkit.Result{Text: "height 42\nmore"}, nil + }} + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "c1", Name: "node__status", Args: json.RawMessage(`{"key":"` + strings.Repeat("ab", 32) + `"}`)}}}, + {Text: "done", Done: true}, + }} + a := newTestAgent(t, prov, tool) + var evs []Event + a.OnEvent = func(e Event) { evs = append(evs, e) } + a.Run(context.Background(), "go") + if len(evs) != 3 || evs[0].Kind != EvToolStart || evs[1].Kind != EvToolResult || evs[2].Kind != EvText { + t.Fatalf("events = %+v", evs) + } + if evs[0].Args["key"] != "[REDACTED_HEX]" { + t.Fatalf("tool args shown unredacted: %v", evs[0].Args) + } + if evs[1].Text != "height 42" { + t.Fatalf("result summary = %q", evs[1].Text) + } +} + +func TestPolicy(t *testing.T) { + var p Policy + if p.Decision(toolkit.TierObserve) != "auto" || p.Decision(toolkit.TierLocalChange) != "confirm" || p.Decision(toolkit.TierOnChain) != "confirm" { + t.Fatalf("default policy wrong: %s", p) + } + if err := p.SetAutopilot("on-chain", true); err == nil { + t.Fatal("on-chain autopilot must be refused") + } + if err := p.SetAutopilot("local-change", true); err != nil { + t.Fatal(err) + } + if p.Decision(toolkit.TierLocalChange) != "auto" || p.Decision(toolkit.TierOnChain) != "confirm" { + t.Fatalf("autopilot policy wrong: %s", p) + } + if p.AutoApproveBelow() > toolkit.TierOnChain { + t.Fatal("on-chain must never be auto-approved") + } + p.Mode = ModeReadOnly + if p.Decision(toolkit.TierLocalChange) != "deny" || p.Decision(toolkit.TierDiagnose) != "auto" { + t.Fatalf("readonly policy wrong: %s", p) + } + if _, err := PolicyFrom(config.AgentConf{Autopilot: []string{"on-chain"}}); err == nil { + t.Fatal("profile autopilot on-chain must be rejected") + } +} + +func TestReadOnlyHidesAndBlocks(t *testing.T) { + ran := false + restart := stubTool{name: "node.service", tier: toolkit.TierLocalChange, run: func(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + ran = true + return &toolkit.Result{Text: "restarted"}, nil + }} + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "c1", Name: "node__service", Args: json.RawMessage(`{}`)}}}, + {Text: "ok", Done: true}, + }} + a := newTestAgent(t, prov, restart) + a.Policy.Mode = ModeReadOnly + a.Run(context.Background(), "restart") + if ran { + t.Fatal("local-change tool ran in readonly mode") + } + if len(prov.reqs[0].Tools) != 0 { + t.Fatalf("readonly advertised mutating tools: %+v", prov.reqs[0].Tools) + } +} + +func TestAutopilotSkipsLocalChangePromptButNotOnChain(t *testing.T) { + var asked []toolkit.Tier + mk := func(name string, tier toolkit.Tier) stubTool { + return stubTool{name: name, tier: tier, run: func(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + if err := c.Approve(name, tier, nil); err != nil { + return nil, err + } + return &toolkit.Result{Text: "ok"}, nil + }} + } + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "1", Name: "net__add-peer", Args: json.RawMessage(`{}`)}, {ID: "2", Name: "val__unjail", Args: json.RawMessage(`{}`)}}}, + {Text: "done", Done: true}, + }} + a := newTestAgent(t, prov, mk("net.add-peer", toolkit.TierLocalChange), mk("val.unjail", toolkit.TierOnChain)) + a.Ctx.Approver = func(c *toolkit.Context, p string, tier toolkit.Tier, d map[string]any) (bool, error) { + asked = append(asked, tier) + return true, nil + } + a.Policy.AutoLocal = true + a.Run(context.Background(), "fix it") + if len(asked) != 1 || asked[0] != toolkit.TierOnChain { + t.Fatalf("approvals asked for %v, want only on-chain", asked) + } +} + +func TestEventApprover(t *testing.T) { + var got *Approval + appr := EventApprover(func(e Event) { + got = e.Approval + go e.Approval.Answer(true) + }) + c := &toolkit.Context{Context: context.Background()} + ok, err := appr(c, "broadcast tx", toolkit.TierOnChain, map[string]any{"fee": "1uatom"}) + if err != nil || !ok || got == nil || got.Tier != "on-chain" { + t.Fatalf("ok=%v err=%v approval=%+v", ok, err, got) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + ok, _ = EventApprover(func(Event) {})(&toolkit.Context{Context: ctx}, "x", toolkit.TierOnChain, nil) + if ok { + t.Fatal("cancelled approval must deny") + } +} + +func TestCommands(t *testing.T) { + a := newTestAgent(t, &mockProvider{}, stubTool{name: "val.unjail", tier: toolkit.TierOnChain}) + run := func(line string) (CmdResult, error) { return RunCommand(a, a.Ctx, a.Reg, line) } + if r, _ := run("/mode readonly"); !strings.Contains(r.Text, "mode readonly") || !a.Policy.ReadOnly() { + t.Fatalf("/mode readonly: %q", r.Text) + } + if r, _ := run("/tools"); !strings.Contains(r.Text, "deny") { + t.Fatalf("/tools should show deny in readonly: %q", r.Text) + } + run("/mode ops") + if _, err := run("/approve on-chain on"); err == nil { + t.Fatal("/approve on-chain must fail") + } + if _, err := run("/approve local-change on"); err != nil || !a.Policy.AutoLocal { + t.Fatalf("/approve local-change: %v", err) + } + if _, err := run("/nope"); err != ErrUnknownCommand { + t.Fatalf("unknown command err = %v", err) + } + if r, _ := run("/runbook jail-recovery"); !strings.Contains(r.Prompt, "jail-recovery") { + t.Fatalf("/runbook should produce a prompt: %+v", r) + } + if r, _ := RunCommand(nil, a.Ctx, a.Reg, "/profile"); !strings.Contains(r.Text, "testp") { + t.Fatalf("/profile without agent: %q", r.Text) + } + if _, err := RunCommand(nil, a.Ctx, a.Reg, "/mode"); err == nil { + t.Fatal("/mode without agent should error") + } +} + +func TestOffline(t *testing.T) { + t.Setenv("COMETCLI_OFFLINE", "1") + if _, err := NewProvider(config.AgentConf{Provider: "anthropic"}); err != ErrOffline { + t.Fatalf("err = %v, want ErrOffline", err) + } +} + +func TestReplayRoundTrip(t *testing.T) { + tool := stubTool{name: "val.signing", tier: toolkit.TierObserve, run: func(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + return &toolkit.Result{Text: "missed 12/10000"}, nil + }} + prov := &mockProvider{responses: []*Response{ + {Text: "checking", Calls: []Call{{ID: "c1", Name: "val__signing", Args: json.RawMessage(`{"n":1}`)}}}, + {Text: "you missed 12 blocks", Done: true}, + {Text: "nothing else", Done: true}, + }} + a := newTestAgent(t, prov, tool) + var live []Event + a.OnEvent = func(e Event) { live = append(live, e) } + a.Run(context.Background(), "am I missing blocks?") + a.Run(context.Background(), "anything else?") + id := a.Audit().Session() + + evs, err := audit.ReadSession(id) + if err != nil { + t.Fatal(err) + } + var replayed []Event + rep, err := Replay(context.Background(), evs, func(e Event) { + if e.Kind != "prompt" { + replayed = append(replayed, e) + } + }) + if err != nil { + t.Fatal(err) + } + if len(rep.Problems) != 0 || rep.Prompts != 2 || rep.Rounds != 3 || rep.Tools != 1 { + t.Fatalf("report = %+v", rep) + } + if fmt.Sprint(live) != fmt.Sprint(replayed) { + t.Fatalf("replay diverged:\nlive: %v\nreplay: %v", live, replayed) + } +} + +func TestAuditSessionStamped(t *testing.T) { + prov := &mockProvider{responses: []*Response{{Text: "hi", Done: true}}} + a := newTestAgent(t, prov) + a.Run(context.Background(), "hello") + b, _ := os.ReadFile(a.Audit().Path()) + for _, want := range []string{`"session":"` + a.ID() + `"`, `"kind":"llm"`} { + if !strings.Contains(string(b), want) { + t.Fatalf("audit missing %s:\n%s", want, b) + } + } + old := a.ID() + a.Reset() + if a.ID() == old { + t.Fatal("Reset must start a new session") + } +} + +func sse(w http.ResponseWriter, lines ...string) { + w.Header().Set("content-type", "text/event-stream") + for _, l := range lines { + fmt.Fprintf(w, "data: %s\n\n", l) + } +} + +func TestAnthropicStream(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + if body["stream"] != true { + t.Errorf("stream flag not sent") + } + sse(w, + `{"type":"message_start"}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Check"}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ing."}}`, + `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tu1","name":"val__status"}}`, + `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"ver"}}`, + `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"bose\":true}"}}`, + `{"type":"message_delta","delta":{"stop_reason":"tool_use"}}`, + `{"type":"message_stop"}`) + })) + defer srv.Close() + p := &anthropic{key: "k", model: "m", base: srv.URL} + var chunks []string + res, err := p.Stream(context.Background(), &Request{Model: "m", MaxTok: 10}, func(s string) { chunks = append(chunks, s) }) + if err != nil { + t.Fatal(err) + } + if res.Text != "Checking." || len(chunks) != 2 || res.Done { + t.Fatalf("res=%+v chunks=%v", res, chunks) + } + if len(res.Calls) != 1 || res.Calls[0].Name != "val__status" || string(res.Calls[0].Args) != `{"verbose":true}` { + t.Fatalf("calls = %+v", res.Calls) + } +} + +func TestOpenAIStream(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sse(w, + `{"choices":[{"delta":{"content":"Look"}}]}`, + `{"choices":[{"delta":{"content":"ing"}}]}`, + `{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"node__peers","arguments":""}}]}}]}`, + `{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"n\":"}}]}}]}`, + `{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"3}"}}]}}]}`, + `{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`, + `[DONE]`) + })) + defer srv.Close() + p := &openai{model: "m", base: srv.URL} + res, err := p.Stream(context.Background(), &Request{Model: "m"}, func(string) {}) + if err != nil { + t.Fatal(err) + } + if res.Text != "Looking" || len(res.Calls) != 1 || string(res.Calls[0].Args) != `{"n":3}` || res.Calls[0].ID != "call_a" { + t.Fatalf("res = %+v", res) + } +} + +func TestStreamHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + w.Write([]byte(`{"error":{"message":"invalid x-api-key"}}`)) + })) + defer srv.Close() + _, err := (&anthropic{base: srv.URL}).Stream(context.Background(), &Request{}, func(string) {}) + if err == nil || !strings.Contains(err.Error(), "invalid x-api-key") { + t.Fatalf("err = %v", err) + } +} + +func TestToolOutputANSIStripped(t *testing.T) { + tool := stubTool{name: "node.logs", tier: toolkit.TierDiagnose, run: func(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + return &toolkit.Result{Text: "\x1b[90m10:15AM\x1b[0m \x1b[32mINF\x1b[0m committed height=15171"}, nil + }} + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "c1", Name: "node__logs", Args: json.RawMessage(`{}`)}}}, + {Text: "ok", Done: true}, + }} + a := newTestAgent(t, prov, tool) + a.Run(context.Background(), "logs") + if got := a.history[2].Text; got != "10:15AM INF committed height=15171" { + t.Fatalf("tool text = %q", got) + } +} diff --git a/internal/audit/audit.go b/internal/audit/audit.go index d4ab4ec..8c29edb 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -4,6 +4,9 @@ package audit import ( + "bufio" + "crypto/sha256" + "encoding/hex" "encoding/json" "os" "path/filepath" @@ -31,11 +34,18 @@ type Event struct { TS time.Time `json:"ts"` Kind Kind `json:"kind"` Profile string `json:"profile,omitempty"` + Session string `json:"session,omitempty"` Detail map[string]any `json:"detail"` } -// Logger appends events to a daily JSONL file. +// Logger appends events to a daily JSONL file. Loggers derived with +// WithSession share the underlying file and stamp a session id. type Logger struct { + sink *sink + session string +} + +type sink struct { mu sync.Mutex file *os.File path string @@ -56,7 +66,24 @@ func Open(profile string) (*Logger, error) { if err != nil { return nil, err } - return &Logger{file: f, path: path, enc: json.NewEncoder(f)}, nil + return &Logger{sink: &sink{file: f, path: path, enc: json.NewEncoder(f)}}, nil +} + +// WithSession returns a logger sharing l's file that stamps every event +// with session id — the key `cometcli audit replay` groups by. +func (l *Logger) WithSession(id string) *Logger { + if l == nil { + return nil + } + return &Logger{sink: l.sink, session: id} +} + +// Session returns the session id stamped on events ("" if none). +func (l *Logger) Session() string { + if l == nil { + return "" + } + return l.session } // Log records one event. Failures are intentionally non-fatal but reported. @@ -64,30 +91,46 @@ func (l *Logger) Log(kind Kind, profile string, detail map[string]any) error { if l == nil { return nil } - l.mu.Lock() - defer l.mu.Unlock() - return l.enc.Encode(Event{TS: time.Now().UTC(), Kind: kind, Profile: profile, Detail: detail}) + l.sink.mu.Lock() + defer l.sink.mu.Unlock() + return l.sink.enc.Encode(Event{TS: time.Now().UTC(), Kind: kind, Profile: profile, Session: l.session, Detail: detail}) } // Path returns the current audit file path. -func (l *Logger) Path() string { return l.path } +func (l *Logger) Path() string { + if l == nil { + return "" + } + return l.sink.path +} -// Close flushes and closes the file. +// Close flushes and closes the file (shared by every WithSession child). func (l *Logger) Close() error { - if l == nil || l.file == nil { + if l == nil || l.sink.file == nil { return nil } - return l.file.Close() + return l.sink.file.Close() } // Tool logs a tool invocation. func (l *Logger) Tool(profile, name string, tier string, args, result map[string]any, err error) { + l.ToolSeen(profile, name, tier, args, result, err, "") +} + +// ToolSeen logs a tool invocation plus the exact (redacted, truncated) text +// the model was shown — what makes an agent session replayable. +func (l *Logger) ToolSeen(profile, name string, tier string, args, result map[string]any, err error, seen string) { d := map[string]any{"name": name, "tier": tier, "args": args} if err != nil { d["error"] = err.Error() } else { d["result"] = result } + if seen != "" { + sum := sha256.Sum256([]byte(seen)) + d["seen"] = seen + d["digest"] = hex.EncodeToString(sum[:8]) + } _ = l.Log(KindTool, profile, d) } @@ -126,3 +169,62 @@ func List() ([]string, error) { } return out, nil } + +// ReadSession returns every event stamped with session id across all audit +// files, oldest first. +func ReadSession(id string) ([]Event, error) { + files, err := List() + if err != nil { + return nil, err + } + var out []Event + for i := len(files) - 1; i >= 0; i-- { // List is newest-first + f, err := os.Open(files[i]) + if err != nil { + return nil, err + } + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 1<<20), 16<<20) + for sc.Scan() { + var e Event + if json.Unmarshal(sc.Bytes(), &e) == nil && e.Session == id { + out = append(out, e) + } + } + f.Close() + if err := sc.Err(); err != nil { + return nil, err + } + } + return out, nil +} + +// Sessions lists distinct session ids found in the given audit file, in +// first-seen order, with their first prompt (for `audit sessions`). +func Sessions(path string) ([][2]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var out [][2]string + idx := map[string]int{} + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 1<<20), 16<<20) + for sc.Scan() { + var e Event + if json.Unmarshal(sc.Bytes(), &e) != nil || e.Session == "" { + continue + } + i, ok := idx[e.Session] + if !ok { + i = len(out) + idx[e.Session] = i + out = append(out, [2]string{e.Session, ""}) + } + if e.Kind == KindPrompt && out[i][1] == "" { + out[i][1], _ = e.Detail["text"].(string) + } + } + return out, sc.Err() +} diff --git a/internal/cli/agent.go b/internal/cli/agent.go index 898ef2b..61a83e7 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -8,7 +8,10 @@ import ( // agentFlags wires the bounded-run flags shared by agent and ask. func agentFlags(cmd *cobra.Command) { - cmd.Flags().Bool("safe", false, "read-only mode: observe/diagnose tools only — mutating tools are refused") + cmd.Flags().String("mode", "", "approval posture: ops | readonly (default from profile agent.mode)") + cmd.Flags().Bool("safe", false, "shorthand for --mode readonly: observe/diagnose tools only") + cmd.Flags().StringSlice("autopilot", nil, "tiers that skip the confirm prompt (only local-change; on-chain never)") + cmd.Flags().Bool("no-stream", false, "disable token streaming") cmd.Flags().Int("budget", 0, "max tool calls per turn (0 = unlimited)") cmd.Flags().Int("max-iter", 0, "max model iterations per turn (default 16)") } diff --git a/internal/cli/agent_impl.go b/internal/cli/agent_impl.go index 848e1fc..c4fb091 100644 --- a/internal/cli/agent_impl.go +++ b/internal/cli/agent_impl.go @@ -1,8 +1,6 @@ package cli import ( - "fmt" - "github.com/spf13/cobra" "github.com/abhijitkrm/cometcli/internal/agent" @@ -17,13 +15,43 @@ func runAgentImpl(cmd *cobra.Command, reg *toolkit.Registry, oneshot string) err return err } defer c.Close() + defer c.Audit.Close() a, err := agent.New(c, reg) if err != nil { return err } + if err := applyAgentFlags(cmd, a); err != nil { + return err + } + if oneshot != "" { + pr := &agent.Printer{Out: c.Out} + a.OnEvent = pr.Handle + _, err := a.Run(cmd.Context(), oneshot) + return err + } + repl := agent.NewREPL(a, cmd.InOrStdin(), c.Out) + return repl.Run() +} + +// applyAgentFlags layers --mode/--safe/--autopilot/--budget/--max-iter/ +// --no-stream over the profile's agent defaults. +func applyAgentFlags(cmd *cobra.Command, a *agent.Agent) error { + if m, _ := cmd.Flags().GetString("mode"); m != "" { + mode, err := agent.ParseMode(m) + if err != nil { + return err + } + a.Policy.Mode = mode + } if safe, _ := cmd.Flags().GetBool("safe"); safe { - a.Safe = true - c.Approver = toolkit.DenyApprover // hard-refuse anything that slips past filtering + a.Policy.Mode = agent.ModeReadOnly + } + if tiers, _ := cmd.Flags().GetStringSlice("autopilot"); len(tiers) > 0 { + for _, t := range tiers { + if err := a.Policy.SetAutopilot(t, true); err != nil { + return err + } + } } if b, _ := cmd.Flags().GetInt("budget"); b > 0 { a.MaxCalls = b @@ -31,14 +59,12 @@ func runAgentImpl(cmd *cobra.Command, reg *toolkit.Registry, oneshot string) err if n, _ := cmd.Flags().GetInt("max-iter"); n > 0 { a.MaxIter = n } - if oneshot != "" { - a.OnText = func(t string) { fmt.Fprintln(c.Out, t) } - a.OnToolCall = func(name string, args map[string]any) { - fmt.Fprintf(c.Out, "◐ %s %v\n", name, args) - } - _, err := a.Run(cmd.Context(), oneshot) - return err + if ns, _ := cmd.Flags().GetBool("no-stream"); ns { + a.Stream = false } - repl := agent.NewREPL(a, cmd.InOrStdin(), c.Out) - return repl.Run() + if a.Policy.ReadOnly() { + // hard-refuse anything that slips past filtering + a.Ctx.Approver = toolkit.DenyApprover + } + return nil } diff --git a/internal/cli/audit_replay.go b/internal/cli/audit_replay.go new file mode 100644 index 0000000..5ee345f --- /dev/null +++ b/internal/cli/audit_replay.go @@ -0,0 +1,89 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/abhijitkrm/cometcli/internal/agent" + "github.com/abhijitkrm/cometcli/internal/audit" +) + +// auditSessionsCmd lists agent sessions recorded in today's (or --all) logs. +func auditSessionsCmd() *cobra.Command { + var all bool + cmd := &cobra.Command{ + Use: "sessions", + Short: "List recorded agent sessions (replayable with `audit replay `)", + RunE: func(cmd *cobra.Command, _ []string) error { + files, err := audit.List() + if err != nil { + return err + } + if !all && len(files) > 1 { + files = files[:1] + } + out := cmd.OutOrStdout() + n := 0 + for _, f := range files { + ss, err := audit.Sessions(f) + if err != nil { + return err + } + for _, s := range ss { + first := strings.ReplaceAll(s[1], "\n", " ") + if len(first) > 70 { + first = first[:70] + "…" + } + fmt.Fprintf(out, "%s %s\n", s[0], first) + n++ + } + } + if n == 0 { + fmt.Fprintln(out, "no agent sessions recorded") + } + return nil + }, + } + cmd.Flags().BoolVar(&all, "all", false, "scan every audit file, not just today's") + return cmd +} + +// auditReplayCmd reproduces a recorded agent session deterministically: +// recorded LLM turns and tool outputs are fed back through the real agent +// loop. No LLM call, no node access. +func auditReplayCmd() *cobra.Command { + return &cobra.Command{ + Use: "replay ", + Short: "Replay a recorded agent session deterministically (offline, no node access)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + evs, err := audit.ReadSession(args[0]) + if err != nil { + return err + } + out := cmd.OutOrStdout() + pr := &agent.Printer{Out: out} + rep, err := agent.Replay(cmd.Context(), evs, func(e agent.Event) { + if e.Kind == "prompt" { + fmt.Fprintf(out, "\n❯ %s\n", e.Text) + return + } + pr.Handle(e) + }) + if err != nil { + return err + } + fmt.Fprintf(out, "\n— replayed session %s (profile %s): %d prompt(s), %d LLM turn(s), %d tool call(s)\n", + rep.Session, rep.Profile, rep.Prompts, rep.Rounds, rep.Tools) + for _, p := range rep.Problems { + fmt.Fprintf(out, " ! %s\n", p) + } + if len(rep.Problems) > 0 { + return fmt.Errorf("replay diverged from the recording (%d problem(s))", len(rep.Problems)) + } + return nil + }, + } +} diff --git a/internal/cli/profile.go b/internal/cli/profile.go index 21fef8b..36310c9 100644 --- a/internal/cli/profile.go +++ b/internal/cli/profile.go @@ -83,7 +83,7 @@ func profileAddCmd() *cobra.Command { Use: "add ", Aliases: []string{"edit", "set"}, Short: "Add or update a node profile", - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, a []string) error { cfg, err := config.Load() if err != nil { @@ -254,6 +254,7 @@ func auditCmd() *cobra.Command { }, } cmd.Flags().IntVar(&tail, "tail", 30, "show last N events") + cmd.AddCommand(auditSessionsCmd(), auditReplayCmd()) return cmd } diff --git a/internal/cli/root.go b/internal/cli/root.go index 920f826..365fb3d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -34,10 +34,22 @@ func NewRoot(reg *toolkit.Registry, extra []*cobra.Command) *cobra.Command { Every capability is a deterministic subcommand (cometcli val status, cometcli doctor, cometcli tx unjail) AND a tool the agent can call. -Run 'cometcli agent' for the AI SRE, or 'cometcli ask "..."' for -one-shot questions.`, +Run 'cometcli' with no arguments to open the chat terminal, 'cometcli +ask "..."' for one-shot questions, or 'cometcli serve' for a local web chat.`, SilenceUsage: true, SilenceErrors: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + // Bare `cometcli` opens the chat TUI when there's a terminal and + // a profile; otherwise it's the classic help screen. + if !isTerminal(cmd.InOrStdin()) || !hasProfile() { + if isTerminal(cmd.InOrStdin()) { + fmt.Fprint(cmd.OutOrStdout(), "no profile yet — run `cometcli init` to connect a node, then `cometcli` opens the chat.\n\n") + } + return cmd.Help() + } + return runUI(cmd, reg, 5*time.Second) + }, } pf := root.PersistentFlags() pf.StringVar(&flagProfile, "profile", "", "profile to use (env COMETCLI_PROFILE)") @@ -210,13 +222,36 @@ func NewCtx(cmd *cobra.Command, requireProfile bool) (*toolkit.Context, error) { return c, nil } +// hasProfile reports whether an active profile is configured. +func hasProfile() bool { + cfg, err := config.Load() + if err != nil { + return false + } + p, err := cfg.ActiveProfile(flagProfile) + return err == nil && p != nil +} + // StdinApprover prompts y/N on the terminal. func StdinApprover(in io.Reader) toolkit.Approver { reader := bufio.NewReader(in) return func(c *toolkit.Context, prompt string, tier toolkit.Tier, detail map[string]any) (bool, error) { fmt.Fprintf(c.Out, "\n⚠ [%s] %s\n", tier, prompt) - for k, v := range detail { - fmt.Fprintf(c.Out, " %s: %v\n", k, v) + keys := make([]string, 0, len(detail)) + for k := range detail { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := fmt.Sprint(detail[k]) + switch { + case k == "doc" && strings.Contains(prompt, v): + // tx doc already printed as part of the prompt + case strings.Contains(v, "\n"): + fmt.Fprintf(c.Out, " %s:\n %s\n", k, strings.ReplaceAll(v, "\n", "\n ")) + default: + fmt.Fprintf(c.Out, " %s: %s\n", k, v) + } } fmt.Fprint(c.Out, "Proceed? [y/N] ") line, err := reader.ReadString('\n') diff --git a/internal/cli/serve.go b/internal/cli/serve.go new file mode 100644 index 0000000..abed279 --- /dev/null +++ b/internal/cli/serve.go @@ -0,0 +1,77 @@ +package cli + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/spf13/cobra" + + "github.com/abhijitkrm/cometcli/internal/agent" + "github.com/abhijitkrm/cometcli/internal/serve" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// ServeCmd runs the local web chat: one agent session over a loopback-only +// HTTP API with an embedded page. Same tools, policy, redaction, and audit +// trail as the TUI. +func ServeCmd(reg *toolkit.Registry) *cobra.Command { + var addr string + var open bool + cmd := &cobra.Command{ + Use: "serve", + Short: "Local web chat for the agent (loopback only, token-protected)", + Long: `Serve a browser chat UI for the agent on 127.0.0.1. + +The server prints a one-time link carrying a random token; only requests with +that token (as a cookie or Bearer header) and a loopback Host are accepted. +Approvals (local-change diffs, on-chain transactions) appear as modals in the +page. To reach it from another machine, tunnel it: ssh -L 8765:127.0.0.1:8765 host`, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := NewCtx(cmd, true) + if err != nil { + return err + } + defer c.Close() + defer c.Audit.Close() + a, agentErr := agent.New(c, reg) + if agentErr == nil { + if err := applyAgentFlags(cmd, a); err != nil { + return err + } + } + ln, err := serve.Listen(addr) + if err != nil { + return err + } + s := serve.New(c, reg, a, agentErr) + url := fmt.Sprintf("http://%s/?token=%s", ln.Addr(), s.Token) + fmt.Fprintf(c.Out, "cometcli web chat for profile %s\n %s\n", c.Profile.Name, url) + if agentErr != nil { + fmt.Fprintf(c.Out, " (agent unavailable: %v)\n", agentErr) + } + fmt.Fprintln(c.Out, "ctrl+c to stop") + if open { + openBrowser(url) + } + return s.Serve(cmd.Context(), ln) + }, + } + agentFlags(cmd) + cmd.Flags().StringVar(&addr, "addr", "127.0.0.1:8765", "listen address (loopback only; port 0 picks a free one)") + cmd.Flags().BoolVar(&open, "open", false, "open the chat in the default browser") + return cmd +} + +func openBrowser(url string) { + var c *exec.Cmd + switch runtime.GOOS { + case "darwin": + c = exec.Command("open", url) + case "windows": + c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + c = exec.Command("xdg-open", url) + } + _ = c.Start() +} diff --git a/internal/cli/ui.go b/internal/cli/ui.go index a0b72a2..ce8362a 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "io" "os" "time" @@ -22,17 +23,28 @@ func UICmd(reg *toolkit.Registry) *cobra.Command { Use: "ui", Short: "Terminal app — chat with the agent, dashboards on tabs", RunE: func(cmd *cobra.Command, _ []string) error { - if f, ok := cmd.InOrStdin().(*os.File); !ok || !term.IsTerminal(int(f.Fd())) { - return fmt.Errorf("cometcli ui needs an interactive terminal — run it in a real shell (or use --json commands for scripting)") - } - c, err := NewCtx(cmd, true) - if err != nil { - return err - } - defer c.Close() - return tui.RunApp(c, reg, time.Duration(interval)*time.Second) + return runUI(cmd, reg, time.Duration(interval)*time.Second) }, } cmd.Flags().IntVar(&interval, "interval", 5, "refresh interval seconds") return cmd } + +func isTerminal(r io.Reader) bool { + f, ok := r.(*os.File) + return ok && term.IsTerminal(int(f.Fd())) +} + +// runUI opens the chat-first TUI (shared by `cometcli ui` and bare `cometcli`). +func runUI(cmd *cobra.Command, reg *toolkit.Registry, interval time.Duration) error { + if !isTerminal(cmd.InOrStdin()) { + return fmt.Errorf("cometcli ui needs an interactive terminal — run it in a real shell (or use --json commands for scripting)") + } + c, err := NewCtx(cmd, true) + if err != nil { + return err + } + defer c.Close() + defer c.Audit.Close() + return tui.RunApp(c, reg, interval) +} diff --git a/internal/client/host/host.go b/internal/client/host/host.go index 31d5523..1ea07ec 100644 --- a/internal/client/host/host.go +++ b/internal/client/host/host.go @@ -220,8 +220,14 @@ func (s *SSH) Stat(ctx context.Context, path string) (os.FileInfo, error) { return &fileInfo{name: f[4], size: size, mode: os.FileMode(mode)}, nil } -// Close shuts the connection. -func (s *SSH) Close() error { return s.client.Close() } +// Close shuts the connection. Safe on a nil or never-connected *SSH (a +// failed dial can surface as a typed nil inside the Host interface). +func (s *SSH) Close() error { + if s == nil || s.client == nil { + return nil + } + return s.client.Close() +} func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" diff --git a/internal/config/config.go b/internal/config/config.go index a8a94f7..c502c88 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -103,6 +103,17 @@ type AgentConf struct { Model string `yaml:"model,omitempty"` BaseURL string `yaml:"base_url,omitempty"` APIKeyEnv string `yaml:"api_key_env,omitempty"` + // Mode is the default approval posture: ops (default) | readonly. + Mode string `yaml:"mode,omitempty"` + // Autopilot lists tiers that run without a confirm prompt. Only + // local-change is accepted; on-chain can never be autopiloted. + Autopilot []string `yaml:"autopilot,omitempty"` + // RedactHosts are hostnames/IPs masked before any text reaches the LLM. + RedactHosts []string `yaml:"redact_hosts,omitempty"` + // RedactEndpoints also masks the profile's own endpoint/SSH hosts. + RedactEndpoints bool `yaml:"redact_endpoints,omitempty"` + // NoStream disables token streaming for endpoints that mishandle it. + NoStream bool `yaml:"no_stream,omitempty"` } // Alerts configures notification sinks for the monitor. diff --git a/internal/redact/redact.go b/internal/redact/redact.go index ac6408b..53bb16b 100644 --- a/internal/redact/redact.go +++ b/internal/redact/redact.go @@ -6,11 +6,16 @@ package redact import ( "regexp" "strings" + + "github.com/cosmos/go-bip39" ) var ( - // BIP-39 style: 12+ consecutive lowercase words (loose match). - mnemonicRe = regexp.MustCompile(`\b([a-z]+ ){11,}[a-z]+\b`) + // candidate mnemonic span: 12+ whitespace-separated lowercase words. + // Spans are confirmed word-by-word against the BIP-39 list so ordinary + // prose ("why is my validator missing blocks …") is left alone. + mnemonicRe = regexp.MustCompile(`\b([a-z]+\s+){11,}[a-z]+\b`) + wordRe = regexp.MustCompile(`[a-z]+`) // 64-char hex (private keys), with or without 0x prefix hex64Re = regexp.MustCompile(`\b(?:0[xX])?[0-9a-fA-F]{64}\b`) // priv_validator_key.json / priv_validator_state.json payloads @@ -28,7 +33,7 @@ var ( // Text scrubs sensitive material from s. func Text(s string) string { - s = mnemonicRe.ReplaceAllString(s, "[REDACTED_MNEMONIC]") + s = mnemonicRe.ReplaceAllStringFunc(s, scrubMnemonic) s = hex64Re.ReplaceAllString(s, "[REDACTED_HEX]") s = privValRe.ReplaceAllString(s, `"$1": "[REDACTED]"`) s = keyValueRe.ReplaceAllString(s, `"value": "[REDACTED]"`) @@ -39,21 +44,130 @@ func Text(s string) string { return s } +// mnemonicLen is the shortest BIP-39 mnemonic (128-bit entropy). +const mnemonicLen = 12 + +// scrubMnemonic replaces every run of ≥12 consecutive BIP-39 words inside a +// candidate span, keeping surrounding non-mnemonic words intact. +func scrubMnemonic(span string) string { + locs := wordRe.FindAllStringIndex(span, -1) + var b strings.Builder + last, runStart, runLen := 0, 0, 0 + flush := func(end int) { + if runLen >= mnemonicLen { + b.WriteString(span[last:locs[runStart][0]]) + b.WriteString("[REDACTED_MNEMONIC]") + last = locs[end-1][1] + } + } + for i, l := range locs { + if _, ok := bip39.ReverseWordMap[span[l[0]:l[1]]]; ok { + if runLen == 0 { + runStart = i + } + runLen++ + continue + } + flush(i) + runLen = 0 + } + flush(len(locs)) + b.WriteString(span[last:]) + return b.String() +} + +// Redactor layers profile-specific patterns (hostnames, IPs the operator +// flagged) over the stateless Text scrubber. The zero value behaves like Text. +type Redactor struct { + hosts []*regexp.Regexp +} + +// NewRedactor builds a redactor that also masks each literal host/IP. +func NewRedactor(hosts ...string) *Redactor { + r := &Redactor{} + seen := map[string]bool{} + for _, h := range hosts { + h = strings.TrimSpace(h) + if h == "" || seen[h] || isLoopback(h) { + continue + } + seen[h] = true + r.hosts = append(r.hosts, regexp.MustCompile(`(^|[^A-Za-z0-9.-])`+regexp.QuoteMeta(h)+`($|[^A-Za-z0-9-])`)) + } + return r +} + +// isLoopback skips hosts that carry no identifying information. +func isLoopback(h string) bool { + switch h { + case "localhost", "127.0.0.1", "0.0.0.0", "::1": + return true + } + return false +} + +// Text scrubs s with the base rules plus the redactor's hosts. +func (r *Redactor) Text(s string) string { + s = Text(s) + if r == nil { + return s + } + for _, re := range r.hosts { + s = re.ReplaceAllString(s, "${1}[REDACTED_HOST]${2}") + } + return s +} + +// HostOf extracts the host part of an endpoint ("tcp://h:26657", "h:9090", +// "https://h"), or "" when there is none. +func HostOf(endpoint string) string { + e := endpoint + if i := strings.Index(e, "://"); i >= 0 { + e = e[i+3:] + } + if i := strings.IndexAny(e, "/?"); i >= 0 { + e = e[:i] + } + if i := strings.LastIndex(e, "@"); i >= 0 { + e = e[i+1:] + } + if strings.HasPrefix(e, "[") { // [ipv6]:port + if j := strings.Index(e, "]"); j > 0 { + return e[1:j] + } + } + if i := strings.LastIndex(e, ":"); i >= 0 && strings.Count(e, ":") == 1 { + e = e[:i] + } + return e +} + // Args deep-scrubs a map (tool args before logging/LLM echo). -func Args(m map[string]any) map[string]any { +func Args(m map[string]any) map[string]any { return argsWith(m, Text) } + +// List scrubs a heterogeneous slice element-wise. +func List(l []any) []any { return listWith(l, Text) } + +// Args deep-scrubs a map with the redactor's rules. +func (r *Redactor) Args(m map[string]any) map[string]any { return argsWith(m, r.Text) } + +func argsWith(m map[string]any, f func(string) string) map[string]any { + if m == nil { + return nil + } out := make(map[string]any, len(m)) for k, v := range m { switch t := v.(type) { case string: - out[k] = Text(t) + out[k] = f(t) case map[string]any: - out[k] = Args(t) + out[k] = argsWith(t, f) case []any: - out[k] = List(t) + out[k] = listWith(t, f) case []string: l := make([]string, len(t)) for i, s := range t { - l[i] = Text(s) + l[i] = f(s) } out[k] = l default: @@ -63,17 +177,16 @@ func Args(m map[string]any) map[string]any { return out } -// List scrubs a heterogeneous slice element-wise. -func List(l []any) []any { +func listWith(l []any, f func(string) string) []any { out := make([]any, len(l)) for i, v := range l { switch t := v.(type) { case string: - out[i] = Text(t) + out[i] = f(t) case map[string]any: - out[i] = Args(t) + out[i] = argsWith(t, f) case []any: - out[i] = List(t) + out[i] = listWith(t, f) default: out[i] = v } diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go index ca4716f..437e4c8 100644 --- a/internal/redact/redact_test.go +++ b/internal/redact/redact_test.go @@ -130,3 +130,61 @@ func TestSensitiveName(t *testing.T) { t.Fatal("config.toml should not be sensitive") } } + +func TestProseIsNotAMnemonic(t *testing.T) { + q := "why is my validator missing blocks and what should i do about it right now please" + if out := Text(q); out != q { + t.Fatalf("plain question mangled: %q", out) + } +} + +func TestMnemonicInsideProse(t *testing.T) { + m := "legal winner thank year wave sausage worth useful legal winner thank yellow" + out := Text("my seed is " + m + " is that ok") + if strings.Contains(out, "sausage") || !strings.Contains(out, "[REDACTED_MNEMONIC]") { + t.Fatalf("mnemonic not redacted: %q", out) + } + if !strings.HasPrefix(out, "my seed is ") || !strings.HasSuffix(out, " is that ok") { + t.Fatalf("surrounding prose lost: %q", out) + } + // multi-line / double-spaced mnemonics too + if out := Text(strings.ReplaceAll(m, " ", "\n ")); strings.Contains(out, "sausage") { + t.Fatalf("multiline mnemonic leaked: %q", out) + } +} + +func TestRedactorHosts(t *testing.T) { + r := NewRedactor("val.internal", "10.0.4.7", "localhost", "") + in := "ssh ops@val.internal; peer abc@10.0.4.7:26656; rpc localhost:26657; val.internal.example stays? 110.0.4.71 stays" + out := r.Text(in) + for _, leak := range []string{"ops@val.internal;", "@10.0.4.7:"} { + if strings.Contains(out, leak) { + t.Fatalf("host leaked (%s): %q", leak, out) + } + } + if !strings.Contains(out, "localhost:26657") || !strings.Contains(out, "110.0.4.71") { + t.Fatalf("over-redacted: %q", out) + } + if got := r.Args(map[string]any{"h": "val.internal"})["h"]; got != "[REDACTED_HOST]" { + t.Fatalf("args not host-redacted: %v", got) + } + var nilR *Redactor + if nilR.Text("x") != "x" { + t.Fatal("nil redactor must behave like Text") + } +} + +func TestHostOf(t *testing.T) { + for in, want := range map[string]string{ + "tcp://10.1.2.3:26657": "10.1.2.3", + "val.internal:9090": "val.internal", + "https://rpc.example.com/x": "rpc.example.com", + "http://u:p@node.lan:8545": "node.lan", + "[fd00::1]:9090": "fd00::1", + "": "", + } { + if got := HostOf(in); got != want { + t.Errorf("HostOf(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/serve/serve.go b/internal/serve/serve.go new file mode 100644 index 0000000..32caf29 --- /dev/null +++ b/internal/serve/serve.go @@ -0,0 +1,382 @@ +// Package serve exposes one agent session over a loopback-only HTTP API +// with a small embedded web chat. It is a third front-end over the same +// agent.Agent the TUI and REPL drive — same tools, same policy, same +// redaction, same audit trail. +// +// Security model: binds to loopback only; every request must carry the +// per-process token (cookie set by the one-time ?token= link, or a Bearer +// header) and a loopback Host header (defeats DNS rebinding). Mutating +// calls require a JSON content type, which cross-site forms cannot send +// without a CORS preflight that this server never answers. +package serve + +import ( + "context" + "crypto/rand" + "crypto/subtle" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/abhijitkrm/cometcli/internal/agent" + "github.com/abhijitkrm/cometcli/internal/monitor" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +//go:embed web/index.html +var indexHTML []byte + +const cookieName = "cometcli_token" + +// Server is one web chat session. +type Server struct { + Agent *agent.Agent // nil when no provider is configured + AgentErr error + Ctx *toolkit.Context + Reg *toolkit.Registry + Token string + + busy atomic.Bool + mu sync.Mutex // guards sink + cancel + sink chan agent.Event + cancel context.CancelFunc + pending sync.Map // approval id → *agent.Approval + port string +} + +// New wires a server around an agent (which may be nil, with err set). +func New(c *toolkit.Context, reg *toolkit.Registry, a *agent.Agent, agentErr error) *Server { + var b [24]byte + _, _ = rand.Read(b[:]) + s := &Server{Agent: a, AgentErr: agentErr, Ctx: c, Reg: reg, Token: hex.EncodeToString(b[:])} + if a != nil { + a.OnEvent = s.emit + // approvals become modal prompts in the browser + a.Ctx.Approver = agent.EventApprover(s.emit) + } + return s +} + +// emit forwards an agent event to the active SSE stream (dropped if none). +func (s *Server) emit(e agent.Event) { + if e.Approval != nil { + s.pending.Store(e.Approval.ID, e.Approval) + } + s.mu.Lock() + ch := s.sink + s.mu.Unlock() + if ch == nil { + if e.Approval != nil { // nobody to ask — deny rather than hang + e.Approval.Answer(false) + } + return + } + select { + case ch <- e: + case <-time.After(5 * time.Second): // stream gone and buffer full — never wedge the agent + if e.Approval != nil { + e.Approval.Answer(false) + } + } +} + +// Listen validates addr is loopback and returns a listener. +func Listen(addr string) (net.Listener, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + if ip := net.ParseIP(host); host != "localhost" && (ip == nil || !ip.IsLoopback()) { + return nil, fmt.Errorf("refusing to listen on %s — the web chat binds to loopback only (use ssh -L to reach it remotely)", host) + } + return net.Listen("tcp", addr) +} + +// Serve runs the HTTP server on ln until ctx is cancelled. +func (s *Server) Serve(ctx context.Context, ln net.Listener) error { + _, s.port, _ = net.SplitHostPort(ln.Addr().String()) + srv := &http.Server{Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second} + go func() { + <-ctx.Done() + sh, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + srv.Shutdown(sh) + }() + err := srv.Serve(ln) + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +// Handler returns the routed, guarded handler (exported for tests). +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", s.index) + mux.HandleFunc("GET /api/info", s.info) + mux.HandleFunc("GET /api/status", s.status) + mux.HandleFunc("POST /api/chat", s.chat) + mux.HandleFunc("POST /api/approve", s.approve) + mux.HandleFunc("POST /api/cancel", s.cancelTurn) + mux.HandleFunc("POST /api/command", s.command) + return s.guard(mux) +} + +func (s *Server) guard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !loopbackHost(r.Host) { + http.Error(w, "forbidden host", http.StatusForbidden) + return + } + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Frame-Options", "DENY") + // one-time link: ?token=… on the page sets the cookie, then strips it + if r.Method == http.MethodGet && r.URL.Path == "/" && r.URL.Query().Has("token") { + if !s.tokenOK(r.URL.Query().Get("token")) { + http.Error(w, "bad token", http.StatusUnauthorized) + return + } + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: s.Token, Path: "/", + HttpOnly: true, SameSite: http.SameSiteStrictMode}) + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if !s.authed(r) { + http.Error(w, "unauthorized — open the link printed by `cometcli serve`", http.StatusUnauthorized) + return + } + if r.Method == http.MethodPost && !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + http.Error(w, "content-type must be application/json", http.StatusUnsupportedMediaType) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) tokenOK(t string) bool { + return subtle.ConstantTimeCompare([]byte(t), []byte(s.Token)) == 1 +} + +func (s *Server) authed(r *http.Request) bool { + if c, err := r.Cookie(cookieName); err == nil && s.tokenOK(c.Value) { + return true + } + if t, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && s.tokenOK(t) { + return true + } + return false +} + +func loopbackHost(hostport string) bool { + host := hostport + if h, _, err := net.SplitHostPort(hostport); err == nil { + host = h + } + host = strings.Trim(host, "[]") + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) +} + +func (s *Server) index(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:") + w.Write(indexHTML) +} + +func (s *Server) info(w http.ResponseWriter, _ *http.Request) { + p := s.Ctx.Profile + out := map[string]any{ + "profile": p.Name, "chain_id": p.ChainID, "evm_chain_id": p.EVMChainID, + "role": p.Role, "busy": s.busy.Load(), "audit": s.Ctx.Audit.Path(), + } + if s.Agent != nil { + out["provider"] = s.Agent.Provider.Name() + out["model"] = s.Agent.Model + out["mode"] = string(s.Agent.Policy.Mode) + out["autopilot_local"] = s.Agent.Policy.AutoLocal + out["policy"] = s.Agent.Policy.String() + out["session"] = s.Agent.ID() + } else if s.AgentErr != nil { + out["agent_error"] = s.AgentErr.Error() + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) status(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) + defer cancel() + sub := &toolkit.Context{Context: ctx, Profile: s.Ctx.Profile, Cfg: s.Ctx.Cfg, Audit: s.Ctx.Audit} + defer sub.Close() + writeJSON(w, http.StatusOK, monitor.Collect(sub)) +} + +// chat runs one agent turn and streams its events as SSE. One turn at a +// time; closing the stream (tab closed, Stop pressed) cancels the turn. +func (s *Server) chat(w http.ResponseWriter, r *http.Request) { + var req struct { + Text string `json:"text"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil || strings.TrimSpace(req.Text) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text required"}) + return + } + if s.Agent == nil { + msg := "no agent configured — set agent.provider in the profile" + if s.AgentErr != nil { + msg = s.AgentErr.Error() + } + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": msg}) + return + } + if !s.busy.CompareAndSwap(false, true) { + writeJSON(w, http.StatusConflict, map[string]string{"error": "a turn is already running — stop it first"}) + return + } + defer s.busy.Store(false) + fl, ok := w.(http.Flusher) + if !ok { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "streaming unsupported"}) + return + } + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + ch := make(chan agent.Event, 256) + s.mu.Lock() + s.sink, s.cancel = ch, cancel + s.mu.Unlock() + defer func() { + s.mu.Lock() + s.sink, s.cancel = nil, nil + s.mu.Unlock() + // deny anything still waiting so the agent goroutine can't hang + s.pending.Range(func(k, v any) bool { + v.(*agent.Approval).Answer(false) + s.pending.Delete(k) + return true + }) + }() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + send := func(v any) { + b, _ := json.Marshal(v) + fmt.Fprintf(w, "data: %s\n\n", b) + fl.Flush() + } + + type result struct { + text string + err error + } + done := make(chan result, 1) + go func() { + out, err := s.Agent.Run(ctx, req.Text) + done <- result{out, err} + }() + keepalive := time.NewTicker(15 * time.Second) + defer keepalive.Stop() + for { + select { + case e := <-ch: + send(e) + case <-keepalive.C: + fmt.Fprint(w, ": keepalive\n\n") + fl.Flush() + case res := <-done: + for drained := false; !drained; { // flush events emitted just before return + select { + case e := <-ch: + send(e) + default: + drained = true + } + } + if res.err != nil { + msg := res.err.Error() + if errors.Is(res.err, context.Canceled) { + msg = "cancelled" + } + send(map[string]string{"kind": "error", "error": msg}) + } else { + send(map[string]string{"kind": "done"}) + } + return + } + } +} + +func (s *Server) approve(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + OK bool `json:"ok"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + v, ok := s.pending.LoadAndDelete(req.ID) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no pending approval " + req.ID}) + return + } + v.(*agent.Approval).Answer(req.OK) + writeJSON(w, http.StatusOK, map[string]bool{"ok": req.OK}) +} + +func (s *Server) cancelTurn(w http.ResponseWriter, _ *http.Request) { + s.mu.Lock() + c := s.cancel + s.mu.Unlock() + if c != nil { + c() + } + writeJSON(w, http.StatusOK, map[string]bool{"cancelled": c != nil}) +} + +func (s *Server) command(w http.ResponseWriter, r *http.Request) { + var req struct { + Line string `json:"line"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if strings.HasPrefix(strings.TrimSpace(req.Line), "/help") { + writeJSON(w, http.StatusOK, map[string]string{"text": "commands:\n" + agent.CommandHelp + "\n /stop cancel the running turn"}) + return + } + if s.busy.Load() { + writeJSON(w, http.StatusConflict, map[string]string{"error": "a turn is running — wait or stop it before changing session settings"}) + return + } + res, err := agent.RunCommand(s.Agent, s.Ctx, s.Reg, req.Line) + if err != nil { + code := http.StatusBadRequest + if errors.Is(err, agent.ErrUnknownCommand) { + err = fmt.Errorf("unknown command — /help") + } + writeJSON(w, code, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"text": res.Text, "prompt": res.Prompt}) +} diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go new file mode 100644 index 0000000..7ef3e96 --- /dev/null +++ b/internal/serve/serve_test.go @@ -0,0 +1,252 @@ +package serve + +import ( + "bufio" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/abhijitkrm/cometcli/internal/agent" + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +type scripted struct { + resps []*agent.Response + n int +} + +func (s *scripted) Name() string { return "scripted" } +func (s *scripted) Chat(context.Context, *agent.Request) (*agent.Response, error) { + if s.n >= len(s.resps) { + return &agent.Response{Text: "done", Done: true}, nil + } + r := s.resps[s.n] + s.n++ + return r, nil +} + +type unjail struct{ ran *bool } + +func (unjail) Name() string { return "val.unjail" } +func (unjail) Desc() string { return "unjail" } +func (unjail) Tier() toolkit.Tier { return toolkit.TierOnChain } +func (unjail) Schema() map[string]any { return toolkit.ObjSchema(map[string]any{}) } +func (u unjail) Run(c *toolkit.Context, _ toolkit.Args) (*toolkit.Result, error) { + if err := c.Approve("broadcast unjail", toolkit.TierOnChain, map[string]any{"fee": "200uatom"}); err != nil { + return nil, err + } + *u.ran = true + return &toolkit.Result{Text: "unjailed at height 100"}, nil +} + +func newTestServer(t *testing.T, prov agent.Provider, tools ...toolkit.Tool) (*Server, *httptest.Server) { + t.Helper() + t.Setenv("COMETCLI_HOME", t.TempDir()) + reg := toolkit.NewRegistry() + for _, tl := range tools { + reg.Register(tl) + } + c := &toolkit.Context{Context: context.Background(), Profile: &config.Profile{Name: "p", ChainID: "test-1"}} + a := &agent.Agent{Provider: prov, Model: "m", Reg: reg, Ctx: c, MaxIter: 4, + SnapshotFn: func(*toolkit.Context) string { return "" }} + s := New(c, reg, a, nil) + ts := httptest.NewServer(s.Handler()) + t.Cleanup(ts.Close) + return s, ts +} + +func authedReq(t *testing.T, s *Server, method, url, body string) *http.Request { + t.Helper() + req, _ := http.NewRequest(method, url, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+s.Token) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + return req +} + +func TestGuards(t *testing.T) { + s, ts := newTestServer(t, &scripted{}) + cases := []struct { + name string + req func() *http.Request + want int + }{ + {"no token", func() *http.Request { r, _ := http.NewRequest("GET", ts.URL+"/api/info", nil); return r }, 401}, + {"bad token", func() *http.Request { + r, _ := http.NewRequest("GET", ts.URL+"/api/info", nil) + r.Header.Set("Authorization", "Bearer nope") + return r + }, 401}, + {"rebinding host", func() *http.Request { + r := authedReq(t, s, "GET", ts.URL+"/api/info", "") + r.Host = "evil.example:8765" + return r + }, 403}, + {"form post (csrf)", func() *http.Request { + r := authedReq(t, s, "POST", ts.URL+"/api/cancel", "") + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return r + }, 415}, + {"ok", func() *http.Request { return authedReq(t, s, "GET", ts.URL+"/api/info", "") }, 200}, + } + for _, c := range cases { + resp, err := http.DefaultClient.Do(c.req()) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != c.want { + t.Errorf("%s: status %d, want %d", c.name, resp.StatusCode, c.want) + } + } +} + +func TestTokenLinkSetsCookie(t *testing.T) { + s, ts := newTestServer(t, &scripted{}) + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get(ts.URL + "/?token=" + s.Token) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther || len(resp.Cookies()) == 0 || !resp.Cookies()[0].HttpOnly { + t.Fatalf("status=%d cookies=%v", resp.StatusCode, resp.Cookies()) + } + req, _ := http.NewRequest("GET", ts.URL+"/", nil) + req.AddCookie(resp.Cookies()[0]) + page, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer page.Body.Close() + if page.StatusCode != 200 || !strings.Contains(page.Header.Get("Content-Type"), "text/html") { + t.Fatalf("page status=%d", page.StatusCode) + } +} + +func TestListenRefusesNonLoopback(t *testing.T) { + if _, err := Listen("0.0.0.0:0"); err == nil { + t.Fatal("0.0.0.0 must be refused") + } + ln, err := Listen("127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + ln.Close() +} + +// readEvents consumes an SSE body, calling fn per event until it returns false. +func readEvents(t *testing.T, resp *http.Response, fn func(map[string]any) bool) { + t.Helper() + sc := bufio.NewScanner(resp.Body) + for sc.Scan() { + line, ok := strings.CutPrefix(sc.Text(), "data: ") + if !ok { + continue + } + var e map[string]any + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("bad event %q: %v", line, err) + } + if !fn(e) { + return + } + } +} + +func TestChatStreamsAndApprovesOnChain(t *testing.T) { + ran := false + prov := &scripted{resps: []*agent.Response{ + {Text: "unjailing", Calls: []agent.Call{{ID: "c1", Name: "val__unjail", Args: json.RawMessage(`{}`)}}}, + {Text: "you are back in the active set", Done: true}, + }} + s, ts := newTestServer(t, prov, unjail{&ran}) + resp, err := http.DefaultClient.Do(authedReq(t, s, "POST", ts.URL+"/api/chat", `{"text":"unjail me"}`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("chat status %d", resp.StatusCode) + } + + // a second turn while one runs is refused + busy, _ := http.DefaultClient.Do(authedReq(t, s, "POST", ts.URL+"/api/chat", `{"text":"again"}`)) + busy.Body.Close() + if busy.StatusCode != http.StatusConflict { + t.Fatalf("concurrent turn status %d, want 409", busy.StatusCode) + } + + var kinds []string + readEvents(t, resp, func(e map[string]any) bool { + kind := e["kind"].(string) + kinds = append(kinds, kind) + if kind == "approval" { + ap := e["approval"].(map[string]any) + if ap["tier"] != "on-chain" { + t.Errorf("approval tier = %v", ap["tier"]) + } + body := `{"id":"` + ap["id"].(string) + `","ok":true}` + r, err := http.DefaultClient.Do(authedReq(t, s, "POST", ts.URL+"/api/approve", body)) + if err != nil || r.StatusCode != 200 { + t.Fatalf("approve failed: %v %v", err, r.StatusCode) + } + r.Body.Close() + } + return kind != "done" && kind != "error" + }) + if !ran { + t.Fatalf("approved tool did not run; events %v", kinds) + } + want := "text tool_start approval tool_result text done" + if strings.Join(kinds, " ") != want { + t.Fatalf("events = %v, want %s", kinds, want) + } +} + +func TestChatDenyAndDisconnectNeverHang(t *testing.T) { + ran := false + prov := &scripted{resps: []*agent.Response{ + {Calls: []agent.Call{{ID: "c1", Name: "val__unjail", Args: json.RawMessage(`{}`)}}}, + }} + s, ts := newTestServer(t, prov, unjail{&ran}) + resp, err := http.DefaultClient.Do(authedReq(t, s, "POST", ts.URL+"/api/chat", `{"text":"unjail"}`)) + if err != nil { + t.Fatal(err) + } + // drop the stream as soon as the approval shows up + readEvents(t, resp, func(e map[string]any) bool { return e["kind"] != "approval" }) + resp.Body.Close() + deadline := time.Now().Add(5 * time.Second) + for s.busy.Load() { + if time.Now().After(deadline) { + t.Fatal("turn still busy after client disconnect — approval wedged") + } + time.Sleep(20 * time.Millisecond) + } + if ran { + t.Fatal("tool ran without approval") + } +} + +func TestCommandEndpoint(t *testing.T) { + s, ts := newTestServer(t, &scripted{}) + r, _ := http.DefaultClient.Do(authedReq(t, s, "POST", ts.URL+"/api/command", `{"line":"/mode readonly"}`)) + var out map[string]string + json.NewDecoder(r.Body).Decode(&out) + r.Body.Close() + if r.StatusCode != 200 || !strings.Contains(out["text"], "readonly") || !s.Agent.Policy.ReadOnly() { + t.Fatalf("status=%d out=%v", r.StatusCode, out) + } + r, _ = http.DefaultClient.Do(authedReq(t, s, "POST", ts.URL+"/api/command", `{"line":"/approve on-chain on"}`)) + r.Body.Close() + if r.StatusCode != 400 { + t.Fatalf("on-chain autopilot status %d, want 400", r.StatusCode) + } +} diff --git a/internal/serve/web/index.html b/internal/serve/web/index.html new file mode 100644 index 0000000..947df68 --- /dev/null +++ b/internal/serve/web/index.html @@ -0,0 +1,389 @@ + + + + + +cometcli + + + +
+ cometcli + … + … + … + + probing node… +
+
+
+
+ + + +
+
Enter to send · Shift+Enter for a newline · tool calls run against your node with the same approval gates as the CLI
+
+ + +
+
+
+ + +
+
+ + + + diff --git a/internal/toolkit/context.go b/internal/toolkit/context.go index af28440..e1e459d 100644 --- a/internal/toolkit/context.go +++ b/internal/toolkit/context.go @@ -154,6 +154,16 @@ func WithDeadline(c *Context, timeout time.Duration) (*Context, context.CancelFu }, cancel } +// WithCancel is WithDeadline without a timeout — for long-running tools +// that still need their own lazy clients and a cancel hook. +func WithCancel(c *Context) (*Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(c.Context) + return &Context{ + Context: ctx, Profile: c.Profile, Cfg: c.Cfg, Out: c.Out, + Audit: c.Audit, Approver: c.Approver, AutoApproveBelow: c.AutoApproveBelow, + }, cancel +} + // LogShell records a host command in the audit log. func (c *Context) LogShell(cmd string, code int) { if c.Audit != nil { @@ -181,7 +191,7 @@ func (c *Context) Close() { if c.grpc != nil { c.grpc.Close() } - if h, ok := c.host.(io.Closer); ok { + if h, ok := c.host.(io.Closer); ok && c.hostErr == nil { h.Close() } } diff --git a/internal/toolkit/diff.go b/internal/toolkit/diff.go new file mode 100644 index 0000000..321a35b --- /dev/null +++ b/internal/toolkit/diff.go @@ -0,0 +1,88 @@ +package toolkit + +import ( + "fmt" + "strings" +) + +// maxDiffLines bounds the LCS table (n*m) — config files are far smaller. +const maxDiffLines = 4000 + +// Diff renders a compact unified-style line diff of a file edit for +// approval prompts: changed lines with one line of context, hunks +// separated by "…". Returns "" when nothing changed. +func Diff(path, before, after string) string { + if before == after { + return "" + } + a, b := strings.Split(before, "\n"), strings.Split(after, "\n") + if len(a) > maxDiffLines || len(b) > maxDiffLines { + return fmt.Sprintf("--- %s\n(file too large to diff: %d → %d lines)", path, len(a), len(b)) + } + // LCS lengths from the end + lcs := make([][]int, len(a)+1) + for i := range lcs { + lcs[i] = make([]int, len(b)+1) + } + for i := len(a) - 1; i >= 0; i-- { + for j := len(b) - 1; j >= 0; j-- { + if a[i] == b[j] { + lcs[i][j] = lcs[i+1][j+1] + 1 + } else { + lcs[i][j] = max(lcs[i+1][j], lcs[i][j+1]) + } + } + } + type op struct { + kind byte // ' ', '-', '+' + text string + } + var ops []op + i, j := 0, 0 + for i < len(a) && j < len(b) { + switch { + case a[i] == b[j]: + ops = append(ops, op{' ', a[i]}) + i++ + j++ + case lcs[i+1][j] >= lcs[i][j+1]: + ops = append(ops, op{'-', a[i]}) + i++ + default: + ops = append(ops, op{'+', b[j]}) + j++ + } + } + for ; i < len(a); i++ { + ops = append(ops, op{'-', a[i]}) + } + for ; j < len(b); j++ { + ops = append(ops, op{'+', b[j]}) + } + // keep changes plus one line of context + keep := make([]bool, len(ops)) + for k, o := range ops { + if o.kind != ' ' { + for d := -1; d <= 1; d++ { + if k+d >= 0 && k+d < len(ops) { + keep[k+d] = true + } + } + } + } + var sb strings.Builder + fmt.Fprintf(&sb, "--- %s\n", path) + gap := false + for k, o := range ops { + if !keep[k] { + gap = true + continue + } + if gap && sb.Len() > 0 { + sb.WriteString("…\n") + } + gap = false + fmt.Fprintf(&sb, "%c %s\n", o.kind, o.text) + } + return strings.TrimRight(sb.String(), "\n") +} diff --git a/internal/toolkit/diff_test.go b/internal/toolkit/diff_test.go new file mode 100644 index 0000000..fdfb2b8 --- /dev/null +++ b/internal/toolkit/diff_test.go @@ -0,0 +1,23 @@ +package toolkit + +import ( + "strings" + "testing" +) + +func TestDiff(t *testing.T) { + before := "a\nb\npersistent_peers = \"x\"\nc\nd\ne\nf" + after := "a\nb\npersistent_peers = \"x,y\"\nc\nd\ne\nf" + d := Diff("config.toml", before, after) + for _, want := range []string{"--- config.toml", `- persistent_peers = "x"`, `+ persistent_peers = "x,y"`, " b", " c"} { + if !strings.Contains(d, want) { + t.Fatalf("diff missing %q:\n%s", want, d) + } + } + if strings.Contains(d, " f") { + t.Fatalf("diff should trim distant context:\n%s", d) + } + if Diff("x", "same", "same") != "" { + t.Fatal("no-op edit must produce empty diff") + } +} diff --git a/internal/tools/montool/mon.go b/internal/tools/montool/mon.go index a56cfe0..fb53eed 100644 --- a/internal/tools/montool/mon.go +++ b/internal/tools/montool/mon.go @@ -131,7 +131,7 @@ func triageFunc(c *toolkit.Context, reg *toolkit.Registry, enabled bool) func(st fmt.Fprintf(c.Out, "triage unavailable (alerting continues without it): %v\n", err) return nil } - ag.Safe = true // diagnose only — never mutate from an alert + ag.Policy = agent.Policy{Mode: agent.ModeReadOnly} // diagnose only — never mutate from an alert ag.MaxIter = 6 ag.MaxCalls = 8 return func(rule, msg string) string { diff --git a/internal/tools/nettool/net.go b/internal/tools/nettool/net.go index afcd9cb..03f4577 100644 --- a/internal/tools/nettool/net.go +++ b/internal/tools/nettool/net.go @@ -103,12 +103,12 @@ func editPeers(c *toolkit.Context, peer string, add bool) (*toolkit.Result, erro clean = keep } newLine := line[:qi+1] + strings.Join(clean, ",") + line[qj:] + updated := cfg[:idx] + newLine + cfg[idx+lineEnd:] if err := c.Approve(fmt.Sprintf("%s persistent peer %s", verb, peer), toolkit.TierLocalChange, - map[string]any{"peer": peer, "peers_after": clean}); err != nil { + map[string]any{"peer": peer, "peers_after": clean, "diff": toolkit.Diff(path, cfg, updated)}); err != nil { return nil, err } - cfg = cfg[:idx] + newLine + cfg[idx+lineEnd:] - if err := h.WriteFile(c, path, []byte(cfg), 0o644); err != nil { + if err := h.WriteFile(c, path, []byte(updated), 0o644); err != nil { return nil, err } return &toolkit.Result{Text: fmt.Sprintf("%s %s — %d persistent peer(s)", done, peer, len(clean)), diff --git a/internal/tools/sectool/sec.go b/internal/tools/sectool/sec.go index 6538582..1f0a037 100644 --- a/internal/tools/sectool/sec.go +++ b/internal/tools/sectool/sec.go @@ -43,56 +43,142 @@ func (exposureTool) Run(c *toolkit.Context, _ toolkit.Args) (*toolkit.Result, er if err != nil { return nil, err } - out, code, err := h.Run(c, "ss -tlnH 2>/dev/null || netstat -tln 2>/dev/null | tail -n +3") - c.LogShell("ss -tln", code) - if err != nil { - return nil, fmt.Errorf("list sockets: %w", err) + var ls []listener + source := "" + // Containers: the published port map is authoritative — host sockets + // show only the docker proxy, and on macOS live in a VM. + if c.Profile.Service.Type == "docker" && c.Profile.Service.Unit != "" { + cmd := "docker port " + c.Profile.Service.Unit + out, code, err := h.Run(c, cmd) + c.LogShell(cmd, code) + if err == nil { + ls, source = parseDockerPorts(out), "docker port "+c.Profile.Service.Unit + } + } + if source == "" { + cmd := "ss -tlnH 2>/dev/null || netstat -anp tcp 2>/dev/null | grep LISTEN || netstat -tln 2>/dev/null" + out, code, err := h.Run(c, cmd) + c.LogShell("ss -tln | netstat", code) + if err != nil { + return nil, fmt.Errorf("list sockets: %w", err) + } + ls, source = parseSockets(out), "host sockets" + } + if len(ls) == 0 { + // Never report "clean" when nothing could be read. + return nil, fmt.Errorf("could not enumerate listening sockets via %s — exposure unknown, not clean", source) } + var b strings.Builder + fmt.Fprintf(&b, "source: %s\n", source) var findings []map[string]string crits := 0 - for _, line := range strings.Split(out, "\n") { - f := strings.Fields(line) - if len(f) < 4 { - continue - } - // local address column position differs between ss/netstat; find host:port - var laddr string - for _, col := range f { - if strings.Count(col, ":") >= 1 { - laddr = col - break - } - } - if laddr == "" { - continue - } - port := laddr[strings.LastIndex(laddr, ":")+1:] - note, sensitive := sensitivePorts[port] + for _, l := range ls { + note, sensitive := sensitivePorts[l.port] if !sensitive { continue } - hostPart := laddr[:strings.LastIndex(laddr, ":")] - public := hostPart == "*" || hostPart == "0.0.0.0" || hostPart == "::" || - (!strings.HasPrefix(hostPart, "127.") && hostPart != "localhost" && hostPart != "[::1]" && hostPart != "::1") - if port == "26656" { - fmt.Fprintf(&b, "i :%s %-14s p2p reachable (%s)\n", port, laddr, note) + if l.port == "26656" { + fmt.Fprintf(&b, "i :%s %-22s p2p reachable (%s)\n", l.port, l.bind, note) continue } - if public { + if l.public { level := "warn" if c.Profile.IsValidator() { level = "critical" crits++ } - fmt.Fprintf(&b, "✗ :%s %-14s PUBLIC — %s [%s]\n", port, laddr, note, level) - findings = append(findings, map[string]string{"port": port, "bind": laddr, "level": level}) + fmt.Fprintf(&b, "✗ :%s %-22s PUBLIC — %s [%s]\n", l.port, l.bind, note, level) + findings = append(findings, map[string]string{"port": l.port, "bind": l.bind, "level": level}) } else { - fmt.Fprintf(&b, "✓ :%s %-14s localhost\n", port, laddr) + fmt.Fprintf(&b, "✓ :%s %-22s localhost\n", l.port, l.bind) } } fmt.Fprintf(&b, "\n%d critical exposure(s)", crits) - return &toolkit.Result{Text: b.String(), Data: map[string]any{"findings": findings, "critical": crits}}, nil + return &toolkit.Result{Text: b.String(), Data: map[string]any{"findings": findings, "critical": crits, "source": source}}, nil +} + +// listener is one listening socket: port classifies it (the service port — +// the container port for docker), bind is what's shown to the operator. +type listener struct { + port, bind string + public bool +} + +func isLoopbackHost(h string) bool { + h = strings.Trim(h, "[]") + return strings.HasPrefix(h, "127.") || h == "localhost" || h == "::1" +} + +// parseDockerPorts reads `docker port ` lines: "26657/tcp -> 0.0.0.0:26677". +// IPv4/IPv6 duplicates of the same mapping collapse into one entry. +func parseDockerPorts(out string) []listener { + var ls []listener + seen := map[string]bool{} + for _, line := range strings.Split(out, "\n") { + cport, hostAddr, ok := strings.Cut(strings.TrimSpace(line), " -> ") + if !ok { + continue + } + cport, _, _ = strings.Cut(cport, "/") + i := strings.LastIndex(hostAddr, ":") + if i < 0 { + continue + } + host, hport := hostAddr[:i], hostAddr[i+1:] + public := !isLoopbackHost(host) + key := fmt.Sprintf("%s|%s|%v", cport, hport, public) + if seen[key] { + continue + } + seen[key] = true + bind := hostAddr + if hport != cport { + bind += " (container :" + cport + ")" + } + ls = append(ls, listener{port: cport, bind: bind, public: public}) + } + return ls +} + +// parseSockets reads ss / Linux netstat ("0.0.0.0:26657") and BSD/macOS +// netstat ("*.26657", "127.0.0.1.6060") listing lines. +func parseSockets(out string) []listener { + var ls []listener + seen := map[string]bool{} + for _, line := range strings.Split(out, "\n") { + f := strings.Fields(line) + for _, col := range f[min(1, len(f)):] { + host, port, ok := splitListenAddr(col) + if !ok { + continue + } + public := host == "*" || !isLoopbackHost(host) + if key := port + "|" + fmt.Sprint(public); !seen[key] { + seen[key] = true + ls = append(ls, listener{port: port, bind: col, public: public}) + } + break // first address column is the local one + } + } + return ls +} + +func splitListenAddr(col string) (host, port string, ok bool) { + sep := strings.LastIndex(col, ":") + if sep < 0 { + sep = strings.LastIndex(col, ".") // BSD netstat: host.port + } + if sep <= 0 || sep == len(col)-1 { + return "", "", false + } + port = col[sep+1:] + for _, r := range port { + if r < '0' || r > '9' { + return "", "", false + } + } + return col[:sep], port, true } type permsTool struct{} diff --git a/internal/tools/sectool/sec_test.go b/internal/tools/sectool/sec_test.go new file mode 100644 index 0000000..6eb5974 --- /dev/null +++ b/internal/tools/sectool/sec_test.go @@ -0,0 +1,55 @@ +package sectool + +import "testing" + +func byPort(ls []listener) map[string]listener { + m := map[string]listener{} + for _, l := range ls { + m[l.port] = l + } + return m +} + +func TestParseDockerPorts(t *testing.T) { + out := `1317/tcp -> 0.0.0.0:1327 +1317/tcp -> [::]:1327 +6060/tcp -> 127.0.0.1:6070 +26657/tcp -> 0.0.0.0:26667 +26657/tcp -> [::]:26667` + ls := parseDockerPorts(out) + if len(ls) != 3 { + t.Fatalf("want 3 deduped mappings, got %+v", ls) + } + m := byPort(ls) + if !m["26657"].public || !m["1317"].public || m["6060"].public { + t.Fatalf("publicity wrong: %+v", m) + } + if m["26657"].bind != "0.0.0.0:26667 (container :26657)" { + t.Fatalf("bind = %q", m["26657"].bind) + } +} + +func TestParseSockets(t *testing.T) { + cases := map[string]struct { + out string + port string + wantPublic bool + }{ + "ss": {"LISTEN 0 4096 0.0.0.0:26657 0.0.0.0:*", "26657", true}, + "ss local": {"LISTEN 0 4096 127.0.0.1:9090 0.0.0.0:*", "9090", false}, + "ss v6": {"LISTEN 0 4096 [::]:8545 [::]:*", "8545", true}, + "linux net": {"tcp 0 0 0.0.0.0:1317 0.0.0.0:* LISTEN", "1317", true}, + "bsd any": {"tcp46 0 0 *.26657 *.* LISTEN", "26657", true}, + "bsd local": {"tcp4 0 0 127.0.0.1.6060 *.* LISTEN", "6060", false}, + } + for name, c := range cases { + m := byPort(parseSockets(c.out)) + l, ok := m[c.port] + if !ok || l.public != c.wantPublic { + t.Errorf("%s: got %+v (ok=%v), want port %s public=%v", name, l, ok, c.port, c.wantPublic) + } + } + if len(parseSockets("garbage\n\n")) != 0 { + t.Fatal("garbage must yield no listeners") + } +} diff --git a/internal/tools/snaptool/snap.go b/internal/tools/snaptool/snap.go index 893159b..fb8f4ac 100644 --- a/internal/tools/snaptool/snap.go +++ b/internal/tools/snaptool/snap.go @@ -80,10 +80,6 @@ trust_period = "%s" "trust_height": height, "trust_hash": hexHash, "rpc_servers": rpcs, }}, nil } - if err := c.Approve(fmt.Sprintf("write [statesync] into %s/config/config.toml (trust_height=%d)", c.Profile.Home, height), - toolkit.TierLocalChange, map[string]any{"height": height, "hash": hexHash}); err != nil { - return nil, err - } h, err := c.Host() if err != nil { return nil, err @@ -94,6 +90,11 @@ trust_period = "%s" return nil, err } updated := patchStatesync(string(raw), rpcs, height, hexHash, trustPeriod) + if err := c.Approve(fmt.Sprintf("write [statesync] into %s (trust_height=%d)", path, height), + toolkit.TierLocalChange, map[string]any{"height": height, "hash": hexHash, + "diff": toolkit.Diff(path, string(raw), updated)}); err != nil { + return nil, err + } if err := h.WriteFile(c, path, []byte(updated), 0o644); err != nil { return nil, err } diff --git a/internal/tui/app.go b/internal/tui/app.go index 3d51af4..8a8c4cf 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -304,29 +304,17 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.chat.append("info", v.text) } m.syncChatView() - case evText: - m.chat.append("agent", v.s) - m.syncChatView() - return m, m.waitEvent() - case evToolCall: - m.chat.append("call", v.name+" "+compactArgs(v.args)) - m.syncChatView() - return m, m.waitEvent() - case evToolRes: - if v.err != nil { - m.chat.append("err", fmt.Sprintf("%s: %v", v.name, v.err)) - } else { - m.chat.append("ok", v.name+" → "+firstLine(v.summary)) - } - m.syncChatView() + case evAgent: + m.onAgentEvent(v.e) return m, m.waitEvent() case evDone: m.chat.busy = false + m.chat.live, m.chat.liveText = -1, "" if v.err != nil { m.chat.append("err", "agent: "+v.err.Error()) m.syncChatView() } - // text already streamed through OnText/evText + // text already streamed through evAgent return m, m.waitEvent() case tickMsg: return m, tea.Batch(m.collectSnap(), m.collectFleet(), tick(m.interval)) @@ -414,18 +402,29 @@ func (m *AppModel) View() string { // approval modal — blocks the UI until y/n if m.pending != nil { var det strings.Builder - for k, v := range m.pending.detail { - fmt.Fprintf(&det, " %s: %v\n", k, v) + keys := make([]string, 0, len(m.pending.detail)) + for k := range m.pending.detail { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := fmt.Sprint(m.pending.detail[k]) + if k == "diff" { + det.WriteString(renderDiff(v)) + continue + } + if k == "doc" && strings.Contains(m.pending.prompt, v) { + continue // the tx doc is already part of the prompt + } + fmt.Fprintf(&det, " %s: %s\n", k, v) + } + risk := "" + if m.pending.tier == toolkit.TierOnChain { + risk = bad.Render("on-chain — signs and broadcasts a real transaction") + "\n" } - fmt.Fprintf(&b, "\n%s\n%s\n%s\n%s\n", + fmt.Fprintf(&b, "\n%s\n%s%s%s\n", warn.Render(fmt.Sprintf("⚠ [%s] %s", m.pending.tier, m.pending.prompt)), - det.String(), - bad.Render("on-chain"+func() string { - if m.pending.tier == toolkit.TierOnChain { - return " — real funds" - } - return "" - }()), + det.String(), risk, headStyle.Render("approve? [y/n]")) } @@ -440,13 +439,31 @@ func (m *AppModel) View() string { return b.String() } +// renderDiff colors a unified-style diff for the approval modal. +func renderDiff(d string) string { + var b strings.Builder + for _, ln := range strings.Split(strings.TrimRight(d, "\n"), "\n") { + switch { + case strings.HasPrefix(ln, "+"): + b.WriteString(" " + ok.Render(ln) + "\n") + case strings.HasPrefix(ln, "-"): + b.WriteString(" " + bad.Render(ln) + "\n") + default: + b.WriteString(" " + dim.Render(ln) + "\n") + } + } + return b.String() +} + // statusLine is the chat-pane header: provider, mode, live height. func (m *AppModel) statusLine() string { prov := "no agent" if m.chat.agent != nil { prov = fmt.Sprintf("%s/%s", m.chat.agent.Provider.Name(), m.chat.agent.Model) - if m.chat.agent.Safe { - prov += " [safe]" + if pol := m.chat.agent.Policy; pol.ReadOnly() { + prov += " [readonly]" + } else if pol.AutoLocal { + prov += " [autopilot: local-change]" } } live := "offline" diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 94b06b3..bcede29 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "github.com/abhijitkrm/cometcli/internal/agent" "strings" "testing" "time" @@ -128,9 +129,9 @@ func TestApp_ChatPlainTextWithoutAgent(t *testing.T) { func TestApp_ChatAgentEventsAppend(t *testing.T) { m := NewApp(appTestCtx(), testReg(), time.Second) - m2, _ := m.Update(evText{"checking the node"}) - m3, _ := m2.(*AppModel).Update(evToolCall{name: "node.status", args: map[string]any{}}) - m4, _ := m3.(*AppModel).Update(evToolRes{name: "node.status", summary: "h=42"}) + m2, _ := m.Update(evAgent{agent.Event{Kind: agent.EvText, Text: "checking the node"}}) + m3, _ := m2.(*AppModel).Update(evAgent{agent.Event{Kind: agent.EvToolStart, Tool: "node.status", Tier: "observe"}}) + m4, _ := m3.(*AppModel).Update(evAgent{agent.Event{Kind: agent.EvToolResult, Tool: "node.status", Text: "h=42"}}) tr := m4.(*AppModel).chat.transcript() for _, want := range []string{"checking the node", "node.status", "h=42"} { if !strings.Contains(tr, want) { @@ -139,6 +140,21 @@ func TestApp_ChatAgentEventsAppend(t *testing.T) { } } +func TestApp_ChatStreamingDeltasCollapseIntoOneBlock(t *testing.T) { + m := NewApp(appTestCtx(), testReg(), time.Second) + before := len(m.chat.blocks) + for _, d := range []string{"node ", "is ", "healthy"} { + m.Update(evAgent{agent.Event{Kind: agent.EvDelta, Text: d}}) + } + if got := m.chat.blocks[len(m.chat.blocks)-1]; got != "node is healthy" { + t.Fatalf("live block = %q", got) + } + m.Update(evAgent{agent.Event{Kind: agent.EvText, Text: "node is healthy"}}) + if len(m.chat.blocks) != before+1 || m.chat.live != -1 { + t.Fatalf("deltas+final should be one block; blocks=%d live=%d", len(m.chat.blocks)-before, m.chat.live) + } +} + func TestApp_SendFormEditing(t *testing.T) { m := NewApp(appTestCtx(), testReg(), time.Second) m.tab = tabSend diff --git a/internal/tui/chat.go b/internal/tui/chat.go index 9e9dded..1da7e97 100644 --- a/internal/tui/chat.go +++ b/internal/tui/chat.go @@ -3,6 +3,7 @@ package tui import ( "context" "encoding/json" + "errors" "fmt" "strings" @@ -24,22 +25,18 @@ type chatPane struct { blocks []string // rendered transcript blocks busy bool cancel context.CancelFunc - events chan tea.Msg // agent callbacks → Update + events chan tea.Msg // agent events → Update + + // streaming: index of the block being filled by deltas (-1 = none) + live int + liveText string md *glamour.TermRenderer mdW int } // agent events pumped into the bubbletea loop -type evText struct{ s string } -type evToolCall struct { - name string - args map[string]any -} -type evToolRes struct { - name, summary string - err error -} +type evAgent struct{ e agent.Event } type evDone struct { text string err error @@ -67,7 +64,7 @@ func newChatPane(c *toolkit.Context, reg *toolkit.Registry, appr *tuiApprover) * ta.CharLimit = 0 ta.KeyMap.InsertNewline.SetEnabled(false) - p := &chatPane{ta: ta, events: make(chan tea.Msg)} + p := &chatPane{ta: ta, events: make(chan tea.Msg), live: -1} // the agent's context uses the app-level approval bridge — tool prompts // surface as the in-app modal, never stdin (bubbletea owns it raw). sub := &toolkit.Context{ @@ -77,12 +74,11 @@ func newChatPane(c *toolkit.Context, reg *toolkit.Registry, appr *tuiApprover) * } p.blocks = append(p.blocks, dimSt.Render("cometcli — ask anything about your node, drive tools with /run, /help for commands")) if ag, err := agent.New(sub, reg); err != nil { - p.blocks = append(p.blocks, dimSt.Render("agent.provider not configured — /run {json-args} works without one")) + p.blocks = append(p.blocks, dimSt.Render("no agent: "+err.Error()+" — /run {json-args} still works")) } else { - ag.OnText = func(t string) { p.push(evText{t}) } - ag.OnToolCall = func(n string, a map[string]any) { p.push(evToolCall{n, a}) } - ag.OnToolResult = func(n, s string, e error) { p.push(evToolRes{n, s, e}) } + ag.OnEvent = func(e agent.Event) { p.push(evAgent{e}) } p.agent = ag + p.blocks = append(p.blocks, dimSt.Render(fmt.Sprintf("%s/%s · %s · session %s", ag.Provider.Name(), ag.Model, ag.Policy, ag.ID()))) } return p } @@ -135,6 +131,13 @@ func (m *AppModel) chatSubmit(s string) tea.Cmd { } p.append("user", s) m.syncChatView() + return m.startTurn(s) +} + +// startTurn runs one agent turn in the background; events stream back +// through p.events. +func (m *AppModel) startTurn(s string) tea.Cmd { + p := m.chat if p.agent == nil { p.append("info", "no agent provider — /run {json} works, or set agent.provider in the profile") m.syncChatView() @@ -169,44 +172,10 @@ func (m *AppModel) chatSlash(s string) tea.Cmd { m.quitting = true return tea.Quit case "/help": - return add("info", `commands: - /run {"args"} run a tool directly (e.g. /run node.logs {"lines":50}) - /tools list the registry - /safe toggle read-only agent mode - /mode approval posture - /profile active profile - /audit audit log path - /reset clear agent memory + transcript - /exit quit -anything else is sent to the agent — "send 1uatom to cosmos1…", "why is disk high", "unjail" all work`) - case "/tools": - var b strings.Builder - for _, t := range m.reg.All() { - fmt.Fprintf(&b, "%-24s [%s] %s\n", t.Name(), t.Tier(), t.Desc()) - } - return add("info", strings.TrimRight(b.String(), "\n")) - case "/safe": - if p.agent == nil { - return add("err", "no agent configured") - } - p.agent.Safe = !p.agent.Safe - return add("info", fmt.Sprintf("safe mode %v — mutating tools refused", p.agent.Safe)) - case "/mode": - return add("info", "approvals: observe/diagnose auto · local-change prompts · on-chain always prompts (in-app modal)") - case "/profile": - pr := m.c.Profile - return add("info", fmt.Sprintf("%s (%s, chain %s, role %s)", pr.Name, pr.Transport.Type, pr.ChainID, pr.Role)) - case "/audit": - if m.c.Audit != nil { - return add("info", m.c.Audit.Path()) - } - return add("info", "audit disabled") - case "/reset": - p.blocks = nil - if p.agent != nil { - p.agent.Reset() - } - return add("info", "cleared") + return add("info", "commands:\n"+agent.CommandHelp+` + /run {"args"} run a tool directly (e.g. /run node.logs {"lines":50}) + /exit quit +anything else is sent to the agent — "why is disk high", "unjail", "send 1uatom to …" all work`) case "/run": if len(f) < 2 { return add("err", "usage: /run {json-args}") @@ -238,8 +207,58 @@ anything else is sent to the agent — "send 1uatom to cosmos1…", "why is disk return runResMsg{name: name, text: txt} } default: - return add("err", "unknown command: "+f[0]+" — /help") + if p.busy { + return add("info", "a turn is running — esc to cancel it before changing session settings") + } + res, err := agent.RunCommand(p.agent, m.c, m.reg, s) + switch { + case errors.Is(err, agent.ErrUnknownCommand): + return add("err", "unknown command: "+f[0]+" — /help") + case err != nil: + return add("err", err.Error()) + } + if res.Text != "" { + p.append("info", res.Text) + m.syncChatView() + } + if res.Prompt != "" { + p.append("user", res.Prompt) + m.syncChatView() + return m.startTurn(res.Prompt) + } + return nil + } +} + +// onAgentEvent renders one streamed agent event into the transcript. +func (m *AppModel) onAgentEvent(e agent.Event) { + p := m.chat + switch e.Kind { + case agent.EvDelta: + p.liveText += e.Text + if p.live < 0 { + p.blocks = append(p.blocks, "") + p.live = len(p.blocks) - 1 + } + p.blocks[p.live] = p.liveText + case agent.EvText: + final := renderBlock("agent", e.Text, p.mdW, &p.md) + if p.live >= 0 { + p.blocks[p.live] = final + } else { + p.blocks = append(p.blocks, final) + } + p.live, p.liveText = -1, "" + case agent.EvToolStart: + p.append("call", fmt.Sprintf("%s %s %s", e.Tool, dimSt.Render("["+e.Tier+"]"), agent.CompactArgs(e.Args))) + case agent.EvToolResult: + if e.Err != "" { + p.append("err", e.Err) + } else { + p.append("ok", e.Text) + } } + m.syncChatView() } // syncChatView pushes the transcript into the viewport and scrolls down. @@ -279,17 +298,4 @@ func (m *AppModel) chatKey(v tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } -func compactArgs(args map[string]any) string { - if len(args) == 0 { - return "" - } - var parts []string - for k, v := range args { - parts = append(parts, fmt.Sprintf("%s=%v", k, v)) - } - s := strings.Join(parts, " ") - if len(s) > 100 { - return s[:100] + "…" - } - return s -} +func compactArgs(args map[string]any) string { return agent.CompactArgs(args) } diff --git a/internal/tui/preview_test.go b/internal/tui/preview_test.go index 357f1f3..c2ac3ae 100644 --- a/internal/tui/preview_test.go +++ b/internal/tui/preview_test.go @@ -6,6 +6,8 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + + "github.com/abhijitkrm/cometcli/internal/agent" ) // TestChatLayout asserts the chat pane composes status, transcript, input. @@ -15,9 +17,9 @@ func TestChatLayout(t *testing.T) { m = mm.(*AppModel) m.chatSubmit("is my validator healthy?") for _, ev := range []tea.Msg{ - evText{"Checking node status…"}, - evToolCall{name: "node.status", args: map[string]any{}}, - evToolRes{name: "node.status", summary: "h=20496 catching_up=false"}, + evAgent{agent.Event{Kind: agent.EvText, Text: "Checking node status…"}}, + evAgent{agent.Event{Kind: agent.EvToolStart, Tool: "node.status", Tier: "observe"}}, + evAgent{agent.Event{Kind: agent.EvToolResult, Tool: "node.status", Text: "h=20496 catching_up=false"}}, evDone{}, } { mm, _ := m.Update(ev) diff --git a/internal/tx/tx.go b/internal/tx/tx.go index b41d5dc..f6b0c97 100644 --- a/internal/tx/tx.go +++ b/internal/tx/tx.go @@ -5,6 +5,7 @@ package tx import ( "context" + "encoding/base64" "encoding/json" "fmt" "strings" @@ -308,7 +309,8 @@ func (b *Builder) Broadcast(ctx context.Context, txBytes []byte) (hash string, c } r := res.TxResponse if b.audit != nil { - b.audit.Tx(b.profile.Name, "broadcast", map[string]any{"hash": r.Txhash, "code": r.Code, "raw_log": r.RawLog}) + b.audit.Tx(b.profile.Name, "broadcast", map[string]any{"hash": r.Txhash, "code": r.Code, "raw_log": r.RawLog, + "tx_bytes": base64.StdEncoding.EncodeToString(txBytes)}) } return r.Txhash, r.Code, r.RawLog, nil } From fbfc8fdf227787516e0d701cc164002580735b6c Mon Sep 17 00:00:00 2001 From: tundak Date: Sun, 4 Oct 2026 16:14:36 +0530 Subject: [PATCH 2/3] fix: test doc --- .gitignore | 1 + README.md | 1 + TESTING.md | 275 ++++++++++++++++++++++++++++++++ internal/toolkit/diff_test.go | 13 ++ internal/toolkit/toolkit.go | 11 +- internal/tools/registry_test.go | 34 ++++ 6 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 TESTING.md create mode 100644 internal/tools/registry_test.go diff --git a/.gitignore b/.gitignore index eb5dff8..2069acd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /cometcli +/bin/ /dist/ *.test *.out diff --git a/README.md b/README.md index 5bd9603..dabc9a8 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ Key env vars: `COMETCLI_PROFILE`, `COMETCLI_KEYRING_PASSWORD`, - [docs/INSTALL.md](docs/INSTALL.md) — install options, checksum & cosign verification - [docs/COMMANDS.md](docs/COMMANDS.md) — full command reference with examples +- [TESTING.md](TESTING.md) — test locally against docker validators, with a free LLM key (Groq/Ollama) - [PLAN.md](PLAN.md) — architecture and design rationale - [docs/RUNBOOKS.md](docs/RUNBOOKS.md) — builtin playbooks + authoring your own - [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) — endpoints, keyrings, diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..2845f99 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,275 @@ +# Testing cometcli locally + +A step-by-step guide to running cometcli against a local Docker validator +network, the deterministic CLI first and then the AI agent with a free LLM. +Every step is read-only unless it says otherwise. + +--- + +## 0. Prerequisites + +- Go 1.25+ +- A running local network (this guide uses the `primium-evm` docker-compose + setup: containers `primium-validator0` … `primium-validator3`) +- Optional, for the agent: a free LLM key (see [§3](#3-pick-a-free-llm)) + +Check the nodes are up and producing blocks: + +```bash +docker ps --format 'table {{.Names}}\t{{.Status}}' +curl -s localhost:26657/status | grep -o '"latest_block_height":"[0-9]*"' +``` + +## 1. Build + +```bash +git clone && cd cometcli +go build -o ./bin/cometcli ./cmd/cometcli +export PATH="$PWD/bin:$PATH" +cometcli version +``` + +Run the test suite once to make sure your checkout is healthy: + +```bash +go test ./... +``` + +## 2. Connect the validators (no LLM needed) + +### Option A: wizard + +```bash +cometcli init # detects docker containers, port bindings, and mounted homes +``` + +### Option B: one command per node + +Each container maps its ports to different host ports. These match the +`primium-evm` compose file; check yours with `docker port primium-validator0`. + +| profile | container | comet | gRPC | REST | EVM | home (host mount) | +|---|---|---|---|---|---|---| +| primium-val0 | primium-validator0 | 26657 | 9090 | 1317 | 8545 | `~/.primium_1` | +| primium-val1 | primium-validator1 | 26667 | 9100 | 1327 | 8555 | `~/.primium_2` | +| primium-val2 | primium-validator2 | 26677 | 9110 | 1337 | 8565 | `~/.primium_3` | +| primium-val3 | primium-validator3 | 26687 | 9120 | 1347 | 8575 | `~/.primium_4` | + +```bash +for i in 0 1 2 3; do + o=$((i*10)) + cometcli profile add primium-val$i \ + --chain-id primium-1 --evm-chain-id 123457 --bech32-prefix cosmos --fee-denom adex \ + --role validator --binary evmd --home "$HOME/.primium_$((i+1))" \ + --comet tcp://127.0.0.1:$((26657+o)) --grpc 127.0.0.1:$((9090+o)) \ + --lcd http://127.0.0.1:$((1317+o)) --evm http://127.0.0.1:$((8545+o)) \ + --transport local --service docker --unit primium-validator$i +done +cometcli profile use primium-val0 +``` + +To see signing and jail info without an ops key, add each validator's +operator address. You can list them with +`curl -s localhost:1317/cosmos/staking/v1beta1/validators`. + +```bash +cometcli profile set primium-val0 --valoper cosmosvaloper1... +``` + +### Smoke-test the tool layer + +| Command | Expect | +|---|---| +| `cometcli node status` | moniker, height increasing, `syncing: false` | +| `cometcli val status` | `BONDED`, `jailed: false` (needs `--valoper`) | +| `cometcli val signing` | missed blocks / uptime | +| `cometcli node logs --lines 20` | container logs via `docker logs` | +| `cometcli fleet status` | one row per profile, same height | +| `cometcli doctor` | checklist (see note below) | +| `cometcli sec exposure` | port audit from `docker port` | +| `cometcli --profile primium-val2 node peers` | any profile, without switching | + +> **Expected findings on a dev network.** `doctor` and `sec exposure` +> report **6 critical exposures** per validator, because the compose file +> publishes RPC, gRPC, REST, EVM JSON-RPC and metrics on `0.0.0.0`. They may +> also flag `config/` as `0755`. These are correct findings, so leave them +> as they are for a local dev network and fix them before production. + +## 3. Pick a free LLM + +The agent needs a model that supports **tool calling**. Each request sends +the system prompt, a live node snapshot, and about 55 tool schemas, roughly +**6–7K tokens**. A typical question with 2–3 tool calls uses **20–25K +tokens**, which decides which free tier is usable. + +| Option | Cost | Daily budget | Best for | +|---|---|---|---| +| **Groq** + `openai/gpt-oss-120b` | free key | ~200K tokens/day, 30 req/min | ✅ recommended: fast, good tool use | +| Groq + `llama-3.3-70b-versatile` (default) | free key | ~100K tokens/day | ~4–5 questions/day | +| **Ollama** (local) | free, no key | unlimited | offline, private, unlimited; needs RAM | +| OpenRouter `:free` models | free key | ~50 requests/day | trying other models | + +Free-tier limits change; check the provider's console for current numbers. + +### Groq (recommended) + +1. Sign up at → **API Keys** → *Create API Key*. +2. Export it and point the profiles at Groq: + +```bash +export GROQ_API_KEY=gsk_... +for i in 0 1 2 3; do + cometcli profile set primium-val$i --agent-provider groq --agent-model openai/gpt-oss-120b +done +``` + +### Ollama (fully local, no key) + +```bash +brew install ollama && ollama serve & # or the macOS app +ollama pull qwen3:8b # ~5 GB; tool-capable. qwen3:32b if you have 32 GB+ RAM +cometcli profile set primium-val0 --agent-provider ollama --agent-model qwen3:8b +``` + +The default base URL is `http://localhost:11434`. Small models make more +tool-calling mistakes, so expect rougher answers than Groq. + +### OpenRouter + +```bash +export COMETCLI_LLM_API_KEY=sk-or-... # from https://openrouter.ai/keys +cometcli profile set primium-val0 --agent-provider openai-compat \ + --agent-base-url https://openrouter.ai/api \ + --agent-model meta-llama/llama-3.3-70b-instruct:free +``` + +### Save tokens while testing + +- `--mode readonly` hides all mutating tools from the model: fewer + schemas, smaller requests, and the model can't change anything. +- `--budget 4` caps tool calls per question. +- If a provider errors mid-stream, add `--no-stream` (or `no_stream: true` + under `agent:` in the profile). + +Keys are read from the environment: `GROQ_API_KEY`, `ANTHROPIC_API_KEY`, +`OPENAI_API_KEY`, or the catch-all `COMETCLI_LLM_API_KEY`. Keys are never +stored in `config.yaml`. + +## 4. Test the agent + +### One-shot (`ask`) + +```bash +cometcli ask --mode readonly "is my validator healthy? cite height and missed blocks" +cometcli ask --mode readonly --profile primium-val2 "compare peers and signing across the fleet" +cometcli ask --mode readonly "why might the EVM height drift from the comet height?" +``` + +You should see text streaming in, `◐ tool` lines as the agent calls tools, +and `✓` results with real numbers from your nodes. + +### Interactive terminal + +```bash +cometcli # chat TUI on the active profile (same as `cometcli ui`) +``` + +Try these in the chat: + +| Input | What it tests | +|---|---| +| `what's the state of my validator?` | snapshot + tool calls + streaming | +| `/tools` | each tool's tier and how this session treats it | +| `/mode readonly` then `restart the node` | the model is refused; nothing runs | +| `/mode ops` then `add persistent peer abc@1.2.3.4:26656` → press **n** | approval modal with a red/green `config.toml` diff; denying writes nothing | +| `/approve on-chain on` | refused: on-chain can never be autopiloted | +| `/runbook` then `/runbook jail-recovery` | runbook list, then the agent walks it | +| `/model llama-3.1-8b-instant` | switch model mid-conversation | +| `/audit` | audit file + session id | +| `Esc` while it's working | cancels the turn | + +`Tab` switches to the Overview, Fleet, Logs, and Send panes. + +### Web chat + +```bash +cometcli serve --open --mode readonly +``` + +This opens `http://127.0.0.1:8765/?token=…` in your browser: the same agent +with a node status bar, streamed answers, tool cards, and approval dialogs. +It accepts connections from this machine only, and only with the printed +token link. To reach it from another machine: +`ssh -L 8765:127.0.0.1:8765 `. + +### Line REPL (for logs and screen recordings) + +```bash +cometcli agent --mode readonly +``` + +## 5. Audit and replay + +Everything the agent does is appended to `~/.cometcli/audit/.jsonl`. + +```bash +cometcli audit --tail 20 # prompts, llm turns, tool calls, approvals +cometcli audit sessions # session ids + first prompt +cometcli audit replay # reproduces the session offline +``` + +Replay feeds the recorded LLM turns and tool outputs back through the agent +loop. It needs no key and doesn't touch the network, so you can turn off +Wi-Fi to prove it. + +## 6. Safety checks worth doing once + +```bash +# Secrets never reach the LLM: the audit log shows what was sent +cometcli ask --mode readonly "my seed is legal winner thank year wave sausage worth useful legal winner thank yellow, is that ok?" +grep -o 'REDACTED_[A-Z]*' ~/.cometcli/audit/$(date +%F).jsonl | sort | uniq -c + +# Kill switch: agent off, CLI on +COMETCLI_OFFLINE=1 cometcli ask hi # refuses +COMETCLI_OFFLINE=1 cometcli node status # still works +``` + +To also mask your hostnames and IPs, add this under the profile's `agent:` +in `~/.cometcli/config.yaml`: + +```yaml +agent: + redact_hosts: [val.internal, 10.0.4.7] + redact_endpoints: true # also mask this profile's own endpoint/SSH hosts +``` + +## 7. On-chain actions (optional, testnet funds only) + +Profiles start read-only. To let the agent build transactions, for example +`unjail`, `withdraw rewards`, or `vote`, attach an ops key from the **test** +keyring. `keys` commands use the active profile's keyring backend, so set it +first: + +```bash +cometcli profile set primium-val0 --signer ops --signer-backend test +cometcli keys add --name ops # new key; fund it from a dev account +# or: cometcli keys add --name ops --recover # import a funded dev mnemonic +``` + +Every transaction is simulated and shown with its messages, fee, chain-id, +and sequence, and it always needs an explicit approval (`y` in the terminal, +**Sign & broadcast** on the web). `--autopilot` cannot skip it. + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| `agent disabled — set agent.provider` | `cometcli profile set

--agent-provider groq` | +| `HTTP 401` / `invalid api key` | key not exported in this shell (`echo $GROQ_API_KEY`) | +| `HTTP 429` / `rate limit` | free-tier limit hit: wait, use `--mode readonly --budget 4`, or switch model | +| `HTTP 413` / `request too large` | model's per-minute token cap is below one request; use `openai/gpt-oss-120b` or Ollama | +| model replies without calling tools | weak tool-calling model; use gpt-oss-120b, llama-3.3-70b, or qwen3 | +| garbled or missing streamed text | `--no-stream` | +| `no live snapshot — node unreachable` | check the profile's `--comet` port matches `docker port ` | +| `node logs` fails | profile needs `--service docker --unit ` | +| `cometcli` prints help instead of the chat | no active profile (`cometcli profile use …`) or not in a real terminal | diff --git a/internal/toolkit/diff_test.go b/internal/toolkit/diff_test.go index fdfb2b8..06775a5 100644 --- a/internal/toolkit/diff_test.go +++ b/internal/toolkit/diff_test.go @@ -21,3 +21,16 @@ func TestDiff(t *testing.T) { t.Fatal("no-op edit must produce empty diff") } } + +func TestObjSchemaNoNulls(t *testing.T) { + s := ObjSchema(nil) + if _, ok := s["required"]; ok { + t.Fatal("empty required must be omitted, not null") + } + if p, ok := s["properties"].(map[string]any); !ok || p == nil { + t.Fatal("nil properties must become {}") + } + if r := ObjSchema(map[string]any{"a": Str("x")}, "a")["required"].([]string); len(r) != 1 { + t.Fatal("required lost") + } +} diff --git a/internal/toolkit/toolkit.go b/internal/toolkit/toolkit.go index 1fb7298..9eb614d 100644 --- a/internal/toolkit/toolkit.go +++ b/internal/toolkit/toolkit.go @@ -130,8 +130,17 @@ type Tool interface { } // Schema helpers for declaring arg schemas compactly. +// Strict validators (Groq, OpenAI strict mode) reject "properties": null and +// "required": null, so empty values are emitted as {} and omitted. func ObjSchema(props map[string]any, required ...string) map[string]any { - return map[string]any{"type": "object", "properties": props, "required": required} + if props == nil { + props = map[string]any{} + } + s := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + s["required"] = required + } + return s } func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } diff --git a/internal/tools/registry_test.go b/internal/tools/registry_test.go new file mode 100644 index 0000000..9b04bef --- /dev/null +++ b/internal/tools/registry_test.go @@ -0,0 +1,34 @@ +package tools + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// Strict schema validators (Groq, OpenAI strict mode) reject null where the +// JSON-Schema metaschema expects an object or array — one bad tool fails +// every agent request, so check them all. +func TestToolSchemasHaveNoNulls(t *testing.T) { + reg := toolkit.NewRegistry() + RegisterAll(reg) + for _, tl := range reg.All() { + b, err := json.Marshal(tl.Schema()) + if err != nil { + t.Fatalf("%s: schema not serializable: %v", tl.Name(), err) + } + if strings.Contains(string(b), "null") { + t.Errorf("%s: schema contains null: %s", tl.Name(), b) + } + var s map[string]any + json.Unmarshal(b, &s) + if s["type"] != "object" { + t.Errorf("%s: top-level schema type = %v, want object", tl.Name(), s["type"]) + } + if _, ok := s["properties"].(map[string]any); !ok { + t.Errorf("%s: properties must be an object", tl.Name()) + } + } +} From d62d5fef6ed840b03bf729f5ded0d83f88d7ee40 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 15:08:42 +0530 Subject: [PATCH 3/3] fix: keep long-running watchers out of agent turns + errcheck cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mon.watch, mon.alerts, upgrade.watch were still advertised to the model and run under WithCancel (no deadline) — a call would hang the turn. Now filtered from toolDefs AND refused in execCall so even a scripted provider cannot block a turn; regression tests cover both layers. Also: audit.Log/Path/Close nil-sink guards on zero-value Logger, and explicit errcheck discards in serve.go (Shutdown/Encode/Write). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/agent/loop.go | 6 ++++ internal/agent/loop_test.go | 69 +++++++++++++++++++++++++++++++++++++ internal/audit/audit.go | 6 ++-- internal/serve/serve.go | 6 ++-- 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 460127b..85e9f8a 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -150,6 +150,9 @@ func (a *Agent) profileName() string { func (a *Agent) toolDefs() []ToolDef { var out []ToolDef for _, t := range a.Reg.All() { + if toolkit.IsLongRunning(t) { + continue // watchers never return inside a turn + } if !a.Policy.Allows(t.Tier()) { continue } @@ -248,6 +251,9 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { } shownArgs := a.Redact.Args(args) a.emit(Event{Kind: EvToolStart, Tool: name, Tier: t.Tier().String(), Args: shownArgs}) + if toolkit.IsLongRunning(t) { + return a.toolErr(call, name, "refused: "+name+" is a long-running watcher and cannot finish inside an agent turn — suggest `cometcli "+strings.ReplaceAll(name, ".", " ")+"` to the operator") + } if !a.Policy.Allows(t.Tier()) { msg := fmt.Sprintf("blocked: %s is a %s tool and the session is in readonly mode — recommend it to the operator instead", name, t.Tier()) a.Audit().ToolSeen(a.profileName(), name, t.Tier().String(), shownArgs, nil, fmt.Errorf("%s", msg), "") diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index 63e02e0..d28ed75 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -6,6 +6,7 @@ import ( "os" "strings" "testing" + "time" "github.com/abhijitkrm/cometcli/internal/audit" "github.com/abhijitkrm/cometcli/internal/config" @@ -215,3 +216,71 @@ func TestLoop_AuditTrail(t *testing.T) { } } } + +// watcherTool never returns until ctx is cancelled — like mon.watch. +type watcherTool struct{ ran *bool } + +func (w watcherTool) Name() string { return "mon.watch" } +func (w watcherTool) Desc() string { return "watch forever" } +func (w watcherTool) Schema() map[string]any { return toolkit.ObjSchema(map[string]any{}) } +func (w watcherTool) Tier() toolkit.Tier { return toolkit.TierObserve } +func (w watcherTool) LongRunning() bool { return true } +func (w watcherTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + *w.ran = true + <-c.Done() // never returns on its own + return nil, c.Err() +} + +func TestLoop_LongRunningNotAdvertised(t *testing.T) { + ran := false + a := newTestAgent(t, &mockProvider{}, watcherTool{ran: &ran}, + stubTool{name: "node.status", tier: toolkit.TierObserve, run: func(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + return &toolkit.Result{Text: "ok"}, nil + }}) + defs := a.toolDefs() + for _, d := range defs { + if d.Name == "mon__watch" { + t.Fatalf("long-running tool advertised: %+v", d) + } + } + found := false + for _, d := range defs { + if d.Name == "node__status" { + found = true + } + } + if !found { + t.Fatal("normal tool missing from advertised set") + } +} + +func TestLoop_LongRunningCallRefused(t *testing.T) { + // A provider can still name the tool even though it wasn't advertised — + // the loop must refuse it instead of blocking on the watcher. + ran := false + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "c1", Name: "mon__watch", Args: json.RawMessage(`{}`)}}}, + {Text: "declined", Done: true}, + }} + a := newTestAgent(t, prov, watcherTool{ran: &ran}) + done := make(chan error, 1) + go func() { + _, err := a.Run(context.Background(), "watch the node") + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(10 * time.Second): + t.Fatal("turn hung on a long-running tool") + } + if ran { + t.Fatal("watcher body executed — must be refused before Run") + } + toolMsg := a.history[2] + if toolMsg.Role != "tool" || !strings.Contains(toolMsg.Text, "long-running") { + t.Fatalf("expected refusal in tool result, got %q", toolMsg.Text) + } +} diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 8c29edb..4358e46 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -88,7 +88,7 @@ func (l *Logger) Session() string { // Log records one event. Failures are intentionally non-fatal but reported. func (l *Logger) Log(kind Kind, profile string, detail map[string]any) error { - if l == nil { + if l == nil || l.sink == nil { return nil } l.sink.mu.Lock() @@ -98,7 +98,7 @@ func (l *Logger) Log(kind Kind, profile string, detail map[string]any) error { // Path returns the current audit file path. func (l *Logger) Path() string { - if l == nil { + if l == nil || l.sink == nil { return "" } return l.sink.path @@ -106,7 +106,7 @@ func (l *Logger) Path() string { // Close flushes and closes the file (shared by every WithSession child). func (l *Logger) Close() error { - if l == nil || l.sink.file == nil { + if l == nil || l.sink == nil || l.sink.file == nil { return nil } return l.sink.file.Close() diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 32caf29..2cf1433 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -108,7 +108,7 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { <-ctx.Done() sh, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - srv.Shutdown(sh) + _ = srv.Shutdown(sh) }() err := srv.Serve(ln) if errors.Is(err, http.ErrServerClosed) { @@ -192,13 +192,13 @@ func loopbackHost(hostport string) bool { func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) - json.NewEncoder(w).Encode(v) + _ = json.NewEncoder(w).Encode(v) } func (s *Server) index(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:") - w.Write(indexHTML) + _, _ = w.Write(indexHTML) } func (s *Server) info(w http.ResponseWriter, _ *http.Request) {