From 2010d35aa620362fea82ca54cda78aa1dd83d87b Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 16:32:05 +0530 Subject: [PATCH 01/48] agent: harden the core loop across all providers (phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retries: 408/409/429/5xx/529 and connection errors with jittered backoff honoring Retry-After; announced as notices, timeouts not retried; --debug logs every API attempt - normalized stop reasons: auto-continue after max_tokens, refusals surface as errors - frozen per-session system prompt; later node snapshots ride on user turns so provider prompt caches stay warm - token usage per provider (incl. cache reads), /cost - compaction: automatic at a turn boundary near the context limit, on context-overflow errors, and /compact [focus] - effort (--effort, /effort, agent.effort) mapped to Anthropic output_config.effort, OpenAI/Groq reasoning_effort, Gemini thinking_level; streamed reasoning shown as dimmed "∴" lines - saved sessions in ~/.cometcli/sessions: -c/--continue, -r/--resume, cometcli sessions, /sessions, /resume - Anthropic: verbatim replay of thinking blocks (dropped when the tool set changes), top-level prompt caching, eager tool input streaming, server-side refusal fallback, default model claude-opus-5-5 - head+tail, UTF-8-safe tool output truncation; max turns 16 -> 50 Co-Authored-By: Claude Opus 5.5 --- docs/COMMANDS.md | 29 +- internal/agent/anthropic.go | 330 +++++++++++++++----- internal/agent/commands.go | 97 +++++- internal/agent/compact.go | 143 +++++++++ internal/agent/events.go | 6 + internal/agent/gemini.go | 120 ++++++-- internal/agent/loop.go | 287 +++++++++++++++-- internal/agent/openai.go | 117 ++++++- internal/agent/phase1_test.go | 561 ++++++++++++++++++++++++++++++++++ internal/agent/provider.go | 54 +++- internal/agent/repl.go | 26 +- internal/agent/retry.go | 172 +++++++++++ internal/agent/session.go | 206 +++++++++++++ internal/cli/agent.go | 13 +- internal/cli/agent_impl.go | 63 +++- internal/cli/root.go | 10 +- internal/cli/serve.go | 2 +- internal/cli/sessions.go | 48 +++ internal/cli/ui.go | 6 +- internal/config/config.go | 12 + internal/serve/serve.go | 8 + internal/tui/app.go | 28 +- internal/tui/chat.go | 52 +++- 23 files changed, 2216 insertions(+), 174 deletions(-) create mode 100644 internal/agent/compact.go create mode 100644 internal/agent/phase1_test.go create mode 100644 internal/agent/retry.go create mode 100644 internal/agent/session.go create mode 100644 internal/cli/sessions.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 06d0cdf..cea1ae7 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -201,12 +201,18 @@ cometcli audit # today's audit log (prompts, LLM turns, 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 sessions [--all] # saved agent conversations, newest first 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`. +`--autopilot local-change`, `--budget N` tool calls per turn, `--max-iter N` (default 50), `--no-stream`. + +Session flags (bare `cometcli`, `ui`, `agent`, `ask`): `-c/--continue` resumes the latest +session started in this directory, `-r/--resume ` a specific one (an id prefix works), +`--model`, `--effort low|medium|high|xhigh|max`, `--max-tokens N`. Every conversation is +saved after each turn to `~/.cometcli/sessions/` (0600, already redacted). ### Agent session commands (TUI, REPL, web) @@ -218,20 +224,39 @@ Agent flags (`agent`, `ask`, `serve`): `--mode ops|readonly`, `--safe` (=readonl | `/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 | +| `/compact [focus]` | summarize the conversation to free context (also automatic near the limit) | +| `/cost` | session token usage, cache hits, and current context size | +| `/effort [level\|default]` | show or set reasoning depth for the rest of the session | +| `/sessions`, `/resume ` | list saved sessions, or load one into the current session | ### Agent config (per profile) ```yaml agent: provider: anthropic # anthropic | openai | groq | gemini | openai-compat/ollama | off - model: claude-sonnet-4-5 + model: claude-opus-5-5 # default per provider when omitted 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 + effort: medium # reasoning depth; mapped per provider (see below) + max_tokens: 0 # output cap per round; 0 = provider default + max_turns: 50 # model rounds per prompt + context_window: 0 # override the model's context size (tokens) + compact_at: 0 # prompt size that triggers auto-compaction; 0 = 80% of window, ≤200k ``` +`effort` maps to each provider's own control: Anthropic `output_config.effort`, OpenAI +and Groq `reasoning_effort` (`xhigh`/`max` → `high`; reasoning models only), Gemini +`generation_config.thinking_level`. Leave it unset for models without a reasoning knob. + +Long sessions stay healthy on every provider: transient API failures (429, 5xx, +overloaded) are retried with backoff honoring `Retry-After`; a reply cut off at the +output limit is continued automatically; the system prompt is frozen per session (later +node snapshots ride on your messages) so provider prompt caches stay warm; and when +the prompt nears `compact_at`, the conversation is summarized at the next turn boundary. + `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 diff --git a/internal/agent/anthropic.go b/internal/agent/anthropic.go index 1c1a2bc..af5ea75 100644 --- a/internal/agent/anthropic.go +++ b/internal/agent/anthropic.go @@ -1,11 +1,11 @@ package agent import ( - "bytes" "context" "encoding/json" "fmt" "net/http" + "regexp" "sort" "strings" "time" @@ -21,120 +21,226 @@ func (a *anthropic) Name() string { return "anthropic" } func (a *anthropic) client() *http.Client { if a.hc == nil { - a.hc = &http.Client{Timeout: 300 * time.Second} + a.hc = &http.Client{Timeout: 10 * time.Minute} } return a.hc } type anthBlock map[string]any -func (a *anthropic) body(r *Request) map[string]any { +// Models that take adaptive thinking (4.6+ Opus/Sonnet, the 5 family, +// Fable/Mythos). Older ones (Haiku 4.5, Sonnet 4.5, …) reject it. +var anthAdaptiveRe = regexp.MustCompile(`^claude-(fable|mythos)-|^claude-(opus|sonnet)-(4-[6-9]|5)`) + +// Models that accept the server-side refusal fallback ("default" routing). +var anthFallbackRe = regexp.MustCompile(`^claude-(fable-5-1|opus-5|sonnet-5-5)`) + +const anthFallbackBeta = "server-side-fallback-2026-07-01" + +func (a *anthropic) firstParty() bool { + return strings.Contains(a.base, "api.anthropic.com") +} + +func (a *anthropic) body(r *Request, stream bool) map[string]any { + maxTok := r.MaxTok + if maxTok == 0 { + // streaming has no HTTP-timeout concern, so give room for thinking + maxTok = 16000 + if stream { + maxTok = 64000 + } + } body := map[string]any{ "model": r.Model, - "max_tokens": r.MaxTok, - "system": r.System, + "max_tokens": maxTok, "messages": a.convMessages(r.Messages), + // auto-places a cache breakpoint on the last cacheable block, so + // the frozen system prompt + tools + history prefix is reused + "cache_control": map[string]any{"type": "ephemeral"}, + } + if r.System != "" { + body["system"] = []anthBlock{{"type": "text", "text": r.System}} } if len(r.Tools) > 0 { var tools []map[string]any for _, t := range r.Tools { - tools = append(tools, map[string]any{ + td := map[string]any{ "name": t.Name, "description": t.Desc, "input_schema": t.Schema, - }) + } + if stream { + td["eager_input_streaming"] = true + } + tools = append(tools, td) } body["tools"] = tools } + if anthAdaptiveRe.MatchString(r.Model) { + body["thinking"] = map[string]any{"type": "adaptive", "display": "summarized"} + } + if r.Effort != "" { + body["output_config"] = map[string]any{"effort": r.Effort} + } + if a.firstParty() && anthFallbackRe.MatchString(r.Model) { + body["fallbacks"] = "default" + } 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", strings.TrimSuffix(a.base, "/")+"/v1/messages", bytes.NewReader(raw)) + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + hdr := map[string]string{ + "x-api-key": a.key, + "anthropic-version": "2023-06-01", + } + if _, ok := body["fallbacks"]; ok { + hdr["anthropic-beta"] = anthFallbackBeta + } + resp, err := doHTTP(ctx, a.client(), strings.TrimSuffix(a.base, "/")+"/v1/messages", raw, hdr) 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") - return a.client().Do(req) + if resp.StatusCode/100 != 2 { + defer resp.Body.Close() + return nil, apiError("anthropic", resp) + } + return resp, nil +} + +// anthUsage is the Messages API usage object; input_tokens excludes the +// cached parts, so total prompt size is the sum of all three. +type anthUsage struct { + Input int `json:"input_tokens"` + CacheWrite int `json:"cache_creation_input_tokens"` + CacheRead int `json:"cache_read_input_tokens"` + Output int `json:"output_tokens"` +} + +func (u anthUsage) usage() Usage { + return Usage{Input: u.Input + u.CacheWrite + u.CacheRead, Output: u.Output, CacheRead: u.CacheRead} +} + +type anthStopDetails struct { + Category string `json:"category"` + Explanation string `json:"explanation"` +} + +func anthStop(reason string, d *anthStopDetails) (StopReason, string) { + switch reason { + case "end_turn", "stop_sequence": + return StopEnd, "" + case "tool_use": + return StopToolUse, "" + case "max_tokens", "model_context_window_exceeded": + return StopMaxTokens, "" + case "refusal": + detail := "" + if d != nil { + detail = strings.TrimSpace(d.Category + " " + d.Explanation) + } + return StopRefusal, detail + } + return "", "" +} + +// anthResponse builds a Response from the final content blocks. The raw +// blocks (thinking signatures included) are kept for verbatim replay. +func anthResponse(blocks []json.RawMessage, stop string, sd *anthStopDetails, u anthUsage) *Response { + res := &Response{Usage: u.usage(), RawSteps: mustJSON(blocks)} + res.Stop, res.StopDetail = anthStop(stop, sd) + var text, thinking strings.Builder + for _, raw := range blocks { + var b struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` + } + if json.Unmarshal(raw, &b) != nil { + continue + } + switch b.Type { + case "text": + text.WriteString(b.Text) + case "thinking": + thinking.WriteString(b.Thinking) + case "tool_use": + args := b.Input + if len(args) == 0 { + args = json.RawMessage(`{}`) + } + res.Calls = append(res.Calls, Call{ID: b.ID, Name: b.Name, Args: args}) + } + } + res.Text, res.Thinking = text.String(), thinking.String() + res.Done = len(res.Calls) == 0 + return res } func (a *anthropic) Chat(ctx context.Context, r *Request) (*Response, error) { - resp, err := a.post(ctx, a.body(r)) + resp, err := a.post(ctx, a.body(r, false)) if err != nil { return nil, err } defer resp.Body.Close() var out struct { - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - ID string `json:"id"` - Name string `json:"name"` - Input json.RawMessage `json:"input"` - } `json:"content"` - StopReason string `json:"stop_reason"` - Error *struct { - Message string `json:"message"` - } `json:"error"` + Content []json.RawMessage `json:"content"` + StopReason string `json:"stop_reason"` + StopDetails *anthStopDetails `json:"stop_details"` + Usage anthUsage `json:"usage"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err } - if out.Error != nil { - return nil, fmt.Errorf("anthropic: %s", out.Error.Message) - } - res := &Response{Done: out.StopReason == "end_turn"} - for _, b := range out.Content { - switch b.Type { - case "text": - res.Text += b.Text - case "tool_use": - res.Calls = append(res.Calls, Call{ID: b.ID, Name: b.Name, Args: b.Input}) - } - } - return res, nil + return anthResponse(out.Content, out.StopReason, out.StopDetails, out.Usage), 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. +// Stream consumes the Messages API SSE stream. Each content block is +// rebuilt from its start event plus deltas — text, thinking (with its +// signature), redacted thinking, and tool_use input (partial JSON) — so +// the assistant turn can be replayed exactly as the model produced it. func (a *anthropic) Stream(ctx context.Context, r *Request, onText func(string)) (*Response, error) { - body := a.body(r) + body := a.body(r, true) 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 + obj map[string]any + text strings.Builder // text or thinking + sig strings.Builder + input strings.Builder } blocks := map[int]*block{} - var text strings.Builder - stop := "" + var usage anthUsage + var stop string + var sd *anthStopDetails 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"` + Type string `json:"type"` + Index int `json:"index"` + ContentBlock map[string]any `json:"content_block"` + Message struct { + Usage anthUsage `json:"usage"` + } `json:"message"` Delta struct { - Type string `json:"type"` - Text string `json:"text"` - PartialJSON string `json:"partial_json"` - StopReason string `json:"stop_reason"` + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + Signature string `json:"signature"` + PartialJSON string `json:"partial_json"` + StopReason string `json:"stop_reason"` + StopDetails *anthStopDetails `json:"stop_details"` } `json:"delta"` + Usage *anthUsage `json:"usage"` Error *struct { Message string `json:"message"` } `json:"error"` @@ -143,26 +249,44 @@ func (a *anthropic) Stream(ctx context.Context, r *Request, onText func(string)) return nil // tolerate keepalives / unknown payloads } switch ev.Type { + case "message_start": + usage = ev.Message.Usage 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) + b := &block{obj: ev.ContentBlock} + if t, _ := b.obj["text"].(string); t != "" { + b.text.WriteString(t) + onText(t) } + blocks[ev.Index] = b case "content_block_delta": + b := blocks[ev.Index] + if b == nil { + return nil + } switch ev.Delta.Type { case "text_delta": - text.WriteString(ev.Delta.Text) + b.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 "thinking_delta": + b.text.WriteString(ev.Delta.Thinking) + if r.OnThinking != nil { + r.OnThinking(ev.Delta.Thinking) } + case "signature_delta": + b.sig.WriteString(ev.Delta.Signature) + case "input_json_delta": + b.input.WriteString(ev.Delta.PartialJSON) } case "message_delta": if ev.Delta.StopReason != "" { stop = ev.Delta.StopReason } + if ev.Delta.StopDetails != nil { + sd = ev.Delta.StopDetails + } + if ev.Usage != nil { + usage.Output = ev.Usage.Output + } case "error": if ev.Error != nil { return fmt.Errorf("anthropic: %s", ev.Error.Message) @@ -174,33 +298,79 @@ func (a *anthropic) Stream(ctx context.Context, r *Request, onText func(string)) 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) + var out []json.RawMessage + bad := map[string]string{} // tool_use id → unparseable input for _, i := range idx { b := blocks[i] - if b.typ != "tool_use" { - continue + switch b.obj["type"] { + case "text": + b.obj["text"] = b.text.String() + case "thinking": + b.obj["thinking"] = b.text.String() + if b.sig.Len() > 0 { + b.obj["signature"] = b.sig.String() + } + case "tool_use": + // eager input streaming means the server no longer validates + // the JSON (and max_tokens can cut it off): replay an empty + // object, but hand the raw text to the loop so the call fails + // as "bad args" and the model re-issues it + in := strings.TrimSpace(b.input.String()) + if in == "" { + in = "{}" + } + if json.Valid([]byte(in)) { + b.obj["input"] = json.RawMessage(in) + } else { + b.obj["input"] = map[string]any{} + id, _ := b.obj["id"].(string) + bad[id] = in + } } - args := b.input.String() - if strings.TrimSpace(args) == "" { - args = "{}" + out = append(out, mustJSON(b.obj)) + } + res := anthResponse(out, stop, sd, usage) + for j, c := range res.Calls { + if in, ok := bad[c.ID]; ok { + res.Calls[j].Args = json.RawMessage(in) } - res.Calls = append(res.Calls, Call{ID: b.id, Name: b.name, Args: json.RawMessage(args)}) } return res, nil } +// convMessages maps neutral history to Messages API turns. Assistant +// turns produced by this provider replay their raw blocks verbatim +// (thinking blocks must come back unchanged); consecutive tool results +// are merged into one user message, as parallel tool use expects. func (a *anthropic) convMessages(in []Msg) []map[string]any { var out []map[string]any + var results []anthBlock + flush := func() { + if len(results) > 0 { + out = append(out, map[string]any{"role": "user", "content": results}) + results = nil + } + } for _, m := range in { + if m.Role != "tool" { + flush() + } switch m.Role { case "user": out = append(out, map[string]any{"role": "user", "content": []anthBlock{{"type": "text", "text": m.Text}}}) case "assistant": + if m.RawProvider == "anthropic" && len(m.RawSteps) > 0 { + var blocks []json.RawMessage + if json.Unmarshal(m.RawSteps, &blocks) == nil && len(blocks) > 0 { + out = append(out, map[string]any{"role": "assistant", "content": blocks}) + continue + } + } var blocks []anthBlock if m.Text != "" { blocks = append(blocks, anthBlock{"type": "text", "text": m.Text}) @@ -212,13 +382,17 @@ func (a *anthropic) convMessages(in []Msg) []map[string]any { } blocks = append(blocks, anthBlock{"type": "tool_use", "id": cl.ID, "name": cl.Name, "input": input}) } + if len(blocks) == 0 { + blocks = append(blocks, anthBlock{"type": "text", "text": "(no output)"}) + } out = append(out, map[string]any{"role": "assistant", "content": blocks}) case "tool": - out = append(out, map[string]any{"role": "user", "content": []anthBlock{{ + results = append(results, anthBlock{ "type": "tool_result", "tool_use_id": m.CallID, "content": m.Text, "is_error": m.IsError, - }}}) + }) } } + flush() return out } diff --git a/internal/agent/commands.go b/internal/agent/commands.go index c524d2e..151fd78 100644 --- a/internal/agent/commands.go +++ b/internal/agent/commands.go @@ -1,6 +1,7 @@ package agent import ( + "context" "errors" "fmt" "strings" @@ -21,6 +22,9 @@ type CmdResult struct { // /runbook so the run goes through the normal approval gate and // streams like any other request. Prompt string + // Job, when set, is slow work (a model call) the front-end runs off + // its UI loop; its returned text is shown like Text. + Job func(context.Context) (string, error) } // CommandHelp lists the shared slash commands; front-ends append their own. @@ -31,7 +35,12 @@ const CommandHelp = ` /mode [ops|readonly] show or set the approval po /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` + /reset clear conversation, start a new audit session + /compact [focus] summarize the conversation to free context + /cost token usage for this session + /effort [low|medium|high|xhigh|max|default] show or set reasoning depth + /sessions list saved sessions + /resume load a saved session into this one` // RunCommand executes a shared slash command. a may be nil when no LLM // provider is configured — commands that need the agent say so. @@ -164,6 +173,81 @@ func RunCommand(a *Agent, c *toolkit.Context, reg *toolkit.Registry, line string 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 "/compact": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + focus := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "/compact")) + return CmdResult{Text: "compacting…", Job: func(ctx context.Context) (string, error) { + if err := a.Compact(ctx, focus); err != nil { + return "", err + } + return "compacted — the summary is attached to your next message", nil + }}, nil + + case "/cost", "/usage": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + u, last := a.Usage() + txt := fmt.Sprintf("input %s (cached %s) · output %s", humanTok(u.Input), humanTok(u.CacheRead), humanTok(u.Output)) + if last > 0 { + txt += fmt.Sprintf("\ncontext %s of %s — auto-compacts at %s", humanTok(last), humanTok(a.contextWindow()), humanTok(a.compactThreshold())) + } + return CmdResult{Text: txt}, nil + + case "/effort": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + if len(f) > 1 { + e := strings.ToLower(f[1]) + if e == "default" || e == "auto" { + e = "" + } + if err := ValidEffort(e); err != nil { + return CmdResult{}, err + } + a.Effort = e + } + if a.Effort == "" { + return CmdResult{Text: "effort: model default"}, nil + } + return CmdResult{Text: "effort: " + a.Effort}, nil + + case "/sessions": + all, err := ListSessions() + if err != nil { + return CmdResult{}, err + } + if len(all) == 0 { + return CmdResult{Text: "no saved sessions"}, nil + } + var b strings.Builder + for i, s := range all { + if i == 20 { + fmt.Fprintf(&b, "… %d more\n", len(all)-20) + break + } + fmt.Fprintf(&b, "%s %s %-10s %s\n", s.ID, s.Updated.Local().Format("Jan 02 15:04"), s.Profile, s.Title) + } + b.WriteString("resume with /resume (a prefix is enough)") + return CmdResult{Text: b.String()}, nil + + case "/resume": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + if len(f) < 2 { + return RunCommand(a, c, reg, "/sessions") + } + sf, err := LoadSession(f[1]) + if err != nil { + return CmdResult{}, err + } + a.Restore(sf) + return CmdResult{Text: fmt.Sprintf("resumed %s — %s (%d messages)", sf.ID, sf.Title, len(sf.History))}, nil + case "/runbook", "/runbooks": if len(f) == 1 { var b strings.Builder @@ -193,3 +277,14 @@ func RunCommand(a *Agent, c *toolkit.Context, reg *toolkit.Registry, line string } return CmdResult{}, ErrUnknownCommand } + +// humanTok renders a token count compactly: 950, 12.3k, 1.2M. +func humanTok(n int) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1e6) + case n >= 1000: + return fmt.Sprintf("%.1fk", float64(n)/1e3) + } + return fmt.Sprint(n) +} diff --git a/internal/agent/compact.go b/internal/agent/compact.go new file mode 100644 index 0000000..6da6f6d --- /dev/null +++ b/internal/agent/compact.go @@ -0,0 +1,143 @@ +package agent + +import ( + "context" + "fmt" + "strings" +) + +// compactPrompt asks the model to summarize the session so far. The +// summary replaces the whole history ("simple compaction"): nothing from +// before it is replayed, so no provider-bound state (thinking blocks, +// thought signatures) survives to be invalidated. +const compactPrompt = `Summarize this conversation so it can continue from the summary alone — earlier messages and tool output will be discarded. + +Do not call any tools. Reply with the summary only, using these sections: +1. Goal — what the operator asked for, in their words where it matters. +2. Findings — facts established, with exact values (heights, versions, hosts, paths, error strings, tx hashes). +3. Actions taken — commands and tools run, changes made, and their results. +4. Open items — what is still pending, blocked, or awaiting approval. +5. Next step — what you were about to do.` + +// contextWindow returns the model's context size in tokens. +func (a *Agent) contextWindow() int { + if a.conf.ContextWindow > 0 { + return a.conf.ContextWindow + } + m := strings.ToLower(a.Model) + switch a.Provider.Name() { + case "anthropic": + if strings.Contains(m, "haiku") || strings.Contains(m, "-3-") || strings.Contains(m, "-4-5") || strings.Contains(m, "-4-1") || strings.Contains(m, "-4-0") { + return 200_000 + } + return 1_000_000 + case "gemini": + return 1_000_000 + case "openai": + if strings.HasPrefix(m, "gpt-4.1") { + return 1_000_000 + } + return 128_000 + case "groq": + return 128_000 + } + return 32_000 // local models: conservative +} + +// compactThreshold is the prompt size that triggers auto-compaction. +func (a *Agent) compactThreshold() int { + if a.CompactAt > 0 { + return a.CompactAt + } + t := a.contextWindow() * 8 / 10 + if t > 200_000 { + t = 200_000 // bound per-request cost on 1M-token models + } + return t +} + +// promptTokens is the latest prompt size: reported by the provider when +// available, otherwise estimated at ~4 bytes per token. +func (a *Agent) promptTokens() int { + if a.lastIn > 0 { + return a.lastIn + } + n := len(a.sys) + for _, m := range a.history { + n += len(m.Text) + len(m.RawSteps) + for _, c := range m.Calls { + n += len(c.Args) + len(c.Name) + } + } + return n / 4 +} + +func (a *Agent) needsCompact() bool { + return len(a.history) >= 2 && a.promptTokens() >= a.compactThreshold() +} + +// Compact summarizes the conversation and replaces the history with the +// summary, which is attached to the next user turn. instructions, when +// set, tell the summarizer what to focus on (/compact ). +func (a *Agent) Compact(ctx context.Context, instructions string) error { + return a.compactNow(ctx, instructions, false) +} + +// compactNow does the work. midTurn means a tool round is in progress: +// the summary becomes a user message asking the model to carry on. +func (a *Agent) compactNow(ctx context.Context, instructions string, midTurn bool) error { + if len(a.history) == 0 { + return fmt.Errorf("nothing to compact") + } + prompt := compactPrompt + if instructions != "" { + prompt += "\n\nOperator focus for this summary: " + a.Redact.Text(instructions) + } + hist := append([]Msg(nil), a.history...) + // a summarizer turn must follow a complete exchange: a trailing user + // message would make two user turns in a row on some providers, so + // fold the prompt into it + if n := len(hist); n > 0 && hist[n-1].Role == "user" { + hist[n-1].Text += "\n\n" + prompt + } else { + hist = append(hist, Msg{Role: "user", Text: prompt}) + } + // same system + tools as the session, so the summarizer reads the + // cached prefix; the prompt forbids tool calls and any are ignored + req := &Request{ + Model: a.Model, System: a.system(), Messages: hist, + Tools: a.toolDefs(), MaxTok: 8192, Effort: a.Effort, + } + resp, err := a.Provider.Chat(ctx, req) + if err != nil { + return err + } + a.total.Add(resp.Usage) + summary := strings.TrimSpace(a.Redact.Text(resp.Text)) + if summary == "" { + return fmt.Errorf("model returned an empty summary") + } + block := "\n" + summary + "\n" + a.history, a.lastIn = nil, 0 + if midTurn { + a.history = []Msg{{Role: "user", Text: block + "\n\nContinue the task in progress from this summary."}} + } else { + a.carry = block + } + if lg := a.Audit(); lg != nil { + _ = lg.Log("compact", a.profileName(), map[string]any{"summary": summary, "mid_turn": midTurn}) + } + a.emit(Event{Kind: EvNotice, Text: fmt.Sprintf("conversation compacted (%d-char summary)", len(summary))}) + return nil +} + +// isContextOverflow recognizes "prompt too long" errors across providers. +func isContextOverflow(err error) bool { + s := strings.ToLower(err.Error()) + return strings.Contains(s, "prompt is too long") || + strings.Contains(s, "context_length_exceeded") || + strings.Contains(s, "context length") || + strings.Contains(s, "context window") || + strings.Contains(s, "maximum context") || + (strings.Contains(s, "too many tokens") && strings.Contains(s, "input")) +} diff --git a/internal/agent/events.go b/internal/agent/events.go index e42eb17..e98cfc7 100644 --- a/internal/agent/events.go +++ b/internal/agent/events.go @@ -21,6 +21,12 @@ const ( EvToolStart EventKind = "tool_start" // EvToolResult fires after a tool runs (Err set on failure). EvToolResult EventKind = "tool_result" + // EvThinking is streamed model reasoning (a summary, where the + // provider exposes one). Front-ends may show it dimmed or hide it. + EvThinking EventKind = "thinking" + // EvNotice is a status line from the loop itself: continuation after + // an output cutoff, compaction, a refusal, a failed session save. + EvNotice EventKind = "notice" // EvApproval asks the human to approve a gated action; answer it via // Approval.Answer. Only emitted when the context uses EventApprover. EvApproval EventKind = "approval" diff --git a/internal/agent/gemini.go b/internal/agent/gemini.go index 530fc8c..a2aa17c 100644 --- a/internal/agent/gemini.go +++ b/internal/agent/gemini.go @@ -1,7 +1,6 @@ package agent import ( - "bytes" "context" "encoding/json" "fmt" @@ -58,7 +57,7 @@ func gemInput(msgs []Msg) []json.RawMessage { {"type": "text", "text": m.Text}, }}) case "assistant": - if len(m.RawSteps) > 0 { + if len(m.RawSteps) > 0 && (m.RawProvider == "" || m.RawProvider == "gemini") { var steps []json.RawMessage if json.Unmarshal(m.RawSteps, &steps) == nil { out = append(out, steps...) @@ -108,8 +107,16 @@ func (g *gemini) body(r *Request, stream bool) map[string]any { if len(tools) > 0 { b["tools"] = tools } + gc := map[string]any{} if r.MaxTok > 0 { - b["generation_config"] = map[string]any{"max_output_tokens": r.MaxTok} + gc["max_output_tokens"] = r.MaxTok + } + if r.Effort != "" { + gc["thinking_level"] = gemEffort(r.Effort) + gc["thinking_summaries"] = "auto" + } + if len(gc) > 0 { + b["generation_config"] = gc } if stream { b["stream"] = true @@ -117,18 +124,58 @@ func (g *gemini) body(r *Request, stream bool) map[string]any { return b } +// gemEffort maps the neutral effort scale onto thinking_level +// (minimal | low | medium | high). +func gemEffort(e string) string { + switch e { + case "xhigh", "max": + return "high" + } + return e +} + +// gemUsage is the Interaction usage object. +type gemUsage struct { + Input int `json:"total_input_tokens"` + Cached int `json:"total_cached_tokens"` + Output int `json:"total_output_tokens"` + Thought int `json:"total_thought_tokens"` +} + +func (u gemUsage) usage() Usage { + return Usage{Input: u.Input, Output: u.Output + u.Thought, CacheRead: u.Cached} +} + +func gemStop(status string, calls int) StopReason { + switch status { + case "incomplete": + return StopMaxTokens + case "requires_action": + return StopToolUse + case "completed": + if calls > 0 { + return StopToolUse + } + return StopEnd + } + return "" +} + func (g *gemini) post(ctx context.Context, body map[string]any) (*http.Response, error) { - raw, _ := json.Marshal(body) - url := strings.TrimSuffix(g.base, "/") + "/v1beta/interactions" - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(raw)) + raw, err := json.Marshal(body) if err != nil { return nil, err } - req.Header.Set("content-type", "application/json") - if g.key != "" { - req.Header.Set("x-goog-api-key", g.key) + resp, err := doHTTP(ctx, g.client(), strings.TrimSuffix(g.base, "/")+"/v1beta/interactions", raw, + map[string]string{"x-goog-api-key": g.key}) + if err != nil { + return nil, err + } + if resp.StatusCode/100 != 2 { + defer resp.Body.Close() + return nil, apiError(g.Name(), resp) } - return g.client().Do(req) + return resp, nil } // gemStepText extracts assistant text from a parsed step. @@ -160,6 +207,8 @@ func gemResponse(steps []json.RawMessage, outputText string) *Response { continue } switch st.Type { + case "thought": + res.Thinking += gemThoughtText(s) case "function_call": args := st.Arguments if len(args) == 0 { @@ -183,18 +232,42 @@ func gemResponse(steps []json.RawMessage, outputText string) *Response { return res } +// gemThoughtText pulls readable text out of a thought step's summary. +func gemThoughtText(raw json.RawMessage) string { + var t struct { + Summary json.RawMessage `json:"summary"` + } + if json.Unmarshal(raw, &t) != nil || len(t.Summary) == 0 { + return "" + } + var parts []struct { + Text string `json:"text"` + } + if json.Unmarshal(t.Summary, &parts) == nil { + var b strings.Builder + for _, p := range parts { + b.WriteString(p.Text) + } + return b.String() + } + var str string + if json.Unmarshal(t.Summary, &str) == nil { + return str + } + return "" +} + func (g *gemini) Chat(ctx context.Context, r *Request) (*Response, error) { resp, err := g.post(ctx, g.body(r, false)) if err != nil { return nil, err } defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - return nil, apiError(g.Name(), resp) - } var out struct { OutputText string `json:"output_text"` Steps []json.RawMessage `json:"steps"` + Status string `json:"status"` + Usage gemUsage `json:"usage"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err @@ -202,7 +275,9 @@ func (g *gemini) Chat(ctx context.Context, r *Request) (*Response, error) { if len(out.Steps) == 0 && out.OutputText == "" { return nil, fmt.Errorf("%s: empty interaction", g.Name()) } - return gemResponse(out.Steps, out.OutputText), nil + res := gemResponse(out.Steps, out.OutputText) + res.Stop, res.Usage = gemStop(out.Status, len(res.Calls)), out.Usage.usage() + return res, nil } // gemEvent is one SSE data payload from the Interactions stream. @@ -218,6 +293,8 @@ type gemEvent struct { Interaction struct { Steps []json.RawMessage `json:"steps"` OutputText string `json:"output_text"` + Status string `json:"status"` + Usage gemUsage `json:"usage"` } `json:"interaction"` } @@ -226,6 +303,8 @@ type gemStreamState struct { steps map[int]*json.RawMessage // index → assembled step order []int argBuf map[int]*strings.Builder + status string + usage gemUsage } func newGemStream() *gemStreamState { @@ -266,6 +345,7 @@ func (s *gemStreamState) handle(ev *gemEvent, onText func(string)) (completed *R } } case "interaction.completed", "interaction.complete": + s.status, s.usage = ev.Interaction.Status, ev.Interaction.Usage if len(ev.Interaction.Steps) > 0 { return &Response{RawSteps: mustJSON(ev.Interaction.Steps), Text: ev.Interaction.OutputText}, nil } @@ -310,9 +390,6 @@ func (g *gemini) Stream(ctx context.Context, r *Request, onText func(string)) (* return nil, err } defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - return nil, apiError(g.Name(), resp) - } st := newGemStream() var text strings.Builder var completed *Response @@ -339,12 +416,13 @@ func (g *gemini) Stream(ctx context.Context, r *Request, onText func(string)) (* if err != nil { return nil, err } + var res *Response if completed != nil && len(completed.RawSteps) > 0 { - res := gemResponse(mustUnmarshalSteps(completed.RawSteps), firstNonEmpty(completed.Text, text.String())) - return res, nil + res = gemResponse(mustUnmarshalSteps(completed.RawSteps), firstNonEmpty(completed.Text, text.String())) + } else { + res = gemResponse(st.assemble(), text.String()) } - steps := st.assemble() - res := gemResponse(steps, text.String()) + res.Stop, res.Usage = gemStop(st.status, len(res.Calls)), st.usage.usage() return res, nil } diff --git a/internal/agent/loop.go b/internal/agent/loop.go index aa592e5..da6f840 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -9,6 +9,7 @@ import ( "regexp" "strings" "time" + "unicode/utf8" "github.com/abhijitkrm/cometcli/internal/audit" "github.com/abhijitkrm/cometcli/internal/config" @@ -42,15 +43,31 @@ type Agent struct { // defaults to SnapshotText. Refreshed at most every SnapshotTTL. SnapshotFn func(*toolkit.Context) string SnapshotTTL time.Duration + // MaxTokens caps each model round's output (0 = provider default). + MaxTokens int + // Effort is the reasoning depth passed to the provider ("" = default). + Effort string + // CompactAt is the prompt size, in tokens, at which history is + // summarized at the next turn boundary (0 = derived from the model's + // context window). + CompactAt int + // Persist saves the session after every turn so it can be resumed. + Persist bool conf config.AgentConf id string + created time.Time audit *audit.Logger history []Msg + sys string // system prompt, frozen per session (cache prefix) + toolSig string // tool names last advertised + carry string // compaction summary awaiting the next user turn snap string snapAt time.Time calls int toolTrunc int + lastIn int // prompt tokens of the latest round (0 = unknown) + total Usage // session token totals } // New builds an agent for a context from the profile's agent config. @@ -65,15 +82,21 @@ func New(c *toolkit.Context, reg *toolkit.Registry) (*Agent, error) { return nil, err } a := &Agent{ - Provider: prov, - Model: def(ac.Model, ""), - Reg: reg, - Ctx: c, - MaxIter: 16, - Policy: pol, - Redact: RedactorFor(c.Profile), - Stream: !ac.NoStream, - conf: ac, + Provider: prov, + Model: def(ac.Model, ""), + Reg: reg, + Ctx: c, + MaxIter: def0(ac.MaxTurns, 50), + Policy: pol, + Redact: RedactorFor(c.Profile), + Stream: !ac.NoStream, + MaxTokens: ac.MaxTokens, + Effort: ac.Effort, + CompactAt: ac.CompactAt, + conf: ac, + } + if err := ValidEffort(a.Effort); err != nil { + return nil, err } if a.Model == "" { a.Model = modelOf(prov) @@ -103,10 +126,28 @@ func modelOf(p Provider) string { return t.model case *openai: return t.model + case *gemini: + return t.model } return "" } +func def0(v, d int) int { + if v == 0 { + return d + } + return v +} + +// ValidEffort accepts "" or one of the neutral effort levels. +func ValidEffort(e string) error { + switch e { + case "", "minimal", "low", "medium", "high", "xhigh", "max": + return nil + } + return fmt.Errorf("unknown effort %q — want low | medium | high | xhigh | max", e) +} + // ID returns the session id (assigned lazily on first use). func (a *Agent) ID() string { if a.id == "" { @@ -129,9 +170,15 @@ func (a *Agent) Audit() *audit.Logger { func (a *Agent) Reset() { a.history = nil a.id, a.audit = "", nil - a.snap = "" + a.snap, a.sys, a.toolSig, a.carry = "", "", "", "" + a.lastIn, a.total = 0, Usage{} + a.created = time.Time{} } +// Usage returns the session's accumulated token usage and the size of +// the latest prompt (0 when the provider doesn't report it). +func (a *Agent) Usage() (total Usage, lastPrompt int) { return a.total, a.lastIn } + func (a *Agent) emit(e Event) { if a.OnEvent != nil { a.OnEvent(e) @@ -167,48 +214,166 @@ func (a *Agent) toolDefs() []ToolDef { func toolFnName(n string) string { return strings.ReplaceAll(n, ".", "__") } +// maxContinuations bounds automatic "continue" rounds after a reply is +// cut off by the output-token limit. +const maxContinuations = 3 + // Run processes one user turn, executing tools until the model finishes. func (a *Agent) Run(ctx context.Context, input string) (string, error) { + out, err := a.run(ctx, input) + if a.Persist { + if serr := a.Save(); serr != nil && err == nil { + a.emit(Event{Kind: EvNotice, Text: "session not saved: " + serr.Error()}) + } + } + return out, err +} + +func (a *Agent) run(ctx context.Context, input string) (string, error) { a.calls = 0 + if a.created.IsZero() { + a.created = time.Now() + } + // compact only at a turn boundary — never inside a tool round + if a.needsCompact() { + if err := a.Compact(ctx, ""); err != nil { + a.emit(Event{Kind: EvNotice, Text: "auto-compact failed: " + err.Error()}) + } + } input = a.Redact.Text(input) - a.history = append(a.history, Msg{Role: "user", Text: input}) if lg := a.Audit(); lg != nil { _ = lg.Log(audit.KindPrompt, a.profileName(), map[string]any{"text": input}) } + a.history = append(a.history, Msg{Role: "user", Text: a.turnPrefix() + input}) + var texts []string + conts, compacted := 0, false for i := 0; i < a.MaxIter; i++ { - req := &Request{ - Model: a.Model, - System: a.system(), - Messages: a.history, - Tools: a.toolDefs(), - MaxTok: 4096, - } + req := a.request() resp, err := a.chat(ctx, req) + if err != nil && !compacted && isContextOverflow(err) { + // the prompt outgrew the window mid-turn: summarize everything + // so far and carry on from the summary + compacted = true + a.emit(Event{Kind: EvNotice, Text: "context window full — compacting and continuing"}) + if cerr := a.compactNow(ctx, "", true); cerr != nil { + return "", fmt.Errorf("%w (compaction failed: %v)", err, cerr) + } + i-- + continue + } if err != nil { return "", err } - a.history = append(a.history, Msg{Role: "assistant", Text: resp.Text, Calls: resp.Calls, RawSteps: resp.RawSteps}) + a.total.Add(resp.Usage) + if resp.Usage.Input > 0 { + a.lastIn = resp.Usage.Input + resp.Usage.Output + } + a.history = append(a.history, Msg{ + Role: "assistant", Text: resp.Text, Calls: resp.Calls, + RawSteps: resp.RawSteps, RawProvider: rawProvider(a.Provider, resp), + }) a.logLLM(i, resp) shown := a.Redact.Text(resp.Text) if shown != "" { a.emit(Event{Kind: EvText, Text: shown}) + texts = append(texts, shown) + } + switch { + case resp.Stop == StopRefusal: + msg := "the model declined this request" + if resp.StopDetail != "" { + msg += " (" + resp.StopDetail + ")" + } + a.emit(Event{Kind: EvNotice, Text: msg}) + return strings.Join(texts, "\n"), fmt.Errorf("%s", msg) + case resp.Stop == StopMaxTokens && len(resp.Calls) == 0: + if conts >= maxContinuations { + a.emit(Event{Kind: EvNotice, Text: "reply truncated at the output-token limit"}) + return strings.Join(texts, ""), nil + } + conts++ + a.emit(Event{Kind: EvNotice, Text: "reply hit the output-token limit — continuing"}) + a.history = append(a.history, Msg{Role: "user", Text: "Your reply was cut off by the output limit. Continue exactly where you stopped, without repeating anything."}) + continue } if resp.Done || len(resp.Calls) == 0 { + if conts > 0 { + return strings.Join(texts[len(texts)-conts-1:], ""), nil + } return shown, nil } for _, call := range resp.Calls { if err := ctx.Err(); err != nil { - return "", err + // answer every outstanding call so the history stays valid + a.history = append(a.history, Msg{Role: "tool", CallID: call.ID, ToolName: toolkit.ResolveName(call.Name), Text: "cancelled by operator", IsError: true}) + continue } a.history = append(a.history, a.execCall(ctx, call)) } + if err := ctx.Err(); err != nil { + return "", err + } } return "", fmt.Errorf("agent exceeded %d iterations", a.MaxIter) } +// rawProvider tags raw output with the provider that produced it, so it +// is only ever replayed to that provider. +func rawProvider(p Provider, resp *Response) string { + if len(resp.RawSteps) == 0 { + return "" + } + return p.Name() +} + +// request builds one model round. The system prompt is frozen for the +// session and the tool set is stable, so providers can reuse the cached +// prefix; when the tool set does change (e.g. /mode readonly), replayed +// thinking bound to the old prefix is dropped first. +func (a *Agent) request() *Request { + tools := a.toolDefs() + names := make([]string, len(tools)) + for i, t := range tools { + names[i] = t.Name + } + sig := strings.Join(names, ",") + if a.toolSig != "" && sig != a.toolSig { + a.dropBoundRaw() + } + a.toolSig = sig + return &Request{ + Model: a.Model, + System: a.system(), + Messages: a.history, + Tools: tools, + MaxTok: a.MaxTokens, + Effort: a.Effort, + OnThinking: func(t string) { + if a.OnEvent != nil && t != "" { + a.emit(Event{Kind: EvThinking, Text: a.Redact.Text(t)}) + } + }, + } +} + +// dropBoundRaw discards raw assistant output whose validity is tied to +// the exact request prefix (Anthropic thinking signatures). Those turns +// fall back to their plain text + tool calls. +func (a *Agent) dropBoundRaw() { + for i := range a.history { + if a.history[i].RawProvider == "anthropic" { + a.history[i].RawSteps, a.history[i].RawProvider = nil, "" + } + } +} + // chat performs one model round, streaming text deltas when possible. +// Retries surface as notices so a backoff never looks like a hang. func (a *Agent) chat(ctx context.Context, req *Request) (*Response, error) { + ctx = WithRetryNotice(ctx, func(attempt int, wait time.Duration, reason string) { + a.emit(Event{Kind: EvNotice, Text: fmt.Sprintf("%s: %s — retrying in %s (attempt %d)", a.Provider.Name(), reason, wait.Round(100*time.Millisecond), attempt+1)}) + }) 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}) @@ -280,9 +445,7 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { 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 = clip(text, limit) // bound context growth text = a.Redact.Text(text) } if lg := a.Audit(); lg != nil { @@ -322,21 +485,52 @@ func (a *Agent) toolCtx(ctx context.Context, t toolkit.Tool) (*toolkit.Context, return sub, cancel } -// system returns the system prompt plus a cached, redacted live snapshot. +// system returns the session's system prompt: instructions plus the live +// snapshot taken when the session started. It is frozen until Reset or +// Compact — rebuilding it every request would defeat prompt caching and +// invalidate replayed thinking. Later snapshots ride on user turns. func (a *Agent) system() string { + if a.sys == "" { + a.sys = a.Redact.Text(SystemPrompt(a.Ctx)) + a.snapshot() + } + return a.sys +} + +// snapshot probes the live node digest and records when. +func (a *Agent) snapshot() string { + fn := a.SnapshotFn + if fn == nil { + fn = SnapshotText + } + a.snap = a.Redact.Text(fn(a.Ctx)) + a.snapAt = time.Now() + return a.snap +} + +// turnPrefix is prepended to a user turn: a pending compaction summary, +// and a refreshed live snapshot once the previous one is older than +// SnapshotTTL. Appending keeps the request prefix byte-stable. +func (a *Agent) turnPrefix() string { + var b strings.Builder + if a.carry != "" { + b.WriteString(a.carry) + b.WriteString("\n\n") + a.carry = "" + } + if a.sys == "" { + a.system() // first turn: the snapshot goes in the system prompt + return b.String() + } 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() + if time.Since(a.snapAt) > ttl { + b.WriteString("\n") + b.WriteString(strings.TrimSpace(a.snapshot())) + b.WriteString("\n\n\n") } - return a.Redact.Text(SystemPrompt(a.Ctx)) + a.snap + return b.String() } // History returns a copy of the conversation (for tests and replay). @@ -345,12 +539,39 @@ func (a *Agent) History() []Msg { return append([]Msg(nil), a.history...) } // ansiRe matches terminal escape sequences (colors, cursor moves). var ansiRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) +// clip bounds s to about limit bytes, keeping the head and the tail (log +// tails usually hold the error) and cutting only at UTF-8 boundaries. +func clip(s string, limit int) string { + if len(s) <= limit { + return s + } + head, tail := limit*2/3, limit/3 + h := safeCut(s, head) + t := s[len(s)-tail:] + for len(t) > 0 && !utf8.RuneStart(t[0]) { + t = t[1:] + } + return fmt.Sprintf("%s\n…[%d bytes truncated]…\n%s", h, len(s)-len(h)-len(t), t) +} + +// safeCut returns the longest prefix of s within n bytes that ends on a +// rune boundary. +func safeCut(s string, n int) string { + if len(s) <= n { + return s + } + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] +} + func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { s = s[:i] } if len(s) > 120 { - return s[:120] + "…" + return safeCut(s, 120) + "…" } return s } diff --git a/internal/agent/openai.go b/internal/agent/openai.go index c147309..a238840 100644 --- a/internal/agent/openai.go +++ b/internal/agent/openai.go @@ -1,7 +1,6 @@ package agent import ( - "bytes" "context" "encoding/json" "fmt" @@ -87,21 +86,84 @@ func (o *openai) body(r *Request) map[string]any { if len(tools) > 0 { body["tools"] = tools } + if r.MaxTok > 0 { + if o.hosted() { + body["max_completion_tokens"] = r.MaxTok + } else { + body["max_tokens"] = r.MaxTok // llama.cpp / older vLLM / Ollama + } + } + if r.Effort != "" { + body["reasoning_effort"] = oaiEffort(r.Effort) + } return body } +// hosted reports whether this is OpenAI or Groq proper (vs a local +// OpenAI-compatible server that may lack newer request fields). +func (o *openai) hosted() bool { + n := o.Name() + return n == "openai" || n == "groq" +} + +// oaiEffort maps the neutral effort scale onto reasoning_effort, which +// tops out at "high" on OpenAI and Groq reasoning models. +func oaiEffort(e string) string { + switch e { + case "xhigh", "max": + return "high" + } + return e +} + +func oaiStop(finish string) StopReason { + switch finish { + case "stop": + return StopEnd + case "tool_calls", "function_call": + return StopToolUse + case "length": + return StopMaxTokens + case "content_filter": + return StopRefusal + } + return "" +} + +// oaiUsage is the chat-completions usage object. +type oaiUsage struct { + Prompt int `json:"prompt_tokens"` + Completion int `json:"completion_tokens"` + Details struct { + Cached int `json:"cached_tokens"` + } `json:"prompt_tokens_details"` +} + +func (u *oaiUsage) usage() Usage { + if u == nil { + return Usage{} + } + return Usage{Input: u.Prompt, Output: u.Completion, CacheRead: u.Details.Cached} +} + 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)) + raw, err := json.Marshal(body) if err != nil { return nil, err } - req.Header.Set("content-type", "application/json") + hdr := map[string]string{} if o.key != "" { - req.Header.Set("authorization", "Bearer "+o.key) + hdr["authorization"] = "Bearer " + o.key } - return o.client().Do(req) + resp, err := doHTTP(ctx, o.client(), strings.TrimSuffix(o.base, "/")+"/v1/chat/completions", raw, hdr) + if err != nil { + return nil, err + } + if resp.StatusCode/100 != 2 { + defer resp.Body.Close() + return nil, apiError(o.Name(), resp) + } + return resp, nil } func (o *openai) Chat(ctx context.Context, r *Request) (*Response, error) { @@ -114,10 +176,12 @@ func (o *openai) Chat(ctx context.Context, r *Request) (*Response, error) { Choices []struct { Message struct { Content string `json:"content"` + Reasoning string `json:"reasoning"` ToolCalls []oaiToolCall `json:"tool_calls"` } `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` + Usage *oaiUsage `json:"usage"` Error *struct { Message string `json:"message"` } `json:"error"` @@ -132,7 +196,10 @@ func (o *openai) Chat(ctx context.Context, r *Request) (*Response, error) { return nil, fmt.Errorf("%s: no choices", o.Name()) } ch := out.Choices[0].Message - res := &Response{Text: ch.Content, Done: len(ch.ToolCalls) == 0} + res := &Response{ + Text: ch.Content, Thinking: ch.Reasoning, Done: len(ch.ToolCalls) == 0, + Stop: oaiStop(out.Choices[0].FinishReason), Usage: out.Usage.usage(), + } for _, tc := range ch.ToolCalls { res.Calls = append(res.Calls, Call{ ID: tc.ID, Name: tc.Function.Name, Args: json.RawMessage(tc.Function.Arguments), @@ -148,28 +215,32 @@ func (o *openai) Chat(ctx context.Context, r *Request) (*Response, error) { func (o *openai) Stream(ctx context.Context, r *Request, onText func(string)) (*Response, error) { body := o.body(r) body["stream"] = true + if o.Name() == "openai" { + body["stream_options"] = map[string]any{"include_usage": 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 + var text, reasoning strings.Builder + var finish string + var usage *oaiUsage err = readSSE(resp.Body, func(data string) error { if data == "[DONE]" { return nil } var ch struct { Choices []struct { - Delta struct { + FinishReason string `json:"finish_reason"` + Delta struct { Content string `json:"content"` + Reasoning string `json:"reasoning"` ToolCalls []struct { Index int `json:"index"` ID string `json:"id"` @@ -180,6 +251,10 @@ func (o *openai) Stream(ctx context.Context, r *Request, onText func(string)) (* } `json:"tool_calls"` } `json:"delta"` } `json:"choices"` + Usage *oaiUsage `json:"usage"` + XGroq *struct { + Usage *oaiUsage `json:"usage"` + } `json:"x_groq"` // Groq reports stream usage here Error *struct { Message string `json:"message"` } `json:"error"` @@ -190,10 +265,24 @@ func (o *openai) Stream(ctx context.Context, r *Request, onText func(string)) (* if ch.Error != nil { return fmt.Errorf("%s: %s", o.Name(), ch.Error.Message) } + if ch.Usage != nil { + usage = ch.Usage + } else if ch.XGroq != nil && ch.XGroq.Usage != nil { + usage = ch.XGroq.Usage + } if len(ch.Choices) == 0 { return nil } + if f := ch.Choices[0].FinishReason; f != "" { + finish = f + } d := ch.Choices[0].Delta + if d.Reasoning != "" { + reasoning.WriteString(d.Reasoning) + if r.OnThinking != nil { + r.OnThinking(d.Reasoning) + } + } if d.Content != "" { text.WriteString(d.Content) onText(d.Content) @@ -217,7 +306,7 @@ func (o *openai) Stream(ctx context.Context, r *Request, onText func(string)) (* if err != nil { return nil, err } - res := &Response{Text: text.String()} + res := &Response{Text: text.String(), Thinking: reasoning.String(), Stop: oaiStop(finish), Usage: usage.usage()} idx := make([]int, 0, len(calls)) for i := range calls { idx = append(idx, i) diff --git a/internal/agent/phase1_test.go b/internal/agent/phase1_test.go new file mode 100644 index 0000000..33a7177 --- /dev/null +++ b/internal/agent/phase1_test.go @@ -0,0 +1,561 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + "unicode/utf8" + + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// noSleep makes retry backoff instant for the duration of a test. +func noSleep(t *testing.T) *[]time.Duration { + t.Helper() + var waits []time.Duration + old := sleep + sleep = func(ctx context.Context, d time.Duration) error { + waits = append(waits, d) + return ctx.Err() + } + t.Cleanup(func() { sleep = old }) + return &waits +} + +func TestRetryHonorsRetryAfterThenSucceeds(t *testing.T) { + waits := noSleep(t) + var n atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch n.Add(1) { + case 1: + w.Header().Set("Retry-After", "7") + w.WriteHeader(429) + case 2: + w.WriteHeader(529) // Anthropic overloaded + default: + io.WriteString(w, "ok") + } + })) + defer srv.Close() + resp, err := doHTTP(context.Background(), srv.Client(), srv.URL, []byte(`{}`), nil) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 200 || n.Load() != 3 { + t.Fatalf("status %d after %d attempts", resp.StatusCode, n.Load()) + } + if len(*waits) != 2 || (*waits)[0] != 7*time.Second { + t.Fatalf("waits = %v, want Retry-After 7s first", *waits) + } +} + +func TestRetrySkipsClientErrors(t *testing.T) { + noSleep(t) + var n atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n.Add(1) + w.WriteHeader(400) + })) + defer srv.Close() + resp, err := doHTTP(context.Background(), srv.Client(), srv.URL, []byte(`{}`), nil) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 400 || n.Load() != 1 { + t.Fatalf("400 retried: %d attempts", n.Load()) + } +} + +func TestRetryGivesUpAfterMax(t *testing.T) { + noSleep(t) + var n atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n.Add(1) + w.WriteHeader(503) + })) + defer srv.Close() + resp, err := doHTTPWith(context.Background(), srv.Client(), srv.URL, []byte(`{}`), nil, retryPolicy{Max: 2, Base: time.Millisecond, Cap: time.Second}) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 503 || n.Load() != 3 { + t.Fatalf("want final 503 after 3 attempts, got %d after %d", resp.StatusCode, n.Load()) + } +} + +// sseServer serves a fixed SSE body and records each request body. +func sseServer(t *testing.T, events string, bodies *[]map[string]any, hdrs *[]http.Header) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var b map[string]any + _ = json.NewDecoder(r.Body).Decode(&b) + *bodies = append(*bodies, b) + if hdrs != nil { + *hdrs = append(*hdrs, r.Header.Clone()) + } + w.Header().Set("content-type", "text/event-stream") + io.WriteString(w, events) + })) + t.Cleanup(srv.Close) + return srv +} + +func sseBody(evs ...string) string { + var b strings.Builder + for _, e := range evs { + b.WriteString("data: " + e + "\n\n") + } + return b.String() +} + +func TestAnthropicStreamThinkingToolUseAndReplay(t *testing.T) { + var bodies []map[string]any + srv := sseServer(t, sseBody( + `{"type":"message_start","message":{"usage":{"input_tokens":10,"cache_read_input_tokens":90,"cache_creation_input_tokens":0}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"check the "}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"node"}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"SIG123"}}`, + `{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`, + `{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Looking."}}`, + `{"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"tu1","name":"node__status","input":{}}}`, + `{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"lines\":"}}`, + `{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"5}"}}`, + `{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":42}}`, + ), &bodies, nil) + p := &anthropic{key: "k", model: "claude-opus-5-5", base: srv.URL, hc: srv.Client()} + var thought, text string + req := &Request{Model: "claude-opus-5-5", System: "sys", Effort: "high", + Messages: []Msg{{Role: "user", Text: "hi"}}, + Tools: []ToolDef{{Name: "node__status", Schema: map[string]any{"type": "object"}}}, + OnThinking: func(s string) { thought += s }} + res, err := p.Stream(context.Background(), req, func(s string) { text += s }) + if err != nil { + t.Fatal(err) + } + if thought != "check the node" || text != "Looking." || res.Thinking != "check the node" { + t.Fatalf("thinking=%q text=%q", thought, text) + } + if res.Stop != StopToolUse || len(res.Calls) != 1 || string(res.Calls[0].Args) != `{"lines":5}` { + t.Fatalf("stop=%s calls=%+v", res.Stop, res.Calls) + } + if res.Usage != (Usage{Input: 100, Output: 42, CacheRead: 90}) { + t.Fatalf("usage = %+v", res.Usage) + } + if !strings.Contains(string(res.RawSteps), `"signature":"SIG123"`) { + t.Fatalf("signature lost from raw blocks: %s", res.RawSteps) + } + b := bodies[0] + if b["cache_control"] == nil { + t.Error("no top-level cache_control") + } + if th, _ := b["thinking"].(map[string]any); th["type"] != "adaptive" { + t.Errorf("thinking = %v", b["thinking"]) + } + if oc, _ := b["output_config"].(map[string]any); oc["effort"] != "high" { + t.Errorf("output_config = %v", b["output_config"]) + } + if tl, _ := b["tools"].([]any); len(tl) != 1 || tl[0].(map[string]any)["eager_input_streaming"] != true { + t.Errorf("tools = %v", b["tools"]) + } + if _, ok := b["fallbacks"]; ok { + t.Error("fallbacks sent to a non-first-party base URL") + } + + // the next request replays the assistant turn byte-for-byte, with both + // tool results in one user message + hist := []Msg{ + {Role: "user", Text: "hi"}, + {Role: "assistant", Text: res.Text, Calls: res.Calls, RawSteps: res.RawSteps, RawProvider: "anthropic"}, + {Role: "tool", CallID: "tu1", Text: "height 5"}, + {Role: "tool", CallID: "tu2", Text: "peers 3"}, + } + msgs := p.convMessages(hist) + if len(msgs) != 3 { + t.Fatalf("want user/assistant/user, got %d messages", len(msgs)) + } + got, _ := json.Marshal(msgs[1]["content"]) + if !jsonEqual(t, got, res.RawSteps) { + t.Fatalf("assistant not replayed verbatim:\n got %s\nwant %s", got, res.RawSteps) + } + if c, _ := msgs[2]["content"].([]anthBlock); len(c) != 2 { + t.Fatalf("tool results not merged: %v", msgs[2]["content"]) + } + // raw from another provider is never replayed to Anthropic + hist[1].RawProvider = "gemini" + got, _ = json.Marshal(p.convMessages(hist)[1]["content"]) + if strings.Contains(string(got), "SIG123") { + t.Fatal("foreign raw steps replayed") + } +} + +func jsonEqual(t *testing.T, a, b []byte) bool { + t.Helper() + var x, y any + if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil { + return false + } + ja, _ := json.Marshal(x) + jb, _ := json.Marshal(y) + return string(ja) == string(jb) +} + +func TestAnthropicFallbackOnFirstParty(t *testing.T) { + p := &anthropic{model: "claude-opus-5-5", base: "https://api.anthropic.com"} + b := p.body(&Request{Model: "claude-opus-5-5"}, false) + if b["fallbacks"] != "default" || b["max_tokens"] != 16000 { + t.Fatalf("body = %v", b) + } + b = p.body(&Request{Model: "claude-haiku-4-5"}, true) + if _, ok := b["fallbacks"]; ok { + t.Fatal("fallbacks sent for a model without it") + } + if _, ok := b["thinking"]; ok { + t.Fatal("adaptive thinking sent to haiku") + } + if b["max_tokens"] != 64000 { + t.Fatalf("stream max_tokens = %v", b["max_tokens"]) + } +} + +func TestAnthropicRefusalAndMaxTokens(t *testing.T) { + r := anthResponse(nil, "refusal", &anthStopDetails{Category: "cyber"}, anthUsage{}) + if r.Stop != StopRefusal || r.StopDetail != "cyber" { + t.Fatalf("refusal = %s %q", r.Stop, r.StopDetail) + } + if r := anthResponse(nil, "max_tokens", nil, anthUsage{}); r.Stop != StopMaxTokens { + t.Fatalf("stop = %s", r.Stop) + } +} + +func TestAnthropicInvalidToolInputReportedNotReplayed(t *testing.T) { + var bodies []map[string]any + srv := sseServer(t, sseBody( + `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tu1","name":"x","input":{}}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"a\":"}}`, + `{"type":"message_delta","delta":{"stop_reason":"max_tokens"}}`, + ), &bodies, nil) + p := &anthropic{key: "k", base: srv.URL, hc: srv.Client()} + res, err := p.Stream(context.Background(), &Request{Model: "m"}, func(string) {}) + if err != nil { + t.Fatal(err) + } + if string(res.Calls[0].Args) != `{"a":` { + t.Fatalf("truncated input should reach the loop as-is, got %s", res.Calls[0].Args) + } + if !json.Valid(res.RawSteps) || !strings.Contains(string(res.RawSteps), `"input":{}`) { + t.Fatalf("raw blocks must stay valid JSON: %s", res.RawSteps) + } +} + +func TestOpenAIGroqStreamUsageReasoningAndLength(t *testing.T) { + var bodies []map[string]any + srv := sseServer(t, sseBody( + `{"choices":[{"delta":{"reasoning":"hmm "}}]}`, + `{"choices":[{"delta":{"content":"partial"}}]}`, + `{"choices":[{"delta":{},"finish_reason":"length"}],"x_groq":{"usage":{"prompt_tokens":120,"completion_tokens":30,"prompt_tokens_details":{"cached_tokens":100}}}}`, + `[DONE]`, + ), &bodies, nil) + o := &openai{name: "groq", model: "openai/gpt-oss-120b", base: srv.URL, hc: srv.Client()} + var thought string + res, err := o.Stream(context.Background(), &Request{Model: o.model, MaxTok: 2048, Effort: "max", + Messages: []Msg{{Role: "user", Text: "x"}}, OnThinking: func(s string) { thought += s }}, func(string) {}) + if err != nil { + t.Fatal(err) + } + if res.Stop != StopMaxTokens || res.Text != "partial" || thought != "hmm " { + t.Fatalf("stop=%s text=%q thought=%q", res.Stop, res.Text, thought) + } + if res.Usage != (Usage{Input: 120, Output: 30, CacheRead: 100}) { + t.Fatalf("usage = %+v", res.Usage) + } + b := bodies[0] + if b["reasoning_effort"] != "high" || b["max_completion_tokens"] != float64(2048) { + t.Fatalf("body = %v", b) + } + if _, ok := b["stream_options"]; ok { + t.Fatal("stream_options sent to groq") + } +} + +func TestOpenAICompatUsesMaxTokens(t *testing.T) { + o := &openai{model: "qwen3:32b", base: "http://localhost:11434", name: "openai-compat"} + b := o.body(&Request{Model: "qwen3:32b", MaxTok: 100}) + if b["max_tokens"] != 100 || b["max_completion_tokens"] != nil || b["reasoning_effort"] != nil { + t.Fatalf("body = %v", b) + } +} + +func TestOpenAIErrorStatusSurfaced(t *testing.T) { + noSleep(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + io.WriteString(w, `{"error":{"message":"bad key"}}`) + })) + defer srv.Close() + o := &openai{name: "groq", base: srv.URL, hc: srv.Client()} + _, err := o.Chat(context.Background(), &Request{Model: "m"}) + if err == nil || !strings.Contains(err.Error(), "bad key") || !strings.Contains(err.Error(), "401") { + t.Fatalf("err = %v", err) + } +} + +func TestGeminiEffortUsageAndStatus(t *testing.T) { + var bodies []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var b map[string]any + _ = json.NewDecoder(r.Body).Decode(&b) + bodies = append(bodies, b) + io.WriteString(w, `{"status":"incomplete","output_text":"cut","steps":[{"type":"thought","signature":"s","summary":[{"type":"text","text":"plan"}]},{"type":"model_output","content":[{"type":"text","text":"cut"}]}], + "usage":{"total_input_tokens":50,"total_cached_tokens":20,"total_output_tokens":5,"total_thought_tokens":7}}`) + })) + defer srv.Close() + g := &gemini{model: "gemini-x", base: srv.URL, hc: srv.Client()} + res, err := g.Chat(context.Background(), &Request{Model: "gemini-x", Effort: "xhigh", MaxTok: 900}) + if err != nil { + t.Fatal(err) + } + if res.Stop != StopMaxTokens || res.Thinking != "plan" || res.Usage != (Usage{Input: 50, Output: 12, CacheRead: 20}) { + t.Fatalf("res = %+v", res) + } + gc, _ := bodies[0]["generation_config"].(map[string]any) + if gc["thinking_level"] != "high" || gc["max_output_tokens"] != float64(900) { + t.Fatalf("generation_config = %v", gc) + } +} + +func TestSystemFrozenSnapshotRefreshAppended(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 fmt.Sprintf("LIVE: height=%d", n) } + a.SnapshotTTL = time.Nanosecond + a.Run(context.Background(), "one") + time.Sleep(time.Millisecond) + a.Run(context.Background(), "two") + if prov.reqs[0].System != prov.reqs[1].System { + t.Fatal("system prompt changed between turns — breaks the cached prefix") + } + if !strings.Contains(prov.reqs[0].System, "height=1") { + t.Fatalf("first snapshot missing from system: %q", prov.reqs[0].System) + } + last := prov.reqs[1].Messages[len(prov.reqs[1].Messages)-1].Text + if !strings.Contains(last, "") || !strings.Contains(last, "height=2") || !strings.HasSuffix(last, "two") { + t.Fatalf("refresh not appended to the user turn: %q", last) + } + // earlier messages are untouched (append-only history) + if prov.reqs[1].Messages[0].Text != prov.reqs[0].Messages[0].Text { + t.Fatal("earlier turn edited") + } +} + +func TestMaxTokensAutoContinues(t *testing.T) { + prov := &mockProvider{responses: []*Response{ + {Text: "first half ", Stop: StopMaxTokens}, + {Text: "second half", Stop: StopEnd, Done: true}, + }} + a := newTestAgent(t, prov) + var notices []string + a.OnEvent = func(e Event) { + if e.Kind == EvNotice { + notices = append(notices, e.Text) + } + } + out, err := a.Run(context.Background(), "write it") + if err != nil { + t.Fatal(err) + } + if out != "first half second half" || prov.calls != 2 || len(notices) != 1 { + t.Fatalf("out=%q calls=%d notices=%v", out, prov.calls, notices) + } +} + +func TestRefusalSurfacesAsError(t *testing.T) { + prov := &mockProvider{responses: []*Response{{Stop: StopRefusal, StopDetail: "cyber"}}} + a := newTestAgent(t, prov) + _, err := a.Run(context.Background(), "x") + if err == nil || !strings.Contains(err.Error(), "cyber") { + t.Fatalf("err = %v", err) + } +} + +func TestUsageAccumulates(t *testing.T) { + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "c1", Name: "node__status", Args: json.RawMessage(`{}`)}}, Usage: Usage{Input: 100, Output: 10}}, + {Text: "ok", Done: true, Usage: Usage{Input: 150, Output: 5, CacheRead: 100}}, + }} + tool := stubTool{name: "node.status", tier: toolkit.TierObserve, run: func(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { + return &toolkit.Result{Text: "h"}, nil + }} + a := newTestAgent(t, prov, tool) + a.Run(context.Background(), "x") + tot, last := a.Usage() + if tot != (Usage{Input: 250, Output: 15, CacheRead: 100}) || last != 155 { + t.Fatalf("total=%+v last=%d", tot, last) + } +} + +// compactProvider answers summarizer requests with a summary and records +// what each normal request carried. +type compactProvider struct { + mockProvider + overflowOnce bool +} + +func (c *compactProvider) Chat(ctx context.Context, r *Request) (*Response, error) { + last := r.Messages[len(r.Messages)-1].Text + if strings.Contains(last, "Summarize this conversation") { + return &Response{Text: "SUMMARY: disk was 95%", Done: true}, nil + } + if c.overflowOnce { + c.overflowOnce = false + return nil, errors.New("anthropic: prompt is too long: 1000001 tokens > 1000000 maximum (HTTP 400)") + } + return c.mockProvider.Chat(ctx, r) +} + +func TestAutoCompactAtTurnBoundary(t *testing.T) { + prov := &compactProvider{mockProvider: mockProvider{responses: []*Response{ + {Text: "one", Done: true, Usage: Usage{Input: 900, Output: 200}}, + {Text: "two", Done: true}, + }}} + a := newTestAgent(t, prov) + a.CompactAt = 1000 + a.Run(context.Background(), "check disk") + a.Run(context.Background(), "and now?") + req := prov.lastReq + if len(req.Messages) != 1 { + t.Fatalf("history not replaced by summary: %d messages", len(req.Messages)) + } + m := req.Messages[0].Text + if !strings.Contains(m, "SUMMARY: disk was 95%") || !strings.HasSuffix(m, "and now?") { + t.Fatalf("summary not attached to next turn: %q", m) + } +} + +func TestContextOverflowCompactsAndRetries(t *testing.T) { + prov := &compactProvider{overflowOnce: true, mockProvider: mockProvider{responses: []*Response{{Text: "recovered", Done: true}}}} + a := newTestAgent(t, prov) + a.history = []Msg{{Role: "user", Text: "old"}, {Role: "assistant", Text: "old reply"}} + out, err := a.Run(context.Background(), "go") + if err != nil || out != "recovered" { + t.Fatalf("out=%q err=%v", out, err) + } + if !strings.Contains(prov.lastReq.Messages[0].Text, "Continue the task in progress") { + t.Fatalf("mid-turn summary missing: %q", prov.lastReq.Messages[0].Text) + } +} + +func TestToolSetChangeDropsBoundThinking(t *testing.T) { + prov := &mockProvider{} + ro := stubTool{name: "node.status", tier: toolkit.TierObserve, run: func(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { return &toolkit.Result{}, nil }} + rw := stubTool{name: "node.restart", tier: toolkit.TierLocalChange, run: func(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { return &toolkit.Result{}, nil }} + a := newTestAgent(t, prov, ro, rw) + a.Run(context.Background(), "one") + a.history[1].RawSteps, a.history[1].RawProvider = json.RawMessage(`[{"type":"thinking","signature":"s"}]`), "anthropic" + a.Policy.Mode = ModeReadOnly // hides node.restart → tool set changes + a.Run(context.Background(), "two") + if a.history[1].RawSteps != nil { + t.Fatal("thinking bound to the old tool set was replayed") + } +} + +func TestSessionSaveRestoreRoundTrip(t *testing.T) { + prov := &mockProvider{responses: []*Response{{Text: "hello", Done: true, Usage: Usage{Input: 10, Output: 2}}}} + a := newTestAgent(t, prov) + a.Persist = true + if _, err := a.Run(context.Background(), "first question"); err != nil { + t.Fatal(err) + } + list, err := ListSessions() + if err != nil || len(list) != 1 || list[0].Title != "first question" || list[0].History != nil { + t.Fatalf("list = %+v err=%v", list, err) + } + sf, err := LoadSession(a.ID()[:12]) + if err != nil { + t.Fatal(err) + } + b := newTestAgent(t, &mockProvider{}) + b.Restore(sf) + if b.ID() != a.ID() || len(b.History()) != 2 || b.system() != a.system() { + t.Fatalf("restore lost state: id=%s hist=%d", b.ID(), len(b.History())) + } + if tot, _ := b.Usage(); tot.Input != 10 { + t.Fatalf("usage not restored: %+v", tot) + } + if _, err := LoadSession("../etc/passwd"); err == nil { + t.Fatal("path traversal accepted") + } +} + +func TestClipKeepsHeadTailAndUTF8(t *testing.T) { + s := strings.Repeat("é", 5000) + "TAIL-ERROR" + out := clip(s, 1000) + if !utf8.ValidString(out) || !strings.HasSuffix(out, "TAIL-ERROR") || len(out) > 1100 { + t.Fatalf("clip: valid=%v len=%d tail=%q", utf8.ValidString(out), len(out), out[len(out)-12:]) + } + if clip("short", 1000) != "short" { + t.Fatal("short input altered") + } +} + +func TestValidEffort(t *testing.T) { + for _, e := range []string{"", "low", "medium", "high", "xhigh", "max"} { + if ValidEffort(e) != nil { + t.Fatalf("%q rejected", e) + } + } + if ValidEffort("ultra") == nil { + t.Fatal("bad effort accepted") + } +} + +func TestRetryIsAnnouncedAndTimeoutsAreNot(t *testing.T) { + noSleep(t) + var n atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if n.Add(1) == 1 { + w.WriteHeader(503) + return + } + io.WriteString(w, "ok") + })) + defer srv.Close() + var notes []string + ctx := WithRetryNotice(context.Background(), func(attempt int, wait time.Duration, reason string) { + notes = append(notes, fmt.Sprintf("%d %s", attempt, reason)) + }) + resp, err := doHTTP(ctx, srv.Client(), srv.URL, []byte(`{}`), nil) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if len(notes) != 1 || notes[0] != "1 HTTP 503" { + t.Fatalf("notices = %v", notes) + } + + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n.Add(1) + time.Sleep(200 * time.Millisecond) + })) + defer slow.Close() + n.Store(0) + hc := &http.Client{Timeout: 20 * time.Millisecond} + if _, err := doHTTP(context.Background(), hc, slow.URL, []byte(`{}`), nil); err == nil { + t.Fatal("want timeout error") + } + if n.Load() != 1 { + t.Fatalf("timed-out request retried: %d attempts", n.Load()) + } +} diff --git a/internal/agent/provider.go b/internal/agent/provider.go index f060176..8a3a8d7 100644 --- a/internal/agent/provider.go +++ b/internal/agent/provider.go @@ -26,9 +26,11 @@ type Msg struct { ToolName string // for role=tool IsError bool // tool result flagged error JSONArgs json.RawMessage // for role=assistant with calls - // RawSteps carries a provider's verbatim turn steps (Gemini Interactions - // echoes them back in stateless mode, preserving thought signatures). - RawSteps json.RawMessage + // RawSteps carries a provider's verbatim turn output (Gemini steps with + // thought signatures, Anthropic content blocks with thinking). It is + // replayed unchanged, but only to the provider named in RawProvider. + RawSteps json.RawMessage + RawProvider string `json:",omitempty"` } // Call is a tool invocation requested by the model. @@ -51,7 +53,38 @@ type Request struct { System string Messages []Msg Tools []ToolDef - MaxTok int + // MaxTok caps output tokens; 0 lets the provider pick its default. + MaxTok int + // Effort is the reasoning depth: low | medium | high | xhigh | max, or + // "" for the model default. Each provider maps it to its own knob. + Effort string + // OnThinking, when set, receives streamed reasoning text (providers + // that expose none never call it). + OnThinking func(string) `json:"-"` +} + +// StopReason is why a model round ended, normalized across providers. +type StopReason string + +const ( + StopEnd StopReason = "end" // finished normally + StopToolUse StopReason = "tool_use" // wants tool results + StopMaxTokens StopReason = "max_tokens" // output cut off at MaxTok + StopRefusal StopReason = "refusal" // declined by a safety filter +) + +// Usage is the token accounting of one model round. +type Usage struct { + Input int `json:"input"` // prompt tokens, including cached + Output int `json:"output"` // generated tokens, including thinking + CacheRead int `json:"cache_read"` // prompt tokens served from cache +} + +// Add accumulates u2 into u. +func (u *Usage) Add(u2 Usage) { + u.Input += u2.Input + u.Output += u2.Output + u.CacheRead += u2.CacheRead } // Response is the model's reply. @@ -59,7 +92,16 @@ type Response struct { Text string Calls []Call Done bool // true when the model finished (no tool calls pending) - // RawSteps is the provider's raw step list for this turn (Gemini only). + // Stop is the normalized stop reason ("" when the provider didn't say). + Stop StopReason + // StopDetail explains a refusal when the provider gives a category. + StopDetail string + // Usage is this round's token accounting (zero when not reported). + Usage Usage + // Thinking is visible reasoning text, when the provider returns any. + Thinking string + // RawSteps is the provider's raw output for this turn, replayed + // verbatim in later requests (see Msg.RawSteps). RawSteps json.RawMessage } @@ -107,7 +149,7 @@ func NewProvider(ac config.AgentConf) (Provider, error) { case "anthropic", "claude": return &anthropic{ key: key("ANTHROPIC_API_KEY"), - model: def(ac.Model, "claude-sonnet-4-5"), + model: def(ac.Model, "claude-opus-5-5"), base: def(ac.BaseURL, "https://api.anthropic.com"), }, nil case "openai": diff --git a/internal/agent/repl.go b/internal/agent/repl.go index 7cfb5ac..607ace1 100644 --- a/internal/agent/repl.go +++ b/internal/agent/repl.go @@ -44,11 +44,28 @@ type Printer struct { Out io.Writer MD *glamour.TermRenderer streamed bool + thinking bool } // Handle renders one event. func (p *Printer) Handle(e Event) { + if p.thinking && e.Kind != EvThinking { + fmt.Fprintln(p.Out) + p.thinking = false + } switch e.Kind { + case EvThinking: + if !p.thinking { + fmt.Fprint(p.Out, toolSt.Render("∴ ")) + p.thinking = true + } + fmt.Fprint(p.Out, toolSt.Render(e.Text)) + case EvNotice: + if p.streamed { + fmt.Fprintln(p.Out) + p.streamed = false + } + fmt.Fprintf(p.Out, "%s %s\n", toolSt.Render("·"), toolSt.Render(e.Text)) case EvDelta: p.streamed = true fmt.Fprint(p.Out, e.Text) @@ -155,6 +172,13 @@ func (r *REPL) slash(cmd string) (prompt string, quit bool) { if res.Text != "" { fmt.Fprintln(r.Out, toolSt.Render(res.Text)) } + if res.Job != nil { + if txt, err := res.Job(r.Agent.Ctx); err != nil { + fmt.Fprintln(r.Out, errSt.Render(err.Error())) + } else if txt != "" { + fmt.Fprintln(r.Out, toolSt.Render(txt)) + } + } } return res.Prompt, false } @@ -175,7 +199,7 @@ func CompactArgs(args map[string]any) string { } s := strings.Join(parts, " ") if len(s) > 100 { - s = s[:100] + "…" + s = safeCut(s, 100) + "…" } return s } diff --git a/internal/agent/retry.go b/internal/agent/retry.go new file mode 100644 index 0000000..70a27ec --- /dev/null +++ b/internal/agent/retry.go @@ -0,0 +1,172 @@ +package agent + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math/rand/v2" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// RetryNotice is told about each retry so front-ends can show it — a +// silent backoff looks exactly like a hang. +type RetryNotice func(attempt int, wait time.Duration, reason string) + +type retryNoticeKey struct{} + +// WithRetryNotice attaches a retry observer to ctx. +func WithRetryNotice(ctx context.Context, fn RetryNotice) context.Context { + return context.WithValue(ctx, retryNoticeKey{}, fn) +} + +// debugOut receives one line per HTTP attempt when COMETCLI_DEBUG is set. +var debugOut io.Writer = os.Stderr + +func debugf(format string, args ...any) { + if v := os.Getenv("COMETCLI_DEBUG"); v != "" && v != "0" { + fmt.Fprintf(debugOut, "[debug] "+format+"\n", args...) + } +} + +// retryPolicy bounds how hard doHTTP tries before surfacing an error. +type retryPolicy struct { + Max int // retries after the first attempt + Base time.Duration // first backoff + Cap time.Duration // longest single wait +} + +var defaultRetry = retryPolicy{Max: 4, Base: time.Second, Cap: 30 * time.Second} + +// sleep is swapped out in tests. +var sleep = func(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +// retryable reports whether a status is worth another attempt: timeouts, +// conflicts, rate limits, and server-side failures (incl. Anthropic 529). +func retryable(code int) bool { + return code == http.StatusRequestTimeout || code == http.StatusConflict || + code == http.StatusTooManyRequests || code >= 500 +} + +// doHTTP sends a JSON POST, retrying connection errors and retryable +// statuses with jittered exponential backoff, honoring Retry-After. It +// only retries before a body is read, so a stream is never replayed +// half-consumed. The returned response is the caller's to close. +func doHTTP(ctx context.Context, hc *http.Client, url string, body []byte, hdr map[string]string) (*http.Response, error) { + return doHTTPWith(ctx, hc, url, body, hdr, defaultRetry) +} + +func doHTTPWith(ctx context.Context, hc *http.Client, url string, body []byte, hdr map[string]string, rp retryPolicy) (*http.Response, error) { + for attempt := 0; ; attempt++ { + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("content-type", "application/json") + for k, v := range hdr { + if v != "" { + req.Header.Set(k, v) + } + } + start := time.Now() + resp, err := hc.Do(req) + var wait time.Duration + var reason string + switch { + case err != nil: + debugf("POST %s attempt %d: %v (%s)", redactURL(url), attempt+1, err, time.Since(start).Round(time.Millisecond)) + if ctx.Err() != nil || !transient(err) || attempt >= rp.Max { + return nil, err + } + reason = "connection error" + case retryable(resp.StatusCode) && attempt < rp.Max: + debugf("POST %s attempt %d: HTTP %d (%s)", redactURL(url), attempt+1, resp.StatusCode, time.Since(start).Round(time.Millisecond)) + wait = retryAfter(resp.Header) + reason = fmt.Sprintf("HTTP %d", resp.StatusCode) + resp.Body.Close() + default: + debugf("POST %s attempt %d: HTTP %d, headers in %s", redactURL(url), attempt+1, resp.StatusCode, time.Since(start).Round(time.Millisecond)) + return resp, nil + } + if wait == 0 { + wait = backoff(rp, attempt) + } + if wait > rp.Cap { + wait = rp.Cap + } + if fn, ok := ctx.Value(retryNoticeKey{}).(RetryNotice); ok && fn != nil { + fn(attempt+1, wait, reason) + } + if err := sleep(ctx, wait); err != nil { + return nil, err + } + } +} + +// transient reports whether a transport error may succeed on retry: +// refused/reset connections and DNS hiccups. Timeouts are not retried — +// the client timeout is already minutes long, and repeating it would +// turn one slow request into a silent multi-minute hang. +func transient(err error) bool { + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return false + } + var oe *net.OpError + var de *net.DNSError + return errors.As(err, &oe) || errors.As(err, &de) || errors.Is(err, net.ErrClosed) || errors.Is(err, io.ErrUnexpectedEOF) +} + +// redactURL drops the query string (some APIs take keys there). +func redactURL(u string) string { + if i := strings.IndexByte(u, '?'); i >= 0 { + return u[:i] + } + return u +} + +func backoff(rp retryPolicy, attempt int) time.Duration { + d := rp.Base << attempt + if d <= 0 || d > rp.Cap { + d = rp.Cap + } + // full jitter in [d/2, d) + return d/2 + time.Duration(rand.Int64N(int64(d/2)+1)) +} + +// retryAfter reads retry-after-ms, then Retry-After (seconds or HTTP date). +func retryAfter(h http.Header) time.Duration { + if v := h.Get("retry-after-ms"); v != "" { + if ms, err := strconv.ParseFloat(v, 64); err == nil && ms > 0 { + return time.Duration(ms * float64(time.Millisecond)) + } + } + v := h.Get("Retry-After") + if v == "" { + return 0 + } + if s, err := strconv.ParseFloat(v, 64); err == nil && s > 0 { + return time.Duration(s * float64(time.Second)) + } + if t, err := http.ParseTime(v); err == nil { + if d := time.Until(t); d > 0 { + return d + } + } + return 0 +} diff --git a/internal/agent/session.go b/internal/agent/session.go new file mode 100644 index 0000000..195b944 --- /dev/null +++ b/internal/agent/session.go @@ -0,0 +1,206 @@ +package agent + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/abhijitkrm/cometcli/internal/config" +) + +// SessionFile is a saved conversation, written after every turn to +// ~/.cometcli/sessions/.json (0600). Everything in it already passed +// the redactor on its way to the model. +type SessionFile struct { + Version int `json:"version"` + ID string `json:"id"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Title string `json:"title"` + Cwd string `json:"cwd"` + Profile string `json:"profile,omitempty"` + Provider string `json:"provider"` + Model string `json:"model"` + Usage Usage `json:"usage"` + // System is the frozen system prompt, restored verbatim so a resumed + // session keeps its cached prefix (and replayable thinking) intact. + System string `json:"system"` + ToolSig string `json:"tool_sig,omitempty"` + Carry string `json:"carry,omitempty"` + History []Msg `json:"history"` +} + +const sessionVersion = 1 + +// ErrNoSession is returned when there is nothing to resume. +var ErrNoSession = errors.New("no saved session to resume") + +func sessionDir() (string, error) { + d, err := config.Path("sessions") + if err != nil { + return "", err + } + return d, os.MkdirAll(d, 0o700) +} + +// sessionPath maps an id to its file, rejecting anything path-like. +func sessionPath(id string) (string, error) { + if id == "" || strings.ContainsAny(id, `/\`) || strings.Contains(id, "..") { + return "", fmt.Errorf("invalid session id %q", id) + } + d, err := sessionDir() + if err != nil { + return "", err + } + return filepath.Join(d, id+".json"), nil +} + +// Save writes the session atomically. A session with no turns is skipped. +func (a *Agent) Save() error { + if len(a.history) == 0 && a.carry == "" { + return nil + } + p, err := sessionPath(a.ID()) + if err != nil { + return err + } + cwd, _ := os.Getwd() + sf := SessionFile{ + Version: sessionVersion, ID: a.ID(), Created: a.created, Updated: time.Now(), + Title: a.title(), Cwd: cwd, Profile: a.profileName(), + Provider: a.Provider.Name(), Model: a.Model, Usage: a.total, + System: a.sys, ToolSig: a.toolSig, Carry: a.carry, History: a.history, + } + raw, err := json.Marshal(sf) + if err != nil { + return err + } + tmp := p + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return os.Rename(tmp, p) +} + +// title is the first user prompt, minus injected prefixes, on one line. +func (a *Agent) title() string { + for _, m := range a.history { + if m.Role != "user" { + continue + } + t := m.Text + for _, tag := range []string{"", ""} { + if i := strings.LastIndex(t, tag); i >= 0 { + t = t[i+len(tag):] + } + } + t = strings.Join(strings.Fields(t), " ") + if len(t) > 80 { + t = safeCut(t, 80) + "…" + } + if t != "" { + return t + } + } + return "(compacted session)" +} + +// Restore loads a saved session into the agent. The conversation is kept +// even if the provider or model changed since; raw output from another +// provider is simply not replayed. +func (a *Agent) Restore(sf *SessionFile) { + a.Reset() + a.id, a.created = sf.ID, sf.Created + a.history, a.sys, a.toolSig, a.carry = sf.History, sf.System, sf.ToolSig, sf.Carry + a.total = sf.Usage + // the frozen system prompt carries the old snapshot: force a refresh + // onto the next turn + a.snapAt = time.Time{} +} + +// LoadSession reads one saved session by id (a unique prefix works). +func LoadSession(id string) (*SessionFile, error) { + p, err := sessionPath(id) + if err != nil { + return nil, err + } + raw, err := os.ReadFile(p) + if errors.Is(err, os.ErrNotExist) { + all, lerr := ListSessions() + if lerr != nil { + return nil, lerr + } + var hit *SessionFile + for _, s := range all { + if strings.HasPrefix(s.ID, id) { + if hit != nil { + return nil, fmt.Errorf("session id %q is ambiguous", id) + } + hit = s + } + } + if hit == nil { + return nil, fmt.Errorf("no session %q", id) + } + return LoadSession(hit.ID) + } + if err != nil { + return nil, err + } + var sf SessionFile + if err := json.Unmarshal(raw, &sf); err != nil { + return nil, fmt.Errorf("session %s: %w", id, err) + } + return &sf, nil +} + +// ListSessions returns saved sessions, most recently updated first, with +// History omitted. +func ListSessions() ([]*SessionFile, error) { + d, err := sessionDir() + if err != nil { + return nil, err + } + ents, err := os.ReadDir(d) + if err != nil { + return nil, err + } + var out []*SessionFile + for _, e := range ents { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + raw, err := os.ReadFile(filepath.Join(d, e.Name())) + if err != nil { + continue + } + var sf SessionFile + if json.Unmarshal(raw, &sf) != nil || sf.ID == "" { + continue + } + sf.History, sf.System = nil, "" + out = append(out, &sf) + } + sort.Slice(out, func(i, j int) bool { return out[i].Updated.After(out[j].Updated) }) + return out, nil +} + +// LatestSession returns the most recent session started in cwd (and on +// profile, when set) — what `--continue` resumes. +func LatestSession(cwd, profile string) (*SessionFile, error) { + all, err := ListSessions() + if err != nil { + return nil, err + } + for _, s := range all { + if s.Cwd == cwd && (profile == "" || s.Profile == profile) { + return LoadSession(s.ID) + } + } + return nil, ErrNoSession +} diff --git a/internal/cli/agent.go b/internal/cli/agent.go index 61a83e7..f6e78d2 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -13,7 +13,18 @@ func agentFlags(cmd *cobra.Command) { 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)") + cmd.Flags().Int("max-iter", 0, "max model iterations per turn (default 50)") + sessionFlags(cmd) +} + +// sessionFlags wires model and session options shared by every agent +// front-end, bare `cometcli` included. +func sessionFlags(cmd *cobra.Command) { + cmd.Flags().BoolP("continue", "c", false, "resume the most recent session started in this directory") + cmd.Flags().StringP("resume", "r", "", "resume a saved `id` (list them with: cometcli sessions)") + cmd.Flags().String("model", "", "model for this session (overrides the profile)") + cmd.Flags().String("effort", "", "reasoning depth: low | medium | high | xhigh | max") + cmd.Flags().Int("max-tokens", 0, "max output tokens per model round") } // AgentCmd opens the agentic REPL. Implemented in internal/agent. diff --git a/internal/cli/agent_impl.go b/internal/cli/agent_impl.go index c4fb091..268c85a 100644 --- a/internal/cli/agent_impl.go +++ b/internal/cli/agent_impl.go @@ -1,6 +1,9 @@ package cli import ( + "fmt" + "os" + "github.com/spf13/cobra" "github.com/abhijitkrm/cometcli/internal/agent" @@ -20,9 +23,13 @@ func runAgentImpl(cmd *cobra.Command, reg *toolkit.Registry, oneshot string) err if err != nil { return err } - if err := applyAgentFlags(cmd, a); err != nil { + note, err := applyAgentFlags(cmd, a) + if err != nil { return err } + if note != "" { + fmt.Fprintln(cmd.ErrOrStderr(), note) + } if oneshot != "" { pr := &agent.Printer{Out: c.Out} a.OnEvent = pr.Handle @@ -33,13 +40,15 @@ func runAgentImpl(cmd *cobra.Command, reg *toolkit.Registry, oneshot string) err 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 { +// applyAgentFlags layers the agent and session flags over the profile's +// agent defaults, turns on session saving, and resumes a session when +// asked (--continue / --resume). Flags a command doesn't define read as +// unset. The returned note describes a resumed session. +func applyAgentFlags(cmd *cobra.Command, a *agent.Agent) (string, error) { if m, _ := cmd.Flags().GetString("mode"); m != "" { mode, err := agent.ParseMode(m) if err != nil { - return err + return "", err } a.Policy.Mode = mode } @@ -49,7 +58,7 @@ func applyAgentFlags(cmd *cobra.Command, a *agent.Agent) error { if tiers, _ := cmd.Flags().GetStringSlice("autopilot"); len(tiers) > 0 { for _, t := range tiers { if err := a.Policy.SetAutopilot(t, true); err != nil { - return err + return "", err } } } @@ -66,5 +75,45 @@ func applyAgentFlags(cmd *cobra.Command, a *agent.Agent) error { // hard-refuse anything that slips past filtering a.Ctx.Approver = toolkit.DenyApprover } - return nil + if m, _ := cmd.Flags().GetString("model"); m != "" { + a.Model = m + } + if e, _ := cmd.Flags().GetString("effort"); e != "" { + if err := agent.ValidEffort(e); err != nil { + return "", err + } + a.Effort = e + } + if n, _ := cmd.Flags().GetInt("max-tokens"); n > 0 { + a.MaxTokens = n + } + a.Persist = true + return resumeSession(cmd, a) +} + +// resumeSession loads the session named by --resume, or the latest one in +// this directory for --continue. +func resumeSession(cmd *cobra.Command, a *agent.Agent) (string, error) { + id, _ := cmd.Flags().GetString("resume") + cont, _ := cmd.Flags().GetBool("continue") + var sf *agent.SessionFile + var err error + switch { + case id != "": + sf, err = agent.LoadSession(id) + case cont: + cwd, _ := os.Getwd() + profile := "" + if a.Ctx.Profile != nil { + profile = a.Ctx.Profile.Name + } + sf, err = agent.LatestSession(cwd, profile) + default: + return "", nil + } + if err != nil { + return "", err + } + a.Restore(sf) + return fmt.Sprintf("resumed session %s — %s (%d messages)", sf.ID, sf.Title, len(sf.History)), nil } diff --git a/internal/cli/root.go b/internal/cli/root.go index 365fb3d..b8ee581 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,6 +7,7 @@ import ( "bufio" "fmt" "io" + "os" "sort" "strings" "time" @@ -55,11 +56,18 @@ ask "..."' for one-shot questions, or 'cometcli serve' for a local web chat.`, pf.StringVar(&flagProfile, "profile", "", "profile to use (env COMETCLI_PROFILE)") pf.BoolVar(&flagJSON, "json", false, "emit structured JSON") pf.BoolVarP(&flagYes, "yes", "y", false, "auto-approve observe/diagnose prompts (never on-chain)") + sessionFlags(root) + pf.Bool("debug", false, "log every LLM API attempt (status, timing, retries) to stderr") + root.PersistentPreRun = func(cmd *cobra.Command, _ []string) { + if d, _ := cmd.Flags().GetBool("debug"); d { + _ = os.Setenv("COMETCLI_DEBUG", "1") + } + } for _, c := range toolGroupCommands(reg) { root.AddCommand(c) } - root.AddCommand(profileCmd(), auditCmd(), versionCmd(), initCmd()) + root.AddCommand(profileCmd(), auditCmd(), versionCmd(), initCmd(), sessionsCmd()) root.AddCommand(extra...) return root } diff --git a/internal/cli/serve.go b/internal/cli/serve.go index abed279..a3368cf 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -36,7 +36,7 @@ page. To reach it from another machine, tunnel it: ssh -L 8765:127.0.0.1:8765 ho defer c.Audit.Close() a, agentErr := agent.New(c, reg) if agentErr == nil { - if err := applyAgentFlags(cmd, a); err != nil { + if _, err := applyAgentFlags(cmd, a); err != nil { return err } } diff --git a/internal/cli/sessions.go b/internal/cli/sessions.go new file mode 100644 index 0000000..5d2fb89 --- /dev/null +++ b/internal/cli/sessions.go @@ -0,0 +1,48 @@ +package cli + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/abhijitkrm/cometcli/internal/agent" +) + +// sessionsCmd lists saved agent sessions for --resume. +func sessionsCmd() *cobra.Command { + var all bool + cmd := &cobra.Command{ + Use: "sessions", + Short: "List saved agent sessions (resume with -r or -c)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + list, err := agent.ListSessions() + if err != nil { + return err + } + if flagJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(list) + } + if len(list) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no saved sessions") + return nil + } + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tUPDATED\tPROFILE\tMODEL\tTITLE") + for i, s := range list { + if i == 20 && !all { + fmt.Fprintf(tw, "… %d more (--all)\t\t\t\t\n", len(list)-20) + break + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", s.ID, s.Updated.Local().Format("Jan 02 15:04"), s.Profile, s.Model, s.Title) + } + return tw.Flush() + }, + } + cmd.Flags().BoolVar(&all, "all", false, "list every session") + return cmd +} diff --git a/internal/cli/ui.go b/internal/cli/ui.go index ce8362a..d118497 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" + "github.com/abhijitkrm/cometcli/internal/agent" "github.com/abhijitkrm/cometcli/internal/toolkit" "github.com/abhijitkrm/cometcli/internal/tui" ) @@ -27,6 +28,7 @@ func UICmd(reg *toolkit.Registry) *cobra.Command { }, } cmd.Flags().IntVar(&interval, "interval", 5, "refresh interval seconds") + sessionFlags(cmd) return cmd } @@ -46,5 +48,7 @@ func runUI(cmd *cobra.Command, reg *toolkit.Registry, interval time.Duration) er } defer c.Close() defer c.Audit.Close() - return tui.RunApp(c, reg, interval) + return tui.RunApp(c, reg, interval, func(a *agent.Agent) (string, error) { + return applyAgentFlags(cmd, a) + }) } diff --git a/internal/config/config.go b/internal/config/config.go index c502c88..e361078 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -114,6 +114,18 @@ type AgentConf struct { RedactEndpoints bool `yaml:"redact_endpoints,omitempty"` // NoStream disables token streaming for endpoints that mishandle it. NoStream bool `yaml:"no_stream,omitempty"` + // Effort is the reasoning depth: low | medium | high | xhigh | max + // (empty = model default). Mapped to each provider's own control. + Effort string `yaml:"effort,omitempty"` + // MaxTokens caps output tokens per model round (0 = provider default). + MaxTokens int `yaml:"max_tokens,omitempty"` + // MaxTurns bounds model rounds per prompt (0 = 50). + MaxTurns int `yaml:"max_turns,omitempty"` + // ContextWindow overrides the model's context size in tokens. + ContextWindow int `yaml:"context_window,omitempty"` + // CompactAt is the prompt size in tokens that triggers summarizing + // the conversation (0 = 80% of the window, at most 200k). + CompactAt int `yaml:"compact_at,omitempty"` } // Alerts configures notification sinks for the monitor. diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 2cf1433..62fa9d0 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -378,5 +378,13 @@ func (s *Server) command(w http.ResponseWriter, r *http.Request) { writeJSON(w, code, map[string]string{"error": err.Error()}) return } + if res.Job != nil { + txt, err := res.Job(r.Context()) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + res.Text = txt + } writeJSON(w, http.StatusOK, map[string]string{"text": res.Text, "prompt": res.Prompt}) } diff --git a/internal/tui/app.go b/internal/tui/app.go index 8a8c4cf..d4135fc 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -7,11 +7,13 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/abhijitkrm/cometcli/internal/agent" "github.com/abhijitkrm/cometcli/internal/config" "github.com/abhijitkrm/cometcli/internal/monitor" "github.com/abhijitkrm/cometcli/internal/toolkit" @@ -94,13 +96,17 @@ type AppModel struct { quitting bool } +// AgentSetup adjusts the chat agent after it is built (CLI flags, +// session resume). The returned text, if any, is shown in the transcript. +type AgentSetup func(*agent.Agent) (string, error) + // NewApp creates the app model. -func NewApp(c *toolkit.Context, reg *toolkit.Registry, interval time.Duration) *AppModel { +func NewApp(c *toolkit.Context, reg *toolkit.Registry, interval time.Duration, setup ...AgentSetup) *AppModel { vp := viewport.New(80, 20) appr := &tuiApprover{req: make(chan approvalReq)} return &AppModel{ c: c, reg: reg, interval: interval, vp: vp, - chat: newChatPane(c, reg, appr), + chat: newChatPane(c, reg, appr, setup...), approver: appr, txGas: "1e9", } } @@ -304,6 +310,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.chat.append("info", v.text) } m.syncChatView() + case jobDoneMsg: + m.chat.busy = false + if v.err != nil { + m.chat.append("err", v.err.Error()) + } else if v.text != "" { + m.chat.append("info", v.text) + } + m.syncChatView() case evAgent: m.onAgentEvent(v.e) return m, m.waitEvent() @@ -555,14 +569,18 @@ func firstLine(s string) string { s = s[:i] } if len(s) > 120 { - s = s[:120] + "…" + n := 120 + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + s = s[:n] + "…" } return s } // RunApp starts the multi-pane TUI. -func RunApp(c *toolkit.Context, reg *toolkit.Registry, interval time.Duration) error { - p := tea.NewProgram(NewApp(c, reg, interval), tea.WithAltScreen()) +func RunApp(c *toolkit.Context, reg *toolkit.Registry, interval time.Duration, setup ...AgentSetup) error { + p := tea.NewProgram(NewApp(c, reg, interval, setup...), tea.WithAltScreen()) _, err := p.Run() return err } diff --git a/internal/tui/chat.go b/internal/tui/chat.go index 1da7e97..e209af9 100644 --- a/internal/tui/chat.go +++ b/internal/tui/chat.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "unicode/utf8" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" @@ -30,6 +31,9 @@ type chatPane struct { // streaming: index of the block being filled by deltas (-1 = none) live int liveText string + // thinking: index of the reasoning block being streamed (-1 = none) + think int + thinkText string md *glamour.TermRenderer mdW int @@ -41,6 +45,13 @@ type evDone struct { text string err error } + +// jobDoneMsg reports a slash command's background job (e.g. /compact). +type jobDoneMsg struct { + text string + err error +} + type runResMsg struct { name, text string err error @@ -54,7 +65,7 @@ var ( approveSt = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) ) -func newChatPane(c *toolkit.Context, reg *toolkit.Registry, appr *tuiApprover) *chatPane { +func newChatPane(c *toolkit.Context, reg *toolkit.Registry, appr *tuiApprover, setup ...AgentSetup) *chatPane { ta := textarea.New() ta.Placeholder = "ask about your node, or /help" ta.Prompt = "❯ " @@ -64,7 +75,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), live: -1} + p := &chatPane{ta: ta, events: make(chan tea.Msg), live: -1, think: -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{ @@ -78,7 +89,17 @@ func newChatPane(c *toolkit.Context, reg *toolkit.Registry, appr *tuiApprover) * } else { ag.OnEvent = func(e agent.Event) { p.push(evAgent{e}) } p.agent = ag + var notes []string + for _, fn := range setup { + note, err := fn(ag) + if err != nil { + notes = append(notes, errStl.Render(err.Error())) + } else if note != "" { + notes = append(notes, dimSt.Render(note)) + } + } p.blocks = append(p.blocks, dimSt.Render(fmt.Sprintf("%s/%s · %s · session %s", ag.Provider.Name(), ag.Model, ag.Policy, ag.ID()))) + p.blocks = append(p.blocks, notes...) } return p } @@ -221,6 +242,14 @@ anything else is sent to the agent — "why is disk high", "unjail", "send 1uato p.append("info", res.Text) m.syncChatView() } + if res.Job != nil { + p.busy = true + job, ctx := res.Job, m.c.Context + return func() tea.Msg { + txt, err := job(ctx) + return jobDoneMsg{txt, err} + } + } if res.Prompt != "" { p.append("user", res.Prompt) m.syncChatView() @@ -233,7 +262,26 @@ anything else is sent to the agent — "why is disk high", "unjail", "send 1uato // onAgentEvent renders one streamed agent event into the transcript. func (m *AppModel) onAgentEvent(e agent.Event) { p := m.chat + if e.Kind != agent.EvThinking { + p.think, p.thinkText = -1, "" + } switch e.Kind { + case agent.EvThinking: + p.thinkText += e.Text + if p.think < 0 { + p.blocks = append(p.blocks, "") + p.think = len(p.blocks) - 1 + } + t := strings.Join(strings.Fields(p.thinkText), " ") + if len(t) > 240 { // show the tail: what it's thinking about now + t = "…" + t[len(t)-240:] + for len(t) > 3 && !utf8.RuneStart(t[3]) { + t = "…" + t[4:] + } + } + p.blocks[p.think] = dimSt.Render("∴ " + t) + case agent.EvNotice: + p.append("info", e.Text) case agent.EvDelta: p.liveText += e.Text if p.live < 0 { From 1c3f56d8c5fab408419e3a7a759fa99fd2e51231 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 16:53:18 +0530 Subject: [PATCH 02/48] agent: general SRE tools and a permission engine (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools (agent-only: no CLI subcommand, not exported over MCP), all over the profile's host transport so they work locally and over SSH: - bash: persistent working directory, combined output capped head+tail at 30k, timeout (default 2m, max 10m) that kills the process group, stdin closed with non-interactive env - read / write / edit: line-numbered reads, edits require a fresh read and show a diff for approval, file mode preserved - glob / grep: newest-first file search; ripgrep or grep -E, with key files and keyring dirs excluded - web_fetch: HTML reduced to text, each new domain approved, localhost and private addresses fetched directly, cross-host redirects reported - todo_write: a visible checklist (EvTodos), saved with the session Shell classification: every command line is lexed (quotes, &&/||/;/|, redirects, $(…), heredocs, sudo/env/xargs/timeout wrappers, bash -c, docker exec/run) and each simple command classified. Reads auto-run; changes ask; transactions (evmd tx anywhere, cast send, eth_sendRawTransaction, or a script whose body does so) always ask; consensus keys, mnemonics, key ceremonies and state resets are refused. Permissions: allow/ask/deny rules in Claude Code syntax (bash(cmd:*), read(./x/**), web_fetch(domain:x), node.*) from agent.permissions, --allowedTools/--disallowedTools and /allow /ask /deny; deny > ask > allow, and an allow must cover every segment of a compound command. Modes: ops, accept-edits, readonly, bypass (--permission-mode, /mode). Also: Context.Host falls back to the local machine without a profile; clearer denial when there is no terminal to approve on. Co-Authored-By: Claude Opus 5.5 --- docs/COMMANDS.md | 41 +- internal/agent/commands.go | 35 +- internal/agent/events.go | 3 + internal/agent/loop.go | 73 +- internal/agent/phase2_test.go | 138 ++++ internal/agent/policy.go | 32 +- internal/agent/prompt.go | 16 + internal/agent/repl.go | 6 + internal/agent/session.go | 7 + internal/agent/session_test.go | 6 +- internal/agent/todo.go | 85 ++ internal/cli/agent.go | 3 + internal/cli/agent_impl.go | 14 +- internal/cli/root.go | 10 +- internal/client/host/exec.go | 179 +++++ internal/client/host/exec_other.go | 13 + internal/client/host/exec_unix.go | 25 + internal/config/config.go | 10 + internal/mcp/server.go | 7 +- internal/toolkit/context.go | 71 +- internal/toolkit/perm.go | 312 ++++++++ internal/toolkit/perm_test.go | 134 ++++ internal/toolkit/session.go | 81 ++ internal/toolkit/toolkit.go | 37 + internal/tools/fs/fs.go | 334 ++++++++ internal/tools/fs/fs_test.go | 127 +++ internal/tools/fs/search.go | 203 +++++ internal/tools/registry.go | 7 + internal/tools/shell/bash.go | 182 +++++ internal/tools/shell/bash_test.go | 121 +++ internal/tools/shell/classify.go | 1068 +++++++++++++++++++++++++ internal/tools/shell/classify_test.go | 150 ++++ internal/tools/web/fetch.go | 136 ++++ internal/tools/web/fetch_test.go | 49 ++ internal/tui/chat.go | 2 + 35 files changed, 3687 insertions(+), 30 deletions(-) create mode 100644 internal/agent/phase2_test.go create mode 100644 internal/agent/todo.go create mode 100644 internal/client/host/exec.go create mode 100644 internal/client/host/exec_other.go create mode 100644 internal/client/host/exec_unix.go create mode 100644 internal/toolkit/perm.go create mode 100644 internal/toolkit/perm_test.go create mode 100644 internal/toolkit/session.go create mode 100644 internal/tools/fs/fs.go create mode 100644 internal/tools/fs/fs_test.go create mode 100644 internal/tools/fs/search.go create mode 100644 internal/tools/shell/bash.go create mode 100644 internal/tools/shell/bash_test.go create mode 100644 internal/tools/shell/classify.go create mode 100644 internal/tools/shell/classify_test.go create mode 100644 internal/tools/web/fetch.go create mode 100644 internal/tools/web/fetch_test.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index cea1ae7..8f6bb0d 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -218,7 +218,7 @@ saved after each turn to `~/.cometcli/sessions/` (0600, already redacted). | Command | Effect | |---|---| -| `/mode [ops\|readonly]` | show or set the approval posture; readonly hides mutating tools from the model | +| `/mode [ops\|accept-edits\|readonly\|bypass]` | 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 | @@ -228,6 +228,45 @@ saved after each turn to `~/.cometcli/sessions/` (0600, already redacted). | `/cost` | session token usage, cache hits, and current context size | | `/effort [level\|default]` | show or set reasoning depth for the rest of the session | | `/sessions`, `/resume ` | list saved sessions, or load one into the current session | +| `/permissions`, `/allow\|/ask\|/deny ` | show rules; add one for this session | + +### General tools and permissions + +Besides the node tools, the agent has general SRE tools that run on the profile's host +(local, or over SSH — same tools either way): + +| Tool | What it does | +|---|---| +| `bash` | any shell command; the working directory persists, stdin is closed (prompts get EOF), default timeout 2 min (max 10) | +| `read`, `write`, `edit` | files with line numbers; edits need a fresh read and show a diff for approval | +| `glob`, `grep` | find files by pattern, search contents (ripgrep when installed) | +| `web_fetch` | release notes, docs, a node's REST endpoint; each new domain is approved, localhost isn't | +| `todo_write` | a visible checklist for multi-step work | + +Every shell command is classified before it runs: reads (`ls`, `journalctl`, `docker +logs`, `systemctl status`, `evmd q …`, JSON-RPC queries via curl, …) run at once; +anything that changes the host asks; a transaction — `evmd tx …` directly, inside +`docker exec`/`docker run`, or inside a script the command runs — **always** asks, in +every mode. Consensus keys, mnemonics, key ceremonies (`keys add/export`, scripts that +run them) and state resets (`unsafe-reset-all`, deleting `priv_validator_state.json`) +are refused outright: the agent hands those steps to you. + +Modes (`--permission-mode`, `/mode`): `ops` (default — changes ask), `accept-edits` +(file edits inside the working directory don't ask), `readonly` (nothing that changes +anything can run; the shell is limited to reads), `bypass` (local changes don't ask; +transactions still do). + +Rules refine that per session (`/allow`, `/ask`, `/deny`, `--allowedTools`, +`--disallowedTools`) or per profile (`agent.permissions`). Deny beats ask beats allow, +and an allow must cover every part of a compound command: + +```yaml +agent: + permissions: + allow: ["bash(docker logs:*)", "bash(./info.sh)", "web_fetch(domain:github.com)", "node.*"] + ask: ["bash(docker compose:*)"] + deny: ["bash(rm:*)", "edit(/etc/**)", "val.unjail"] +``` ### Agent config (per profile) diff --git a/internal/agent/commands.go b/internal/agent/commands.go index 151fd78..e17c9fa 100644 --- a/internal/agent/commands.go +++ b/internal/agent/commands.go @@ -28,7 +28,7 @@ type CmdResult struct { } // CommandHelp lists the shared slash commands; front-ends append their own. -const CommandHelp = ` /mode [ops|readonly] show or set the approval posture +const CommandHelp = ` /mode [ops|accept-edits|readonly|bypass] 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 @@ -39,6 +39,8 @@ const CommandHelp = ` /mode [ops|readonly] show or set the approval po /compact [focus] summarize the conversation to free context /cost token usage for this session /effort [low|medium|high|xhigh|max|default] show or set reasoning depth + /permissions show permission rules + /allow|/ask|/deny add a rule for this session, e.g. /allow bash(docker logs:*) /sessions list saved sessions /resume load a saved session into this one` @@ -215,6 +217,37 @@ func RunCommand(a *Agent, c *toolkit.Context, reg *toolkit.Registry, line string } return CmdResult{Text: "effort: " + a.Effort}, nil + case "/permissions", "/rules": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + allow, ask, deny := a.Rules.Lists() + var b strings.Builder + fmt.Fprintf(&b, "%s\n", a.Policy) + for _, l := range []struct { + name string + rules []string + }{{"deny", deny}, {"ask", ask}, {"allow", allow}} { + if len(l.rules) > 0 { + fmt.Fprintf(&b, "%-5s %s\n", l.name, strings.Join(l.rules, ", ")) + } + } + b.WriteString("always: transactions ask; key material and state resets are refused") + return CmdResult{Text: b.String()}, nil + + case "/allow", "/ask", "/deny": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + rule := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), f[0])) + if rule == "" { + return CmdResult{}, fmt.Errorf("usage: %s — e.g. bash(systemctl status:*), edit(./config/**), val.unjail", f[0]) + } + if err := a.Rules.Add(f[0][1:], rule); err != nil { + return CmdResult{}, err + } + return CmdResult{Text: fmt.Sprintf("%s %s (this session; add it to agent.permissions in the profile to keep it)", f[0][1:], rule)}, nil + case "/sessions": all, err := ListSessions() if err != nil { diff --git a/internal/agent/events.go b/internal/agent/events.go index e98cfc7..42310bf 100644 --- a/internal/agent/events.go +++ b/internal/agent/events.go @@ -27,6 +27,8 @@ const ( // EvNotice is a status line from the loop itself: continuation after // an output cutoff, compaction, a refusal, a failed session save. EvNotice EventKind = "notice" + // EvTodos carries the agent's updated task checklist (Todos). + EvTodos EventKind = "todos" // EvApproval asks the human to approve a gated action; answer it via // Approval.Answer. Only emitted when the context uses EventApprover. EvApproval EventKind = "approval" @@ -42,6 +44,7 @@ type Event struct { Args map[string]any `json:"args,omitempty"` Err string `json:"error,omitempty"` Approval *Approval `json:"approval,omitempty"` + Todos []Todo `json:"todos,omitempty"` } // Approval is a pending human decision. Answer it exactly once. diff --git a/internal/agent/loop.go b/internal/agent/loop.go index da6f840..684f21e 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "os" "regexp" "strings" "time" @@ -53,6 +54,13 @@ type Agent struct { CompactAt int // Persist saves the session after every turn so it can be resumed. Persist bool + // Rules are the session's permission rules (allow/ask/deny). + Rules *toolkit.Rules + // Tools is shell/file state shared by general tools (working + // directory, files read). + Tools *toolkit.Session + // WorkRoot anchors relative permission patterns and accept-edits. + WorkRoot string conf config.AgentConf id string @@ -68,6 +76,7 @@ type Agent struct { toolTrunc int lastIn int // prompt tokens of the latest round (0 = unknown) total Usage // session token totals + todos []Todo } // New builds an agent for a context from the profile's agent config. @@ -98,6 +107,11 @@ func New(c *toolkit.Context, reg *toolkit.Registry) (*Agent, error) { if err := ValidEffort(a.Effort); err != nil { return nil, err } + if a.Rules, err = toolkit.NewRules(ac.Permissions.Allow, ac.Permissions.Ask, ac.Permissions.Deny); err != nil { + return nil, err + } + a.WorkRoot, _ = os.Getwd() + a.Tools = toolkit.NewSession("") if a.Model == "" { a.Model = modelOf(prov) } @@ -173,6 +187,8 @@ func (a *Agent) Reset() { a.snap, a.sys, a.toolSig, a.carry = "", "", "", "" a.lastIn, a.total = 0, Usage{} a.created = time.Time{} + a.todos = nil + a.Tools = toolkit.NewSession("") } // Usage returns the session's accumulated token usage and the size of @@ -200,7 +216,7 @@ func (a *Agent) toolDefs() []ToolDef { if toolkit.IsLongRunning(t) { continue // watchers never return inside a turn } - if !a.Policy.Allows(t.Tier()) { + if !a.Policy.Allows(t.Tier()) && !toolkit.IsDynamic(t) { continue } out = append(out, ToolDef{ @@ -209,7 +225,7 @@ func (a *Agent) toolDefs() []ToolDef { Schema: t.Schema(), }) } - return out + return append(out, todoToolDef) } func toolFnName(n string) string { return strings.ReplaceAll(n, ".", "__") } @@ -410,6 +426,9 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { if err := json.Unmarshal(call.Args, &args); err != nil { return a.toolErr(call, name, "bad args: "+err.Error()) } + if name == todoToolName { + return a.todoWrite(call, args) + } t, ok := a.Reg.Get(name) if !ok { return a.toolErr(call, name, "no such tool: "+name) @@ -419,7 +438,7 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { 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()) { + if !a.Policy.Allows(t.Tier()) && !toolkit.IsDynamic(t) { 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) @@ -429,8 +448,12 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { 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) + runCtx, cancel := a.toolCtx(ctx, t, args) + var res *toolkit.Result + err := a.ruleGate(runCtx, t, shownArgs) + if err == nil { + res, err = t.Run(runCtx, args) + } runCtx.Close() cancel() @@ -441,8 +464,11 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { text = res.JSON() } limit := a.toolTrunc + if ol, ok := t.(toolkit.OutputLimiter); ok && limit == 0 { + limit = ol.OutputLimit() + } if limit == 0 { - limit = 8192 + limit = 16_000 } text = ansiRe.ReplaceAllString(text, "") // colored logs waste tokens and confuse models text = clip(text, limit) // bound context growth @@ -466,17 +492,24 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { // 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) { +func (a *Agent) toolCtx(ctx context.Context, t toolkit.Tool, args toolkit.Args) (*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, + Session: a.Tools, Rules: a.Rules, WorkRoot: a.WorkRoot, + ReadOnly: a.Policy.ReadOnly(), AcceptEdits: a.Policy.AcceptEdits(), } var sub *toolkit.Context var cancel context.CancelFunc - if toolkit.IsLongRunning(t) { + switch { + case toolkit.IsLongRunning(t): sub, cancel = toolkit.WithCancel(parent) - } else { - sub, cancel = toolkit.WithDeadline(parent, 90*time.Second) + default: + d := 90 * time.Second + if to, ok := t.(toolkit.Timeouter); ok { + d = to.Timeout(args) + } + sub, cancel = toolkit.WithDeadline(parent, d) } sub.AutoApproveBelow = a.Policy.AutoApproveBelow() if a.Policy.ReadOnly() { @@ -485,6 +518,26 @@ func (a *Agent) toolCtx(ctx context.Context, t toolkit.Tool) (*toolkit.Context, return sub, cancel } +// ruleGate applies name-level permission rules to registry tools that +// don't gate themselves (general tools match rules on their own specs): +// deny refuses, ask confirms first, allow skips the tool's local-change +// prompt. Transactions keep their own approval regardless. +func (a *Agent) ruleGate(c *toolkit.Context, t toolkit.Tool, shownArgs map[string]any) error { + if toolkit.IsAgentOnly(t) { + return nil + } + d, rule := a.Rules.Decide(toolkit.Request{Tool: t.Name()}) + switch d { + case toolkit.DecideDeny: + return fmt.Errorf("denied by permission rule %s", rule) + case toolkit.DecideAsk: + return toolkit.RequireApproval(c, fmt.Sprintf("run %s %s", t.Name(), CompactArgs(shownArgs)), t.Tier(), map[string]any{"rule": rule}) + case toolkit.DecideAllow: + c.AutoApproveBelow = toolkit.TierOnChain + } + return nil +} + // system returns the session's system prompt: instructions plus the live // snapshot taken when the session started. It is frozen until Reset or // Compact — rebuilding it every request would defeat prompt caching and diff --git a/internal/agent/phase2_test.go b/internal/agent/phase2_test.go new file mode 100644 index 0000000..02aaf8f --- /dev/null +++ b/internal/agent/phase2_test.go @@ -0,0 +1,138 @@ +package agent + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/client/host" + "github.com/abhijitkrm/cometcli/internal/toolkit" + "github.com/abhijitkrm/cometcli/internal/tools/shell" +) + +func bashCall(id, cmd string) Call { + b, _ := json.Marshal(map[string]any{"command": cmd}) + return Call{ID: id, Name: "bash", Args: b} +} + +func newShellAgent(t *testing.T, prov Provider, tools ...toolkit.Tool) (*Agent, string) { + a := newTestAgent(t, prov, append([]toolkit.Tool{shell.Bash{}}, tools...)...) + dir := t.TempDir() + a.Tools = toolkit.NewSession(dir) + a.WorkRoot = dir + a.Ctx.SetHost(&host.Local{}) + return a, dir +} + +func lastToolText(a *Agent) string { + h := a.History() + for i := len(h) - 1; i >= 0; i-- { + if h[i].Role == "tool" { + return h[i].Text + } + } + return "" +} + +func TestReadOnlyModeShellReadsButNeverWrites(t *testing.T) { + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{bashCall("c1", "echo hello")}}, + {Calls: []Call{bashCall("c2", "touch made.txt")}}, + {Text: "done", Done: true}, + }} + a, dir := newShellAgent(t, prov) + a.Policy.Mode = ModeReadOnly + if _, err := a.Run(context.Background(), "go"); err != nil { + t.Fatal(err) + } + var names []string + for _, td := range prov.reqs[0].Tools { + names = append(names, td.Name) + } + if !strings.Contains(strings.Join(names, ","), "bash") { + t.Fatalf("bash hidden in readonly: %v", names) + } + h := a.History() + if !strings.Contains(h[2].Text, "hello") { + t.Fatalf("read command blocked: %q", h[2].Text) + } + if !strings.Contains(h[4].Text, "read-only") { + t.Fatalf("write not blocked: %q", h[4].Text) + } + if _, err := os.Stat(filepath.Join(dir, "made.txt")); err == nil { + t.Fatal("file created in readonly mode") + } +} + +func TestDenyRuleBlocksRegistryTool(t *testing.T) { + ran := false + unjail := stubTool{name: "val.unjail", tier: toolkit.TierOnChain, run: func(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { + ran = true + return &toolkit.Result{}, nil + }} + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "c1", Name: "val__unjail", Args: json.RawMessage(`{}`)}}}, + {Text: "ok", Done: true}, + }} + a, _ := newShellAgent(t, prov, unjail) + a.Rules, _ = toolkit.NewRules(nil, nil, []string{"val.unjail"}) + a.Run(context.Background(), "unjail") + if ran || !strings.Contains(lastToolText(a), "denied by permission rule val.unjail") { + t.Fatalf("ran=%v result=%q", ran, lastToolText(a)) + } +} + +func TestAllowRuleSkipsLocalChangePrompt(t *testing.T) { + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{bashCall("c1", "touch ok.txt")}}, + {Text: "ok", Done: true}, + }} + a, dir := newShellAgent(t, prov) + asked := 0 + a.Ctx.Approver = func(*toolkit.Context, string, toolkit.Tier, map[string]any) (bool, error) { asked++; return false, nil } + a.Ctx.AutoApproveBelow = toolkit.TierLocalChange + a.Rules, _ = toolkit.NewRules([]string{"bash(touch:*)"}, nil, nil) + a.Run(context.Background(), "x") + if asked != 0 { + t.Fatal("allow rule still asked") + } + if _, err := os.Stat(filepath.Join(dir, "ok.txt")); err != nil { + t.Fatalf("allowed command didn't run: %s", lastToolText(a)) + } +} + +func TestTodoWriteEmitsChecklist(t *testing.T) { + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "c1", Name: "todo_write", Args: json.RawMessage(`{"todos":[{"content":"stop node","status":"completed"},{"content":"swap binary","status":"in_progress"},{"content":"start node","status":"pending"}]}`)}}}, + {Text: "ok", Done: true}, + }} + a, _ := newShellAgent(t, prov) + var got []Todo + a.OnEvent = func(e Event) { + if e.Kind == EvTodos { + got = e.Todos + } + } + a.Run(context.Background(), "upgrade") + if len(got) != 3 || !strings.Contains(lastToolText(a), "1/3 done") { + t.Fatalf("todos=%v result=%q", got, lastToolText(a)) + } + if r := RenderTodos(got); !strings.Contains(r, "☒ stop node") || !strings.Contains(r, "▸ swap binary") { + t.Fatalf("render = %q", r) + } +} + +func TestBashOutputUsesToolLimit(t *testing.T) { + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{bashCall("c1", "head -c 20000 /dev/zero | tr '\\0' 'a'")}}, + {Text: "ok", Done: true}, + }} + a, _ := newShellAgent(t, prov) + a.Run(context.Background(), "x") + if n := len(lastToolText(a)); n < 19000 || n > 30500 { + t.Fatalf("bash output length %d — should keep up to 30k", n) + } +} diff --git a/internal/agent/policy.go b/internal/agent/policy.go index c146662..264af2c 100644 --- a/internal/agent/policy.go +++ b/internal/agent/policy.go @@ -16,19 +16,34 @@ const ( // 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. + // anything mutating is neither advertised nor runnable (the shell + // stays, limited to read-only commands). ModeReadOnly Mode = "readonly" + // ModeAcceptEdits: like ops, but file writes and edits inside the + // working root run without a prompt. + ModeAcceptEdits Mode = "accept-edits" + // ModeBypass: every local-change runs without a prompt. Transactions + // still always ask; key material and state resets stay refused. + ModeBypass Mode = "bypass" ) -// ParseMode accepts ops | readonly (plus "safe"/"ro" aliases). +// Modes lists the approval postures in shift+tab cycling order. +var Modes = []Mode{ModeOps, ModeAcceptEdits, ModeReadOnly, ModeBypass} + +// ParseMode accepts ops | readonly | accept-edits | bypass, plus Claude +// Code's names (default, plan, acceptEdits, bypassPermissions). func ParseMode(s string) (Mode, error) { switch strings.ToLower(strings.TrimSpace(s)) { - case "", "ops", "rw": + case "", "ops", "rw", "default": return ModeOps, nil - case "readonly", "read-only", "ro", "safe": + case "readonly", "read-only", "ro", "safe", "plan": return ModeReadOnly, nil + case "accept-edits", "acceptedits", "edits": + return ModeAcceptEdits, nil + case "bypass", "bypasspermissions", "yolo": + return ModeBypass, nil } - return "", fmt.Errorf("unknown mode %q — want ops or readonly", s) + return "", fmt.Errorf("unknown mode %q — want ops, readonly, accept-edits or bypass", s) } // Policy maps tool tiers to auto | confirm | deny for one session. @@ -66,12 +81,15 @@ func (p Policy) Allows(t toolkit.Tier) bool { // 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() { + if (p.AutoLocal || p.Mode == ModeBypass) && !p.ReadOnly() { return toolkit.TierOnChain } return toolkit.TierLocalChange } +// AcceptEdits reports whether in-root file edits skip the prompt. +func (p Policy) AcceptEdits() bool { return p.Mode == ModeAcceptEdits } + // Decision describes how a tier is handled: auto | confirm | deny. func (p Policy) Decision(t toolkit.Tier) string { switch { @@ -79,6 +97,8 @@ func (p Policy) Decision(t toolkit.Tier) string { return "deny" case t < p.AutoApproveBelow(): return "auto" + case t == toolkit.TierLocalChange && p.AcceptEdits(): + return "confirm (edits in root: auto)" default: return "confirm" } diff --git a/internal/agent/prompt.go b/internal/agent/prompt.go index dc26e40..b9e9119 100644 --- a/internal/agent/prompt.go +++ b/internal/agent/prompt.go @@ -27,6 +27,22 @@ You operate ONE node via tools. Rules you must follow: - Reference exact numbers (heights, missed counts, drift) in answers. - Keep answers terse, technical, and actionable. Use markdown sparingly. +General tools (they run on the node's host — local or over SSH): +- bash: any shell command; the working directory persists between calls. + Read-only commands run at once; changes wait for the operator's + approval; transactions always do. stdin is closed: a command or script + that prompts (read -p, sudo password, y/N) gets EOF — ask the operator + to run those in their terminal instead. +- read / grep / glob for files — prefer them over cat/grep/find in bash. + edit and write need a read of the file first; keep edits minimal. +- Before running a script, read it and say what it will do. +- Refusals are final: consensus keys, key ceremonies (keys add/export), + mnemonics and state resets never pass through you. Don't retry + variations of a refused or denied command; explain and hand the step + to the operator with the exact command. +- web_fetch for release notes and docs. todo_write to track work of 3+ + steps (an upgrade, a migration) so the operator sees progress. + `) fmt.Fprintf(&b, "NODE PROFILE: %s (role=%s, chain-id=%s, evm-chain-id=%d, binary=%s, home=%s)\n", p.Name, p.Role, p.ChainID, p.EVMChainID, p.Binary, p.Home) diff --git a/internal/agent/repl.go b/internal/agent/repl.go index 607ace1..8429d2c 100644 --- a/internal/agent/repl.go +++ b/internal/agent/repl.go @@ -60,6 +60,12 @@ func (p *Printer) Handle(e Event) { p.thinking = true } fmt.Fprint(p.Out, toolSt.Render(e.Text)) + case EvTodos: + if p.streamed { + fmt.Fprintln(p.Out) + p.streamed = false + } + fmt.Fprintln(p.Out, toolSt.Render(RenderTodos(e.Todos))) case EvNotice: if p.streamed { fmt.Fprintln(p.Out) diff --git a/internal/agent/session.go b/internal/agent/session.go index 195b944..609bb0f 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -11,6 +11,7 @@ import ( "time" "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/toolkit" ) // SessionFile is a saved conversation, written after every turn to @@ -33,6 +34,9 @@ type SessionFile struct { ToolSig string `json:"tool_sig,omitempty"` Carry string `json:"carry,omitempty"` History []Msg `json:"history"` + // ShellCwd is the bash tool's working directory; Todos the checklist. + ShellCwd string `json:"shell_cwd,omitempty"` + Todos []Todo `json:"todos,omitempty"` } const sessionVersion = 1 @@ -75,6 +79,7 @@ func (a *Agent) Save() error { Title: a.title(), Cwd: cwd, Profile: a.profileName(), Provider: a.Provider.Name(), Model: a.Model, Usage: a.total, System: a.sys, ToolSig: a.toolSig, Carry: a.carry, History: a.history, + ShellCwd: a.Tools.Cwd(""), Todos: a.todos, } raw, err := json.Marshal(sf) if err != nil { @@ -118,6 +123,8 @@ func (a *Agent) Restore(sf *SessionFile) { a.id, a.created = sf.ID, sf.Created a.history, a.sys, a.toolSig, a.carry = sf.History, sf.System, sf.ToolSig, sf.Carry a.total = sf.Usage + a.todos = sf.Todos + a.Tools = toolkit.NewSession(sf.ShellCwd) // the frozen system prompt carries the old snapshot: force a refresh // onto the next turn a.snapAt = time.Time{} diff --git a/internal/agent/session_test.go b/internal/agent/session_test.go index a85b7fc..86ee614 100644 --- a/internal/agent/session_test.go +++ b/internal/agent/session_test.go @@ -159,8 +159,10 @@ func TestReadOnlyHidesAndBlocks(t *testing.T) { 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) + for _, td := range prov.reqs[0].Tools { + if td.Name != todoToolName { + t.Fatalf("readonly advertised mutating tool %s", td.Name) + } } } diff --git a/internal/agent/todo.go b/internal/agent/todo.go new file mode 100644 index 0000000..c6a9426 --- /dev/null +++ b/internal/agent/todo.go @@ -0,0 +1,85 @@ +package agent + +import ( + "fmt" + "strings" +) + +// Todo is one item of the agent's task list (todo_write). +type Todo struct { + Content string `json:"content"` + Status string `json:"status"` // pending | in_progress | completed +} + +const todoToolName = "todo_write" + +// todoToolDef is a built-in tool: the agent keeps a visible checklist for +// multi-step work (an upgrade, a migration) and updates it as it goes. +var todoToolDef = ToolDef{ + Name: todoToolName, + Desc: "Replace the session's task checklist, shown to the operator. Use it for work with 3+ steps: list the steps, keep exactly one in_progress, mark each completed as soon as it is done. Send the full list every time.", + Schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "todos": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{"type": "string", "description": "the step, imperative"}, + "status": map[string]any{"type": "string", "enum": []string{"pending", "in_progress", "completed"}}, + }, + "required": []string{"content", "status"}, + }, + }, + }, + "required": []string{"todos"}, + }, +} + +// Todos returns the current checklist. +func (a *Agent) Todos() []Todo { return append([]Todo(nil), a.todos...) } + +func (a *Agent) todoWrite(call Call, args map[string]any) Msg { + raw, _ := args["todos"].([]any) + var todos []Todo + for _, r := range raw { + m, _ := r.(map[string]any) + c, _ := m["content"].(string) + s, _ := m["status"].(string) + if strings.TrimSpace(c) == "" { + continue + } + switch s { + case "pending", "in_progress", "completed": + default: + return a.toolErr(call, todoToolName, fmt.Sprintf("bad status %q — want pending, in_progress or completed", s)) + } + todos = append(todos, Todo{Content: a.Redact.Text(c), Status: s}) + } + a.todos = todos + a.emit(Event{Kind: EvTodos, Todos: a.Todos()}) + done := 0 + for _, t := range todos { + if t.Status == "completed" { + done++ + } + } + return Msg{Role: "tool", CallID: call.ID, ToolName: todoToolName, Text: fmt.Sprintf("checklist updated: %d/%d done", done, len(todos))} +} + +// RenderTodos draws the checklist as plain lines. +func RenderTodos(todos []Todo) string { + var b strings.Builder + for _, t := range todos { + mark := "☐" + switch t.Status { + case "in_progress": + mark = "▸" + case "completed": + mark = "☒" + } + fmt.Fprintf(&b, "%s %s\n", mark, t.Content) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/cli/agent.go b/internal/cli/agent.go index f6e78d2..4a09cda 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -25,6 +25,9 @@ func sessionFlags(cmd *cobra.Command) { cmd.Flags().String("model", "", "model for this session (overrides the profile)") cmd.Flags().String("effort", "", "reasoning depth: low | medium | high | xhigh | max") cmd.Flags().Int("max-tokens", 0, "max output tokens per model round") + cmd.Flags().String("permission-mode", "", "approval posture: ops | accept-edits | readonly | bypass") + cmd.Flags().StringSlice("allowedTools", nil, `permission rules to allow, e.g. "bash(git status:*)","read"`) + cmd.Flags().StringSlice("disallowedTools", nil, `permission rules to deny, e.g. "bash(rm:*)","web_fetch"`) } // AgentCmd opens the agentic REPL. Implemented in internal/agent. diff --git a/internal/cli/agent_impl.go b/internal/cli/agent_impl.go index 268c85a..89f2093 100644 --- a/internal/cli/agent_impl.go +++ b/internal/cli/agent_impl.go @@ -45,7 +45,11 @@ func runAgentImpl(cmd *cobra.Command, reg *toolkit.Registry, oneshot string) err // asked (--continue / --resume). Flags a command doesn't define read as // unset. The returned note describes a resumed session. func applyAgentFlags(cmd *cobra.Command, a *agent.Agent) (string, error) { - if m, _ := cmd.Flags().GetString("mode"); m != "" { + m, _ := cmd.Flags().GetString("mode") + if pm, _ := cmd.Flags().GetString("permission-mode"); pm != "" { + m = pm + } + if m != "" { mode, err := agent.ParseMode(m) if err != nil { return "", err @@ -78,6 +82,14 @@ func applyAgentFlags(cmd *cobra.Command, a *agent.Agent) (string, error) { if m, _ := cmd.Flags().GetString("model"); m != "" { a.Model = m } + for list, flag := range map[string]string{"allow": "allowedTools", "deny": "disallowedTools"} { + rules, _ := cmd.Flags().GetStringSlice(flag) + for _, r := range rules { + if err := a.Rules.Add(list, r); err != nil { + return "", fmt.Errorf("--%s: %w", flag, err) + } + } + } if e, _ := cmd.Flags().GetString("effort"); e != "" { if err := agent.ValidEffort(e); err != nil { return "", err diff --git a/internal/cli/root.go b/internal/cli/root.go index b8ee581..30b3998 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -77,8 +77,8 @@ func toolGroupCommands(reg *toolkit.Registry) []*cobra.Command { groups := map[string]*cobra.Command{} for _, t := range reg.All() { domain, _, ok := strings.Cut(t.Name(), ".") - if !ok { - continue + if !ok || toolkit.IsAgentOnly(t) { + continue // general agent tools (bash, read, …) have no subcommand } parent, ok := groups[domain] if !ok { @@ -263,7 +263,11 @@ func StdinApprover(in io.Reader) toolkit.Approver { } fmt.Fprint(c.Out, "Proceed? [y/N] ") line, err := reader.ReadString('\n') - if err != nil { + if err == io.EOF && strings.TrimSpace(line) == "" { + fmt.Fprintln(c.Out) + return false, fmt.Errorf("not approved: no terminal to answer on (stdin closed) — run interactively, or allow it with --allowedTools / agent.permissions") + } + if err != nil && err != io.EOF { return false, err } return strings.EqualFold(strings.TrimSpace(line), "y"), nil diff --git a/internal/client/host/exec.go b/internal/client/host/exec.go new file mode 100644 index 0000000..8cde083 --- /dev/null +++ b/internal/client/host/exec.go @@ -0,0 +1,179 @@ +package host + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "sync" + "time" + + "golang.org/x/crypto/ssh" +) + +// ExecResult is the outcome of a shell script run by Exec. +type ExecResult struct { + Output string // stdout and stderr, interleaved as written + Code int // exit status (-1 when killed) + TimedOut bool + Truncated bool // Output kept only its head and tail +} + +// Execer runs a script with combined output, an output cap, and +// process-tree cleanup on cancel. Local and SSH implement it; other Host +// implementations fall back to Run. +type Execer interface { + Exec(ctx context.Context, script string, maxOut int) (ExecResult, error) +} + +// Exec runs script on h via Execer when available. +func Exec(ctx context.Context, h Host, script string, maxOut int) (ExecResult, error) { + if e, ok := h.(Execer); ok { + return e.Exec(ctx, script, maxOut) + } + out, code, err := h.Run(ctx, script) + if err != nil && code != 0 { + out += "\n" + err.Error() + err = nil + } + return ExecResult{Output: out, Code: code, TimedOut: errors.Is(ctx.Err(), context.DeadlineExceeded)}, err +} + +// nonInteractiveEnv keeps tools from waiting on a terminal that isn't +// there (stdin is /dev/null): pagers, git credential prompts, apt. +var nonInteractiveEnv = []string{ + "PAGER=cat", "GIT_PAGER=cat", "SYSTEMD_PAGER=", "GIT_TERMINAL_PROMPT=0", + "DEBIAN_FRONTEND=noninteractive", "TERM=dumb", "NO_COLOR=1", +} + +// capWriter keeps the first and last half of max bytes. +type capWriter struct { + mu sync.Mutex + max int + head bytes.Buffer + tail []byte + truncated bool +} + +func (w *capWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + n := len(p) + if room := w.max/2 - w.head.Len(); room > 0 { + k := min(room, len(p)) + w.head.Write(p[:k]) + p = p[k:] + } + if len(p) > 0 { + w.tail = append(w.tail, p...) + if over := len(w.tail) - w.max/2; over > 0 { + w.tail = w.tail[over:] + w.truncated = true + } + } + return n, nil +} + +func (w *capWriter) String() string { + w.mu.Lock() + defer w.mu.Unlock() + if !w.truncated { + return w.head.String() + string(w.tail) + } + return fmt.Sprintf("%s\n…[output truncated]…\n%s", w.head.String(), w.tail) +} + +func shellBin() string { + if p, err := exec.LookPath("bash"); err == nil { + return p + } + return "/bin/sh" +} + +// Exec runs script under bash (or sh) with stdin from /dev/null. On +// cancel or timeout the whole process group gets SIGTERM, then SIGKILL. +func (l *Local) Exec(ctx context.Context, script string, maxOut int) (ExecResult, error) { + c := exec.Command(shellBin(), "-c", script) + c.Env = append(os.Environ(), nonInteractiveEnv...) + w := &capWriter{max: maxOut} + c.Stdout, c.Stderr = w, w + setProcessGroup(c) + if err := c.Start(); err != nil { + return ExecResult{}, err + } + done := make(chan error, 1) + go func() { done <- c.Wait() }() + var err error + timedOut := false + select { + case err = <-done: + case <-ctx.Done(): + timedOut = errors.Is(ctx.Err(), context.DeadlineExceeded) + killGroup(c, false) + select { + case err = <-done: + case <-time.After(2 * time.Second): + killGroup(c, true) + err = <-done + } + } + res := ExecResult{Output: w.String(), Truncated: w.truncated, TimedOut: timedOut} + var ee *exec.ExitError + switch { + case err == nil: + case errors.As(err, &ee): + res.Code = ee.ExitCode() + default: + return res, err + } + if ctx.Err() != nil && res.Code == 0 { + res.Code = -1 + } + return res, nil +} + +// Exec runs script on the remote host. Cancel kills the session. +func (s *SSH) Exec(ctx context.Context, script string, maxOut int) (ExecResult, error) { + sess, err := s.client.NewSession() + if err != nil { + return ExecResult{}, err + } + defer sess.Close() + w := &capWriter{max: maxOut} + sess.Stdout, sess.Stderr = w, w + env := "" + for _, e := range nonInteractiveEnv { + env += e + " " + } + q := shellQuote(script) + cmd := fmt.Sprintf("export %s; if command -v bash >/dev/null 2>&1; then exec bash -c %s; else exec sh -c %s; fi", env, q, q) + done := make(chan error, 1) + go func() { done <- sess.Run(cmd) }() + res := ExecResult{} + select { + case err = <-done: + case <-ctx.Done(): + res.TimedOut = errors.Is(ctx.Err(), context.DeadlineExceeded) + _ = sess.Signal(ssh.SIGKILL) + sess.Close() + err = <-done + res.Code = -1 + } + res.Output, res.Truncated = w.String(), w.truncated + var ee *ssh.ExitError + switch { + case err == nil: + case errors.As(err, &ee): + res.Code = ee.ExitStatus() + case res.Code == -1: + default: + var missing *ssh.ExitMissingError + if !errors.As(err, &missing) { + return res, err + } + res.Code = -1 + } + return res, nil +} diff --git a/internal/client/host/exec_other.go b/internal/client/host/exec_other.go new file mode 100644 index 0000000..b399fbb --- /dev/null +++ b/internal/client/host/exec_other.go @@ -0,0 +1,13 @@ +//go:build !unix + +package host + +import "os/exec" + +func setProcessGroup(*exec.Cmd) {} + +func killGroup(c *exec.Cmd, _ bool) { + if c.Process != nil { + _ = c.Process.Kill() + } +} diff --git a/internal/client/host/exec_unix.go b/internal/client/host/exec_unix.go new file mode 100644 index 0000000..c81dda1 --- /dev/null +++ b/internal/client/host/exec_unix.go @@ -0,0 +1,25 @@ +//go:build unix + +package host + +import ( + "os/exec" + "syscall" +) + +func setProcessGroup(c *exec.Cmd) { + c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// killGroup signals the command's whole process group, so children +// (docker compose, build steps) die with the shell. +func killGroup(c *exec.Cmd, hard bool) { + if c.Process == nil { + return + } + sig := syscall.SIGTERM + if hard { + sig = syscall.SIGKILL + } + _ = syscall.Kill(-c.Process.Pid, sig) +} diff --git a/internal/config/config.go b/internal/config/config.go index e361078..90a02d2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -126,6 +126,16 @@ type AgentConf struct { // CompactAt is the prompt size in tokens that triggers summarizing // the conversation (0 = 80% of the window, at most 200k). CompactAt int `yaml:"compact_at,omitempty"` + // Permissions are allow/ask/deny rules, e.g. "bash(systemctl status:*)", + // "edit(./config/**)", "web_fetch(domain:github.com)", "val.unjail". + Permissions Permissions `yaml:"permissions,omitempty"` +} + +// Permissions holds permission rule lists. Deny beats ask beats allow. +type Permissions struct { + Allow []string `yaml:"allow,omitempty"` + Ask []string `yaml:"ask,omitempty"` + Deny []string `yaml:"deny,omitempty"` } // Alerts configures notification sinks for the monitor. diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 843a9f8..78eda12 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -135,8 +135,8 @@ func (s *Server) handle(ctx context.Context, raw []byte) *response { func (s *Server) toolList() []map[string]any { var out []map[string]any for _, t := range s.Reg.All() { - if toolkit.IsLongRunning(t) { - continue // watchers never return from a tools/call + if toolkit.IsLongRunning(t) || toolkit.IsAgentOnly(t) { + continue // watchers never return; bash/read/edit are the client's own job } d := map[string]any{ "name": t.Name(), @@ -186,6 +186,9 @@ func (s *Server) callTool(ctx context.Context, name string, args map[string]any) if toolkit.IsLongRunning(t) { return callResult{text: name + " is a long-running watcher — not callable over MCP; use the CLI"}, true } + if toolkit.IsAgentOnly(t) { + return callResult{text: name + " is not exported over MCP"}, true + } runCtx, cancel := toolkit.WithDeadline(s.Ctx, 120*time.Second) defer func() { runCtx.Close(); cancel() }() res, err := t.Run(runCtx, toolkit.Args(args)) diff --git a/internal/toolkit/context.go b/internal/toolkit/context.go index e1e459d..9046025 100644 --- a/internal/toolkit/context.go +++ b/internal/toolkit/context.go @@ -28,6 +28,16 @@ type Context struct { Approver Approver // AutoApprove tiers below this run without prompting (agent/CI use). AutoApproveBelow Tier + // Session is agent-session state for general tools (cwd, reads). + Session *Session + // Rules are the session's allow/ask/deny permission patterns. + Rules *Rules + // ReadOnly refuses any action whose effective tier mutates. + ReadOnly bool + // AcceptEdits auto-approves file writes inside WorkRoot. + AcceptEdits bool + // WorkRoot anchors relative rule patterns and accept-edits scope. + WorkRoot string mu sync.Mutex comet *comet.Client @@ -101,11 +111,13 @@ func (c *Context) EVM() (*evmclient.Client, error) { func (c *Context) Host() (host.Host, error) { c.mu.Lock() defer c.mu.Unlock() - if c.Profile == nil { - return nil, errNoProfile - } if c.host == nil && c.hostErr == nil { - c.host, c.hostErr = host.Connect(c.Context, c.Profile) + if c.Profile == nil { + // no node profile (general mode): the operator's own machine + c.host = &host.Local{} + } else { + c.host, c.hostErr = host.Connect(c.Context, c.Profile) + } } return c.host, c.hostErr } @@ -151,6 +163,8 @@ func WithDeadline(c *Context, timeout time.Duration) (*Context, context.CancelFu return &Context{ Context: ctx, Profile: c.Profile, Cfg: c.Cfg, Out: c.Out, Audit: c.Audit, Approver: c.Approver, AutoApproveBelow: c.AutoApproveBelow, + Session: c.Session, Rules: c.Rules, ReadOnly: c.ReadOnly, + AcceptEdits: c.AcceptEdits, WorkRoot: c.WorkRoot, }, cancel } @@ -161,6 +175,8 @@ func WithCancel(c *Context) (*Context, context.CancelFunc) { return &Context{ Context: ctx, Profile: c.Profile, Cfg: c.Cfg, Out: c.Out, Audit: c.Audit, Approver: c.Approver, AutoApproveBelow: c.AutoApproveBelow, + Session: c.Session, Rules: c.Rules, ReadOnly: c.ReadOnly, + AcceptEdits: c.AcceptEdits, WorkRoot: c.WorkRoot, }, cancel } @@ -183,6 +199,53 @@ func (c *Context) Approve(prompt string, tier Tier, detail map[string]any) error return RequireApproval(c, prompt, tier, detail) } +// Gate describes an action whose risk is decided per call (a shell +// command, a file path) rather than by the tool's static tier. +type Gate struct { + Request + Tier Tier + Prompt string + Detail map[string]any + // Forbidden, when set, refuses the action in every mode and despite + // any rule (consensus keys, state resets): the reason is shown. + Forbidden string + // InRoot marks a file edit inside WorkRoot (auto under accept-edits). + InRoot bool + // AskByDefault prompts when no rule matches even though the tier is + // read-only (web fetches: the URL itself can carry data out). Bypass + // mode still auto-approves. + AskByDefault bool +} + +// Check runs the permission pipeline for a dynamic action: forbidden → +// read-only mode → deny/ask/allow rules → accept-edits → tier policy. +// On-chain actions always reach the human, whatever the rules say. +func (c *Context) Check(g Gate) error { + if g.Forbidden != "" { + return fmt.Errorf("refused: %s", g.Forbidden) + } + if c.ReadOnly && g.Tier >= TierLocalChange { + return fmt.Errorf("blocked: this is a %s action and the session is read-only — recommend it to the operator instead", g.Tier) + } + if g.Root == "" { + g.Root = c.WorkRoot + } + d, rule := c.Rules.Decide(g.Request) + switch { + case d == DecideDeny: + return fmt.Errorf("denied by permission rule %s", rule) + case d == DecideAsk || g.Tier == TierOnChain: + return RequireApproval(c, g.Prompt, g.Tier, g.Detail) + case d == DecideAllow: + return nil + case c.AcceptEdits && g.InRoot && g.Tier == TierLocalChange: + return nil + case g.AskByDefault && c.AutoApproveBelow <= TierLocalChange: + return RequireApproval(c, g.Prompt, g.Tier, g.Detail) + } + return c.Approve(g.Prompt, g.Tier, g.Detail) +} + // Close releases lazy clients. Audit is deliberately not closed here: // derived contexts (WithDeadline) share the parent's logger, and closing // it per-call would kill auditing for the rest of the session. The owner diff --git a/internal/toolkit/perm.go b/internal/toolkit/perm.go new file mode 100644 index 0000000..b37eaa7 --- /dev/null +++ b/internal/toolkit/perm.go @@ -0,0 +1,312 @@ +package toolkit + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" +) + +// Decision is the outcome of a permission check. +type Decision int + +const ( + // DecideDefault means no rule matched: fall back to the tier policy. + DecideDefault Decision = iota + // DecideAllow runs without prompting (never for on-chain actions). + DecideAllow + // DecideAsk always prompts, even for read-only actions. + DecideAsk + // DecideDeny refuses outright. + DecideDeny +) + +func (d Decision) String() string { + switch d { + case DecideAllow: + return "allow" + case DecideAsk: + return "ask" + case DecideDeny: + return "deny" + } + return "default" +} + +// Rule is one permission pattern, in Claude Code's syntax: +// +// bash any shell command +// bash(git status) exactly this command +// bash(systemctl status:*) commands starting with "systemctl status" +// read(/etc/**) paths matching a glob (** spans directories) +// edit(./run-validator/**) relative to the session's working root +// web_fetch(domain:x.com) a host and its subdomains +// val.unjail, node.* registry tools by name or prefix +// +// Tool names match case-insensitively, so Bash(...) and WebFetch(...) +// from Claude Code configs work too. +type Rule struct { + Raw string + Tool string // lower-cased; may end in ".*" + Spec string // "" matches any invocation of Tool +} + +var ruleRe = regexp.MustCompile(`^\s*([A-Za-z_][A-Za-z0-9_.*-]*)\s*(?:\((.*)\))?\s*$`) + +// toolAliases maps Claude Code tool names onto ours. +var toolAliases = map[string]string{"webfetch": "web_fetch", "todowrite": "todo_write", "multiedit": "edit"} + +// ParseRule parses one rule string. +func ParseRule(s string) (Rule, error) { + m := ruleRe.FindStringSubmatch(s) + if m == nil { + return Rule{}, fmt.Errorf("bad permission rule %q — want tool or tool(pattern)", s) + } + tool := strings.ToLower(m[1]) + if a, ok := toolAliases[tool]; ok { + tool = a + } + spec := strings.TrimSpace(m[2]) + if spec == "*" { + spec = "" + } + return Rule{Raw: strings.TrimSpace(s), Tool: tool, Spec: spec}, nil +} + +func (r Rule) toolMatches(tool string) bool { + tool = strings.ToLower(tool) + if strings.HasSuffix(r.Tool, ".*") { + return strings.HasPrefix(tool, strings.TrimSuffix(r.Tool, "*")) + } + return r.Tool == tool +} + +// Request describes one action for rule matching. +type Request struct { + Tool string + // Specs are the things the action touches: each shell segment of a + // compound command, a file path, or a URL. Empty for plain tools. + Specs []string + // Kind selects how Spec patterns match: "command", "path", "url". + Kind string + // Root resolves relative path patterns ("./x/**"). + Root string +} + +func (r Rule) specMatches(req Request, spec string) bool { + if r.Spec == "" { + return true + } + switch req.Kind { + case "command": + if p, ok := strings.CutSuffix(r.Spec, ":*"); ok { + return spec == p || strings.HasPrefix(spec, p+" ") + } + if strings.HasSuffix(r.Spec, "*") { + return strings.HasPrefix(spec, strings.TrimSuffix(r.Spec, "*")) + } + return spec == r.Spec + case "path": + return MatchPathGlob(expandPattern(r.Spec, req.Root), spec) + case "url": + d, ok := strings.CutPrefix(r.Spec, "domain:") + if !ok { + return strings.HasPrefix(spec, r.Spec) + } + h := urlHost(spec) + return h == d || strings.HasSuffix(h, "."+d) + } + return spec == r.Spec +} + +func expandPattern(p, root string) string { + switch { + case strings.HasPrefix(p, "~/"): + home, _ := os.UserHomeDir() + return filepath.Join(home, p[2:]) + case strings.HasPrefix(p, "//"): // Claude Code's absolute-path form + return p[1:] + case filepath.IsAbs(p): + return p + } + return filepath.Join(root, p) +} + +func urlHost(u string) string { + if i := strings.Index(u, "://"); i >= 0 { + u = u[i+3:] + } + if i := strings.IndexAny(u, "/?#"); i >= 0 { + u = u[:i] + } + if i := strings.LastIndexByte(u, '@'); i >= 0 { + u = u[i+1:] + } + if h, _, ok := strings.Cut(u, ":"); ok && !strings.Contains(h, "[") { + u = h + } + return strings.ToLower(u) +} + +// Rules is a session's permission rule set. Deny beats ask beats allow; +// an allow must cover every spec of a compound action, while a single +// matching deny or ask spec decides the whole action. +type Rules struct { + mu sync.RWMutex + allow, ask, deny []Rule +} + +// NewRules parses the three lists. +func NewRules(allow, ask, deny []string) (*Rules, error) { + r := &Rules{} + for _, l := range []struct { + in []string + out *[]Rule + }{{allow, &r.allow}, {ask, &r.ask}, {deny, &r.deny}} { + for _, s := range l.in { + rule, err := ParseRule(s) + if err != nil { + return nil, err + } + *l.out = append(*l.out, rule) + } + } + return r, nil +} + +// Add appends a rule to one list ("allow" | "ask" | "deny"). +func (r *Rules) Add(list, s string) error { + rule, err := ParseRule(s) + if err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + switch list { + case "allow": + r.allow = append(r.allow, rule) + case "ask": + r.ask = append(r.ask, rule) + case "deny": + r.deny = append(r.deny, rule) + default: + return fmt.Errorf("unknown rule list %q", list) + } + return nil +} + +// Lists returns copies of the rule strings. +func (r *Rules) Lists() (allow, ask, deny []string) { + if r == nil { + return nil, nil, nil + } + r.mu.RLock() + defer r.mu.RUnlock() + raw := func(rs []Rule) []string { + out := make([]string, len(rs)) + for i, x := range rs { + out[i] = x.Raw + } + return out + } + return raw(r.allow), raw(r.ask), raw(r.deny) +} + +// Decide evaluates a request. It returns the decision and the rule that +// produced it ("" for DecideDefault). +func (r *Rules) Decide(req Request) (Decision, string) { + if r == nil { + return DecideDefault, "" + } + r.mu.RLock() + defer r.mu.RUnlock() + specs := req.Specs + if len(specs) == 0 { + specs = []string{""} + } + any := func(rules []Rule) (string, bool) { + for _, rule := range rules { + if !rule.toolMatches(req.Tool) { + continue + } + for _, s := range specs { + if rule.specMatches(req, s) { + return rule.Raw, true + } + } + } + return "", false + } + if raw, ok := any(r.deny); ok { + return DecideDeny, raw + } + if raw, ok := any(r.ask); ok { + return DecideAsk, raw + } + var used string + for _, s := range specs { + covered := false + for _, rule := range r.allow { + if rule.toolMatches(req.Tool) && rule.specMatches(req, s) { + covered, used = true, rule.Raw + break + } + } + if !covered { + return DecideDefault, "" + } + } + if used != "" { + return DecideAllow, used + } + return DecideDefault, "" +} + +// MatchPathGlob matches a cleaned absolute path against a glob where * +// and ? stay within one path segment and ** spans any number of them. +func MatchPathGlob(pattern, path string) bool { + re, err := globRegexp(filepath.Clean(pattern)) + if err != nil { + return false + } + return re.MatchString(filepath.Clean(path)) +} + +var globCache sync.Map + +func globRegexp(p string) (*regexp.Regexp, error) { + if re, ok := globCache.Load(p); ok { + return re.(*regexp.Regexp), nil + } + var b strings.Builder + b.WriteString("^") + for i := 0; i < len(p); i++ { + c := p[i] + switch c { + case '*': + if i+1 < len(p) && p[i+1] == '*' { + i++ + if i+1 < len(p) && p[i+1] == '/' { + i++ + b.WriteString("(?:.*/)?") // "**/" matches zero or more dirs + } else { + b.WriteString(".*") + } + } else { + b.WriteString("[^/]*") + } + case '?': + b.WriteString("[^/]") + default: + b.WriteString(regexp.QuoteMeta(string(c))) + } + } + b.WriteString("$") + re, err := regexp.Compile(b.String()) + if err == nil { + globCache.Store(p, re) + } + return re, err +} diff --git a/internal/toolkit/perm_test.go b/internal/toolkit/perm_test.go new file mode 100644 index 0000000..08bf6bd --- /dev/null +++ b/internal/toolkit/perm_test.go @@ -0,0 +1,134 @@ +package toolkit + +import ( + "context" + "strings" + "testing" +) + +func TestRulesDecide(t *testing.T) { + r, err := NewRules( + []string{"Bash(git status:*)", "bash(systemctl status *)", "read(./config/**)", "WebFetch(domain:github.com)", "node.*"}, + []string{"bash(docker compose:*)"}, + []string{"bash(rm:*)", "edit(/etc/**)", "val.unjail"}, + ) + if err != nil { + t.Fatal(err) + } + cases := []struct { + req Request + want Decision + }{ + {Request{Tool: "bash", Kind: "command", Specs: []string{"git status"}}, DecideAllow}, + {Request{Tool: "bash", Kind: "command", Specs: []string{"git status --short"}}, DecideAllow}, + {Request{Tool: "bash", Kind: "command", Specs: []string{"git statusx"}}, DecideDefault}, + {Request{Tool: "bash", Kind: "command", Specs: []string{"systemctl status evmd"}}, DecideAllow}, + // compound: allow must cover every segment + {Request{Tool: "bash", Kind: "command", Specs: []string{"git status", "ls"}}, DecideDefault}, + // a single deny or ask segment decides + {Request{Tool: "bash", Kind: "command", Specs: []string{"git status", "rm -rf x"}}, DecideDeny}, + {Request{Tool: "bash", Kind: "command", Specs: []string{"docker compose up -d"}}, DecideAsk}, + {Request{Tool: "read", Kind: "path", Root: "/srv/node", Specs: []string{"/srv/node/config/app.toml"}}, DecideAllow}, + {Request{Tool: "read", Kind: "path", Root: "/srv/node", Specs: []string{"/srv/node/data/x"}}, DecideDefault}, + {Request{Tool: "edit", Kind: "path", Specs: []string{"/etc/hosts"}}, DecideDeny}, + {Request{Tool: "web_fetch", Kind: "url", Specs: []string{"https://api.github.com/repos/x"}}, DecideAllow}, + {Request{Tool: "web_fetch", Kind: "url", Specs: []string{"https://evilgithub.com/"}}, DecideDefault}, + {Request{Tool: "node.status"}, DecideAllow}, + {Request{Tool: "val.unjail"}, DecideDeny}, + {Request{Tool: "val.status"}, DecideDefault}, + } + for _, c := range cases { + if got, rule := r.Decide(c.req); got != c.want { + t.Errorf("%s %v: got %s (%s), want %s", c.req.Tool, c.req.Specs, got, rule, c.want) + } + } + var nilRules *Rules + if d, _ := nilRules.Decide(Request{Tool: "bash"}); d != DecideDefault { + t.Fatal("nil rules must be default") + } + if _, err := ParseRule("bash(unclosed"); err == nil { + t.Fatal("bad rule accepted") + } +} + +func TestMatchPathGlob(t *testing.T) { + cases := []struct { + pat, path string + want bool + }{ + {"/a/**", "/a/b/c.toml", true}, + {"/a/**/*.toml", "/a/c.toml", true}, + {"/a/**/*.toml", "/a/b/c/d.toml", true}, + {"/a/*.toml", "/a/b/c.toml", false}, + {"/a/?.go", "/a/x.go", true}, + {"/a/b", "/a/b/../b", true}, + } + for _, c := range cases { + if got := MatchPathGlob(c.pat, c.path); got != c.want { + t.Errorf("%s ~ %s = %v", c.pat, c.path, got) + } + } +} + +func TestCheckPipeline(t *testing.T) { + var asked []string + appr := func(_ *Context, prompt string, tier Tier, _ map[string]any) (bool, error) { + asked = append(asked, prompt) + return true, nil + } + rules, _ := NewRules([]string{"bash(evmd tx:*)", "bash(make:*)"}, nil, []string{"bash(curl:*)"}) + c := &Context{Context: context.Background(), Approver: appr, AutoApproveBelow: TierLocalChange, Rules: rules} + g := func(spec string, tier Tier) Gate { + return Gate{Request: Request{Tool: "bash", Kind: "command", Specs: []string{spec}}, Tier: tier, Prompt: spec} + } + if err := c.Check(g("ls", TierDiagnose)); err != nil || len(asked) != 0 { + t.Fatalf("read asked or failed: %v %v", err, asked) + } + if err := c.Check(g("make build", TierLocalChange)); err != nil || len(asked) != 0 { + t.Fatalf("allow rule ignored: %v %v", err, asked) + } + // an allow rule never skips the tx prompt + if err := c.Check(g("evmd tx gov vote 1 yes", TierOnChain)); err != nil || len(asked) != 1 { + t.Fatalf("on-chain not asked: %v %v", err, asked) + } + if err := c.Check(g("curl x", TierDiagnose)); err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("deny rule ignored: %v", err) + } + if err := c.Check(Gate{Tier: TierDiagnose, Forbidden: "keys"}); err == nil { + t.Fatal("forbidden passed") + } + c.ReadOnly = true + if err := c.Check(g("make build", TierLocalChange)); err == nil { + t.Fatal("readonly allowed a change despite allow rule") + } + c.ReadOnly, c.AcceptEdits = false, true + asked = nil + if err := c.Check(Gate{Tier: TierLocalChange, InRoot: true, Prompt: "edit"}); err != nil || len(asked) != 0 { + t.Fatalf("accept-edits asked: %v", asked) + } + if err := c.Check(Gate{Tier: TierLocalChange, Prompt: "edit outside"}); err != nil || len(asked) != 1 { + t.Fatalf("out-of-root edit not asked: %v", asked) + } + asked = nil + if err := c.Check(Gate{Tier: TierDiagnose, AskByDefault: true, Prompt: "fetch"}); err != nil || len(asked) != 1 { + t.Fatalf("ask-by-default not asked: %v", asked) + } +} + +func TestSessionFreshness(t *testing.T) { + s := NewSession("/x") + if err := s.CheckFresh("k", []byte("a")); err == nil { + t.Fatal("unread file passed") + } + s.MarkRead("k", []byte("a")) + if err := s.CheckFresh("k", []byte("a")); err != nil { + t.Fatal(err) + } + if err := s.CheckFresh("k", []byte("b")); err == nil { + t.Fatal("changed file passed") + } + var nilS *Session + if nilS.Cwd("d") != "d" || nilS.CheckFresh("k", nil) != nil { + t.Fatal("nil session not permissive") + } +} diff --git a/internal/toolkit/session.go b/internal/toolkit/session.go new file mode 100644 index 0000000..e0ead6d --- /dev/null +++ b/internal/toolkit/session.go @@ -0,0 +1,81 @@ +package toolkit + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sync" +) + +// Session is state that general-purpose tools keep across calls within +// one agent session: the shell's working directory and which files the +// model has read (edits require a fresh read, so the model never +// overwrites content it hasn't seen). Nil-safe: CLI invocations without +// a session get defaults. +type Session struct { + mu sync.Mutex + cwd string + reads map[string]string // host-qualified path → content hash +} + +// NewSession starts session state with an initial working directory. +func NewSession(cwd string) *Session { + return &Session{cwd: cwd, reads: map[string]string{}} +} + +// Cwd returns the working directory, or def when unset. +func (s *Session) Cwd(def string) string { + if s == nil { + return def + } + s.mu.Lock() + defer s.mu.Unlock() + if s.cwd == "" { + return def + } + return s.cwd +} + +// SetCwd records a new working directory. +func (s *Session) SetCwd(dir string) { + if s == nil || dir == "" { + return + } + s.mu.Lock() + s.cwd = dir + s.mu.Unlock() +} + +// HashContent fingerprints file content for read-before-write checks. +func HashContent(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:8]) +} + +// MarkRead records that the model saw path with this content. +func (s *Session) MarkRead(key string, content []byte) { + if s == nil { + return + } + s.mu.Lock() + s.reads[key] = HashContent(content) + s.mu.Unlock() +} + +// CheckFresh verifies path was read in this session and is unchanged +// since. Without a session, nothing is enforced. +func (s *Session) CheckFresh(key string, current []byte) error { + if s == nil { + return nil + } + s.mu.Lock() + h, ok := s.reads[key] + s.mu.Unlock() + if !ok { + return fmt.Errorf("read the file first — edits and overwrites need the current content in context") + } + if h != HashContent(current) { + return fmt.Errorf("the file changed since it was last read — read it again before editing") + } + return nil +} diff --git a/internal/toolkit/toolkit.go b/internal/toolkit/toolkit.go index 9eb614d..d14dbf9 100644 --- a/internal/toolkit/toolkit.go +++ b/internal/toolkit/toolkit.go @@ -12,6 +12,7 @@ import ( "sort" "strings" "sync" + "time" ) // Tier is the safety classification of a tool. It drives the approval @@ -236,3 +237,39 @@ func RequireApproval(c *Context, prompt string, tier Tier, detail map[string]any } return nil } + +// Dynamic is implemented by tools whose risk depends on their arguments +// (a shell command, a file path). They gate themselves via Context.Check, +// so the agent advertises them even in read-only mode. +type Dynamic interface { + DynamicTier() bool +} + +// IsDynamic reports whether t decides its tier per call. +func IsDynamic(t Tool) bool { + d, ok := t.(Dynamic) + return ok && d.DynamicTier() +} + +// AgentOnly is implemented by general-purpose tools (bash, read, edit…) +// that exist for the agent loop only: they get no CLI subcommand and are +// not exported over MCP, where the client has its own equivalents. +type AgentOnly interface { + AgentOnly() bool +} + +// IsAgentOnly reports whether t is agent-only. +func IsAgentOnly(t Tool) bool { + a, ok := t.(AgentOnly) + return ok && a.AgentOnly() +} + +// Timeouter lets a tool set its own deadline per call (the default is 90s). +type Timeouter interface { + Timeout(args Args) time.Duration +} + +// OutputLimiter lets a tool raise how much of its output reaches the model. +type OutputLimiter interface { + OutputLimit() int +} diff --git a/internal/tools/fs/fs.go b/internal/tools/fs/fs.go new file mode 100644 index 0000000..84c5949 --- /dev/null +++ b/internal/tools/fs/fs.go @@ -0,0 +1,334 @@ +// Package fs implements the agent's file tools — read, write, edit, glob +// and grep — over the profile's host transport, so they work the same on +// the local machine and over SSH. +package fs + +import ( + "bytes" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/abhijitkrm/cometcli/internal/client/host" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// Register adds the file tools. +func Register(r *toolkit.Registry) { + r.Register(Read{}) + r.Register(Write{}) + r.Register(Edit{}) + r.Register(Glob{}) + r.Register(Grep{}) +} + +const ( + maxReadBytes = 10 << 20 + defaultLines = 2000 + maxLineLen = 2000 +) + +// base is embedded by every file tool. +type base struct{} + +func (base) AgentOnly() bool { return true } +func (base) OutputLimit() int { return 30_000 } + +// target resolves a tool path against the session's working directory. +type target struct { + h host.Host + path string // absolute, cleaned + key string // host-qualified, for read tracking +} + +func resolve(c *toolkit.Context, p string) (*target, error) { + if strings.TrimSpace(p) == "" { + return nil, fmt.Errorf("file_path is required") + } + h, err := c.Host() + if err != nil { + return nil, err + } + _, local := h.(*host.Local) + if strings.HasPrefix(p, "~/") && local { + if home, err := os.UserHomeDir(); err == nil { + p = filepath.Join(home, p[2:]) + } + } + if !path.IsAbs(p) { + cwd := c.Session.Cwd("") + if cwd == "" && local { + cwd, _ = os.Getwd() + } + if cwd == "" { + return nil, fmt.Errorf("use an absolute path on %s", h) + } + p = path.Join(cwd, p) + } + p = path.Clean(p) + return &target{h: h, path: p, key: h.String() + ":" + p}, nil +} + +// inRoot reports whether p lies under the session's work root (for +// accept-edits); only meaningful on the local host. +func (t *target) inRoot(c *toolkit.Context) bool { + if _, local := t.h.(*host.Local); !local || c.WorkRoot == "" { + return false + } + rel, err := filepath.Rel(c.WorkRoot, t.path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, "../") +} + +var protectedRe = regexp.MustCompile(`priv_validator_key\.json$|node_key\.json$|(^|/)\.?mnemonics?(\.txt)?(/|$)|mnemonic|(^|/)keyring-(file|test|os)(/|$)|/\.ssh/id_|/\.cometcli/keys(/|$)|/\.gnupg/`) + +// forbidden explains why a path may never pass through the agent. +func forbidden(p string, write bool) string { + if m := protectedRe.FindString(strings.ToLower(p)); m != "" { + return "protected key material (" + strings.Trim(m, "/") + ") never passes through the agent" + } + if write && strings.HasSuffix(p, "priv_validator_state.json") { + return "rewriting priv_validator_state.json risks double-signing" + } + return "" +} + +func (t *target) gate(c *toolkit.Context, tool string, tier toolkit.Tier, prompt string, detail map[string]any, write bool) error { + return c.Check(toolkit.Gate{ + Request: toolkit.Request{Tool: tool, Specs: []string{t.path}, Kind: "path"}, + Tier: tier, + Prompt: prompt, + Detail: detail, + Forbidden: forbidden(t.path, write), + InRoot: write && t.inRoot(c), + }) +} + +// readFile fetches content with size and binary checks. +func (t *target) readFile(c *toolkit.Context) ([]byte, error) { + fi, err := t.h.Stat(c, t.path) + if err != nil { + return nil, err + } + if fi.IsDir() { + return nil, fmt.Errorf("%s is a directory — use glob or bash ls", t.path) + } + if fi.Size() > maxReadBytes { + return nil, fmt.Errorf("%s is %d MB — too large; use bash with head/tail/grep", t.path, fi.Size()>>20) + } + b, err := t.h.ReadFile(c, t.path) + if err != nil { + return nil, err + } + if bytes.IndexByte(b[:min(len(b), 8192)], 0) >= 0 { + return nil, fmt.Errorf("%s looks binary — use bash (xxd, file, strings)", t.path) + } + return b, nil +} + +func (t *target) exists(c *toolkit.Context) (os.FileInfo, bool) { + fi, err := t.h.Stat(c, t.path) + return fi, err == nil +} + +// writeFile writes atomically, creating parent directories and keeping +// an existing file's mode. +func (t *target) writeFile(c *toolkit.Context, data []byte) error { + mode := os.FileMode(0o644) + if fi, ok := t.exists(c); ok { + mode = fi.Mode().Perm() + } else if _, local := t.h.(*host.Local); local { + if err := os.MkdirAll(filepath.Dir(t.path), 0o755); err != nil { + return err + } + } else if _, _, err := t.h.Run(c, "mkdir -p -- "+quote(path.Dir(t.path))); err != nil { + return err + } + return t.h.WriteFile(c, t.path, data, mode) +} + +func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + +// --- read ------------------------------------------------------------------ + +// Read shows a text file with line numbers. +type Read struct{ base } + +func (Read) Name() string { return "read" } +func (Read) Desc() string { + return "Read a text file on the node's host with line numbers (relative paths resolve against the shell's working directory). Reads up to 2000 lines; use offset/limit for more. Required before write/edit of an existing file. Key material is refused." +} +func (Read) Schema() map[string]any { + return toolkit.ObjSchema(map[string]any{ + "file_path": toolkit.Str("path to the file"), + "offset": toolkit.Int("first line to show, 1-based (default 1)"), + "limit": toolkit.Int("number of lines to show (default 2000)"), + }, "file_path") +} +func (Read) Tier() toolkit.Tier { return toolkit.TierDiagnose } + +func (Read) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + t, err := resolve(c, a.String("file_path", "")) + if err != nil { + return nil, err + } + if err := t.gate(c, "read", toolkit.TierDiagnose, "read "+t.path, nil, false); err != nil { + return nil, err + } + b, err := t.readFile(c) + if err != nil { + return nil, err + } + c.Session.MarkRead(t.key, b) + if len(b) == 0 { + return &toolkit.Result{Text: "(empty file)"}, nil + } + lines := strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") + off := max(int(a.Int("offset", 1)), 1) + lim := int(a.Int("limit", defaultLines)) + if lim <= 0 { + lim = defaultLines + } + if off > len(lines) { + return nil, fmt.Errorf("offset %d is past the end (%d lines)", off, len(lines)) + } + end := min(off-1+lim, len(lines)) + var out strings.Builder + for i := off - 1; i < end; i++ { + l := lines[i] + if len(l) > maxLineLen { + l = l[:maxLineLen] + "…[line truncated]" + } + fmt.Fprintf(&out, "%6d\t%s\n", i+1, l) + } + if off > 1 || end < len(lines) { + fmt.Fprintf(&out, "[lines %d-%d of %d", off, end, len(lines)) + if end < len(lines) { + fmt.Fprintf(&out, "; pass offset=%d for more", end+1) + } + out.WriteString("]\n") + } + return &toolkit.Result{Text: strings.TrimRight(out.String(), "\n")}, nil +} + +// --- write ----------------------------------------------------------------- + +// Write creates or replaces a file. +type Write struct{ base } + +func (Write) Name() string { return "write" } +func (Write) Desc() string { + return "Create or overwrite a file on the node's host. Overwriting requires reading the file first in this session. Prefer edit for changes to existing files. The operator approves the diff." +} +func (Write) Schema() map[string]any { + return toolkit.ObjSchema(map[string]any{ + "file_path": toolkit.Str("path to the file"), + "content": toolkit.Str("the complete new file content"), + }, "file_path", "content") +} +func (Write) Tier() toolkit.Tier { return toolkit.TierLocalChange } + +func (Write) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + t, err := resolve(c, a.String("file_path", "")) + if err != nil { + return nil, err + } + content, _ := a["content"].(string) + detail := map[string]any{"path": t.path, "host": t.h.String()} + verb := "create" + if _, ok := t.exists(c); ok { + if f := forbidden(t.path, true); f != "" { + return nil, fmt.Errorf("refused: %s", f) + } + old, err := t.readFile(c) + if err != nil { + return nil, err + } + if err := c.Session.CheckFresh(t.key, old); err != nil { + return nil, err + } + if string(old) == content { + return &toolkit.Result{Text: "no change — file already has this content"}, nil + } + detail["diff"] = toolkit.Diff(t.path, string(old), content) + verb = "overwrite" + } else { + detail["new_file"] = fmt.Sprintf("%d lines", strings.Count(content, "\n")+1) + } + if err := t.gate(c, "write", toolkit.TierLocalChange, verb+" "+t.path+" on "+t.h.String(), detail, true); err != nil { + return nil, err + } + if err := t.writeFile(c, []byte(content)); err != nil { + return nil, err + } + c.Session.MarkRead(t.key, []byte(content)) + return &toolkit.Result{Text: fmt.Sprintf("%sd %s (%d bytes)", strings.TrimSuffix(verb, "e"), t.path, len(content))}, nil +} + +// --- edit ------------------------------------------------------------------ + +// Edit replaces exact text in a file. +type Edit struct{ base } + +func (Edit) Name() string { return "edit" } +func (Edit) Desc() string { + return "Replace an exact string in a file on the node's host. old_string must match the file exactly (including indentation) and be unique unless replace_all is set. Read the file first. The operator approves the diff." +} +func (Edit) Schema() map[string]any { + return toolkit.ObjSchema(map[string]any{ + "file_path": toolkit.Str("path to the file"), + "old_string": toolkit.Str("exact text to replace"), + "new_string": toolkit.Str("replacement text"), + "replace_all": toolkit.Bool("replace every occurrence (default false)"), + }, "file_path", "old_string", "new_string") +} +func (Edit) Tier() toolkit.Tier { return toolkit.TierLocalChange } + +func (Edit) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + t, err := resolve(c, a.String("file_path", "")) + if err != nil { + return nil, err + } + if f := forbidden(t.path, true); f != "" { + return nil, fmt.Errorf("refused: %s", f) + } + oldS, _ := a["old_string"].(string) + newS, _ := a["new_string"].(string) + if oldS == "" { + return nil, fmt.Errorf("old_string is empty — use write to create a file") + } + if oldS == newS { + return nil, fmt.Errorf("old_string and new_string are identical") + } + b, err := t.readFile(c) + if err != nil { + return nil, err + } + if err := c.Session.CheckFresh(t.key, b); err != nil { + return nil, err + } + cur := string(b) + n := strings.Count(cur, oldS) + switch { + case n == 0: + return nil, fmt.Errorf("old_string not found in %s — it must match exactly, including whitespace; read the file again", t.path) + case n > 1 && !a.Bool("replace_all", false): + return nil, fmt.Errorf("old_string occurs %d times in %s — include more surrounding context to make it unique, or set replace_all", n, t.path) + } + count := 1 + if a.Bool("replace_all", false) { + count = n + } + next := strings.Replace(cur, oldS, newS, count) + detail := map[string]any{"path": t.path, "host": t.h.String(), "diff": toolkit.Diff(t.path, cur, next)} + if err := t.gate(c, "edit", toolkit.TierLocalChange, "edit "+t.path+" on "+t.h.String(), detail, true); err != nil { + return nil, err + } + if err := t.writeFile(c, []byte(next)); err != nil { + return nil, err + } + c.Session.MarkRead(t.key, []byte(next)) + return &toolkit.Result{Text: fmt.Sprintf("edited %s (%d replacement(s))\n%s", t.path, count, detail["diff"])}, nil +} diff --git a/internal/tools/fs/fs_test.go b/internal/tools/fs/fs_test.go new file mode 100644 index 0000000..80042ce --- /dev/null +++ b/internal/tools/fs/fs_test.go @@ -0,0 +1,127 @@ +package fs + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/client/host" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +func ctx(t *testing.T) (*toolkit.Context, string, *int) { + t.Helper() + dir := t.TempDir() + n := 0 + c := &toolkit.Context{ + Context: context.Background(), + Approver: func(*toolkit.Context, string, toolkit.Tier, map[string]any) (bool, error) { + n++ + return true, nil + }, + AutoApproveBelow: toolkit.TierLocalChange, + Session: toolkit.NewSession(dir), + WorkRoot: dir, + } + c.SetHost(&host.Local{}) + return c, dir, &n +} + +func TestReadEditRoundTrip(t *testing.T) { + c, dir, asked := ctx(t) + p := filepath.Join(dir, "app.toml") + os.WriteFile(p, []byte("[api]\nenable = true\nswagger = true\n"), 0o640) + + if _, err := (Edit{}).Run(c, toolkit.Args{"file_path": "app.toml", "old_string": "swagger = true", "new_string": "swagger = false"}); err == nil { + t.Fatal("edit without read allowed") + } + res, err := (Read{}).Run(c, toolkit.Args{"file_path": "app.toml"}) + if err != nil || !strings.Contains(res.Text, " 2\tenable = true") { + t.Fatalf("read = %q %v", res, err) + } + if _, err := (Edit{}).Run(c, toolkit.Args{"file_path": "app.toml", "old_string": "true", "new_string": "false"}); err == nil || !strings.Contains(err.Error(), "2 times") { + t.Fatalf("ambiguous edit: %v", err) + } + res, err = (Edit{}).Run(c, toolkit.Args{"file_path": "app.toml", "old_string": "swagger = true", "new_string": "swagger = false"}) + if err != nil || *asked != 1 || !strings.Contains(res.Text, "+ swagger = false") { + t.Fatalf("edit: %v asked=%d %q", err, *asked, res) + } + b, _ := os.ReadFile(p) + if string(b) != "[api]\nenable = true\nswagger = false\n" { + t.Fatalf("content = %q", b) + } + if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o640 { + t.Fatalf("mode changed to %v", fi.Mode().Perm()) + } + // an outside change invalidates the read + os.WriteFile(p, []byte("changed\n"), 0o640) + if _, err := (Edit{}).Run(c, toolkit.Args{"file_path": p, "old_string": "changed", "new_string": "x"}); err == nil || !strings.Contains(err.Error(), "changed since") { + t.Fatalf("stale edit: %v", err) + } +} + +func TestWriteNewFileAndAcceptEdits(t *testing.T) { + c, dir, asked := ctx(t) + c.AcceptEdits = true + if _, err := (Write{}).Run(c, toolkit.Args{"file_path": "sub/new.conf", "content": "a=1\n"}); err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(filepath.Join(dir, "sub", "new.conf")); string(b) != "a=1\n" || *asked != 0 { + t.Fatalf("content=%q asked=%d", b, *asked) + } + other := filepath.Join(t.TempDir(), "x.conf") + if _, err := (Write{}).Run(c, toolkit.Args{"file_path": other, "content": "b"}); err != nil || *asked != 1 { + t.Fatalf("outside root must ask: %v asked=%d", err, *asked) + } +} + +func TestKeyMaterialRefused(t *testing.T) { + c, dir, asked := ctx(t) + os.MkdirAll(filepath.Join(dir, "config"), 0o700) + key := filepath.Join(dir, "config", "priv_validator_key.json") + os.WriteFile(key, []byte(`{"priv_key":"secret"}`), 0o600) + for _, tc := range []struct { + tool toolkit.Tool + args toolkit.Args + }{ + {Read{}, toolkit.Args{"file_path": key}}, + {Write{}, toolkit.Args{"file_path": key, "content": "x"}}, + {Edit{}, toolkit.Args{"file_path": key, "old_string": "secret", "new_string": "x"}}, + {Read{}, toolkit.Args{"file_path": filepath.Join(dir, ".mnemonics", "mnemonics.txt")}}, + } { + if _, err := tc.tool.Run(c, tc.args); err == nil || !strings.Contains(err.Error(), "refused") { + t.Errorf("%s %v: err = %v", tc.tool.Name(), tc.args["file_path"], err) + } + } + if *asked != 0 { + t.Fatal("asked about key material instead of refusing") + } +} + +func TestGlobAndGrep(t *testing.T) { + c, dir, _ := ctx(t) + os.MkdirAll(filepath.Join(dir, "a", "b"), 0o755) + os.MkdirAll(filepath.Join(dir, ".git"), 0o755) + os.WriteFile(filepath.Join(dir, "a", "config.toml"), []byte("moniker = \"val\"\n"), 0o644) + os.WriteFile(filepath.Join(dir, "a", "b", "app.toml"), []byte("pruning = \"nothing\"\n"), 0o644) + os.WriteFile(filepath.Join(dir, ".git", "x.toml"), []byte("moniker\n"), 0o644) + os.WriteFile(filepath.Join(dir, "a", "node_key.json"), []byte("moniker secret\n"), 0o600) + + res, err := (Glob{}).Run(c, toolkit.Args{"pattern": "**/*.toml"}) + if err != nil || strings.Count(res.Text, "\n") != 1 || strings.Contains(res.Text, ".git") { + t.Fatalf("glob = %q %v", res.Text, err) + } + res, err = (Grep{}).Run(c, toolkit.Args{"pattern": "moniker", "output_mode": "content"}) + if err != nil || !strings.Contains(res.Text, "config.toml:1:moniker") { + t.Fatalf("grep = %q %v", res.Text, err) + } + if strings.Contains(res.Text, "node_key") || strings.Contains(res.Text, ".git") { + t.Fatalf("grep searched excluded files: %q", res.Text) + } + res, _ = (Grep{}).Run(c, toolkit.Args{"pattern": "nomatch-xyz"}) + if !strings.HasPrefix(res.Text, "no matches") { + t.Fatalf("no-match = %q", res.Text) + } +} diff --git a/internal/tools/fs/search.go b/internal/tools/fs/search.go new file mode 100644 index 0000000..6f7ce3c --- /dev/null +++ b/internal/tools/fs/search.go @@ -0,0 +1,203 @@ +package fs + +import ( + "fmt" + iofs "io/fs" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/abhijitkrm/cometcli/internal/client/host" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +const maxGlob = 500 + +// skipDirs are never descended into by glob, nor searched by grep. +var skipDirs = map[string]bool{".git": true, "node_modules": true, ".build": true, "vendor": true, ".cache": true, "__pycache__": true} + +// Glob finds files by name pattern. +type Glob struct{ base } + +func (Glob) Name() string { return "glob" } +func (Glob) Desc() string { + return "Find files by glob pattern (e.g. **/*.toml, config/*.json) under a directory on the node's host, newest first. Skips .git, node_modules and vendor." +} +func (Glob) Schema() map[string]any { + return toolkit.ObjSchema(map[string]any{ + "pattern": toolkit.Str("glob; ** spans directories"), + "path": toolkit.Str("directory to search (default: working directory)"), + }, "pattern") +} +func (Glob) Tier() toolkit.Tier { return toolkit.TierDiagnose } + +func (Glob) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + pat := strings.TrimSpace(a.String("pattern", "")) + if pat == "" { + return nil, fmt.Errorf("pattern is required") + } + t, err := resolve(c, a.String("path", ".")) + if err != nil { + return nil, err + } + if err := t.gate(c, "glob", toolkit.TierDiagnose, "list files under "+t.path, nil, false); err != nil { + return nil, err + } + full := pat + if !path.IsAbs(pat) { + full = path.Join(t.path, pat) + } + type hit struct { + p string + m time.Time + } + var hits []hit + more := false + if _, local := t.h.(*host.Local); local { + err = filepath.WalkDir(t.path, func(p string, d iofs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if p != t.path && skipDirs[d.Name()] { + return filepath.SkipDir + } + return nil + } + if toolkit.MatchPathGlob(full, p) { + if len(hits) >= 5*maxGlob { + more = true + return filepath.SkipAll + } + var m time.Time + if fi, err := d.Info(); err == nil { + m = fi.ModTime() + } + hits = append(hits, hit{p, m}) + } + return nil + }) + if err != nil { + return nil, err + } + } else { + cmd := fmt.Sprintf("find %s -type f -not -path '*/.git/*' -not -path '*/node_modules/*' -not -path '*/vendor/*' 2>/dev/null | head -n 50000", quote(t.path)) + out, _, _ := t.h.Run(c, cmd) + for _, p := range strings.Split(out, "\n") { + if p != "" && toolkit.MatchPathGlob(full, p) { + hits = append(hits, hit{p: p}) + } + } + } + sort.SliceStable(hits, func(i, j int) bool { return hits[i].m.After(hits[j].m) }) + if len(hits) == 0 { + return &toolkit.Result{Text: "no files match " + pat + " under " + t.path}, nil + } + if len(hits) > maxGlob { + hits, more = hits[:maxGlob], true + } + var b strings.Builder + for _, h := range hits { + b.WriteString(h.p + "\n") + } + if more { + fmt.Fprintf(&b, "[first %d matches — narrow the pattern or path]\n", maxGlob) + } + return &toolkit.Result{Text: strings.TrimRight(b.String(), "\n")}, nil +} + +// Grep searches file contents with a regular expression. +type Grep struct{ base } + +func (Grep) Name() string { return "grep" } +func (Grep) Desc() string { + return "Search file contents by regular expression on the node's host (ripgrep when installed, else grep -E). output_mode: files_with_matches (default), content (matching lines with numbers), or count. Key files are never searched." +} +func (Grep) Schema() map[string]any { + return toolkit.ObjSchema(map[string]any{ + "pattern": toolkit.Str("extended regular expression"), + "path": toolkit.Str("file or directory to search (default: working directory)"), + "glob": toolkit.Str("only files matching this name glob, e.g. *.toml"), + "output_mode": toolkit.Enum("what to return", "files_with_matches", "content", "count"), + "case_insensitive": toolkit.Bool("ignore case"), + "context": toolkit.Int("lines of context around each match (content mode)"), + "head_limit": toolkit.Int("max output lines (default 250)"), + }, "pattern") +} +func (Grep) Tier() toolkit.Tier { return toolkit.TierDiagnose } + +// grepExcludes keeps key material out of results. +var grepExcludes = []string{"priv_validator_key.json", "node_key.json", "*mnemonic*", "id_rsa*", "id_ed25519*", "id_ecdsa*"} +var grepExcludeDirs = []string{".git", "node_modules", "vendor", "keyring-file", "keyring-test", "keyring-os", ".mnemonics", ".gnupg"} + +func (Grep) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + pat := a.String("pattern", "") + if pat == "" { + return nil, fmt.Errorf("pattern is required") + } + t, err := resolve(c, a.String("path", ".")) + if err != nil { + return nil, err + } + if err := t.gate(c, "grep", toolkit.TierDiagnose, "search "+t.path, nil, false); err != nil { + return nil, err + } + mode := a.String("output_mode", "files_with_matches") + limit := int(a.Int("head_limit", 250)) + if limit <= 0 { + limit = 250 + } + var rg, gr []string + rg = append(rg, "rg", "--no-heading", "--color=never", "--no-messages") + gr = append(gr, "grep", "-rEI", "--color=never", "-s") + if a.Bool("case_insensitive", false) { + rg, gr = append(rg, "-i"), append(gr, "-i") + } + switch mode { + case "content": + rg, gr = append(rg, "-n"), append(gr, "-n") + if n := a.Int("context", 0); n > 0 { + rg, gr = append(rg, fmt.Sprintf("-C%d", n)), append(gr, fmt.Sprintf("-C%d", n)) + } + case "count": + rg, gr = append(rg, "-c"), append(gr, "-c") + default: + rg, gr = append(rg, "-l"), append(gr, "-l") + } + if g := a.String("glob", ""); g != "" { + rg, gr = append(rg, "--glob", quote(g)), append(gr, "--include="+quote(g)) + } + for _, x := range grepExcludes { + rg, gr = append(rg, "--glob", quote("!"+x)), append(gr, "--exclude="+quote(x)) + } + for _, x := range grepExcludeDirs { + rg, gr = append(rg, "--glob", quote("!"+x+"/")), append(gr, "--exclude-dir="+quote(x)) + } + tail := fmt.Sprintf(" -- %s %s", quote(pat), quote(t.path)) + script := fmt.Sprintf("if command -v rg >/dev/null 2>&1; then %s%s; else %s%s; fi | head -n %d", + strings.Join(rg, " "), tail, strings.Join(gr, " "), tail, limit+1) + res, err := host.Exec(c, t.h, script, 64<<10) + if err != nil { + return nil, err + } + out := strings.TrimRight(res.Output, "\n") + if mode == "count" { // grep -c lists every file; keep the hits + var keep []string + for _, l := range strings.Split(out, "\n") { + if !strings.HasSuffix(l, ":0") && l != "" { + keep = append(keep, l) + } + } + out = strings.Join(keep, "\n") + } + if out == "" { + return &toolkit.Result{Text: "no matches for " + pat + " under " + t.path}, nil + } + lines := strings.Split(out, "\n") + if len(lines) > limit { + out = strings.Join(lines[:limit], "\n") + fmt.Sprintf("\n[first %d lines — narrow the search or raise head_limit]", limit) + } + return &toolkit.Result{Text: out}, nil +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go index fa7e85d..99e1682 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -5,16 +5,19 @@ import ( "github.com/abhijitkrm/cometcli/internal/tools/chain" "github.com/abhijitkrm/cometcli/internal/tools/evmtool" "github.com/abhijitkrm/cometcli/internal/tools/fleet" + "github.com/abhijitkrm/cometcli/internal/tools/fs" "github.com/abhijitkrm/cometcli/internal/tools/keystool" "github.com/abhijitkrm/cometcli/internal/tools/montool" "github.com/abhijitkrm/cometcli/internal/tools/nettool" "github.com/abhijitkrm/cometcli/internal/tools/node" "github.com/abhijitkrm/cometcli/internal/tools/runbooktool" "github.com/abhijitkrm/cometcli/internal/tools/sectool" + "github.com/abhijitkrm/cometcli/internal/tools/shell" "github.com/abhijitkrm/cometcli/internal/tools/snaptool" "github.com/abhijitkrm/cometcli/internal/tools/txtool" "github.com/abhijitkrm/cometcli/internal/tools/upgradetool" "github.com/abhijitkrm/cometcli/internal/tools/val" + "github.com/abhijitkrm/cometcli/internal/tools/web" ) // RegisterAll registers every tool in the catalog. @@ -32,4 +35,8 @@ func RegisterAll(r *toolkit.Registry) { fleet.Register(r) nettool.Register(r) txtool.Register(r) + // general-purpose agent tools + shell.Register(r) + fs.Register(r) + web.Register(r) } diff --git a/internal/tools/shell/bash.go b/internal/tools/shell/bash.go new file mode 100644 index 0000000..d583a73 --- /dev/null +++ b/internal/tools/shell/bash.go @@ -0,0 +1,182 @@ +package shell + +import ( + "fmt" + "os" + "path" + "strings" + "time" + + "github.com/abhijitkrm/cometcli/internal/client/host" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +const ( + defaultTimeout = 2 * time.Minute + maxTimeout = 10 * time.Minute + maxOutput = 30_000 + cwdMarker = "__COMETCLI_CWD__" +) + +// Bash runs shell commands on the profile's host. +type Bash struct{} + +func (Bash) Name() string { return "bash" } +func (Bash) Desc() string { + return "Run a shell command on the node's host (local or over SSH). The working directory persists between calls; environment changes do not. stdin is closed, so commands that prompt get EOF. Read-only commands run immediately; commands that change the host ask the operator; transactions always ask; consensus keys, key ceremonies and state resets are refused. Prefer the read/grep/glob tools for files." +} +func (Bash) Schema() map[string]any { + return toolkit.ObjSchema(map[string]any{ + "command": toolkit.Str("the shell command to run (bash syntax)"), + "description": toolkit.Str("5-10 words on what this does, shown to the operator"), + "timeout": toolkit.Int("seconds before the command is killed (default 120, max 600)"), + }, "command") +} +func (Bash) Tier() toolkit.Tier { return toolkit.TierLocalChange } +func (Bash) DynamicTier() bool { return true } +func (Bash) AgentOnly() bool { return true } +func (Bash) OutputLimit() int { return maxOutput } + +// Timeout covers the command plus time for the operator to approve it. +func (Bash) Timeout(a toolkit.Args) time.Duration { return cmdTimeout(a) + 15*time.Minute } + +func cmdTimeout(a toolkit.Args) time.Duration { + d := time.Duration(a.Int("timeout", 0)) * time.Second + if d <= 0 { + return defaultTimeout + } + return min(d, maxTimeout) +} + +func (Bash) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + cmd := strings.TrimSpace(a.String("command", "")) + if cmd == "" { + return nil, fmt.Errorf("command is required") + } + h, err := c.Host() + if err != nil { + return nil, err + } + cwd := c.Session.Cwd(defaultCwd(h)) + binary := "" + if c.Profile != nil { + binary = c.Profile.Binary + } + v := Classify(cmd, Opts{Binary: binary, ReadScript: func(p string) ([]byte, bool) { + return readScript(c, h, cwd, p) + }}) + detail := map[string]any{"command": cmd, "host": h.String()} + if d := a.String("description", ""); d != "" { + detail["description"] = d + } + if cwd != "" { + detail["cwd"] = cwd + } + if v.Reason != "" { + detail["why"] = v.Reason + } + if len(v.Notes) > 0 { + detail["notes"] = strings.Join(v.Notes, "\n") + } + prompt := "run on " + h.String() + ": " + cmd + if err := c.Check(toolkit.Gate{ + Request: toolkit.Request{Tool: "bash", Specs: v.Segments, Kind: "command"}, + Tier: v.Tier, + Prompt: prompt, + Detail: detail, + Forbidden: v.Forbidden, + }); err != nil { + return nil, err + } + + ctx, cancel := toolkit.WithDeadline(c, cmdTimeout(a)) + defer cancel() + defer ctx.Close() + res, err := host.Exec(ctx, h, wrap(cmd, cwd), maxOutput) + c.LogShell(cmd, res.Code) + if err != nil { + return nil, err + } + out, newCwd := splitCwd(res.Output) + if res.Code == 97 && strings.TrimSpace(out) == "" && cwd != "" { + c.Session.SetCwd(defaultCwd(h)) + return nil, fmt.Errorf("working directory %s no longer exists — reset to %s; run the command again", cwd, c.Session.Cwd("")) + } + if newCwd != "" { + c.Session.SetCwd(newCwd) + } + var b strings.Builder + b.WriteString(strings.TrimRight(out, "\n")) + if res.TimedOut { + fmt.Fprintf(&b, "\n[killed after %s — pass a larger timeout (max 600s) or narrow the command]", cmdTimeout(a)) + } else if res.Code != 0 { + fmt.Fprintf(&b, "\n[exit code %d]", res.Code) + } + for _, n := range v.Notes { + fmt.Fprintf(&b, "\n[note: %s]", n) + } + text := strings.TrimLeft(b.String(), "\n") + if text == "" { + text = "(no output)" + } + return &toolkit.Result{Text: text, Data: map[string]any{ + "exit_code": res.Code, "cwd": c.Session.Cwd(newCwd), "timed_out": res.TimedOut, "truncated": res.Truncated, + }}, nil +} + +// defaultCwd is where a fresh session starts: the process directory for a +// local host, the login directory over SSH ("" = don't cd). +func defaultCwd(h host.Host) string { + if _, ok := h.(*host.Local); ok { + if d, err := os.Getwd(); err == nil { + return d + } + } + return "" +} + +// wrap runs cmd in cwd and reports the final working directory after it. +func wrap(cmd, cwd string) string { + var b strings.Builder + if cwd != "" { + fmt.Fprintf(&b, "cd -- %s 2>/dev/null || exit 97\n", quote(cwd)) + } + b.WriteString(cmd) + fmt.Fprintf(&b, "\n__cometcli_rc=$?\nprintf '\\n%s%%s\\n' \"$(pwd)\"\nexit $__cometcli_rc\n", cwdMarker) + return b.String() +} + +// splitCwd removes the trailing cwd marker line (and the newline wrap +// printed before it) from the output. +func splitCwd(out string) (string, string) { + i := strings.LastIndex(out, cwdMarker) + if i < 0 || (i > 0 && out[i-1] != '\n') { + return out, "" + } + dir, _, _ := strings.Cut(out[i+len(cwdMarker):], "\n") + return strings.TrimSuffix(out[:i], "\n"), strings.TrimSpace(dir) +} + +func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + +// readScript fetches a script the command would run, for classification. +func readScript(c *toolkit.Context, h host.Host, cwd, p string) ([]byte, bool) { + if strings.HasPrefix(p, "~/") { + if home, err := os.UserHomeDir(); err == nil { + if _, local := h.(*host.Local); local { + p = path.Join(home, p[2:]) + } + } + } else if !path.IsAbs(p) && cwd != "" { + p = path.Join(cwd, p) + } + fi, err := h.Stat(c, p) + if err != nil || fi.IsDir() || fi.Size() > 1<<20 { + return nil, false + } + b, err := h.ReadFile(c, p) + return b, err == nil +} + +// Register adds the bash tool. +func Register(r *toolkit.Registry) { r.Register(Bash{}) } diff --git a/internal/tools/shell/bash_test.go b/internal/tools/shell/bash_test.go new file mode 100644 index 0000000..ae54ee3 --- /dev/null +++ b/internal/tools/shell/bash_test.go @@ -0,0 +1,121 @@ +package shell + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/abhijitkrm/cometcli/internal/client/host" + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +func testCtx(t *testing.T, approve bool) (*toolkit.Context, *[]string) { + t.Helper() + var asked []string + c := &toolkit.Context{ + Context: context.Background(), + Profile: &config.Profile{Name: "t", Binary: "evmd"}, + Approver: func(_ *toolkit.Context, p string, _ toolkit.Tier, _ map[string]any) (bool, error) { + asked = append(asked, p) + return approve, nil + }, + AutoApproveBelow: toolkit.TierLocalChange, + Session: toolkit.NewSession(t.TempDir()), + } + c.SetHost(&host.Local{}) + return c, &asked +} + +func TestBashCwdPersistsAndReadsAutoRun(t *testing.T) { + c, asked := testCtx(t, true) + dir := c.Session.Cwd("") + if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := (Bash{}).Run(c, toolkit.Args{"command": "cd sub"}); err != nil { + t.Fatal(err) + } + res, err := (Bash{}).Run(c, toolkit.Args{"command": "pwd"}) + if err != nil { + t.Fatal(err) + } + want, _ := filepath.EvalSymlinks(filepath.Join(dir, "sub")) + got, _ := filepath.EvalSymlinks(strings.TrimSpace(res.Text)) + if got != want { + t.Fatalf("cwd = %q, want %q", res.Text, want) + } + if len(*asked) != 0 { + t.Fatalf("read-only commands asked: %v", *asked) + } +} + +func TestBashChangeAsksAndDenialStops(t *testing.T) { + c, asked := testCtx(t, false) + _, err := (Bash{}).Run(c, toolkit.Args{"command": "touch created.txt"}) + if err == nil || len(*asked) != 1 { + t.Fatalf("err=%v asked=%v", err, *asked) + } + if _, serr := os.Stat(filepath.Join(c.Session.Cwd(""), "created.txt")); serr == nil { + t.Fatal("denied command ran") + } +} + +func TestBashForbiddenNeverAsks(t *testing.T) { + c, asked := testCtx(t, true) + _, err := (Bash{}).Run(c, toolkit.Args{"command": "cat config/priv_validator_key.json"}) + if err == nil || !strings.Contains(err.Error(), "refused") || len(*asked) != 0 { + t.Fatalf("err=%v asked=%v", err, *asked) + } +} + +func TestBashExitCodeAndStderr(t *testing.T) { + c, _ := testCtx(t, true) + res, err := (Bash{}).Run(c, toolkit.Args{"command": "echo out; echo err >&2; false"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(res.Text, "out") || !strings.Contains(res.Text, "err") || !strings.Contains(res.Text, "[exit code 1]") { + t.Fatalf("text = %q", res.Text) + } + if strings.Contains(res.Text, cwdMarker) { + t.Fatal("cwd marker leaked") + } +} + +func TestBashTimeoutKillsProcessGroup(t *testing.T) { + c, _ := testCtx(t, true) + start := time.Now() + res, err := (Bash{}).Run(c, toolkit.Args{"command": "sleep 30 | cat", "timeout": float64(1)}) + if err != nil { + t.Fatal(err) + } + if time.Since(start) > 10*time.Second || !strings.Contains(res.Text, "killed after") { + t.Fatalf("not killed promptly: %s %q", time.Since(start), res.Text) + } +} + +func TestBashScriptWithTxIsOnChain(t *testing.T) { + c, asked := testCtx(t, false) + dir := c.Session.Cwd("") + script := filepath.Join(dir, "vote.sh") + os.WriteFile(script, []byte("#!/bin/sh\nevmd tx gov vote 1 yes --from v\n"), 0o755) + c.AutoApproveBelow = toolkit.TierOnChain // bypass mode + _, err := (Bash{}).Run(c, toolkit.Args{"command": "./vote.sh"}) + if err == nil || len(*asked) != 1 { + t.Fatalf("script broadcasting a tx ran without asking in bypass: err=%v asked=%v", err, *asked) + } +} + +func TestSplitCwd(t *testing.T) { + out, dir := splitCwd("hello\n\n" + cwdMarker + "/tmp/x\n") + if out != "hello\n" || dir != "/tmp/x" { + t.Fatalf("out=%q dir=%q", out, dir) + } + if out, dir := splitCwd("no marker"); out != "no marker" || dir != "" { + t.Fatal("altered output without marker") + } +} diff --git a/internal/tools/shell/classify.go b/internal/tools/shell/classify.go new file mode 100644 index 0000000..dc429f0 --- /dev/null +++ b/internal/tools/shell/classify.go @@ -0,0 +1,1068 @@ +// Package shell implements the general-purpose `bash` tool: shell +// commands on the profile's host (local or SSH), with a per-call risk +// classification that drives the permission gate. +package shell + +import ( + "path" + "regexp" + "strings" + + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// Verdict is the classification of a whole command line. +type Verdict struct { + Tier toolkit.Tier + Reason string // why the tier is what it is (shown when asking) + Forbidden string // non-empty: refuse in every mode + Segments []string // normalized simple commands, for rule matching + Notes []string // advisories (e.g. the script prompts for input) +} + +// Opts tunes classification for the session's node. +type Opts struct { + // Binary is the profile's chain binary (e.g. "evmd"), recognized + // alongside the generic "d" convention. + Binary string + // ReadScript returns a script file's content (relative paths resolve + // against the working directory); ok=false when unreadable. + ReadScript func(path string) (content []byte, ok bool) +} + +type acc struct { + opts Opts + v Verdict + depth int +} + +func (a *acc) raise(t toolkit.Tier, reason string) { + if t > a.v.Tier || a.v.Reason == "" && t == a.v.Tier && t >= toolkit.TierLocalChange { + a.v.Tier, a.v.Reason = t, reason + } +} + +func (a *acc) forbid(reason string) { + if a.v.Forbidden == "" { + a.v.Forbidden = reason + } +} + +func (a *acc) note(n string) { + for _, x := range a.v.Notes { + if x == n { + return + } + } + a.v.Notes = append(a.v.Notes, n) +} + +// Classify decides the effective tier of a command line: the highest tier +// of any simple command in it, with redirects to files, substitutions, +// wrappers (sudo, env, xargs, docker exec, bash -c) and invoked scripts +// all taken into account. Unknown commands are local-change (ask). +func Classify(cmd string, o Opts) Verdict { + a := &acc{opts: o, v: Verdict{Tier: toolkit.TierDiagnose}} + a.line(cmd) + return a.v +} + +func (a *acc) line(cmd string) { + if a.depth > 4 { + a.raise(toolkit.TierLocalChange, "deeply nested command") + return + } + a.depth++ + defer func() { a.depth-- }() + if strings.Contains(cmd, ":(){") || strings.Contains(cmd, ":() {") { + a.forbid("fork bomb") + } + segs, subs, writes := lex(cmd) + for _, w := range writes { + if hit, why := protectedPath(w); hit { + a.forbid("writes to " + why) + } + if strings.Contains(w, "priv_validator_state.json") { + a.forbid("overwriting priv_validator_state.json risks double-signing") + } + a.raise(toolkit.TierLocalChange, "writes to "+w) + } + for _, s := range subs { + a.line(s) + } + for _, words := range segs { + if len(words) == 0 { + continue + } + a.v.Segments = append(a.v.Segments, strings.Join(words, " ")) + a.simple(words) + } +} + +// --- lexer --------------------------------------------------------------- + +// lex splits a command line into simple commands (as word lists, quotes +// removed), the bodies of $(…), `…` and <(…) substitutions, and the +// targets of output redirections other than /dev/null and fd dups. +func lex(s string) (segs [][]string, subs []string, writes []string) { + var words []string + var cur strings.Builder + inWord := false + endWord := func() { + if inWord { + words = append(words, cur.String()) + cur.Reset() + inWord = false + } + } + endSeg := func() { + endWord() + if len(words) > 0 { + segs = append(segs, words) + } + words = nil + } + pendingRedirect := false + finishWord := func() { + if pendingRedirect && inWord { + t := cur.String() + if t != "/dev/null" && t != "/dev/stdout" && t != "/dev/stderr" && !strings.HasPrefix(t, "&") { + writes = append(writes, t) + } + cur.Reset() + inWord = false + pendingRedirect = false + return + } + endWord() + } + // readSub consumes a balanced (...) starting after the opening paren. + readSub := func(i int) (string, int) { + depth, j := 1, i + var q byte + for ; j < len(s); j++ { + c := s[j] + switch { + case q != 0: + if c == q { + q = 0 + } else if c == '\\' && q == '"' { + j++ + } + case c == '\'' || c == '"': + q = c + case c == '\\': + j++ + case c == '(': + depth++ + case c == ')': + depth-- + if depth == 0 { + return s[i:j], j + } + } + } + return s[i:], len(s) + } + for i := 0; i < len(s); i++ { + c := s[i] + switch c { + case '\\': + if i+1 < len(s) { + if s[i+1] != '\n' { + cur.WriteByte(s[i+1]) + inWord = true + } + i++ + } + case '\'': + j := strings.IndexByte(s[i+1:], '\'') + if j < 0 { + j = len(s) - i - 1 + } + cur.WriteString(s[i+1 : i+1+j]) + inWord = true + i += j + 1 + case '"': + inWord = true + for i++; i < len(s) && s[i] != '"'; i++ { + switch { + case s[i] == '\\' && i+1 < len(s): + i++ + cur.WriteByte(s[i]) + case s[i] == '$' && i+1 < len(s) && s[i+1] == '(': + body, end := readSub(i + 2) + subs = append(subs, body) + cur.WriteString("$(" + body + ")") + i = end + case s[i] == '`': + j := strings.IndexByte(s[i+1:], '`') + if j < 0 { + j = len(s) - i - 1 + } + subs = append(subs, s[i+1:i+1+j]) + i += j + 1 + default: + cur.WriteByte(s[i]) + } + } + case '$': + if i+1 < len(s) && s[i+1] == '(' { + body, end := readSub(i + 2) + subs = append(subs, body) + cur.WriteString("$(" + body + ")") + inWord = true + i = end + } else { + cur.WriteByte(c) + inWord = true + } + case '`': + j := strings.IndexByte(s[i+1:], '`') + if j < 0 { + j = len(s) - i - 1 + } + subs = append(subs, s[i+1:i+1+j]) + i += j + 1 + case '<': + if i+1 < len(s) && s[i+1] == '(' { // process substitution + body, end := readSub(i + 2) + subs = append(subs, body) + i = end + continue + } + finishWord() + if i+1 < len(s) && s[i+1] == '<' { // heredoc / herestring + i++ + if i+1 < len(s) && s[i+1] == '<' { + i++ // <<< herestring: the word that follows is data + continue + } + // <<[-]DELIM: the body up to a line holding DELIM is data, + // not commands — skip it, keeping the rest of this line + j := i + 1 + if j < len(s) && s[j] == '-' { + j++ + } + for j < len(s) && (s[j] == ' ' || s[j] == '\t') { + j++ + } + k := j + for k < len(s) && !strings.ContainsRune(" \t\n;&|<>()", rune(s[k])) { + k++ + } + delim := strings.Trim(s[j:k], `'"`) + if delim == "" { + continue + } + nl := strings.IndexByte(s[k:], '\n') + if nl < 0 { + i = k - 1 + continue + } + bodyStart := k + nl + 1 + end := len(s) + for pos := bodyStart; pos < len(s); { + e := strings.IndexByte(s[pos:], '\n') + line := s[pos:] + if e >= 0 { + line = s[pos : pos+e] + } + if strings.TrimLeft(line, "\t") == delim { + end = pos + len(line) + break + } + if e < 0 { + break + } + pos += e + 1 + } + // lex the rest of the heredoc's own line, then resume after + // the delimiter line + restSegs, restSubs, restWrites := lex(s[k : k+nl]) + if len(restSegs) > 0 { + words = append(words, restSegs[0]...) + segs = append(segs, restSegs[1:]...) + } + subs = append(subs, restSubs...) + writes = append(writes, restWrites...) + i = end - 1 + continue + } + case '>': + // "2>&1", ">&2" are fd dups; "N>" redirects fd N + if inWord && isDigits(cur.String()) { + cur.Reset() + inWord = false + } else { + finishWord() + } + if i+1 < len(s) && (s[i+1] == '>' || s[i+1] == '|') { + i++ + } + if i+1 < len(s) && s[i+1] == '&' { + i++ + for i+1 < len(s) && (isDigit(s[i+1]) || s[i+1] == '-') { + i++ + } + continue + } + pendingRedirect = true + case ';', '\n': + finishWord() + endSeg() + case '&': + if i+1 < len(s) && s[i+1] == '>' { // &> file + finishWord() + i++ + if i+1 < len(s) && s[i+1] == '>' { + i++ + } + pendingRedirect = true + continue + } + finishWord() + endSeg() + if i+1 < len(s) && s[i+1] == '&' { + i++ + } + case '|': + finishWord() + endSeg() + if i+1 < len(s) && (s[i+1] == '|' || s[i+1] == '&') { + i++ + } + case '(', ')', '{', '}': + if inWord { + cur.WriteByte(c) + } else if c == '(' || c == ')' { + endSeg() + } else { + // lone { } group delimiters + finishWord() + } + case ' ', '\t', '\r': + finishWord() + case '#': + if !inWord { // comment to end of line + j := strings.IndexByte(s[i:], '\n') + if j < 0 { + i = len(s) + } else { + i += j - 1 + } + continue + } + cur.WriteByte(c) + default: + cur.WriteByte(c) + inWord = true + } + } + finishWord() + endSeg() + return segs, subs, writes +} + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } +func isDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if !isDigit(s[i]) { + return false + } + } + return true +} + +// --- sensitive material -------------------------------------------------- + +var protectedRe = regexp.MustCompile(`priv_validator_key\.json|node_key\.json|(^|/)\.?mnemonics?(\.txt)?(/|$)|mnemonic|keyring-(file|test|os)|/\.ssh/id_|\.cometcli/keys|\.gnupg/`) + +func protectedPath(word string) (bool, string) { + if m := protectedRe.FindString(strings.ToLower(word)); m != "" { + return true, "protected key material (" + strings.Trim(m, "/") + ")" + } + return false, "" +} + +// permOnly commands may name key files without reading them: permission +// audits and fixes are legitimate. +var permOnly = map[string]bool{"ls": true, "stat": true, "chmod": true, "chown": true, "test": true, "[": true, "du": true, "file": true} + +// --- simple commands ----------------------------------------------------- + +var assignRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`) + +// daemonNames end in "d" but aren't chain binaries. +var daemonNames = map[string]bool{"sshd": true, "systemd": true, "dockerd": true, "containerd": true, "chronyd": true, "named": true, "httpd": true, "crond": true, "rsyslogd": true, "ntpd": true, "launchd": true, "find": true, "fd": true, "sed": true, "head": true, "pwd": true, "cd": true, "add": true, "rmd": true, + "dd": true, "chmod": true, "read": true, "kind": true, "bind": true, "cmd": true, "od": true, "xxd": true, "fold": true, + "expand": true, "unexpand": true, "etcd": true, "uuidd": true, "md": true} + +func (a *acc) isChainBinary(word string) bool { + b := path.Base(word) + if a.opts.Binary != "" && b == path.Base(a.opts.Binary) { + return true + } + return len(b) > 2 && strings.HasSuffix(b, "d") && !daemonNames[b] && chainNameRe.MatchString(b) +} + +var chainNameRe = regexp.MustCompile(`^[a-z][a-z0-9_-]*d$`) + +var broadcastRe = regexp.MustCompile(`eth_sendRawTransaction|eth_sendTransaction|broadcast_tx_(sync|async|commit)|/cosmos/tx/v1beta1/txs|BroadcastTx`) + +// onChain spots a transaction broadcast anywhere in a simple command, +// including when wrapped (docker exec evmd tx …). +func (a *acc) onChain(words []string) (bool, string) { + for i, w := range words { + if a.isChainBinary(w) { + for j := i + 1; j+1 < len(words); j++ { + if words[j] == "tx" && !strings.HasPrefix(words[j+1], "-") { + for _, f := range words[j:] { + if f == "--generate-only" || f == "--dry-run" { + return false, "" + } + } + return true, "broadcasts a transaction (" + path.Base(w) + " tx " + words[j+1] + ")" + } + } + } + if path.Base(w) == "cast" && i+1 < len(words) { + switch words[i+1] { + case "send", "publish": + return true, "broadcasts a transaction (cast " + words[i+1] + ")" + } + } + } + if m := broadcastRe.FindString(strings.Join(words, " ")); m != "" { + return true, "broadcasts a transaction (" + m + ")" + } + return false, "" +} + +func (a *acc) simple(words []string) { + // leading VAR=value assignments + for len(words) > 0 && assignRe.MatchString(words[0]) { + words = words[1:] + } + if len(words) == 0 { + return + } + if ok, why := a.onChain(words); ok { + a.raise(toolkit.TierOnChain, why) + } + name := path.Base(words[0]) + args := words[1:] + + for _, w := range words { + if hit, why := protectedPath(w); hit && !permOnly[name] { + a.forbid("touches " + why) + } + } + if (name == "rm" || name == "mv" || name == "truncate" || name == "shred") && containsWord(args, "priv_validator_state.json") { + a.forbid("removing priv_validator_state.json risks double-signing") + } + + switch name { + case "sudo", "doas": + a.raise(toolkit.TierLocalChange, "runs as root ("+name+")") + a.simple(skipFlags(args, "-u", "-g", "-C", "-h", "-p", "-U")) + return + case "env": + rest := skipFlags(args, "-u", "-C", "-S") + for len(rest) > 0 && assignRe.MatchString(rest[0]) { + rest = rest[1:] + } + a.simple(rest) + return + case "time", "nice", "nohup", "command", "exec", "builtin", "stdbuf", "ionice", "chrt", "unbuffer": + if name == "command" && len(args) > 0 && (args[0] == "-v" || args[0] == "-V") { + return + } + a.simple(skipFlags(args, "-n", "-c", "-p", "-o", "-e", "-i")) + return + case "timeout": + rest := skipFlags(args, "-s", "-k", "--signal", "--kill-after") + if len(rest) > 0 { + rest = rest[1:] // duration + } + a.simple(rest) + return + case "watch": + a.simple(skipFlags(args, "-n", "-d", "--interval")) + return + case "xargs": + rest := skipFlags(args, "-n", "-I", "-P", "-L", "-d", "-E", "-s", "-a") + if len(rest) == 0 { + return // xargs alone is echo + } + a.simple(rest) + return + case "bash", "sh", "zsh", "dash", "ksh": + for i, x := range args { + if x == "-c" && i+1 < len(args) { + a.line(args[i+1]) + return + } + } + if s := firstNonFlag(args); s != "" { + a.script(s) + return + } + a.raise(toolkit.TierLocalChange, "starts a shell") + return + case "source", ".": + if len(args) > 0 { + a.script(args[0]) + } + return + } + + if fn, ok := checkers[name]; ok { + if ok, why := fn(args); !ok { + if why == "" { + why = name + " " + strings.Join(firstN(args, 2), " ") + } + a.raise(toolkit.TierLocalChange, strings.TrimSpace(why)) + } + return + } + if fn, ok := wrappers[name]; ok { + fn(a, args) + return + } + if readOnly[name] { + return + } + if a.isChainBinary(words[0]) { + a.chainCmd(name, args) + return + } + if strings.Contains(words[0], "/") || strings.HasSuffix(name, ".sh") { + a.script(words[0]) + return + } + a.raise(toolkit.TierLocalChange, "runs "+name) +} + +// script classifies running a script file: local-change at least, and +// on-chain when its body broadcasts transactions. +func (a *acc) script(p string) { + a.raise(toolkit.TierLocalChange, "runs script "+p) + if a.opts.ReadScript == nil { + return + } + raw, ok := a.opts.ReadScript(p) + if !ok { + return + } + body := stripComments(raw) + if m := scriptTxRe.Find(body); m != nil { + a.raise(toolkit.TierOnChain, "script "+p+" broadcasts transactions ("+strings.TrimSpace(string(m))+"…)") + } + if m := scriptKeyRe.Find(body); m != nil { + a.forbid("script " + p + " handles key material (" + strings.TrimSpace(string(m)) + ") and would print it into the conversation — the operator runs it in their own terminal") + } + if scriptPromptRe.Match(body) { + a.note("script " + p + " prompts for input (read -p) — stdin is closed here, so prompts get EOF; check for a --yes/non-interactive flag, or ask the operator to run it in their terminal") + } + if scriptSudoRe.Match(body) { + a.note("script " + p + " uses sudo — without cached credentials it fails asking for a password; the operator may need to run it") + } + if strings.Contains(string(body), "unsafe-reset-all") { + a.note("script " + p + " contains unsafe-reset-all") + } +} + +var ( + // a chain binary (or $BINARY-style variable) … tx : the module + // list keeps prose ("enabled, tx indexer") from matching + scriptTxRe = regexp.MustCompile(`(?m)(\b[a-z][a-z0-9_-]*d\b|\$\{?[A-Z_]*(BIN|BINARY|DAEMON|CMD)[A-Z_]*\}?)[^\n]{0,200}?\btx\s+(bank|staking|gov|slashing|distribution|upgrade|ibc|ibc-transfer|transfer|evm|vm|erc20|feemarket|authz|feegrant|vesting|crisis|wasm|precisebank|consensus|circuit|group|nft|mint|ratelimit|ics20|interchain-accounts)\b|\bcast\s+send\b|\beth_sendRawTransaction\b`) + scriptSudoRe = regexp.MustCompile(`(?m)^[^#\n]*\bsudo\s`) + scriptKeyRe = regexp.MustCompile(`\bkeys\s+(add|export|mnemonic|import|unsafe-export-eth-key)\b|\bcast\s+wallet\s+(new|new-mnemonic|private-key|decrypt-keystore)`) + scriptPromptRe = regexp.MustCompile(`(?m)\bread\s+(-[a-zA-Z]+\s+)*-[a-zA-Z]*p`) +) + +func (a *acc) chainCmd(name string, args []string) { + sub := firstNonFlag(args) + switch sub { + case "q", "query", "status", "version", "debug", "validate-genesis", "validate", "export", "help", "": + return + case "keys": + switch firstNonFlag(args[indexOf(args, "keys")+1:]) { + case "list", "show", "parse", "": + return + } + a.forbid("key management (" + name + " keys …) handles key material — the operator runs these") + return + case "comet", "tendermint", "cometbft": + rest := args[indexOf(args, sub)+1:] + s2 := firstNonFlag(rest) + if strings.HasPrefix(s2, "unsafe-reset") || s2 == "reset-state" { + a.forbid(name + " " + sub + " " + s2 + " wipes chain state — the operator must run it") + return + } + if strings.HasPrefix(s2, "show-") || s2 == "version" { + return + } + case "unsafe-reset-all": + a.forbid(name + " unsafe-reset-all wipes chain state — the operator must run it") + return + case "config": + if len(args) > 1 && (args[1] == "get" || args[1] == "view") { + return + } + case "tx": + return // onChain already raised it (or it's --generate-only) + } + a.raise(toolkit.TierLocalChange, "runs "+name+" "+sub) +} + +// --- tables -------------------------------------------------------------- + +var readOnly = setOf(`ls cat head tail less more wc uniq cut tr nl column diff cmp comm grep egrep fgrep rg ag jq + stat file du df free uptime uname whoami id groups cal which whereis type echo printf pwd true false test [ + sleep basename dirname realpath readlink printenv ps pgrep vmstat iostat mpstat sar lsof ss netstat + traceroute tracepath dig nslookup host nproc lscpu lsblk lsmem lspci lsusb findmnt blkid last w who getent + sha256sum sha1sum sha512sum shasum md5sum b2sum cksum xxd hexdump od strings tree fd zcat zgrep bzcat xzcat + cd pushd popd export unset set alias seq yes expr bc factor numfmt locale tty arch sw_vers system_profiler + vm_stat top htop btop atop iotop nload ifstat grpcurl`) + +var checkers = map[string]func([]string) (bool, string){ + "date": func(a []string) (bool, string) { return !hasFlag(a, "-s", "--set"), "sets the clock" }, + "hostname": func(a []string) (bool, string) { return firstNonFlag(a) == "", "sets the hostname" }, + "sort": func(a []string) (bool, string) { return !hasFlagPrefix(a, "-o", "--output"), "writes a file" }, + "tee": func(a []string) (bool, string) { return allAre(nonFlags(a), "/dev/null"), "writes files" }, + "sed": func(a []string) (bool, string) { return !sedInPlace(a), "edits files in place" }, + "awk": awkOK, "gawk": awkOK, "mawk": awkOK, + "yq": func(a []string) (bool, string) { return !hasFlagPrefix(a, "-i", "--inplace"), "edits files in place" }, + "find": findOK, + "curl": curlOK, + "wget": func(a []string) (bool, string) { + return hasFlag(a, "-O-", "-qO-", "--spider") || containsSeq(a, "-O", "-") || containsSeq(a, "-O", "/dev/null"), "downloads to a file" + }, + "ping": func(a []string) (bool, string) { return true, "" }, + "nc": func(a []string) (bool, string) { return hasFlagLetter(a, 'z'), "opens a raw connection" }, + "ip": ipOK, + "ifconfig": func(a []string) (bool, string) { return len(nonFlags(a)) <= 1, "changes an interface" }, + "mount": func(a []string) (bool, string) { return len(nonFlags(a)) == 0, "mounts a filesystem" }, + "dmesg": func(a []string) (bool, string) { + return !hasFlag(a, "-c", "-C", "--clear", "--read-clear"), "clears the kernel log" + }, + "tar": func(a []string) (bool, string) { return tarListOnly(a), "extracts or creates an archive" }, + "unzip": func(a []string) (bool, string) { return hasFlag(a, "-l", "-t", "-v", "-Z"), "extracts an archive" }, + "gzip": func(a []string) (bool, string) { + return hasFlag(a, "-l", "-t", "-c", "--list", "--test", "--stdout"), "compresses files" + }, + "openssl": opensslOK, + "timedatectl": func(a []string) (bool, string) { + s := firstNonFlag(a) + return s == "" || s == "status" || s == "show" || s == "timesync-status" || s == "show-timesync", "changes time settings" + }, + "chronyc": func(a []string) (bool, string) { + return oneOf(firstNonFlag(a), "", "tracking", "sources", "sourcestats", "activity", "ntpdata", "serverstats"), "changes chrony" + }, + "ntpq": func(a []string) (bool, string) { return true, "" }, + "systemctl": systemctlOK, + "journalctl": func(a []string) (bool, string) { + return !hasFlagPrefix(a, "--vacuum", "--rotate", "--flush", "--sync", "--relinquish-var", "--setup-keys"), "maintains the journal" + }, + "service": func(a []string) (bool, string) { + return len(a) == 0 || a[len(a)-1] == "status" || hasFlag(a, "--status-all"), "controls a service" + }, + "launchctl": func(a []string) (bool, string) { + return oneOf(firstNonFlag(a), "list", "print", "print-disabled", "blame", "version"), "controls launchd" + }, + "git": gitOK, + "kubectl": kubectlOK, + "helm": func(a []string) (bool, string) { + return oneOf(firstNonFlag(a), "list", "ls", "status", "get", "history", "show", "version", "search", "env"), "changes a release" + }, + "brew": func(a []string) (bool, string) { + return oneOf(firstNonFlag(a), "list", "ls", "info", "--version", "outdated", "config", "doctor", "deps", "leaves", "search", "home"), "changes packages" + }, + "apt": func(a []string) (bool, string) { + return oneOf(firstNonFlag(a), "list", "show", "policy", "search", "depends", "rdepends"), "changes packages" + }, + "apt-cache": func(a []string) (bool, string) { return true, "" }, + "dpkg": func(a []string) (bool, string) { + return hasFlag(a, "-l", "-L", "-s", "-S", "--list", "--status", "--listfiles", "--search", "--get-selections"), "changes packages" + }, + "rpm": func(a []string) (bool, string) { + return len(a) > 0 && strings.HasPrefix(a[0], "-q"), "changes packages" + }, + "go": func(a []string) (bool, string) { + return oneOf(firstNonFlag(a), "version", "env", "list", "doc", "help"), "runs go " + firstNonFlag(a) + }, + "cosmovisor": func(a []string) (bool, string) { + return oneOf(firstNonFlag(a), "version", "config", "help", "show-upgrade-info"), "runs cosmovisor " + firstNonFlag(a) + }, + "cast": func(a []string) (bool, string) { + s := firstNonFlag(a) + if s == "wallet" { + return false, "cast wallet handles keys" + } + return oneOf(s, "call", "block", "block-number", "bn", "chain-id", "cid", "balance", "b", "tx", "t", "receipt", "re", "code", "storage", "nonce", "gas-price", "gp", "logs", "age", "client", "basefee", "estimate", "e", "to-hex", "to-dec", "to-wei", "from-wei", "abi-decode", "abi-encode", "calldata", "4byte", "sig", "keccak", "to-check-sum-address", "find-block", "run", "rpc", "decode-transaction"), "runs cast " + s + }, + "docker": dockerOK, + "docker-compose": func(a []string) (bool, string) { + return composeOK(a), "changes containers (compose " + firstNonFlag(a) + ")" + }, + "podman": dockerOK, +} + +// wrappers recurse into an inner command (docker exec ). +var wrappers = map[string]func(*acc, []string){} + +func init() { + wrappers["docker"] = dockerWrap + wrappers["podman"] = dockerWrap + delete(checkers, "docker") + delete(checkers, "podman") +} + +func dockerWrap(a *acc, args []string) { + rest := skipFlags(args, "--context", "-c", "-H", "--host", "--config", "-l", "--log-level") + if len(rest) == 0 { + return + } + sub := rest[0] + if sub == "container" && len(rest) > 1 { + sub, rest = rest[1], rest[1:] + } + switch sub { + case "exec": + inner := skipFlags(rest[1:], "-u", "--user", "-w", "--workdir", "-e", "--env", "--env-file", "--detach-keys") + if len(inner) > 1 { + a.simple(inner[1:]) // inner[0] is the container + } + return + case "run": + a.raise(toolkit.TierLocalChange, "starts a container (docker run)") + inner := skipFlags(rest[1:], "-u", "--user", "-w", "--workdir", "-e", "--env", "--env-file", "-v", "--volume", + "--name", "--network", "--net", "-p", "--publish", "--entrypoint", "--mount", "--tmpfs", "--platform", "-h", "--hostname", "--add-host", "--label", "-l", "--memory", "-m", "--cpus", "--restart", "--log-driver", "--log-opt", "--ulimit", "--cap-add", "--cap-drop", "--security-opt", "--device", "--pid", "--ipc", "--user-ns", "--userns", "--stop-signal", "--stop-timeout", "--health-cmd", "--pull", "--gpus", "--dns", "--expose", "--link", "--cidfile", "--group-add", "--shm-size", "--workdir") + if len(inner) > 1 { + a.simple(inner[1:]) // inner[0] is the image + } + return + case "compose": + if !composeOK(rest[1:]) { + a.raise(toolkit.TierLocalChange, "changes containers (docker compose "+firstNonFlag(rest[1:])+")") + } + return + } + if ok, _ := dockerOK(rest); !ok { + a.raise(toolkit.TierLocalChange, "changes docker state (docker "+sub+")") + } +} + +func dockerOK(a []string) (bool, string) { + rest := skipFlags(a, "--context", "-c", "-H", "--host", "--config") + if len(rest) == 0 { + return true, "" + } + s := rest[0] + if (s == "container" || s == "image" || s == "network" || s == "volume" || s == "system" || s == "context" || s == "buildx" || s == "node" || s == "service") && len(rest) > 1 { + return oneOf(rest[1], "ls", "list", "inspect", "logs", "top", "port", "stats", "diff", "history", "df", "info", "show", "ps"), "changes docker state" + } + return oneOf(s, "ps", "images", "logs", "inspect", "stats", "top", "port", "diff", "version", "info", "history", "search", "events", "--version", "-v"), "changes docker state" +} + +func composeOK(a []string) bool { + rest := skipFlags(a, "-f", "--file", "-p", "--project-name", "--project-directory", "--env-file", "--profile", "--ansi", "--progress") + return len(rest) == 0 || oneOf(rest[0], "ps", "logs", "config", "ls", "images", "top", "version", "port", "events") +} + +func systemctlOK(a []string) (bool, string) { + s := firstNonFlag(a) + return s == "" || oneOf(s, "status", "is-active", "is-enabled", "is-failed", "list-units", "list-unit-files", + "list-timers", "list-sockets", "list-jobs", "show", "cat", "list-dependencies", "show-environment", "get-default"), "changes services (systemctl " + s + ")" +} + +func gitOK(a []string) (bool, string) { + rest := skipFlags(a, "-C", "-c", "--git-dir", "--work-tree") + if len(rest) == 0 { + return true, "" + } + s, args := rest[0], rest[1:] + why := "changes the repo (git " + s + ")" + switch s { + case "status", "log", "diff", "show", "rev-parse", "describe", "blame", "ls-files", "ls-tree", "ls-remote", + "shortlog", "reflog", "grep", "cat-file", "show-ref", "rev-list", "whatchanged", "name-rev", "merge-base", "version", "help": + return true, "" + case "branch": + return len(nonFlags(args)) == 0 && !hasFlag(args, "-d", "-D", "-m", "-M", "-c", "-C", "--delete", "--move", "--copy", "-u", "--set-upstream-to", "--unset-upstream"), why + case "tag": + return len(nonFlags(args)) == 0 || hasFlag(args, "-l", "--list"), why + case "remote": + return len(args) == 0 || args[0] == "-v" || args[0] == "show" || args[0] == "get-url", why + case "config": + return hasFlag(args, "--get", "--list", "-l", "--get-all", "--get-regexp"), why + case "stash": + return len(args) > 0 && (args[0] == "list" || args[0] == "show"), why + case "worktree": + return len(args) > 0 && args[0] == "list", why + } + return false, why +} + +func kubectlOK(a []string) (bool, string) { + rest := skipFlags(a, "-n", "--namespace", "--context", "--kubeconfig", "-l", "--selector", "-o", "--output", "--cluster", "--user", "-s", "--server") + s := firstNonFlag(rest) + why := "changes the cluster (kubectl " + s + ")" + switch s { + case "get", "describe", "logs", "top", "explain", "version", "api-resources", "api-versions", "cluster-info", "events", "": + return true, "" + case "config": + return oneOf(firstNonFlag(rest[1:]), "view", "get-contexts", "current-context", "get-clusters", "get-users"), why + case "auth": + return firstNonFlag(rest[1:]) == "can-i", why + case "rollout": + return oneOf(firstNonFlag(rest[1:]), "status", "history"), why + } + return false, why +} + +func findOK(a []string) (bool, string) { + for _, x := range a { + switch x { + case "-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprint0", "-fprintf", "-fls": + return false, "find " + x + " runs or changes things" + } + } + return true, "" +} + +var awkWriteRe = regexp.MustCompile(`system\s*\(|print[f]?[^;}]*>\s*"|\|\s*"|getline\s*<`) + +func awkOK(a []string) (bool, string) { + for _, x := range a { + if awkWriteRe.MatchString(x) { + return false, "awk program writes files or runs commands" + } + } + return !hasFlag(a, "-i"), "awk edits in place" +} + +var readRPCRe = regexp.MustCompile(`"method"\s*:\s*"(eth_(get|block|call|chainId|syncing|gasPrice|estimateGas|feeHistory|maxPriority|protocolVersion|accounts|coinbase|mining|hashrate)[A-Za-z]*|net_[A-Za-z]+|web3_[A-Za-z]+|txpool_(status|inspect|content)|debug_trace[A-Za-z]*|status|net_info|health|abci_info|abci_query|block[a-z_]*|validators|consensus_state|dump_consensus_state|genesis[a-z_]*|tx|tx_search|commit|num_unconfirmed_txs|unconfirmed_txs)"`) + +func curlOK(a []string) (bool, string) { + write := false + data := "" + for i, x := range a { + switch { + case x == "-o" || x == "--output": + if i+1 < len(a) && a[i+1] != "/dev/null" && a[i+1] != "-" { + return false, "curl writes a file" + } + case x == "-O" || x == "--remote-name" || x == "-T" || strings.HasPrefix(x, "--upload-file") || x == "-K" || x == "--config": + return false, "curl writes or uploads files" + case x == "-X" || x == "--request": + if i+1 < len(a) && !oneOf(strings.ToUpper(a[i+1]), "GET", "HEAD", "POST") { + return false, "curl " + a[i+1] + " request" + } + case x == "-d" || strings.HasPrefix(x, "--data") || x == "--json" || x == "-F" || x == "--form": + write = true + if i+1 < len(a) { + data += a[i+1] + } + case strings.HasPrefix(x, "-d") && len(x) > 2: + write = true + data += x[2:] + } + } + if write { + // JSON-RPC queries (eth_blockNumber, status, …) are reads + if readRPCRe.MatchString(data) { + return true, "" + } + return false, "curl sends data" + } + return true, "" +} + +func ipOK(a []string) (bool, string) { + rest := skipFlags(a, "-4", "-6", "-o", "-br", "-c", "-d", "-j", "-p", "-s") + if len(rest) <= 1 { + return true, "" + } + return oneOf(rest[1], "show", "list", "ls", "get"), "changes networking (ip " + strings.Join(firstN(rest, 2), " ") + ")" +} + +func opensslOK(a []string) (bool, string) { + s := firstNonFlag(a) + if oneOf(s, "s_client", "version", "ciphers", "x509", "verify", "dgst", "speed", "list") { + return !hasFlag(a, "-out"), "openssl writes a file" + } + return false, "runs openssl " + s +} + +func tarListOnly(a []string) bool { + for _, x := range a { + if x == "--list" || x == "-t" { + return true + } + if !strings.HasPrefix(x, "--") && strings.TrimLeft(x, "-") != "" && strings.ContainsRune(strings.TrimLeft(x, "-"), 't') && !strings.ContainsAny(x, "/.") { + return true + } + } + return false +} + +func sedInPlace(a []string) bool { + for _, x := range a { + if x == "--in-place" || strings.HasPrefix(x, "--in-place=") { + return true + } + if strings.HasPrefix(x, "-") && !strings.HasPrefix(x, "--") && len(x) > 1 && strings.ContainsRune(x[1:], 'i') { + return true + } + } + return false +} + +// --- helpers ------------------------------------------------------------- + +func setOf(s string) map[string]bool { + m := map[string]bool{} + for _, f := range strings.Fields(s) { + m[f] = true + } + return m +} + +func oneOf(s string, opts ...string) bool { + for _, o := range opts { + if s == o { + return true + } + } + return false +} + +func hasFlag(a []string, flags ...string) bool { + for _, x := range a { + if oneOf(x, flags...) { + return true + } + } + return false +} + +func hasFlagPrefix(a []string, prefixes ...string) bool { + for _, x := range a { + for _, p := range prefixes { + if strings.HasPrefix(x, p) { + return true + } + } + } + return false +} + +func hasFlagLetter(a []string, l rune) bool { + for _, x := range a { + if strings.HasPrefix(x, "-") && !strings.HasPrefix(x, "--") && strings.ContainsRune(x[1:], l) { + return true + } + } + return false +} + +func containsWord(a []string, sub string) bool { + for _, x := range a { + if strings.Contains(x, sub) { + return true + } + } + return false +} + +func containsSeq(a []string, x, y string) bool { + for i := 0; i+1 < len(a); i++ { + if a[i] == x && a[i+1] == y { + return true + } + } + return false +} + +func allAre(a []string, v string) bool { + for _, x := range a { + if x != v { + return false + } + } + return true +} + +func nonFlags(a []string) []string { + var out []string + for _, x := range a { + if !strings.HasPrefix(x, "-") { + out = append(out, x) + } + } + return out +} + +func firstNonFlag(a []string) string { + for _, x := range a { + if !strings.HasPrefix(x, "-") { + return x + } + } + return "" +} + +func firstN(a []string, n int) []string { + if len(a) < n { + return a + } + return a[:n] +} + +func indexOf(a []string, v string) int { + for i, x := range a { + if x == v { + return i + } + } + return -1 +} + +// skipFlags drops leading flags; those listed in withValue consume the +// next word as their value (unless written as --flag=value). +func skipFlags(a []string, withValue ...string) []string { + i := 0 + for i < len(a) && strings.HasPrefix(a[i], "-") && a[i] != "-" { + f := a[i] + i++ + if f == "--" { + break + } + if !strings.Contains(f, "=") && oneOf(f, withValue...) && i < len(a) { + i++ + } + } + return a[i:] +} + +// stripComments blanks shell comment lines and trailing " # …" comments +// so prose in a script never reads as a command. +func stripComments(b []byte) []byte { + lines := strings.Split(string(b), "\n") + for i, l := range lines { + t := strings.TrimSpace(l) + if strings.HasPrefix(t, "#") { + lines[i] = "" + continue + } + if j := strings.Index(l, " #"); j >= 0 && !strings.ContainsAny(l[:j], `'"`) { + lines[i] = l[:j] + } + } + return []byte(strings.Join(lines, "\n")) +} diff --git a/internal/tools/shell/classify_test.go b/internal/tools/shell/classify_test.go new file mode 100644 index 0000000..6911780 --- /dev/null +++ b/internal/tools/shell/classify_test.go @@ -0,0 +1,150 @@ +package shell + +import ( + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +const ( + read = toolkit.TierDiagnose + local = toolkit.TierLocalChange + chain = toolkit.TierOnChain + forb = toolkit.Tier(-1) // forbidden +) + +func TestClassify(t *testing.T) { + scripts := map[string]string{ + "./upgrade/vote-upgrade.sh": "#!/bin/bash\ndocker exec \"$C\" evmd tx gov vote \"$ID\" yes --from val --yes\n", + "./info.sh": "#!/bin/bash\ncurl -s localhost:26657/status | jq .\n", + "generate-keys.sh": "#!/bin/bash\nread -p 'Number of validators: ' N\ndocker run --rm img evmd keys add v --keyring-backend test\n", + } + o := Opts{Binary: "evmd", ReadScript: func(p string) ([]byte, bool) { + b, ok := scripts[p] + return []byte(b), ok + }} + cases := []struct { + cmd string + want toolkit.Tier + }{ + // plain reads + {"ls -la /var/lib", read}, + {"df -h && free -m", read}, + {"tail -n 200 /var/log/syslog | grep -i error", read}, + {"journalctl -u evmd --since '10 min ago' --no-pager", read}, + {"systemctl status evmd", read}, + {"docker ps --format '{{.Names}}'", read}, + {"docker logs --tail 100 primium-validator0 2>&1 | grep -i panic", read}, + {"docker compose -f docker-compose.yml ps", read}, + {"docker exec primium-validator0 evmd status", read}, + {"docker exec -i primium-validator0 evmd q staking validators -o json | jq '.validators | length'", read}, + {"curl -s localhost:26657/status | jq .result.sync_info", read}, + {`curl -s -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' localhost:8545`, read}, + {"evmd q bank balances cosmos1abc", read}, + {"evmd keys list --keyring-backend test", read}, + {"git status && git log --oneline -5", read}, + {"kubectl get pods -n chain", read}, + {"find . -name '*.toml' -newer genesis.json", read}, + {"sed -n '1,40p' config/app.toml", read}, + {"awk '$5 > 80 {print $6}' <(df -h)", read}, + {"echo $(hostname) $(uptime)", read}, + {"cat config.toml 2>/dev/null || echo missing", read}, + {"ps aux | grep [e]vmd", read}, + {"xxd -l 32 data/blockstore.db/000001.log", read}, + {"/usr/bin/ls -l", read}, + {"cd run-validator && ls", read}, + {"FOO=1 env | sort", read}, + {"ss -tlnp | grep 26656", read}, + {"tar -tzf snapshot.tar.gz | head", read}, + {"./info.sh", local}, + + // host changes + {"systemctl restart evmd", local}, + {"echo hi > notes.txt", local}, + {"echo hi >> /etc/hosts", local}, + {"sed -i 's/a/b/' config.toml", local}, + {"docker compose up -d", local}, + {"docker restart primium-validator0", local}, + {"docker run --rm primium-evm:v0.7.2 evmd version", local}, + {"rm -rf ./tmp", local}, + {"sudo apt install jq", local}, + {"curl -o /tmp/x https://example.com", local}, + {"curl -X DELETE localhost:9200/index", local}, + {"python3 script.py", local}, + {"awk '{print > \"out\"}' f", local}, + {"find . -name '*.log' -delete", local}, + {"cat > config.toml < data/priv_validator_state.json", forb}, + {"base64 ~/.ssh/id_ed25519", forb}, + {"generate-keys.sh", forb}, + } + for _, c := range cases { + v := Classify(c.cmd, o) + got := v.Tier + if v.Forbidden != "" { + got = forb + } + if got != c.want { + t.Errorf("%q: got %s (%s%s), want %s", c.cmd, tierName(got), v.Reason, v.Forbidden, tierName(c.want)) + } + } +} + +func tierName(t toolkit.Tier) string { + if t == forb { + return "forbidden" + } + return t.String() +} + +func TestPermOnlyMayNameKeyFiles(t *testing.T) { + for _, cmd := range []string{"ls -l config/priv_validator_key.json", "chmod 600 config/priv_validator_key.json", "stat -c %a config/node_key.json"} { + if v := Classify(cmd, Opts{}); v.Forbidden != "" { + t.Errorf("%q forbidden: %s", cmd, v.Forbidden) + } + } +} + +func TestScriptNotes(t *testing.T) { + o := Opts{ReadScript: func(string) ([]byte, bool) { return []byte("read -p 'name: ' N\necho $N\n"), true }} + v := Classify("./setup.sh", o) + if v.Tier != local || len(v.Notes) == 0 || !strings.Contains(v.Notes[0], "prompt") { + t.Fatalf("verdict = %+v", v) + } +} + +func TestSegmentsForRules(t *testing.T) { + v := Classify("cd /data && git status | head -5; FOO=1 ls -l", Opts{}) + want := []string{"cd /data", "git status", "head -5", "FOO=1 ls -l"} + if strings.Join(v.Segments, "|") != strings.Join(want, "|") { + t.Fatalf("segments = %q", v.Segments) + } +} diff --git a/internal/tools/web/fetch.go b/internal/tools/web/fetch.go new file mode 100644 index 0000000..c5798bf --- /dev/null +++ b/internal/tools/web/fetch.go @@ -0,0 +1,136 @@ +// Package web implements the agent's web_fetch tool. +package web + +import ( + "fmt" + "html" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// Register adds web_fetch. +func Register(r *toolkit.Registry) { r.Register(Fetch{}) } + +const maxBody = 5 << 20 + +// Fetch GETs a URL from the machine cometcli runs on. +type Fetch struct{} + +func (Fetch) Name() string { return "web_fetch" } +func (Fetch) Desc() string { + return "Fetch a URL (http/https GET) from the operator's machine and return it as text — release notes, docs, upgrade instructions, a node's REST/RPC endpoint. The operator approves each new domain; localhost and private addresses are fetched directly. Redirects to another host are reported, not followed." +} +func (Fetch) Schema() map[string]any { + return toolkit.ObjSchema(map[string]any{ + "url": toolkit.Str("absolute http(s) URL"), + }, "url") +} +func (Fetch) Tier() toolkit.Tier { return toolkit.TierDiagnose } +func (Fetch) AgentOnly() bool { return true } +func (Fetch) OutputLimit() int { return 30_000 } + +func (Fetch) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + raw := strings.TrimSpace(a.String("url", "")) + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("want an absolute http(s) URL, got %q", raw) + } + if u.User != nil { + return nil, fmt.Errorf("URLs with embedded credentials are refused") + } + if err := c.Check(toolkit.Gate{ + Request: toolkit.Request{Tool: "web_fetch", Specs: []string{u.String()}, Kind: "url"}, + Tier: toolkit.TierDiagnose, + Prompt: "fetch " + u.String(), + Detail: map[string]any{"domain": u.Hostname()}, + AskByDefault: !privateHost(u.Hostname()), + }); err != nil { + return nil, err + } + hc := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.URL.Hostname() != u.Hostname() { + return http.ErrUseLastResponse + } + if len(via) >= 5 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + req, err := http.NewRequestWithContext(c, "GET", u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "cometcli (+https://github.com/abhijitkrm/cometcli)") + req.Header.Set("Accept", "text/html,text/plain,application/json,text/markdown;q=0.9,*/*;q=0.5") + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode/100 == 3 { + return &toolkit.Result{Text: fmt.Sprintf("HTTP %d redirect to %s — a different host; fetch that URL if it should be followed", resp.StatusCode, resp.Header.Get("Location"))}, nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return nil, err + } + ct := resp.Header.Get("Content-Type") + text := string(body) + if strings.Contains(ct, "html") || (ct == "" && strings.Contains(strings.ToLower(text[:min(len(text), 512)]), "|`) + breakRe = regexp.MustCompile(`(?i)<\s*(br|/p|/div|/li|/h[1-6]|/tr|/pre|/section|/article|/table|hr)\b[^>]*>`) + itemRe = regexp.MustCompile(`(?i)<\s*li\b[^>]*>`) + headRe = regexp.MustCompile(`(?i)<\s*h([1-6])\b[^>]*>`) + tagRe = regexp.MustCompile(`(?s)<[^>]+>`) + blankRe = regexp.MustCompile(`\n[ \t]*\n([ \t]*\n)+`) + spaceRe = regexp.MustCompile(`[ \t]+`) +) + +// HTMLText reduces an HTML page to readable text: scripts, styles and +// chrome dropped, block ends turned into line breaks, list items and +// headings marked, entities decoded. +func HTMLText(s string) string { + s = dropRe.ReplaceAllString(s, "") + s = headRe.ReplaceAllStringFunc(s, func(m string) string { + n := headRe.FindStringSubmatch(m)[1] + return "\n" + strings.Repeat("#", int(n[0]-'0')) + " " + }) + s = itemRe.ReplaceAllString(s, "\n- ") + s = breakRe.ReplaceAllString(s, "\n") + s = tagRe.ReplaceAllString(s, "") + s = html.UnescapeString(s) + lines := strings.Split(s, "\n") + for i, l := range lines { + lines[i] = strings.TrimSpace(spaceRe.ReplaceAllString(l, " ")) + } + return blankRe.ReplaceAllString(strings.Join(lines, "\n"), "\n\n") +} diff --git a/internal/tools/web/fetch_test.go b/internal/tools/web/fetch_test.go new file mode 100644 index 0000000..e2d1a67 --- /dev/null +++ b/internal/tools/web/fetch_test.go @@ -0,0 +1,49 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +func TestHTMLText(t *testing.T) { + in := `x +

Upgrade v0.7.2

Halt at height 1200 & swap.

  • stop node
  • replace binary
+` + out := HTMLText(in) + for _, want := range []string{"## Upgrade v0.7.2", "Halt at height 1200 & swap.", "- stop node", "- replace binary"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in %q", want, out) + } + } + for _, bad := range []string{"alert", "menu", "p{}", "<"} { + if strings.Contains(out, bad) { + t.Errorf("kept %q in %q", bad, out) + } + } +} + +func TestFetchLocalNoPromptExternalAsks(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + w.Write([]byte(`{"height":"42"}`)) + })) + defer srv.Close() + asked := 0 + c := &toolkit.Context{Context: context.Background(), AutoApproveBelow: toolkit.TierLocalChange, + Approver: func(*toolkit.Context, string, toolkit.Tier, map[string]any) (bool, error) { asked++; return false, nil }} + res, err := (Fetch{}).Run(c, toolkit.Args{"url": srv.URL}) + if err != nil || !strings.Contains(res.Text, `"height":"42"`) || asked != 0 { + t.Fatalf("local fetch: %v asked=%d %v", res, asked, err) + } + if _, err := (Fetch{}).Run(c, toolkit.Args{"url": "https://example.com/"}); err == nil || asked != 1 { + t.Fatalf("external fetch must ask: err=%v asked=%d", err, asked) + } + if _, err := (Fetch{}).Run(c, toolkit.Args{"url": "file:///etc/passwd"}); err == nil { + t.Fatal("file URL accepted") + } +} diff --git a/internal/tui/chat.go b/internal/tui/chat.go index e209af9..d9a6604 100644 --- a/internal/tui/chat.go +++ b/internal/tui/chat.go @@ -282,6 +282,8 @@ func (m *AppModel) onAgentEvent(e agent.Event) { p.blocks[p.think] = dimSt.Render("∴ " + t) case agent.EvNotice: p.append("info", e.Text) + case agent.EvTodos: + p.append("info", agent.RenderTodos(e.Todos)) case agent.EvDelta: p.liveText += e.Text if p.live < 0 { From 89e8c199a6a2a7f32a6130377784527d47e67407 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 17:15:37 +0530 Subject: [PATCH 03/48] agent: load non-core tools on demand via tool_search Every request sent all 65 tool schemas (~6.5k tokens), over Groq's free 8k tokens-per-minute budget on its own. Requests now carry the general tools plus node.status/health/logs and val.status/signing, the frozen system prompt lists the rest by name, and the built-in tool_search loads definitions on demand (select: or keywords). Loaded tools persist for the session and are saved with it. agent.tools: all restores the old behavior. First request: ~30KB -> ~10KB. Groq's 413 "request too large" now triggers compaction like a context-window overflow. Co-Authored-By: Claude Opus 5.5 --- docs/COMMANDS.md | 6 ++ internal/agent/compact.go | 1 + internal/agent/loop.go | 19 +++- internal/agent/phase1_test.go | 1 + internal/agent/phase2_test.go | 42 ++++++++ internal/agent/session.go | 11 ++- internal/agent/session_test.go | 2 +- internal/agent/toolsearch.go | 173 +++++++++++++++++++++++++++++++++ internal/config/config.go | 3 + 9 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 internal/agent/toolsearch.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 8f6bb0d..421b510 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -284,8 +284,14 @@ agent: max_turns: 50 # model rounds per prompt context_window: 0 # override the model's context size (tokens) compact_at: 0 # prompt size that triggers auto-compaction; 0 = 80% of window, ≤200k + tools: "" # "all" sends every tool schema each request; default loads on demand ``` +Each request carries a core tool set (the general tools plus node.status/health/logs and +val.status/signing) and a catalog of the rest by name; the model loads others with +`tool_search` as it needs them. That keeps a request near 2.5k tokens instead of ~7.5k — +small enough for free-tier per-minute limits — and cheaper on every provider. + `effort` maps to each provider's own control: Anthropic `output_config.effort`, OpenAI and Groq `reasoning_effort` (`xhigh`/`max` → `high`; reasoning models only), Gemini `generation_config.thinking_level`. Leave it unset for models without a reasoning knob. diff --git a/internal/agent/compact.go b/internal/agent/compact.go index 6da6f6d..5ece68c 100644 --- a/internal/agent/compact.go +++ b/internal/agent/compact.go @@ -139,5 +139,6 @@ func isContextOverflow(err error) bool { strings.Contains(s, "context length") || strings.Contains(s, "context window") || strings.Contains(s, "maximum context") || + strings.Contains(s, "request too large") || // Groq 413: prompt exceeds the per-minute token budget (strings.Contains(s, "too many tokens") && strings.Contains(s, "input")) } diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 684f21e..c9dd2e5 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -77,6 +77,7 @@ type Agent struct { lastIn int // prompt tokens of the latest round (0 = unknown) total Usage // session token totals todos []Todo + loaded map[string]bool // tools loaded with tool_search } // New builds an agent for a context from the profile's agent config. @@ -187,7 +188,7 @@ func (a *Agent) Reset() { a.snap, a.sys, a.toolSig, a.carry = "", "", "", "" a.lastIn, a.total = 0, Usage{} a.created = time.Time{} - a.todos = nil + a.todos, a.loaded = nil, nil a.Tools = toolkit.NewSession("") } @@ -219,13 +220,20 @@ func (a *Agent) toolDefs() []ToolDef { if !a.Policy.Allows(t.Tier()) && !toolkit.IsDynamic(t) { continue } + if !a.advertised(t) { + continue + } out = append(out, ToolDef{ Name: toolFnName(t.Name()), Desc: fmt.Sprintf("[%s] %s", t.Tier(), t.Desc()), Schema: t.Schema(), }) } - return append(out, todoToolDef) + out = append(out, todoToolDef) + if a.deferred() { + out = append(out, toolSearchDef) + } + return out } func toolFnName(n string) string { return strings.ReplaceAll(n, ".", "__") } @@ -426,8 +434,11 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { if err := json.Unmarshal(call.Args, &args); err != nil { return a.toolErr(call, name, "bad args: "+err.Error()) } - if name == todoToolName { + switch name { + case todoToolName: return a.todoWrite(call, args) + case toolSearchName: + return a.toolSearch(call, args) } t, ok := a.Reg.Get(name) if !ok { @@ -544,7 +555,7 @@ func (a *Agent) ruleGate(c *toolkit.Context, t toolkit.Tool, shownArgs map[strin // invalidate replayed thinking. Later snapshots ride on user turns. func (a *Agent) system() string { if a.sys == "" { - a.sys = a.Redact.Text(SystemPrompt(a.Ctx)) + a.snapshot() + a.sys = a.Redact.Text(SystemPrompt(a.Ctx)) + a.toolCatalog() + a.snapshot() } return a.sys } diff --git a/internal/agent/phase1_test.go b/internal/agent/phase1_test.go index 33a7177..5e290f0 100644 --- a/internal/agent/phase1_test.go +++ b/internal/agent/phase1_test.go @@ -462,6 +462,7 @@ func TestToolSetChangeDropsBoundThinking(t *testing.T) { ro := stubTool{name: "node.status", tier: toolkit.TierObserve, run: func(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { return &toolkit.Result{}, nil }} rw := stubTool{name: "node.restart", tier: toolkit.TierLocalChange, run: func(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { return &toolkit.Result{}, nil }} a := newTestAgent(t, prov, ro, rw) + a.conf.Tools = "all" a.Run(context.Background(), "one") a.history[1].RawSteps, a.history[1].RawProvider = json.RawMessage(`[{"type":"thinking","signature":"s"}]`), "anthropic" a.Policy.Mode = ModeReadOnly // hides node.restart → tool set changes diff --git a/internal/agent/phase2_test.go b/internal/agent/phase2_test.go index 02aaf8f..f3e6f68 100644 --- a/internal/agent/phase2_test.go +++ b/internal/agent/phase2_test.go @@ -136,3 +136,45 @@ func TestBashOutputUsesToolLimit(t *testing.T) { t.Fatalf("bash output length %d — should keep up to 30k", n) } } + +func TestDeferredToolsLoadOnDemand(t *testing.T) { + gov := stubTool{name: "chain.gov", tier: toolkit.TierObserve, run: func(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { + return &toolkit.Result{Text: "proposal 3 voting"}, nil + }} + core := stubTool{name: "node.status", tier: toolkit.TierObserve, run: func(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { + return &toolkit.Result{}, nil + }} + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "c1", Name: "tool_search", Args: json.RawMessage(`{"query":"select:chain.gov"}`)}}}, + {Calls: []Call{{ID: "c2", Name: "chain__gov", Args: json.RawMessage(`{}`)}}}, + {Text: "ok", Done: true}, + }} + a, _ := newShellAgent(t, prov, gov, core) + a.Run(context.Background(), "any proposals?") + names := func(r *Request) string { + var n []string + for _, td := range r.Tools { + n = append(n, td.Name) + } + return strings.Join(n, ",") + } + if first := names(prov.reqs[0]); strings.Contains(first, "chain__gov") || !strings.Contains(first, "node__status") || !strings.Contains(first, "tool_search") { + t.Fatalf("first request tools = %s", first) + } + if !strings.Contains(prov.reqs[0].System, "- chain: gov") { + t.Fatalf("catalog missing from system prompt: %q", prov.reqs[0].System) + } + if second := names(prov.reqs[1]); !strings.Contains(second, "chain__gov") { + t.Fatalf("loaded tool not advertised: %s", second) + } + if !strings.Contains(lastToolText(a), "proposal 3") { + t.Fatalf("loaded tool didn't run: %q", lastToolText(a)) + } + // keyword search ranks by name/description + if got := a.searchTools("governance gov proposals", 3); len(got) == 0 || got[0].Name() != "chain.gov" { + t.Fatalf("search = %v", got) + } + if lt := a.LoadedTools(); len(lt) != 1 || lt[0] != "chain.gov" { + t.Fatalf("loaded = %v", lt) + } +} diff --git a/internal/agent/session.go b/internal/agent/session.go index 609bb0f..99b4618 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -35,8 +35,9 @@ type SessionFile struct { Carry string `json:"carry,omitempty"` History []Msg `json:"history"` // ShellCwd is the bash tool's working directory; Todos the checklist. - ShellCwd string `json:"shell_cwd,omitempty"` - Todos []Todo `json:"todos,omitempty"` + ShellCwd string `json:"shell_cwd,omitempty"` + Todos []Todo `json:"todos,omitempty"` + Loaded []string `json:"loaded_tools,omitempty"` } const sessionVersion = 1 @@ -79,7 +80,7 @@ func (a *Agent) Save() error { Title: a.title(), Cwd: cwd, Profile: a.profileName(), Provider: a.Provider.Name(), Model: a.Model, Usage: a.total, System: a.sys, ToolSig: a.toolSig, Carry: a.carry, History: a.history, - ShellCwd: a.Tools.Cwd(""), Todos: a.todos, + ShellCwd: a.Tools.Cwd(""), Todos: a.todos, Loaded: a.LoadedTools(), } raw, err := json.Marshal(sf) if err != nil { @@ -124,6 +125,10 @@ func (a *Agent) Restore(sf *SessionFile) { a.history, a.sys, a.toolSig, a.carry = sf.History, sf.System, sf.ToolSig, sf.Carry a.total = sf.Usage a.todos = sf.Todos + a.loaded = map[string]bool{} + for _, n := range sf.Loaded { + a.loaded[n] = true + } a.Tools = toolkit.NewSession(sf.ShellCwd) // the frozen system prompt carries the old snapshot: force a refresh // onto the next turn diff --git a/internal/agent/session_test.go b/internal/agent/session_test.go index 86ee614..9f6ea1c 100644 --- a/internal/agent/session_test.go +++ b/internal/agent/session_test.go @@ -160,7 +160,7 @@ func TestReadOnlyHidesAndBlocks(t *testing.T) { t.Fatal("local-change tool ran in readonly mode") } for _, td := range prov.reqs[0].Tools { - if td.Name != todoToolName { + if td.Name != todoToolName && td.Name != toolSearchName { t.Fatalf("readonly advertised mutating tool %s", td.Name) } } diff --git a/internal/agent/toolsearch.go b/internal/agent/toolsearch.go new file mode 100644 index 0000000..bbd3872 --- /dev/null +++ b/internal/agent/toolsearch.go @@ -0,0 +1,173 @@ +package agent + +import ( + "fmt" + "sort" + "strings" + + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// Deferred tool loading. Sending all ~65 tool schemas costs ~6.5k tokens +// per request — more than some providers' whole per-minute budget — so +// by default each request carries a core set, the system prompt lists the +// rest by name, and tool_search loads full definitions on demand. Loaded +// tools stay for the session (and are saved with it). + +const toolSearchName = "tool_search" + +// coreTools are always advertised (with the agent-only general tools). +var coreTools = map[string]bool{ + "node.status": true, "node.health": true, "node.logs": true, + "val.status": true, "val.signing": true, +} + +var toolSearchDef = ToolDef{ + Name: toolSearchName, + Desc: "Load more tools so you can call them. Pass names from the tool catalog in the system prompt (\"select:val.unjail,chain.gov\") or keywords (\"governance proposals vote\"). Loaded tools are callable from your next step on.", + Schema: toolkit.ObjSchema(map[string]any{ + "query": toolkit.Str(`"select:name1,name2" for exact tools, or keywords`), + }, "query"), +} + +// deferred reports whether this session loads tools on demand. +func (a *Agent) deferred() bool { return a.conf.Tools != "all" } + +// advertised reports whether t goes into this request's tool list. +func (a *Agent) advertised(t toolkit.Tool) bool { + if !a.deferred() || toolkit.IsAgentOnly(t) || coreTools[t.Name()] { + return true + } + return a.loaded[t.Name()] +} + +// toolCatalog renders the not-yet-advertised tools by domain, names only, +// for the frozen system prompt. +func (a *Agent) toolCatalog() string { + if !a.deferred() { + return "" + } + groups := map[string][]string{} + var order []string + for _, t := range a.Reg.All() { + if toolkit.IsAgentOnly(t) || coreTools[t.Name()] || toolkit.IsLongRunning(t) { + continue + } + dom, verb, ok := strings.Cut(t.Name(), ".") + if !ok { + continue + } + if _, seen := groups[dom]; !seen { + order = append(order, dom) + } + groups[dom] = append(groups[dom], verb) + } + if len(order) == 0 { + return "" + } + var b strings.Builder + b.WriteString("TOOL CATALOG — load with tool_search before calling (e.g. select:val.unjail):\n") + for _, d := range order { + fmt.Fprintf(&b, "- %s: %s\n", d, strings.Join(groups[d], ", ")) + } + return b.String() + "\n" +} + +func (a *Agent) toolSearch(call Call, args map[string]any) Msg { + q := strings.TrimSpace(fmt.Sprint(args["query"])) + if q == "" || q == "" { + return a.toolErr(call, toolSearchName, "query is required") + } + var picks []toolkit.Tool + var missing []string + if names, ok := strings.CutPrefix(q, "select:"); ok { + for _, n := range strings.Split(names, ",") { + n = toolkit.ResolveName(strings.TrimSpace(n)) + if t, ok := a.Reg.Get(n); ok { + picks = append(picks, t) + } else if n != "" { + missing = append(missing, n) + } + } + } else { + picks = a.searchTools(q, 5) + } + if a.loaded == nil { + a.loaded = map[string]bool{} + } + var b strings.Builder + var names []string + for _, t := range picks { + if toolkit.IsLongRunning(t) { + continue + } + a.loaded[t.Name()] = true + names = append(names, toolFnName(t.Name())) + note := "" + if !a.Policy.Allows(t.Tier()) && !toolkit.IsDynamic(t) { + note = " (not callable in readonly mode)" + } + fmt.Fprintf(&b, "- %s [%s] %s%s\n", toolFnName(t.Name()), t.Tier(), t.Desc(), note) + } + if len(names) == 0 { + msg := "no tools matched " + q + " — use names from the catalog" + if len(missing) > 0 { + msg = "unknown tools: " + strings.Join(missing, ", ") + } + return a.toolErr(call, toolSearchName, msg) + } + text := "loaded: " + strings.Join(names, ", ") + " — callable from your next step\n" + b.String() + if len(missing) > 0 { + text += "unknown: " + strings.Join(missing, ", ") + "\n" + } + a.emit(Event{Kind: EvToolResult, Tool: toolSearchName, Text: "loaded " + strings.Join(names, ", ")}) + return Msg{Role: "tool", CallID: call.ID, ToolName: toolSearchName, Text: strings.TrimRight(text, "\n")} +} + +// searchTools ranks tools by keyword hits in name (weighted) and +// description. +func (a *Agent) searchTools(q string, n int) []toolkit.Tool { + words := strings.Fields(strings.ToLower(q)) + type hit struct { + t toolkit.Tool + score int + } + var hits []hit + for _, t := range a.Reg.All() { + if toolkit.IsAgentOnly(t) { + continue + } + name, desc := strings.ToLower(t.Name()), strings.ToLower(t.Desc()) + s := 0 + for _, w := range words { + if len(w) < 3 && name != w { + continue + } + if strings.Contains(name, w) { + s += 3 + } + if strings.Contains(desc, w) { + s++ + } + } + if s > 0 { + hits = append(hits, hit{t, s}) + } + } + sort.SliceStable(hits, func(i, j int) bool { return hits[i].score > hits[j].score }) + var out []toolkit.Tool + for i := 0; i < len(hits) && i < n; i++ { + out = append(out, hits[i].t) + } + return out +} + +// LoadedTools returns the names loaded with tool_search, sorted. +func (a *Agent) LoadedTools() []string { + var out []string + for n := range a.loaded { + out = append(out, n) + } + sort.Strings(out) + return out +} diff --git a/internal/config/config.go b/internal/config/config.go index 90a02d2..bfc8170 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -126,6 +126,9 @@ type AgentConf struct { // CompactAt is the prompt size in tokens that triggers summarizing // the conversation (0 = 80% of the window, at most 200k). CompactAt int `yaml:"compact_at,omitempty"` + // Tools is "all" to send every tool schema on every request; the + // default loads non-core tools on demand via tool_search. + Tools string `yaml:"tools,omitempty"` // Permissions are allow/ask/deny rules, e.g. "bash(systemctl status:*)", // "edit(./config/**)", "web_fetch(domain:github.com)", "val.unjail". Permissions Permissions `yaml:"permissions,omitempty"` From 5672150c29ded5dcda7bb2491f57c6ddaf787787 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 17:27:24 +0530 Subject: [PATCH 04/48] cli: general mode, node mode, headless -p and piped input (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bare `cometcli [prompt]` is general mode: an SRE agent on this machine with the general tools and a general system prompt (OS, host, cwd, available node profiles); no profile needed. An explicit --profile means node mode. - `cometcli one [profile] [prompt]`: node mode — node tools (deferred), validator prompt, live snapshot, shell on the node's host. /one and /one off switch scope mid-session, keeping the conversation (replay state dropped, the model told on the next turn). - -p/--print: answer once and exit; --output-format text|json| stream-json, --include-partial-messages, --verbose, --append-system- prompt, --max-turns. Headless runs never read approvals from stdin. - piped stdin (pipes and redirected files only) is attached as context, capped at 200k with head+tail kept. - global `agent:` section in config.yaml, overridden per profile (AgentFor/MergeAgent); general mode falls back to the active profile's settings. `cometcli config show|set|unset agent.`. - single-word typos of subcommands (doctr) are reported, not sent. - the loop streams whenever the provider can, rendered or not. - ask/agent/ui unchanged (node mode on the active profile). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +- README.md | 33 +++- docs/COMMANDS.md | 26 +++ internal/agent/commands.go | 38 +++- internal/agent/loop.go | 42 ++++- internal/agent/phase2_test.go | 46 +++++ internal/agent/prompt.go | 18 +- internal/agent/provider.go | 2 +- internal/agent/repl.go | 6 +- internal/agent/scope.go | 98 +++++++++++ internal/agent/toolsearch.go | 22 ++- internal/cli/chat.go | 307 +++++++++++++++++++++++++++++++++ internal/cli/chat_test.go | 49 ++++++ internal/cli/configcmd.go | 140 +++++++++++++++ internal/cli/root.go | 54 +++--- internal/cli/ui.go | 6 +- internal/config/config.go | 70 +++++++- internal/config/config_test.go | 31 ++++ internal/tui/app.go | 29 +++- internal/tui/chat.go | 2 +- 20 files changed, 954 insertions(+), 71 deletions(-) create mode 100644 internal/agent/scope.go create mode 100644 internal/cli/chat.go create mode 100644 internal/cli/chat_test.go create mode 100644 internal/cli/configcmd.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1410fc..990f3fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,11 @@ jobs: - name: build run: go build -o cometcli ./cmd/cometcli - name: smoke - run: ./cometcli --help && ./cometcli doctor --help + run: | + ./cometcli --help && ./cometcli doctor --help && ./cometcli one --help + ./cometcli config show + # no provider configured: headless mode must fail fast, not hang + ! ./cometcli -p "hi" # resume one (cometcli sessions) ``` +**General mode** is an SRE agent on your machine: `bash`, `read`/`write`/`edit`, +`glob`/`grep`, `web_fetch`. **Node mode** (`cometcli one `, or `/one +` mid-conversation) adds the node tools, runs the shell on the node's host +(local or SSH), applies the validator safety rules, and keeps a live snapshot of the +node in context. Every shell command is classified before it runs: reads run, changes +ask, transactions always ask, and key material or state resets are refused. See +[docs/COMMANDS.md](docs/COMMANDS.md#general-tools-and-permissions). + +`cometcli ask "…"`, `cometcli agent` and `cometcli ui` still work as before (node mode +on the active profile). + Providers: `anthropic`, `openai`, `groq` (`GROQ_API_KEY`, default model `llama-3.3-70b-versatile`), `gemini` (`GEMINI_API_KEY`, default model `gemini-3.8-flash` — Interactions API, `store=false` so nothing is kept diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 421b510..12a7036 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -9,6 +9,31 @@ Global flags: `--profile ` · `--json` · `-y/--yes` (auto-approve observe/diagnose only — never on-chain) · `COMETCLI_PROFILE`, `COMETCLI_KEYRING_PASSWORD` env vars. +## Chat + +```bash +cometcli # general mode: SRE agent on this machine (TUI) +cometcli "prompt" # same, starting with a prompt +cometcli one [profile] ["prompt"] # node mode for a profile (active profile if omitted) +cometcli -p "prompt" # headless: print the answer and exit +cmd | cometcli -p "prompt" # piped input is attached as context +cometcli -c / -r # continue the latest session here / resume one +cometcli config show # global agent settings (general mode; profiles override) +cometcli config set agent.provider groq +``` + +Headless flags: `--output-format text|json|stream-json`, `--include-partial-messages` +(token deltas in stream-json), `--verbose` (tool calls on stderr in text mode), +`--append-system-prompt "…"`, `--max-turns N`. Headless runs never read approvals from +stdin: anything that needs approval is denied unless `--allowedTools` or +`--permission-mode` allows it. An explicit `--profile` on bare `cometcli` also means +node mode. + +`json` prints one object: `{"type":"result","subtype":"success|error","is_error", +"result","session_id","num_turns","duration_ms","provider","model","mode","usage"}`. +`stream-json` prints a `system/init` line, then `assistant`, `tool_use`, `tool_result`, +`notice` and `todos` events, then the same `result` object. + ## Setup ```bash @@ -229,6 +254,7 @@ saved after each turn to `~/.cometcli/sessions/` (0600, already redacted). | `/effort [level\|default]` | show or set reasoning depth for the rest of the session | | `/sessions`, `/resume ` | list saved sessions, or load one into the current session | | `/permissions`, `/allow\|/ask\|/deny ` | show rules; add one for this session | +| `/one [profile\|off]` | switch the session to node mode for a profile, or back to general (conversation kept) | ### General tools and permissions diff --git a/internal/agent/commands.go b/internal/agent/commands.go index e17c9fa..4605bff 100644 --- a/internal/agent/commands.go +++ b/internal/agent/commands.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sort" "strings" "github.com/abhijitkrm/cometcli/internal/runbook" @@ -33,6 +34,7 @@ const CommandHelp = ` /mode [ops|accept-edits|readonly|bypass] show or set the /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 + /one [profile|off] switch to node mode for a profile, or back to general /profile active profile /audit audit log path + session id /reset clear conversation, start a new audit session @@ -56,7 +58,7 @@ func RunCommand(a *Agent, c *toolkit.Context, reg *toolkit.Registry, line string if Offline() { return ErrOffline } - return fmt.Errorf("no agent configured — set agent.provider in the profile") + return fmt.Errorf("no agent provider — run `cometcli config set agent.provider `") } return nil } @@ -175,6 +177,40 @@ func RunCommand(a *Agent, c *toolkit.Context, reg *toolkit.Registry, line string 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 "/one", "/node": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + if len(f) == 1 { + txt := "general mode — shell and files on this machine; /one for node work" + if a.Node() { + txt = "node mode: " + a.Ctx.Profile.Name + " — /one off for general mode" + } + if cfg := a.Ctx.Cfg; cfg != nil && len(cfg.Profiles) > 0 { + var names []string + for n := range cfg.Profiles { + names = append(names, n) + } + sort.Strings(names) + txt += "\nprofiles: " + strings.Join(names, ", ") + } + return CmdResult{Text: txt}, nil + } + if f[1] == "off" || f[1] == "general" { + a.SwitchScope(nil) + return CmdResult{Text: "general mode — node tools off; the shell runs on this machine"}, nil + } + if a.Ctx.Cfg == nil { + return CmdResult{}, fmt.Errorf("no config loaded") + } + p, ok := a.Ctx.Cfg.Profiles[f[1]] + if !ok { + return CmdResult{}, fmt.Errorf("no profile %q — /one lists them", f[1]) + } + p.Name = f[1] + a.SwitchScope(p) + return CmdResult{Text: fmt.Sprintf("node mode: %s (%s, %s via %s) — conversation kept", p.Name, p.Role, p.ChainID, orNone(p.Transport.Type))}, nil + case "/compact": if err := needAgent(); err != nil { return CmdResult{}, err diff --git a/internal/agent/loop.go b/internal/agent/loop.go index c9dd2e5..c61a393 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -61,6 +61,9 @@ type Agent struct { Tools *toolkit.Session // WorkRoot anchors relative permission patterns and accept-edits. WorkRoot string + // AppendSystem is operator text added to the system prompt + // (--append-system-prompt). + AppendSystem string conf config.AgentConf id string @@ -78,11 +81,22 @@ type Agent struct { total Usage // session token totals todos []Todo loaded map[string]bool // tools loaded with tool_search + rounds int // model rounds in the latest Run + ownCtx bool // a.Ctx was built by SwitchScope } -// New builds an agent for a context from the profile's agent config. +// New builds an agent for a context. With a profile it runs in node mode +// (node tools, validator rules, live snapshot); without one, in general +// mode on the operator's machine. Agent settings come from the global +// agent section overridden by the profile's. func New(c *toolkit.Context, reg *toolkit.Registry) (*Agent, error) { - ac := c.Profile.Agent + var ac config.AgentConf + switch { + case c.Cfg != nil: + ac = c.Cfg.AgentFor(c.Profile) + case c.Profile != nil: + ac = c.Profile.Agent + } prov, err := NewProvider(ac) if err != nil { return nil, err @@ -203,7 +217,7 @@ func (a *Agent) emit(e Event) { } func (a *Agent) profileName() string { - if a.Ctx != nil && a.Ctx.Profile != nil { + if a.Node() { return a.Ctx.Profile.Name } return "" @@ -272,7 +286,9 @@ func (a *Agent) run(ctx context.Context, input string) (string, error) { var texts []string conts, compacted := 0, false + a.rounds = 0 for i := 0; i < a.MaxIter; i++ { + a.rounds++ req := a.request() resp, err := a.chat(ctx, req) if err != nil && !compacted && isContextOverflow(err) { @@ -398,7 +414,7 @@ func (a *Agent) chat(ctx context.Context, req *Request) (*Response, error) { ctx = WithRetryNotice(ctx, func(attempt int, wait time.Duration, reason string) { a.emit(Event{Kind: EvNotice, Text: fmt.Sprintf("%s: %s — retrying in %s (attempt %d)", a.Provider.Name(), reason, wait.Round(100*time.Millisecond), attempt+1)}) }) - if s, ok := a.Provider.(Streamer); ok && a.Stream && a.OnEvent != nil { + if s, ok := a.Provider.(Streamer); ok && a.Stream { return s.Stream(ctx, req, func(chunk string) { a.emit(Event{Kind: EvDelta, Text: chunk}) }) @@ -555,11 +571,24 @@ func (a *Agent) ruleGate(c *toolkit.Context, t toolkit.Tool, shownArgs map[strin // invalidate replayed thinking. Later snapshots ride on user turns. func (a *Agent) system() string { if a.sys == "" { - a.sys = a.Redact.Text(SystemPrompt(a.Ctx)) + a.toolCatalog() + a.snapshot() + if a.Node() { + a.sys = a.Redact.Text(SystemPrompt(a.Ctx)) + a.toolCatalog() + a.snapshot() + } else { + a.sys = a.Redact.Text(GeneralPrompt(a.Ctx, a.Tools.Cwd(a.WorkRoot))) + } + if a.AppendSystem != "" { + a.sys += "\n" + a.Redact.Text(a.AppendSystem) + "\n" + } } return a.sys } +// Node reports whether the session is scoped to a node profile. +func (a *Agent) Node() bool { return a.Ctx != nil && a.Ctx.Profile != nil } + +// Rounds is the number of model rounds the latest Run took. +func (a *Agent) Rounds() int { return a.rounds } + // snapshot probes the live node digest and records when. func (a *Agent) snapshot() string { fn := a.SnapshotFn @@ -585,6 +614,9 @@ func (a *Agent) turnPrefix() string { a.system() // first turn: the snapshot goes in the system prompt return b.String() } + if !a.Node() { + return b.String() + } ttl := a.SnapshotTTL if ttl == 0 { ttl = 60 * time.Second diff --git a/internal/agent/phase2_test.go b/internal/agent/phase2_test.go index f3e6f68..0e35a59 100644 --- a/internal/agent/phase2_test.go +++ b/internal/agent/phase2_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/abhijitkrm/cometcli/internal/client/host" + "github.com/abhijitkrm/cometcli/internal/config" "github.com/abhijitkrm/cometcli/internal/toolkit" "github.com/abhijitkrm/cometcli/internal/tools/shell" ) @@ -178,3 +179,48 @@ func TestDeferredToolsLoadOnDemand(t *testing.T) { t.Fatalf("loaded = %v", lt) } } + +func TestGeneralModeAndScopeSwitch(t *testing.T) { + prov := &mockProvider{responses: []*Response{{Text: "a", Done: true}, {Text: "b", Done: true}, {Text: "c", Done: true}}} + nodeTool := stubTool{name: "node.status", tier: toolkit.TierObserve, run: func(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { return &toolkit.Result{}, nil }} + a, _ := newShellAgent(t, prov, nodeTool) + prof := a.Ctx.Profile + prof.Role, prof.ChainID = "validator", "primium-1" + a.Ctx.Cfg = &config.Config{Profiles: map[string]*config.Profile{"testp": prof}} + a.SwitchScope(nil) // start in general mode + a.carry = "" + a.Run(context.Background(), "one") + r := prov.reqs[0] + if !strings.Contains(r.System, "working in the operator's terminal") || !strings.Contains(r.System, "testp (validator, primium-1)") { + t.Fatalf("general prompt = %q", r.System) + } + for _, td := range r.Tools { + if td.Name == "node__status" || td.Name == toolSearchName { + t.Fatalf("node tool %s advertised in general mode", td.Name) + } + } + if strings.Contains(r.System, "LIVE SNAPSHOT") { + t.Fatal("snapshot in general mode") + } + + a.SwitchScope(prof) + a.Run(context.Background(), "two") + r = prov.reqs[1] + if !strings.Contains(r.System, "Cosmos-EVM validators") || !strings.Contains(r.System, "LIVE: height=42") { + t.Fatalf("node prompt = %q", r.System) + } + var names []string + for _, td := range r.Tools { + names = append(names, td.Name) + } + if !strings.Contains(strings.Join(names, ","), "node__status") { + t.Fatalf("node tools missing: %v", names) + } + last := r.Messages[len(r.Messages)-1].Text + if !strings.Contains(last, "switched this session to node mode for profile testp") || !strings.HasSuffix(last, "two") { + t.Fatalf("switch note missing: %q", last) + } + if len(r.Messages) != 3 { + t.Fatalf("conversation not kept: %d messages", len(r.Messages)) + } +} diff --git a/internal/agent/prompt.go b/internal/agent/prompt.go index b9e9119..fd0ccfe 100644 --- a/internal/agent/prompt.go +++ b/internal/agent/prompt.go @@ -27,23 +27,9 @@ You operate ONE node via tools. Rules you must follow: - Reference exact numbers (heights, missed counts, drift) in answers. - Keep answers terse, technical, and actionable. Use markdown sparingly. -General tools (they run on the node's host — local or over SSH): -- bash: any shell command; the working directory persists between calls. - Read-only commands run at once; changes wait for the operator's - approval; transactions always do. stdin is closed: a command or script - that prompts (read -p, sudo password, y/N) gets EOF — ask the operator - to run those in their terminal instead. -- read / grep / glob for files — prefer them over cat/grep/find in bash. - edit and write need a read of the file first; keep edits minimal. -- Before running a script, read it and say what it will do. -- Refusals are final: consensus keys, key ceremonies (keys add/export), - mnemonics and state resets never pass through you. Don't retry - variations of a refused or denied command; explain and hand the step - to the operator with the exact command. -- web_fetch for release notes and docs. todo_write to track work of 3+ - steps (an upgrade, a migration) so the operator sees progress. - `) + b.WriteString(strings.Replace(generalToolsText, "General tools:", "General tools (they run on the node's host — local or over SSH):", 1)) + b.WriteString("\n") fmt.Fprintf(&b, "NODE PROFILE: %s (role=%s, chain-id=%s, evm-chain-id=%d, binary=%s, home=%s)\n", p.Name, p.Role, p.ChainID, p.EVMChainID, p.Binary, p.Home) fmt.Fprintf(&b, "ENDPOINTS: comet=%s grpc=%s evm=%s transport=%s service=%s:%s\n\n", diff --git a/internal/agent/provider.go b/internal/agent/provider.go index 8a3a8d7..acc6057 100644 --- a/internal/agent/provider.go +++ b/internal/agent/provider.go @@ -184,7 +184,7 @@ func NewProvider(ac config.AgentConf) (Provider, error) { base: base, }, nil case "off", "none", "": - return nil, fmt.Errorf("agent disabled — set agent.provider in the profile") + return nil, fmt.Errorf("no agent provider — run `cometcli config set agent.provider groq` (or gemini, anthropic, openai, openai-compat), or set one in a profile") default: return nil, fmt.Errorf("unknown agent.provider %q", ac.Provider) } diff --git a/internal/agent/repl.go b/internal/agent/repl.go index 8429d2c..3e89530 100644 --- a/internal/agent/repl.go +++ b/internal/agent/repl.go @@ -105,9 +105,13 @@ func (r *REPL) Run() error { pr := &Printer{Out: r.Out, MD: r.md} a.OnEvent = pr.Handle + scope := "general" + if a.Node() { + scope = a.Ctx.Profile.Name + } 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, a.Policy, toolSt.Render("type /help for commands, ctrl+c cancels a running turn, /exit quits")) + scope, 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) diff --git a/internal/agent/scope.go b/internal/agent/scope.go new file mode 100644 index 0000000..1dd72d4 --- /dev/null +++ b/internal/agent/scope.go @@ -0,0 +1,98 @@ +package agent + +import ( + "fmt" + "os" + "runtime" + "sort" + "strings" + "time" + + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// GeneralPrompt is the system prompt for general mode: an SRE agent in +// the operator's terminal, with no node attached. +func GeneralPrompt(c *toolkit.Context, cwd string) string { + host, _ := os.Hostname() + var b strings.Builder + fmt.Fprintf(&b, `You are cometcli — an expert SRE agent working in the operator's terminal. +You run shell commands and edit files on their machine through tools; they +watch and approve anything that changes the system. + +Rules you must follow: +- Investigate before changing anything; reference exact evidence (log + lines, versions, exit codes) in answers. +- Keep answers terse, technical, and actionable. Use markdown sparingly. +- NEVER ask for or echo secrets: mnemonics, private keys, tokens. +- For destructive or irreversible steps, say what will happen first. + +%s +ENVIRONMENT: %s/%s, host %s, working directory %s, date %s +`, generalToolsText, runtime.GOOS, runtime.GOARCH, host, cwd, time.Now().Format("2006-01-02")) + if c != nil && c.Cfg != nil && len(c.Cfg.Profiles) > 0 { + var names []string + for n, p := range c.Cfg.Profiles { + names = append(names, fmt.Sprintf("%s (%s, %s)", n, orNone(p.Role), orNone(p.ChainID))) + } + sort.Strings(names) + fmt.Fprintf(&b, "NODE PROFILES: %s — for validator/node work (signing, jailing, upgrades, txs), the operator switches with /one , which adds the node tools and safety rules; suggest it when relevant.\n", strings.Join(names, "; ")) + } + return b.String() +} + +// generalToolsText describes the general tools; shared by both modes. +const generalToolsText = `General tools: +- bash: any shell command; the working directory persists between calls. + Read-only commands run at once; changes wait for the operator's + approval; transactions always do. stdin is closed: a command or script + that prompts (read -p, sudo password, y/N) gets EOF — ask the operator + to run those in their terminal instead. +- read / grep / glob for files — prefer them over cat/grep/find in bash. + edit and write need a read of the file first; keep edits minimal. +- Before running a script, read it and say what it will do. +- Refusals are final: consensus keys, key ceremonies (keys add/export), + mnemonics and state resets never pass through you. Don't retry + variations of a refused or denied command; explain and hand the step + to the operator with the exact command. +- web_fetch for release notes and docs. todo_write to track work of 3+ + steps (an upgrade, a migration) so the operator sees progress. +` + +// SwitchScope moves the session to a node profile (nil = general mode), +// keeping the conversation. The system prompt and tool set are rebuilt, +// so provider-bound replay state is dropped and the cache restarts; the +// model is told about the switch on the next turn. +func (a *Agent) SwitchScope(p *config.Profile) { + old := a.Ctx + c := &toolkit.Context{ + Context: old.Context, Profile: p, Cfg: old.Cfg, Out: old.Out, + Audit: old.Audit, Approver: old.Approver, AutoApproveBelow: old.AutoApproveBelow, + } + if a.ownCtx { + old.Close() + } + a.Ctx, a.ownCtx = c, true + a.Redact = RedactorFor(p) + a.sys, a.snap, a.toolSig = "", "", "" + a.loaded = nil + a.Tools = toolkit.NewSession("") + a.dropBoundRaw() + note := "[The operator switched this session to general mode: node tools are gone; the shell runs on their machine.]" + if p != nil { + note = fmt.Sprintf("[The operator switched this session to node mode for profile %s (role %s, chain %s, transport %s); node tools, rules and a live snapshot are now in your instructions.]", + p.Name, orNone(p.Role), orNone(p.ChainID), orNone(p.Transport.Type)) + } + if a.carry != "" { + a.carry += "\n\n" + } + a.carry += note +} + +// Close releases a context created by SwitchScope. +func (a *Agent) Close() { + if a.ownCtx { + a.Ctx.Close() + } +} diff --git a/internal/agent/toolsearch.go b/internal/agent/toolsearch.go index bbd3872..4a3565c 100644 --- a/internal/agent/toolsearch.go +++ b/internal/agent/toolsearch.go @@ -30,12 +30,19 @@ var toolSearchDef = ToolDef{ }, "query"), } -// deferred reports whether this session loads tools on demand. -func (a *Agent) deferred() bool { return a.conf.Tools != "all" } +// deferred reports whether this session loads node tools on demand. +// General mode has no node tools to defer. +func (a *Agent) deferred() bool { return a.conf.Tools != "all" && a.Node() } // advertised reports whether t goes into this request's tool list. func (a *Agent) advertised(t toolkit.Tool) bool { - if !a.deferred() || toolkit.IsAgentOnly(t) || coreTools[t.Name()] { + if toolkit.IsAgentOnly(t) { + return true + } + if !a.Node() { + return false // node tools need a node: /one + } + if !a.deferred() || coreTools[t.Name()] { return true } return a.loaded[t.Name()] @@ -171,3 +178,12 @@ func (a *Agent) LoadedTools() []string { sort.Strings(out) return out } + +// ToolNames lists the tools the next request would advertise. +func (a *Agent) ToolNames() []string { + var out []string + for _, d := range a.toolDefs() { + out = append(out, d.Name) + } + return out +} diff --git a/internal/cli/chat.go b/internal/cli/chat.go new file mode 100644 index 0000000..1cbd335 --- /dev/null +++ b/internal/cli/chat.go @@ -0,0 +1,307 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/abhijitkrm/cometcli/internal/agent" + "github.com/abhijitkrm/cometcli/internal/audit" + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/toolkit" + "github.com/abhijitkrm/cometcli/internal/tui" +) + +// maxStdin bounds piped input; larger input keeps its head and tail. +const maxStdin = 200_000 + +// printFlags wires headless-mode flags (bare cometcli and `one`). +func printFlags(cmd *cobra.Command) { + cmd.Flags().BoolP("print", "p", false, "answer once and exit — no TUI; for scripts, pipes and CI") + cmd.Flags().String("output-format", "text", "with -p: text | json | stream-json") + cmd.Flags().Bool("include-partial-messages", false, "with stream-json: also emit token deltas") + cmd.Flags().Bool("verbose", false, "with -p text: show tool calls and notices on stderr") + cmd.Flags().String("append-system-prompt", "", "extra instructions appended to the system prompt") + cmd.Flags().Int("max-turns", 0, "max model rounds per prompt (same as --max-iter)") +} + +// oneCmd is node mode: `cometcli one [profile] [prompt]`. +func oneCmd(reg *toolkit.Registry) *cobra.Command { + cmd := &cobra.Command{ + Use: "one [profile] [prompt]", + Short: "Node mode — chat scoped to one node profile (validator tools, safety rules, live snapshot)", + Long: `Node mode scopes the session to one profile: the node tools (val, chain, +tx, upgrade, …) join the general ones, the shell runs on the node's host +(local or SSH), the validator safety rules apply, and a live snapshot +of the node rides along. Without a profile name the active profile is +used. Inside any session, /one and /one off switch modes.`, + Args: cobra.ArbitraryArgs, + ValidArgsFunction: func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + cfg, err := config.Load() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var names []string + for n := range cfg.Profiles { + names = append(names, n) + } + return names, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + name := flagProfile + if len(args) > 0 { + if _, ok := cfg.Profiles[args[0]]; ok { + name, args = args[0], args[1:] + } + } + p, err := cfg.ActiveProfile(name) + if err != nil { + return fmt.Errorf("%w — `cometcli one `, or `cometcli init` to add one", err) + } + return runChat(cmd, reg, p, args) + }, + } + agentFlags(cmd) + printFlags(cmd) + return cmd +} + +// runChat is the shared entry for bare `cometcli` and `cometcli one`: +// interactive TUI on a terminal, headless (-p) otherwise. p == nil is +// general mode. +func runChat(cmd *cobra.Command, reg *toolkit.Registry, p *config.Profile, args []string) error { + // `cometcli doctr` is a typo, not a prompt — checked only for a single + // word typed interactively, long enough that a near miss is meaningful + if p, _ := cmd.Flags().GetBool("print"); !p && len(args) == 1 && len(args[0]) >= 4 && !strings.ContainsAny(args[0], " \t?") { + cmd.Root().SuggestionsMinimumDistance = 1 + if len(args[0]) >= 6 { + cmd.Root().SuggestionsMinimumDistance = 2 + } + if sug := cmd.Root().SuggestionsFor(args[0]); len(sug) > 0 { + return fmt.Errorf("unknown command %q — did you mean %s? (quote a one-word prompt with a ?)", args[0], strings.Join(sug, ", ")) + } + } + prompt := strings.TrimSpace(strings.Join(args, " ")) + piped, err := readPipedStdin(cmd.InOrStdin()) + if err != nil { + return err + } + if piped != "" { + if prompt == "" { + prompt = piped + } else { + prompt += "\n\n\n" + piped + "\n" + } + } + printMode, _ := cmd.Flags().GetBool("print") + headless := printMode || piped != "" || (!isTerminal(cmd.OutOrStdout()) && prompt != "") + if !headless && !isTerminal(cmd.InOrStdin()) { + if prompt == "" { + return cmd.Help() + } + headless = true + } + + c, err := chatCtx(cmd, p, headless) + if err != nil { + return err + } + defer c.Close() + if c.Audit != nil { + defer c.Audit.Close() + } + if headless { + if prompt == "" { + return fmt.Errorf("no prompt — pass one as an argument or pipe input in") + } + return runPrint(cmd, c, reg, prompt) + } + return tui.RunAppWith(c, reg, 5*time.Second, prompt, func(a *agent.Agent) (string, error) { + return configureChatAgent(cmd, a) + }) +} + +// chatCtx builds the tool context for a chat session. Headless sessions +// never read approvals from stdin (it may be the piped input): anything +// needing approval is denied unless rules or the mode allow it. +func chatCtx(cmd *cobra.Command, p *config.Profile, headless bool) (*toolkit.Context, error) { + cfg, err := config.Load() + if err != nil { + return nil, err + } + name := "general" + if p != nil { + name = p.Name + } + lg, _ := audit.Open(name) + c := &toolkit.Context{ + Context: cmd.Context(), Profile: p, Cfg: cfg, Out: cmd.OutOrStdout(), Audit: lg, + Approver: StdinApprover(cmd.InOrStdin()), + } + if headless { + c.Out = cmd.ErrOrStderr() + c.Approver = headlessApprover + } + if flagYes { + c.AutoApproveBelow = toolkit.TierLocalChange + } + return c, nil +} + +func headlessApprover(_ *toolkit.Context, prompt string, tier toolkit.Tier, _ map[string]any) (bool, error) { + return false, fmt.Errorf("not approved: %s needs approval and -p has no one to ask — allow it with --allowedTools or --permission-mode, or run interactively", tier) +} + +// configureChatAgent applies the agent flags plus the chat-only ones. +func configureChatAgent(cmd *cobra.Command, a *agent.Agent) (string, error) { + if s, _ := cmd.Flags().GetString("append-system-prompt"); s != "" { + a.AppendSystem = s + } + if n, _ := cmd.Flags().GetInt("max-turns"); n > 0 { + a.MaxIter = n + } + return applyAgentFlags(cmd, a) +} + +// readPipedStdin returns piped or redirected input. Terminals, /dev/null +// and other devices are skipped, so an idle stdin never blocks. +func readPipedStdin(r io.Reader) (string, error) { + f, ok := r.(*os.File) + if !ok { + return "", nil + } + fi, err := f.Stat() + if err != nil || fi.Mode()&(os.ModeNamedPipe) == 0 && !fi.Mode().IsRegular() { + return "", nil + } + b, err := io.ReadAll(io.LimitReader(f, 8*maxStdin)) + if err != nil { + return "", err + } + s := strings.TrimSpace(string(b)) + if len(s) > maxStdin { + head, tail := s[:maxStdin*2/3], s[len(s)-maxStdin/3:] + s = head + fmt.Sprintf("\n…[%d bytes of input omitted]…\n", len(s)-len(head)-len(tail)) + tail + } + return s, nil +} + +// runPrint answers one prompt headlessly in the requested format. +func runPrint(cmd *cobra.Command, c *toolkit.Context, reg *toolkit.Registry, prompt string) error { + format, _ := cmd.Flags().GetString("output-format") + switch format { + case "text", "json", "stream-json": + default: + return fmt.Errorf("--output-format %q: want text, json or stream-json", format) + } + a, err := agent.New(c, reg) + if err != nil { + return err + } + defer a.Close() + if _, err := configureChatAgent(cmd, a); err != nil { + return err + } + out := cmd.OutOrStdout() + enc := json.NewEncoder(out) + partial, _ := cmd.Flags().GetBool("include-partial-messages") + verbose, _ := cmd.Flags().GetBool("verbose") + + switch format { + case "stream-json": + _ = enc.Encode(map[string]any{ + "type": "system", "subtype": "init", "session_id": a.ID(), "cwd": a.WorkRoot, + "provider": a.Provider.Name(), "model": a.Model, "mode": scopeName(a), + "permission_mode": a.Policy.Mode, "tools": a.ToolNames(), + }) + a.OnEvent = func(e agent.Event) { + if line := streamEvent(e, partial); line != nil { + _ = enc.Encode(line) + } + } + case "text": + if verbose { + pr := &agent.Printer{Out: cmd.ErrOrStderr()} + a.OnEvent = func(e agent.Event) { + if e.Kind != agent.EvDelta && e.Kind != agent.EvText && e.Kind != agent.EvThinking { + pr.Handle(e) + } + } + } + } + start := time.Now() + res, runErr := a.Run(cmd.Context(), prompt) + if format == "text" { + if res != "" { + fmt.Fprintln(out, res) + } + return runErr + } + usage, _ := a.Usage() + result := map[string]any{ + "type": "result", "subtype": "success", "is_error": runErr != nil, "result": res, + "session_id": a.ID(), "num_turns": a.Rounds(), "duration_ms": time.Since(start).Milliseconds(), + "provider": a.Provider.Name(), "model": a.Model, "mode": scopeName(a), + "usage": map[string]int{"input_tokens": usage.Input, "output_tokens": usage.Output, "cache_read_input_tokens": usage.CacheRead}, + } + if runErr != nil { + result["subtype"] = "error" + result["error"] = runErr.Error() + if errors.Is(runErr, cmd.Context().Err()) && cmd.Context().Err() != nil { + result["subtype"] = "cancelled" + } + } + _ = enc.Encode(result) + return runErr +} + +func scopeName(a *agent.Agent) string { + if a.Node() { + return "node:" + a.Ctx.Profile.Name + } + return "general" +} + +// streamEvent maps an agent event to a stream-json line (nil = skip). +func streamEvent(e agent.Event, partial bool) map[string]any { + switch e.Kind { + case agent.EvDelta: + if partial { + return map[string]any{"type": "stream_event", "delta": e.Text} + } + case agent.EvText: + return map[string]any{"type": "assistant", "text": e.Text} + case agent.EvThinking: + if partial { + return map[string]any{"type": "thinking", "delta": e.Text} + } + case agent.EvToolStart: + return map[string]any{"type": "tool_use", "tool": e.Tool, "tier": e.Tier, "input": e.Args} + case agent.EvToolResult: + m := map[string]any{"type": "tool_result", "tool": e.Tool, "is_error": e.Err != ""} + if e.Err != "" { + m["output"] = e.Err + } else { + m["output"] = e.Text + } + return m + case agent.EvNotice: + return map[string]any{"type": "notice", "text": e.Text} + case agent.EvTodos: + return map[string]any{"type": "todos", "todos": e.Todos} + } + return nil +} diff --git a/internal/cli/chat_test.go b/internal/cli/chat_test.go new file mode 100644 index 0000000..cfa9c48 --- /dev/null +++ b/internal/cli/chat_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "os" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/agent" +) + +func TestReadPipedStdin(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + go func() { + w.WriteString(" panic: boom\n") + w.Close() + }() + got, err := readPipedStdin(r) + if err != nil || got != "panic: boom" { + t.Fatalf("pipe = %q %v", got, err) + } + null, _ := os.Open(os.DevNull) + defer null.Close() + if got, _ := readPipedStdin(null); got != "" { + t.Fatalf("/dev/null read %q", got) + } + big, _ := os.CreateTemp(t.TempDir(), "in") + big.WriteString(strings.Repeat("x", maxStdin) + "TAIL") + big.Seek(0, 0) + got, _ = readPipedStdin(big) + if len(got) > maxStdin+100 || !strings.HasSuffix(got, "TAIL") || !strings.Contains(got, "omitted") { + t.Fatalf("large input: len=%d", len(got)) + } +} + +func TestStreamEvent(t *testing.T) { + if streamEvent(agent.Event{Kind: agent.EvDelta, Text: "x"}, false) != nil { + t.Fatal("delta emitted without --include-partial-messages") + } + m := streamEvent(agent.Event{Kind: agent.EvToolResult, Tool: "bash", Err: "denied"}, false) + if m["type"] != "tool_result" || m["is_error"] != true || m["output"] != "denied" { + t.Fatalf("tool_result = %v", m) + } + if m := streamEvent(agent.Event{Kind: agent.EvText, Text: "hi"}, false); m["type"] != "assistant" { + t.Fatalf("assistant = %v", m) + } +} diff --git a/internal/cli/configcmd.go b/internal/cli/configcmd.go new file mode 100644 index 0000000..12be07f --- /dev/null +++ b/internal/cli/configcmd.go @@ -0,0 +1,140 @@ +package cli + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/abhijitkrm/cometcli/internal/agent" + "github.com/abhijitkrm/cometcli/internal/config" +) + +// agentKeys are the settable global agent keys and their setters. +var agentKeys = map[string]func(*config.AgentConf, string) error{ + "provider": func(a *config.AgentConf, v string) error { a.Provider = v; return nil }, + "model": func(a *config.AgentConf, v string) error { a.Model = v; return nil }, + "base_url": func(a *config.AgentConf, v string) error { a.BaseURL = v; return nil }, + "api_key_env": func(a *config.AgentConf, v string) error { a.APIKeyEnv = v; return nil }, + "effort": func(a *config.AgentConf, v string) error { + if err := agent.ValidEffort(v); err != nil { + return err + } + a.Effort = v + return nil + }, + "mode": func(a *config.AgentConf, v string) error { + if _, err := agent.ParseMode(v); err != nil { + return err + } + a.Mode = v + return nil + }, + "tools": func(a *config.AgentConf, v string) error { + if v != "" && v != "all" && v != "auto" { + return fmt.Errorf("tools: want all or auto") + } + a.Tools = v + return nil + }, + "max_tokens": intKey(func(a *config.AgentConf) *int { return &a.MaxTokens }), + "max_turns": intKey(func(a *config.AgentConf) *int { return &a.MaxTurns }), + "context_window": intKey(func(a *config.AgentConf) *int { return &a.ContextWindow }), + "compact_at": intKey(func(a *config.AgentConf) *int { return &a.CompactAt }), + "no_stream": func(a *config.AgentConf, v string) error { + b, err := strconv.ParseBool(v) + a.NoStream = b + return err + }, +} + +func intKey(f func(*config.AgentConf) *int) func(*config.AgentConf, string) error { + return func(a *config.AgentConf, v string) error { + n, err := strconv.Atoi(v) + if err != nil { + return fmt.Errorf("want a number, got %q", v) + } + *f(a) = n + return nil + } +} + +// configCmd reads and writes the global agent settings in config.yaml — +// the defaults general mode uses and profiles override. +func configCmd() *cobra.Command { + cmd := &cobra.Command{Use: "config", Short: "Show or change global agent settings (agent.provider, agent.model, …)"} + keys := make([]string, 0, len(agentKeys)) + for k := range agentKeys { + keys = append(keys, "agent."+k) + } + sort.Strings(keys) + cmd.AddCommand(&cobra.Command{ + Use: "show", + Short: "Print the global agent section", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + b, _ := yaml.Marshal(map[string]any{"agent": cfg.Agent}) + fmt.Fprint(cmd.OutOrStdout(), string(b)) + if cfg.Agent.Provider == "" { + fmt.Fprintln(cmd.OutOrStdout(), "# no global provider — general mode uses the active profile's agent settings") + } + return nil + }, + }, &cobra.Command{ + Use: "set ", + Short: "Set a key: " + strings.Join(keys, ", "), + Args: cobra.ExactArgs(2), + ValidArgs: keys, + RunE: func(cmd *cobra.Command, args []string) error { + k := strings.TrimPrefix(args[0], "agent.") + set, ok := agentKeys[k] + if !ok { + return fmt.Errorf("unknown key %q — one of %s", args[0], strings.Join(keys, ", ")) + } + cfg, err := config.Load() + if err != nil { + return err + } + if err := set(&cfg.Agent, args[1]); err != nil { + return err + } + if err := cfg.Save(); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "agent.%s = %s\n", k, args[1]) + return nil + }, + }, &cobra.Command{ + Use: "unset ", + Short: "Clear a key", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + k := strings.TrimPrefix(args[0], "agent.") + set, ok := agentKeys[k] + if !ok { + return fmt.Errorf("unknown key %q", args[0]) + } + cfg, err := config.Load() + if err != nil { + return err + } + zero := "" + if strings.HasPrefix(k, "max_") || k == "context_window" || k == "compact_at" { + zero = "0" + } else if k == "no_stream" { + zero = "false" + } + if err := set(&cfg.Agent, zero); err != nil { + return err + } + return cfg.Save() + }, + }) + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 30b3998..5bb98be 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -29,34 +29,42 @@ var ( // NewRoot builds the root command and registers all subcommands. func NewRoot(reg *toolkit.Registry, extra []*cobra.Command) *cobra.Command { root := &cobra.Command{ - Use: "cometcli", - Short: "Agentic SRE terminal for Cosmos-EVM validators", - Long: `cometcli — an agentic SRE terminal for Cosmos-EVM validators. + Use: "cometcli [prompt]", + Short: "Agentic SRE terminal — general ops, plus Cosmos-EVM validators", + Long: `cometcli — an agentic SRE terminal. -Every capability is a deterministic subcommand (cometcli val status, -cometcli doctor, cometcli tx unjail) AND a tool the agent can call. -Run 'cometcli' with no arguments to open the chat terminal, 'cometcli -ask "..."' for one-shot questions, or 'cometcli serve' for a local web chat.`, + cometcli chat on this machine (shell, files, web) + cometcli "why is disk full?" start the chat with a prompt + cometcli -p "…" answer once and exit (scripts, pipes, CI) + cmd | cometcli -p "…" piped input becomes context + cometcli one [profile] […] node mode: validator tools, rules, live snapshot + +Every node capability is also a deterministic subcommand (cometcli val +status, cometcli doctor, cometcli tx unjail) and a tool the agent calls.`, 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") + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // an explicit --profile means node mode, like `cometcli one` + var p *config.Profile + if cmd.Flags().Changed("profile") { + cfg, err := config.Load() + if err != nil { + return err + } + if p, err = cfg.ActiveProfile(flagProfile); err != nil { + return err } - return cmd.Help() } - return runUI(cmd, reg, 5*time.Second) + return runChat(cmd, reg, p, args) }, } pf := root.PersistentFlags() pf.StringVar(&flagProfile, "profile", "", "profile to use (env COMETCLI_PROFILE)") pf.BoolVar(&flagJSON, "json", false, "emit structured JSON") pf.BoolVarP(&flagYes, "yes", "y", false, "auto-approve observe/diagnose prompts (never on-chain)") - sessionFlags(root) + agentFlags(root) + printFlags(root) pf.Bool("debug", false, "log every LLM API attempt (status, timing, retries) to stderr") root.PersistentPreRun = func(cmd *cobra.Command, _ []string) { if d, _ := cmd.Flags().GetBool("debug"); d { @@ -67,7 +75,7 @@ ask "..."' for one-shot questions, or 'cometcli serve' for a local web chat.`, for _, c := range toolGroupCommands(reg) { root.AddCommand(c) } - root.AddCommand(profileCmd(), auditCmd(), versionCmd(), initCmd(), sessionsCmd()) + root.AddCommand(profileCmd(), auditCmd(), versionCmd(), initCmd(), sessionsCmd(), oneCmd(reg), configCmd()) root.AddCommand(extra...) return root } @@ -230,16 +238,6 @@ 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) diff --git a/internal/cli/ui.go b/internal/cli/ui.go index d118497..a7a1530 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "io" "os" "time" @@ -32,8 +31,9 @@ func UICmd(reg *toolkit.Registry) *cobra.Command { return cmd } -func isTerminal(r io.Reader) bool { - f, ok := r.(*os.File) +// isTerminal reports whether a stream (stdin or stdout) is a terminal. +func isTerminal(s any) bool { + f, ok := s.(*os.File) return ok && term.IsTerminal(int(f.Fd())) } diff --git a/internal/config/config.go b/internal/config/config.go index bfc8170..dcb3b5e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,10 +39,78 @@ const configFile = "config.yaml" // Config is the root of ~/.cometcli/config.yaml. type Config struct { - Active string `yaml:"active"` + Active string `yaml:"active"` + // Agent is the default LLM setup, used on its own in general mode + // (no node) and as the base a profile's agent section overrides. + Agent AgentConf `yaml:"agent,omitempty"` Profiles map[string]*Profile `yaml:"profiles"` } +// AgentFor resolves the agent settings for a profile (nil = general +// mode): the global agent section, overridden field by field by the +// profile's. In general mode without a global provider, the active +// profile's settings are used, so existing setups keep working. +func (c *Config) AgentFor(p *Profile) AgentConf { + if c == nil { + if p != nil { + return p.Agent + } + return AgentConf{} + } + if p == nil { + if c.Agent.Provider != "" { + return c.Agent + } + if ap, err := c.ActiveProfile(""); err == nil && ap != nil { + return MergeAgent(c.Agent, ap.Agent) + } + return c.Agent + } + return MergeAgent(c.Agent, p.Agent) +} + +// MergeAgent overlays o's set fields onto base. A different provider in o +// drops base's model, endpoint and key (they belong to the old provider); +// permission rules accumulate. +func MergeAgent(base, o AgentConf) AgentConf { + r := base + if o.Provider != "" && o.Provider != base.Provider { + r.Provider, r.Model, r.BaseURL, r.APIKeyEnv = o.Provider, "", "", "" + } + str := func(dst *string, v string) { + if v != "" { + *dst = v + } + } + num := func(dst *int, v int) { + if v != 0 { + *dst = v + } + } + str(&r.Model, o.Model) + str(&r.BaseURL, o.BaseURL) + str(&r.APIKeyEnv, o.APIKeyEnv) + str(&r.Mode, o.Mode) + str(&r.Effort, o.Effort) + str(&r.Tools, o.Tools) + num(&r.MaxTokens, o.MaxTokens) + num(&r.MaxTurns, o.MaxTurns) + num(&r.ContextWindow, o.ContextWindow) + num(&r.CompactAt, o.CompactAt) + if len(o.Autopilot) > 0 { + r.Autopilot = o.Autopilot + } + r.RedactHosts = append(append([]string{}, base.RedactHosts...), o.RedactHosts...) + r.RedactEndpoints = base.RedactEndpoints || o.RedactEndpoints + r.NoStream = base.NoStream || o.NoStream + r.Permissions = Permissions{ + Allow: append(append([]string{}, base.Permissions.Allow...), o.Permissions.Allow...), + Ask: append(append([]string{}, base.Permissions.Ask...), o.Permissions.Ask...), + Deny: append(append([]string{}, base.Permissions.Deny...), o.Permissions.Deny...), + } + return r +} + // Profile describes one managed node. type Profile struct { Name string `yaml:"-"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6907800..e5e42a6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -64,3 +64,34 @@ func TestOverride(t *testing.T) { t.Fatalf("override failed: %v", p.ChainID) } } + +func TestAgentForMergesAndFallsBack(t *testing.T) { + c := &Config{ + Active: "v1", + Agent: AgentConf{Provider: "groq", Model: "openai/gpt-oss-120b", Effort: "low", Permissions: Permissions{Deny: []string{"bash(rm:*)"}}}, + Profiles: map[string]*Profile{ + "v1": {Agent: AgentConf{Model: "llama-3.3-70b-versatile", Permissions: Permissions{Allow: []string{"node.*"}}}}, + "v2": {Agent: AgentConf{Provider: "gemini"}}, + }, + } + g := c.AgentFor(nil) + if g.Provider != "groq" || g.Model != "openai/gpt-oss-120b" { + t.Fatalf("general = %+v", g) + } + n := c.AgentFor(c.Profiles["v1"]) + if n.Provider != "groq" || n.Model != "llama-3.3-70b-versatile" || n.Effort != "low" { + t.Fatalf("node override = %+v", n) + } + if len(n.Permissions.Deny) != 1 || len(n.Permissions.Allow) != 1 { + t.Fatalf("rules not accumulated: %+v", n.Permissions) + } + if m := c.AgentFor(c.Profiles["v2"]); m.Provider != "gemini" || m.Model != "" { + t.Fatalf("provider switch kept the old model: %+v", m) + } + // no global provider: general mode borrows the active profile's + c.Agent = AgentConf{} + c.Profiles["v1"].Agent.Provider = "gemini" + if g := c.AgentFor(nil); g.Provider != "gemini" { + t.Fatalf("fallback = %+v", g) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index d4135fc..c974caa 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -89,6 +89,7 @@ type AppModel struct { approver *tuiApprover pending *approvalReq + initial string // prompt submitted on start vp viewport.Model width int @@ -118,10 +119,19 @@ func (m *AppModel) waitApproval() tea.Cmd { } func (m *AppModel) Init() tea.Cmd { - return tea.Batch(m.collectSnap(), m.collectFleet(), tick(m.interval), m.waitApproval(), m.waitEvent()) + cmds := []tea.Cmd{m.collectSnap(), m.collectFleet(), tick(m.interval), m.waitApproval(), m.waitEvent()} + if s := m.initial; s != "" { + m.initial = "" + m.chat.append("user", s) + cmds = append(cmds, m.startTurn(s)) + } + return tea.Batch(cmds...) } func (m *AppModel) collectSnap() tea.Cmd { + if m.c.Profile == nil { + return nil // general mode: no node to watch + } return func() tea.Msg { return monitor.Collect(m.c) } } @@ -393,7 +403,13 @@ func (m *AppModel) View() string { if m.snap != nil { clock = m.snap.TS.Format("15:04:05") } - fmt.Fprintf(&b, " %s %s\n", headStyle.Render(m.c.Profile.Name), dim.Render(clock)) + scope := "general" + if m.chat.agent != nil && m.chat.agent.Node() { + scope = m.chat.agent.Ctx.Profile.Name + } else if m.chat.agent == nil && m.c.Profile != nil { + scope = m.c.Profile.Name + } + fmt.Fprintf(&b, " %s %s\n", headStyle.Render(scope), dim.Render(clock)) if m.tab == tabChat { b.WriteString(m.statusLine() + "\n") @@ -580,7 +596,14 @@ func firstLine(s string) string { // RunApp starts the multi-pane TUI. func RunApp(c *toolkit.Context, reg *toolkit.Registry, interval time.Duration, setup ...AgentSetup) error { - p := tea.NewProgram(NewApp(c, reg, interval, setup...), tea.WithAltScreen()) + return RunAppWith(c, reg, interval, "", setup...) +} + +// RunAppWith starts the TUI and submits prompt (if any) right away. +func RunAppWith(c *toolkit.Context, reg *toolkit.Registry, interval time.Duration, prompt string, setup ...AgentSetup) error { + m := NewApp(c, reg, interval, setup...) + m.initial = prompt + p := tea.NewProgram(m, tea.WithAltScreen()) _, err := p.Run() return err } diff --git a/internal/tui/chat.go b/internal/tui/chat.go index d9a6604..85d4e9f 100644 --- a/internal/tui/chat.go +++ b/internal/tui/chat.go @@ -160,7 +160,7 @@ func (m *AppModel) chatSubmit(s string) tea.Cmd { 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") + p.append("info", "no agent provider — /run {json} works; set one with: cometcli config set agent.provider groq") m.syncChatView() return nil } From f4b3f3ead363c5fd5e82769374d8c178efa0e731 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 17:31:13 +0530 Subject: [PATCH 05/48] agent: tell the model questions are about the operator's own system gpt-oss on Groq answered "summarize docker container health" with a generic explainer and no tool calls. Both system prompts now say that questions concern this machine/node and must be answered from evidence gathered with tools, generic explanations only on request. Co-Authored-By: Claude Opus 5.5 --- internal/agent/prompt.go | 3 +++ internal/agent/scope.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/internal/agent/prompt.go b/internal/agent/prompt.go index fd0ccfe..e23709f 100644 --- a/internal/agent/prompt.go +++ b/internal/agent/prompt.go @@ -18,6 +18,9 @@ func SystemPrompt(c *toolkit.Context) string { (CometBFT consensus + Ethereum-compatible execution via the cosmos/evm stack). You operate ONE node via tools. Rules you must follow: +- Questions are about THIS node: inspect it with your tools and answer + from what you find — never with a generic explanation unless the + operator asks how something works in general. - Prefer read-only tools first; diagnose before proposing changes. - NEVER ask for or echo mnemonics, private keys, or priv_validator contents. - For on-chain actions: explain the tx you're about to build, then call the diff --git a/internal/agent/scope.go b/internal/agent/scope.go index 1dd72d4..f4dadd0 100644 --- a/internal/agent/scope.go +++ b/internal/agent/scope.go @@ -22,6 +22,12 @@ You run shell commands and edit files on their machine through tools; they watch and approve anything that changes the system. Rules you must follow: +- Questions are about THIS machine unless the operator says otherwise. + "Summarize container health", "why is disk full", "check nginx" mean: + inspect it now with your tools (docker ps / docker inspect, df -h, + systemctl status, logs …) and answer from what you found. Never answer + such a question with a generic explanation; give one only when asked + how something works in general. - Investigate before changing anything; reference exact evidence (log lines, versions, exit codes) in answers. - Keep answers terse, technical, and actionable. Use markdown sparingly. From 2cd8fa14eebbb249528965124eeaeea415222ec1 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 18:04:09 +0530 Subject: [PATCH 06/48] extensibility: settings, memory, commands, hooks, MCP client, subagents (phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code-compatible formats throughout: - settings.json at user / project / local scope: model, effort, permissions (allow/ask/deny/defaultMode), env (exported before the provider reads keys), hooks, mcpServers; plus a project .mcp.json. - trust: project hooks and MCP servers run commands, so they are ignored (with a notice) until `cometcli trust`; same for a project command's allowed-tools. - COMET.md memory: user, project root down to cwd (AGENTS.md fallback, COMET.local.md), per-node memory in node mode, @imports (key material refused); /memory, /remember appends and tells the model at once. - custom slash commands from .cometcli/commands/**.md: frontmatter (description, argument-hint, allowed-tools as one-turn allow rules), $ARGUMENTS/$1..$9, !`cmd` inlined for read-only commands only, @file; listed in /help, usable with -p. - hooks: PreToolUse (exit 2 / permissionDecision deny|ask|allow — allow never skips a tx prompt), PostToolUse (feedback to the model), UserPromptSubmit (block / add context), Stop (keep working, max 3), SessionStart; stdin JSON contract, process-group kill on timeout. - MCP client: stdio and streamable HTTP (SSE responses, session ids), pagination, ${VAR} expansion, server-request rejection; tools become mcp__server__tool, deferred behind tool_search, readOnlyHint runs freely, others ask, "mcp__server" rules cover a server. `cometcli mcp add|list [--check]|remove` (user/project/local scope); bare `cometcli mcp` still serves. - subagents: a task tool running a fresh-context child agent (same tools/permissions, no recursion, events shown with ↳), plus custom agents from .cometcli/agents/*.md with tool restrictions. - docs/EXTENDING.md. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + docs/COMMANDS.md | 5 + docs/EXTENDING.md | 192 ++++++++++++ internal/agent/commands.go | 94 ++++++ internal/agent/ext.go | 350 +++++++++++++++++++++ internal/agent/loop.go | 121 +++++++- internal/agent/phase4_test.go | 239 +++++++++++++++ internal/agent/repl.go | 2 +- internal/agent/scope.go | 5 +- internal/agent/session.go | 3 + internal/agent/session_test.go | 2 +- internal/agent/toolsearch.go | 45 ++- internal/cli/agent_impl.go | 1 + internal/cli/chat.go | 28 ++ internal/cli/mcp.go | 10 +- internal/cli/mcpservers.go | 256 +++++++++++++++ internal/cli/root.go | 2 +- internal/hooks/hooks.go | 249 +++++++++++++++ internal/hooks/hooks_test.go | 76 +++++ internal/hooks/proc_other.go | 7 + internal/hooks/proc_unix.go | 20 ++ internal/mcpclient/client.go | 478 +++++++++++++++++++++++++++++ internal/mcpclient/client_test.go | 160 ++++++++++ internal/mcpclient/tool.go | 103 +++++++ internal/settings/markdown.go | 417 +++++++++++++++++++++++++ internal/settings/settings.go | 328 ++++++++++++++++++++ internal/settings/settings_test.go | 160 ++++++++++ internal/toolkit/context.go | 11 +- internal/toolkit/perm.go | 29 +- internal/toolkit/toolkit.go | 21 +- internal/tui/app.go | 3 + internal/tui/chat.go | 2 +- 32 files changed, 3396 insertions(+), 26 deletions(-) create mode 100644 docs/EXTENDING.md create mode 100644 internal/agent/ext.go create mode 100644 internal/agent/phase4_test.go create mode 100644 internal/cli/mcpservers.go create mode 100644 internal/hooks/hooks.go create mode 100644 internal/hooks/hooks_test.go create mode 100644 internal/hooks/proc_other.go create mode 100644 internal/hooks/proc_unix.go create mode 100644 internal/mcpclient/client.go create mode 100644 internal/mcpclient/client_test.go create mode 100644 internal/mcpclient/tool.go create mode 100644 internal/settings/markdown.go create mode 100644 internal/settings/settings.go create mode 100644 internal/settings/settings_test.go diff --git a/README.md b/README.md index 9fa4be6..92f2003 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,9 @@ node in context. Every shell command is classified before it runs: reads run, ch ask, transactions always ask, and key material or state resets are refused. See [docs/COMMANDS.md](docs/COMMANDS.md#general-tools-and-permissions). +Settings files, `COMET.md` memory, custom slash commands, hooks, MCP servers and +subagents work like Claude Code's — see [docs/EXTENDING.md](docs/EXTENDING.md). + `cometcli ask "…"`, `cometcli agent` and `cometcli ui` still work as before (node mode on the active profile). diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 12a7036..6151bdb 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -20,6 +20,8 @@ cmd | cometcli -p "prompt" # piped input is attached as cont cometcli -c / -r # continue the latest session here / resume one cometcli config show # global agent settings (general mode; profiles override) cometcli config set agent.provider groq +cometcli trust # let this project's hooks and MCP servers run +cometcli mcp add|list|remove … # MCP servers the agent connects to ``` Headless flags: `--output-format text|json|stream-json`, `--include-partial-messages` @@ -254,6 +256,9 @@ saved after each turn to `~/.cometcli/sessions/` (0600, already redacted). | `/effort [level\|default]` | show or set reasoning depth for the rest of the session | | `/sessions`, `/resume ` | list saved sessions, or load one into the current session | | `/permissions`, `/allow\|/ask\|/deny ` | show rules; add one for this session | +| `/memory`, `/remember [--user\|--node] ` | memory files loaded; add a line to COMET.md | +| `/mcp`, `/agents` | connected MCP servers; subagents for the task tool | +| `/` | commands from `.cometcli/commands/*.md` — see [EXTENDING.md](EXTENDING.md) | | `/one [profile\|off]` | switch the session to node mode for a profile, or back to general (conversation kept) | ### General tools and permissions diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md new file mode 100644 index 0000000..bf522c5 --- /dev/null +++ b/docs/EXTENDING.md @@ -0,0 +1,192 @@ +# Extending cometcli + +Settings, memory, custom commands, hooks, MCP servers and subagents all live in plain +files. The formats follow Claude Code's, so configs written for it mostly carry over. + +A **project** is the nearest directory above where you start cometcli that holds a +`.cometcli/` folder, a `COMET.md` or a `.git`. + +## Settings — `settings.json` + +| File | Scope | +|---|---| +| `~/.cometcli/settings.json` | you, everywhere | +| `/.cometcli/settings.json` | the project — commit it | +| `/.cometcli/settings.local.json` | you, in this project — gitignore it | + +Later files override single values (`model`, `effort`, `permissions.defaultMode`); +lists (`permissions.allow/ask/deny`), `env`, `hooks` and `mcpServers` accumulate. +`config.yaml`'s `agent:` section still picks the provider; settings refine it. + +```json +{ + "model": "openai/gpt-oss-120b", + "effort": "medium", + "permissions": { + "allow": ["bash(docker logs:*)", "bash(./info.sh)", "web_fetch(domain:github.com)"], + "ask": ["bash(docker compose:*)"], + "deny": ["bash(rm:*)", "edit(/etc/**)", "val.unjail"], + "defaultMode": "acceptEdits" + }, + "env": { "GROQ_API_KEY": "…" }, + "hooks": { … }, + "mcpServers": { … } +} +``` + +`env` is exported before the provider starts, without overriding variables already set. +`defaultMode` takes `default`/`ops`, `acceptEdits`, `plan`/`readonly`, `bypassPermissions`. + +## Trust + +Project hooks and project MCP servers run commands on your machine — on a validator +host, that matters. They are **ignored until you trust the project**: + +```bash +cometcli trust # trust the project you're in (covers subfolders) +cometcli trust --off # revoke +cometcli trust --list +``` + +Untrusted items are listed when a session starts. Your user-level settings are always +trusted. A project command's `allowed-tools` also needs trust. + +## Memory — `COMET.md` + +Standing instructions loaded into every session's system prompt: + +1. `~/.cometcli/COMET.md` — yours, everywhere +2. `COMET.md` in each directory from the project root down to where you started + (`AGENTS.md` is read where a directory has no `COMET.md`), plus `COMET.local.md` +3. In node mode, `~/.cometcli/memory/.md` — notes about that node + +A line `@path/to/file.md` imports another file (key material is never imported). + +```markdown +# primium node-setup +- Validators run in docker; containers are primium-validator0..3. +- Never restart validator0 without asking — it's the genesis proposer. +- Upgrades follow upgrade/README.md. +@docs/ports.md +``` + +In a session: `/memory` lists what's loaded; `/remember ` appends to the +project's `COMET.md` (`--user`, `--node` for the others) and tells the model right away. + +## Custom slash commands + +Markdown files in `/.cometcli/commands/` or `~/.cometcli/commands/` become +commands. `ops/vote.md` is `/ops:vote`. A project command shadows a user one. + +```markdown +--- +description: Vote on an upgrade proposal +argument-hint: +allowed-tools: bash(./upgrade/vote-upgrade.sh:*), read +--- +Vote $2 on proposal $1. + +Current proposals: !`docker exec primium-validator0 evmd q gov proposals -o json | jq -r '.proposals[] | "\(.id) \(.status)"'` +Network config: @run-genesis/network-config.env + +Read upgrade/vote-upgrade.sh first, confirm the proposal is in its voting period, +then run it. Report the tx hash. +``` + +- `$ARGUMENTS` is everything after the command; `$1`…`$9` are the words (quotes group). +- `` !`cmd` `` runs while expanding and inlines the output — **read-only commands only** + (the same classifier as the bash tool); anything else is left for the agent to run + with approval. +- `@path` inlines a file (never key material). +- `allowed-tools` adds allow rules for that one turn. Transactions still always ask. + +Commands work headlessly too: `cometcli -p "/ops:vote 7 yes"`. + +## Hooks + +Commands run at points in the loop. Events: `PreToolUse`, `PostToolUse`, +`UserPromptSubmit`, `Stop`, `SessionStart`. + +```json +{ + "hooks": { + "PreToolUse": [ + { "matcher": "bash", "hooks": [{ "type": "command", "command": ".cometcli/hooks/guard.sh", "timeout": 10 }] } + ], + "PostToolUse": [ + { "matcher": "edit|write", "hooks": [{ "type": "command", "command": "evmd validate-genesis 2>&1 | tail -3" }] } + ], + "UserPromptSubmit": [ + { "hooks": [{ "type": "command", "command": "echo \"on-call: $(cat ~/.oncall)\"" }] } + ] + } +} +``` + +`matcher` is a case-insensitive regex over tool names (`bash`, `edit`, `node.status`, +`mcp__grafana__.*`; Claude Code's `Bash` works); empty or `*` matches all. Hooks run +from the project root with `COMETCLI_PROJECT_DIR` (and `CLAUDE_PROJECT_DIR`) set, and +get the event as JSON on stdin: + +```json +{"session_id":"…","transcript_path":"…","cwd":"…","hook_event_name":"PreToolUse", + "permission_mode":"ops","tool_name":"bash","tool_input":{"command":"…"}} +``` + +PostToolUse adds `tool_response`, UserPromptSubmit `prompt`, Stop `stop_hook_active`, +SessionStart `source` (`startup`|`resume`). + +| Exit | Effect | +|---|---| +| 0 | pass. UserPromptSubmit/SessionStart stdout is added as context for the model | +| 2 | **block** — PreToolUse: the call doesn't run and stderr goes to the model; PostToolUse: stderr goes to the model; UserPromptSubmit: the prompt is rejected; Stop: the agent keeps working on stderr (at most 3 times) | +| other | non-blocking error, shown to you | + +Exit 0 with JSON on stdout can decide too: `{"hookSpecificOutput":{"permissionDecision": +"allow|deny|ask","permissionDecisionReason":"…","additionalContext":"…"}}`, +`{"decision":"block","reason":"…"}`, `{"systemMessage":"…"}`, `{"continue":false}`. +A hook `allow` skips the prompt for a host change — **never for a transaction**, and +it can't unlock key material or state resets. + +## MCP servers + +```bash +cometcli mcp add grafana -- npx -y @grafana/mcp-grafana +cometcli mcp add k8s -e KUBECONFIG=~/.kube/config -- kubectl-mcp-server +cometcli mcp add pd --transport http https://mcp.example.com/mcp -H "Authorization: Bearer ${PD_TOKEN}" +cometcli mcp add tools --scope project -- ./scripts/mcp.sh # writes .mcp.json, shared +cometcli mcp list --check # connect and count tools +cometcli mcp remove grafana +``` + +Scopes: `user` (default, `~/.cometcli/settings.json`), `project` (`.mcp.json` — the +same file Claude Code reads), `local` (`.cometcli/settings.local.json`). `${VAR}` and +`${VAR:-default}` expand in commands, args, env, URLs and headers. + +Servers start with each session; their tools are named `mcp____` and, +like node tools, load on demand — the system prompt lists them, the model calls +`tool_search` to load the ones it needs. Tools marked `readOnlyHint` run without a +prompt; others ask. Rules: `mcp__grafana` covers a whole server. `/mcp` shows what's +connected. (`cometcli mcp` with no subcommand still *serves* cometcli's own tools.) + +## Subagents + +The agent can hand a self-contained investigation to a subagent with the `task` tool: +it gets a fresh context, the same tools and permissions, works through the task and +returns only its report — so long log trawls don't fill the main conversation (or a +small per-minute token budget). Subagents can't spawn subagents. + +Define specialized ones in `/.cometcli/agents/.md` or +`~/.cometcli/agents/`: + +```markdown +--- +name: log-digger +description: Trawls container and journal logs for errors around a time window +tools: bash, grep, read +--- +You read logs. Start with docker logs --since for each validator container, +then journalctl. Report each distinct error with first/last timestamp and count. +``` + +`tools` restricts what it can use. `/agents` lists them. diff --git a/internal/agent/commands.go b/internal/agent/commands.go index 4605bff..4a6c5c4 100644 --- a/internal/agent/commands.go +++ b/internal/agent/commands.go @@ -4,10 +4,13 @@ import ( "context" "errors" "fmt" + "path/filepath" "sort" "strings" + "github.com/abhijitkrm/cometcli/internal/config" "github.com/abhijitkrm/cometcli/internal/runbook" + "github.com/abhijitkrm/cometcli/internal/settings" "github.com/abhijitkrm/cometcli/internal/toolkit" ) @@ -279,6 +282,9 @@ func RunCommand(a *Agent, c *toolkit.Context, reg *toolkit.Registry, line string if rule == "" { return CmdResult{}, fmt.Errorf("usage: %s — e.g. bash(systemctl status:*), edit(./config/**), val.unjail", f[0]) } + if a.Rules == nil { + a.Rules, _ = toolkit.NewRules(nil, nil, nil) + } if err := a.Rules.Add(f[0][1:], rule); err != nil { return CmdResult{}, err } @@ -317,6 +323,66 @@ func RunCommand(a *Agent, c *toolkit.Context, reg *toolkit.Registry, line string a.Restore(sf) return CmdResult{Text: fmt.Sprintf("resumed %s — %s (%d messages)", sf.ID, sf.Title, len(sf.History))}, nil + case "/memory": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + a.system() + var b strings.Builder + if a.ext == nil || len(a.ext.memory) == 0 { + b.WriteString("no memory files loaded\n") + } + if a.ext != nil { + for _, m := range a.ext.memory { + fmt.Fprintf(&b, "%-8s %s (%d lines)\n", m.Scope, m.Path, strings.Count(m.Content, "\n")+1) + } + } + b.WriteString("add with /remember (project COMET.md), /remember --user|--node ") + return CmdResult{Text: b.String()}, nil + + case "/remember", "/#": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), f[0])) + path, err := a.memoryTarget(&rest) + if err != nil { + return CmdResult{}, err + } + if rest == "" { + return CmdResult{}, fmt.Errorf("usage: /remember [--user|--node] ") + } + if err := settings.AppendMemory(path, rest); err != nil { + return CmdResult{}, err + } + // the system prompt is frozen: tell the model now, the file + // carries it into future sessions + a.carry = strings.TrimSpace(a.carry + "\n\n[Operator added to memory (" + path + "): " + a.Redact.Text(rest) + "]") + return CmdResult{Text: "remembered in " + path}, nil + + case "/mcp": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + srv := a.MCPServers() + if len(srv) == 0 { + return CmdResult{Text: "no MCP servers connected — add one with `cometcli mcp add -- [args…]`"}, nil + } + return CmdResult{Text: "connected: " + strings.Join(srv, ", ") + "\ntheir tools load on demand (tool_search)"}, nil + + case "/agents": + if err := needAgent(); err != nil { + return CmdResult{}, err + } + if a.ext == nil || len(a.ext.Agents) == 0 { + return CmdResult{Text: "only the general subagent — define more in .cometcli/agents/.md (frontmatter: name, description, tools; body: its instructions)"}, nil + } + var b strings.Builder + for _, d := range a.ext.Agents { + fmt.Fprintf(&b, "%-16s %s (%s)\n", d.Name, d.Description, d.Path) + } + return CmdResult{Text: strings.TrimRight(b.String(), "\n")}, nil + case "/runbook", "/runbooks": if len(f) == 1 { var b strings.Builder @@ -344,9 +410,37 @@ func RunCommand(a *Agent, c *toolkit.Context, reg *toolkit.Registry, line string Prompt: fmt.Sprintf("Run runbook %q with runbook.run, then summarize each step's outcome and anything that needs my attention.", rb.Name), }, nil } + if a != nil && a.ext != nil { + if cmd, ok := a.ext.Commands[strings.TrimPrefix(f[0], "/")]; ok { + args := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), f[0])) + return a.runCustomCommand(c, cmd, args) + } + } return CmdResult{}, ErrUnknownCommand } +// memoryTarget picks the file /remember writes: --user, --node, or the +// project's COMET.md (the user file without a project). +func (a *Agent) memoryTarget(rest *string) (string, error) { + switch { + case strings.HasPrefix(*rest, "--user"): + *rest = strings.TrimSpace(strings.TrimPrefix(*rest, "--user")) + d, err := config.Dir() + return filepath.Join(d, "COMET.md"), err + case strings.HasPrefix(*rest, "--node"): + *rest = strings.TrimSpace(strings.TrimPrefix(*rest, "--node")) + if !a.Node() { + return "", fmt.Errorf("--node needs node mode (/one )") + } + return settings.NodeMemoryPath(a.Ctx.Profile.Name) + } + if a.ext != nil && a.ext.Settings.Root != "" { + return filepath.Join(a.ext.Settings.Root, "COMET.md"), nil + } + d, err := config.Dir() + return filepath.Join(d, "COMET.md"), err +} + // humanTok renders a token count compactly: 950, 12.3k, 1.2M. func humanTok(n int) string { switch { diff --git a/internal/agent/ext.go b/internal/agent/ext.go new file mode 100644 index 0000000..8356517 --- /dev/null +++ b/internal/agent/ext.go @@ -0,0 +1,350 @@ +package agent + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/abhijitkrm/cometcli/internal/client/host" + "github.com/abhijitkrm/cometcli/internal/hooks" + "github.com/abhijitkrm/cometcli/internal/mcpclient" + "github.com/abhijitkrm/cometcli/internal/settings" + "github.com/abhijitkrm/cometcli/internal/toolkit" + "github.com/abhijitkrm/cometcli/internal/tools/shell" +) + +// ext holds what settings files contribute to a session. +type ext struct { + Settings *settings.Settings + Hooks *hooks.Runner + Commands map[string]*settings.Command + Agents map[string]*settings.AgentDef + mcp []*mcpclient.Client + notes []string // startup notices (MCP failures, untrusted items) + memory []settings.Memory + resumed bool // SessionStart source + turnRule []string // allow rules granted for the current turn only +} + +// loadExtensions reads settings, memory and commands for cwd, connects +// MCP servers (registering their tools) and builds the hook runner. +// Settings env vars are exported before this returns, so the provider +// sees API keys set there. +func loadExtensions(cwd string, reg *toolkit.Registry) (*ext, error) { + st, err := settings.Load(cwd) + if err != nil { + return nil, err + } + st.ApplyEnv() + dir := st.Root + if dir == "" { + dir = cwd + } + e := &ext{ + Settings: st, + Hooks: &hooks.Runner{Matchers: st.Hooks, Dir: dir}, + Commands: settings.LoadCommands(st.Root), + Agents: settings.LoadAgents(st.Root), + } + if len(st.Ignored) > 0 { + e.notes = append(e.notes, fmt.Sprintf("project settings in %s are not trusted, so these were skipped: %s — run `cometcli trust` there to enable them", + st.Root, strings.Join(st.Ignored, ", "))) + } + e.connectMCP(reg) + return e, nil +} + +func (e *ext) connectMCP(reg *toolkit.Registry) { + names := make([]string, 0, len(e.Settings.MCPServers)) + for n := range e.Settings.MCPServers { + names = append(names, n) + } + sort.Strings(names) + var mu sync.Mutex + var wg sync.WaitGroup + for _, n := range names { + wg.Add(1) + go func(n string, cfg settings.MCPServer) { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + c, err := mcpclient.Connect(ctx, n, cfg) + mu.Lock() + defer mu.Unlock() + if err != nil { + e.notes = append(e.notes, err.Error()) + return + } + e.mcp = append(e.mcp, c) + }(n, e.Settings.MCPServers[n]) + } + wg.Wait() + for _, c := range e.mcp { + for _, t := range c.Tools { + reg.Upsert(mcpclient.NewAgentTool(c, t)) + } + } +} + +// close stops MCP servers. +func (e *ext) close(reg *toolkit.Registry) { + if e == nil { + return + } + for _, c := range e.mcp { + for _, t := range c.Tools { + reg.Remove(mcpclient.ToolName(c.Name, t.Name)) + } + c.Close() + } + e.mcp = nil +} + +// isMCP reports whether t came from an MCP server. +func isMCP(t toolkit.Tool) bool { return strings.HasPrefix(t.Name(), "mcp__") } + +// MCPServers lists connected servers with their tool counts. +func (a *Agent) MCPServers() []string { + if a.ext == nil { + return nil + } + var out []string + for _, c := range a.ext.mcp { + out = append(out, fmt.Sprintf("%s (%d tools)", c.Name, len(c.Tools))) + } + return out +} + +// memoryText loads COMET.md memory for the current scope. +func (a *Agent) memoryText() string { + if a.ext == nil { + return "" + } + cwd := a.WorkRoot + profile := "" + if a.Node() { + profile = a.Ctx.Profile.Name + } + a.ext.memory = settings.LoadMemory(a.ext.Settings.Root, cwd, profile) + return a.Redact.Text(settings.RenderMemory(a.ext.memory)) +} + +// --- hooks ----------------------------------------------------------------- + +func (a *Agent) hookInput(event string) hooks.Input { + in := hooks.Input{SessionID: a.ID(), Cwd: a.Tools.Cwd(a.WorkRoot), Event: event, PermissionMode: string(a.Policy.Mode)} + if p, err := sessionPath(a.ID()); err == nil { + in.TranscriptPath = p + } + return in +} + +// runHook runs an event's hooks and surfaces their operator messages. +func (a *Agent) runHook(ctx context.Context, in hooks.Input) hooks.Outcome { + if a.ext == nil || !a.ext.Hooks.Has(in.Event) { + return hooks.Outcome{} + } + out := a.ext.Hooks.Run(ctx, in) + for _, m := range out.Messages { + a.emit(Event{Kind: EvNotice, Text: a.Redact.Text(m)}) + } + if lg := a.Audit(); lg != nil && (out.Block || out.Decision != "") { + _ = lg.Log("hook", a.profileName(), map[string]any{"event": in.Event, "tool": in.ToolName, "block": out.Block, "decision": out.Decision, "reason": a.Redact.Text(out.Reason)}) + } + return out +} + +// --- custom slash commands --------------------------------------------------- + +// runCustomCommand expands a markdown command into a prompt. +func (a *Agent) runCustomCommand(c *toolkit.Context, cmd *settings.Command, args string) (CmdResult, error) { + h, herr := a.Ctx.Host() + cwd := a.Tools.Cwd(a.WorkRoot) + run := func(command string) string { + if herr != nil { + return "[" + herr.Error() + "]" + } + binary := "" + if a.Node() { + binary = a.Ctx.Profile.Binary + } + v := shell.Classify(command, shell.Opts{Binary: binary}) + if v.Forbidden != "" || v.Tier > toolkit.TierDiagnose { + return fmt.Sprintf("[!`%s` not run: only read-only commands expand inline — the agent can run it with approval]", command) + } + ctx, cancel := context.WithTimeout(c, 30*time.Second) + defer cancel() + script := command + if cwd != "" { + script = "cd '" + strings.ReplaceAll(cwd, "'", `'\''`) + "' && " + command + } + res, err := host.Exec(ctx, h, script, 16_000) + if err != nil { + return "[" + err.Error() + "]" + } + return strings.TrimRight(res.Output, "\n") + } + read := func(p string) (string, bool) { + if strings.HasPrefix(p, "~/") { + home, _ := os.UserHomeDir() + p = filepath.Join(home, p[2:]) + } else if !filepath.IsAbs(p) { + p = filepath.Join(cwd, p) + } + if _, local := h.(*host.Local); !local || shell.Classify("cat "+p, shell.Opts{}).Forbidden != "" { + return "", false + } + b, err := os.ReadFile(p) + if err != nil || len(b) > 100_000 { + return "", false + } + return string(b), true + } + prompt := a.Redact.Text(cmd.Expand(args, run, read)) + if prompt == "" { + return CmdResult{}, fmt.Errorf("/%s expanded to nothing", cmd.Name) + } + text := fmt.Sprintf("/%s (%s)", cmd.Name, cmd.Scope) + if len(cmd.AllowedTools) > 0 { + if cmd.Scope == "project" && !a.ext.Settings.Trusted { + text += " — allowed-tools ignored: project not trusted (`cometcli trust`)" + } else { + a.ext.turnRule = append(a.ext.turnRule, cmd.AllowedTools...) + text += " — allowing for this turn: " + strings.Join(cmd.AllowedTools, ", ") + } + } + return CmdResult{Text: text, Prompt: prompt}, nil +} + +// grantTurnRules applies a command's allowed-tools for one turn and +// returns the cleanup. +func (a *Agent) grantTurnRules() func() { + if a.ext == nil || len(a.ext.turnRule) == 0 { + return func() {} + } + rules := a.ext.turnRule + a.ext.turnRule = nil + if a.Rules == nil { + a.Rules, _ = toolkit.NewRules(nil, nil, nil) + } + var added []string + for _, r := range rules { + if a.Rules.Add("allow", r) == nil { + added = append(added, r) + } + } + return func() { + for _, r := range added { + a.Rules.Remove("allow", r) + } + } +} + +// HelpText is the slash-command help, including custom commands. +func HelpText(a *Agent) string { + s := CommandHelp + "\n /memory, /remember show memory files; add a line to COMET.md\n /mcp connected MCP servers\n /agents subagents available to the task tool" + if a == nil || a.ext == nil || len(a.ext.Commands) == 0 { + return s + } + var b strings.Builder + b.WriteString(s + "\ncustom commands:") + for _, c := range settings.SortedCommands(a.ext.Commands) { + name := "/" + c.Name + if c.ArgHint != "" { + name += " " + c.ArgHint + } + fmt.Fprintf(&b, "\n %-29s %s (%s)", name, c.Description, c.Scope) + } + return b.String() +} + +// --- subagents --------------------------------------------------------------- + +const taskToolName = "task" + +func (a *Agent) taskToolDef() ToolDef { + desc := "Delegate a self-contained investigation to a subagent with its own fresh context — log trawls, multi-file reads, comparing hosts. It has the same tools and permissions, works through the task, and returns only its final report, keeping your context small. Give it everything it needs in prompt." + props := map[string]any{ + "description": toolkit.Str("3-5 word label shown to the operator"), + "prompt": toolkit.Str("the complete task: goal, where to look, what to report"), + } + if a.ext != nil && len(a.ext.Agents) > 0 { + var names []string + var lines []string + for _, d := range a.ext.Agents { + names = append(names, d.Name) + lines = append(lines, d.Name+": "+d.Description) + } + sort.Strings(names) + sort.Strings(lines) + props["agent"] = toolkit.Enum("a specialized subagent (optional): "+strings.Join(lines, "; "), names...) + } + return ToolDef{Name: taskToolName, Desc: desc, Schema: toolkit.ObjSchema(props, "description", "prompt")} +} + +// runTask runs a subagent to completion and returns its report. +func (a *Agent) runTask(ctx context.Context, call Call, args map[string]any) Msg { + prompt, _ := args["prompt"].(string) + label, _ := args["description"].(string) + if strings.TrimSpace(prompt) == "" { + return a.toolErr(call, taskToolName, "prompt is required") + } + var def *settings.AgentDef + if n, _ := args["agent"].(string); n != "" && a.ext != nil { + if def = a.ext.Agents[n]; def == nil { + return a.toolErr(call, taskToolName, "no subagent "+n) + } + } + child := &Agent{ + Provider: a.Provider, Model: a.Model, Reg: a.Reg, Ctx: a.Ctx, + MaxIter: 25, Policy: a.Policy, Redact: a.Redact, Stream: a.Stream, + SnapshotFn: a.SnapshotFn, MaxTokens: a.MaxTokens, Effort: a.Effort, + Rules: a.Rules, Tools: toolkit.NewSession(a.Tools.Cwd("")), WorkRoot: a.WorkRoot, + conf: a.conf, ext: a.ext, parent: a, def: def, id: a.ID(), audit: a.audit, + } + child.OnEvent = func(e Event) { + switch e.Kind { + case EvToolStart, EvToolResult, EvNotice: + e.Tool = "↳ " + e.Tool + a.emit(e) + } + } + a.emit(Event{Kind: EvToolStart, Tool: taskToolName, Args: map[string]any{"description": label}}) + out, err := child.Run(ctx, prompt) + a.total.Add(child.total) + if err != nil { + return a.toolErr(call, taskToolName, "subagent failed: "+err.Error()) + } + a.emit(Event{Kind: EvToolResult, Tool: taskToolName, Text: firstLine(out)}) + return Msg{Role: "tool", CallID: call.ID, ToolName: taskToolName, Text: clip(out, 30_000)} +} + +// subagentPrompt is appended to a subagent's system prompt. +func (a *Agent) subagentPrompt() string { + s := "\nYOU ARE A SUBAGENT: another agent delegated one task to you. Work through it with your tools, then reply with a concise, complete report — findings with exact evidence, what you changed, what remains. Only your final message is returned.\n" + if a.def != nil && a.def.Prompt != "" { + s += "\n" + a.def.Prompt + "\n" + } + return s +} + +// allowedByDef restricts a custom subagent to its declared tools. +func (a *Agent) allowedByDef(name string) bool { + if a.def == nil || len(a.def.Tools) == 0 { + return true + } + for _, t := range a.def.Tools { + if r, err := toolkit.ParseRule(t); err == nil && strings.EqualFold(r.Tool, name) { + return true + } + if strings.HasSuffix(t, "*") && strings.HasPrefix(name, strings.TrimSuffix(t, "*")) { + return true + } + } + return false +} diff --git a/internal/agent/loop.go b/internal/agent/loop.go index c61a393..11487e5 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -14,7 +14,9 @@ import ( "github.com/abhijitkrm/cometcli/internal/audit" "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/hooks" "github.com/abhijitkrm/cometcli/internal/redact" + "github.com/abhijitkrm/cometcli/internal/settings" "github.com/abhijitkrm/cometcli/internal/toolkit" ) @@ -83,6 +85,9 @@ type Agent struct { loaded map[string]bool // tools loaded with tool_search rounds int // model rounds in the latest Run ownCtx bool // a.Ctx was built by SwitchScope + ext *ext // settings, hooks, commands, MCP servers + parent *Agent // set on subagents + def *settings.AgentDef } // New builds an agent for a context. With a profile it runs in node mode @@ -97,8 +102,27 @@ func New(c *toolkit.Context, reg *toolkit.Registry) (*Agent, error) { case c.Profile != nil: ac = c.Profile.Agent } + cwd, _ := os.Getwd() + e, err := loadExtensions(cwd, reg) // exports settings env before the provider reads keys + if err != nil { + return nil, err + } + st := e.Settings + if st.Model != "" { + ac.Model = st.Model + } + if st.Effort != "" { + ac.Effort = st.Effort + } + if st.Permissions.DefaultMode != "" { + ac.Mode = st.Permissions.DefaultMode + } + ac.Permissions.Allow = append(ac.Permissions.Allow, st.Permissions.Allow...) + ac.Permissions.Ask = append(ac.Permissions.Ask, st.Permissions.Ask...) + ac.Permissions.Deny = append(ac.Permissions.Deny, st.Permissions.Deny...) prov, err := NewProvider(ac) if err != nil { + e.close(reg) return nil, err } pol, err := PolicyFrom(ac) @@ -125,8 +149,9 @@ func New(c *toolkit.Context, reg *toolkit.Registry) (*Agent, error) { if a.Rules, err = toolkit.NewRules(ac.Permissions.Allow, ac.Permissions.Ask, ac.Permissions.Deny); err != nil { return nil, err } - a.WorkRoot, _ = os.Getwd() + a.WorkRoot = cwd a.Tools = toolkit.NewSession("") + a.ext = e if a.Model == "" { a.Model = modelOf(prov) } @@ -234,7 +259,7 @@ func (a *Agent) toolDefs() []ToolDef { if !a.Policy.Allows(t.Tier()) && !toolkit.IsDynamic(t) { continue } - if !a.advertised(t) { + if !a.advertised(t) || !a.allowedByDef(t.Name()) { continue } out = append(out, ToolDef{ @@ -247,6 +272,9 @@ func (a *Agent) toolDefs() []ToolDef { if a.deferred() { out = append(out, toolSearchDef) } + if a.parent == nil { + out = append(out, a.taskToolDef()) + } return out } @@ -278,6 +306,33 @@ func (a *Agent) run(ctx context.Context, input string) (string, error) { a.emit(Event{Kind: EvNotice, Text: "auto-compact failed: " + err.Error()}) } } + if a.ext != nil && a.parent == nil { + for _, n := range a.ext.notes { + a.emit(Event{Kind: EvNotice, Text: n}) + } + a.ext.notes = nil + if a.sys == "" || a.ext.resumed { + in := a.hookInput(hooks.SessionStart) + in.Source = "startup" + if a.ext.resumed { + in.Source = "resume" + } + a.ext.resumed = false + if out := a.runHook(ctx, in); out.Context != "" { + a.carry = strings.TrimSpace(a.carry + "\n\n\n" + a.Redact.Text(out.Context) + "\n") + } + } + in := a.hookInput(hooks.UserPromptSubmit) + in.Prompt = input + out := a.runHook(ctx, in) + if out.Block { + return "", fmt.Errorf("prompt blocked by hook: %s", out.Reason) + } + if out.Context != "" { + input += "\n\n\n" + out.Context + "\n" + } + } + defer a.grantTurnRules()() input = a.Redact.Text(input) if lg := a.Audit(); lg != nil { _ = lg.Log(audit.KindPrompt, a.profileName(), map[string]any{"text": input}) @@ -285,7 +340,7 @@ func (a *Agent) run(ctx context.Context, input string) (string, error) { a.history = append(a.history, Msg{Role: "user", Text: a.turnPrefix() + input}) var texts []string - conts, compacted := 0, false + conts, compacted, stops := 0, false, 0 a.rounds = 0 for i := 0; i < a.MaxIter; i++ { a.rounds++ @@ -337,6 +392,16 @@ func (a *Agent) run(ctx context.Context, input string) (string, error) { a.history = append(a.history, Msg{Role: "user", Text: "Your reply was cut off by the output limit. Continue exactly where you stopped, without repeating anything."}) continue } + if (resp.Done || len(resp.Calls) == 0) && a.parent == nil && stops < 3 && a.ext != nil && a.ext.Hooks.Has(hooks.Stop) { + in := a.hookInput(hooks.Stop) + in.StopHookActive = stops > 0 + if out := a.runHook(ctx, in); out.Block { + stops++ + a.emit(Event{Kind: EvNotice, Text: "Stop hook: " + a.Redact.Text(out.Reason)}) + a.history = append(a.history, Msg{Role: "user", Text: "[Stop hook] " + a.Redact.Text(out.Reason) + "\nAddress this, then finish."}) + continue + } + } if resp.Done || len(resp.Calls) == 0 { if conts > 0 { return strings.Join(texts[len(texts)-conts-1:], ""), nil @@ -455,6 +520,10 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { return a.todoWrite(call, args) case toolSearchName: return a.toolSearch(call, args) + case taskToolName: + if a.parent == nil { + return a.runTask(ctx, call, args) + } } t, ok := a.Reg.Get(name) if !ok { @@ -477,12 +546,37 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { runCtx, cancel := a.toolCtx(ctx, t, args) var res *toolkit.Result - err := a.ruleGate(runCtx, t, shownArgs) + in := a.hookInput(hooks.PreToolUse) + in.ToolName, in.ToolInput = name, args + pre := a.runHook(ctx, in) + var err error + switch { + case pre.Block || pre.Decision == "deny": + err = fmt.Errorf("blocked by PreToolUse hook: %s", pre.Reason) + default: + runCtx.HookDecision = pre.Decision + err = a.ruleGate(runCtx, t, shownArgs) + } if err == nil { res, err = t.Run(runCtx, args) } runCtx.Close() cancel() + var postNote string + if a.ext != nil && a.ext.Hooks.Has(hooks.PostToolUse) && !pre.Block && pre.Decision != "deny" { + in := a.hookInput(hooks.PostToolUse) + in.ToolName, in.ToolInput = name, args + resp := map[string]any{"success": err == nil} + if err != nil { + resp["error"] = err.Error() + } else if res != nil { + resp["output"] = res.Text + } + in.ToolResponse = resp + if post := a.runHook(ctx, in); post.Block || post.Context != "" { + postNote = strings.TrimSpace(post.Reason + "\n" + post.Context) + } + } var text string if err == nil { @@ -508,9 +602,13 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { } lg.ToolSeen(a.profileName(), name, t.Tier().String(), shownArgs, data, err, text) } + if postNote != "" { + postNote = "\n[PostToolUse hook] " + a.Redact.Text(postNote) + } if err != nil { - return a.toolErr(call, name, "error: "+err.Error()) + return a.toolErr(call, name, "error: "+err.Error()+postNote) } + text += postNote 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} } @@ -554,6 +652,12 @@ func (a *Agent) ruleGate(c *toolkit.Context, t toolkit.Tool, shownArgs map[strin return nil } d, rule := a.Rules.Decide(toolkit.Request{Tool: t.Name()}) + switch { + case d != toolkit.DecideDeny && c.HookDecision == "ask": + d, rule = toolkit.DecideAsk, "PreToolUse hook" + case d == toolkit.DecideDefault && c.HookDecision == "allow": + d = toolkit.DecideAllow + } switch d { case toolkit.DecideDeny: return fmt.Errorf("denied by permission rule %s", rule) @@ -572,9 +676,12 @@ func (a *Agent) ruleGate(c *toolkit.Context, t toolkit.Tool, shownArgs map[strin func (a *Agent) system() string { if a.sys == "" { if a.Node() { - a.sys = a.Redact.Text(SystemPrompt(a.Ctx)) + a.toolCatalog() + a.snapshot() + a.sys = a.Redact.Text(SystemPrompt(a.Ctx)) + a.memoryText() + a.toolCatalog() + a.snapshot() } else { - a.sys = a.Redact.Text(GeneralPrompt(a.Ctx, a.Tools.Cwd(a.WorkRoot))) + a.sys = a.Redact.Text(GeneralPrompt(a.Ctx, a.Tools.Cwd(a.WorkRoot))) + a.memoryText() + a.toolCatalog() + } + if a.parent != nil { + a.sys += a.subagentPrompt() } if a.AppendSystem != "" { a.sys += "\n" + a.Redact.Text(a.AppendSystem) + "\n" diff --git a/internal/agent/phase4_test.go b/internal/agent/phase4_test.go new file mode 100644 index 0000000..a09d17f --- /dev/null +++ b/internal/agent/phase4_test.go @@ -0,0 +1,239 @@ +package agent + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/hooks" + "github.com/abhijitkrm/cometcli/internal/settings" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +func hookScript(t *testing.T, dir, name, body string) string { + p := filepath.Join(dir, name) + os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755) + return p +} + +func withExt(a *Agent, dir string, hm map[string][]settings.HookMatcher) { + a.ext = &ext{Settings: &settings.Settings{Root: dir}, Hooks: &hooks.Runner{Matchers: hm, Dir: dir}, + Commands: map[string]*settings.Command{}, Agents: map[string]*settings.AgentDef{}} +} + +func TestPreAndPostToolUseHooks(t *testing.T) { + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{bashCall("c1", "echo should-not-run > x.txt")}}, + {Calls: []Call{bashCall("c2", "echo fine")}}, + {Text: "done", Done: true}, + }} + a, dir := newShellAgent(t, prov) + pre := hookScript(t, dir, "pre.sh", `grep -q 'x.txt' && { echo "writing x.txt is not allowed" >&2; exit 2; }; exit 0`) + post := hookScript(t, dir, "post.sh", `echo '{"decision":"block","reason":"remember to check peers"}'`) + withExt(a, dir, map[string][]settings.HookMatcher{ + hooks.PreToolUse: {{Matcher: "bash", Hooks: []settings.HookCommand{{Command: pre}}}}, + hooks.PostToolUse: {{Matcher: "Bash", Hooks: []settings.HookCommand{{Command: post}}}}, + }) + a.Run(context.Background(), "go") + h := a.History() + if !strings.Contains(h[2].Text, "blocked by PreToolUse hook: writing x.txt is not allowed") { + t.Fatalf("pre hook: %q", h[2].Text) + } + if _, err := os.Stat(filepath.Join(dir, "x.txt")); err == nil { + t.Fatal("blocked command ran") + } + if !strings.Contains(h[4].Text, "fine") || !strings.Contains(h[4].Text, "[PostToolUse hook] remember to check peers") { + t.Fatalf("post hook: %q", h[4].Text) + } +} + +func TestPreToolUseAllowSkipsPromptButNotForTx(t *testing.T) { + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{bashCall("c1", "touch ok.txt")}}, + {Calls: []Call{bashCall("c2", "evmd tx gov vote 1 yes --from v")}}, + {Text: "done", Done: true}, + }} + a, dir := newShellAgent(t, prov) + asked := []string{} + a.Ctx.Approver = func(_ *toolkit.Context, p string, _ toolkit.Tier, _ map[string]any) (bool, error) { + asked = append(asked, p) + return false, nil + } + a.Ctx.AutoApproveBelow = toolkit.TierLocalChange + allow := hookScript(t, dir, "allow.sh", `echo '{"hookSpecificOutput":{"permissionDecision":"allow"}}'`) + withExt(a, dir, map[string][]settings.HookMatcher{hooks.PreToolUse: {{Hooks: []settings.HookCommand{{Command: allow}}}}}) + a.Run(context.Background(), "go") + if _, err := os.Stat(filepath.Join(dir, "ok.txt")); err != nil { + t.Fatal("hook allow didn't skip the prompt") + } + if len(asked) != 1 || !strings.Contains(asked[0], "evmd tx") { + t.Fatalf("a tx must still ask despite a hook allow: %v", asked) + } +} + +func TestUserPromptSubmitAndStopHooks(t *testing.T) { + prov := &mockProvider{responses: []*Response{{Text: "first", Done: true}, {Text: "second", Done: true}}} + a, dir := newShellAgent(t, prov) + ups := hookScript(t, dir, "ups.sh", `in=$(cat); case "$in" in *secret-project*) echo "not here" >&2; exit 2;; esac; echo "on-call: alice"`) + stop := hookScript(t, dir, "stop.sh", `in=$(cat); case "$in" in *'"stop_hook_active":true'*) exit 0;; esac; echo '{"decision":"block","reason":"verify with node status"}'`) + withExt(a, dir, map[string][]settings.HookMatcher{ + hooks.UserPromptSubmit: {{Hooks: []settings.HookCommand{{Command: ups}}}}, + hooks.Stop: {{Hooks: []settings.HookCommand{{Command: stop}}}}, + }) + if _, err := a.Run(context.Background(), "tell me about secret-project"); err == nil || !strings.Contains(err.Error(), "not here") { + t.Fatalf("prompt not blocked: %v", err) + } + if prov.calls != 0 { + t.Fatal("blocked prompt reached the model") + } + out, err := a.Run(context.Background(), "status?") + if err != nil || out != "second" || prov.calls != 2 { + t.Fatalf("stop hook should force one more round: out=%q calls=%d err=%v", out, prov.calls, err) + } + if !strings.Contains(prov.reqs[0].Messages[0].Text, "on-call: alice") { + t.Fatalf("hook context missing: %q", prov.reqs[0].Messages[0].Text) + } + last := prov.reqs[1].Messages[len(prov.reqs[1].Messages)-1].Text + if !strings.Contains(last, "[Stop hook] verify with node status") { + t.Fatalf("stop reason not fed back: %q", last) + } +} + +func TestCustomCommandExpandsAndGrantsTurnRules(t *testing.T) { + prov := &mockProvider{} + a, dir := newShellAgent(t, prov) + withExt(a, dir, nil) + a.ext.Settings.Trusted = true + os.WriteFile(filepath.Join(dir, "network-config.env"), []byte("CHAIN_ID=primium-1\n"), 0o644) + a.ext.Commands["vote"] = &settings.Command{Name: "vote", Scope: "project", AllowedTools: []string{"bash(./upgrade/vote-upgrade.sh:*)"}, + Body: "Vote $1 on proposal $2.\nHost: !`uname -s`\nRefused: !`touch x`\nConfig: @network-config.env"} + res, err := RunCommand(a, a.Ctx, a.Reg, "/vote yes 7") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"Vote yes on proposal 7.", "Host: " + uname(t), "not run: only read-only", "CHAIN_ID=primium-1"} { + if !strings.Contains(res.Prompt, want) { + t.Errorf("prompt missing %q:\n%s", want, res.Prompt) + } + } + if !strings.Contains(res.Text, "allowing for this turn") { + t.Fatalf("text = %q", res.Text) + } + done := a.grantTurnRules() + if d, _ := a.Rules.Decide(toolkit.Request{Tool: "bash", Kind: "command", Specs: []string{"./upgrade/vote-upgrade.sh 7 yes"}}); d != toolkit.DecideAllow { + t.Fatal("turn rule not granted") + } + done() + if d, _ := a.Rules.Decide(toolkit.Request{Tool: "bash", Kind: "command", Specs: []string{"./upgrade/vote-upgrade.sh 7 yes"}}); d != toolkit.DecideDefault { + t.Fatal("turn rule outlived the turn") + } + // untrusted project: allowed-tools ignored + a.ext.Settings.Trusted = false + res, _ = RunCommand(a, a.Ctx, a.Reg, "/vote yes 7") + if !strings.Contains(res.Text, "ignored") || len(a.ext.turnRule) != 0 { + t.Fatalf("untrusted grant: %q %v", res.Text, a.ext.turnRule) + } +} + +func TestMemoryInSystemPrompt(t *testing.T) { + prov := &mockProvider{} + a, dir := newShellAgent(t, prov) + withExt(a, dir, nil) + os.WriteFile(filepath.Join(dir, "COMET.md"), []byte("Validators run in docker; never restart val0 without asking."), 0o644) + a.WorkRoot = dir + a.Run(context.Background(), "hi") + if !strings.Contains(prov.reqs[0].System, "never restart val0 without asking") { + t.Fatalf("memory missing from system prompt") + } + res, err := RunCommand(a, a.Ctx, a.Reg, "/remember peers live in config.toml") + if err != nil || !strings.Contains(res.Text, filepath.Join(dir, "COMET.md")) { + t.Fatalf("/remember: %v %v", res, err) + } + a.Run(context.Background(), "next") + last := prov.reqs[1].Messages[len(prov.reqs[1].Messages)-1].Text + if !strings.Contains(last, "Operator added to memory") || prov.reqs[1].System != prov.reqs[0].System { + t.Fatalf("memory update should ride on the turn, system frozen: %q", last) + } +} + +func TestSubagentTask(t *testing.T) { + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "t1", Name: "task", Args: json.RawMessage(`{"description":"scan logs","prompt":"find panics in the logs"}`)}}}, + {Calls: []Call{bashCall("s1", "echo 'panic: oom'")}}, // subagent round 1 + {Text: "REPORT: one OOM panic", Done: true}, // subagent final + {Text: "The node OOM-panicked once.", Done: true}, // parent final + }} + a, _ := newShellAgent(t, prov) + var notices []string + a.OnEvent = func(e Event) { + if e.Kind == EvToolStart { + notices = append(notices, e.Tool) + } + } + out, err := a.Run(context.Background(), "check logs") + if err != nil || out != "The node OOM-panicked once." { + t.Fatalf("out=%q err=%v", out, err) + } + if got := a.History()[2].Text; got != "REPORT: one OOM panic" { + t.Fatalf("task result = %q", got) + } + child := prov.reqs[1] + if !strings.Contains(child.System, "YOU ARE A SUBAGENT") || len(child.Messages) != 1 { + t.Fatalf("subagent request: %d msgs", len(child.Messages)) + } + for _, td := range child.Tools { + if td.Name == taskToolName { + t.Fatal("subagent can spawn subagents") + } + } + if strings.Join(notices, ",") != "task,↳ bash" { + t.Fatalf("events = %v", notices) + } + if len(a.History()) != 4 { + t.Fatalf("subagent turns leaked into the parent history: %d", len(a.History())) + } +} + +func TestMCPToolsDeferredInGeneralMode(t *testing.T) { + mcpTool := stubTool{name: "mcp__grafana__query", tier: toolkit.TierDiagnose, run: func(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { + return &toolkit.Result{Text: "p99 120ms"}, nil + }} + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "c1", Name: "tool_search", Args: json.RawMessage(`{"query":"select:mcp__grafana__query"}`)}}}, + {Calls: []Call{{ID: "c2", Name: "mcp__grafana__query", Args: json.RawMessage(`{}`)}}}, + {Text: "ok", Done: true}, + }} + a, _ := newShellAgent(t, prov, mcpTool) + a.Ctx.Profile = nil // general mode + a.Run(context.Background(), "latency?") + first := strings.Join(toolNames(prov.reqs[0]), ",") + if strings.Contains(first, "mcp__grafana__query") || !strings.Contains(first, "tool_search") { + t.Fatalf("first tools = %s", first) + } + if !strings.Contains(prov.reqs[0].System, "mcp__grafana: query") { + t.Fatalf("catalog missing MCP tools") + } + if !strings.Contains(strings.Join(toolNames(prov.reqs[1]), ","), "mcp__grafana__query") || !strings.Contains(lastToolText(a), "p99 120ms") { + t.Fatalf("MCP tool not loaded/called: %q", lastToolText(a)) + } +} + +func uname(t *testing.T) string { + out, err := exec.Command("uname", "-s").Output() + if err != nil { + t.Skip("no uname") + } + return strings.TrimSpace(string(out)) +} + +func toolNames(r *Request) []string { + var n []string + for _, td := range r.Tools { + n = append(n, td.Name) + } + return n +} diff --git a/internal/agent/repl.go b/internal/agent/repl.go index 3e89530..ecde659 100644 --- a/internal/agent/repl.go +++ b/internal/agent/repl.go @@ -169,7 +169,7 @@ func (r *REPL) slash(cmd string) (prompt string, quit bool) { case "/exit", "/quit", "/q": return "", true case "/help": - fmt.Fprintln(r.Out, "Commands:\n"+CommandHelp+"\n /exit quit") + fmt.Fprintln(r.Out, "Commands:\n"+HelpText(r.Agent)+"\n /exit quit") return "", false } res, err := RunCommand(r.Agent, r.Agent.Ctx, r.Agent.Reg, cmd) diff --git a/internal/agent/scope.go b/internal/agent/scope.go index f4dadd0..d6ed6f4 100644 --- a/internal/agent/scope.go +++ b/internal/agent/scope.go @@ -96,9 +96,12 @@ func (a *Agent) SwitchScope(p *config.Profile) { a.carry += note } -// Close releases a context created by SwitchScope. +// Close releases a context created by SwitchScope and stops MCP servers. func (a *Agent) Close() { if a.ownCtx { a.Ctx.Close() } + if a.parent == nil { + a.ext.close(a.Reg) + } } diff --git a/internal/agent/session.go b/internal/agent/session.go index 99b4618..005205c 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -130,6 +130,9 @@ func (a *Agent) Restore(sf *SessionFile) { a.loaded[n] = true } a.Tools = toolkit.NewSession(sf.ShellCwd) + if a.ext != nil { + a.ext.resumed = true + } // the frozen system prompt carries the old snapshot: force a refresh // onto the next turn a.snapAt = time.Time{} diff --git a/internal/agent/session_test.go b/internal/agent/session_test.go index 9f6ea1c..dea917e 100644 --- a/internal/agent/session_test.go +++ b/internal/agent/session_test.go @@ -160,7 +160,7 @@ func TestReadOnlyHidesAndBlocks(t *testing.T) { t.Fatal("local-change tool ran in readonly mode") } for _, td := range prov.reqs[0].Tools { - if td.Name != todoToolName && td.Name != toolSearchName { + if td.Name != todoToolName && td.Name != toolSearchName && td.Name != taskToolName { t.Fatalf("readonly advertised mutating tool %s", td.Name) } } diff --git a/internal/agent/toolsearch.go b/internal/agent/toolsearch.go index 4a3565c..a74feb4 100644 --- a/internal/agent/toolsearch.go +++ b/internal/agent/toolsearch.go @@ -30,12 +30,36 @@ var toolSearchDef = ToolDef{ }, "query"), } -// deferred reports whether this session loads node tools on demand. -// General mode has no node tools to defer. -func (a *Agent) deferred() bool { return a.conf.Tools != "all" && a.Node() } +// deferred reports whether this session loads tools on demand: node +// tools in node mode, MCP server tools in either mode. +func (a *Agent) deferred() bool { + if a.conf.Tools == "all" { + return false + } + if a.Node() { + return true + } + for _, t := range a.Reg.All() { + if isMCP(t) { + return true + } + } + return false +} + +// deferrable reports whether t is listed in the catalog rather than sent. +func (a *Agent) deferrable(t toolkit.Tool) bool { + if isMCP(t) { + return true + } + return a.Node() && !toolkit.IsAgentOnly(t) && !coreTools[t.Name()] && !toolkit.IsLongRunning(t) && strings.Contains(t.Name(), ".") +} // advertised reports whether t goes into this request's tool list. func (a *Agent) advertised(t toolkit.Tool) bool { + if isMCP(t) { + return a.conf.Tools == "all" || a.loaded[t.Name()] + } if toolkit.IsAgentOnly(t) { return true } @@ -57,10 +81,15 @@ func (a *Agent) toolCatalog() string { groups := map[string][]string{} var order []string for _, t := range a.Reg.All() { - if toolkit.IsAgentOnly(t) || coreTools[t.Name()] || toolkit.IsLongRunning(t) { + if !a.deferrable(t) { continue } dom, verb, ok := strings.Cut(t.Name(), ".") + if isMCP(t) { + rest := strings.TrimPrefix(t.Name(), "mcp__") + srv, tool, _ := strings.Cut(rest, "__") + dom, verb, ok = "mcp__"+srv, tool, true + } if !ok { continue } @@ -73,7 +102,7 @@ func (a *Agent) toolCatalog() string { return "" } var b strings.Builder - b.WriteString("TOOL CATALOG — load with tool_search before calling (e.g. select:val.unjail):\n") + b.WriteString("TOOL CATALOG — load with tool_search before calling (e.g. select:val.unjail, select:mcp__server__tool):\n") for _, d := range order { fmt.Fprintf(&b, "- %s: %s\n", d, strings.Join(groups[d], ", ")) } @@ -108,6 +137,10 @@ func (a *Agent) toolSearch(call Call, args map[string]any) Msg { if toolkit.IsLongRunning(t) { continue } + if !a.deferrable(t) && !coreTools[t.Name()] { + missing = append(missing, t.Name()+" (node tool: needs node mode — /one )") + continue + } a.loaded[t.Name()] = true names = append(names, toolFnName(t.Name())) note := "" @@ -141,7 +174,7 @@ func (a *Agent) searchTools(q string, n int) []toolkit.Tool { } var hits []hit for _, t := range a.Reg.All() { - if toolkit.IsAgentOnly(t) { + if !a.deferrable(t) && !coreTools[t.Name()] { continue } name, desc := strings.ToLower(t.Name()), strings.ToLower(t.Desc()) diff --git a/internal/cli/agent_impl.go b/internal/cli/agent_impl.go index 89f2093..b5ff7fb 100644 --- a/internal/cli/agent_impl.go +++ b/internal/cli/agent_impl.go @@ -23,6 +23,7 @@ func runAgentImpl(cmd *cobra.Command, reg *toolkit.Registry, oneshot string) err if err != nil { return err } + defer a.Close() note, err := applyAgentFlags(cmd, a) if err != nil { return err diff --git a/internal/cli/chat.go b/internal/cli/chat.go index 1cbd335..2b2cb14 100644 --- a/internal/cli/chat.go +++ b/internal/cli/chat.go @@ -242,6 +242,34 @@ func runPrint(cmd *cobra.Command, c *toolkit.Context, reg *toolkit.Registry, pro } } } + // slash commands work headlessly too: `cometcli -p "/vote yes 7"` + if strings.HasPrefix(prompt, "/") { + cr, err := agent.RunCommand(a, c, reg, prompt) + if err != nil { + if errors.Is(err, agent.ErrUnknownCommand) { + return fmt.Errorf("unknown command %s", strings.Fields(prompt)[0]) + } + return err + } + if cr.Job != nil { + txt, err := cr.Job(cmd.Context()) + if err != nil { + return err + } + cr.Text = txt + } + if cr.Prompt == "" { + if cr.Text != "" { + fmt.Fprintln(out, cr.Text) + } + return nil + } + if verbose && cr.Text != "" { + fmt.Fprintln(cmd.ErrOrStderr(), cr.Text) + } + prompt = cr.Prompt + } + start := time.Now() res, runErr := a.Run(cmd.Context(), prompt) if format == "text" { diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 09c3207..104e18b 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -15,9 +15,13 @@ import ( // Observe/diagnose tools run freely; anything that would prompt (host // mutations, on-chain txs) is denied — stdio has no human to approve. func MCPCmd(reg *toolkit.Registry) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "mcp", - Short: "Serve cometcli tools over MCP (stdio) for external agents", + Short: "Serve cometcli tools over MCP (stdio); add/list/remove MCP servers the agent uses", + Long: `With no subcommand, serves cometcli's node tools over MCP on stdio for +external agents. The subcommands manage MCP servers that cometcli's own +agent connects to (their tools appear as mcp____).`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { c, err := NewCtx(cmd, true) if err != nil { @@ -32,4 +36,6 @@ func MCPCmd(reg *toolkit.Registry) *cobra.Command { }).Serve(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout()) }, } + cmd.AddCommand(mcpAddCmd(), mcpListCmd(), mcpRemoveCmd()) + return cmd } diff --git a/internal/cli/mcpservers.go b/internal/cli/mcpservers.go new file mode 100644 index 0000000..a87d017 --- /dev/null +++ b/internal/cli/mcpservers.go @@ -0,0 +1,256 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/abhijitkrm/cometcli/internal/mcpclient" + "github.com/abhijitkrm/cometcli/internal/settings" +) + +// mcpScopePath returns the file a scope's servers live in: user settings, +// the project's .mcp.json (shared with Claude Code), or local settings. +func mcpScopePath(scope string) (string, error) { + switch scope { + case "user", "": + return settings.UserPath() + case "project", "local": + cwd, _ := os.Getwd() + root := settings.FindRoot(cwd) + if root == "" { + root = cwd + } + if scope == "project" { + return filepath.Join(root, ".mcp.json"), nil + } + return filepath.Join(root, ".cometcli", "settings.local.json"), nil + } + return "", fmt.Errorf("--scope: want user, project or local") +} + +// editServers mutates the mcpServers map of a scope's file. +func editServers(scope string, fn func(map[string]settings.MCPServer) error) (string, error) { + p, err := mcpScopePath(scope) + if err != nil { + return "", err + } + if scope != "project" { + return p, settings.EditFile(p, func(f *settings.File) error { + if f.MCPServers == nil { + f.MCPServers = map[string]settings.MCPServer{} + } + return fn(f.MCPServers) + }) + } + // .mcp.json: {"mcpServers": {...}}, other keys preserved + doc := map[string]any{} + if raw, err := os.ReadFile(p); err == nil { + if err := json.Unmarshal(raw, &doc); err != nil { + return p, fmt.Errorf("%s: %w", p, err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return p, err + } + servers := map[string]settings.MCPServer{} + if raw, ok := doc["mcpServers"]; ok { + b, _ := json.Marshal(raw) + _ = json.Unmarshal(b, &servers) + } + if err := fn(servers); err != nil { + return p, err + } + doc["mcpServers"] = servers + out, _ := json.MarshalIndent(doc, "", " ") + return p, os.WriteFile(p, append(out, '\n'), 0o644) +} + +func mcpAddCmd() *cobra.Command { + var scope, transport string + var env, headers []string + cmd := &cobra.Command{ + Use: "add [--transport http ] | [-- [args…]]", + Short: "Add an MCP server for the agent", + Example: ` cometcli mcp add grafana -- npx -y @grafana/mcp-grafana + cometcli mcp add k8s -e KUBECONFIG=~/.kube/config -- kubectl-mcp-server + cometcli mcp add pagerduty --transport http https://mcp.example.com/mcp --header "Authorization: Bearer ${PD_TOKEN}" + cometcli mcp add tools --scope project -- ./scripts/mcp.sh # shared via .mcp.json`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + srv := settings.MCPServer{Type: transport} + switch transport { + case "http": + srv.URL = args[1] + case "stdio", "": + srv.Type = "" + srv.Command, srv.Args = args[1], args[2:] + default: + return fmt.Errorf("--transport: want stdio or http") + } + for _, kv := range env { + k, v, ok := strings.Cut(kv, "=") + if !ok { + return fmt.Errorf("-e %q: want KEY=value", kv) + } + if srv.Env == nil { + srv.Env = map[string]string{} + } + srv.Env[k] = v + } + for _, h := range headers { + k, v, ok := strings.Cut(h, ":") + if !ok { + return fmt.Errorf("--header %q: want 'Name: value'", h) + } + if srv.Headers == nil { + srv.Headers = map[string]string{} + } + srv.Headers[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + p, err := editServers(scope, func(m map[string]settings.MCPServer) error { + m[name] = srv + return nil + }) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "added MCP server %s to %s\n", name, p) + if scope == "project" || scope == "local" { + fmt.Fprintln(cmd.OutOrStdout(), "project servers run once the folder is trusted: cometcli trust") + } + return nil + }, + } + cmd.Flags().StringVarP(&scope, "scope", "s", "user", "user | project (.mcp.json, shared) | local") + cmd.Flags().StringVarP(&transport, "transport", "t", "stdio", "stdio | http") + cmd.Flags().StringArrayVarP(&env, "env", "e", nil, "KEY=value for a stdio server (${VAR} expands)") + cmd.Flags().StringArrayVarP(&headers, "header", "H", nil, "'Name: value' for an http server (${VAR} expands)") + return cmd +} + +func mcpRemoveCmd() *cobra.Command { + var scope string + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove an MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + p, err := editServers(scope, func(m map[string]settings.MCPServer) error { + if _, ok := m[args[0]]; !ok { + return fmt.Errorf("no MCP server %q in %s scope", args[0], scope) + } + delete(m, args[0]) + return nil + }) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "removed %s from %s\n", args[0], p) + return nil + }, + } + cmd.Flags().StringVarP(&scope, "scope", "s", "user", "user | project | local") + return cmd +} + +func mcpListCmd() *cobra.Command { + var check bool + cmd := &cobra.Command{ + Use: "list", + Short: "List MCP servers the agent will use here (--check connects to each)", + RunE: func(cmd *cobra.Command, _ []string) error { + cwd, _ := os.Getwd() + st, err := settings.Load(cwd) + if err != nil { + return err + } + names := make([]string, 0, len(st.MCPServers)) + for n := range st.MCPServers { + names = append(names, n) + } + sort.Strings(names) + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tSCOPE\tTARGET\tSTATUS") + for _, n := range names { + srv := st.MCPServers[n] + target := srv.URL + if target == "" { + target = strings.TrimSpace(srv.Command + " " + strings.Join(srv.Args, " ")) + } + status := "-" + if check { + ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second) + c, err := mcpclient.Connect(ctx, n, srv) + cancel() + if err != nil { + status = "✗ " + err.Error() + } else { + status = fmt.Sprintf("✓ %d tools", len(c.Tools)) + c.Close() + } + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", n, srv.Scope, target, status) + } + if len(names) == 0 { + fmt.Fprintln(tw, "(none)\t\t\t") + } + for _, ig := range st.Ignored { + fmt.Fprintf(tw, "skipped\t\t%s\tproject not trusted — cometcli trust\n", ig) + } + return tw.Flush() + }, + } + cmd.Flags().BoolVar(&check, "check", false, "connect to each server and count its tools") + return cmd +} + +// trustCmd marks a project directory as trusted so its hooks and MCP +// servers may run. +func trustCmd() *cobra.Command { + var off, list bool + cmd := &cobra.Command{ + Use: "trust [dir]", + Short: "Trust a project folder: allow its hooks and MCP servers to run", + Long: `Project settings (.cometcli/settings.json, settings.local.json, .mcp.json) +can define hooks and MCP servers — commands that run on this machine. They +are ignored until you trust the folder. Trust covers subdirectories.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if list { + for _, d := range settings.Trusted() { + fmt.Fprintln(cmd.OutOrStdout(), d) + } + return nil + } + dir, _ := os.Getwd() + if len(args) == 1 { + dir = args[0] + } else if root := settings.FindRoot(dir); root != "" { + dir = root + } + if err := settings.Trust(dir, !off); err != nil { + return err + } + abs, _ := filepath.Abs(dir) + verb := "trusted" + if off { + verb = "no longer trusted" + } + fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", verb, abs) + return nil + }, + } + cmd.Flags().BoolVar(&off, "off", false, "revoke trust") + cmd.Flags().BoolVar(&list, "list", false, "list trusted folders") + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 5bb98be..d33a252 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -75,7 +75,7 @@ status, cometcli doctor, cometcli tx unjail) and a tool the agent calls.`, for _, c := range toolGroupCommands(reg) { root.AddCommand(c) } - root.AddCommand(profileCmd(), auditCmd(), versionCmd(), initCmd(), sessionsCmd(), oneCmd(reg), configCmd()) + root.AddCommand(profileCmd(), auditCmd(), versionCmd(), initCmd(), sessionsCmd(), oneCmd(reg), configCmd(), trustCmd()) root.AddCommand(extra...) return root } diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go new file mode 100644 index 0000000..8022dbd --- /dev/null +++ b/internal/hooks/hooks.go @@ -0,0 +1,249 @@ +// Package hooks runs operator-defined commands at points in the agent +// loop, with Claude Code's contract: the event arrives as JSON on stdin; +// exit 0 passes (stdout may carry JSON decisions or extra context); exit +// 2 blocks, with stderr as the reason; any other exit is a non-blocking +// error shown to the operator. +package hooks + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/abhijitkrm/cometcli/internal/settings" +) + +// Hook events. +const ( + PreToolUse = "PreToolUse" + PostToolUse = "PostToolUse" + UserPromptSubmit = "UserPromptSubmit" + Stop = "Stop" + SessionStart = "SessionStart" +) + +// Events lists the supported events. +var Events = []string{PreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionStart} + +// Runner executes the hooks configured for a session. +type Runner struct { + Matchers map[string][]settings.HookMatcher + // Dir is where hooks run and what COMETCLI_PROJECT_DIR points at. + Dir string +} + +// Input is the JSON document a hook reads on stdin. +type Input struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path,omitempty"` + Cwd string `json:"cwd"` + Event string `json:"hook_event_name"` + PermissionMode string `json:"permission_mode,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolInput map[string]any `json:"tool_input,omitempty"` + ToolResponse map[string]any `json:"tool_response,omitempty"` + Prompt string `json:"prompt,omitempty"` + StopHookActive bool `json:"stop_hook_active,omitempty"` + Source string `json:"source,omitempty"` +} + +// Outcome is what the hooks for one event decided, combined. +type Outcome struct { + // Block stops the action (tool call, prompt) or, for Stop, makes the + // agent continue; Reason says why (fed to the model for tool events). + Block bool + Reason string + // Decision is a PreToolUse permission decision: allow | deny | ask. + Decision string + // Context is extra text for the model (UserPromptSubmit, SessionStart + // stdout; additionalContext). + Context string + // Messages are shown to the operator (non-blocking errors, systemMessage). + Messages []string +} + +// Has reports whether any hook is configured for the event. +func (r *Runner) Has(event string) bool { + return r != nil && len(r.Matchers[event]) > 0 +} + +// Run executes every hook for event whose matcher fits in.ToolName. +// Decisions combine conservatively: deny > ask > allow, any block blocks. +func (r *Runner) Run(ctx context.Context, in Input) Outcome { + var out Outcome + if !r.Has(in.Event) { + return out + } + in.Cwd = def(in.Cwd, r.Dir) + payload, _ := json.Marshal(in) + var reasons, contexts []string + for _, m := range r.Matchers[in.Event] { + if !matches(m.Matcher, in.ToolName) { + continue + } + for _, h := range m.Hooks { + if h.Type != "" && h.Type != "command" || strings.TrimSpace(h.Command) == "" { + continue + } + res := r.exec(ctx, h, payload) + switch { + case res.err != nil && res.code < 0: + out.Messages = append(out.Messages, fmt.Sprintf("%s hook %q failed: %v", in.Event, h.Command, res.err)) + case res.code == 2: + out.Block = true + reasons = append(reasons, firstNonEmpty(strings.TrimSpace(res.stderr), "blocked by "+in.Event+" hook")) + case res.code != 0: + msg := strings.TrimSpace(res.stderr) + out.Messages = append(out.Messages, fmt.Sprintf("%s hook %q exited %d: %s", in.Event, h.Command, res.code, msg)) + default: + r.parseStdout(in.Event, res.stdout, &out, &reasons, &contexts) + } + } + } + out.Reason = strings.Join(reasons, "\n") + out.Context = strings.Join(contexts, "\n") + return out +} + +func (r *Runner) parseStdout(event, stdout string, out *Outcome, reasons, contexts *[]string) { + s := strings.TrimSpace(stdout) + if s == "" { + return + } + if !strings.HasPrefix(s, "{") { + if event == UserPromptSubmit || event == SessionStart { + *contexts = append(*contexts, s) + } + return + } + var j struct { + Continue *bool `json:"continue"` + StopReason string `json:"stopReason"` + Decision string `json:"decision"` + Reason string `json:"reason"` + SystemMessage string `json:"systemMessage"` + Specific struct { + PermissionDecision string `json:"permissionDecision"` + PermissionDecisionReason string `json:"permissionDecisionReason"` + AdditionalContext string `json:"additionalContext"` + } `json:"hookSpecificOutput"` + } + if json.Unmarshal([]byte(s), &j) != nil { + if event == UserPromptSubmit || event == SessionStart { + *contexts = append(*contexts, s) + } + return + } + if j.SystemMessage != "" { + out.Messages = append(out.Messages, j.SystemMessage) + } + if j.Continue != nil && !*j.Continue { + out.Block = true + *reasons = append(*reasons, firstNonEmpty(j.StopReason, j.Reason, "stopped by hook")) + } + switch j.Decision { + case "block": + out.Block = true + *reasons = append(*reasons, firstNonEmpty(j.Reason, "blocked by hook")) + case "approve": + out.Decision = stricter(out.Decision, "allow") + } + if d := j.Specific.PermissionDecision; d == "allow" || d == "deny" || d == "ask" { + out.Decision = stricter(out.Decision, d) + if d == "deny" { + *reasons = append(*reasons, firstNonEmpty(j.Specific.PermissionDecisionReason, "denied by PreToolUse hook")) + } + } + if j.Specific.AdditionalContext != "" { + *contexts = append(*contexts, j.Specific.AdditionalContext) + } +} + +// stricter keeps the more restrictive permission decision. +func stricter(a, b string) string { + rank := map[string]int{"": 0, "allow": 1, "ask": 2, "deny": 3} + if rank[b] > rank[a] { + return b + } + return a +} + +type execResult struct { + code int + stdout, stderr string + err error +} + +const maxHookOutput = 64 << 10 + +func (r *Runner) exec(ctx context.Context, h settings.HookCommand, payload []byte) execResult { + to := time.Duration(h.Timeout) * time.Second + if to <= 0 { + to = 60 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, to) + defer cancel() + c := exec.CommandContext(ctx, "sh", "-c", h.Command) + c.Dir = r.Dir + c.Env = append(os.Environ(), "COMETCLI_PROJECT_DIR="+r.Dir, "CLAUDE_PROJECT_DIR="+r.Dir) + c.Stdin = bytes.NewReader(payload) + isolate(c) + c.WaitDelay = time.Second // don't wait on pipes a killed hook left open + var so, se bytes.Buffer + c.Stdout, c.Stderr = &so, &se + err := c.Run() + res := execResult{stdout: trim(so.String()), stderr: trim(se.String())} + var ee *exec.ExitError + switch { + case err == nil: + case ctx.Err() != nil: + res.code, res.err = -1, fmt.Errorf("timed out after %s", to) + case errors.As(err, &ee): + res.code = ee.ExitCode() + default: + res.code, res.err = -1, err + } + return res +} + +func matches(matcher, tool string) bool { + m := strings.TrimSpace(matcher) + if m == "" || m == "*" || tool == "" { + return true + } + re, err := regexp.Compile("(?i)^(?:" + m + ")$") + if err != nil { + return strings.EqualFold(m, tool) + } + return re.MatchString(tool) +} + +func trim(s string) string { + if len(s) > maxHookOutput { + return s[:maxHookOutput] + "…[truncated]" + } + return s +} + +func def(v, d string) string { + if v == "" { + return d + } + return v +} + +func firstNonEmpty(vs ...string) string { + for _, v := range vs { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go new file mode 100644 index 0000000..c5c6592 --- /dev/null +++ b/internal/hooks/hooks_test.go @@ -0,0 +1,76 @@ +package hooks + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/settings" +) + +func runner(t *testing.T, event, matcher, script string) *Runner { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "hook.sh") + os.WriteFile(p, []byte("#!/bin/sh\n"+script+"\n"), 0o755) + return &Runner{Dir: dir, Matchers: map[string][]settings.HookMatcher{ + event: {{Matcher: matcher, Hooks: []settings.HookCommand{{Type: "command", Command: p, Timeout: 5}}}}, + }} +} + +func TestExitCodes(t *testing.T) { + ctx := context.Background() + out := runner(t, PreToolUse, "bash", `echo "no rm here" >&2; exit 2`).Run(ctx, Input{Event: PreToolUse, ToolName: "bash"}) + if !out.Block || out.Reason != "no rm here" { + t.Fatalf("exit 2: %+v", out) + } + out = runner(t, PreToolUse, "bash", `exit 1`).Run(ctx, Input{Event: PreToolUse, ToolName: "bash"}) + if out.Block || len(out.Messages) != 1 { + t.Fatalf("exit 1 should be non-blocking: %+v", out) + } + // matcher doesn't fit → not run + out = runner(t, PreToolUse, "edit|write", `exit 2`).Run(ctx, Input{Event: PreToolUse, ToolName: "bash"}) + if out.Block { + t.Fatal("hook ran for a non-matching tool") + } + out = runner(t, PreToolUse, "Bash", `exit 2`).Run(ctx, Input{Event: PreToolUse, ToolName: "bash"}) + if !out.Block { + t.Fatal("matcher should be case-insensitive (Claude Code's Bash)") + } +} + +func TestStdinPayloadAndJSONDecisions(t *testing.T) { + ctx := context.Background() + r := runner(t, PreToolUse, "*", `in=$(cat); case "$in" in *'"command":"rm -rf x"'*) echo '{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"rm blocked"}}';; *) echo '{"hookSpecificOutput":{"permissionDecision":"allow"}}';; esac`) + out := r.Run(ctx, Input{Event: PreToolUse, ToolName: "bash", ToolInput: map[string]any{"command": "rm -rf x"}}) + if out.Decision != "deny" || !strings.Contains(out.Reason, "rm blocked") { + t.Fatalf("deny: %+v", out) + } + out = r.Run(ctx, Input{Event: PreToolUse, ToolName: "bash", ToolInput: map[string]any{"command": "ls"}}) + if out.Decision != "allow" || out.Block { + t.Fatalf("allow: %+v", out) + } + out = runner(t, UserPromptSubmit, "", `echo "on-call: alice"`).Run(ctx, Input{Event: UserPromptSubmit, Prompt: "hi"}) + if out.Context != "on-call: alice" { + t.Fatalf("context: %+v", out) + } + out = runner(t, Stop, "", `echo '{"decision":"block","reason":"run the tests first"}'`).Run(ctx, Input{Event: Stop}) + if !out.Block || out.Reason != "run the tests first" { + t.Fatalf("stop block: %+v", out) + } + out = runner(t, PostToolUse, "", `echo $COMETCLI_PROJECT_DIR > /dev/null; echo '{"systemMessage":"lint ok"}'`).Run(ctx, Input{Event: PostToolUse, ToolName: "edit"}) + if len(out.Messages) != 1 || out.Messages[0] != "lint ok" { + t.Fatalf("systemMessage: %+v", out) + } +} + +func TestTimeout(t *testing.T) { + r := runner(t, PreToolUse, "", `sleep 10`) + r.Matchers[PreToolUse][0].Hooks[0].Timeout = 1 + out := r.Run(context.Background(), Input{Event: PreToolUse, ToolName: "bash"}) + if out.Block || len(out.Messages) != 1 || !strings.Contains(out.Messages[0], "timed out") { + t.Fatalf("timeout: %+v", out) + } +} diff --git a/internal/hooks/proc_other.go b/internal/hooks/proc_other.go new file mode 100644 index 0000000..154e44a --- /dev/null +++ b/internal/hooks/proc_other.go @@ -0,0 +1,7 @@ +//go:build !unix + +package hooks + +import "os/exec" + +func isolate(*exec.Cmd) {} diff --git a/internal/hooks/proc_unix.go b/internal/hooks/proc_unix.go new file mode 100644 index 0000000..cdec21c --- /dev/null +++ b/internal/hooks/proc_unix.go @@ -0,0 +1,20 @@ +//go:build unix + +package hooks + +import ( + "os/exec" + "syscall" +) + +// isolate runs the hook in its own process group and makes cancel kill +// the whole group, so a hook's children can't outlive its timeout. +func isolate(c *exec.Cmd) { + c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + c.Cancel = func() error { + if c.Process == nil { + return nil + } + return syscall.Kill(-c.Process.Pid, syscall.SIGKILL) + } +} diff --git a/internal/mcpclient/client.go b/internal/mcpclient/client.go new file mode 100644 index 0000000..61968eb --- /dev/null +++ b/internal/mcpclient/client.go @@ -0,0 +1,478 @@ +// Package mcpclient connects to Model Context Protocol servers (stdio or +// streamable HTTP) so their tools can be offered to the agent. +package mcpclient + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/abhijitkrm/cometcli/internal/settings" +) + +const protocolVersion = "2025-06-18" + +// Tool is a tool a server offers. +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` + Annotations struct { + ReadOnlyHint bool `json:"readOnlyHint"` + DestructiveHint bool `json:"destructiveHint"` + } `json:"annotations"` +} + +// Content is one item of a tool result. +type Content struct { + Type string `json:"type"` + Text string `json:"text"` + MimeType string `json:"mimeType"` + Resource *struct { + URI string `json:"uri"` + Text string `json:"text"` + } `json:"resource"` +} + +// CallResult is a tools/call result. +type CallResult struct { + Content []Content `json:"content"` + IsError bool `json:"isError"` +} + +// Text flattens the result for the model. +func (r *CallResult) Text() string { + var parts []string + for _, c := range r.Content { + switch c.Type { + case "text": + parts = append(parts, c.Text) + case "resource": + if c.Resource != nil { + parts = append(parts, fmt.Sprintf("[resource %s]\n%s", c.Resource.URI, c.Resource.Text)) + } + default: + parts = append(parts, fmt.Sprintf("[%s content (%s) omitted]", c.Type, c.MimeType)) + } + } + return strings.Join(parts, "\n") +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type rpcMsg struct { + JSONRPC string `json:"jsonrpc"` + ID *json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params any `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type transport interface { + roundTrip(ctx context.Context, id int64, method string, params any) (json.RawMessage, error) + notify(ctx context.Context, method string, params any) error + close() error +} + +// Client is one connected server. +type Client struct { + Name string + Tools []Tool + tr transport + seq atomic.Int64 +} + +// Connect starts or dials a server, initializes it and lists its tools. +func Connect(ctx context.Context, name string, cfg settings.MCPServer) (*Client, error) { + var tr transport + var err error + switch strings.ToLower(cfg.Type) { + case "", "stdio": + tr, err = newStdio(cfg) + case "http", "streamable-http", "streamablehttp": + tr = &httpTransport{url: expand(cfg.URL), headers: expandMap(cfg.Headers), hc: &http.Client{Timeout: 5 * time.Minute}} + case "sse": + return nil, fmt.Errorf("mcp %s: legacy SSE transport isn't supported — use the server's streamable HTTP endpoint", name) + default: + return nil, fmt.Errorf("mcp %s: unknown transport %q", name, cfg.Type) + } + if err != nil { + return nil, fmt.Errorf("mcp %s: %w", name, err) + } + c := &Client{Name: name, tr: tr} + if err := c.init(ctx); err != nil { + _ = tr.close() + return nil, fmt.Errorf("mcp %s: %w", name, err) + } + return c, nil +} + +func (c *Client) call(ctx context.Context, method string, params any, out any) error { + raw, err := c.tr.roundTrip(ctx, c.seq.Add(1), method, params) + if err != nil { + return err + } + if out == nil { + return nil + } + return json.Unmarshal(raw, out) +} + +func (c *Client) init(ctx context.Context) error { + err := c.call(ctx, "initialize", map[string]any{ + "protocolVersion": protocolVersion, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "cometcli", "version": "1"}, + }, nil) + if err != nil { + return fmt.Errorf("initialize: %w", err) + } + if err := c.tr.notify(ctx, "notifications/initialized", nil); err != nil { + return err + } + cursor := "" + for page := 0; page < 20; page++ { + var res struct { + Tools []Tool `json:"tools"` + NextCursor string `json:"nextCursor"` + } + var params any + if cursor != "" { + params = map[string]any{"cursor": cursor} + } + if err := c.call(ctx, "tools/list", params, &res); err != nil { + return fmt.Errorf("tools/list: %w", err) + } + c.Tools = append(c.Tools, res.Tools...) + if cursor = res.NextCursor; cursor == "" { + break + } + } + return nil +} + +// CallTool invokes a tool. +func (c *Client) CallTool(ctx context.Context, name string, args map[string]any) (*CallResult, error) { + if args == nil { + args = map[string]any{} + } + var res CallResult + if err := c.call(ctx, "tools/call", map[string]any{"name": name, "arguments": args}, &res); err != nil { + return nil, err + } + return &res, nil +} + +// Close stops the server or ends the HTTP session. +func (c *Client) Close() error { return c.tr.close() } + +// --- stdio ------------------------------------------------------------------- + +type stdioTransport struct { + cmd *exec.Cmd + stdin io.WriteCloser + mu sync.Mutex // serializes writes + pmu sync.Mutex + wait map[string]chan rpcMsg + done chan struct{} + stderr *limitedBuffer + err error + waitOnce sync.Once +} + +// reap waits for the process exactly once (Wait isn't concurrency-safe). +func (t *stdioTransport) reap() { t.waitOnce.Do(func() { _ = t.cmd.Wait() }) } + +func newStdio(cfg settings.MCPServer) (*stdioTransport, error) { + if cfg.Command == "" { + return nil, fmt.Errorf("no command") + } + args := make([]string, len(cfg.Args)) + for i, a := range cfg.Args { + args[i] = expand(a) + } + cmd := exec.Command(expand(cfg.Command), args...) + cmd.Env = os.Environ() + for k, v := range cfg.Env { + cmd.Env = append(cmd.Env, k+"="+expand(v)) + } + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + t := &stdioTransport{cmd: cmd, stdin: stdin, wait: map[string]chan rpcMsg{}, done: make(chan struct{}), stderr: &limitedBuffer{b: &bytes.Buffer{}, max: 8 << 10}} + cmd.Stderr = t.stderr + if err := cmd.Start(); err != nil { + return nil, err + } + go t.read(stdout) + return t, nil +} + +func (t *stdioTransport) read(r io.Reader) { + defer close(t.done) + br := bufio.NewReaderSize(r, 1<<20) + for { + line, err := br.ReadBytes('\n') + if len(bytes.TrimSpace(line)) > 0 { + var m rpcMsg + if json.Unmarshal(line, &m) == nil { + t.dispatch(m) + } + } + if err != nil { + t.err = err + return + } + } +} + +func (t *stdioTransport) dispatch(m rpcMsg) { + if m.ID == nil { + return // notification from the server + } + if m.Method != "" { + // a request from the server (roots/list, sampling…): unsupported + _ = t.write(rpcMsg{JSONRPC: "2.0", ID: m.ID, Error: &rpcError{Code: -32601, Message: "not supported by cometcli"}}) + return + } + t.pmu.Lock() + ch := t.wait[string(*m.ID)] + delete(t.wait, string(*m.ID)) + t.pmu.Unlock() + if ch != nil { + ch <- m + } +} + +func (t *stdioTransport) write(m rpcMsg) error { + b, err := json.Marshal(m) + if err != nil { + return err + } + t.mu.Lock() + defer t.mu.Unlock() + _, err = t.stdin.Write(append(b, '\n')) + return err +} + +func (t *stdioTransport) roundTrip(ctx context.Context, id int64, method string, params any) (json.RawMessage, error) { + rid := json.RawMessage(fmt.Sprint(id)) + ch := make(chan rpcMsg, 1) + t.pmu.Lock() + t.wait[string(rid)] = ch + t.pmu.Unlock() + if err := t.write(rpcMsg{JSONRPC: "2.0", ID: &rid, Method: method, Params: params}); err != nil { + return nil, err + } + select { + case m := <-ch: + if m.Error != nil { + return nil, fmt.Errorf("%s (code %d)", m.Error.Message, m.Error.Code) + } + return m.Result, nil + case <-t.done: + t.reap() // stderr is fully copied once the process is reaped + msg := strings.TrimSpace(t.stderr.String()) + if msg == "" && t.err != nil { + msg = t.err.Error() + } + return nil, fmt.Errorf("server exited: %s", lastLines(msg, 3)) + case <-ctx.Done(): + t.pmu.Lock() + delete(t.wait, string(rid)) + t.pmu.Unlock() + return nil, ctx.Err() + } +} + +func (t *stdioTransport) notify(_ context.Context, method string, params any) error { + return t.write(rpcMsg{JSONRPC: "2.0", Method: method, Params: params}) +} + +func (t *stdioTransport) close() error { + t.stdin.Close() + select { + case <-t.done: + case <-time.After(2 * time.Second): + _ = t.cmd.Process.Kill() + } + t.reap() + return nil +} + +type limitedBuffer struct { + mu sync.Mutex + b *bytes.Buffer + max int +} + +func (l *limitedBuffer) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + if room := l.max - l.b.Len(); room > 0 { + l.b.Write(p[:min(room, len(p))]) + } + return len(p), nil +} + +// String returns what was captured so far. +func (l *limitedBuffer) String() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.b.String() +} + +func lastLines(s string, n int) string { + lines := strings.Split(s, "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, " | ") +} + +// --- streamable HTTP ----------------------------------------------------------- + +type httpTransport struct { + url string + headers map[string]string + hc *http.Client + session atomic.Value // string +} + +func (h *httpTransport) post(ctx context.Context, m rpcMsg) (*http.Response, error) { + b, _ := json.Marshal(m) + req, err := http.NewRequestWithContext(ctx, "POST", h.url, bytes.NewReader(b)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("MCP-Protocol-Version", protocolVersion) + if s, _ := h.session.Load().(string); s != "" { + req.Header.Set("Mcp-Session-Id", s) + } + for k, v := range h.headers { + req.Header.Set(k, v) + } + resp, err := h.hc.Do(req) + if err != nil { + return nil, err + } + if s := resp.Header.Get("Mcp-Session-Id"); s != "" { + h.session.Store(s) + } + if resp.StatusCode/100 != 2 { + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return resp, nil +} + +func (h *httpTransport) roundTrip(ctx context.Context, id int64, method string, params any) (json.RawMessage, error) { + rid := json.RawMessage(fmt.Sprint(id)) + resp, err := h.post(ctx, rpcMsg{JSONRPC: "2.0", ID: &rid, Method: method, Params: params}) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var m rpcMsg + if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") { + found := false + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 64<<10), 16<<20) + var data []string + for sc.Scan() && !found { + line := sc.Text() + switch { + case strings.HasPrefix(line, "data:"): + data = append(data, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + case line == "" && len(data) > 0: + var x rpcMsg + if json.Unmarshal([]byte(strings.Join(data, "\n")), &x) == nil && x.ID != nil && string(*x.ID) == string(rid) && x.Method == "" { + m, found = x, true + } + data = nil + } + } + if !found { + return nil, errors.New("stream ended without a response") + } + } else if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + return nil, err + } + if m.Error != nil { + return nil, fmt.Errorf("%s (code %d)", m.Error.Message, m.Error.Code) + } + return m.Result, nil +} + +func (h *httpTransport) notify(ctx context.Context, method string, params any) error { + resp, err := h.post(ctx, rpcMsg{JSONRPC: "2.0", Method: method, Params: params}) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +func (h *httpTransport) close() error { + s, _ := h.session.Load().(string) + if s == "" { + return nil + } + req, err := http.NewRequest("DELETE", h.url, nil) + if err != nil { + return nil + } + req.Header.Set("Mcp-Session-Id", s) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if resp, err := h.hc.Do(req.WithContext(ctx)); err == nil { + resp.Body.Close() + } + return nil +} + +// --- ${VAR} expansion ------------------------------------------------------------ + +// expand substitutes ${VAR} and ${VAR:-default} from the environment. +func expand(s string) string { + return os.Expand(s, func(k string) string { + if name, d, ok := strings.Cut(k, ":-"); ok { + if v := os.Getenv(name); v != "" { + return v + } + return d + } + return os.Getenv(k) + }) +} + +func expandMap(m map[string]string) map[string]string { + out := map[string]string{} + for k, v := range m { + out[k] = expand(v) + } + return out +} diff --git a/internal/mcpclient/client_test.go b/internal/mcpclient/client_test.go new file mode 100644 index 0000000..d8e9eb5 --- /dev/null +++ b/internal/mcpclient/client_test.go @@ -0,0 +1,160 @@ +package mcpclient + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/abhijitkrm/cometcli/internal/settings" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// TestMain doubles as a tiny stdio MCP server when MCP_FAKE_SERVER=1. +func TestMain(m *testing.M) { + if os.Getenv("MCP_FAKE_SERVER") == "1" { + fakeServer() + return + } + os.Exit(m.Run()) +} + +func respond(id json.RawMessage, method string, params map[string]any) any { + switch method { + case "initialize": + return map[string]any{"protocolVersion": protocolVersion, "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]any{"name": "fake"}} + case "tools/list": + if params != nil && params["cursor"] == "p2" { + return map[string]any{"tools": []any{map[string]any{"name": "delete_dash", "description": "deletes", "inputSchema": map[string]any{"type": "object"}}}} + } + return map[string]any{"nextCursor": "p2", "tools": []any{map[string]any{"name": "echo", "description": "echoes", "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"msg": map[string]any{"type": "string"}}}, "annotations": map[string]any{"readOnlyHint": true}}}} + case "tools/call": + args, _ := params["arguments"].(map[string]any) + return map[string]any{"content": []any{map[string]any{"type": "text", "text": fmt.Sprintf("echo: %v", args["msg"])}}} + } + return nil +} + +func fakeServer() { + sc := bufio.NewScanner(os.Stdin) + for sc.Scan() { + var m struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + if json.Unmarshal(sc.Bytes(), &m) != nil || len(m.ID) == 0 { + continue + } + // a server→client request first, to check the client answers it + if m.Method == "tools/call" { + fmt.Println(`{"jsonrpc":"2.0","id":"srv-1","method":"roots/list"}`) + fmt.Println(`{"jsonrpc":"2.0","method":"notifications/message","params":{}}`) + } + b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": respond(m.ID, m.Method, m.Params)}) + fmt.Println(string(b)) + } +} + +func TestStdioServer(t *testing.T) { + exe, _ := os.Executable() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + c, err := Connect(ctx, "fake", settings.MCPServer{Command: exe, Args: []string{"-test.run=^$"}, Env: map[string]string{"MCP_FAKE_SERVER": "1"}}) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if len(c.Tools) != 2 { + t.Fatalf("paginated tools = %+v", c.Tools) + } + res, err := c.CallTool(ctx, "echo", map[string]any{"msg": "hi"}) + if err != nil || res.Text() != "echo: hi" { + t.Fatalf("call = %+v %v", res, err) + } + + // adapter: names, tiers, schema, permission gate + echo, del := NewAgentTool(c, c.Tools[0]), NewAgentTool(c, c.Tools[1]) + if echo.Name() != "mcp__fake__echo" || echo.Tier() != toolkit.TierDiagnose || del.Tier() != toolkit.TierLocalChange { + t.Fatalf("adapter: %s %s %s", echo.Name(), echo.Tier(), del.Tier()) + } + if p, ok := del.Schema()["properties"].(map[string]any); !ok || p == nil { + t.Fatal("schema properties not normalized") + } + asked := 0 + tc := &toolkit.Context{Context: ctx, AutoApproveBelow: toolkit.TierLocalChange, + Approver: func(*toolkit.Context, string, toolkit.Tier, map[string]any) (bool, error) { asked++; return false, nil }} + if r, err := echo.Run(tc, toolkit.Args{"msg": "x"}); err != nil || r.Text != "echo: x" || asked != 0 { + t.Fatalf("read-only tool: %v %v asked=%d", r, err, asked) + } + if _, err := del.Run(tc, toolkit.Args{}); err == nil || asked != 1 { + t.Fatalf("mutating tool must ask: %v asked=%d", err, asked) + } + tc.Rules, _ = toolkit.NewRules([]string{"mcp__fake"}, nil, nil) + if _, err := del.Run(tc, toolkit.Args{}); err != nil || asked != 1 { + t.Fatalf("server-wide allow rule ignored: %v asked=%d", err, asked) + } +} + +func TestHTTPServerSSEAndSession(t *testing.T) { + var sawSession bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + return + } + var m struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + json.NewDecoder(r.Body).Decode(&m) + if r.Header.Get("Authorization") != "Bearer tok" { + w.WriteHeader(401) + return + } + if m.Method == "initialize" { + w.Header().Set("Mcp-Session-Id", "s-1") + } else if r.Header.Get("Mcp-Session-Id") == "s-1" { + sawSession = true + } + if len(m.ID) == 0 { + w.WriteHeader(202) + return + } + b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": respond(m.ID, m.Method, m.Params)}) + if m.Method == "tools/call" { // answer as an SSE stream + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\n\ndata: %s\n\n", b) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(b) + })) + defer srv.Close() + t.Setenv("TOK", "tok") + ctx := context.Background() + c, err := Connect(ctx, "web", settings.MCPServer{Type: "http", URL: srv.URL, Headers: map[string]string{"Authorization": "Bearer ${TOK}"}}) + if err != nil { + t.Fatal(err) + } + defer c.Close() + res, err := c.CallTool(ctx, "echo", map[string]any{"msg": "sse"}) + if err != nil || res.Text() != "echo: sse" || !sawSession { + t.Fatalf("call = %v %v session=%v", res, err, sawSession) + } +} + +func TestStdioServerThatExits(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := Connect(ctx, "bad", settings.MCPServer{Command: "sh", Args: []string{"-c", "echo 'boom: missing token' >&2; exit 1"}}) + if err == nil || !strings.Contains(err.Error(), "boom: missing token") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/mcpclient/tool.go b/internal/mcpclient/tool.go new file mode 100644 index 0000000..3344c84 --- /dev/null +++ b/internal/mcpclient/tool.go @@ -0,0 +1,103 @@ +package mcpclient + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// AgentTool adapts one MCP tool to the registry. Its name is +// mcp____; read-only tools (readOnlyHint) run without a +// prompt, everything else asks, and rules like "mcp__github" or +// "mcp__github__create_issue" apply. +type AgentTool struct { + Client *Client + Tool Tool + name string +} + +var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]`) + +// ToolName builds the agent-facing name for a server tool. +func ToolName(server, tool string) string { + n := "mcp__" + unsafeName.ReplaceAllString(server, "_") + "__" + unsafeName.ReplaceAllString(tool, "_") + if len(n) > 64 { + n = n[:64] + } + return n +} + +// NewAgentTool wraps a server tool. +func NewAgentTool(c *Client, t Tool) *AgentTool { + return &AgentTool{Client: c, Tool: t, name: ToolName(c.Name, t.Name)} +} + +func (t *AgentTool) Name() string { return t.name } +func (t *AgentTool) AgentOnly() bool { return true } +func (t *AgentTool) OutputLimit() int { return 30_000 } +func (t *AgentTool) Timeout(toolkit.Args) time.Duration { + return 5*time.Minute + 15*time.Minute // call + approval +} + +func (t *AgentTool) Desc() string { + d := strings.TrimSpace(t.Tool.Description) + if i := strings.Index(d, "\n\n"); i > 0 && len(d) > 600 { + d = d[:i] + } + if len(d) > 600 { + d = d[:600] + "…" + } + return fmt.Sprintf("(MCP server %s) %s", t.Client.Name, d) +} + +// Schema normalizes the server's input schema for strict validators. +func (t *AgentTool) Schema() map[string]any { + s := map[string]any{} + for k, v := range t.Tool.InputSchema { + s[k] = v + } + s["type"] = "object" + if p, ok := s["properties"].(map[string]any); !ok || p == nil { + s["properties"] = map[string]any{} + } + if r, ok := s["required"].([]any); ok && len(r) == 0 { + delete(s, "required") + } + if s["required"] == nil { + delete(s, "required") + } + return s +} + +func (t *AgentTool) Tier() toolkit.Tier { + if t.Tool.Annotations.ReadOnlyHint && !t.Tool.Annotations.DestructiveHint { + return toolkit.TierDiagnose + } + return toolkit.TierLocalChange +} + +func (t *AgentTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + if err := c.Check(toolkit.Gate{ + Request: toolkit.Request{Tool: t.name}, + Tier: t.Tier(), + Prompt: fmt.Sprintf("call %s on MCP server %s", t.Tool.Name, t.Client.Name), + Detail: map[string]any{"arguments": fmt.Sprint(map[string]any(a))}, + }); err != nil { + return nil, err + } + res, err := t.Client.CallTool(c, t.Tool.Name, a) + if err != nil { + return nil, err + } + text := res.Text() + if res.IsError { + return nil, fmt.Errorf("%s: %s", t.Tool.Name, text) + } + if text == "" { + text = "(no output)" + } + return &toolkit.Result{Text: text}, nil +} diff --git a/internal/settings/markdown.go b/internal/settings/markdown.go new file mode 100644 index 0000000..bc6489e --- /dev/null +++ b/internal/settings/markdown.go @@ -0,0 +1,417 @@ +package settings + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/abhijitkrm/cometcli/internal/config" +) + +// --- memory ---------------------------------------------------------------- + +// Memory is one loaded COMET.md-style file. +type Memory struct { + Path string + Scope string // user | project | local | node + Content string +} + +// maxMemory bounds all memory text injected into the system prompt. +const maxMemory = 40_000 + +// LoadMemory reads, in order: ~/.cometcli/COMET.md; COMET.md (or +// AGENTS.md when a directory has no COMET.md) and COMET.local.md in each +// directory from the project root down to cwd; and in node mode +// ~/.cometcli/memory/.md. "@path" lines import another file. +func LoadMemory(root, cwd, profile string) []Memory { + var out []Memory + seen := map[string]bool{} + add := func(p, scope string) { + if seen[p] { + return + } + seen[p] = true + if b, err := os.ReadFile(p); err == nil && len(strings.TrimSpace(string(b))) > 0 { + out = append(out, Memory{Path: p, Scope: scope, Content: expandImports(string(b), filepath.Dir(p), 0)}) + } + } + if d, err := config.Dir(); err == nil { + add(filepath.Join(d, "COMET.md"), "user") + } + for _, dir := range chain(root, cwd) { + if _, err := os.Stat(filepath.Join(dir, "COMET.md")); err == nil { + add(filepath.Join(dir, "COMET.md"), "project") + } else { + add(filepath.Join(dir, "AGENTS.md"), "project") + } + add(filepath.Join(dir, "COMET.local.md"), "local") + } + if profile != "" { + if p, err := NodeMemoryPath(profile); err == nil { + add(p, "node") + } + } + return out +} + +// NodeMemoryPath is the per-profile memory file. +func NodeMemoryPath(profile string) (string, error) { + return config.Path("memory", profile+".md") +} + +// chain lists root, then each directory down to cwd (just cwd without a +// root). +func chain(root, cwd string) []string { + cwd = filepath.Clean(cwd) + if root == "" { + return []string{cwd} + } + rel, err := filepath.Rel(root, cwd) + if err != nil || strings.HasPrefix(rel, "..") { + return []string{cwd} + } + dirs := []string{root} + if rel == "." { + return dirs + } + d := root + for _, part := range strings.Split(rel, string(filepath.Separator)) { + d = filepath.Join(d, part) + dirs = append(dirs, d) + } + return dirs +} + +var importRe = regexp.MustCompile(`(?m)^@(\S+)\s*$`) + +// keyFileRe matches files whose content must never enter a prompt. +var keyFileRe = regexp.MustCompile(`priv_validator_key\.json|node_key\.json|mnemonic|keyring-|/\.ssh/|\.cometcli/keys`) + +func expandImports(s, base string, depth int) string { + if depth >= 3 { + return s + } + return importRe.ReplaceAllStringFunc(s, func(line string) string { + p := strings.TrimSpace(line[1:]) + if strings.HasPrefix(p, "~/") { + home, _ := os.UserHomeDir() + p = filepath.Join(home, p[2:]) + } else if !filepath.IsAbs(p) { + p = filepath.Join(base, p) + } + if keyFileRe.MatchString(strings.ToLower(p)) { + return "[import of key material refused: " + line[1:] + "]" + } + b, err := os.ReadFile(p) + if err != nil { + return line + } + return expandImports(string(b), filepath.Dir(p), depth+1) + }) +} + +// RenderMemory formats memory for the system prompt. +func RenderMemory(ms []Memory) string { + if len(ms) == 0 { + return "" + } + var b strings.Builder + b.WriteString("MEMORY — standing instructions from the operator's COMET.md files; follow them:\n") + for _, m := range ms { + fmt.Fprintf(&b, "### %s (%s)\n%s\n", m.Path, m.Scope, strings.TrimSpace(m.Content)) + } + s := b.String() + if len(s) > maxMemory { + s = s[:maxMemory] + "\n…[memory truncated — trim your COMET.md files]\n" + } + return s + "\n" +} + +// AppendMemory adds a bullet to a memory file, creating it if needed. +func AppendMemory(path, text string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + if fi, _ := f.Stat(); fi != nil && fi.Size() > 0 { + if b, _ := os.ReadFile(path); len(b) > 0 && b[len(b)-1] != '\n' { + if _, err := f.WriteString("\n"); err != nil { + return err + } + } + } + _, err = f.WriteString("- " + strings.TrimSpace(text) + "\n") + return err +} + +// --- frontmatter ------------------------------------------------------------- + +// splitFrontmatter separates a leading "---" YAML block from the body. +func splitFrontmatter(s string) (map[string]any, string) { + if !strings.HasPrefix(s, "---\n") && !strings.HasPrefix(s, "---\r\n") { + return map[string]any{}, s + } + rest := s[strings.Index(s, "\n")+1:] + end := strings.Index(rest, "\n---") + if end < 0 { + return map[string]any{}, s + } + meta := map[string]any{} + _ = yaml.Unmarshal([]byte(rest[:end]), &meta) + body := rest[end+4:] + body = strings.TrimPrefix(strings.TrimPrefix(body, "\r"), "\n") + return meta, body +} + +func metaStr(m map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + return strings.TrimSpace(fmt.Sprint(v)) + } + } + return "" +} + +func metaList(m map[string]any, keys ...string) []string { + for _, k := range keys { + switch v := m[k].(type) { + case string: + var out []string + for _, p := range splitRules(v) { + if p != "" { + out = append(out, p) + } + } + return out + case []any: + var out []string + for _, x := range v { + out = append(out, strings.TrimSpace(fmt.Sprint(x))) + } + return out + } + } + return nil +} + +// splitRules splits "Bash(git status:*), Read" on commas outside parens. +func splitRules(s string) []string { + var out []string + depth, start := 0, 0 + for i, c := range s { + switch c { + case '(': + depth++ + case ')': + depth-- + case ',': + if depth == 0 { + out = append(out, strings.TrimSpace(s[start:i])) + start = i + 1 + } + } + } + return append(out, strings.TrimSpace(s[start:])) +} + +// mdFiles collects *.md under dir, keyed by name: the relative path +// without extension, subdirectories joined with ":". +func mdFiles(dir string) map[string]string { + out := map[string]string{} + _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(p, ".md") { + return nil + } + rel, _ := filepath.Rel(dir, strings.TrimSuffix(p, ".md")) + out[strings.ReplaceAll(rel, string(filepath.Separator), ":")] = p + return nil + }) + return out +} + +// --- custom slash commands --------------------------------------------------- + +// Command is a markdown-defined slash command. +type Command struct { + Name string + Description string + ArgHint string + AllowedTools []string // permission rules allowed while it runs + Body string + Path string + Scope string // user | project +} + +// LoadCommands reads ~/.cometcli/commands and /.cometcli/commands; +// a project command shadows a user one of the same name. +func LoadCommands(root string) map[string]*Command { + out := map[string]*Command{} + load := func(dir, scope string) { + for name, p := range mdFiles(dir) { + raw, err := os.ReadFile(p) + if err != nil { + continue + } + meta, body := splitFrontmatter(string(raw)) + c := &Command{ + Name: name, Path: p, Scope: scope, Body: body, + Description: metaStr(meta, "description"), + ArgHint: metaStr(meta, "argument-hint", "argument_hint"), + AllowedTools: metaList(meta, "allowed-tools", "allowed_tools"), + } + if c.Description == "" { + c.Description = firstLine(body) + } + out[name] = c + } + } + if d, err := config.Dir(); err == nil { + load(filepath.Join(d, "commands"), "user") + } + if root != "" { + load(filepath.Join(root, ".cometcli", "commands"), "project") + } + return out +} + +// SortedCommands returns commands by name. +func SortedCommands(m map[string]*Command) []*Command { + out := make([]*Command, 0, len(m)) + for _, c := range m { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +var ( + bangRe = regexp.MustCompile("!`([^`]+)`") + atRe = regexp.MustCompile(`(^|\s)@([\w./~-][\w./~:-]*)`) + posRe = regexp.MustCompile(`\$([1-9])`) +) + +// Expand fills the command template: $ARGUMENTS and $1..$9, then +// !`cmd` (replaced by run's output) and @path (replaced by read's +// content; unreadable paths are left as written). +func (c *Command) Expand(args string, run func(cmd string) string, read func(path string) (string, bool)) string { + pos := shellSplit(args) + s := strings.ReplaceAll(c.Body, "$ARGUMENTS", args) + s = posRe.ReplaceAllStringFunc(s, func(m string) string { + i := int(m[1] - '1') + if i < len(pos) { + return pos[i] + } + return "" + }) + if run != nil { + s = bangRe.ReplaceAllStringFunc(s, func(m string) string { + return run(bangRe.FindStringSubmatch(m)[1]) + }) + } + if read != nil { + s = atRe.ReplaceAllStringFunc(s, func(m string) string { + sub := atRe.FindStringSubmatch(m) + if body, ok := read(sub[2]); ok { + return sub[1] + "\n\n" + body + "\n\n" + } + return m + }) + } + return strings.TrimSpace(s) +} + +// shellSplit splits on spaces, honoring single and double quotes. +func shellSplit(s string) []string { + var out []string + var cur strings.Builder + var q rune + in := false + for _, c := range s { + switch { + case q != 0: + if c == q { + q = 0 + } else { + cur.WriteRune(c) + } + case c == '\'' || c == '"': + q, in = c, true + case c == ' ' || c == '\t': + if in { + out = append(out, cur.String()) + cur.Reset() + in = false + } + default: + cur.WriteRune(c) + in = true + } + } + if in { + out = append(out, cur.String()) + } + return out +} + +func firstLine(s string) string { + for _, l := range strings.Split(s, "\n") { + if l = strings.TrimSpace(strings.TrimLeft(l, "# ")); l != "" { + if len(l) > 80 { + l = l[:80] + "…" + } + return l + } + } + return "" +} + +// --- subagents --------------------------------------------------------------- + +// AgentDef is a markdown-defined subagent. +type AgentDef struct { + Name string + Description string + Tools []string // tool names it may use (empty = the general tools) + Prompt string // its system prompt + Path string +} + +// LoadAgents reads ~/.cometcli/agents and /.cometcli/agents. +func LoadAgents(root string) map[string]*AgentDef { + out := map[string]*AgentDef{} + load := func(dir string) { + for name, p := range mdFiles(dir) { + raw, err := os.ReadFile(p) + if err != nil { + continue + } + meta, body := splitFrontmatter(string(raw)) + if n := metaStr(meta, "name"); n != "" { + name = n + } + out[name] = &AgentDef{ + Name: name, Path: p, Prompt: strings.TrimSpace(body), + Description: metaStr(meta, "description"), + Tools: metaList(meta, "tools"), + } + } + } + if d, err := config.Dir(); err == nil { + load(filepath.Join(d, "agents")) + } + if root != "" { + load(filepath.Join(root, ".cometcli", "agents")) + } + return out +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go new file mode 100644 index 0000000..7c72431 --- /dev/null +++ b/internal/settings/settings.go @@ -0,0 +1,328 @@ +// Package settings discovers and merges cometcli's file-based +// extensibility: settings.json at user, project and local scope, COMET.md +// memory files, custom slash commands, subagent definitions and MCP +// server configs. Formats follow Claude Code's, so its configs carry over. +// +// Project-scope hooks and MCP servers execute commands, so they only take +// effect once the project directory is trusted (`cometcli trust`). +package settings + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/abhijitkrm/cometcli/internal/config" +) + +// Scope says which file a setting came from. +type Scope string + +const ( + ScopeUser Scope = "user" // ~/.cometcli/settings.json + ScopeProject Scope = "project" // /.cometcli/settings.json + ScopeLocal Scope = "local" // /.cometcli/settings.local.json +) + +// Permissions mirrors Claude Code's permissions block. +type Permissions struct { + Allow []string `json:"allow,omitempty"` + Ask []string `json:"ask,omitempty"` + Deny []string `json:"deny,omitempty"` + DefaultMode string `json:"defaultMode,omitempty"` +} + +// HookCommand is one command run for a hook event. +type HookCommand struct { + Type string `json:"type"` // "command" + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` // seconds, default 60 +} + +// HookMatcher binds commands to tools whose name matches Matcher (a +// regular expression, case-insensitive; "" or "*" matches all). +type HookMatcher struct { + Matcher string `json:"matcher,omitempty"` + Hooks []HookCommand `json:"hooks"` + // Scope is where the matcher was defined (set on load). + Scope Scope `json:"-"` +} + +// MCPServer is an MCP server definition (stdio or HTTP). +type MCPServer struct { + Type string `json:"type,omitempty"` // stdio (default) | http + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + // Scope is where the server was defined (set on load). + Scope Scope `json:"-"` +} + +// File is one settings.json document. +type File struct { + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` + Permissions Permissions `json:"permissions,omitempty"` + Env map[string]string `json:"env,omitempty"` + Hooks map[string][]HookMatcher `json:"hooks,omitempty"` + MCPServers map[string]MCPServer `json:"mcpServers,omitempty"` +} + +// Settings is the merged view for one session. +type Settings struct { + Root string // project root ("" when none was found) + Trusted bool // project hooks/MCP servers may run + Model string + Effort string + Permissions Permissions + Env map[string]string + Hooks map[string][]HookMatcher + MCPServers map[string]MCPServer + // Sources lists the files that were read, in precedence order. + Sources []string + // Ignored explains project hooks/servers skipped for lack of trust. + Ignored []string +} + +// FindRoot walks up from dir to the nearest directory holding a +// .cometcli/ folder, a COMET.md or a .git, stopping at the home directory. +func FindRoot(dir string) string { + home, _ := os.UserHomeDir() + d := filepath.Clean(dir) + for { + if d != home { + for _, marker := range []string{".cometcli", "COMET.md", ".git"} { + if _, err := os.Stat(filepath.Join(d, marker)); err == nil { + return d + } + } + } + parent := filepath.Dir(d) + if parent == d || d == home { + return "" + } + d = parent + } +} + +// Load merges user, project and local settings for a session started in +// cwd. Later scopes override scalar keys; permission rules, env, hooks +// and MCP servers accumulate (a later server definition of the same name +// wins). +func Load(cwd string) (*Settings, error) { + s := &Settings{Root: FindRoot(cwd), Env: map[string]string{}, Hooks: map[string][]HookMatcher{}, MCPServers: map[string]MCPServer{}} + s.Trusted = s.Root != "" && IsTrusted(s.Root) + userDir, err := config.Dir() + if err != nil { + return nil, err + } + type src struct { + path string + scope Scope + } + srcs := []src{{filepath.Join(userDir, "settings.json"), ScopeUser}} + if s.Root != "" { + srcs = append(srcs, + src{filepath.Join(s.Root, ".cometcli", "settings.json"), ScopeProject}, + src{filepath.Join(s.Root, ".cometcli", "settings.local.json"), ScopeLocal}) + } + for _, sc := range srcs { + f, err := readFile(sc.path) + if err != nil { + return nil, err + } + if f == nil { + continue + } + s.Sources = append(s.Sources, sc.path) + s.apply(f, sc.scope) + } + // a project's .mcp.json (Claude Code's file) adds project servers + if s.Root != "" { + var mj struct { + MCPServers map[string]MCPServer `json:"mcpServers"` + } + p := filepath.Join(s.Root, ".mcp.json") + if raw, err := os.ReadFile(p); err == nil { + if err := json.Unmarshal(raw, &mj); err != nil { + return nil, fmt.Errorf("%s: %w", p, err) + } + s.Sources = append(s.Sources, p) + for n, srv := range mj.MCPServers { + s.addServer(n, srv, ScopeProject) + } + } + } + return s, nil +} + +func readFile(p string) (*File, error) { + raw, err := os.ReadFile(p) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var f File + if err := json.Unmarshal(raw, &f); err != nil { + return nil, fmt.Errorf("%s: %w", p, err) + } + return &f, nil +} + +func (s *Settings) apply(f *File, scope Scope) { + if f.Model != "" { + s.Model = f.Model + } + if f.Effort != "" { + s.Effort = f.Effort + } + if f.Permissions.DefaultMode != "" { + s.Permissions.DefaultMode = f.Permissions.DefaultMode + } + s.Permissions.Allow = append(s.Permissions.Allow, f.Permissions.Allow...) + s.Permissions.Ask = append(s.Permissions.Ask, f.Permissions.Ask...) + s.Permissions.Deny = append(s.Permissions.Deny, f.Permissions.Deny...) + for k, v := range f.Env { + s.Env[k] = v + } + for ev, ms := range f.Hooks { + for _, m := range ms { + if scope != ScopeUser && !s.Trusted { + s.Ignored = append(s.Ignored, fmt.Sprintf("%s hook (%s settings)", ev, scope)) + continue + } + m.Scope = scope + s.Hooks[ev] = append(s.Hooks[ev], m) + } + } + for n, srv := range f.MCPServers { + s.addServer(n, srv, scope) + } +} + +func (s *Settings) addServer(name string, srv MCPServer, scope Scope) { + if scope != ScopeUser && !s.Trusted { + s.Ignored = append(s.Ignored, fmt.Sprintf("MCP server %q (%s)", name, scope)) + return + } + srv.Scope = scope + s.MCPServers[name] = srv +} + +// ApplyEnv exports the settings' env vars into this process (before +// providers read their API keys). Existing variables are not overridden. +func (s *Settings) ApplyEnv() { + for k, v := range s.Env { + if _, set := os.LookupEnv(k); !set { + _ = os.Setenv(k, v) + } + } +} + +// --- trust ----------------------------------------------------------------- + +func trustFile() (string, error) { return config.Path("trusted.json") } + +func trustedList() []string { + p, err := trustFile() + if err != nil { + return nil + } + raw, err := os.ReadFile(p) + if err != nil { + return nil + } + var dirs []string + _ = json.Unmarshal(raw, &dirs) + return dirs +} + +// IsTrusted reports whether dir (or a parent of it) was trusted. +func IsTrusted(dir string) bool { + dir = filepath.Clean(dir) + for _, t := range trustedList() { + if dir == t || strings.HasPrefix(dir, t+string(filepath.Separator)) { + return true + } + } + return false +} + +// Trust marks dir as trusted (or untrusted when on is false). +func Trust(dir string, on bool) error { + dir, err := filepath.Abs(dir) + if err != nil { + return err + } + var out []string + for _, t := range trustedList() { + if t != dir { + out = append(out, t) + } + } + if on { + out = append(out, dir) + } + sort.Strings(out) + raw, _ := json.MarshalIndent(out, "", " ") + p, err := trustFile() + if err != nil { + return err + } + return os.WriteFile(p, raw, 0o600) +} + +// Trusted lists trusted directories. +func Trusted() []string { return trustedList() } + +// --- user settings editing (cometcli mcp add/remove) ----------------------- + +// EditFile loads, mutates and saves one settings file. +func EditFile(path string, fn func(*File) error) error { + f, err := readFile(path) + if err != nil { + return err + } + if f == nil { + f = &File{} + } + // keep keys we don't model by round-tripping through a generic map + raw, _ := os.ReadFile(path) + var generic map[string]any + _ = json.Unmarshal(raw, &generic) + if err := fn(f); err != nil { + return err + } + b, _ := json.Marshal(f) + var mine map[string]any + _ = json.Unmarshal(b, &mine) + if generic == nil { + generic = map[string]any{} + } + for _, k := range []string{"model", "effort", "permissions", "env", "hooks", "mcpServers"} { + if v, ok := mine[k]; ok { + generic[k] = v + } else { + delete(generic, k) + } + } + out, err := json.MarshalIndent(generic, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return os.WriteFile(path, append(out, '\n'), 0o600) +} + +// UserPath is the user settings file. +func UserPath() (string, error) { return config.Path("settings.json") } diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go new file mode 100644 index 0000000..157a44d --- /dev/null +++ b/internal/settings/settings_test.go @@ -0,0 +1,160 @@ +package settings + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func setup(t *testing.T) (home, proj string) { + t.Helper() + home = t.TempDir() + t.Setenv("HOME", home) + t.Setenv("COMETCLI_HOME", filepath.Join(home, ".cometcli")) + proj = filepath.Join(home, "work", "node-setup") + os.MkdirAll(filepath.Join(proj, ".cometcli", "commands", "ops"), 0o755) + os.MkdirAll(filepath.Join(proj, "run-validator"), 0o755) + os.MkdirAll(filepath.Join(home, ".cometcli"), 0o700) + return home, proj +} + +func write(t *testing.T, p, s string) { + t.Helper() + os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(s), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestLoadMergesScopesAndGatesTrust(t *testing.T) { + home, proj := setup(t) + write(t, filepath.Join(home, ".cometcli", "settings.json"), `{"model":"m-user","permissions":{"allow":["bash(git status:*)"]}, + "env":{"FOO":"user","KEEP":"x"},"hooks":{"PreToolUse":[{"matcher":"bash","hooks":[{"type":"command","command":"true"}]}]}, + "mcpServers":{"u":{"command":"u-srv"}}}`) + write(t, filepath.Join(proj, ".cometcli", "settings.json"), `{"effort":"low","permissions":{"deny":["bash(rm:*)"],"defaultMode":"acceptEdits"}, + "hooks":{"Stop":[{"hooks":[{"type":"command","command":"evil"}]}]},"mcpServers":{"p":{"command":"p-srv"}}}`) + write(t, filepath.Join(proj, ".cometcli", "settings.local.json"), `{"model":"m-local","env":{"FOO":"local"}}`) + write(t, filepath.Join(proj, ".mcp.json"), `{"mcpServers":{"shared":{"type":"http","url":"https://x/mcp"}}}`) + + s, err := Load(filepath.Join(proj, "run-validator")) + if err != nil { + t.Fatal(err) + } + if s.Root != proj || s.Model != "m-local" || s.Effort != "low" || s.Permissions.DefaultMode != "acceptEdits" { + t.Fatalf("merge: root=%s model=%s effort=%s mode=%s", s.Root, s.Model, s.Effort, s.Permissions.DefaultMode) + } + if len(s.Permissions.Allow) != 1 || len(s.Permissions.Deny) != 1 || s.Env["FOO"] != "local" { + t.Fatalf("accumulate: %+v %v", s.Permissions, s.Env) + } + // untrusted: project hooks and servers skipped, user ones kept + if len(s.Hooks["Stop"]) != 0 || len(s.Hooks["PreToolUse"]) != 1 { + t.Fatalf("hooks = %+v", s.Hooks) + } + if _, ok := s.MCPServers["p"]; ok { + t.Fatal("untrusted project server loaded") + } + if _, ok := s.MCPServers["u"]; !ok || len(s.Ignored) != 3 { + t.Fatalf("servers=%v ignored=%v", s.MCPServers, s.Ignored) + } + + if err := Trust(proj, true); err != nil { + t.Fatal(err) + } + s, _ = Load(filepath.Join(proj, "run-validator")) + if !s.Trusted || len(s.Hooks["Stop"]) != 1 || s.MCPServers["shared"].URL != "https://x/mcp" || s.MCPServers["p"].Scope != ScopeProject { + t.Fatalf("trusted load: %+v", s) + } + Trust(proj, false) + if IsTrusted(proj) { + t.Fatal("trust not revoked") + } + + os.Setenv("KEEP", "already") + s.ApplyEnv() + if os.Getenv("KEEP") != "already" || os.Getenv("FOO") != "local" { + t.Fatal("ApplyEnv overrode an existing var or missed one") + } + os.Unsetenv("FOO") +} + +func TestEditFileKeepsUnknownKeys(t *testing.T) { + home, _ := setup(t) + p := filepath.Join(home, ".cometcli", "settings.json") + write(t, p, `{"statusLine":{"type":"command"},"model":"x"}`) + if err := EditFile(p, func(f *File) error { + f.MCPServers = map[string]MCPServer{"g": {Command: "grafana"}} + return nil + }); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(p) + if !strings.Contains(string(b), "statusLine") || !strings.Contains(string(b), `"grafana"`) || !strings.Contains(string(b), `"model": "x"`) { + t.Fatalf("file = %s", b) + } +} + +func TestMemoryOrderAndImports(t *testing.T) { + home, proj := setup(t) + write(t, filepath.Join(home, ".cometcli", "COMET.md"), "user rule") + write(t, filepath.Join(proj, "COMET.md"), "project rule\n@docs/ports.md\n@config/priv_validator_key.json") + write(t, filepath.Join(proj, "docs", "ports.md"), "p2p is 26656") + write(t, filepath.Join(proj, "run-validator", "AGENTS.md"), "validator dir rule") + write(t, filepath.Join(home, ".cometcli", "memory", "val01.md"), "val01 runs in docker") + ms := LoadMemory(proj, filepath.Join(proj, "run-validator"), "val01") + var scopes []string + for _, m := range ms { + scopes = append(scopes, m.Scope) + } + if strings.Join(scopes, ",") != "user,project,project,node" { + t.Fatalf("scopes = %v", scopes) + } + r := RenderMemory(ms) + for _, want := range []string{"user rule", "p2p is 26656", "validator dir rule", "val01 runs in docker", "import of key material refused"} { + if !strings.Contains(r, want) { + t.Errorf("memory missing %q", want) + } + } + p := filepath.Join(proj, "COMET.md") + AppendMemory(p, "always check peers first") + b, _ := os.ReadFile(p) + if !strings.HasSuffix(string(b), "\n- always check peers first\n") { + t.Fatalf("append = %q", b) + } +} + +func TestCommandsLoadAndExpand(t *testing.T) { + home, proj := setup(t) + write(t, filepath.Join(home, ".cometcli", "commands", "status.md"), "user version") + write(t, filepath.Join(proj, ".cometcli", "commands", "status.md"), "---\ndescription: Node status\nargument-hint: [profile]\nallowed-tools: Bash(docker ps:*), Read\n---\nCheck $ARGUMENTS ($1 then $2).\nContainers: !`docker ps`\nSee @notes.md") + write(t, filepath.Join(proj, ".cometcli", "commands", "ops", "vote.md"), "# Vote on upgrade $1") + cs := LoadCommands(proj) + st := cs["status"] + if st == nil || st.Scope != "project" || st.Description != "Node status" || st.ArgHint != "[profile]" { + t.Fatalf("status = %+v", st) + } + if len(st.AllowedTools) != 2 || st.AllowedTools[0] != "Bash(docker ps:*)" { + t.Fatalf("allowed-tools = %q", st.AllowedTools) + } + if cs["ops:vote"] == nil || cs["ops:vote"].Description != "Vote on upgrade $1" { + t.Fatalf("namespaced = %+v", cs["ops:vote"]) + } + out := st.Expand(`val01 "two words"`, + func(c string) string { return "[ran " + c + "]" }, + func(p string) (string, bool) { return "NOTES", p == "notes.md" }) + for _, want := range []string{`Check val01 "two words" (val01 then two words).`, "[ran docker ps]", ``, "NOTES"} { + if !strings.Contains(out, want) { + t.Errorf("expansion missing %q in %q", want, out) + } + } +} + +func TestAgentsLoad(t *testing.T) { + _, proj := setup(t) + write(t, filepath.Join(proj, ".cometcli", "agents", "log-digger.md"), "---\nname: logs\ndescription: trawls logs\ntools: bash, grep, read\n---\nYou read logs and report anomalies.") + as := LoadAgents(proj) + d := as["logs"] + if d == nil || d.Description != "trawls logs" || len(d.Tools) != 3 || !strings.Contains(d.Prompt, "anomalies") { + t.Fatalf("agent = %+v", d) + } +} diff --git a/internal/toolkit/context.go b/internal/toolkit/context.go index 9046025..6667850 100644 --- a/internal/toolkit/context.go +++ b/internal/toolkit/context.go @@ -38,6 +38,9 @@ type Context struct { AcceptEdits bool // WorkRoot anchors relative rule patterns and accept-edits scope. WorkRoot string + // HookDecision is a PreToolUse hook's verdict for this call: + // "allow" skips the prompt, "ask" forces one ("" = no opinion). + HookDecision string mu sync.Mutex comet *comet.Client @@ -164,7 +167,7 @@ func WithDeadline(c *Context, timeout time.Duration) (*Context, context.CancelFu Context: ctx, Profile: c.Profile, Cfg: c.Cfg, Out: c.Out, Audit: c.Audit, Approver: c.Approver, AutoApproveBelow: c.AutoApproveBelow, Session: c.Session, Rules: c.Rules, ReadOnly: c.ReadOnly, - AcceptEdits: c.AcceptEdits, WorkRoot: c.WorkRoot, + AcceptEdits: c.AcceptEdits, WorkRoot: c.WorkRoot, HookDecision: c.HookDecision, }, cancel } @@ -176,7 +179,7 @@ func WithCancel(c *Context) (*Context, context.CancelFunc) { Context: ctx, Profile: c.Profile, Cfg: c.Cfg, Out: c.Out, Audit: c.Audit, Approver: c.Approver, AutoApproveBelow: c.AutoApproveBelow, Session: c.Session, Rules: c.Rules, ReadOnly: c.ReadOnly, - AcceptEdits: c.AcceptEdits, WorkRoot: c.WorkRoot, + AcceptEdits: c.AcceptEdits, WorkRoot: c.WorkRoot, HookDecision: c.HookDecision, }, cancel } @@ -234,9 +237,9 @@ func (c *Context) Check(g Gate) error { switch { case d == DecideDeny: return fmt.Errorf("denied by permission rule %s", rule) - case d == DecideAsk || g.Tier == TierOnChain: + case d == DecideAsk || g.Tier == TierOnChain || c.HookDecision == "ask": return RequireApproval(c, g.Prompt, g.Tier, g.Detail) - case d == DecideAllow: + case d == DecideAllow || c.HookDecision == "allow": return nil case c.AcceptEdits && g.InRoot && g.Tier == TierLocalChange: return nil diff --git a/internal/toolkit/perm.go b/internal/toolkit/perm.go index b37eaa7..5af5e98 100644 --- a/internal/toolkit/perm.go +++ b/internal/toolkit/perm.go @@ -77,8 +77,12 @@ func ParseRule(s string) (Rule, error) { func (r Rule) toolMatches(tool string) bool { tool = strings.ToLower(tool) - if strings.HasSuffix(r.Tool, ".*") { + switch { + case strings.HasSuffix(r.Tool, "*"): return strings.HasPrefix(tool, strings.TrimSuffix(r.Tool, "*")) + case strings.HasPrefix(r.Tool, "mcp__") && strings.Count(r.Tool, "__") == 1: + // "mcp__github" covers every tool of that server + return strings.HasPrefix(tool, r.Tool+"__") } return r.Tool == tool } @@ -197,6 +201,29 @@ func (r *Rules) Add(list, s string) error { return nil } +// Remove deletes the first rule in a list whose text is s. +func (r *Rules) Remove(list, s string) { + r.mu.Lock() + defer r.mu.Unlock() + var l *[]Rule + switch list { + case "allow": + l = &r.allow + case "ask": + l = &r.ask + case "deny": + l = &r.deny + default: + return + } + for i, x := range *l { + if x.Raw == strings.TrimSpace(s) { + *l = append((*l)[:i], (*l)[i+1:]...) + return + } + } +} + // Lists returns copies of the rule strings. func (r *Rules) Lists() (allow, ask, deny []string) { if r == nil { diff --git a/internal/toolkit/toolkit.go b/internal/toolkit/toolkit.go index d14dbf9..5e0306a 100644 --- a/internal/toolkit/toolkit.go +++ b/internal/toolkit/toolkit.go @@ -171,6 +171,20 @@ func (r *Registry) Register(t Tool) { r.tools[t.Name()] = t } +// Upsert adds or replaces a tool (dynamic tools: MCP servers). +func (r *Registry) Upsert(t Tool) { + r.mu.Lock() + defer r.mu.Unlock() + r.tools[t.Name()] = t +} + +// Remove drops a tool by name. +func (r *Registry) Remove(name string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.tools, name) +} + // Get fetches a tool by name. func (r *Registry) Get(name string) (Tool, bool) { r.mu.RLock() @@ -205,7 +219,12 @@ func (r *Registry) FuncSchemas() []map[string]any { } // ResolveName maps an agent-side function name back to a tool name. -func ResolveName(fnName string) string { return strings.ReplaceAll(fnName, "__", ".") } +func ResolveName(fnName string) string { + if strings.HasPrefix(fnName, "mcp__") { + return fnName // MCP tools keep their mcp__server__tool name + } + return strings.ReplaceAll(fnName, "__", ".") +} // Approver asks the human for confirmation. It must return true to proceed. type Approver func(c *Context, prompt string, tier Tier, detail map[string]any) (bool, error) diff --git a/internal/tui/app.go b/internal/tui/app.go index c974caa..829c253 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -605,5 +605,8 @@ func RunAppWith(c *toolkit.Context, reg *toolkit.Registry, interval time.Duratio m.initial = prompt p := tea.NewProgram(m, tea.WithAltScreen()) _, err := p.Run() + if m.chat.agent != nil { + m.chat.agent.Close() + } return err } diff --git a/internal/tui/chat.go b/internal/tui/chat.go index 85d4e9f..5778dbd 100644 --- a/internal/tui/chat.go +++ b/internal/tui/chat.go @@ -193,7 +193,7 @@ func (m *AppModel) chatSlash(s string) tea.Cmd { m.quitting = true return tea.Quit case "/help": - return add("info", "commands:\n"+agent.CommandHelp+` + return add("info", "commands:\n"+agent.HelpText(p.agent)+` /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`) From aa89937101d840a7db898966263095f96305d4f9 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 18:24:42 +0530 Subject: [PATCH 07/48] agent: /help works headlessly (cometcli -p /help) and lists custom commands Co-Authored-By: Claude Opus 5.5 --- internal/agent/commands.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/agent/commands.go b/internal/agent/commands.go index 4a6c5c4..5f730ef 100644 --- a/internal/agent/commands.go +++ b/internal/agent/commands.go @@ -66,6 +66,9 @@ func RunCommand(a *Agent, c *toolkit.Context, reg *toolkit.Registry, line string return nil } switch f[0] { + case "/help": + return CmdResult{Text: HelpText(a)}, nil + case "/profile": if c == nil || c.Profile == nil { return CmdResult{Text: "no active profile"}, nil From b342d1b68cea1407ff31d87ecbf6236a08e99706 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 18:35:58 +0530 Subject: [PATCH 08/48] tui: inline Claude Code-style chat for cometcli and cometcli one (phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New internal/tui/chatui, used by bare `cometcli` and `cometcli one`: - inline (no alt screen): finished messages print into scrollback; only streaming text, spinner, input, dialogs and the status line redraw - banner, ● tool lines with ⎿ results (diffs colored), markdown answers, todo checklists, last answer shown on resume, resume hint on exit - input: enter sends, \⏎ / ctrl+j newline, history, / command menu (builtins + custom commands), @file completion with content attached, # adds memory, messages typed during a turn are queued - !cmd runs in the operator's real terminal via ExecProcess (prompts and hidden password input work, e.g. vote-upgrade.sh); output never reaches the model, which is told the command and exit code; over `ssh -t` in node mode with an SSH transport - approvals: Yes / Yes, don't ask again for (session + .cometcli/settings.local.json or user settings) / No (declines and interrupts); transactions offer only Yes/No - shift+tab cycles ops → accept-edits → read-only (→ bypass when started with it); esc interrupts; ctrl+c clear/interrupt/double-exit; ctrl+d - no terminal background query (dark unless COLORFGBG says light); zero-size pty reports ignored Supporting: toolkit.SuggestRule + RuleHint on approval details (never for transactions), Context.ToolName, Event.Output previews, /status and /clear, agent.Commands/ExpandMentions/AddNote/AllowAlways/ProjectFiles/ ContextPercent; the y/N approver hides "_" hint keys. `cometcli ui` keeps the full-screen dashboard. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 + docs/COMMANDS.md | 27 + internal/agent/commands.go | 14 +- internal/agent/events.go | 2 + internal/agent/loop.go | 17 +- internal/agent/uisupport.go | 175 +++++ internal/cli/chat.go | 19 +- internal/cli/root.go | 4 +- internal/toolkit/context.go | 14 +- internal/toolkit/perm.go | 45 ++ internal/toolkit/toolkit.go | 14 + internal/tui/chatui/chatui.go | 1076 ++++++++++++++++++++++++++++ internal/tui/chatui/chatui_test.go | 216 ++++++ 13 files changed, 1619 insertions(+), 9 deletions(-) create mode 100644 internal/agent/uisupport.go create mode 100644 internal/tui/chatui/chatui.go create mode 100644 internal/tui/chatui/chatui_test.go diff --git a/README.md b/README.md index 92f2003..7f39556 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,11 @@ cometcli -c # continue the last s cometcli -r # resume one (cometcli sessions) ``` +The terminal is an inline chat like Claude Code's — scrollback-friendly, `/` command +menu, `@file` mentions, `!cmd` to run something interactive in your own terminal, +`shift+tab` to change permission mode, approvals with "don't ask again". +[Keys and prefixes](docs/COMMANDS.md#the-interactive-terminal). + **General mode** is an SRE agent on your machine: `bash`, `read`/`write`/`edit`, `glob`/`grep`, `web_fetch`. **Node mode** (`cometcli one `, or `/one ` mid-conversation) adds the node tools, runs the shell on the node's host diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6151bdb..14c3b19 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -36,6 +36,33 @@ node mode. `stream-json` prints a `system/init` line, then `assistant`, `tool_use`, `tool_result`, `notice` and `todos` events, then the same `result` object. +### The interactive terminal + +`cometcli` and `cometcli one` open an inline chat: answers land in your normal +scrollback (scroll, copy, search as usual); only the bottom area — streaming text, +input, approval dialog, status line — redraws. + +| Key / prefix | Does | +|---|---| +| `enter` · `\`+`enter` / `ctrl+j` | send · new line | +| `↑` `↓` | prompt history (or move in a menu) | +| `/` | command menu as you type; `tab` completes, `enter` runs | +| `@path` | file completion; the file's content is attached to your message | +| `!cmd` | run `cmd` **in your terminal** — the UI steps aside, so prompts and passwords work (e.g. `!./upgrade/vote-upgrade.sh`). Output is never sent to the model; it's told the command and exit code. In node mode over SSH it runs on the node (`ssh -t`) | +| `#text` | add a line to memory (`/remember`) | +| `shift+tab` | cycle mode: ops → accept-edits → read-only (→ bypass, if started with it) | +| `esc` | interrupt the running turn (or close a menu) | +| `ctrl+c` | interrupt · clear input · twice to exit | +| `ctrl+d` | exit (empty input) | +| `?` | shortcut help | + +Messages typed while a turn runs are queued and sent when it ends. Approvals offer +**Yes**, **Yes, and don't ask again for ``** (saved to the project's +`.cometcli/settings.local.json`, or your user settings outside a project), and **No** +(declines and interrupts so you can redirect). Transactions offer only Yes/No. + +`cometcli ui` still opens the full-screen dashboard (overview, fleet, logs, send). + ## Setup ```bash diff --git a/internal/agent/commands.go b/internal/agent/commands.go index 5f730ef..bdbe70c 100644 --- a/internal/agent/commands.go +++ b/internal/agent/commands.go @@ -40,7 +40,8 @@ const CommandHelp = ` /mode [ops|accept-edits|readonly|bypass] show or set the /one [profile|off] switch to node mode for a profile, or back to general /profile active profile /audit audit log path + session id - /reset clear conversation, start a new audit session + /reset, /clear clear conversation, start a new audit session + /status live node snapshot (node mode) /compact [focus] summarize the conversation to free context /cost token usage for this session /effort [low|medium|high|xhigh|max|default] show or set reasoning depth @@ -69,6 +70,17 @@ func RunCommand(a *Agent, c *toolkit.Context, reg *toolkit.Registry, line string case "/help": return CmdResult{Text: HelpText(a)}, nil + case "/clear": + return RunCommand(a, c, reg, "/reset") + + case "/status": + if a == nil || !a.Node() { + return CmdResult{Text: "general mode — /one for a node status"}, nil + } + return CmdResult{Text: "probing " + a.Ctx.Profile.Name + "…", Job: func(ctx context.Context) (string, error) { + return strings.TrimSpace(a.Redact.Text(SnapshotText(a.Ctx))), nil + }}, nil + case "/profile": if c == nil || c.Profile == nil { return CmdResult{Text: "no active profile"}, nil diff --git a/internal/agent/events.go b/internal/agent/events.go index 42310bf..4d5b0a0 100644 --- a/internal/agent/events.go +++ b/internal/agent/events.go @@ -45,6 +45,8 @@ type Event struct { Err string `json:"error,omitempty"` Approval *Approval `json:"approval,omitempty"` Todos []Todo `json:"todos,omitempty"` + // Output is a multi-line preview of a tool's result (EvToolResult). + Output string `json:"output,omitempty"` } // Approval is a pending human decision. Answer it exactly once. diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 11487e5..2055e82 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -609,7 +609,7 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { return a.toolErr(call, name, "error: "+err.Error()+postNote) } text += postNote - a.emit(Event{Kind: EvToolResult, Tool: name, Tier: t.Tier().String(), Text: firstLine(text)}) + a.emit(Event{Kind: EvToolResult, Tool: name, Tier: t.Tier().String(), Text: firstLine(text), Output: preview(text)}) return Msg{Role: "tool", CallID: call.ID, ToolName: name, Text: text} } @@ -637,6 +637,7 @@ func (a *Agent) toolCtx(ctx context.Context, t toolkit.Tool, args toolkit.Args) sub, cancel = toolkit.WithDeadline(parent, d) } sub.AutoApproveBelow = a.Policy.AutoApproveBelow() + sub.ToolName = t.Name() if a.Policy.ReadOnly() { sub.Approver = toolkit.DenyApprover } @@ -742,6 +743,20 @@ func (a *Agent) History() []Msg { return append([]Msg(nil), a.history...) } // ansiRe matches terminal escape sequences (colors, cursor moves). var ansiRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) +// preview is the first lines of tool output, for front-ends. +func preview(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + more := 0 + if len(lines) > 8 { + more, lines = len(lines)-8, lines[:8] + } + out := safeCut(strings.Join(lines, "\n"), 1500) + if more > 0 { + out += fmt.Sprintf("\n… +%d lines", more) + } + return out +} + // clip bounds s to about limit bytes, keeping the head and the tail (log // tails usually hold the error) and cutting only at UTF-8 boundaries. func clip(s string, limit int) string { diff --git a/internal/agent/uisupport.go b/internal/agent/uisupport.go new file mode 100644 index 0000000..c845f3e --- /dev/null +++ b/internal/agent/uisupport.go @@ -0,0 +1,175 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/abhijitkrm/cometcli/internal/settings" + "github.com/abhijitkrm/cometcli/internal/toolkit" + "github.com/abhijitkrm/cometcli/internal/tools/shell" +) + +// CmdInfo describes a slash command for completion menus. +type CmdInfo struct { + Name string // with the leading slash + Args string + Desc string +} + +var helpLineRe = regexp.MustCompile(`^\s+(/\S+)(.*?)\s{2,}(\S.*)$`) + +// Commands lists the slash commands available in this session: the +// shared ones, front-end ones, and custom commands. +func Commands(a *Agent) []CmdInfo { + var out []CmdInfo + seen := map[string]bool{} + add := func(c CmdInfo) { + for _, n := range strings.Split(c.Name, ",") { + n = strings.TrimSpace(n) + if n == "" || seen[n] { + continue + } + seen[n] = true + out = append(out, CmdInfo{Name: n, Args: c.Args, Desc: c.Desc}) + } + } + for _, l := range strings.Split(HelpText(nil)+"\n /exit quit", "\n") { + l = strings.ReplaceAll(l, ", /", "|/") // "/reset, /clear" names two commands + m := helpLineRe.FindStringSubmatch(l) + if m == nil { + continue + } + names := strings.Split(m[1], "|") + args := strings.TrimSpace(m[2]) + for _, n := range names { + if !strings.HasPrefix(n, "/") { + n = "/" + n + } + add(CmdInfo{Name: strings.TrimSuffix(n, ","), Args: args, Desc: m[3]}) + } + } + if a != nil && a.ext != nil { + for _, c := range settings.SortedCommands(a.ext.Commands) { + add(CmdInfo{Name: "/" + c.Name, Args: c.ArgHint, Desc: c.Description + " (" + c.Scope + ")"}) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +var mentionRe = regexp.MustCompile(`(^|\s)@([\w./~-][\w./~:-]*)`) + +// ExpandMentions attaches the content of @path mentions (files on this +// machine, relative to the shell's working directory) to a prompt. Key +// material and large or unreadable files are left as plain text. +func (a *Agent) ExpandMentions(input string) string { + cwd := a.Tools.Cwd(a.WorkRoot) + var files []string + for _, m := range mentionRe.FindAllStringSubmatch(input, 10) { + p := m[2] + full := p + if strings.HasPrefix(p, "~/") { + home, _ := os.UserHomeDir() + full = filepath.Join(home, p[2:]) + } else if !filepath.IsAbs(p) { + full = filepath.Join(cwd, p) + } + if shell.Classify("cat "+full, shell.Opts{}).Forbidden != "" { + continue + } + fi, err := os.Stat(full) + if err != nil || fi.IsDir() || fi.Size() > 100_000 { + continue + } + b, err := os.ReadFile(full) + if err != nil || strings.IndexByte(string(b[:min(len(b), 8192)]), 0) >= 0 { + continue + } + if a.Tools != nil { + h, _ := a.Ctx.Host() + key := "local:" + full + if h != nil { + key = h.String() + ":" + full + } + a.Tools.MarkRead(key, b) // the model has seen it: edits may follow + } + files = append(files, fmt.Sprintf("\n%s\n", full, strings.TrimRight(string(b), "\n"))) + } + if len(files) == 0 { + return input + } + return input + "\n\n" + strings.Join(files, "\n") +} + +// ContextPercent is how full the context window is (0 when unknown). +func (a *Agent) ContextPercent() int { + _, last := a.Usage() + if last == 0 { + return 0 + } + return min(100, last*100/a.contextWindow()) +} + +// AddNote tells the model something on its next turn (e.g. the operator +// ran a command in their own terminal). +func (a *Agent) AddNote(note string) { + a.carry = strings.TrimSpace(a.carry + "\n\n" + a.Redact.Text(note)) +} + +// AllowAlways adds an allow rule to the session and saves it to the +// project's settings.local.json (or the user settings without a +// project). Returns where it was saved. +func (a *Agent) AllowAlways(rule string) (string, error) { + if a.Rules == nil { + a.Rules, _ = toolkit.NewRules(nil, nil, nil) + } + if err := a.Rules.Add("allow", rule); err != nil { + return "", err + } + path, err := settings.UserPath() + if a.ext != nil && a.ext.Settings.Root != "" { + path, err = filepath.Join(a.ext.Settings.Root, ".cometcli", "settings.local.json"), nil + } + if err != nil { + return "", err + } + return path, settings.EditFile(path, func(f *settings.File) error { + for _, r := range f.Permissions.Allow { + if r == rule { + return nil + } + } + f.Permissions.Allow = append(f.Permissions.Allow, rule) + return nil + }) +} + +// ProjectFiles lists files under the working directory for @ completion +// (skipping VCS, dependency and build directories), up to limit. +func (a *Agent) ProjectFiles(limit int) []string { + root := a.Tools.Cwd(a.WorkRoot) + var out []string + skip := map[string]bool{".git": true, "node_modules": true, "vendor": true, ".build": true, "dist": true, "evm": true, ".mnemonics": true} + _ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if p != root && (skip[d.Name()] || strings.HasPrefix(d.Name(), ".") && d.Name() != ".cometcli") { + return filepath.SkipDir + } + return nil + } + rel, _ := filepath.Rel(root, p) + out = append(out, rel) + if len(out) >= limit { + return filepath.SkipAll + } + return nil + }) + return out +} diff --git a/internal/cli/chat.go b/internal/cli/chat.go index 2b2cb14..9bcd20a 100644 --- a/internal/cli/chat.go +++ b/internal/cli/chat.go @@ -15,7 +15,7 @@ import ( "github.com/abhijitkrm/cometcli/internal/audit" "github.com/abhijitkrm/cometcli/internal/config" "github.com/abhijitkrm/cometcli/internal/toolkit" - "github.com/abhijitkrm/cometcli/internal/tui" + "github.com/abhijitkrm/cometcli/internal/tui/chatui" ) // maxStdin bounds piped input; larger input keeps its head and tail. @@ -129,9 +129,20 @@ func runChat(cmd *cobra.Command, reg *toolkit.Registry, p *config.Profile, args } return runPrint(cmd, c, reg, prompt) } - return tui.RunAppWith(c, reg, 5*time.Second, prompt, func(a *agent.Agent) (string, error) { - return configureChatAgent(cmd, a) - }) + a, err := agent.New(c, reg) + if err != nil { + return err + } + defer a.Close() + note, err := configureChatAgent(cmd, a) + if err != nil { + return err + } + var notes []string + if note != "" { + notes = append(notes, note) + } + return chatui.Run(chatui.Options{Agent: a, Prompt: prompt, Notes: notes}) } // chatCtx builds the tool context for a chat session. Headless sessions diff --git a/internal/cli/root.go b/internal/cli/root.go index d33a252..6722681 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -245,7 +245,9 @@ func StdinApprover(in io.Reader) toolkit.Approver { fmt.Fprintf(c.Out, "\n⚠ [%s] %s\n", tier, prompt) keys := make([]string, 0, len(detail)) for k := range detail { - keys = append(keys, k) + if !strings.HasPrefix(k, "_") { // front-end hints, not for display + keys = append(keys, k) + } } sort.Strings(keys) for _, k := range keys { diff --git a/internal/toolkit/context.go b/internal/toolkit/context.go index 6667850..1f3ddcf 100644 --- a/internal/toolkit/context.go +++ b/internal/toolkit/context.go @@ -38,6 +38,8 @@ type Context struct { AcceptEdits bool // WorkRoot anchors relative rule patterns and accept-edits scope. WorkRoot string + // ToolName is the tool being run (for approval rule suggestions). + ToolName string // HookDecision is a PreToolUse hook's verdict for this call: // "allow" skips the prompt, "ask" forces one ("" = no opinion). HookDecision string @@ -167,7 +169,7 @@ func WithDeadline(c *Context, timeout time.Duration) (*Context, context.CancelFu Context: ctx, Profile: c.Profile, Cfg: c.Cfg, Out: c.Out, Audit: c.Audit, Approver: c.Approver, AutoApproveBelow: c.AutoApproveBelow, Session: c.Session, Rules: c.Rules, ReadOnly: c.ReadOnly, - AcceptEdits: c.AcceptEdits, WorkRoot: c.WorkRoot, HookDecision: c.HookDecision, + AcceptEdits: c.AcceptEdits, WorkRoot: c.WorkRoot, HookDecision: c.HookDecision, ToolName: c.ToolName, }, cancel } @@ -179,7 +181,7 @@ func WithCancel(c *Context) (*Context, context.CancelFunc) { Context: ctx, Profile: c.Profile, Cfg: c.Cfg, Out: c.Out, Audit: c.Audit, Approver: c.Approver, AutoApproveBelow: c.AutoApproveBelow, Session: c.Session, Rules: c.Rules, ReadOnly: c.ReadOnly, - AcceptEdits: c.AcceptEdits, WorkRoot: c.WorkRoot, HookDecision: c.HookDecision, + AcceptEdits: c.AcceptEdits, WorkRoot: c.WorkRoot, HookDecision: c.HookDecision, ToolName: c.ToolName, }, cancel } @@ -233,6 +235,14 @@ func (c *Context) Check(g Gate) error { if g.Root == "" { g.Root = c.WorkRoot } + if g.Tier < TierOnChain { + if g.Detail == nil { + g.Detail = map[string]any{} + } + if r := SuggestRule(g.Request); r != "" { + g.Detail[RuleHint] = r + } + } d, rule := c.Rules.Decide(g.Request) switch { case d == DecideDeny: diff --git a/internal/toolkit/perm.go b/internal/toolkit/perm.go index 5af5e98..b8f42e6 100644 --- a/internal/toolkit/perm.go +++ b/internal/toolkit/perm.go @@ -291,6 +291,51 @@ func (r *Rules) Decide(req Request) (Decision, string) { return DecideDefault, "" } +// SuggestRule proposes an allow rule covering this kind of request, for a +// "don't ask again" answer: the first two words of a shell command as a +// prefix (docker compose:*), a file's directory, a URL's domain, or the +// tool itself. +func SuggestRule(req Request) string { + switch req.Kind { + case "command": + if len(req.Specs) == 0 { + return "" + } + w := strings.Fields(req.Specs[0]) + for len(w) > 0 && strings.Contains(w[0], "=") { + w = w[1:] // VAR=value prefixes + } + if len(w) == 0 { + return "" + } + n := 1 + if len(w) > 1 && !strings.HasPrefix(w[1], "-") && !strings.ContainsAny(w[1], "/.=") && (len(w) > 2 || !strings.HasPrefix(w[0], "./")) { + n = 2 + } + return req.Tool + "(" + strings.Join(w[:n], " ") + ":*)" + case "path": + if len(req.Specs) == 0 { + return "" + } + dir := filepath.Dir(req.Specs[0]) + if req.Root != "" { + if rel, err := filepath.Rel(req.Root, dir); err == nil && !strings.HasPrefix(rel, "..") { + if rel == "." { + return req.Tool + "(./**)" + } + return req.Tool + "(./" + rel + "/**)" + } + } + return req.Tool + "(" + dir + "/**)" + case "url": + if len(req.Specs) == 0 { + return "" + } + return req.Tool + "(domain:" + urlHost(req.Specs[0]) + ")" + } + return req.Tool +} + // MatchPathGlob matches a cleaned absolute path against a glob where * // and ? stay within one path segment and ** spans any number of them. func MatchPathGlob(pattern, path string) bool { diff --git a/internal/toolkit/toolkit.go b/internal/toolkit/toolkit.go index 5e0306a..412b4f1 100644 --- a/internal/toolkit/toolkit.go +++ b/internal/toolkit/toolkit.go @@ -226,6 +226,11 @@ func ResolveName(fnName string) string { return strings.ReplaceAll(fnName, "__", ".") } +// RuleHint is the approval-detail key carrying a suggested allow rule +// for this kind of action. Keys starting with "_" are for front-ends, not +// for display. +const RuleHint = "_rule" + // Approver asks the human for confirmation. It must return true to proceed. type Approver func(c *Context, prompt string, tier Tier, detail map[string]any) (bool, error) @@ -240,6 +245,15 @@ func RequireApproval(c *Context, prompt string, tier Tier, detail map[string]any if c.Approver == nil { return fmt.Errorf("approval required but no approver configured: %s", prompt) } + // suggest an allow rule for "don't ask again" — never for transactions + if tier < TierOnChain && c.ToolName != "" { + if detail == nil { + detail = map[string]any{} + } + if _, ok := detail[RuleHint]; !ok { + detail[RuleHint] = c.ToolName + } + } ok, err := c.Approver(c, prompt, tier, detail) if err != nil { return err diff --git a/internal/tui/chatui/chatui.go b/internal/tui/chatui/chatui.go new file mode 100644 index 0000000..fe487cc --- /dev/null +++ b/internal/tui/chatui/chatui.go @@ -0,0 +1,1076 @@ +// Package chatui is cometcli's interactive terminal: an inline, +// scrollback-friendly chat in the style of Claude Code. Finished messages +// are printed into the terminal's normal scrollback; only a small live +// region (streaming text, spinner, input, approval dialog, status line) +// is redrawn. +package chatui + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/styles" + "github.com/charmbracelet/lipgloss" + + "github.com/abhijitkrm/cometcli/internal/agent" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +// Options configure a chat session. +type Options struct { + Agent *agent.Agent + Prompt string // submitted on start + Notes []string // shown under the banner (e.g. "resumed session …") +} + +// Run starts the interactive chat and blocks until the operator exits. +func Run(o Options) error { + m := newModel(o) + p := tea.NewProgram(m) + m.send = p.Send + o.Agent.OnEvent = func(e agent.Event) { p.Send(eventMsg{e}) } + o.Agent.Ctx.Approver = m.approve + _, err := p.Run() + if m.a.Persist && len(m.a.History()) > 0 { + fmt.Printf("\n%s\n", dim.Render("resume this session with: cometcli -r "+m.a.ID())) + } + return err +} + +// --- messages -------------------------------------------------------------- + +type eventMsg struct{ e agent.Event } +type doneMsg struct { + text string + err error +} +type jobDoneMsg struct { + text string + err error +} +type execDoneMsg struct { + cmd string + err error + code int +} +type approvalMsg struct{ p *pendingApproval } + +type approvalResult struct { + ok bool + err error +} + +type pendingApproval struct { + prompt string + tier toolkit.Tier + detail map[string]any + resp chan approvalResult + sel int +} + +// --- styles ---------------------------------------------------------------- + +var ( + accent = lipgloss.Color("#D97757") + dim = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + faint = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + bold = lipgloss.NewStyle().Bold(true) + errSt = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + okSt = lipgloss.NewStyle().Foreground(lipgloss.Color("114")) + warnSt = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + accentSt = lipgloss.NewStyle().Foreground(accent) + userSt = lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Background(lipgloss.Color("236")) + addSt = lipgloss.NewStyle().Foreground(lipgloss.Color("114")) + delSt = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) +) + +// --- model ----------------------------------------------------------------- + +type suggestion struct { + insert string // replaces the current token + label string + desc string +} + +type model struct { + a *agent.Agent + send func(tea.Msg) + + ta textarea.Model + sp spinner.Model + width int + + running bool + cancel context.CancelFunc + started time.Time + live strings.Builder + think string + queue []string + + history []string + histIdx int + draft string + + sugg []suggestion + suggIdx int + cmds []agent.CmdInfo + files []string + + approval *pendingApproval + quitArmed time.Time + hint string // transient message in the footer + baseMode agent.Mode + + md *glamour.TermRenderer + mdWidth int + mdStyle string + + initial string + notes []string + printed []string // everything printed, for tests +} + +func newModel(o Options) *model { + ta := textarea.New() + ta.Placeholder = `Try "check my validator's last 100 log lines for errors"` + ta.Prompt = "" + ta.ShowLineNumbers = false + ta.CharLimit = 0 + ta.SetHeight(1) + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() + ta.FocusedStyle.Placeholder = faint + ta.KeyMap.InsertNewline.SetEnabled(false) // enter submits; ctrl+j / \⏎ add lines + ta.Focus() + sp := spinner.New() + sp.Spinner = spinner.Spinner{Frames: []string{"·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"}, FPS: 120 * time.Millisecond} + sp.Style = accentSt + // no terminal background query (its reply can leak into the input): + // dark unless COLORFGBG says the background is light + style := "dark" + if fgbg := os.Getenv("COLORFGBG"); fgbg != "" { + if bg := fgbg[strings.LastIndex(fgbg, ";")+1:]; bg == "7" || bg == "15" { + style = "light" + } + } + return &model{ + a: o.Agent, ta: ta, sp: sp, width: 100, initial: o.Prompt, notes: o.Notes, + cmds: agent.Commands(o.Agent), histIdx: -1, baseMode: o.Agent.Policy.Mode, mdStyle: style, + } +} + +func (m *model) Init() tea.Cmd { + cmds := []tea.Cmd{textarea.Blink, m.print(m.banner())} + if m.initial != "" { + cmds = append(cmds, m.submit(m.initial)) + m.initial = "" + } + return tea.Sequence(cmds...) +} + +// print commits lines to the scrollback above the live region. +func (m *model) print(lines ...string) tea.Cmd { + s := strings.Join(lines, "\n") + if s == "" { + return nil + } + m.printed = append(m.printed, s) + return tea.Println(s) +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch v := msg.(type) { + case tea.WindowSizeMsg: + if v.Width <= 0 { + return m, nil // some ptys report 0×0 + } + m.width = v.Width + m.ta.SetWidth(max(20, v.Width-6)) + return m, nil + case spinner.TickMsg: + if !m.running { + return m, nil + } + var cmd tea.Cmd + m.sp, cmd = m.sp.Update(v) + return m, cmd + case eventMsg: + return m, m.onEvent(v.e) + case approvalMsg: + m.approval = v.p + return m, nil + case doneMsg: + return m, m.onDone(v) + case jobDoneMsg: + if v.err != nil { + return m, m.print(result(errSt.Render(v.err.Error()))) + } + return m, m.print(result(v.text)) + case execDoneMsg: + return m, m.onExecDone(v) + case tea.KeyMsg: + if m.approval != nil { + return m, m.approvalKey(v) + } + return m, m.key(v) + } + var cmd tea.Cmd + m.ta, cmd = m.ta.Update(msg) + return m, cmd +} + +// --- keys ------------------------------------------------------------------ + +func (m *model) key(k tea.KeyMsg) tea.Cmd { + if k.String() != "ctrl+c" { + m.quitArmed = time.Time{} + } + m.hint = "" + switch k.String() { + case "ctrl+c": + switch { + case m.running: + m.interrupt() + case m.ta.Value() != "": + m.ta.Reset() + m.resize() + case time.Since(m.quitArmed) < 2*time.Second: + return tea.Quit + default: + m.quitArmed = time.Now() + m.hint = "press ctrl+c again to exit" + } + return nil + case "ctrl+d": + if m.ta.Value() == "" { + return tea.Quit + } + case "esc": + switch { + case len(m.sugg) > 0: + m.sugg = nil + case m.running: + m.interrupt() + } + return nil + case "shift+tab": + m.cycleMode() + return nil + case "tab": + if len(m.sugg) > 0 { + m.acceptSuggestion() + return nil + } + case "up", "down": + if len(m.sugg) > 0 { + d := 1 + if k.String() == "up" { + d = -1 + } + m.suggIdx = (m.suggIdx + d + len(m.sugg)) % len(m.sugg) + return nil + } + if !strings.Contains(m.ta.Value(), "\n") { + m.historyStep(k.String() == "up") + return nil + } + case "ctrl+j", "alt+enter": + m.ta.InsertString("\n") + m.resize() + return nil + case "enter": + if len(m.sugg) > 0 && m.sugg[m.suggIdx].insert != m.currentToken() { + m.acceptSuggestion() + if !strings.HasPrefix(m.ta.Value(), "/") || strings.Contains(m.ta.Value(), "@") { + return nil + } + } + v := m.ta.Value() + if strings.HasSuffix(v, "\\") { + m.ta.SetValue(strings.TrimSuffix(v, "\\") + "\n") + m.resize() + return nil + } + m.ta.Reset() + m.sugg = nil + m.resize() + return m.submit(v) + } + var cmd tea.Cmd + m.ta, cmd = m.ta.Update(k) + m.resize() + m.suggest() + return cmd +} + +func (m *model) resize() { + n := strings.Count(m.ta.Value(), "\n") + 1 + m.ta.SetHeight(min(max(n, 1), 8)) +} + +func (m *model) historyStep(up bool) { + if len(m.history) == 0 { + return + } + if m.histIdx == -1 { + m.draft = m.ta.Value() + } + switch { + case up && m.histIdx < len(m.history)-1: + m.histIdx++ + case !up && m.histIdx > -1: + m.histIdx-- + } + if m.histIdx == -1 { + m.ta.SetValue(m.draft) + } else { + m.ta.SetValue(m.history[len(m.history)-1-m.histIdx]) + } + m.ta.CursorEnd() + m.resize() +} + +func (m *model) cycleMode() { + order := []agent.Mode{agent.ModeOps, agent.ModeAcceptEdits, agent.ModeReadOnly} + if m.baseMode == agent.ModeBypass { + order = append(order, agent.ModeBypass) + } + cur := m.a.Policy.Mode + if cur == "" { + cur = agent.ModeOps + } + next := order[0] + for i, md := range order { + if md == cur { + next = order[(i+1)%len(order)] + } + } + m.a.Policy.Mode = next +} + +func (m *model) interrupt() { + if m.cancel != nil { + m.cancel() + } +} + +// --- submit ---------------------------------------------------------------- + +func (m *model) submit(raw string) tea.Cmd { + text := strings.TrimSpace(raw) + if text == "" { + return nil + } + if len(m.history) == 0 || m.history[len(m.history)-1] != text { + m.history = append(m.history, text) + } + m.histIdx = -1 + switch { + case strings.HasPrefix(text, "!"): + return m.runShell(strings.TrimSpace(text[1:])) + case strings.HasPrefix(text, "#"): + return m.command("/remember " + strings.TrimSpace(text[1:])) + case strings.HasPrefix(text, "/"): + return m.command(text) + } + if m.running { + m.queue = append(m.queue, text) + return m.print(userLine(text), result(dim.Render("queued — sent when the current turn ends"))) + } + return tea.Sequence(m.print(userLine(text)), m.startTurn(m.a.ExpandMentions(text))) +} + +func (m *model) startTurn(prompt string) tea.Cmd { + ctx, cancel := context.WithCancel(m.a.Ctx.Context) + m.cancel, m.running, m.started = cancel, true, time.Now() + m.live.Reset() + m.think = "" + a := m.a + return tea.Batch(m.sp.Tick, func() tea.Msg { + defer cancel() + out, err := a.Run(ctx, prompt) + return doneMsg{out, err} + }) +} + +func (m *model) command(line string) tea.Cmd { + f := strings.Fields(line) + switch f[0] { + case "/exit", "/quit", "/q": + return tea.Quit + } + if m.running && f[0] != "/cost" && f[0] != "/help" && f[0] != "/permissions" && f[0] != "/mcp" { + return m.print(userLine(line), result(dim.Render("a turn is running — esc to interrupt it first"))) + } + res, err := agent.RunCommand(m.a, m.a.Ctx, m.a.Reg, line) + echo := userLine(line) + switch { + case errors.Is(err, agent.ErrUnknownCommand): + return m.print(echo, result(errSt.Render("unknown command "+f[0]+" — /help lists them"))) + case err != nil: + return m.print(echo, result(errSt.Render(err.Error()))) + } + if f[0] == "/one" || f[0] == "/reset" || f[0] == "/clear" || f[0] == "/resume" { + m.cmds = agent.Commands(m.a) + m.files = nil + } + cmds := []tea.Cmd{m.print(echo, result(res.Text))} + if res.Job != nil { + job, ctx := res.Job, m.a.Ctx.Context + cmds = append(cmds, func() tea.Msg { + txt, err := job(ctx) + return jobDoneMsg{txt, err} + }) + } + if res.Prompt != "" { + cmds = append(cmds, m.startTurn(res.Prompt)) + } + return tea.Sequence(cmds...) +} + +// runShell runs a command in the operator's own terminal (the UI is +// suspended), so interactive scripts work. Its output is never captured; +// the model is told the command and its exit code. +func (m *model) runShell(cmdline string) tea.Cmd { + if cmdline == "" { + return m.print(result(dim.Render("! runs a command in your terminal, e.g. !./upgrade/vote-upgrade.sh"))) + } + if m.running { + return m.print(result(dim.Render("a turn is running — esc to interrupt it first"))) + } + c := m.shellCmd(cmdline) + return tea.Sequence( + m.print(accentSt.Render("!")+" "+bold.Render(cmdline)), + tea.ExecProcess(c, func(err error) tea.Msg { + code := 0 + var ee *exec.ExitError + if errors.As(err, &ee) { + code, err = ee.ExitCode(), nil + } + return execDoneMsg{cmd: cmdline, err: err, code: code} + }), + ) +} + +// shellCmd builds the command for "!": local, or on the node over ssh -t +// in node mode with an SSH transport. +func (m *model) shellCmd(cmdline string) *exec.Cmd { + cwd := m.a.Tools.Cwd(m.a.WorkRoot) + if m.a.Node() && m.a.Ctx.Profile.Transport.Type == "ssh" { + t := m.a.Ctx.Profile.Transport + args := []string{"-t"} + if t.Port != 0 { + args = append(args, "-p", strconv.Itoa(t.Port)) + } + if t.KeyFile != "" { + args = append(args, "-i", t.KeyFile) + } + target := t.Host + if t.User != "" { + target = t.User + "@" + t.Host + } + remote := cmdline + if rc := m.a.Tools.Cwd(""); rc != "" { + remote = "cd '" + strings.ReplaceAll(rc, "'", `'\''`) + "' && " + cmdline + } + return exec.Command("ssh", append(args, target, remote)...) + } + sh := os.Getenv("SHELL") + if sh == "" { + sh = "/bin/sh" + } + c := exec.Command(sh, "-c", cmdline) + c.Dir = cwd + return c +} + +func (m *model) onExecDone(v execDoneMsg) tea.Cmd { + status := okSt.Render("done") + switch { + case v.err != nil: + status = errSt.Render(v.err.Error()) + case v.code != 0: + status = warnSt.Render(fmt.Sprintf("exit %d", v.code)) + } + where := "their terminal" + if m.a.Node() && m.a.Ctx.Profile.Transport.Type == "ssh" { + where = m.a.Ctx.Profile.Name + " over ssh" + } + m.a.AddNote(fmt.Sprintf("[The operator ran `%s` themselves in %s (exit %d). Its output was not captured — ask them if you need it.]", + v.cmd, where, v.code)) + return m.print(result(status + dim.Render(" · the agent will know you ran this"))) +} + +// --- agent events ---------------------------------------------------------- + +func (m *model) onEvent(e agent.Event) tea.Cmd { + switch e.Kind { + case agent.EvDelta: + m.live.WriteString(e.Text) + return nil + case agent.EvThinking: + m.think += e.Text + return nil + case agent.EvText: + m.live.Reset() + m.think = "" + return m.print("", accentSt.Render("● ")+strings.TrimLeft(m.markdown(e.Text), "\n ")) + case agent.EvToolStart: + m.think = "" + return m.print("", accentSt.Render("● ")+toolHeader(e)) + case agent.EvToolResult: + if e.Err != "" { + return m.print(result(errSt.Render(e.Err))) + } + out := e.Output + if out == "" { + out = e.Text + } + return m.print(result(colorDiff(out))) + case agent.EvNotice: + return m.print(result(dim.Render(e.Text))) + case agent.EvTodos: + return m.print("", accentSt.Render("● ")+bold.Render("Update todos"), result(todoLines(e.Todos))) + } + return nil +} + +func (m *model) onDone(v doneMsg) tea.Cmd { + m.running, m.cancel = false, nil + var cmds []tea.Cmd + if s := strings.TrimSpace(m.live.String()); s != "" && v.err != nil { + cmds = append(cmds, m.print("", accentSt.Render("● ")+s)) // partial text before an error + } + m.live.Reset() + m.think = "" + switch { + case v.err == nil: + case errors.Is(v.err, context.Canceled): + cmds = append(cmds, m.print(result(warnSt.Render("Interrupted")+dim.Render(" · what should cometcli do instead?")))) + m.queue = nil + default: + cmds = append(cmds, m.print(result(errSt.Render(v.err.Error())))) + } + if len(m.queue) > 0 { + next := strings.Join(m.queue, "\n\n") + m.queue = nil + cmds = append(cmds, m.startTurn(m.a.ExpandMentions(next))) + } + return tea.Sequence(cmds...) +} + +// --- approvals ------------------------------------------------------------- + +// approve is the toolkit.Approver: it parks the tool goroutine until the +// operator answers the dialog. +func (m *model) approve(c *toolkit.Context, prompt string, tier toolkit.Tier, detail map[string]any) (bool, error) { + p := &pendingApproval{prompt: prompt, tier: tier, detail: detail, resp: make(chan approvalResult, 1)} + m.send(approvalMsg{p}) + select { + case r := <-p.resp: + return r.ok, r.err + case <-c.Done(): + return false, c.Err() + } +} + +type option struct { + label string + kind string // yes | always | no +} + +func (p *pendingApproval) options() []option { + opts := []option{{"Yes", "yes"}} + if r, _ := p.detail[toolkit.RuleHint].(string); r != "" && p.tier < toolkit.TierOnChain { + opts = append(opts, option{"Yes, and don't ask again for " + bold.Render(r), "always"}) + } + return append(opts, option{"No, and tell cometcli what to do differently " + dim.Render("(esc)"), "no"}) +} + +func (m *model) approvalKey(k tea.KeyMsg) tea.Cmd { + p := m.approval + opts := p.options() + switch k.String() { + case "up", "k": + p.sel = (p.sel - 1 + len(opts)) % len(opts) + case "down", "j", "tab": + p.sel = (p.sel + 1) % len(opts) + case "1", "2", "3": + if i := int(k.String()[0] - '1'); i < len(opts) { + return m.answer(opts[i].kind) + } + case "y": + return m.answer("yes") + case "n", "esc": + return m.answer("no") + case "ctrl+c": + return m.answer("no") + case "enter": + return m.answer(opts[p.sel].kind) + } + return nil +} + +func (m *model) answer(kind string) tea.Cmd { + p := m.approval + m.approval = nil + switch kind { + case "yes": + p.resp <- approvalResult{ok: true} + return nil + case "always": + rule, _ := p.detail[toolkit.RuleHint].(string) + path, err := m.a.AllowAlways(rule) + p.resp <- approvalResult{ok: true} + if err != nil { + return m.print(result(warnSt.Render("allowed for this session; couldn't save: " + err.Error()))) + } + return m.print(result(dim.Render("allowed " + rule + " — saved to " + path))) + } + p.resp <- approvalResult{err: errors.New("the operator declined")} + m.interrupt() + return nil +} + +// --- suggestions ----------------------------------------------------------- + +// currentToken is the word under the cursor (the input's last word). +func (m *model) currentToken() string { + v := m.ta.Value() + if i := strings.LastIndexAny(v, " \n"); i >= 0 { + return v[i+1:] + } + return v +} + +func (m *model) suggest() { + m.sugg, m.suggIdx = nil, 0 + v := m.ta.Value() + tok := m.currentToken() + switch { + case strings.HasPrefix(v, "/") && !strings.ContainsAny(v, " \n"): + for _, c := range m.cmds { + if strings.HasPrefix(c.Name, v) { + m.sugg = append(m.sugg, suggestion{insert: c.Name, label: c.Name + " " + dim.Render(c.Args), desc: c.Desc}) + } + } + case strings.HasPrefix(tok, "@") && len(tok) >= 1: + if m.files == nil { + m.files = m.a.ProjectFiles(5000) + } + q := strings.ToLower(tok[1:]) + for _, f := range m.files { + if q == "" || strings.Contains(strings.ToLower(f), q) { + m.sugg = append(m.sugg, suggestion{insert: "@" + f, label: "@" + f}) + if len(m.sugg) == 8 { + break + } + } + } + } + if len(m.sugg) > 8 { + m.sugg = m.sugg[:8] + } +} + +func (m *model) acceptSuggestion() { + s := m.sugg[m.suggIdx] + v := m.ta.Value() + v = v[:len(v)-len(m.currentToken())] + s.insert + " " + m.ta.SetValue(v) + m.ta.CursorEnd() + m.sugg = nil +} + +// --- view ------------------------------------------------------------------ + +func (m *model) View() string { + var b strings.Builder + w := max(40, m.width) + if m.running { + if s := strings.TrimSpace(m.live.String()); s != "" { + b.WriteString(lastLines(lipgloss.NewStyle().Width(w-2).Render(accentSt.Render("● ")+s), 14) + "\n") + } else if t := strings.TrimSpace(m.think); t != "" { + b.WriteString(faint.Render(lastLines(lipgloss.NewStyle().Width(w-4).Render("∴ "+oneLine(t, 400)), 3)) + "\n") + } + u, _ := m.a.Usage() + fmt.Fprintf(&b, "\n%s %s %s\n", m.sp.View(), accentSt.Render(working(m.started)), + dim.Render(fmt.Sprintf("(%s · ↑ %s tokens · esc to interrupt)", time.Since(m.started).Round(time.Second), humanTok(u.Input+u.Output)))) + } + if m.approval != nil { + b.WriteString(m.approvalView(w)) + return b.String() + } + box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Width(w-2).Padding(0, 1) + b.WriteString(box.Render(accentSt.Render("❯ ") + m.ta.View())) + b.WriteString("\n") + switch { + case m.ta.Value() == "?": + b.WriteString(dim.Render(shortcuts) + "\n") + case len(m.sugg) > 0: + for i, s := range m.sugg { + line := " " + s.label + if s.desc != "" { + line += " " + dim.Render(oneLine(s.desc, w-len(s.insert)-12)) + } + if i == m.suggIdx { + line = accentSt.Render("❯") + line[1:] + } + b.WriteString(line + "\n") + } + } + b.WriteString(m.footer(w)) + return b.String() +} + +const shortcuts = ` ! run in your terminal / commands @ attach a file # remember + shift+tab cycle mode esc interrupt ↑↓ history ctrl+j newline + ctrl+c clear / exit \⏎ newline tab complete ctrl+d exit` + +func (m *model) footer(w int) string { + left := dim.Render("? for shortcuts") + switch m.a.Policy.Mode { + case agent.ModeAcceptEdits: + left = okSt.Render("⏵⏵ accept edits on") + dim.Render(" (shift+tab to cycle)") + case agent.ModeReadOnly: + left = lipgloss.NewStyle().Foreground(lipgloss.Color("75")).Render("⏸ read-only mode on") + dim.Render(" (shift+tab to cycle)") + case agent.ModeBypass: + left = errSt.Render("⏵⏵ bypass permissions on") + dim.Render(" (shift+tab to cycle)") + } + if m.hint != "" { + left = warnSt.Render(m.hint) + } + scope := "general" + if m.a.Node() { + scope = "node:" + m.a.Ctx.Profile.Name + } + right := scope + " · " + m.a.Provider.Name() + "/" + m.a.Model + if pct := m.a.ContextPercent(); pct > 0 { + right += fmt.Sprintf(" · ctx %d%%", pct) + } + right = dim.Render(right) + gap := w - lipgloss.Width(left) - lipgloss.Width(right) - 2 + if gap < 1 { + return " " + left + "\n " + right + } + return " " + left + strings.Repeat(" ", gap) + right +} + +func (m *model) approvalView(w int) string { + p := m.approval + title, body := describe(p) + border := lipgloss.Color("214") + if p.tier == toolkit.TierOnChain { + border = lipgloss.Color("203") + } + var b strings.Builder + b.WriteString(bold.Render(title) + "\n\n") + b.WriteString(body + "\n\n") + if p.tier == toolkit.TierOnChain { + b.WriteString(errSt.Render("This broadcasts a transaction.") + " ") + } + b.WriteString("Do you want to proceed?\n") + for i, o := range p.options() { + prefix := " " + label := fmt.Sprintf("%d. %s", i+1, o.label) + if i == p.sel { + prefix = accentSt.Render("❯ ") + label = accentSt.Render(fmt.Sprintf("%d. ", i+1)) + o.label + } + b.WriteString(prefix + label + "\n") + } + box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(border).Width(w-2).Padding(0, 1) + return box.Render(strings.TrimRight(b.String(), "\n")) + "\n" +} + +// describe renders an approval request: a title and the detail worth +// showing (command, diff, tx document, reasons). +func describe(p *pendingApproval) (string, string) { + d := p.detail + var lines []string + shown := map[string]bool{toolkit.RuleHint: true} + str := func(k string) string { + shown[k] = true + if v, ok := d[k]; ok && v != nil { + return strings.TrimSpace(fmt.Sprint(v)) + } + return "" + } + title := "Approval needed" + switch { + case p.tier == toolkit.TierOnChain: + title = "Transaction" + lines = append(lines, p.prompt) + shown["doc"] = true + case str("command") != "": + title = "Bash command" + if h := str("host"); h != "" && h != "local" { + title += " on " + h + } + lines = append(lines, " "+bold.Render(str("command"))) + if desc := str("description"); desc != "" { + lines = append(lines, " "+dim.Render(desc)) + } + str("cwd") + case str("diff") != "": + title = "Edit " + str("path") + lines = append(lines, colorDiff(str("diff"))) + str("host") + case str("new_file") != "": + title = "Create " + str("path") + lines = append(lines, " "+str("new_file")) + str("host") + default: + lines = append(lines, p.prompt) + } + if why := str("why"); why != "" { + lines = append(lines, dim.Render("why: "+why)) + } + if notes := str("notes"); notes != "" { + lines = append(lines, warnSt.Render(notes)) + } + for k, v := range d { + if shown[k] || strings.HasPrefix(k, "_") { + continue + } + lines = append(lines, dim.Render(k+": ")+oneLine(fmt.Sprint(v), 200)) + } + return title, strings.Join(lines, "\n") +} + +// --- rendering helpers ------------------------------------------------------- + +func (m *model) banner() string { + scope := "general mode — shell, files and web on this machine" + if m.a.Node() { + p := m.a.Ctx.Profile + scope = fmt.Sprintf("node mode — %s (%s, %s)", p.Name, p.ChainID, orDefault(p.Transport.Type, "local")) + } + cwd := m.a.WorkRoot + if home, _ := os.UserHomeDir(); home != "" && strings.HasPrefix(cwd, home) { + cwd = "~" + strings.TrimPrefix(cwd, home) + } + inner := accentSt.Render("✻") + " " + bold.Render("cometcli") + "\n\n" + + dim.Render(" "+scope) + "\n" + + dim.Render(" "+m.a.Provider.Name()+"/"+m.a.Model+" · "+cwd) + box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(0, 1).Render(inner) + out := []string{box, dim.Render(" /help for commands · ! runs in your terminal · # remembers · @ attaches a file · shift+tab changes mode"), ""} + for _, n := range m.notes { + out = append(out, result(dim.Render(n))) + } + if h := m.a.History(); len(h) > 0 { + // resumed: show where we left off + for i := len(h) - 1; i >= 0; i-- { + if h[i].Role == "assistant" && strings.TrimSpace(h[i].Text) != "" { + out = append(out, dim.Render(" last answer:"), accentSt.Render("● ")+strings.TrimLeft(m.markdown(h[i].Text), "\n ")) + break + } + } + } + return strings.Join(out, "\n") +} + +// markdown renders assistant text with no outer margin; lines after the +// first are indented to sit under the "● " bullet. +func (m *model) markdown(s string) string { + w := min(max(40, m.width-4), 120) + if m.md == nil || m.mdWidth != w { + cfg := styles.DarkStyleConfig + if m.mdStyle == "light" { + cfg = styles.LightStyleConfig + } + zero := uint(0) + cfg.Document.Margin = &zero + cfg.Document.BlockPrefix, cfg.Document.BlockSuffix = "", "" + if r, err := glamour.NewTermRenderer(glamour.WithStyles(cfg), glamour.WithWordWrap(w)); err == nil { + m.md, m.mdWidth = r, w + } + } + out := s + if m.md != nil { + if r, err := m.md.Render(s); err == nil { + out = r + } + } + lines := strings.Split(strings.Trim(out, "\n"), "\n") + for i := 1; i < len(lines); i++ { + lines[i] = " " + lines[i] + } + return strings.Join(lines, "\n") +} + +func userLine(s string) string { + lines := strings.Split(s, "\n") + for i, l := range lines { + p := " " + if i == 0 { + p = "> " + } + lines[i] = userSt.Render(p + l + " ") + } + return "\n" + strings.Join(lines, "\n") +} + +// result indents text under the previous line with Claude Code's ⎿. +func result(s string) string { + if strings.TrimSpace(s) == "" { + return "" + } + lines := strings.Split(s, "\n") + for i, l := range lines { + if i == 0 { + lines[i] = faint.Render(" ⎿ ") + l + } else { + lines[i] = " " + l + } + } + return strings.Join(lines, "\n") +} + +func toolHeader(e agent.Event) string { + a := e.Args + arg := func(keys ...string) string { + for _, k := range keys { + if v, ok := a[k]; ok && v != nil { + return oneLine(fmt.Sprint(v), 160) + } + } + return "" + } + name := e.Tool + var s string + switch strings.TrimPrefix(name, "↳ ") { + case "bash": + s = arg("command") + case "read", "write", "edit": + s = arg("file_path") + case "glob", "grep": + s = arg("pattern") + if p := arg("path"); p != "" { + s += " in " + p + } + case "web_fetch": + s = arg("url") + case "task": + s = arg("description") + default: + s = agent.CompactArgs(a) + } + label := toolLabel(name) + if s == "" { + return bold.Render(label) + } + return bold.Render(label) + "(" + s + ")" +} + +func toolLabel(name string) string { + prefix := "" + if strings.HasPrefix(name, "↳ ") { + prefix, name = "↳ ", strings.TrimPrefix(name, "↳ ") + } + switch name { + case "bash": + name = "Bash" + case "read": + name = "Read" + case "write": + name = "Write" + case "edit": + name = "Update" + case "glob": + name = "Search" + case "grep": + name = "Grep" + case "web_fetch": + name = "Fetch" + case "task": + name = "Task" + case "tool_search": + name = "Load tools" + default: + if strings.HasPrefix(name, "mcp__") { + srv, tool, _ := strings.Cut(strings.TrimPrefix(name, "mcp__"), "__") + name = srv + " · " + tool + " (MCP)" + } + } + return prefix + name +} + +// colorDiff colors +/- lines of a diff; other text passes through. +func colorDiff(s string) string { + lines := strings.Split(s, "\n") + for i, l := range lines { + switch { + case strings.HasPrefix(l, "+ ") || l == "+": + lines[i] = addSt.Render(l) + case strings.HasPrefix(l, "- ") || l == "-": + lines[i] = delSt.Render(l) + case strings.HasPrefix(l, "--- "): + lines[i] = dim.Render(l) + } + } + return strings.Join(lines, "\n") +} + +func todoLines(ts []agent.Todo) string { + var out []string + for _, t := range ts { + switch t.Status { + case "completed": + out = append(out, okSt.Render("☒ ")+faint.Strikethrough(true).Render(t.Content)) + case "in_progress": + out = append(out, accentSt.Render("☐ ")+bold.Render(t.Content)) + default: + out = append(out, "☐ "+t.Content) + } + } + return strings.Join(out, "\n") +} + +var verbs = []string{"Working", "Investigating", "Checking", "Digging", "Reasoning", "Inspecting"} + +func working(start time.Time) string { + return verbs[int(time.Since(start)/(6*time.Second))%len(verbs)] + "…" +} + +func lastLines(s string, n int) string { + lines := strings.Split(s, "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} + +func oneLine(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + if n > 3 && len(s) > n { + s = s[:n-1] + "…" + } + return s +} + +func humanTok(n int) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1e6) + case n >= 1000: + return fmt.Sprintf("%.1fk", float64(n)/1e3) + } + return strconv.Itoa(n) +} + +func orDefault(s, d string) string { + if s == "" { + return d + } + return s +} diff --git a/internal/tui/chatui/chatui_test.go b/internal/tui/chatui/chatui_test.go new file mode 100644 index 0000000..72acb8a --- /dev/null +++ b/internal/tui/chatui/chatui_test.go @@ -0,0 +1,216 @@ +package chatui + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/abhijitkrm/cometcli/internal/agent" + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/redact" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +type fakeProvider struct{} + +func (fakeProvider) Name() string { return "fake" } +func (fakeProvider) Chat(context.Context, *agent.Request) (*agent.Response, error) { + return &agent.Response{Text: "ok", Done: true}, nil +} + +func newTestModel(t *testing.T) (*model, string) { + t.Helper() + home := t.TempDir() + t.Setenv("COMETCLI_HOME", home) + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "config.toml"), []byte("x"), 0o644) + os.MkdirAll(filepath.Join(dir, "upgrade"), 0o755) + os.WriteFile(filepath.Join(dir, "upgrade", "vote-upgrade.sh"), []byte("x"), 0o755) + a := &agent.Agent{ + Provider: fakeProvider{}, Model: "m", Reg: toolkit.NewRegistry(), + Ctx: &toolkit.Context{Context: context.Background(), Cfg: &config.Config{}}, + MaxIter: 4, Redact: redact.NewRedactor(), Tools: toolkit.NewSession(dir), WorkRoot: dir, + } + m := newModel(Options{Agent: a}) + m.width = 100 + return m, dir +} + +func typeText(m *model, s string) { + for _, r := range s { + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } +} + +func key(m *model, k tea.KeyType) tea.Cmd { + _, cmd := m.Update(tea.KeyMsg{Type: k}) + return cmd +} + +func TestApprovalYesAlwaysNo(t *testing.T) { + m, _ := newTestModel(t) + var got tea.Msg + m.send = func(msg tea.Msg) { got = msg } + ctx := &toolkit.Context{Context: context.Background()} + + ask := func(detail map[string]any, tier toolkit.Tier) chan approvalResult { + done := make(chan approvalResult, 1) + got = nil + go func() { + ok, err := m.approve(ctx, "run: docker compose up -d", tier, detail) + done <- approvalResult{ok, err} + }() + for i := 0; got == nil && i < 200; i++ { + time.Sleep(5 * time.Millisecond) + } + m.Update(got) + return done + } + + // yes + done := ask(map[string]any{"command": "docker compose up -d", toolkit.RuleHint: "bash(docker compose:*)"}, toolkit.TierLocalChange) + title, body := describe(m.approval) + if title != "Bash command" || !strings.Contains(body, "docker compose up -d") || strings.Contains(body, "_rule") { + t.Fatalf("dialog: %q %q", title, body) + } + if len(m.approval.options()) != 3 { + t.Fatal("expected yes / always / no") + } + key(m, tea.KeyEnter) + if r := <-done; !r.ok || m.approval != nil { + t.Fatalf("yes: %+v", r) + } + + // always: rule added and saved + done = ask(map[string]any{"command": "docker compose up -d", toolkit.RuleHint: "bash(docker compose:*)"}, toolkit.TierLocalChange) + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) + if r := <-done; !r.ok { + t.Fatal("always should approve") + } + if d, _ := m.a.Rules.Decide(toolkit.Request{Tool: "bash", Kind: "command", Specs: []string{"docker compose down"}}); d != toolkit.DecideAllow { + t.Fatal("rule not added to the session") + } + b, _ := os.ReadFile(filepath.Join(os.Getenv("COMETCLI_HOME"), "settings.json")) + if !strings.Contains(string(b), "bash(docker compose:*)") { + t.Fatalf("rule not saved: %s", b) + } + + // a transaction never offers "don't ask again"; esc declines + done = ask(map[string]any{toolkit.RuleHint: "val.unjail"}, toolkit.TierOnChain) + if len(m.approval.options()) != 2 { + t.Fatalf("tx options = %d", len(m.approval.options())) + } + cancelled := false + m.cancel = func() { cancelled = true } + key(m, tea.KeyEsc) + if r := <-done; r.ok || r.err == nil || !cancelled { + t.Fatalf("esc should decline and interrupt: %+v cancelled=%v", r, cancelled) + } +} + +func TestSlashAndFileSuggestions(t *testing.T) { + m, _ := newTestModel(t) + typeText(m, "/com") + if len(m.sugg) == 0 || m.sugg[0].insert != "/compact" { + t.Fatalf("slash sugg = %+v", m.sugg) + } + key(m, tea.KeyTab) + if m.ta.Value() != "/compact " { + t.Fatalf("tab completion = %q", m.ta.Value()) + } + m.ta.Reset() + typeText(m, "look at @vote") + if len(m.sugg) != 1 || m.sugg[0].insert != "@upgrade/vote-upgrade.sh" { + t.Fatalf("file sugg = %+v", m.sugg) + } + key(m, tea.KeyEnter) // enter accepts a file suggestion, doesn't submit + if m.ta.Value() != "look at @upgrade/vote-upgrade.sh " || m.running { + t.Fatalf("after enter: %q running=%v", m.ta.Value(), m.running) + } +} + +func TestHistoryModeCycleAndQuit(t *testing.T) { + m, _ := newTestModel(t) + m.history = []string{"first", "second"} + key(m, tea.KeyUp) + if m.ta.Value() != "second" { + t.Fatalf("up = %q", m.ta.Value()) + } + key(m, tea.KeyUp) + key(m, tea.KeyDown) + if m.ta.Value() != "second" { + t.Fatalf("down = %q", m.ta.Value()) + } + key(m, tea.KeyDown) + if m.ta.Value() != "" { + t.Fatalf("draft not restored: %q", m.ta.Value()) + } + for _, want := range []agent.Mode{agent.ModeAcceptEdits, agent.ModeReadOnly, agent.ModeOps} { + key(m, tea.KeyShiftTab) + if m.a.Policy.Mode != want { + t.Fatalf("mode = %s, want %s", m.a.Policy.Mode, want) + } + } + if !strings.Contains(m.footer(100), "? for shortcuts") { + t.Fatal("footer") + } + if cmd := key(m, tea.KeyCtrlC); cmd != nil || m.hint == "" { + t.Fatal("first ctrl+c should arm, not quit") + } + if cmd := key(m, tea.KeyCtrlC); cmd == nil { + t.Fatal("second ctrl+c should quit") + } +} + +func TestEventRendering(t *testing.T) { + m, _ := newTestModel(t) + m.onEvent(agent.Event{Kind: agent.EvToolStart, Tool: "bash", Args: map[string]any{"command": "docker ps"}}) + m.onEvent(agent.Event{Kind: agent.EvToolResult, Tool: "bash", Output: "primium-validator0\nprimium-validator1"}) + m.onEvent(agent.Event{Kind: agent.EvToolResult, Tool: "edit", Output: "--- f\n- a = 1\n+ a = 2"}) + m.onEvent(agent.Event{Kind: agent.EvToolStart, Tool: "mcp__grafana__query", Args: map[string]any{}}) + m.onEvent(agent.Event{Kind: agent.EvTodos, Todos: []agent.Todo{{Content: "stop", Status: "completed"}, {Content: "swap", Status: "in_progress"}}}) + out := strings.Join(m.printed, "\n") + for _, want := range []string{"Bash", "(docker ps)", "⎿", "primium-validator1", "+ a = 2", "grafana · query (MCP)", "swap"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } + m.running = true + m.onEvent(agent.Event{Kind: agent.EvDelta, Text: "streaming…"}) + if !strings.Contains(m.View(), "streaming…") { + t.Fatal("live text not in view") + } + m.onDone(doneMsg{err: context.Canceled}) + if !strings.Contains(m.printed[len(m.printed)-1], "Interrupted") || m.running { + t.Fatal("interrupt not shown") + } + m.onDone(doneMsg{err: errors.New("groq: HTTP 429")}) + if !strings.Contains(m.printed[len(m.printed)-1], "429") { + t.Fatal("error not shown") + } +} + +func TestShellCommandLocalAndSSH(t *testing.T) { + m, dir := newTestModel(t) + c := m.shellCmd("./upgrade/vote-upgrade.sh") + if c.Dir != dir || c.Args[len(c.Args)-1] != "./upgrade/vote-upgrade.sh" { + t.Fatalf("local cmd = %v in %s", c.Args, c.Dir) + } + m.a.Ctx.Profile = &config.Profile{Name: "val02", Transport: config.Transport{Type: "ssh", Host: "10.0.0.5", User: "ops", Port: 2222}} + m.a.Tools.SetCwd("/srv/node") + c = m.shellCmd("./vote.sh") + got := strings.Join(c.Args, " ") + if got != "ssh -t -p 2222 ops@10.0.0.5 cd '/srv/node' && ./vote.sh" { + t.Fatalf("ssh cmd = %q", got) + } + m.onExecDone(execDoneMsg{cmd: "./vote.sh", code: 0}) + if !strings.Contains(m.printed[len(m.printed)-1], "the agent will know") { + t.Fatal("exec result not shown") + } +} From 62e783ea3ac56ee984ed0e9438ce448b5fa87f99 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 18:53:49 +0530 Subject: [PATCH 09/48] tx: fix every transaction being signed with sequence 0 first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Options.ForceSeq was documented as ">=0 overrides the queried sequence", but its zero value is 0 and no caller set -1, so Build always signed with sequence 0. Ordinary transactions only worked because simulation failed with "expected N, got 0" and the single automatic retry used the right sequence — costing an extra round trip and a spurious "sequence drifted" message on every tx. Two cases were genuinely broken: offline signing with --seq always signed sequence 0 (pinned accounts are never retried), and a real sequence drift could not heal because the bug had already spent the one retry. The override is now an unexported pointer set via ForceSequence(n), so the zero Options means "use the chain's sequence". Found by the new fakenode test harness (internal/testutil/fakenode): an in-process cosmos gRPC node that decodes every broadcast, verifies the secp256k1 signature over the SIGN_MODE_DIRECT sign doc (keccak for eth_secp256k1, sha256 for secp256k1) and enforces sequences with the SDK's error text. New tests cover both key types, fee/gas math, the approval gate (denied txs never reach the node; on-chain is never auto-approved), drift healing with re-approval, pinned sequences, CheckTx rejection, in-block failure, unconfirmed-as-pending, wrong chain-id, and DecScaled. Co-Authored-By: Claude Opus 5.5 --- internal/testutil/fakenode/fakenode.go | 378 ++++++++++++++++++++++++ internal/tools/common/broadcast_test.go | 168 +++++++++++ internal/tools/common/common.go | 6 +- internal/tx/tx.go | 28 +- 4 files changed, 571 insertions(+), 9 deletions(-) create mode 100644 internal/testutil/fakenode/fakenode.go create mode 100644 internal/tools/common/broadcast_test.go diff --git a/internal/testutil/fakenode/fakenode.go b/internal/testutil/fakenode/fakenode.go new file mode 100644 index 0000000..20abef4 --- /dev/null +++ b/internal/testutil/fakenode/fakenode.go @@ -0,0 +1,378 @@ +// Package fakenode is an in-process stand-in for a cosmos-evm node's gRPC +// API, for tests of the transaction pipeline and node tools. It is +// strict where it matters: every broadcast transaction is decoded, its +// secp256k1 signature verified over the SIGN_MODE_DIRECT sign doc +// (keccak256 for eth_secp256k1 keys, sha256 for cosmos secp256k1), and +// its sequence checked, returning the SDK's own error text on mismatch. +package fakenode + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "strings" + "sync" + "testing" + + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "golang.org/x/crypto/sha3" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" + + authv1beta1 "cosmossdk.io/api/cosmos/auth/v1beta1" + abciv1beta1 "cosmossdk.io/api/cosmos/base/abci/v1beta1" + secp256k1api "cosmossdk.io/api/cosmos/crypto/secp256k1" + distv1beta1 "cosmossdk.io/api/cosmos/distribution/v1beta1" + govv1 "cosmossdk.io/api/cosmos/gov/v1" + slashingv1beta1 "cosmossdk.io/api/cosmos/slashing/v1beta1" + stakingv1beta1 "cosmossdk.io/api/cosmos/staking/v1beta1" + txv1beta1 "cosmossdk.io/api/cosmos/tx/v1beta1" + + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/keys" +) + +// Tx is a transaction the node accepted to its mempool. +type Tx struct { + Hash string + Seq uint64 + Msgs []*anypb.Any + Memo string + Fee *txv1beta1.Fee +} + +// Node is the fake. Configure its fields before or between calls; all +// access is mutex-guarded. +type Node struct { + Addr string // host:port to dial + ChainID string + + mu sync.Mutex + // account state + AccNum uint64 + Seq uint64 // the chain's next expected sequence + // StaleSeq, when set, is what account queries report (a tx pending in + // the mempool makes the query lag the CheckTx state). + StaleSeq *uint64 + // SimGas is what Simulate reports. + SimGas uint64 + // CheckCode/CheckLog reject at CheckTx; DeliverCode/DeliverLog fail + // in the block. NeverCommit makes GetTx return NotFound forever. + CheckCode uint32 + CheckLog string + DeliverCode uint32 + DeliverLog string + NeverCommit bool + // Validator, SigningInfo and Proposals back the staking/slashing/gov + // queries tools make. + Validator *stakingv1beta1.Validator + SigningInfo *slashingv1beta1.ValidatorSigningInfo + Window int64 + Proposals []*govv1.Proposal + + Simulations int + Broadcasts []Tx // accepted to the mempool, in order + Rejected []string // raw logs of rejected broadcasts + committed map[string]*abciv1beta1.TxResponse + height int64 + srv *grpc.Server +} + +// Start runs a fake node for the test's lifetime. +func Start(t testing.TB, chainID string) *Node { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + n := &Node{Addr: ln.Addr().String(), ChainID: chainID, AccNum: 7, SimGas: 100_000, Window: 10_000, + committed: map[string]*abciv1beta1.TxResponse{}, height: 1000} + n.srv = grpc.NewServer() + authv1beta1.RegisterQueryServer(n.srv, &authSrv{n: n}) + txv1beta1.RegisterServiceServer(n.srv, &txSrv{n: n}) + stakingv1beta1.RegisterQueryServer(n.srv, &stakingSrv{n: n}) + slashingv1beta1.RegisterQueryServer(n.srv, &slashingSrv{n: n}) + distv1beta1.RegisterQueryServer(n.srv, &distSrv{}) + govv1.RegisterQueryServer(n.srv, &govSrv{n: n}) + go func() { _ = n.srv.Serve(ln) }() + t.Cleanup(n.srv.Stop) + return n +} + +// Set mutates the node under its lock. +func (n *Node) Set(fn func(n *Node)) { + n.mu.Lock() + defer n.mu.Unlock() + fn(n) +} + +// Snapshot returns copies of what the node saw. +func (n *Node) Snapshot() (broadcasts []Tx, rejected []string, sims int) { + n.mu.Lock() + defer n.mu.Unlock() + return append([]Tx(nil), n.Broadcasts...), append([]string(nil), n.Rejected...), n.Simulations +} + +// --- auth ------------------------------------------------------------------ + +type authSrv struct { + authv1beta1.UnimplementedQueryServer + n *Node +} + +func (s *authSrv) Account(_ context.Context, r *authv1beta1.QueryAccountRequest) (*authv1beta1.QueryAccountResponse, error) { + s.n.mu.Lock() + defer s.n.mu.Unlock() + seq := s.n.Seq + if s.n.StaleSeq != nil { + seq = *s.n.StaleSeq + } + b, _ := proto.Marshal(&authv1beta1.BaseAccount{Address: r.Address, AccountNumber: s.n.AccNum, Sequence: seq}) + return &authv1beta1.QueryAccountResponse{Account: &anypb.Any{TypeUrl: "/cosmos.auth.v1beta1.BaseAccount", Value: b}}, nil +} + +// --- tx -------------------------------------------------------------------- + +type txSrv struct { + txv1beta1.UnimplementedServiceServer + n *Node +} + +// decoded is a parsed and verified transaction. +type decoded struct { + body *txv1beta1.TxBody + auth *txv1beta1.AuthInfo + seq uint64 +} + +// verify decodes raw tx bytes and checks the signature against the sign +// doc the chain would build. It does not check the sequence. +func (n *Node) verify(raw []byte, accNum uint64) (*decoded, error) { + var tr txv1beta1.TxRaw + if err := proto.Unmarshal(raw, &tr); err != nil { + return nil, fmt.Errorf("tx parse error: %w", err) + } + var body txv1beta1.TxBody + var ai txv1beta1.AuthInfo + if err := proto.Unmarshal(tr.BodyBytes, &body); err != nil { + return nil, fmt.Errorf("body parse error: %w", err) + } + if err := proto.Unmarshal(tr.AuthInfoBytes, &ai); err != nil { + return nil, fmt.Errorf("auth info parse error: %w", err) + } + for _, m := range body.Messages { + if !strings.HasPrefix(m.TypeUrl, "/") || strings.Contains(m.TypeUrl, "googleapis") { + return nil, fmt.Errorf("unable to resolve type URL %s", m.TypeUrl) + } + } + if len(ai.SignerInfos) != 1 || len(tr.Signatures) != 1 || len(tr.Signatures[0]) != 64 { + return nil, fmt.Errorf("expected one signer with a 64-byte signature") + } + si := ai.SignerInfos[0] + if si.ModeInfo.GetSingle().GetMode().String() != "SIGN_MODE_DIRECT" { + return nil, fmt.Errorf("unsupported sign mode") + } + var pubBytes []byte + keccak := false + switch si.PublicKey.GetTypeUrl() { + case "/cosmos.crypto.secp256k1.PubKey": + var pk secp256k1api.PubKey + if err := proto.Unmarshal(si.PublicKey.Value, &pk); err != nil { + return nil, err + } + pubBytes = pk.Key + case "/cosmos.evm.crypto.v1.ethsecp256k1.PubKey": + pubBytes = fieldBytes(si.PublicKey.Value, 1) + keccak = true + default: + return nil, fmt.Errorf("unknown pubkey type %q", si.PublicKey.GetTypeUrl()) + } + pub, err := secp256k1.ParsePubKey(pubBytes) + if err != nil { + return nil, fmt.Errorf("bad pubkey: %w", err) + } + sd, _ := proto.Marshal(&txv1beta1.SignDoc{BodyBytes: tr.BodyBytes, AuthInfoBytes: tr.AuthInfoBytes, ChainId: n.ChainID, AccountNumber: accNum}) + var h []byte + if keccak { + k := sha3.NewLegacyKeccak256() + k.Write(sd) + h = k.Sum(nil) + } else { + s := sha256.Sum256(sd) + h = s[:] + } + var r, s secp256k1.ModNScalar + r.SetByteSlice(tr.Signatures[0][:32]) + s.SetByteSlice(tr.Signatures[0][32:]) + if !ecdsa.NewSignature(&r, &s).Verify(h, pub) { + return nil, fmt.Errorf("signature verification failed; please verify account number (%d) and chain-id (%s): unauthorized", accNum, n.ChainID) + } + return &decoded{body: &body, auth: &ai, seq: si.Sequence}, nil +} + +func (s *txSrv) Simulate(_ context.Context, r *txv1beta1.SimulateRequest) (*txv1beta1.SimulateResponse, error) { + n := s.n + n.mu.Lock() + defer n.mu.Unlock() + n.Simulations++ + d, err := n.verify(r.TxBytes, n.AccNum) + if err != nil { + return nil, status.Error(codes.Unknown, err.Error()) + } + if d.seq != n.Seq { + return nil, status.Error(codes.Unknown, fmt.Sprintf("account sequence mismatch, expected %d, got %d: incorrect account sequence", n.Seq, d.seq)) + } + return &txv1beta1.SimulateResponse{GasInfo: &abciv1beta1.GasInfo{GasUsed: n.SimGas, GasWanted: n.SimGas}}, nil +} + +func (s *txSrv) BroadcastTx(_ context.Context, r *txv1beta1.BroadcastTxRequest) (*txv1beta1.BroadcastTxResponse, error) { + n := s.n + n.mu.Lock() + defer n.mu.Unlock() + sum := sha256.Sum256(r.TxBytes) + hash := strings.ToUpper(hex.EncodeToString(sum[:])) + reject := func(code uint32, log string) (*txv1beta1.BroadcastTxResponse, error) { + n.Rejected = append(n.Rejected, log) + return &txv1beta1.BroadcastTxResponse{TxResponse: &abciv1beta1.TxResponse{Txhash: hash, Code: code, RawLog: log}}, nil + } + d, err := n.verify(r.TxBytes, n.AccNum) + if err != nil { + return reject(4, err.Error()) + } + if d.seq != n.Seq { + return reject(32, fmt.Sprintf("account sequence mismatch, expected %d, got %d: incorrect account sequence", n.Seq, d.seq)) + } + if n.CheckCode != 0 { + return reject(n.CheckCode, n.CheckLog) + } + n.Seq++ + n.StaleSeq = nil + n.Broadcasts = append(n.Broadcasts, Tx{Hash: hash, Seq: d.seq, Msgs: d.body.Messages, Memo: d.body.Memo, Fee: d.auth.Fee}) + n.height++ + n.committed[hash] = &abciv1beta1.TxResponse{Txhash: hash, Height: n.height, Code: n.DeliverCode, RawLog: n.DeliverLog, GasUsed: int64(n.SimGas)} + return &txv1beta1.BroadcastTxResponse{TxResponse: &abciv1beta1.TxResponse{Txhash: hash}}, nil +} + +func (s *txSrv) GetTx(_ context.Context, r *txv1beta1.GetTxRequest) (*txv1beta1.GetTxResponse, error) { + n := s.n + n.mu.Lock() + defer n.mu.Unlock() + res, ok := n.committed[r.Hash] + if !ok || n.NeverCommit { + return nil, status.Error(codes.NotFound, "tx not found: "+r.Hash) + } + return &txv1beta1.GetTxResponse{TxResponse: res}, nil +} + +// --- staking / slashing / distribution / gov --------------------------------- + +type stakingSrv struct { + stakingv1beta1.UnimplementedQueryServer + n *Node +} + +func (s *stakingSrv) Validator(_ context.Context, r *stakingv1beta1.QueryValidatorRequest) (*stakingv1beta1.QueryValidatorResponse, error) { + s.n.mu.Lock() + defer s.n.mu.Unlock() + if s.n.Validator == nil || s.n.Validator.OperatorAddress != r.ValidatorAddr { + return nil, status.Error(codes.NotFound, "validator "+r.ValidatorAddr+" does not exist") + } + return &stakingv1beta1.QueryValidatorResponse{Validator: s.n.Validator}, nil +} + +type slashingSrv struct { + slashingv1beta1.UnimplementedQueryServer + n *Node +} + +func (s *slashingSrv) SigningInfo(_ context.Context, r *slashingv1beta1.QuerySigningInfoRequest) (*slashingv1beta1.QuerySigningInfoResponse, error) { + s.n.mu.Lock() + defer s.n.mu.Unlock() + if s.n.SigningInfo == nil { + return nil, status.Error(codes.NotFound, "no signing info for "+r.ConsAddress) + } + return &slashingv1beta1.QuerySigningInfoResponse{ValSigningInfo: s.n.SigningInfo}, nil +} + +func (s *slashingSrv) Params(context.Context, *slashingv1beta1.QueryParamsRequest) (*slashingv1beta1.QueryParamsResponse, error) { + s.n.mu.Lock() + defer s.n.mu.Unlock() + return &slashingv1beta1.QueryParamsResponse{Params: &slashingv1beta1.Params{SignedBlocksWindow: s.n.Window, MinSignedPerWindow: []byte("500000000000000000")}}, nil +} + +type distSrv struct { + distv1beta1.UnimplementedQueryServer +} + +type govSrv struct { + govv1.UnimplementedQueryServer + n *Node +} + +func (s *govSrv) Proposals(context.Context, *govv1.QueryProposalsRequest) (*govv1.QueryProposalsResponse, error) { + s.n.mu.Lock() + defer s.n.mu.Unlock() + return &govv1.QueryProposalsResponse{Proposals: s.n.Proposals}, nil +} + +func (s *govSrv) Vote(_ context.Context, r *govv1.QueryVoteRequest) (*govv1.QueryVoteResponse, error) { + return nil, status.Error(codes.NotFound, fmt.Sprintf("voter %s has not voted on proposal %d", r.Voter, r.ProposalId)) +} + +func fieldBytes(b []byte, num protowire.Number) []byte { + for len(b) > 0 { + n, typ, l := protowire.ConsumeTag(b) + if l < 0 { + return nil + } + b = b[l:] + if typ == protowire.BytesType { + v, l := protowire.ConsumeBytes(b) + if l < 0 { + return nil + } + if n == num { + return v + } + b = b[l:] + continue + } + l = protowire.ConsumeFieldValue(n, typ, b) + if l < 0 { + return nil + } + b = b[l:] + } + return nil +} + +// Profile builds a profile pointed at the node with an ops key in a file +// keyring under a temp COMETCLI_HOME. algo is "eth_secp256k1" or +// "secp256k1". +func (n *Node) Profile(t testing.TB, algo string) *config.Profile { + t.Helper() + t.Setenv("COMETCLI_HOME", t.TempDir()) + t.Setenv("COMETCLI_KEYRING_PASSWORD", "test-password") + p := &config.Profile{ + Name: "fake", ChainID: n.ChainID, Bech32Prefix: "cosmos", Role: "validator", + Endpoints: config.Endpoints{GRPC: n.Addr}, + Signer: config.Signer{Backend: "file", Key: "ops"}, + Metadata: map[string]string{"fee_denom": "adex", "gas_price": "1000000000"}, + } + ring, err := keys.Open(p) + if err != nil { + t.Fatal(err) + } + // a fixed test key — never use outside tests + if _, err := ring.ImportHex("ops", "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318", keys.Algo(algo)); err != nil { + t.Fatal(err) + } + return p +} diff --git a/internal/tools/common/broadcast_test.go b/internal/tools/common/broadcast_test.go new file mode 100644 index 0000000..5f847f1 --- /dev/null +++ b/internal/tools/common/broadcast_test.go @@ -0,0 +1,168 @@ +package common + +import ( + "context" + "strings" + "testing" + "time" + + bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1" + basev1beta1 "cosmossdk.io/api/cosmos/base/v1beta1" + "google.golang.org/protobuf/proto" + + "github.com/abhijitkrm/cometcli/internal/testutil/fakenode" + "github.com/abhijitkrm/cometcli/internal/toolkit" + "github.com/abhijitkrm/cometcli/internal/tx" +) + +func setup(t *testing.T, algo string) (*fakenode.Node, *toolkit.Context, *[]string) { + t.Helper() + tx.ConfirmTimeout, tx.ConfirmPoll = 300*time.Millisecond, 20*time.Millisecond + n := fakenode.Start(t, "primium-1") + p := n.Profile(t, algo) + var asked []string + c := &toolkit.Context{Context: context.Background(), Profile: p, AutoApproveBelow: toolkit.TierOnChain, + Approver: func(_ *toolkit.Context, prompt string, tier toolkit.Tier, _ map[string]any) (bool, error) { + if tier != toolkit.TierOnChain { + t.Errorf("tx approval asked at tier %s", tier) + } + asked = append(asked, prompt) + return true, nil + }} + t.Cleanup(c.Close) + return n, c, &asked +} + +func send(to string) tx.Msgs { + return tx.Msgs{&bankv1beta1.MsgSend{FromAddress: "x", ToAddress: to, Amount: []*basev1beta1.Coin{{Denom: "adex", Amount: "5"}}}} +} + +func TestBroadcastHappyPathBothKeyTypes(t *testing.T) { + for _, algo := range []string{"eth_secp256k1", "secp256k1"} { + t.Run(algo, func(t *testing.T) { + n, c, asked := setup(t, algo) + n.Set(func(n *fakenode.Node) { n.Seq = 3 }) + res, err := BroadcastMsgs(c, send("cosmos1dest"), "memo-1", map[string]string{"why": "test"}, tx.Options{}) + if err != nil { + t.Fatal(err) + } + bs, rej, sims := n.Snapshot() + if len(bs) != 1 || len(rej) != 0 || sims != 1 || len(*asked) != 1 { + t.Fatalf("broadcasts=%d rejected=%v sims=%d asked=%d", len(bs), rej, sims, len(*asked)) + } + b := bs[0] + if b.Seq != 3 || b.Memo != "memo-1" || b.Msgs[0].TypeUrl != "/cosmos.bank.v1beta1.MsgSend" { + t.Fatalf("tx = %+v", b) + } + var m bankv1beta1.MsgSend + proto.Unmarshal(b.Msgs[0].Value, &m) + if m.ToAddress != "cosmos1dest" || m.Amount[0].Amount != "5" { + t.Fatalf("msg = %+v", &m) + } + // gas = simulated 100000 × 1.4; fee = gas × 1e9 + if b.Fee.GasLimit != 140000 || b.Fee.Amount[0].Amount != "140000000000000" || b.Fee.Amount[0].Denom != "adex" { + t.Fatalf("fee = %+v", b.Fee) + } + if !strings.Contains((*asked)[0], `"sequence": 3`) || res.Data["height"] == nil || res.Data["code"] != uint32(0) { + t.Fatalf("approval doc / result: %q %v", (*asked)[0], res.Data) + } + }) + } +} + +func TestBroadcastDeniedNeverReachesTheNode(t *testing.T) { + n, c, _ := setup(t, "eth_secp256k1") + c.Approver = func(*toolkit.Context, string, toolkit.Tier, map[string]any) (bool, error) { return false, nil } + if _, err := BroadcastMsgs(c, send("cosmos1dest"), "", nil, tx.Options{}); err == nil { + t.Fatal("denied tx returned no error") + } + if bs, _, _ := n.Snapshot(); len(bs) != 0 { + t.Fatal("a denied transaction was broadcast") + } + // even with every lower tier autopiloted + c.AutoApproveBelow = toolkit.TierOnChain + if _, err := BroadcastMsgs(c, send("cosmos1dest"), "", nil, tx.Options{}); err == nil { + t.Fatal("on-chain must never be auto-approved") + } +} + +func TestSequenceDriftHealsOnceWithReapproval(t *testing.T) { + n, c, asked := setup(t, "eth_secp256k1") + stale := uint64(4) + // the account query lags (a tx is pending): simulation and broadcast + // see the real next sequence 5 + n.Set(func(n *fakenode.Node) { n.Seq, n.StaleSeq = 5, &stale }) + if _, err := BroadcastMsgs(c, send("cosmos1dest"), "", nil, tx.Options{GasLimit: 200000}); err != nil { + t.Fatal(err) + } + bs, rej, _ := n.Snapshot() + if len(bs) != 1 || bs[0].Seq != 5 || len(rej) != 1 || !strings.Contains(rej[0], "expected 5, got 4") { + t.Fatalf("broadcasts=%+v rejected=%v", bs, rej) + } + if len(*asked) != 2 { + t.Fatalf("rebuilt tx must be re-approved: asked %d times", len(*asked)) + } +} + +func TestPinnedSequenceIsNeverRebuilt(t *testing.T) { + n, c, _ := setup(t, "eth_secp256k1") + n.Set(func(n *fakenode.Node) { n.Seq = 9 }) + _, err := BroadcastMsgs(c, send("cosmos1dest"), "", nil, tx.Options{GasLimit: 200000}.WithAccount(7, 8)) + if err == nil || !strings.Contains(err.Error(), "expected 9, got 8") { + t.Fatalf("err = %v", err) + } + if bs, rej, _ := n.Snapshot(); len(bs) != 0 || len(rej) != 1 { + t.Fatal("pinned seq retried") + } +} + +func TestCheckTxRejectionAndBlockFailure(t *testing.T) { + n, c, _ := setup(t, "eth_secp256k1") + n.Set(func(n *fakenode.Node) { n.CheckCode, n.CheckLog = 13, "insufficient fee" }) + if _, err := BroadcastMsgs(c, send("cosmos1dest"), "", nil, tx.Options{}); err == nil || !strings.Contains(err.Error(), "mempool rejected tx (code 13): insufficient fee") { + t.Fatalf("checktx: %v", err) + } + n.Set(func(n *fakenode.Node) { n.CheckCode, n.DeliverCode, n.DeliverLog = 0, 5, "insufficient funds" }) + if _, err := BroadcastMsgs(c, send("cosmos1dest"), "", nil, tx.Options{}); err == nil || !strings.Contains(err.Error(), "tx failed in block (code 5)") { + t.Fatalf("deliver: %v", err) + } +} + +func TestUnconfirmedIsReportedPendingNotFailed(t *testing.T) { + n, c, _ := setup(t, "eth_secp256k1") + n.Set(func(n *fakenode.Node) { n.NeverCommit = true }) + res, err := BroadcastMsgs(c, send("cosmos1dest"), "", nil, tx.Options{}) + if err != nil || res.Data["pending"] != true || !strings.Contains(res.Text, "cometcli tx get") { + t.Fatalf("res=%+v err=%v", res, err) + } +} + +func TestWrongChainIDFailsSignatureCheck(t *testing.T) { + n, c, _ := setup(t, "eth_secp256k1") + c.Profile.ChainID = "other-chain-1" // the key signs for the wrong chain + _, err := BroadcastMsgs(c, send("cosmos1dest"), "", nil, tx.Options{}) + if err == nil || !strings.Contains(err.Error(), "signature verification failed") { + t.Fatalf("err = %v", err) + } + if bs, _, _ := n.Snapshot(); len(bs) != 0 { + t.Fatal("bad signature accepted") + } +} + +func TestDecScaled(t *testing.T) { + cases := map[string]string{ + "0.10": "100000000000000000", "1": "1000000000000000000", "0.05": "50000000000000000", + "100000000000000000": "100000000000000000", // already scaled + } + for in, want := range cases { + if got, err := DecScaled(in); err != nil || got != want { + t.Errorf("DecScaled(%q) = %q, %v; want %q", in, got, err, want) + } + } + if _, err := DecScaled("0.0000000000000000001"); err == nil { + t.Error("19 decimals accepted") + } + if DecPct("100000000000000000") != "10" || DecFrac("50000000000000000") != "0.05" { + t.Error("DecPct/DecFrac") + } +} diff --git a/internal/tools/common/common.go b/internal/tools/common/common.go index 1b522ed..8c828d5 100644 --- a/internal/tools/common/common.go +++ b/internal/tools/common/common.go @@ -202,7 +202,7 @@ func BroadcastMsgs(c *toolkit.Context, msgs tx.Msgs, memo string, meta map[strin // retrySeq reports whether the failure is a sequence mismatch worth one // automatic rebuild — impossible when the caller pinned the seq explicitly, // and never retried more than once. On a healable mismatch it sets -// opt.ForceSeq to the chain's expected value so a mempool-pending tx +// a forced sequence (the chain's expected value) so a mempool-pending tx // doesn't leave the re-query returning the same stale seq. var expectedSeqRe = regexp.MustCompile(`expected (\d+)`) @@ -211,8 +211,8 @@ func retrySeq(log string, attempt int, opt *tx.Options) bool { return false } if m := expectedSeqRe.FindStringSubmatch(log); m != nil { - if n, err := strconv.ParseInt(m[1], 10, 64); err == nil { - opt.ForceSeq = n + if n, err := strconv.ParseUint(m[1], 10, 64); err == nil { + *opt = opt.ForceSequence(n) } } return true diff --git a/internal/tx/tx.go b/internal/tx/tx.go index f6b0c97..7acee9c 100644 --- a/internal/tx/tx.go +++ b/internal/tx/tx.go @@ -79,8 +79,17 @@ type Options struct { GasPrice string // decimal, e.g. "0.025" AccountNum uint64 Sequence uint64 - ForceSeq int64 // >=0 overrides the queried sequence (mempool heal) haveAccount bool + // forcedSeq overrides the queried sequence (mempool heal); nil = use + // the chain's. A pointer so the zero Options can't mean "sequence 0". + forcedSeq *uint64 +} + +// ForceSequence overrides the queried sequence — used to heal a mismatch +// with the value the chain reported it expects. +func (o Options) ForceSequence(seq uint64) Options { + o.forcedSeq = &seq + return o } // WithAccount pins the account number/sequence explicitly (offline signing, @@ -130,8 +139,8 @@ func (b *Builder) Build(ctx context.Context, msgs Msgs, opt Options) (*Built, er return nil, err } } - if opt.ForceSeq >= 0 { - seq = uint64(opt.ForceSeq) + if opt.forcedSeq != nil { + seq = *opt.forcedSeq } if opt.GasAdjust == 0 { opt.GasAdjust = 1.4 @@ -315,10 +324,17 @@ func (b *Builder) Broadcast(ctx context.Context, txBytes []byte) (hash string, c return r.Txhash, r.Code, r.RawLog, nil } +// ConfirmTimeout and ConfirmPoll bound how long Confirm waits for a tx to +// be committed (variables so tests can shorten them). +var ( + ConfirmTimeout = 30 * time.Second + ConfirmPoll = 1500 * time.Millisecond +) + // Confirm polls GetTx until the tx is committed (or ctx/timeout expires) and // returns the final on-chain result — the code that actually matters. func (b *Builder) Confirm(ctx context.Context, hash string) (*abciv1beta1.TxResponse, error) { - deadline := time.Now().Add(30 * time.Second) + deadline := time.Now().Add(ConfirmTimeout) var lastErr error for time.Now().Before(deadline) { res, err := b.GetTx(ctx, hash) @@ -329,10 +345,10 @@ func (b *Builder) Confirm(ctx context.Context, hash string) (*abciv1beta1.TxResp select { case <-ctx.Done(): return nil, ctx.Err() - case <-time.After(1500 * time.Millisecond): + case <-time.After(ConfirmPoll): } } - return nil, fmt.Errorf("tx %s not committed within 30s (last: %v)", hash, lastErr) + return nil, fmt.Errorf("tx %s not committed within %s (last: %v)", hash, ConfirmTimeout, lastErr) } // GetTx fetches a committed tx by hash. From 042e05224ac60b64767c69666d0ea9d3ecb15296 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 18:55:54 +0530 Subject: [PATCH 10/48] tools: strict coin amounts, validated votes and edits, nil-safe val.status Found by new tests running the tx tools against fakenode: - parseCoin accepted "1.5adex" (decimal amount), "1e18adex" (1 unit of denom "e18adex"), "10 adex", "0adex" and one-letter denoms. Amounts are now positive integers in base units with an SDK-valid denom, with specific messages for decimals and scientific notation. - val.vote broadcast VOTE_OPTION_UNSPECIFIED for an unknown or upper-case option and proposal 0 when the id was missing; options are now case-insensitive and validated, the id required. - val.edit with nothing to change broadcast a no-op edit (paying fees); it now refuses. - val.status dereferenced Commission/Description without nil checks. Tests: commission scaling + description preservation on edit, unjail, withdraw with commission, vote options, send message contents, input validation never reaching the node. Co-Authored-By: Claude Opus 5.5 --- internal/tools/txtool/tx.go | 25 +++-- internal/tools/txtool/tx_test.go | 54 +++++++++++ internal/tools/val/val.go | 26 ++++-- internal/tools/val/val_test.go | 154 +++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 internal/tools/txtool/tx_test.go create mode 100644 internal/tools/val/val_test.go diff --git a/internal/tools/txtool/tx.go b/internal/tools/txtool/tx.go index cf8f5af..218ec1d 100644 --- a/internal/tools/txtool/tx.go +++ b/internal/tools/txtool/tx.go @@ -3,6 +3,7 @@ package txtool import ( "fmt" + "regexp" "strings" bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1" @@ -207,13 +208,25 @@ func (redelegateTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, } // parseCoin parses "1000000atest" into a Coin. +// coinRe is an integer amount in base units followed by an SDK denom. +var ( + coinRe = regexp.MustCompile(`^([0-9]+)([a-zA-Z][a-zA-Z0-9/:._-]{2,127})$`) + decimRe = regexp.MustCompile(`^[0-9]*\.[0-9]+[a-zA-Z]`) +) + func parseCoin(s string) (*basev1beta1.Coin, error) { - i := 0 - for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') { - i++ + if decimRe.MatchString(s) { + return nil, fmt.Errorf("bad amount %q — amounts are integers in the base denom (with 18 decimals, 1.5 tokens is 1500000000000000000adex)", s) + } + m := coinRe.FindStringSubmatch(s) + if m == nil { + return nil, fmt.Errorf("bad amount %q — want , e.g. 1000000atest", s) + } + if len(m[2]) > 1 && (m[2][0] == 'e' || m[2][0] == 'E') && m[2][1] >= '0' && m[2][1] <= '9' { + return nil, fmt.Errorf("bad amount %q — scientific notation isn't supported; write the integer out", s) } - if i == 0 || i == len(s) { - return nil, fmt.Errorf("bad amount %q — want e.g. 1000000atest", s) + if strings.TrimLeft(m[1], "0") == "" { + return nil, fmt.Errorf("bad amount %q — must be greater than zero", s) } - return &basev1beta1.Coin{Amount: s[:i], Denom: s[i:]}, nil + return &basev1beta1.Coin{Amount: m[1], Denom: m[2]}, nil } diff --git a/internal/tools/txtool/tx_test.go b/internal/tools/txtool/tx_test.go new file mode 100644 index 0000000..214f2b5 --- /dev/null +++ b/internal/tools/txtool/tx_test.go @@ -0,0 +1,54 @@ +package txtool + +import ( + "context" + "testing" + "time" + + bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1" + "google.golang.org/protobuf/proto" + + "github.com/abhijitkrm/cometcli/internal/testutil/fakenode" + "github.com/abhijitkrm/cometcli/internal/toolkit" + "github.com/abhijitkrm/cometcli/internal/tx" +) + +func TestParseCoin(t *testing.T) { + good := map[string][2]string{ + "1000000atest": {"1000000", "atest"}, + "1500000000000000000adex": {"1500000000000000000", "adex"}, + "5ibc/27394FB092D2ECCD5612": {"5", "ibc/27394FB092D2ECCD5612"}, + } + for in, want := range good { + c, err := parseCoin(in) + if err != nil || c.Amount != want[0] || c.Denom != want[1] { + t.Errorf("parseCoin(%q) = %+v, %v", in, c, err) + } + } + for _, in := range []string{"", "adex", "100", "1.5adex", "1e18adex", "-5adex", "10 adex", "0adex", "5a"} { + if c, err := parseCoin(in); err == nil { + t.Errorf("parseCoin(%q) accepted: %+v", in, c) + } + } +} + +func TestSendBuildsMsgSend(t *testing.T) { + tx.ConfirmTimeout, tx.ConfirmPoll = 300*time.Millisecond, 20*time.Millisecond + n := fakenode.Start(t, "primium-1") + c := &toolkit.Context{Context: context.Background(), Profile: n.Profile(t, "eth_secp256k1"), + Approver: func(*toolkit.Context, string, toolkit.Tier, map[string]any) (bool, error) { return true, nil }} + defer c.Close() + if _, err := (sendTool{}).Run(c, toolkit.Args{"to": "cosmos1recipient", "amount": "42adex", "memo": "refill"}); err != nil { + t.Fatal(err) + } + bs, _, _ := n.Snapshot() + var m bankv1beta1.MsgSend + proto.Unmarshal(bs[0].Msgs[0].Value, &m) + tb, _ := c.Tx() + if m.FromAddress != tb.Address() || m.ToAddress != "cosmos1recipient" || m.Amount[0].Amount != "42" || bs[0].Memo != "refill" { + t.Fatalf("send = %+v memo=%q", &m, bs[0].Memo) + } + if _, err := (sendTool{}).Run(c, toolkit.Args{"to": "cosmos1recipient", "amount": "1.5adex"}); err == nil { + t.Fatal("decimal amount accepted") + } +} diff --git a/internal/tools/val/val.go b/internal/tools/val/val.go index 43d1a59..2ec6d17 100644 --- a/internal/tools/val/val.go +++ b/internal/tools/val/val.go @@ -60,13 +60,12 @@ func (statusTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, erro v := res.Validator var b strings.Builder fmt.Fprintf(&b, "validator: %s\n", valoper) - fmt.Fprintf(&b, "moniker: %s\n", v.Description.Moniker) + rates := v.GetCommission().GetCommissionRates() + fmt.Fprintf(&b, "moniker: %s\n", v.GetDescription().GetMoniker()) fmt.Fprintf(&b, "status: %s jailed: %v\n", bondStatus(v.Status), v.Jailed) fmt.Fprintf(&b, "tokens: %s\n", v.Tokens) fmt.Fprintf(&b, "commission: %s%% (max %s%%, max change %s%%)\n", - common.DecPct(string(v.Commission.CommissionRates.Rate)), - common.DecPct(string(v.Commission.CommissionRates.MaxRate)), - common.DecPct(string(v.Commission.CommissionRates.MaxChangeRate))) + common.DecPct(rates.GetRate()), common.DecPct(rates.GetMaxRate()), common.DecPct(rates.GetMaxChangeRate())) if cons, err := common.ConsAddress(c, valoper); err == nil { fmt.Fprintf(&b, "valcons: %s\n", cons) if si, err := g.Slashing.SigningInfo(c, &slashingv1beta1.QuerySigningInfoRequest{ConsAddress: cons}); err == nil { @@ -76,7 +75,7 @@ func (statusTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, erro } } return &toolkit.Result{Text: b.String(), Data: map[string]any{ - "valoper": valoper, "moniker": v.Description.Moniker, "jailed": v.Jailed, + "valoper": valoper, "moniker": v.GetDescription().GetMoniker(), "jailed": v.Jailed, "status": bondStatus(v.Status), "tokens": v.Tokens, }}, nil } @@ -305,6 +304,9 @@ func (editTool) Schema() map[string]any { func (editTool) Tier() toolkit.Tier { return toolkit.TierOnChain } func (editTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { + if a.String("commission-rate", "") == "" && a.String("moniker", "") == "" && a.String("min-self-delegation", "") == "" { + return nil, fmt.Errorf("nothing to change — pass commission-rate, moniker or min-self-delegation") + } valoper, err := common.Valoper(c, a) if err != nil { return nil, err @@ -359,19 +361,27 @@ func (voteTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) if err != nil { return nil, err } - opt := map[string]govv1.VoteOption{ + optName := strings.ToLower(strings.TrimSpace(a.String("option", ""))) + opt, ok := map[string]govv1.VoteOption{ "yes": govv1.VoteOption_VOTE_OPTION_YES, "no": govv1.VoteOption_VOTE_OPTION_NO, "abstain": govv1.VoteOption_VOTE_OPTION_ABSTAIN, "no_with_veto": govv1.VoteOption_VOTE_OPTION_NO_WITH_VETO, - }[a.String("option", "yes")] + "nowithveto": govv1.VoteOption_VOTE_OPTION_NO_WITH_VETO, + }[optName] + if !ok { + return nil, fmt.Errorf("option %q: want yes, no, abstain or no_with_veto", a.String("option", "")) + } + if a.Int("proposal", 0) <= 0 { + return nil, fmt.Errorf("proposal id is required") + } msg := &govv1.MsgVote{ ProposalId: uint64(a.Int("proposal", 0)), Voter: acct, Option: opt, } return common.BroadcastMsgs(c, tx.Msgs{msg}, a.String("memo", ""), - map[string]string{"action": "gov-vote", "proposal": fmt.Sprint(msg.ProposalId), "option": a.String("option", "yes")}, common.TxOpts(a)) + map[string]string{"action": "gov-vote", "proposal": fmt.Sprint(msg.ProposalId), "option": optName}, common.TxOpts(a)) } // ---- helpers ---- diff --git a/internal/tools/val/val_test.go b/internal/tools/val/val_test.go new file mode 100644 index 0000000..efa4b2e --- /dev/null +++ b/internal/tools/val/val_test.go @@ -0,0 +1,154 @@ +package val + +import ( + "context" + "strings" + "testing" + "time" + + distv1beta1 "cosmossdk.io/api/cosmos/distribution/v1beta1" + govv1 "cosmossdk.io/api/cosmos/gov/v1" + slashingv1beta1 "cosmossdk.io/api/cosmos/slashing/v1beta1" + stakingv1beta1 "cosmossdk.io/api/cosmos/staking/v1beta1" + "google.golang.org/protobuf/proto" + + "github.com/abhijitkrm/cometcli/internal/testutil/fakenode" + "github.com/abhijitkrm/cometcli/internal/toolkit" + "github.com/abhijitkrm/cometcli/internal/tx" +) + +func setup(t *testing.T) (*fakenode.Node, *toolkit.Context, string) { + t.Helper() + tx.ConfirmTimeout, tx.ConfirmPoll = 300*time.Millisecond, 20*time.Millisecond + n := fakenode.Start(t, "primium-1") + p := n.Profile(t, "eth_secp256k1") + c := &toolkit.Context{Context: context.Background(), Profile: p, AutoApproveBelow: toolkit.TierLocalChange, + Approver: func(*toolkit.Context, string, toolkit.Tier, map[string]any) (bool, error) { return true, nil }} + t.Cleanup(c.Close) + tb, err := c.Tx() + if err != nil { + t.Fatal(err) + } + valoper, _ := tb.ValAddress() + n.Set(func(n *fakenode.Node) { + n.Validator = &stakingv1beta1.Validator{OperatorAddress: valoper, + Description: &stakingv1beta1.Description{Moniker: "validator-01", Website: "https://primium.example", Details: "genesis validator"}} + }) + return n, c, valoper +} + +func lastMsgs(t *testing.T, n *fakenode.Node) []proto.Message { + t.Helper() + bs, rej, _ := n.Snapshot() + if len(bs) == 0 { + t.Fatalf("nothing broadcast (rejected: %v)", rej) + } + var out []proto.Message + for _, a := range bs[len(bs)-1].Msgs { + var m proto.Message + switch a.TypeUrl { + case "/cosmos.staking.v1beta1.MsgEditValidator": + m = &stakingv1beta1.MsgEditValidator{} + case "/cosmos.slashing.v1beta1.MsgUnjail": + m = &slashingv1beta1.MsgUnjail{} + case "/cosmos.gov.v1.MsgVote": + m = &govv1.MsgVote{} + case "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward": + m = &distv1beta1.MsgWithdrawDelegatorReward{} + case "/cosmos.distribution.v1beta1.MsgWithdrawValidatorCommission": + m = &distv1beta1.MsgWithdrawValidatorCommission{} + default: + t.Fatalf("unexpected msg %s", a.TypeUrl) + } + if err := proto.Unmarshal(a.Value, m); err != nil { + t.Fatal(err) + } + out = append(out, m) + } + return out +} + +func TestEditCommissionKeepsDescription(t *testing.T) { + n, c, valoper := setup(t) + if _, err := (editTool{}).Run(c, toolkit.Args{"commission-rate": "0.10"}); err != nil { + t.Fatal(err) + } + m := lastMsgs(t, n)[0].(*stakingv1beta1.MsgEditValidator) + if m.ValidatorAddress != valoper || m.CommissionRate != "100000000000000000" { + t.Fatalf("edit = %+v", m) + } + if m.Description.GetMoniker() != "validator-01" || m.Description.GetWebsite() != "https://primium.example" { + t.Fatalf("description wiped: %+v", m.Description) + } + if _, err := (editTool{}).Run(c, toolkit.Args{"moniker": "validator-one"}); err != nil { + t.Fatal(err) + } + m = lastMsgs(t, n)[0].(*stakingv1beta1.MsgEditValidator) + if m.Description.GetMoniker() != "validator-one" || m.Description.GetDetails() != "genesis validator" || m.CommissionRate != "" { + t.Fatalf("moniker edit = %+v", m) + } +} + +func TestEditRejectsBadInputBeforeBroadcast(t *testing.T) { + n, c, _ := setup(t) + for _, args := range []toolkit.Args{{"commission-rate": "abc"}, {"commission-rate": "0.0000000000000000001"}, {}} { + if _, err := (editTool{}).Run(c, args); err == nil { + t.Errorf("%v: accepted", args) + } + } + if bs, _, _ := n.Snapshot(); len(bs) != 0 { + t.Fatal("invalid edit broadcast") + } +} + +func TestUnjailAndWithdraw(t *testing.T) { + n, c, valoper := setup(t) + if _, err := (unjailTool{}).Run(c, toolkit.Args{}); err != nil { + t.Fatal(err) + } + if m := lastMsgs(t, n)[0].(*slashingv1beta1.MsgUnjail); m.ValidatorAddr != valoper { + t.Fatalf("unjail = %+v", m) + } + if _, err := (withdrawTool{}).Run(c, toolkit.Args{"commission": true}); err != nil { + t.Fatal(err) + } + ms := lastMsgs(t, n) + if len(ms) != 2 || ms[0].(*distv1beta1.MsgWithdrawDelegatorReward).ValidatorAddress != valoper || ms[1].(*distv1beta1.MsgWithdrawValidatorCommission).ValidatorAddress != valoper { + t.Fatalf("withdraw = %v", ms) + } +} + +func TestVoteOptions(t *testing.T) { + n, c, _ := setup(t) + for opt, want := range map[string]govv1.VoteOption{"yes": govv1.VoteOption_VOTE_OPTION_YES, "no_with_veto": govv1.VoteOption_VOTE_OPTION_NO_WITH_VETO, "ABSTAIN": govv1.VoteOption_VOTE_OPTION_ABSTAIN} { + if _, err := (voteTool{}).Run(c, toolkit.Args{"proposal": float64(7), "option": opt}); err != nil { + t.Fatal(err) + } + m := lastMsgs(t, n)[0].(*govv1.MsgVote) + if m.ProposalId != 7 || m.Option != want || m.Voter == "" { + t.Fatalf("%s: vote = %+v", opt, m) + } + } + before, _, _ := n.Snapshot() + for _, args := range []toolkit.Args{{"proposal": float64(7), "option": "yea"}, {"option": "yes"}} { + if _, err := (voteTool{}).Run(c, args); err == nil { + t.Errorf("%v accepted", args) + } + } + if after, _, _ := n.Snapshot(); len(after) != len(before) { + t.Fatal("invalid vote broadcast") + } +} + +func TestStatusAndSigning(t *testing.T) { + n, c, valoper := setup(t) + res, err := (statusTool{}).Run(c, toolkit.Args{"validator": valoper}) + if err != nil || !strings.Contains(res.Text, "validator-01") { + t.Fatalf("status = %v %v", res, err) + } + n.Set(func(n *fakenode.Node) { n.Validator.Jailed = true }) + res, _ = (statusTool{}).Run(c, toolkit.Args{"validator": valoper}) + if !strings.Contains(strings.ToLower(res.Text), "jailed") { + t.Fatalf("jailed not shown: %q", res.Text) + } +} From 9b88e1f6db6344b6cbf036a8f248adbdcfb6cfc8 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 18:57:43 +0530 Subject: [PATCH 11/48] tools: robust peer edits and verified upgrade staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit net.add-peer / net.rm-peer: - anchor on the `persistent_peers = "…"` line itself (a comment mentioning persistent_peers matched first and the edit failed; the longer persistent_peers_max_dial_period key was also a risk) - validate peers as <40-hex node id>@host:port before asking - removing an absent peer (or re-adding a present one) changes nothing and asks nothing, instead of rewriting the file and saying "removed" - keep config.toml's file mode (writes reset it to 0644) upgrade.prepare: - find the binary anywhere in the archive (releases nest it under bin/) - accept the checksum of the archive or of the binary — releases publish either; a correct archive checksum used to fail - no checksum: the approval carries a warning and the result says UNVERIFIED with verified=false - work dir cleaned up on exit Tests for both, against a temp node home on the local host and a test HTTP server serving real tarballs (three layouts × two checksum kinds, mismatch, missing binary). Co-Authored-By: Claude Opus 5.5 --- internal/tools/nettool/net.go | 64 ++++++------ internal/tools/nettool/net_test.go | 79 +++++++++++++++ internal/tools/upgradetool/upgrade.go | 39 +++++--- internal/tools/upgradetool/upgrade_test.go | 108 +++++++++++++++++++++ 4 files changed, 247 insertions(+), 43 deletions(-) create mode 100644 internal/tools/nettool/net_test.go create mode 100644 internal/tools/upgradetool/upgrade_test.go diff --git a/internal/tools/nettool/net.go b/internal/tools/nettool/net.go index 03f4577..fdcc17c 100644 --- a/internal/tools/nettool/net.go +++ b/internal/tools/nettool/net.go @@ -3,6 +3,8 @@ package nettool import ( "fmt" + "os" + "regexp" "strings" "github.com/abhijitkrm/cometcli/internal/toolkit" @@ -48,9 +50,16 @@ func (rmPeerTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, erro return editPeers(c, a.String("peer", ""), false) } +// peerRe is node_id@host:port (a 40-hex-char CometBFT node id). +var ( + peerRe = regexp.MustCompile(`^[0-9a-f]{40}@[^@:\s]+:[0-9]{1,5}$`) + peersLineRe = regexp.MustCompile(`(?m)^(\s*persistent_peers\s*=\s*")([^"]*)(".*)$`) +) + func editPeers(c *toolkit.Context, peer string, add bool) (*toolkit.Result, error) { - if !strings.Contains(peer, "@") { - return nil, fmt.Errorf("peer must be node_id@host:port") + peer = strings.TrimSpace(peer) + if !peerRe.MatchString(peer) { + return nil, fmt.Errorf("peer %q: want <40-hex node id>@host:port", peer) } h, err := c.Host() if err != nil { @@ -62,37 +71,31 @@ func editPeers(c *toolkit.Context, peer string, add bool) (*toolkit.Result, erro return nil, err } cfg := string(raw) - // find persistent_peers = "a,b,c" line - idx := strings.Index(cfg, "persistent_peers") - if idx < 0 { - return nil, fmt.Errorf("no persistent_peers key in %s", path) - } - lineEnd := strings.IndexByte(cfg[idx:], '\n') - if lineEnd < 0 { - return nil, fmt.Errorf("unterminated persistent_peers line") + loc := peersLineRe.FindStringSubmatchIndex(cfg) + if loc == nil { + return nil, fmt.Errorf("no persistent_peers = \"…\" line in %s", path) } - line := cfg[idx : idx+lineEnd] - qi := strings.IndexByte(line, '"') - qj := strings.LastIndexByte(line, '"') - if qi < 0 || qj <= qi { - return nil, fmt.Errorf("persistent_peers not a quoted string: %s", line) - } - peers := strings.Split(line[qi+1:qj], ",") var clean []string - for _, p := range peers { + for _, p := range strings.Split(cfg[loc[4]:loc[5]], ",") { if p = strings.TrimSpace(p); p != "" { clean = append(clean, p) } } - verb, done := "add", "added" - if add { - for _, p := range clean { - if p == peer { - return &toolkit.Result{Text: "peer already present"}, nil - } + present := false + for _, p := range clean { + if p == peer { + present = true } + } + verb, done := "add", "added" + switch { + case add && present: + return &toolkit.Result{Text: "peer already present — nothing to change", Data: map[string]any{"peers": clean}}, nil + case !add && !present: + return &toolkit.Result{Text: "peer not present — nothing to change", Data: map[string]any{"peers": clean}}, nil + case add: clean = append(clean, peer) - } else { + default: verb, done = "remove", "removed" var keep []string for _, p := range clean { @@ -102,15 +105,18 @@ 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:] + updated := cfg[:loc[4]] + strings.Join(clean, ",") + cfg[loc[5]:] if err := c.Approve(fmt.Sprintf("%s persistent peer %s", verb, peer), toolkit.TierLocalChange, map[string]any{"peer": peer, "peers_after": clean, "diff": toolkit.Diff(path, cfg, updated)}); err != nil { return nil, err } - if err := h.WriteFile(c, path, []byte(updated), 0o644); err != nil { + mode := os.FileMode(0o600) + if fi, err := h.Stat(c, path); err == nil { + mode = fi.Mode().Perm() + } + if err := h.WriteFile(c, path, []byte(updated), mode); err != nil { return nil, err } - return &toolkit.Result{Text: fmt.Sprintf("%s %s — %d persistent peer(s)", done, peer, len(clean)), + return &toolkit.Result{Text: fmt.Sprintf("%s %s — %d persistent peer(s); restart the node to apply", done, peer, len(clean)), Data: map[string]any{"peers": clean}}, nil } diff --git a/internal/tools/nettool/net_test.go b/internal/tools/nettool/net_test.go new file mode 100644 index 0000000..844990c --- /dev/null +++ b/internal/tools/nettool/net_test.go @@ -0,0 +1,79 @@ +package nettool + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/client/host" + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +const cfgTOML = `# Comma separated list of nodes to keep persistent connections to +# (persistent_peers below is what this edits) +persistent_peers = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@10.0.0.1:26656" + +# Maximum pause when redialing a persistent peer +persistent_peers_max_dial_period = "0s" +` + +const peerB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb@10.0.0.2:26656" + +func setup(t *testing.T) (*toolkit.Context, string, *int) { + t.Helper() + home := t.TempDir() + os.MkdirAll(filepath.Join(home, "config"), 0o700) + p := filepath.Join(home, "config", "config.toml") + os.WriteFile(p, []byte(cfgTOML), 0o600) + asked := 0 + c := &toolkit.Context{Context: context.Background(), Profile: &config.Profile{Name: "t", Home: home}, + AutoApproveBelow: toolkit.TierLocalChange, + Approver: func(*toolkit.Context, string, toolkit.Tier, map[string]any) (bool, error) { asked++; return true, nil }} + c.SetHost(&host.Local{}) + return c, p, &asked +} + +func TestAddRemovePeerPreservesFile(t *testing.T) { + c, p, asked := setup(t) + if _, err := (addPeerTool{}).Run(c, toolkit.Args{"peer": peerB}); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(p) + if !strings.Contains(string(b), `persistent_peers = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@10.0.0.1:26656,`+peerB+`"`) { + t.Fatalf("add: %s", b) + } + if !strings.Contains(string(b), "# (persistent_peers below is what this edits)") || !strings.Contains(string(b), `persistent_peers_max_dial_period = "0s"`) { + t.Fatalf("other lines changed: %s", b) + } + if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o600 { + t.Fatalf("mode changed to %v", fi.Mode().Perm()) + } + if res, _ := (addPeerTool{}).Run(c, toolkit.Args{"peer": peerB}); !strings.Contains(res.Text, "already") || *asked != 1 { + t.Fatalf("duplicate add: %v asked=%d", res, *asked) + } + if _, err := (rmPeerTool{}).Run(c, toolkit.Args{"peer": peerB}); err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(p); string(b) != cfgTOML { + t.Fatalf("round trip changed the file:\n%s", b) + } + res, err := (rmPeerTool{}).Run(c, toolkit.Args{"peer": peerB}) + if err != nil || !strings.Contains(res.Text, "not") || *asked != 2 { + t.Fatalf("removing an absent peer: %v %v asked=%d", res, err, *asked) + } +} + +func TestPeerFormatValidated(t *testing.T) { + c, _, asked := setup(t) + for _, bad := range []string{"10.0.0.2:26656", "xyz@10.0.0.2:26656", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb@10.0.0.2", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb@:26656"} { + if _, err := (addPeerTool{}).Run(c, toolkit.Args{"peer": bad}); err == nil { + t.Errorf("%q accepted", bad) + } + } + if *asked != 0 { + t.Fatal("asked to approve an invalid peer") + } +} diff --git a/internal/tools/upgradetool/upgrade.go b/internal/tools/upgradetool/upgrade.go index d11260e..23db9a7 100644 --- a/internal/tools/upgradetool/upgrade.go +++ b/internal/tools/upgradetool/upgrade.go @@ -106,6 +106,10 @@ func (prepareTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, err repo, tag, binary, strings.TrimPrefix(tag, "v"), runtime.GOOS, runtime.GOARCH) } detail := map[string]any{"url": url, "upgrade": name, "binary": binary} + checksum := strings.ToLower(strings.TrimSpace(a.String("checksum", ""))) + if checksum == "" { + detail["warning"] = "no checksum given — the downloaded binary will NOT be verified" + } if err := c.Approve(fmt.Sprintf("download %s and stage for cosmovisor upgrade %q", url, name), toolkit.TierLocalChange, detail); err != nil { return nil, err @@ -114,21 +118,24 @@ func (prepareTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, err if err != nil { return nil, err } - // download on the host, extract, verify checksum, stage into cosmovisor + // download on the host, find the binary anywhere in the archive + // (releases often nest it under bin/), verify the checksum against the + // archive or the binary (releases publish either), then stage it dir := fmt.Sprintf("%s/cosmovisor/upgrades/%s/bin", c.Profile.Home, name) cmd := fmt.Sprintf(`set -e -mkdir -p %s -cd "$(mktemp -d)" -curl -fsSL %s -o asset.tar.gz +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT; cd "$work" +sha() { (sha256sum "$1" 2>/dev/null || shasum -a 256 "$1") | cut -d' ' -f1; } +curl -fsSL %[1]s -o asset.tar.gz tar xzf asset.tar.gz -`, common.ShellQ(dir), common.ShellQ(url)) - if cs := a.String("checksum", ""); cs != "" { - // sum goes to a file so a failing first tool doesn't consume the - // pipe before the fallback runs - cmd += fmt.Sprintf("printf '%%s %%s\\n' %s %s > expected.sum && (sha256sum -c expected.sum 2>/dev/null || shasum -a 256 -c expected.sum)\n", - common.ShellQ(cs), common.ShellQ("./"+binary)) - } - cmd += fmt.Sprintf("install -m 0755 ./%s %s/\n", common.ShellQ(binary), common.ShellQ(dir)) +bin=$(find . -type f -name %[2]s | head -1) +[ -n "$bin" ] || { echo "no %[2]s binary in the archive" >&2; exit 3; } +want=%[3]s +if [ -n "$want" ] && [ "$(sha asset.tar.gz)" != "$want" ] && [ "$(sha "$bin")" != "$want" ]; then + echo "checksum mismatch: archive $(sha asset.tar.gz), binary $(sha "$bin"), expected $want" >&2; exit 4 +fi +mkdir -p %[4]s +install -m 0755 "$bin" %[4]s/%[2]s +`, common.ShellQ(url), common.ShellQ(binary), common.ShellQ(checksum), common.ShellQ(dir)) out, code, err := h.Run(c, cmd) c.LogShell("upgrade.prepare "+name, code) if err != nil { @@ -137,9 +144,13 @@ tar xzf asset.tar.gz // verify sha256 of staged binary for the audit trail sum, _, _ := h.Run(c, fmt.Sprintf("shasum -a 256 %s/%s 2>/dev/null || sha256sum %s/%s", common.ShellQ(dir), binary, common.ShellQ(dir), binary)) + text := fmt.Sprintf("staged %s → %s\nsha256: %s", url, dir+"/"+binary, sha256Str(sum)) + if checksum == "" { + text += "\nUNVERIFIED: no checksum was given — compare the sha256 above with the release's published checksum before the upgrade height" + } return &toolkit.Result{ - Text: fmt.Sprintf("staged %s → %s\nsha256: %s", url, dir+"/"+binary, strings.TrimSpace(sum)), - Data: map[string]any{"dir": dir, "sha256": sha256Str(sum)}, + Text: text, + Data: map[string]any{"dir": dir, "sha256": sha256Str(sum), "verified": checksum != ""}, }, nil } diff --git a/internal/tools/upgradetool/upgrade_test.go b/internal/tools/upgradetool/upgrade_test.go new file mode 100644 index 0000000..ab87e21 --- /dev/null +++ b/internal/tools/upgradetool/upgrade_test.go @@ -0,0 +1,108 @@ +package upgradetool + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/client/host" + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +func tarball(t *testing.T, name string, body []byte) []byte { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}) + tw.Write(body) + tw.Close() + gz.Close() + return buf.Bytes() +} + +func sum(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } + +func setup(t *testing.T, asset []byte) (*toolkit.Context, string, string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(asset) })) + t.Cleanup(srv.Close) + home := t.TempDir() + c := &toolkit.Context{Context: context.Background(), Profile: &config.Profile{Name: "t", Home: home, Binary: "evmd"}, + AutoApproveBelow: toolkit.TierOnChain} + c.SetHost(&host.Local{}) + return c, srv.URL + "/evmd.tar.gz", filepath.Join(home, "cosmovisor", "upgrades", "v0.6.0-to-v0.7.0", "bin", "evmd") +} + +func TestPrepareStagesBinaryVerifyingEitherChecksum(t *testing.T) { + bin := []byte("#!/bin/sh\necho evmd v0.7.2\n") + for _, layout := range []string{"evmd", "bin/evmd", "evmd_0.7.2_linux_amd64/bin/evmd"} { + asset := tarball(t, layout, bin) + for which, cs := range map[string]string{"tarball": sum(asset), "binary": sum(bin)} { + t.Run(layout+"/"+which, func(t *testing.T) { + c, url, staged := setup(t, asset) + res, err := (prepareTool{}).Run(c, toolkit.Args{"name": "v0.6.0-to-v0.7.0", "url": url, "checksum": cs}) + if err != nil { + t.Fatalf("%v\n%v", err, res) + } + b, err := os.ReadFile(staged) + if err != nil || !bytes.Equal(b, bin) { + t.Fatalf("staged binary: %v", err) + } + if fi, _ := os.Stat(staged); fi.Mode().Perm() != 0o755 { + t.Fatalf("mode %v", fi.Mode().Perm()) + } + if res.Data["sha256"] != sum(bin) || res.Data["verified"] != true { + t.Fatalf("data = %v", res.Data) + } + }) + } + } +} + +func TestPrepareRejectsChecksumMismatch(t *testing.T) { + asset := tarball(t, "evmd", []byte("tampered")) + c, url, staged := setup(t, asset) + _, err := (prepareTool{}).Run(c, toolkit.Args{"name": "v0.6.0-to-v0.7.0", "url": url, "checksum": strings.Repeat("ab", 32)}) + if err == nil { + t.Fatal("checksum mismatch accepted") + } + if _, err := os.Stat(staged); err == nil { + t.Fatal("binary staged despite a checksum mismatch") + } +} + +func TestPrepareWithoutChecksumIsFlaggedUnverified(t *testing.T) { + asset := tarball(t, "evmd", []byte("bin")) + c, url, _ := setup(t, asset) + var detail map[string]any + c.AutoApproveBelow = toolkit.TierLocalChange + c.Approver = func(_ *toolkit.Context, _ string, _ toolkit.Tier, d map[string]any) (bool, error) { + detail = d + return true, nil + } + res, err := (prepareTool{}).Run(c, toolkit.Args{"name": "v0.6.0-to-v0.7.0", "url": url}) + if err != nil { + t.Fatal(err) + } + if res.Data["verified"] != false || !strings.Contains(res.Text, "UNVERIFIED") || detail["warning"] == nil { + t.Fatalf("unverified install not flagged: %v %v", res, detail) + } +} + +func TestPrepareMissingBinaryInArchive(t *testing.T) { + asset := tarball(t, "README.md", []byte("no binary here")) + c, url, _ := setup(t, asset) + if _, err := (prepareTool{}).Run(c, toolkit.Args{"name": "v0.6.0-to-v0.7.0", "url": url}); err == nil || !strings.Contains(err.Error(), "evmd") { + t.Fatalf("err = %v", err) + } +} From 7d41ec73012473fcadfb0cd2879f87308918098b Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 18:59:05 +0530 Subject: [PATCH 12/48] tools/audit: robust statesync config patching; audit log tests snap.statesync --apply reported success but wrote nothing when config.toml had no [statesync] section, never added missing keys when [statesync] was the last section, matched keys by prefix (enable vs enable_*), and reset the file mode to 0644. It now edits exact keys in place, appends missing keys or the whole section, and keeps the mode. Audit log tests: concurrent sessions sharing one file keep every JSONL line intact, 0600/0700 permissions, session lookup across daily files (bad lines skipped), first prompts per session, nil logger no-op. Co-Authored-By: Claude Opus 5.5 --- internal/audit/audit_test.go | 103 +++++++++++++++++++++++++++ internal/tools/snaptool/snap.go | 98 ++++++++++++++----------- internal/tools/snaptool/snap_test.go | 74 +++++++++++++++++++ 3 files changed, 233 insertions(+), 42 deletions(-) create mode 100644 internal/audit/audit_test.go create mode 100644 internal/tools/snaptool/snap_test.go diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go new file mode 100644 index 0000000..3a3f703 --- /dev/null +++ b/internal/audit/audit_test.go @@ -0,0 +1,103 @@ +package audit + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestConcurrentSessionsKeepJSONLIntact(t *testing.T) { + t.Setenv("COMETCLI_HOME", t.TempDir()) + lg, err := Open("val01") + if err != nil { + t.Fatal(err) + } + defer lg.Close() + var wg sync.WaitGroup + for s := 0; s < 8; s++ { + wg.Add(1) + go func(s int) { + defer wg.Done() + l := lg.WithSession(fmt.Sprintf("sess-%d", s)) + _ = l.Log(KindPrompt, "val01", map[string]any{"text": fmt.Sprintf("prompt %d", s)}) + for i := 0; i < 50; i++ { + l.ToolSeen("val01", "node.status", "observe", map[string]any{"i": i}, map[string]any{"ok": true}, nil, "height 42") + } + l.Approval("val01", "broadcast tx", s%2 == 0) + l.Tool("val01", "val.unjail", "on-chain", nil, nil, errors.New("denied")) + }(s) + } + wg.Wait() + + f, _ := os.Open(lg.Path()) + defer f.Close() + sc := bufio.NewScanner(f) + n := 0 + for sc.Scan() { + var e Event + if err := json.Unmarshal(sc.Bytes(), &e); err != nil { + t.Fatalf("line %d is not valid JSON (interleaved write?): %q", n+1, sc.Text()) + } + n++ + } + if n != 8*(1+50+2) { + t.Fatalf("got %d events, want %d", n, 8*53) + } + fi, _ := os.Stat(lg.Path()) + di, _ := os.Stat(filepath.Dir(lg.Path())) + if fi.Mode().Perm() != 0o600 || di.Mode().Perm() != 0o700 { + t.Fatalf("permissions: file %v dir %v", fi.Mode().Perm(), di.Mode().Perm()) + } + + evs, err := ReadSession("sess-3") + if err != nil || len(evs) != 53 || evs[0].Kind != KindPrompt || evs[52].Detail["error"] != "denied" { + t.Fatalf("ReadSession: %d events err=%v", len(evs), err) + } + if evs[1].Detail["digest"] == nil || evs[1].Detail["seen"] != "height 42" { + t.Fatalf("tool event = %v", evs[1].Detail) + } + ss, err := Sessions(lg.Path()) + if err != nil || len(ss) != 8 { + t.Fatalf("Sessions = %v %v", ss, err) + } + for _, s := range ss { + if s[1] == "" { + t.Fatalf("session %s has no first prompt", s[0]) + } + } +} + +func TestReadSessionAcrossDays(t *testing.T) { + home := t.TempDir() + t.Setenv("COMETCLI_HOME", home) + dir := filepath.Join(home, "audit") + os.MkdirAll(dir, 0o700) + line := func(kind, text string) string { + b, _ := json.Marshal(Event{Kind: Kind(kind), Session: "s1", Detail: map[string]any{"text": text}}) + return string(b) + "\n" + } + os.WriteFile(filepath.Join(dir, "2026-10-05.jsonl"), []byte(line("prompt", "before midnight")+"not json\n"), 0o600) + os.WriteFile(filepath.Join(dir, "2026-10-06.jsonl"), []byte(line("llm", "after midnight")), 0o600) + evs, err := ReadSession("s1") + if err != nil || len(evs) != 2 || evs[0].Detail["text"] != "before midnight" || evs[1].Detail["text"] != "after midnight" { + t.Fatalf("events = %+v err=%v", evs, err) + } + files, _ := List() + if len(files) != 2 || filepath.Base(files[0]) != "2026-10-06.jsonl" { + t.Fatalf("List = %v", files) + } +} + +func TestNilLoggerIsNoop(t *testing.T) { + var l *Logger + if l.Log(KindTool, "", nil) != nil || l.Path() != "" || l.Session() != "" || l.Close() != nil || l.WithSession("x") != nil { + t.Fatal("nil logger must be a no-op") + } + l.Shell("p", "ls", 0) + l.Tx("p", "simulate", nil) +} diff --git a/internal/tools/snaptool/snap.go b/internal/tools/snaptool/snap.go index fb8f4ac..92d136e 100644 --- a/internal/tools/snaptool/snap.go +++ b/internal/tools/snaptool/snap.go @@ -4,6 +4,7 @@ package snaptool import ( "fmt" + "os" "strings" "github.com/abhijitkrm/cometcli/internal/client/comet" @@ -95,71 +96,84 @@ trust_period = "%s" "diff": toolkit.Diff(path, string(raw), updated)}); err != nil { return nil, err } - if err := h.WriteFile(c, path, []byte(updated), 0o644); err != nil { + mode := os.FileMode(0o600) + if fi, err := h.Stat(c, path); err == nil { + mode = fi.Mode().Perm() + } + if err := h.WriteFile(c, path, []byte(updated), mode); err != nil { return nil, err } return &toolkit.Result{Text: fmt.Sprintf("statesync config applied (trust_height=%d)\n\n%s", height, cfg), Data: map[string]any{"applied": true, "trust_height": height}}, nil } -// patchStatesync rewrites the [statesync] section of config.toml in place. +// patchStatesync sets the statesync keys in config.toml: in place inside +// an existing [statesync] section (other keys and comments untouched), +// adding any that are missing at the section's end — or appending the +// whole section when the file has none. func patchStatesync(cfg, rpcs string, height int64, hash, trustPeriod string) string { + want := []struct{ key, val string }{ + {"enable", "true"}, + {"rpc_servers", `"` + rpcs + `"`}, + {"trust_height", fmt.Sprint(height)}, + {"trust_hash", `"` + hash + `"`}, + {"trust_period", `"` + trustPeriod + `"`}, + } + vals := map[string]string{} + for _, w := range want { + vals[w.key] = w.val + } lines := strings.Split(cfg, "\n") var out []string - inSS := false - setKeys := map[string]bool{} - put := func(key, val string) { - out = append(out, fmt.Sprintf("%s = %s", key, val)) - setKeys[key] = true + inSS, found := false, false + set := map[string]bool{} + flush := func() { + for _, w := range want { + if !set[w.key] { + out = append(out, w.key+" = "+w.val) + set[w.key] = true + } + } } for _, ln := range lines { t := strings.TrimSpace(ln) if strings.HasPrefix(t, "[") { if inSS { - // flush unset keys before leaving the section - if !setKeys["rpc_servers"] { - put("rpc_servers", `"`+rpcs+`"`) - } - if !setKeys["trust_height"] { - put("trust_height", fmt.Sprint(height)) - } - if !setKeys["trust_hash"] { - put("trust_hash", `"`+hash+`"`) - } - if !setKeys["trust_period"] { - put("trust_period", `"`+trustPeriod+`"`) - } + flush() } inSS = t == "[statesync]" + found = found || inSS out = append(out, ln) continue } - if inSS { - switch { - case strings.HasPrefix(t, "enable"): - out = append(out, "enable = true") - setKeys["enable"] = true - continue - case strings.HasPrefix(t, "rpc_servers"): - out = append(out, fmt.Sprintf(`rpc_servers = "%s"`, rpcs)) - setKeys["rpc_servers"] = true - continue - case strings.HasPrefix(t, "trust_height"): - out = append(out, fmt.Sprintf("trust_height = %d", height)) - setKeys["trust_height"] = true - continue - case strings.HasPrefix(t, "trust_hash"): - out = append(out, fmt.Sprintf(`trust_hash = "%s"`, hash)) - setKeys["trust_hash"] = true - continue - case strings.HasPrefix(t, "trust_period"): - out = append(out, fmt.Sprintf(`trust_period = "%s"`, trustPeriod)) - setKeys["trust_period"] = true - continue + if inSS && !strings.HasPrefix(t, "#") { + if k, _, ok := strings.Cut(t, "="); ok { + k = strings.TrimSpace(k) + if v, ok := vals[k]; ok { + out = append(out, k+" = "+v) + set[k] = true + continue + } } } out = append(out, ln) } + if inSS { + // [statesync] was the last section: keep a trailing newline last + for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" { + out = out[:len(out)-1] + } + flush() + out = append(out, "") + } + if !found { + for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" { + out = out[:len(out)-1] + } + out = append(out, "", "[statesync]") + flush() + out = append(out, "") + } return strings.Join(out, "\n") } diff --git a/internal/tools/snaptool/snap_test.go b/internal/tools/snaptool/snap_test.go new file mode 100644 index 0000000..e222b98 --- /dev/null +++ b/internal/tools/snaptool/snap_test.go @@ -0,0 +1,74 @@ +package snaptool + +import ( + "strings" + "testing" +) + +const cometCfg = `[p2p] +persistent_peers = "" + +####################################################### +### State Sync Configuration Options ### +####################################################### +[statesync] +# State sync rapidly bootstraps a new node by discovering, fetching, and restoring a state machine +enable = false + +# trust_height and trust_hash are filled from a trusted RPC +rpc_servers = "" +trust_height = 0 +trust_hash = "" +trust_period = "168h0m0s" +discovery_time = "15s" +temp_dir = "" + +[blocksync] +version = "v0" +` + +func TestPatchStatesyncInPlace(t *testing.T) { + out := patchStatesync(cometCfg, "http://a:26657,http://b:26657", 1200, "ABCD", "112h0m0s") + for _, want := range []string{ + "enable = true", `rpc_servers = "http://a:26657,http://b:26657"`, "trust_height = 1200", + `trust_hash = "ABCD"`, `trust_period = "112h0m0s"`, + `discovery_time = "15s"`, "# trust_height and trust_hash are filled from a trusted RPC", "[blocksync]", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q", want) + } + } + if strings.Count(out, "trust_height = ") != 1 || strings.Count(out, "enable = ") != 1 { + t.Fatalf("duplicated keys:\n%s", out) + } + if strings.Contains(out, "enable = false") { + t.Fatal("old value kept") + } + // the [p2p] section's lines are untouched + if !strings.HasPrefix(out, "[p2p]\npersistent_peers = \"\"\n") { + t.Fatalf("other sections changed:\n%s", out) + } +} + +func TestPatchStatesyncSectionLastOrMissing(t *testing.T) { + last := "[p2p]\nx = 1\n\n[statesync]\nenable = false\n" + out := patchStatesync(last, "a,b", 5, "H", "1h") + for _, k := range []string{"enable = true", `rpc_servers = "a,b"`, "trust_height = 5", `trust_hash = "H"`, `trust_period = "1h"`} { + if strings.Count(out, k) != 1 { + t.Errorf("last section: %q count %d in\n%s", k, strings.Count(out, k), out) + } + } + none := "[p2p]\nx = 1\n" + out = patchStatesync(none, "a,b", 5, "H", "1h") + if !strings.Contains(out, "\n[statesync]\nenable = true\n") || !strings.Contains(out, "trust_height = 5") || !strings.HasPrefix(out, "[p2p]\nx = 1\n") { + t.Fatalf("missing section not appended:\n%s", out) + } +} + +func TestPatchStatesyncExactKeys(t *testing.T) { + cfg := "[statesync]\nenable_extra = \"keep\"\nenable = false\n[x]\n" + out := patchStatesync(cfg, "a,b", 1, "H", "1h") + if !strings.Contains(out, `enable_extra = "keep"`) || strings.Count(out, "enable = true") != 1 { + t.Fatalf("prefix-matched a different key:\n%s", out) + } +} From 3229dd2e203e5425ab5545b30ae4c84d7a45228d Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 19:01:09 +0530 Subject: [PATCH 13/48] cli: in-process end-to-end tests; fix openai-compat identity and headless resume note E2E tests run the real root command against a scripted OpenAI-compatible server: -p text/json/stream-json, general-mode tools and prompt, a real bash call, headless denial of a write (and --allowedTools allowing it), sessions/-c/-r, piped stdin, slash and custom commands in -p, config set, typo detection, one without a profile. Bugs found: - the openai-compat provider (Ollama/vLLM/llama.cpp) had no name, so it reported itself as "openai" and was treated as hosted: it got max_completion_tokens and reasoning_effort, fields local servers may reject. It now identifies as openai-compat. - cometcli -p -r resumed silently; the resume note goes to stderr. - a retry test asserted on timing; made robust. Co-Authored-By: Claude Opus 5.5 --- internal/agent/openai.go | 3 +- internal/agent/phase1_test.go | 2 +- internal/agent/provider.go | 1 + internal/cli/chat.go | 6 +- internal/cli/e2e_test.go | 275 ++++++++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 internal/cli/e2e_test.go diff --git a/internal/agent/openai.go b/internal/agent/openai.go index a238840..9af70e9 100644 --- a/internal/agent/openai.go +++ b/internal/agent/openai.go @@ -93,7 +93,8 @@ func (o *openai) body(r *Request) map[string]any { body["max_tokens"] = r.MaxTok // llama.cpp / older vLLM / Ollama } } - if r.Effort != "" { + if r.Effort != "" && o.hosted() { + // local OpenAI-compatible servers may reject unknown fields body["reasoning_effort"] = oaiEffort(r.Effort) } return body diff --git a/internal/agent/phase1_test.go b/internal/agent/phase1_test.go index 5e290f0..a0030fb 100644 --- a/internal/agent/phase1_test.go +++ b/internal/agent/phase1_test.go @@ -556,7 +556,7 @@ func TestRetryIsAnnouncedAndTimeoutsAreNot(t *testing.T) { if _, err := doHTTP(context.Background(), hc, slow.URL, []byte(`{}`), nil); err == nil { t.Fatal("want timeout error") } - if n.Load() != 1 { + if n.Load() > 1 { // 0 when the timeout fires before the handler runs t.Fatalf("timed-out request retried: %d attempts", n.Load()) } } diff --git a/internal/agent/provider.go b/internal/agent/provider.go index acc6057..3a97e96 100644 --- a/internal/agent/provider.go +++ b/internal/agent/provider.go @@ -179,6 +179,7 @@ func NewProvider(ac config.AgentConf) (Provider, error) { base = "http://localhost:11434" // ollama default } return &openai{ + name: "openai-compat", key: key("OLLAMA_API_KEY"), model: def(ac.Model, "qwen3:32b"), base: base, diff --git a/internal/cli/chat.go b/internal/cli/chat.go index 9bcd20a..f6e62ce 100644 --- a/internal/cli/chat.go +++ b/internal/cli/chat.go @@ -223,9 +223,13 @@ func runPrint(cmd *cobra.Command, c *toolkit.Context, reg *toolkit.Registry, pro return err } defer a.Close() - if _, err := configureChatAgent(cmd, a); err != nil { + note, err := configureChatAgent(cmd, a) + if err != nil { return err } + if note != "" { + fmt.Fprintln(cmd.ErrOrStderr(), note) + } out := cmd.OutOrStdout() enc := json.NewEncoder(out) partial, _ := cmd.Flags().GetBool("include-partial-messages") diff --git a/internal/cli/e2e_test.go b/internal/cli/e2e_test.go new file mode 100644 index 0000000..b034fc7 --- /dev/null +++ b/internal/cli/e2e_test.go @@ -0,0 +1,275 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + + "github.com/abhijitkrm/cometcli/internal/toolkit" + "github.com/abhijitkrm/cometcli/internal/tools" +) + +// fakeLLM is a scripted OpenAI-compatible chat-completions server. It +// answers by looking at the last message: user prompts are matched to a +// script entry (which may call a tool first), tool results get a final +// answer quoting the tool output. +type fakeLLM struct { + mu sync.Mutex + requests []map[string]any + script map[string]string // prompt substring → shell command to run first +} + +func (f *fakeLLM) handler(w http.ResponseWriter, r *http.Request) { + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + f.mu.Lock() + f.requests = append(f.requests, req) + f.mu.Unlock() + msgs, _ := req["messages"].([]any) + last, _ := msgs[len(msgs)-1].(map[string]any) + var text string + var call map[string]any + switch last["role"] { + case "tool": + text = "Result: " + strings.SplitN(fmt.Sprint(last["content"]), "\n", 2)[0] + default: + prompt := fmt.Sprint(last["content"]) + text = "answer to: " + strings.SplitN(prompt, "\n", 2)[0] + for k, cmd := range f.script { + if strings.Contains(prompt, k) { + args, _ := json.Marshal(map[string]any{"command": cmd}) + call = map[string]any{"index": 0, "id": "call_1", "type": "function", + "function": map[string]any{"name": "bash", "arguments": string(args)}} + } + } + } + w.Header().Set("content-type", "text/event-stream") + send := func(v any) { b, _ := json.Marshal(v); fmt.Fprintf(w, "data: %s\n\n", b) } + if call != nil { + send(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"tool_calls": []any{call}}}}}) + send(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "tool_calls"}}, + "usage": map[string]any{"prompt_tokens": 500, "completion_tokens": 20}}) + } else { + send(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"content": text}}}}) + send(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "stop"}}, + "usage": map[string]any{"prompt_tokens": 600, "completion_tokens": 10}}) + } + fmt.Fprint(w, "data: [DONE]\n\n") +} + +func (f *fakeLLM) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.requests) +} + +func (f *fakeLLM) last() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return f.requests[len(f.requests)-1] +} + +type env struct { + t *testing.T + llm *fakeLLM + home string + work string + reg *toolkit.Registry +} + +func newEnv(t *testing.T) *env { + t.Helper() + f := &fakeLLM{script: map[string]string{}} + srv := httptest.NewServer(http.HandlerFunc(f.handler)) + t.Cleanup(srv.Close) + home, work := t.TempDir(), t.TempDir() + t.Setenv("HOME", home) + t.Setenv("COMETCLI_HOME", filepath.Join(home, ".cometcli")) + t.Setenv("OLLAMA_API_KEY", "x") + os.MkdirAll(filepath.Join(home, ".cometcli"), 0o700) + cfg := fmt.Sprintf("agent:\n provider: openai-compat\n base_url: %s\n model: fake-1\nprofiles: {}\n", srv.URL) + os.WriteFile(filepath.Join(home, ".cometcli", "config.yaml"), []byte(cfg), 0o600) + wd, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(wd) }) + os.Chdir(work) + reg := toolkit.NewRegistry() + tools.RegisterAll(reg) + return &env{t: t, llm: f, home: home, work: work, reg: reg} +} + +// run executes the CLI in-process and returns stdout, stderr and error. +func (e *env) run(args ...string) (string, string, error) { + e.t.Helper() + root := NewRoot(e.reg, []*cobra.Command{DoctorCmd(e.reg), AskCmd(e.reg), AgentCmd(e.reg), UICmd(e.reg), MCPCmd(e.reg), ServeCmd(e.reg)}) + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + root.SetIn(strings.NewReader("")) + root.SetArgs(args) + err := root.Execute() + return out.String(), errb.String(), err +} + +func TestE2EPrintTextAndJSON(t *testing.T) { + e := newEnv(t) + e.llm.script["disk"] = "echo disk-ok" + out, _, err := e.run("-p", "how is the disk?") + if err != nil || strings.TrimSpace(out) != "Result: disk-ok" { + t.Fatalf("text: out=%q err=%v", out, err) + } + // general mode: only general tools, general prompt + req := e.llm.last() + names := toolNamesOf(req) + if !strings.Contains(names, "bash") || strings.Contains(names, "node__status") { + t.Fatalf("tools = %s", names) + } + sys := fmt.Sprint(req["messages"].([]any)[0].(map[string]any)["content"]) + if !strings.Contains(sys, "working in the operator's terminal") { + t.Fatalf("system prompt: %.200s", sys) + } + + out, _, err = e.run("-p", "--output-format", "json", "hello") + var res map[string]any + if err != nil || json.Unmarshal([]byte(out), &res) != nil { + t.Fatalf("json: %q %v", out, err) + } + if res["type"] != "result" || res["subtype"] != "success" || res["result"] != "answer to: hello" || res["mode"] != "general" { + t.Fatalf("json result = %v", res) + } + if u := res["usage"].(map[string]any); u["input_tokens"].(float64) != 600 { + t.Fatalf("usage = %v", u) + } +} + +func TestE2EStreamJSONAndHeadlessDenial(t *testing.T) { + e := newEnv(t) + target := filepath.Join(e.work, "made.txt") + e.llm.script["create"] = "touch " + target + out, _, err := e.run("-p", "--output-format", "stream-json", "create the file") + if err != nil { + t.Fatal(err) + } + var types []string + var denied bool + for _, l := range strings.Split(strings.TrimSpace(out), "\n") { + var ev map[string]any + if json.Unmarshal([]byte(l), &ev) != nil { + t.Fatalf("not JSON: %q", l) + } + types = append(types, fmt.Sprint(ev["type"])) + if ev["type"] == "tool_result" && ev["is_error"] == true && strings.Contains(fmt.Sprint(ev["output"]), "-p has no one to ask") { + denied = true + } + } + if got := strings.Join(types, ","); got != "system,tool_use,tool_result,assistant,result" { + t.Fatalf("event types = %s", got) + } + if !denied { + t.Fatalf("headless write wasn't denied:\n%s", out) + } + if _, err := os.Stat(target); err == nil { + t.Fatal("headless run created a file without approval") + } + // allowed explicitly → runs + if _, _, err := e.run("-p", "--allowedTools", "bash(touch:*)", "create the file"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(target); err != nil { + t.Fatal("--allowedTools didn't allow the command") + } +} + +func TestE2ESessionsContinueAndResume(t *testing.T) { + e := newEnv(t) + if _, _, err := e.run("-p", "first question"); err != nil { + t.Fatal(err) + } + out, _, err := e.run("sessions") + if err != nil || !strings.Contains(out, "first question") { + t.Fatalf("sessions: %q %v", out, err) + } + id := strings.Fields(strings.Split(out, "\n")[1])[0] + if _, _, err := e.run("-p", "-c", "second question"); err != nil { + t.Fatal(err) + } + msgs := e.llm.last()["messages"].([]any) + if len(msgs) != 4 { // system, first user, first answer, second user + t.Fatalf("continue didn't carry the conversation: %d messages", len(msgs)) + } + if _, stderr, err := e.run("-p", "-r", id[:10], "third"); err != nil || !strings.Contains(stderr, "resumed session") { + t.Fatalf("resume: %q %v", stderr, err) + } + if _, _, err := e.run("-p", "-r", "no-such-id", "x"); err == nil { + t.Fatal("bad resume id accepted") + } +} + +func TestE2EPipedStdinSlashCommandsConfigAndTypos(t *testing.T) { + e := newEnv(t) + // piped input: a real file as stdin + in := filepath.Join(e.work, "log.txt") + os.WriteFile(in, []byte("panic: out of memory\n"), 0o644) + f, _ := os.Open(in) + defer f.Close() + root := NewRoot(e.reg, nil) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&bytes.Buffer{}) + root.SetIn(f) + root.SetArgs([]string{"-p", "why did it crash?"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + user := fmt.Sprint(e.llm.last()["messages"].([]any)[1].(map[string]any)["content"]) + if !strings.Contains(user, "\npanic: out of memory\n") { + t.Fatalf("stdin not attached: %q", user) + } + + // a slash command headlessly, no model call + before := e.llm.count() + if out, _, err := e.run("-p", "/permissions"); err != nil || !strings.Contains(out, "transactions ask") || e.llm.count() != before { + t.Fatalf("/permissions: %q %v", out, err) + } + // a custom command expands into a prompt + os.MkdirAll(filepath.Join(e.work, ".cometcli", "commands"), 0o755) + os.WriteFile(filepath.Join(e.work, ".cometcli", "commands", "greet.md"), []byte("Say hi to $1"), 0o644) + if out, _, err := e.run("-p", "/greet operator"); err != nil || strings.TrimSpace(out) != "answer to: Say hi to operator" { + t.Fatalf("/greet: %q %v", out, err) + } + + if out, _, err := e.run("config", "set", "agent.effort", "low"); err != nil || !strings.Contains(out, "agent.effort = low") { + t.Fatalf("config set: %q %v", out, err) + } + if _, _, err := e.run("-p", "hi"); err != nil { + t.Fatal(err) + } + if e.llm.last()["reasoning_effort"] != nil { + // openai-compat (local servers) never gets reasoning_effort + t.Fatalf("reasoning_effort sent to an openai-compat server") + } + if _, _, err := e.run("doctr"); err == nil || !strings.Contains(err.Error(), "did you mean doctor") { + t.Fatalf("typo: %v", err) + } + if _, _, err := e.run("one"); err == nil || !strings.Contains(err.Error(), "init") { + t.Fatalf("one without a profile: %v", err) + } +} + +func toolNamesOf(req map[string]any) string { + var n []string + tl, _ := req["tools"].([]any) + for _, t := range tl { + fn := t.(map[string]any)["function"].(map[string]any) + n = append(n, fmt.Sprint(fn["name"])) + } + return strings.Join(n, ",") +} From e4100ee216fc12b2328c805fbdf24b526cabfe9c Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 20:38:29 +0530 Subject: [PATCH 14/48] safety: gate fleet.shell like bash; key management is operator-only fleet.shell ran any command on every host after one generic local-change approval, bypassing the shell classifier: key material was not refused (cat priv_validator_key.json across the fleet would reach the model), an `evmd tx` was not treated as a transaction (in bypass mode it ran without asking), and permission rules didn't apply. It now goes through shell.Classify + Context.Check like bash; read-only commands still run without a prompt, and a fleet-wide script run always asks (remote scripts can't be inspected). keys.add / keys.rm were agent-callable: a generated mnemonic went into the tool result (scrubbed by redaction, so the operator never saw the only copy) and --recover read stdin directly. New toolkit.OperatorOnly: such tools are never advertised to the model, refused if called, hidden from tool_search and MCP, and refused in runbook steps the agent runs. They stay CLI subcommands. Runbooks now validate every step before running any (an unknown tool in step 5 surfaced after steps 1-4 ran). keys.convert validates 0x input. Tests: fleet (reads across hosts, key material refused, tx and scripts ask in bypass, rules), fleet.exec refusing mutating tools, keys round trip + operator-only flags, runbook steps/optional/manual/validation, host Local exec (combined output, closed stdin, non-interactive env, head+tail cap, timeout killing children, file mode), agent refusing operator-only tools. Co-Authored-By: Claude Opus 5.5 --- internal/agent/loop.go | 7 +- internal/agent/phase2_test.go | 27 +++++++ internal/agent/toolsearch.go | 2 +- internal/client/host/exec_test.go | 57 +++++++++++++++ internal/mcp/server.go | 4 +- internal/runbook/runbook.go | 20 +++++- internal/runbook/runbook_test.go | 102 +++++++++++++++++++++++++++ internal/toolkit/toolkit.go | 14 ++++ internal/tools/fleet/fleet.go | 23 +++++- internal/tools/fleet/fleet_test.go | 66 +++++++++++++++++ internal/tools/keystool/keys.go | 8 +++ internal/tools/keystool/keys_test.go | 60 ++++++++++++++++ 12 files changed, 380 insertions(+), 10 deletions(-) create mode 100644 internal/client/host/exec_test.go create mode 100644 internal/runbook/runbook_test.go create mode 100644 internal/tools/fleet/fleet_test.go create mode 100644 internal/tools/keystool/keys_test.go diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 2055e82..05afba2 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -253,8 +253,8 @@ 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 toolkit.IsLongRunning(t) || toolkit.IsOperatorOnly(t) { + continue // watchers never return inside a turn; key management is the operator's } if !a.Policy.Allows(t.Tier()) && !toolkit.IsDynamic(t) { continue @@ -531,6 +531,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.IsOperatorOnly(t) { + return a.toolErr(call, name, "refused: "+name+" handles key material and is operator-only — give the operator the command: cometcli "+strings.ReplaceAll(name, ".", " ")) + } 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") } diff --git a/internal/agent/phase2_test.go b/internal/agent/phase2_test.go index 0e35a59..62ece14 100644 --- a/internal/agent/phase2_test.go +++ b/internal/agent/phase2_test.go @@ -224,3 +224,30 @@ func TestGeneralModeAndScopeSwitch(t *testing.T) { t.Fatalf("conversation not kept: %d messages", len(r.Messages)) } } + +type opOnly struct{ stubTool } + +func (opOnly) OperatorOnly() bool { return true } + +func TestOperatorOnlyToolsNeverReachTheAgent(t *testing.T) { + ran := false + keyAdd := opOnly{stubTool{name: "keys.add", tier: toolkit.TierLocalChange, run: func(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { + ran = true + return &toolkit.Result{Text: "mnemonic: …"}, nil + }}} + prov := &mockProvider{responses: []*Response{ + {Calls: []Call{{ID: "c1", Name: "keys__add", Args: json.RawMessage(`{"name":"x"}`)}}}, + {Text: "ok", Done: true}, + }} + a, _ := newShellAgent(t, prov, keyAdd) + a.conf.Tools = "all" + a.Run(context.Background(), "make me a key") + for _, td := range prov.reqs[0].Tools { + if td.Name == "keys__add" { + t.Fatal("operator-only tool advertised") + } + } + if ran || !strings.Contains(lastToolText(a), "operator-only") { + t.Fatalf("ran=%v result=%q", ran, lastToolText(a)) + } +} diff --git a/internal/agent/toolsearch.go b/internal/agent/toolsearch.go index a74feb4..61ba73b 100644 --- a/internal/agent/toolsearch.go +++ b/internal/agent/toolsearch.go @@ -52,7 +52,7 @@ func (a *Agent) deferrable(t toolkit.Tool) bool { if isMCP(t) { return true } - return a.Node() && !toolkit.IsAgentOnly(t) && !coreTools[t.Name()] && !toolkit.IsLongRunning(t) && strings.Contains(t.Name(), ".") + return a.Node() && !toolkit.IsAgentOnly(t) && !coreTools[t.Name()] && !toolkit.IsLongRunning(t) && !toolkit.IsOperatorOnly(t) && strings.Contains(t.Name(), ".") } // advertised reports whether t goes into this request's tool list. diff --git a/internal/client/host/exec_test.go b/internal/client/host/exec_test.go new file mode 100644 index 0000000..2b2fdea --- /dev/null +++ b/internal/client/host/exec_test.go @@ -0,0 +1,57 @@ +package host + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestLocalExecCombinedOutputAndCodes(t *testing.T) { + h := &Local{} + res, err := h.Exec(context.Background(), "echo out; echo err >&2; exit 3", 1000) + if err != nil || res.Code != 3 || !strings.Contains(res.Output, "out") || !strings.Contains(res.Output, "err") { + t.Fatalf("res=%+v err=%v", res, err) + } + // stdin is closed: a prompt gets EOF instead of hanging + res, _ = h.Exec(context.Background(), "read x; echo got=[$x] rc=$?", 1000) + if !strings.Contains(res.Output, "got=[]") { + t.Fatalf("read from stdin: %q", res.Output) + } + // non-interactive env + res, _ = h.Exec(context.Background(), "echo $GIT_TERMINAL_PROMPT $PAGER", 1000) + if strings.TrimSpace(res.Output) != "0 cat" { + t.Fatalf("env: %q", res.Output) + } +} + +func TestLocalExecCapsOutputKeepingHeadAndTail(t *testing.T) { + res, err := (&Local{}).Exec(context.Background(), "echo HEAD; head -c 100000 /dev/zero | tr '\\0' x; echo; echo TAIL", 2000) + if err != nil || !res.Truncated || len(res.Output) > 2200 || !strings.HasPrefix(res.Output, "HEAD") || !strings.Contains(res.Output, "TAIL") { + t.Fatalf("truncated=%v len=%d", res.Truncated, len(res.Output)) + } +} + +func TestLocalExecTimeoutKillsChildren(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + start := time.Now() + res, err := (&Local{}).Exec(ctx, "sleep 30 & sleep 30; wait", 1000) + if err != nil || !res.TimedOut || time.Since(start) > 5*time.Second { + t.Fatalf("res=%+v err=%v took %s", res, err, time.Since(start)) + } +} + +func TestLocalFiles(t *testing.T) { + dir := t.TempDir() + h := &Local{} + p := dir + "/f.toml" + if err := h.WriteFile(context.Background(), p, []byte("a=1"), 0o600); err != nil { + t.Fatal(err) + } + b, err := h.ReadFile(context.Background(), p) + fi, _ := h.Stat(context.Background(), p) + if err != nil || string(b) != "a=1" || fi.Mode().Perm() != 0o600 { + t.Fatalf("read=%q err=%v mode=%v", b, err, fi.Mode().Perm()) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 78eda12..82b6fce 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -135,7 +135,7 @@ func (s *Server) handle(ctx context.Context, raw []byte) *response { func (s *Server) toolList() []map[string]any { var out []map[string]any for _, t := range s.Reg.All() { - if toolkit.IsLongRunning(t) || toolkit.IsAgentOnly(t) { + if toolkit.IsLongRunning(t) || toolkit.IsAgentOnly(t) || toolkit.IsOperatorOnly(t) { continue // watchers never return; bash/read/edit are the client's own job } d := map[string]any{ @@ -186,7 +186,7 @@ func (s *Server) callTool(ctx context.Context, name string, args map[string]any) if toolkit.IsLongRunning(t) { return callResult{text: name + " is a long-running watcher — not callable over MCP; use the CLI"}, true } - if toolkit.IsAgentOnly(t) { + if toolkit.IsAgentOnly(t) || toolkit.IsOperatorOnly(t) { return callResult{text: name + " is not exported over MCP"}, true } runCtx, cancel := toolkit.WithDeadline(s.Ctx, 120*time.Second) diff --git a/internal/runbook/runbook.go b/internal/runbook/runbook.go index a7a2a30..6c71820 100644 --- a/internal/runbook/runbook.go +++ b/internal/runbook/runbook.go @@ -101,6 +101,20 @@ func Get(name string) (*Runbook, error) { // Run executes a runbook against a context, printing progress. func Run(rb *Runbook, c *toolkit.Context, reg *toolkit.Registry, out io.Writer) error { + // validate every step before running any: a typo in step 5 must not + // surface after steps 1-4 already changed things + for _, s := range rb.Steps { + if s.Manual != "" || s.Tool == "" { + continue + } + t, ok := reg.Get(s.Tool) + if !ok { + return fmt.Errorf("runbook %q step %q references unknown tool %q", rb.Name, s.Name, s.Tool) + } + if toolkit.IsOperatorOnly(t) && c.ToolName != "" { + return fmt.Errorf("runbook %q step %q uses %s, which handles key material — run the runbook yourself with `cometcli runbook run %s`", rb.Name, s.Name, s.Tool, rb.Name) + } + } fmt.Fprintf(out, "▶ runbook: %s — %s\n\n", rb.Name, rb.Desc) for i, s := range rb.Steps { fmt.Fprintf(out, "── step %d/%d: %s\n", i+1, len(rb.Steps), s.Name) @@ -114,10 +128,10 @@ func Run(rb *Runbook, c *toolkit.Context, reg *toolkit.Registry, out io.Writer) } continue } - t, ok := reg.Get(s.Tool) - if !ok { - return fmt.Errorf("runbook references unknown tool %q", s.Tool) + if s.Tool == "" { + continue // note-only step } + t, _ := reg.Get(s.Tool) res, err := t.Run(c, s.Args) if err != nil { if s.Optional { diff --git a/internal/runbook/runbook_test.go b/internal/runbook/runbook_test.go new file mode 100644 index 0000000..05c44ec --- /dev/null +++ b/internal/runbook/runbook_test.go @@ -0,0 +1,102 @@ +package runbook + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +type stub struct { + name string + err error + ran *[]string + op bool +} + +func (s stub) Name() string { return s.name } +func (s stub) Desc() string { return s.name } +func (s stub) Schema() map[string]any { return toolkit.ObjSchema(nil) } +func (s stub) Tier() toolkit.Tier { return toolkit.TierObserve } +func (s stub) OperatorOnly() bool { return s.op } +func (s stub) Run(*toolkit.Context, toolkit.Args) (*toolkit.Result, error) { + *s.ran = append(*s.ran, s.name) + return &toolkit.Result{Text: s.name + " ok"}, s.err +} + +func setup() (*toolkit.Registry, *[]string) { + var ran []string + reg := toolkit.NewRegistry() + reg.Register(stub{name: "a.ok", ran: &ran}) + reg.Register(stub{name: "a.fail", ran: &ran, err: errors.New("boom")}) + reg.Register(stub{name: "keys.add", ran: &ran, op: true}) + return reg, &ran +} + +func TestRunStepsOptionalAndManual(t *testing.T) { + reg, ran := setup() + asked := 0 + c := &toolkit.Context{Context: context.Background(), Approver: func(*toolkit.Context, string, toolkit.Tier, map[string]any) (bool, error) { asked++; return true, nil }} + rb := &Runbook{Name: "r", Steps: []Step{ + {Name: "one", Tool: "a.ok"}, + {Name: "may fail", Tool: "a.fail", Optional: true}, + {Name: "human", Manual: "restart the sentry"}, + {Name: "note only", Note: "just a note"}, + {Name: "two", Tool: "a.ok"}, + }} + var out bytes.Buffer + if err := Run(rb, c, reg, &out); err != nil { + t.Fatal(err) + } + if strings.Join(*ran, ",") != "a.ok,a.fail,a.ok" || asked != 1 || !strings.Contains(out.String(), "✓ runbook complete") { + t.Fatalf("ran=%v asked=%d out=%s", *ran, asked, out.String()) + } + *ran = nil + rb.Steps[1].Optional = false + if err := Run(rb, c, reg, &out); err == nil || !strings.Contains(err.Error(), `step "may fail" failed`) || len(*ran) != 2 { + t.Fatalf("required failure: %v ran=%v", err, *ran) + } +} + +func TestRunValidatesBeforeRunningAnything(t *testing.T) { + reg, ran := setup() + c := &toolkit.Context{Context: context.Background()} + rb := &Runbook{Name: "r", Steps: []Step{{Name: "one", Tool: "a.ok"}, {Name: "typo", Tool: "a.okk"}}} + if err := Run(rb, c, reg, &bytes.Buffer{}); err == nil || len(*ran) != 0 { + t.Fatalf("unknown tool must fail before step 1 runs: %v ran=%v", err, *ran) + } + // key management steps: fine for the operator, refused for the agent + rb = &Runbook{Name: "keys", Steps: []Step{{Name: "add", Tool: "keys.add"}}} + if err := Run(rb, c, reg, &bytes.Buffer{}); err != nil { + t.Fatalf("operator run: %v", err) + } + c.ToolName = "runbook.run" + *ran = nil + if err := Run(rb, c, reg, &bytes.Buffer{}); err == nil || len(*ran) != 0 { + t.Fatalf("agent ran a key-management step: %v", err) + } +} + +func TestCustomRunbooksLoad(t *testing.T) { + home := t.TempDir() + t.Setenv("COMETCLI_HOME", home) + os.MkdirAll(filepath.Join(home, "runbooks"), 0o700) + os.WriteFile(filepath.Join(home, "runbooks", "mine.yaml"), []byte("name: my-check\ndesc: checks\nsteps:\n - name: status\n tool: node.status\n - name: human\n manual: do it\n"), 0o600) + rb, err := GetAll("my-check") + if err != nil || len(rb.Steps) != 2 || rb.Steps[1].Manual != "do it" { + t.Fatalf("custom = %+v %v", rb, err) + } + if len(Builtins()) == 0 { + t.Fatal("no builtin runbooks") + } + for _, b := range Builtins() { + if _, err := GetAll(b.Name); err != nil { + t.Errorf("builtin %s: %v", b.Name, err) + } + } +} diff --git a/internal/toolkit/toolkit.go b/internal/toolkit/toolkit.go index 412b4f1..06c2520 100644 --- a/internal/toolkit/toolkit.go +++ b/internal/toolkit/toolkit.go @@ -306,3 +306,17 @@ type Timeouter interface { type OutputLimiter interface { OutputLimit() int } + +// OperatorOnly is implemented by tools the agent must never call — key +// management, whose output (a generated mnemonic) or input (a mnemonic +// read from stdin) must stay between the operator and their terminal. +// They remain CLI subcommands. +type OperatorOnly interface { + OperatorOnly() bool +} + +// IsOperatorOnly reports whether t is operator-only. +func IsOperatorOnly(t Tool) bool { + o, ok := t.(OperatorOnly) + return ok && o.OperatorOnly() +} diff --git a/internal/tools/fleet/fleet.go b/internal/tools/fleet/fleet.go index 22bb3b9..10e2ff5 100644 --- a/internal/tools/fleet/fleet.go +++ b/internal/tools/fleet/fleet.go @@ -13,6 +13,7 @@ import ( "github.com/abhijitkrm/cometcli/internal/config" "github.com/abhijitkrm/cometcli/internal/monitor" "github.com/abhijitkrm/cometcli/internal/toolkit" + "github.com/abhijitkrm/cometcli/internal/tools/shell" ) // Register adds fleet.* tools. @@ -228,8 +229,26 @@ func (shellTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error if len(names) == 0 { return nil, fmt.Errorf("no profiles configured") } - if err := c.Approve(fmt.Sprintf("run on %d host(s): %s", len(names), cmd), - toolkit.TierLocalChange, map[string]any{"cmd": cmd, "profiles": names}); err != nil { + // same classification and gate as the bash tool: key material and + // state resets refused, transactions always asked, rules applied + binary := "" + if p := c.Cfg.Profiles[names[0]]; p != nil { + binary = p.Binary + } + v := shell.Classify(cmd, shell.Opts{Binary: binary}) + tier, why := v.Tier, v.Reason + if strings.HasPrefix(why, "runs script") { + // scripts can't be inspected on every remote host: treat a fleet- + // wide script run like a transaction — it always asks + tier, why = toolkit.TierOnChain, why+" on every host (not inspectable remotely)" + } + if err := c.Check(toolkit.Gate{ + Request: toolkit.Request{Tool: "fleet.shell", Specs: v.Segments, Kind: "command"}, + Tier: tier, + Prompt: fmt.Sprintf("run on %d host(s): %s", len(names), cmd), + Detail: map[string]any{"command": cmd, "profiles": strings.Join(names, ", "), "why": why}, + Forbidden: v.Forbidden, + }); err != nil { return nil, err } to := time.Duration(a.Int("timeout", 30)) * time.Second diff --git a/internal/tools/fleet/fleet_test.go b/internal/tools/fleet/fleet_test.go new file mode 100644 index 0000000..3b403cc --- /dev/null +++ b/internal/tools/fleet/fleet_test.go @@ -0,0 +1,66 @@ +package fleet + +import ( + "context" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +func ctx(t *testing.T, bypass bool) (*toolkit.Context, *[]string) { + t.Helper() + var asked []string + cfg := &config.Config{Profiles: map[string]*config.Profile{ + "val0": {Name: "val0", Binary: "evmd", Transport: config.Transport{Type: "local"}}, + "val1": {Name: "val1", Binary: "evmd", Transport: config.Transport{Type: "local"}}, + }} + c := &toolkit.Context{Context: context.Background(), Cfg: cfg, AutoApproveBelow: toolkit.TierLocalChange, + Approver: func(_ *toolkit.Context, p string, _ toolkit.Tier, _ map[string]any) (bool, error) { + asked = append(asked, p) + return false, nil + }} + if bypass { + c.AutoApproveBelow = toolkit.TierOnChain + } + return c, &asked +} + +func TestFleetShellReadRunsEverywhere(t *testing.T) { + c, asked := ctx(t, false) + res, err := (shellTool{}).Run(c, toolkit.Args{"cmd": "echo up"}) + if err != nil || strings.Count(res.Text, "up") != 2 || len(*asked) != 0 { + t.Fatalf("read across fleet: %v %v asked=%v", res, err, *asked) + } +} + +func TestFleetShellGatesLikeBash(t *testing.T) { + c, asked := ctx(t, true) // bypass: local changes don't ask + if _, err := (shellTool{}).Run(c, toolkit.Args{"cmd": "cat ~/.evmd/config/priv_validator_key.json"}); err == nil || !strings.Contains(err.Error(), "refused") { + t.Fatalf("key material: %v", err) + } + if _, err := (shellTool{}).Run(c, toolkit.Args{"cmd": "docker exec v evmd tx gov vote 3 yes --from v"}); err == nil || len(*asked) != 1 { + t.Fatalf("a fleet-wide tx must ask even in bypass: %v asked=%v", err, *asked) + } + if _, err := (shellTool{}).Run(c, toolkit.Args{"cmd": "./vote.sh"}); err == nil || len(*asked) != 2 { + t.Fatalf("fleet-wide script must ask: %v asked=%v", err, *asked) + } + c.Rules, _ = toolkit.NewRules(nil, nil, []string{"fleet.shell(systemctl:*)"}) + if _, err := (shellTool{}).Run(c, toolkit.Args{"cmd": "systemctl restart evmd"}); err == nil || !strings.Contains(err.Error(), "denied by permission rule") { + t.Fatalf("rules ignored: %v", err) + } +} + +func TestFleetExecRefusesMutatingTools(t *testing.T) { + reg := toolkit.NewRegistry() + Register(reg) + c, _ := ctx(t, false) + ex, _ := reg.Get("fleet.exec") + if _, err := ex.Run(c, toolkit.Args{"tool": "fleet.shell"}); err == nil || !strings.Contains(err.Error(), "refusing") { + t.Fatalf("fan-out of a mutating tool: %v", err) + } + if _, err := ex.Run(c, toolkit.Args{"tool": "nope"}); err == nil { + t.Fatal("unknown tool accepted") + } +} diff --git a/internal/tools/keystool/keys.go b/internal/tools/keystool/keys.go index 6d26581..aac10ae 100644 --- a/internal/tools/keystool/keys.go +++ b/internal/tools/keystool/keys.go @@ -5,6 +5,7 @@ import ( "bufio" "fmt" "os" + "regexp" "strings" "github.com/abhijitkrm/cometcli/internal/config" @@ -49,6 +50,7 @@ func (addTool) Schema() map[string]any { }, "name") } func (addTool) Tier() toolkit.Tier { return toolkit.TierLocalChange } +func (addTool) OperatorOnly() bool { return true } func (addTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { name := a.String("name", "") @@ -183,6 +185,7 @@ func (rmTool) Schema() map[string]any { return toolkit.ObjSchema(map[string]any{"name": toolkit.Str("key name")}, "name") } func (rmTool) Tier() toolkit.Tier { return toolkit.TierLocalChange } +func (rmTool) OperatorOnly() bool { return true } func (rmTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { name := a.String("name", "") @@ -199,6 +202,8 @@ func (rmTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, error) { return &toolkit.Result{Text: "deleted " + name, Data: map[string]any{"deleted": name}}, nil } +var hexAddrRe = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) + type convertTool struct{} func (convertTool) Name() string { return "keys.convert" } @@ -217,6 +222,9 @@ func (convertTool) Run(c *toolkit.Context, a toolkit.Args) (*toolkit.Result, err addr := a.String("address", "") var b strings.Builder if strings.HasPrefix(addr, "0x") { + if !hexAddrRe.MatchString(addr) { + return nil, fmt.Errorf("%q is not a 0x address (want 0x + 40 hex characters)", addr) + } prefix := a.String("prefix", "") if prefix == "" && c.Profile != nil { prefix = c.Profile.Bech32Prefix diff --git a/internal/tools/keystool/keys_test.go b/internal/tools/keystool/keys_test.go new file mode 100644 index 0000000..55d21d2 --- /dev/null +++ b/internal/tools/keystool/keys_test.go @@ -0,0 +1,60 @@ +package keystool + +import ( + "context" + "strings" + "testing" + + "github.com/abhijitkrm/cometcli/internal/config" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +func TestKeyToolsRoundTripAndOperatorOnly(t *testing.T) { + t.Setenv("COMETCLI_HOME", t.TempDir()) + t.Setenv("COMETCLI_KEYRING_PASSWORD", "pw") + reg := toolkit.NewRegistry() + Register(reg) + c := &toolkit.Context{Context: context.Background(), Profile: &config.Profile{Name: "t", Bech32Prefix: "cosmos", Signer: config.Signer{Backend: "file"}}, + AutoApproveBelow: toolkit.TierOnChain} + add, _ := reg.Get("keys.add") + rm, _ := reg.Get("keys.rm") + if !toolkit.IsOperatorOnly(add) || !toolkit.IsOperatorOnly(rm) { + t.Fatal("key management must be operator-only") + } + for _, n := range []string{"keys.list", "keys.show", "keys.convert"} { + if tl, _ := reg.Get(n); toolkit.IsOperatorOnly(tl) { + t.Fatalf("%s should stay agent-callable", n) + } + } + res, err := add.Run(c, toolkit.Args{"name": "ops", "privkey-hex": "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"}) + if err != nil { + t.Fatal(err) + } + addr := res.Data["address"].(string) + show, _ := reg.Get("keys.show") + sr, err := show.Run(c, toolkit.Args{"name": "ops"}) + if err != nil || !strings.Contains(sr.Text, addr) || !strings.Contains(sr.Text, "cosmosvaloper") { + t.Fatalf("show: %v %v", sr, err) + } + conv, _ := reg.Get("keys.convert") + cr, err := conv.Run(c, toolkit.Args{"address": addr}) + if err != nil { + t.Fatal(err) + } + hex := strings.Fields(strings.Split(cr.Text, "\n")[1])[1] + back, err := conv.Run(c, toolkit.Args{"address": hex}) + if err != nil || !strings.Contains(back.Text, addr) { + t.Fatalf("hex→bech32 round trip: %v %v", back, err) + } + for _, bad := range []string{"0xzz", "0x1234"} { + if _, err := conv.Run(c, toolkit.Args{"address": bad}); err == nil { + t.Errorf("%q accepted", bad) + } + } + if _, err := rm.Run(c, toolkit.Args{"name": "ops"}); err != nil { + t.Fatal(err) + } + if _, err := show.Run(c, toolkit.Args{"name": "ops"}); err == nil { + t.Fatal("key still present after rm") + } +} From a7ffbb931ba89ab6ffe902e3ccec5cd35d985925 Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 20:40:22 +0530 Subject: [PATCH 15/48] test: golden snapshots for the inline UI; race detector + coverage in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golden files (internal/tui/chatui/testdata, regenerate with -update) for the idle prompt, slash menu, bash and transaction approval dialogs, a running turn, the accept-edits footer and a transcript of tool calls, diffs, subagent results, todos and markdown — layout changes show up as diffs. CI runs go test -race with a coverage summary. Fixed a data race in the approval test (shared variable polled across goroutines → channel). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +- internal/tui/chatui/chatui_test.go | 13 +-- internal/tui/chatui/golden_test.go | 94 +++++++++++++++++++ .../testdata/accept_edits_footer.golden | 1 + .../tui/chatui/testdata/approval_bash.golden | 12 +++ .../tui/chatui/testdata/approval_tx.golden | 12 +++ internal/tui/chatui/testdata/idle.golden | 4 + internal/tui/chatui/testdata/running.golden | 7 ++ .../tui/chatui/testdata/slash_menu.golden | 7 ++ .../tui/chatui/testdata/transcript.golden | 23 +++++ 10 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 internal/tui/chatui/golden_test.go create mode 100644 internal/tui/chatui/testdata/accept_edits_footer.golden create mode 100644 internal/tui/chatui/testdata/approval_bash.golden create mode 100644 internal/tui/chatui/testdata/approval_tx.golden create mode 100644 internal/tui/chatui/testdata/idle.golden create mode 100644 internal/tui/chatui/testdata/running.golden create mode 100644 internal/tui/chatui/testdata/slash_menu.golden create mode 100644 internal/tui/chatui/testdata/transcript.golden diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 990f3fb..8aec2f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,10 @@ jobs: cache: true - name: vet run: go vet ./... - - name: test - run: go test ./... -count=1 + - name: test (race detector, coverage) + run: | + go test -race -count=1 -coverprofile=cover.out ./... + go tool cover -func=cover.out | tail -1 - name: build run: go build -o cometcli ./cmd/cometcli - name: smoke diff --git a/internal/tui/chatui/chatui_test.go b/internal/tui/chatui/chatui_test.go index 72acb8a..8f38c67 100644 --- a/internal/tui/chatui/chatui_test.go +++ b/internal/tui/chatui/chatui_test.go @@ -55,21 +55,22 @@ func key(m *model, k tea.KeyType) tea.Cmd { func TestApprovalYesAlwaysNo(t *testing.T) { m, _ := newTestModel(t) - var got tea.Msg - m.send = func(msg tea.Msg) { got = msg } + sent := make(chan tea.Msg, 1) + m.send = func(msg tea.Msg) { sent <- msg } ctx := &toolkit.Context{Context: context.Background()} ask := func(detail map[string]any, tier toolkit.Tier) chan approvalResult { done := make(chan approvalResult, 1) - got = nil go func() { ok, err := m.approve(ctx, "run: docker compose up -d", tier, detail) done <- approvalResult{ok, err} }() - for i := 0; got == nil && i < 200; i++ { - time.Sleep(5 * time.Millisecond) + select { + case msg := <-sent: + m.Update(msg) + case <-time.After(2 * time.Second): + t.Fatal("approval request never arrived") } - m.Update(got) return done } diff --git a/internal/tui/chatui/golden_test.go b/internal/tui/chatui/golden_test.go new file mode 100644 index 0000000..fbcb7ee --- /dev/null +++ b/internal/tui/chatui/golden_test.go @@ -0,0 +1,94 @@ +package chatui + +import ( + "flag" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/abhijitkrm/cometcli/internal/agent" + "github.com/abhijitkrm/cometcli/internal/toolkit" +) + +var update = flag.Bool("update", false, "rewrite golden files") + +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]`) + +// golden compares a rendered screen (ANSI stripped, trailing spaces +// trimmed) with testdata/.golden. +func golden(t *testing.T, name, got string) { + t.Helper() + got = ansiRe.ReplaceAllString(got, "") + lines := strings.Split(got, "\n") + for i, l := range lines { + lines[i] = strings.TrimRight(l, " ") + } + got = strings.Join(lines, "\n") + p := filepath.Join("testdata", name+".golden") + if *update { + os.MkdirAll("testdata", 0o755) + os.WriteFile(p, []byte(got), 0o644) + return + } + want, err := os.ReadFile(p) + if err != nil { + t.Fatalf("%v — run go test -update", err) + } + if string(want) != got { + t.Errorf("%s changed (go test -update to accept):\n--- want\n%s\n--- got\n%s", name, want, got) + } +} + +func goldenModel(t *testing.T) *model { + m, _ := newTestModel(t) + m.width = 80 + m.ta.SetWidth(74) + return m +} + +func TestGoldenScreens(t *testing.T) { + m := goldenModel(t) + golden(t, "idle", m.View()) + + typeText(m, "/c") + golden(t, "slash_menu", m.View()) + m.ta.Reset() + m.sugg = nil + + m.approval = &pendingApproval{prompt: "run on local: docker compose up -d", tier: toolkit.TierLocalChange, + detail: map[string]any{"command": "docker compose up -d", "description": "start the validator stack", "host": "local", + "why": "changes containers (docker compose up)", toolkit.RuleHint: "bash(docker compose:*)"}} + golden(t, "approval_bash", m.View()) + + m.approval = &pendingApproval{prompt: `broadcast transaction +{"chain_id": "primium-1", "messages": ["/cosmos.gov.v1.MsgVote {proposalId:3 option:VOTE_OPTION_YES}"], "fee": "140000000000000adex"}`, + tier: toolkit.TierOnChain, detail: map[string]any{"action": "gov-vote", toolkit.RuleHint: "val.vote"}} + golden(t, "approval_tx", m.View()) + m.approval = nil + + m.running, m.started = true, time.Now() + m.onEvent(agent.Event{Kind: agent.EvDelta, Text: "The validator missed 12 blocks in the last window; peers are"}) + golden(t, "running", m.View()) + m.running = false + m.live.Reset() + + m.a.Policy.Mode = agent.ModeAcceptEdits + golden(t, "accept_edits_footer", m.footer(80)) +} + +func TestGoldenTranscript(t *testing.T) { + m := goldenModel(t) + m.printed = nil + m.onEvent(agent.Event{Kind: agent.EvToolStart, Tool: "bash", Args: map[string]any{"command": "docker ps --format '{{.Names}}'"}}) + m.onEvent(agent.Event{Kind: agent.EvToolResult, Tool: "bash", Output: "primium-validator0\nprimium-validator1"}) + m.onEvent(agent.Event{Kind: agent.EvToolStart, Tool: "edit", Args: map[string]any{"file_path": "config/app.toml"}}) + m.onEvent(agent.Event{Kind: agent.EvToolResult, Tool: "edit", Output: "--- config/app.toml\n [api]\n- swagger = true\n+ swagger = false"}) + m.onEvent(agent.Event{Kind: agent.EvToolStart, Tool: "↳ grep", Args: map[string]any{"pattern": "panic", "path": "/var/log"}}) + m.onEvent(agent.Event{Kind: agent.EvToolResult, Tool: "↳ grep", Err: "no matches for panic under /var/log"}) + m.onEvent(agent.Event{Kind: agent.EvTodos, Todos: []agent.Todo{{Content: "stop node", Status: "completed"}, {Content: "swap binary", Status: "in_progress"}, {Content: "start node", Status: "pending"}}}) + m.onEvent(agent.Event{Kind: agent.EvText, Text: "Both validators are **up**.\n\n- validator0: height 162248\n- validator1: height 162248"}) + golden(t, "transcript", strings.Join(m.printed, "\n")) +} diff --git a/internal/tui/chatui/testdata/accept_edits_footer.golden b/internal/tui/chatui/testdata/accept_edits_footer.golden new file mode 100644 index 0000000..7ce1684 --- /dev/null +++ b/internal/tui/chatui/testdata/accept_edits_footer.golden @@ -0,0 +1 @@ + ⏵⏵ accept edits on (shift+tab to cycle) general · fake/m \ No newline at end of file diff --git a/internal/tui/chatui/testdata/approval_bash.golden b/internal/tui/chatui/testdata/approval_bash.golden new file mode 100644 index 0000000..7b7a26a --- /dev/null +++ b/internal/tui/chatui/testdata/approval_bash.golden @@ -0,0 +1,12 @@ +╭──────────────────────────────────────────────────────────────────────────────╮ +│ Bash command │ +│ │ +│ docker compose up -d │ +│ start the validator stack │ +│ why: changes containers (docker compose up) │ +│ │ +│ Do you want to proceed? │ +│ ❯ 1. Yes │ +│ 2. Yes, and don't ask again for bash(docker compose:*) │ +│ 3. No, and tell cometcli what to do differently (esc) │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/tui/chatui/testdata/approval_tx.golden b/internal/tui/chatui/testdata/approval_tx.golden new file mode 100644 index 0000000..f4c1077 --- /dev/null +++ b/internal/tui/chatui/testdata/approval_tx.golden @@ -0,0 +1,12 @@ +╭──────────────────────────────────────────────────────────────────────────────╮ +│ Transaction │ +│ │ +│ broadcast transaction │ +│ {"chain_id": "primium-1", "messages": ["/cosmos.gov.v1.MsgVote {proposalId:3 │ +│ option:VOTE_OPTION_YES}"], "fee": "140000000000000adex"} │ +│ action: gov-vote │ +│ │ +│ This broadcasts a transaction. Do you want to proceed? │ +│ ❯ 1. Yes │ +│ 2. No, and tell cometcli what to do differently (esc) │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/tui/chatui/testdata/idle.golden b/internal/tui/chatui/testdata/idle.golden new file mode 100644 index 0000000..6581927 --- /dev/null +++ b/internal/tui/chatui/testdata/idle.golden @@ -0,0 +1,4 @@ +╭──────────────────────────────────────────────────────────────────────────────╮ +│ ❯ Try "check my validator's last 100 log lines for errors" │ +╰──────────────────────────────────────────────────────────────────────────────╯ + ? for shortcuts general · fake/m \ No newline at end of file diff --git a/internal/tui/chatui/testdata/running.golden b/internal/tui/chatui/testdata/running.golden new file mode 100644 index 0000000..9cac084 --- /dev/null +++ b/internal/tui/chatui/testdata/running.golden @@ -0,0 +1,7 @@ +● The validator missed 12 blocks in the last window; peers are + +· Working… (0s · ↑ 0 tokens · esc to interrupt) +╭──────────────────────────────────────────────────────────────────────────────╮ +│ ❯ Try "check my validator's last 100 log lines for errors" │ +╰──────────────────────────────────────────────────────────────────────────────╯ + ? for shortcuts general · fake/m \ No newline at end of file diff --git a/internal/tui/chatui/testdata/slash_menu.golden b/internal/tui/chatui/testdata/slash_menu.golden new file mode 100644 index 0000000..8045767 --- /dev/null +++ b/internal/tui/chatui/testdata/slash_menu.golden @@ -0,0 +1,7 @@ +╭──────────────────────────────────────────────────────────────────────────────╮ +│ ❯ /c │ +╰──────────────────────────────────────────────────────────────────────────────╯ +❯ /clear clear conversation, start a new audit session + /compact [focus] summarize the conversation to free context + /cost token usage for this session + ? for shortcuts general · fake/m \ No newline at end of file diff --git a/internal/tui/chatui/testdata/transcript.golden b/internal/tui/chatui/testdata/transcript.golden new file mode 100644 index 0000000..00d8aac --- /dev/null +++ b/internal/tui/chatui/testdata/transcript.golden @@ -0,0 +1,23 @@ + +● Bash(docker ps --format '{{.Names}}') + ⎿ primium-validator0 + primium-validator1 + +● Update(config/app.toml) + ⎿ --- config/app.toml + [api] + - swagger = true + + swagger = false + +● ↳ Grep(panic in /var/log) + ⎿ no matches for panic under /var/log + +● Update todos + ⎿ ☒ stop node + ☐ swap binary + ☐ start node + +● Both validators are up. + + • validator0: height 162248 + • validator1: height 162248 \ No newline at end of file From c3ebaad61e38f08996881c8baffe66fb044db31a Mon Sep 17 00:00:00 2001 From: Abhijit Date: Tue, 6 Oct 2026 20:42:22 +0530 Subject: [PATCH 16/48] evm client: malformed quantities and HTTP errors are errors hexToUint64 returned 0 on a parse failure, so a malformed eth_blockNumber read as block 0 and evm.parity / the monitor reported a huge false drift. Quantities must be 0x-hex or the call fails; non-2xx responses (a proxy's 502 page) report the status instead of 'invalid character <'. Tests: quantities, syncing false vs progress object, txpool, client version, decimal-string rejection, RPC errors, HTTP errors. Co-Authored-By: Claude Opus 5.5 --- internal/client/evm/evm.go | 64 +++++++++++++++++---------- internal/client/evm/evm_test.go | 77 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 24 deletions(-) create mode 100644 internal/client/evm/evm_test.go diff --git a/internal/client/evm/evm.go b/internal/client/evm/evm.go index 6c9dc82..354b7c4 100644 --- a/internal/client/evm/evm.go +++ b/internal/client/evm/evm.go @@ -7,8 +7,10 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "strconv" + "strings" "sync/atomic" "time" ) @@ -56,9 +58,13 @@ func (c *Client) Call(ctx context.Context, method string, params []any, out any) return err } defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 300)) + return fmt.Errorf("%s: HTTP %d: %s", method, resp.StatusCode, strings.TrimSpace(string(b))) + } var r rpcResp if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return err + return fmt.Errorf("%s: bad JSON-RPC response: %w", method, err) } if r.Error != nil { return fmt.Errorf("%s: rpc error %d: %s", method, r.Error.Code, r.Error.Message) @@ -69,18 +75,36 @@ func (c *Client) Call(ctx context.Context, method string, params []any, out any) return json.Unmarshal(r.Result, out) } -func hexToUint64(s string) uint64 { - n, _ := strconv.ParseUint(s, 0, 64) - return n +// hexToUint64 parses a JSON-RPC quantity ("0x1a"). A malformed value is +// an error, never a silent 0 (which would read as block 0 and fake a +// huge parity drift). +func hexToUint64(s string) (uint64, error) { + if !strings.HasPrefix(s, "0x") && !strings.HasPrefix(s, "0X") { + return 0, fmt.Errorf("bad quantity %q: want 0x-prefixed hex", s) + } + n, err := strconv.ParseUint(s[2:], 16, 64) + if err != nil { + return 0, fmt.Errorf("bad quantity %q: %w", s, err) + } + return n, nil } -// BlockNumber returns the latest EVM block number. -func (c *Client) BlockNumber(ctx context.Context) (uint64, error) { +// quantity calls a method returning one hex quantity. +func (c *Client) quantity(ctx context.Context, method string) (uint64, error) { var hex string - if err := c.Call(ctx, "eth_blockNumber", nil, &hex); err != nil { + if err := c.Call(ctx, method, nil, &hex); err != nil { return 0, err } - return hexToUint64(hex), nil + n, err := hexToUint64(hex) + if err != nil { + return 0, fmt.Errorf("%s: %w", method, err) + } + return n, nil +} + +// BlockNumber returns the latest EVM block number. +func (c *Client) BlockNumber(ctx context.Context) (uint64, error) { + return c.quantity(ctx, "eth_blockNumber") } // Syncing returns nil if synced, or the sync progress object. @@ -103,20 +127,12 @@ func (c *Client) Syncing(ctx context.Context) (map[string]any, error) { // GasPrice returns the current gas price in wei. func (c *Client) GasPrice(ctx context.Context) (uint64, error) { - var hex string - if err := c.Call(ctx, "eth_gasPrice", nil, &hex); err != nil { - return 0, err - } - return hexToUint64(hex), nil + return c.quantity(ctx, "eth_gasPrice") } // PeerCount returns net_peerCount. func (c *Client) PeerCount(ctx context.Context) (uint64, error) { - var hex string - if err := c.Call(ctx, "net_peerCount", nil, &hex); err != nil { - return 0, err - } - return hexToUint64(hex), nil + return c.quantity(ctx, "net_peerCount") } // ClientVersion returns web3_clientVersion. @@ -128,11 +144,7 @@ func (c *Client) ClientVersion(ctx context.Context) (string, error) { // ChainID returns eth_chainId (the EIP-155 id). func (c *Client) ChainID(ctx context.Context) (uint64, error) { - var hex string - if err := c.Call(ctx, "eth_chainId", nil, &hex); err != nil { - return 0, err - } - return hexToUint64(hex), nil + return c.quantity(ctx, "eth_chainId") } // TxPoolStatus returns txpool_status {pending, queued} counts. @@ -143,7 +155,11 @@ func (c *Client) TxPoolStatus(ctx context.Context) (map[string]uint64, error) { } out := map[string]uint64{} for k, v := range m { - out[k] = hexToUint64(v) + n, err := hexToUint64(v) + if err != nil { + return nil, fmt.Errorf("txpool_status %s: %w", k, err) + } + out[k] = n } return out, nil } diff --git a/internal/client/evm/evm_test.go b/internal/client/evm/evm_test.go new file mode 100644 index 0000000..6c5b907 --- /dev/null +++ b/internal/client/evm/evm_test.go @@ -0,0 +1,77 @@ +package evm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func server(t *testing.T, results map[string]string, status int) *Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if status != 200 { + w.WriteHeader(status) + w.Write([]byte("bad gateway")) + return + } + var req rpcReq + json.NewDecoder(r.Body).Decode(&req) + res, ok := results[req.Method] + if !ok { + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}`)) + return + } + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":` + res + `}`)) + })) + t.Cleanup(srv.Close) + return New(srv.URL) +} + +func TestQuantitiesAndSyncing(t *testing.T) { + c := server(t, map[string]string{ + "eth_blockNumber": `"0x279c8"`, "eth_gasPrice": `"0x3b9aca00"`, "net_peerCount": `"0x3"`, + "eth_chainId": `"0x1e241"`, "eth_syncing": `false`, "web3_clientVersion": `"evmd/v0.7.2"`, + "txpool_status": `{"pending":"0x2","queued":"0x0"}`, + }, 200) + ctx := context.Background() + if n, err := c.BlockNumber(ctx); err != nil || n != 162248 { + t.Fatalf("block = %d %v", n, err) + } + if n, _ := c.GasPrice(ctx); n != 1_000_000_000 { + t.Fatalf("gas = %d", n) + } + if n, _ := c.ChainID(ctx); n != 123457 { + t.Fatalf("chain id = %d", n) + } + if s, err := c.Syncing(ctx); err != nil || s != nil { + t.Fatalf("syncing false → nil: %v %v", s, err) + } + if v, _ := c.ClientVersion(ctx); v != "evmd/v0.7.2" { + t.Fatalf("version = %q", v) + } + if p, err := c.TxPoolStatus(ctx); err != nil || p["pending"] != 2 { + t.Fatalf("txpool = %v %v", p, err) + } + c2 := server(t, map[string]string{"eth_syncing": `{"currentBlock":"0x10","highestBlock":"0x20"}`}, 200) + if s, err := c2.Syncing(ctx); err != nil || s["highestBlock"] != "0x20" { + t.Fatalf("syncing object: %v %v", s, err) + } +} + +func TestMalformedAndErrorsAreErrors(t *testing.T) { + ctx := context.Background() + c := server(t, map[string]string{"eth_blockNumber": `"162248"`}, 200) + if _, err := c.BlockNumber(ctx); err == nil || !strings.Contains(err.Error(), "bad quantity") { + t.Fatalf("decimal string must not parse as a block number: %v", err) + } + if _, err := c.GasPrice(ctx); err == nil || !strings.Contains(err.Error(), "method not found") { + t.Fatalf("rpc error: %v", err) + } + c = server(t, nil, 502) + if _, err := c.BlockNumber(ctx); err == nil || !strings.Contains(err.Error(), "HTTP 502") { + t.Fatalf("http error: %v", err) + } +} From 9b9ef26ec570f1de954b1175713e92c8327f43fe Mon Sep 17 00:00:00 2001 From: Abhijit Date: Wed, 7 Oct 2026 09:40:25 +0530 Subject: [PATCH 17/48] tx: sign with cometcli's keyring or inside the node container; choose at signing time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transactions are now prepared → approved → signed → broadcast, with two signers behind tx.Signer: - LocalSigner: cometcli's ops keyring (previous behavior). - ContainerSigner: the node's own keyring inside its container — the key never leaves the node. cometcli builds the unsigned tx (the --generate-only JSON, passed base64 in the command so stdin carries only a password), runs `evmd tx sign --offline --sign-mode direct` and `evmd tx encode` via `docker exec` (locally or over SSH), and broadcasts the result. Gas is simulated with the account's on-chain public key; without one it falls back to 300000 and the approval says so. A file keyring's password is asked for after approval only, never stored or sent to the model; wrong passwords, missing keys and stopped containers get clear errors. Choosing: signer.mode local|container, else the only one available, else the operator picks when both can sign (CLI numbered prompt, TUI dialog); headless runs must set signer.mode. Keyring backend and key address are detected when not configured (test keyring probed, else file; address from metadata account/valoper or `keys show`). New Context hooks: Chooser and Secret (CLI: shared stdin reader, /dev/tty with echo off; TUI: choice dialog and masked input; headless: env COMETCLI_CONTAINER_KEYRING_PASSWORD), carried through agent tool contexts and scope switches. Host exec gained stdin (ExecIn). Tests: a fake `docker` (the test binary on PATH) emulates evmd keys show / tx sign / tx encode with real SIGN_MODE_DIRECT signing, verified by fakenode: test and file keyrings, simulation with an on-chain pubkey vs default gas, no password prompt for a declined tx, wrong password, signer choice / headless refusal / signer.mode. Co-Authored-By: Claude Opus 5.5 --- internal/agent/loop.go | 2 +- internal/agent/scope.go | 1 + internal/cli/chat.go | 7 +- internal/cli/root.go | 44 ++++- internal/client/grpc/account.go | 18 ++ internal/client/grpc/grpc.go | 14 ++ internal/client/host/exec.go | 30 ++++ internal/config/config.go | 10 ++ internal/keys/keys.go | 13 ++ internal/testutil/fakenode/fakedocker.go | 206 +++++++++++++++++++++ internal/testutil/fakenode/fakenode.go | 28 ++- internal/toolkit/context.go | 17 +- internal/toolkit/signers.go | 190 ++++++++++++++++++++ internal/tools/common/common.go | 10 +- internal/tools/common/container_test.go | 141 +++++++++++++++ internal/tui/chatui/chatui.go | 136 +++++++++++++- internal/tx/signer.go | 219 +++++++++++++++++++++++ internal/tx/tx.go | 158 ++++++++-------- 18 files changed, 1150 insertions(+), 94 deletions(-) create mode 100644 internal/testutil/fakenode/fakedocker.go create mode 100644 internal/toolkit/signers.go create mode 100644 internal/tools/common/container_test.go create mode 100644 internal/tx/signer.go diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 05afba2..389b4f7 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -623,7 +623,7 @@ func (a *Agent) execCall(ctx context.Context, call Call) Msg { func (a *Agent) toolCtx(ctx context.Context, t toolkit.Tool, args toolkit.Args) (*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, + Audit: a.Audit(), Approver: a.Ctx.Approver, Chooser: a.Ctx.Chooser, Secret: a.Ctx.Secret, Session: a.Tools, Rules: a.Rules, WorkRoot: a.WorkRoot, ReadOnly: a.Policy.ReadOnly(), AcceptEdits: a.Policy.AcceptEdits(), } diff --git a/internal/agent/scope.go b/internal/agent/scope.go index d6ed6f4..7481c34 100644 --- a/internal/agent/scope.go +++ b/internal/agent/scope.go @@ -75,6 +75,7 @@ func (a *Agent) SwitchScope(p *config.Profile) { c := &toolkit.Context{ Context: old.Context, Profile: p, Cfg: old.Cfg, Out: old.Out, Audit: old.Audit, Approver: old.Approver, AutoApproveBelow: old.AutoApproveBelow, + Chooser: old.Chooser, Secret: old.Secret, } if a.ownCtx { old.Close() diff --git a/internal/cli/chat.go b/internal/cli/chat.go index f6e62ce..91500a9 100644 --- a/internal/cli/chat.go +++ b/internal/cli/chat.go @@ -160,11 +160,14 @@ func chatCtx(cmd *cobra.Command, p *config.Profile, headless bool) (*toolkit.Con lg, _ := audit.Open(name) c := &toolkit.Context{ Context: cmd.Context(), Profile: p, Cfg: cfg, Out: cmd.OutOrStdout(), Audit: lg, - Approver: StdinApprover(cmd.InOrStdin()), } + c.Approver, c.Chooser = StdinPrompts(cmd.InOrStdin()) + c.Secret = TTYSecret if headless { + // never read prompts from stdin (it may be the piped input); a + // secret still comes from the terminal or the env var c.Out = cmd.ErrOrStderr() - c.Approver = headlessApprover + c.Approver, c.Chooser = headlessApprover, nil } if flagYes { c.AutoApproveBelow = toolkit.TierLocalChange diff --git a/internal/cli/root.go b/internal/cli/root.go index 6722681..5958670 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -6,9 +6,11 @@ package cli import ( "bufio" "fmt" + "golang.org/x/term" "io" "os" "sort" + "strconv" "strings" "time" @@ -234,13 +236,37 @@ func NewCtx(cmd *cobra.Command, requireProfile bool) (*toolkit.Context, error) { if flagYes { c.AutoApproveBelow = toolkit.TierLocalChange } - c.Approver = StdinApprover(cmd.InOrStdin()) + c.Approver, c.Chooser = StdinPrompts(cmd.InOrStdin()) + c.Secret = TTYSecret return c, nil } // StdinApprover prompts y/N on the terminal. func StdinApprover(in io.Reader) toolkit.Approver { + a, _ := StdinPrompts(in) + return a +} + +// StdinPrompts returns a y/N approver and a numbered chooser sharing one +// reader (two readers on one stdin would steal each other's input). +func StdinPrompts(in io.Reader) (toolkit.Approver, toolkit.Chooser) { reader := bufio.NewReader(in) + choose := func(c *toolkit.Context, prompt string, options []string) (int, error) { + fmt.Fprintf(c.Out, "\n%s\n", prompt) + for i, o := range options { + fmt.Fprintf(c.Out, " %d. %s\n", i+1, o) + } + fmt.Fprintf(c.Out, "Choose [1-%d]: ", len(options)) + line, err := reader.ReadString('\n') + if err != nil && strings.TrimSpace(line) == "" { + return 0, fmt.Errorf("no choice made: stdin closed") + } + n, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || n < 1 || n > len(options) { + return 0, fmt.Errorf("invalid choice %q", strings.TrimSpace(line)) + } + return n - 1, nil + } 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) keys := make([]string, 0, len(detail)) @@ -271,5 +297,21 @@ func StdinApprover(in io.Reader) toolkit.Approver { return false, err } return strings.EqualFold(strings.TrimSpace(line), "y"), nil + }, choose +} + +// TTYSecret reads a secret from the controlling terminal with echo off. +func TTYSecret(c *toolkit.Context, prompt string) (string, error) { + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return "", fmt.Errorf("%s: no terminal available", prompt) + } + defer tty.Close() + fmt.Fprintf(tty, "%s: ", prompt) + b, err := term.ReadPassword(int(tty.Fd())) + fmt.Fprintln(tty) + if err != nil { + return "", err } + return string(b), nil } diff --git a/internal/client/grpc/account.go b/internal/client/grpc/account.go index 0639edd..382fb40 100644 --- a/internal/client/grpc/account.go +++ b/internal/client/grpc/account.go @@ -6,6 +6,7 @@ import ( authv1beta1 "cosmossdk.io/api/cosmos/auth/v1beta1" "google.golang.org/protobuf/encoding/protowire" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" ) // DecodeAccount extracts (account_number, sequence) from an account Any. @@ -27,6 +28,23 @@ func DecodeAccount(typeURL string, value []byte) (num, seq uint64, err error) { return acc.AccountNumber, acc.Sequence, nil } +// DecodeAccountPubKey extracts the account's public key Any (nil when the +// account has never signed a transaction). +func DecodeAccountPubKey(typeURL string, value []byte) (*anypb.Any, error) { + raw := value + if typeURL == "/ethermint.types.v1.EthAccount" || typeURL == "/cosmos.evm.types.v1.EthAccount" { + raw = fieldBytes(value, 1) + } + var acc authv1beta1.BaseAccount + if err := proto.Unmarshal(raw, &acc); err != nil { + return nil, fmt.Errorf("decoding %s: %w", typeURL, err) + } + if acc.PubKey == nil || acc.PubKey.TypeUrl == "" { + return nil, nil + } + return &anypb.Any{TypeUrl: acc.PubKey.TypeUrl, Value: acc.PubKey.Value}, nil +} + // fieldBytes returns the bytes of a length-delimited field, or nil. func fieldBytes(b []byte, num protowire.Number) []byte { for len(b) > 0 { diff --git a/internal/client/grpc/grpc.go b/internal/client/grpc/grpc.go index f339671..8897b86 100644 --- a/internal/client/grpc/grpc.go +++ b/internal/client/grpc/grpc.go @@ -12,6 +12,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/anypb" authv1beta1 "cosmossdk.io/api/cosmos/auth/v1beta1" bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1" @@ -84,6 +85,19 @@ func (c *Conn) RawConn() *grpc.ClientConn { return c.cc } func (c *Conn) Close() error { return c.cc.Close() } +// AccountPubKey returns the public key the chain has on record for an +// address (nil if it never signed). +func (c *Conn) AccountPubKey(ctx context.Context, bech32Addr string) (*anypb.Any, error) { + res, err := c.Auth.Account(ctx, &authv1beta1.QueryAccountRequest{Address: bech32Addr}) + if err != nil { + return nil, err + } + if res.Account == nil { + return nil, nil + } + return DecodeAccountPubKey(res.Account.TypeUrl, res.Account.Value) +} + // Account fetches account_number/sequence for an address. Cosmos-EVM chains // may return either BaseAccount or EthAccount; both are handled. func (c *Conn) Account(ctx context.Context, bech32Addr string) (num, seq uint64, err error) { diff --git a/internal/client/host/exec.go b/internal/client/host/exec.go index 8cde083..94c43b9 100644 --- a/internal/client/host/exec.go +++ b/internal/client/host/exec.go @@ -28,6 +28,20 @@ type Execer interface { Exec(ctx context.Context, script string, maxOut int) (ExecResult, error) } +// StdinExecer runs a script with input on stdin (secrets, documents). +type StdinExecer interface { + ExecIn(ctx context.Context, script string, stdin []byte, maxOut int) (ExecResult, error) +} + +// ExecIn runs script on h with stdin. Hosts without stdin support error. +func ExecIn(ctx context.Context, h Host, script string, stdin []byte, maxOut int) (ExecResult, error) { + e, ok := h.(StdinExecer) + if !ok { + return ExecResult{}, fmt.Errorf("host %s can't pass stdin to commands", h) + } + return e.ExecIn(ctx, script, stdin, maxOut) +} + // Exec runs script on h via Execer when available. func Exec(ctx context.Context, h Host, script string, maxOut int) (ExecResult, error) { if e, ok := h.(Execer); ok { @@ -95,8 +109,16 @@ func shellBin() string { // Exec runs script under bash (or sh) with stdin from /dev/null. On // cancel or timeout the whole process group gets SIGTERM, then SIGKILL. func (l *Local) Exec(ctx context.Context, script string, maxOut int) (ExecResult, error) { + return l.ExecIn(ctx, script, nil, maxOut) +} + +// ExecIn is Exec with stdin (nil = /dev/null). +func (l *Local) ExecIn(ctx context.Context, script string, stdin []byte, maxOut int) (ExecResult, error) { c := exec.Command(shellBin(), "-c", script) c.Env = append(os.Environ(), nonInteractiveEnv...) + if stdin != nil { + c.Stdin = bytes.NewReader(stdin) + } w := &capWriter{max: maxOut} c.Stdout, c.Stderr = w, w setProcessGroup(c) @@ -136,6 +158,11 @@ func (l *Local) Exec(ctx context.Context, script string, maxOut int) (ExecResult // Exec runs script on the remote host. Cancel kills the session. func (s *SSH) Exec(ctx context.Context, script string, maxOut int) (ExecResult, error) { + return s.ExecIn(ctx, script, nil, maxOut) +} + +// ExecIn is Exec with stdin. +func (s *SSH) ExecIn(ctx context.Context, script string, stdin []byte, maxOut int) (ExecResult, error) { sess, err := s.client.NewSession() if err != nil { return ExecResult{}, err @@ -143,6 +170,9 @@ func (s *SSH) Exec(ctx context.Context, script string, maxOut int) (ExecResult, defer sess.Close() w := &capWriter{max: maxOut} sess.Stdout, sess.Stderr = w, w + if stdin != nil { + sess.Stdin = bytes.NewReader(stdin) + } env := "" for _, e := range nonInteractiveEnv { env += e + " " diff --git a/internal/config/config.go b/internal/config/config.go index dcb3b5e..1774585 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -163,6 +163,16 @@ type Signer struct { CoinType uint32 `yaml:"coin_type,omitempty"` Account uint32 `yaml:"account,omitempty"` Index uint32 `yaml:"index,omitempty"` + // Mode picks the signer when both are available: "" asks at signing + // time, "local" uses cometcli's keyring, "container" the node's. + Mode string `yaml:"mode,omitempty"` + // Container signing: the key lives in the node container's own + // keyring and evmd signs there (`docker exec … evmd tx sign`). + Container string `yaml:"container,omitempty"` // default: service.unit for docker services + ContainerKey string `yaml:"container_key,omitempty"` // default: key + ContainerKeyring string `yaml:"container_keyring,omitempty"` // test | file (default: detect) + ContainerHome string `yaml:"container_home,omitempty"` // evmd --home inside the container + ContainerBinary string `yaml:"container_binary,omitempty"` // default: the profile binary } // AgentConf configures the LLM backend for agent mode. diff --git a/internal/keys/keys.go b/internal/keys/keys.go index dbac656..79dc69b 100644 --- a/internal/keys/keys.go +++ b/internal/keys/keys.go @@ -90,6 +90,19 @@ func ConsAddress(addr []byte, prefix string) (string, error) { } // Bech32ToHex converts a bech32 account address to 0x hex. +// AccFromValoper converts a valoper address to its account address (same +// bytes, the "valoper" suffix dropped from the prefix). +func AccFromValoper(valoper string) (string, error) { + prefix, data, err := bech32.DecodeNoLimit(valoper) + if err != nil { + return "", err + } + if !strings.HasSuffix(prefix, "valoper") { + return "", fmt.Errorf("%s is not a valoper address", valoper) + } + return bech32.Encode(strings.TrimSuffix(prefix, "valoper"), data) +} + func Bech32ToHex(addr string) (string, error) { _, data, err := bech32.DecodeNoLimit(addr) if err != nil { diff --git a/internal/testutil/fakenode/fakedocker.go b/internal/testutil/fakenode/fakedocker.go new file mode 100644 index 0000000..b8199a9 --- /dev/null +++ b/internal/testutil/fakenode/fakedocker.go @@ -0,0 +1,206 @@ +package fakenode + +import ( + "bufio" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "golang.org/x/crypto/sha3" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/known/anypb" + + basev1beta1 "cosmossdk.io/api/cosmos/base/v1beta1" + signingv1beta1 "cosmossdk.io/api/cosmos/tx/signing/v1beta1" + txv1beta1 "cosmossdk.io/api/cosmos/tx/v1beta1" + + "github.com/abhijitkrm/cometcli/internal/keys" +) + +// TestKeyHex is the fixed test key every fake uses — never use it for real. +const TestKeyHex = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318" + +// TestAddress is the eth_secp256k1 cosmos address of TestKeyHex. +func TestAddress() string { + priv, _ := hex.DecodeString(TestKeyHex) + pk := secp256k1.PrivKeyFromBytes(priv) + addr := keys.AddressFor(keys.AlgoEthSecp256k1, pk.PubKey()) + k := keys.Key{Address: addr} + s, _ := k.Bech32("cosmos") + return s +} + +// InstallFakeDocker puts a fake `docker` first on PATH: the test binary +// itself, which must call MaybeRunFakeDocker from TestMain. keyring is +// the backend the container's key lives in ("test" or "file"); password +// is the file keyring's password. +func InstallFakeDocker(t testing.TB, keyring, password string) { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.Symlink(exe, filepath.Join(dir, "docker")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("FAKENODE_DOCKER", "1") + t.Setenv("FAKENODE_KEYRING", keyring) + t.Setenv("FAKENODE_PASSWORD", password) +} + +// MaybeRunFakeDocker runs the fake when the process was started as the +// fake docker, then exits. Call it first thing in TestMain. +func MaybeRunFakeDocker() { + if os.Getenv("FAKENODE_DOCKER") != "1" || filepath.Base(os.Args[0]) != "docker" { + return + } + os.Exit(fakeDocker(os.Args[1:])) +} + +var ( + reKeyring = regexp.MustCompile(`--keyring-backend '?([a-z]+)'?`) + reB64 = regexp.MustCompile(`printf %s '([A-Za-z0-9+/=]+)'`) + reAccNum = regexp.MustCompile(`--account-number (\d+)`) + reSeq = regexp.MustCompile(`--sequence (\d+)`) + reChain = regexp.MustCompile(`--chain-id '([^']+)'`) +) + +// fakeDocker emulates `docker exec -i sh -c