Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/fxamacker/cbor/v2 v2.9.4
github.com/gorilla/websocket v1.5.3
github.com/quic-go/quic-go v0.63.0
github.com/srcfl/ftw/go/protocol v0.0.0-20261017163816-75a8b2c910ad
github.com/srcfl/ftw/go/protocol v0.0.0-20261018144106-0546e9d76dea
golang.org/x/crypto v0.57.0
golang.org/x/net v0.58.0
golang.org/x/sys v0.48.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78
github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/srcfl/ftw/go/protocol v0.0.0-20261017163816-75a8b2c910ad h1:DFoeYtGWX2iVb6QdZMY4rv7HRrhUG649sKs2Yjd6X0s=
github.com/srcfl/ftw/go/protocol v0.0.0-20261017163816-75a8b2c910ad/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU=
github.com/srcfl/ftw/go/protocol v0.0.0-20261018144106-0546e9d76dea h1:aRddS85WDd+mhA1X4oDB+Xz9XsYzrVk4hHcYtvOfJVE=
github.com/srcfl/ftw/go/protocol v0.0.0-20261018144106-0546e9d76dea/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
Expand Down
32 changes: 28 additions & 4 deletions internal/mcp/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,9 @@ func (p *Pool) get(ctx context.Context, sub oauth.Subject) (*agentConn, error) {
}
return c, nil
}
if len(p.conns) >= maxConns {
p.evictLocked()
if len(p.conns) >= maxConns && !p.evictLocked() {
p.mu.Unlock()
return nil, errBusy
}
c = &agentConn{p: p, home: sub.Home, agent: sub.Agent, ready: make(chan struct{}), done: make(chan struct{}),
turn: make(chan struct{}, 1), waiting: map[uint32]chan appwire.Envelope{}, byCmd: map[string]chan appwire.Envelope{},
Expand All @@ -223,21 +224,39 @@ func (p *Pool) get(ctx context.Context, sub oauth.Subject) (*agentConn, error) {
return c, nil
}

func (p *Pool) evictLocked() {
func (p *Pool) evictLocked() bool {
var oldest connKey
var at time.Time
for k, c := range p.conns {
c.mu.Lock()
busy := c.queued > 0 || len(c.turn) > 0
u := c.used
c.mu.Unlock()
select {
case <-c.ready:
default:
continue
}
if busy {
continue
}
if at.IsZero() || u.Before(at) {
oldest, at = k, u
}
}
if c := p.conns[oldest]; c != nil {
c.mu.Lock()
if c.queued > 0 || len(c.turn) > 0 {
c.mu.Unlock()
return false
}
c.evicting = true
c.mu.Unlock()
delete(p.conns, oldest)
go c.close(errIdle)
return true
}
return false
}

// Errors a tool turns into words for the model.
Expand Down Expand Up @@ -280,6 +299,7 @@ type agentConn struct {
closing bool
closeErr error
queued int
evicting bool
used time.Time
nextID uint32
waiting map[uint32]chan appwire.Envelope
Expand Down Expand Up @@ -320,7 +340,7 @@ func (c *agentConn) ended() bool {
func (c *agentConn) idleSince(now time.Time) time.Duration {
c.mu.Lock()
defer c.mu.Unlock()
if c.queued > 0 {
if c.queued > 0 || len(c.turn) > 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude active diagnostic sessions from capacity eviction

This protects a long-running read from the periodic idle sweep, but evictLocked still chooses solely by used and does not check queued or turn (internal/mcp/agent.go:227-240). Since used is not refreshed again until the call releases its turn, a diagnostic read running for several minutes can become the oldest connection and be closed when creation of another session reaches maxConns, aborting an otherwise healthy export. Apply the same busy-session exclusion to capacity eviction.

Useful? React with 👍 / 👎.

return 0
}
return now.Sub(c.used)
Expand Down Expand Up @@ -541,6 +561,10 @@ func (c *agentConn) send(ctx context.Context, t string, id *uint32, body any) er
// take waits for the session's turn. At most maxWaiting calls wait.
func (c *agentConn) take(ctx context.Context) (func(), error) {
c.mu.Lock()
if c.evicting {
c.mu.Unlock()
return nil, errIdle
}
if c.queued >= maxWaiting {
c.mu.Unlock()
return nil, errBusy
Expand Down
185 changes: 185 additions & 0 deletions internal/mcp/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package mcp

import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"strconv"
"strings"
"time"

"github.com/fxamacker/cbor/v2"
"github.com/srcfl/ftw/go/protocol"
)

func apiTools() []*tool {
return []*tool{
{
name: "api_catalog", title: "Start technical troubleshooting",
description: "For technical troubleshooting, start here. Ask the connected box for every diagnostic read it supports, its running version, source-code links and a workflow for collecting bug-report evidence. Includes energy logs, original measurements, planner snapshots, configuration, driver source, logs, support reports and exports. Ordinary status, history and control use the existing tools. The owner-approved diagnostic grant covers these reads; there are no extra endpoint permissions." + dataNote,
scope: readScope(protocol.MsgAPIRead), input: object(nil), readOnly: true,
run: func(ctx context.Context, cc *callCtx, args json.RawMessage) (any, error) {
if err := decodeArgs(args, &struct{}{}); err != nil {
return nil, err
}
return diagnosticAPIRead(ctx, cc, protocol.APIRead{Path: protocol.AgentAPICatalogPath})
},
},
{
name: "api_read", title: "Read full technical diagnostic data",
description: "Use during technical troubleshooting after api_catalog. Read any listed path, replacing {id} or {name} with the URL-encoded real value returned by the box. Technical names, addresses, exact numbers, source code and provenance stay intact. Use /api/support/report for an overview; /api/energy/history for Wh and provenance; /api/series for original measurements; /api/mpc/diagnose/history then /api/mpc/diagnose/at for incident planning. JSON, CSV, text and binary exports are supported. Large answers have byte pages: pass next_offset as offset and sha256 as if_sha256 to detect changed data. For ZIP or tar.gz exports, use archive_member '.' to list files, then a listed member name to read its contents as text. Binary pages use base64. Time parameters accept Unix milliseconds or RFC 3339 with a timezone. Reads use the existing handlers; controls and settings use their command tools." + dataNote,
scope: readScope(protocol.MsgAPIRead), readOnly: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require fresh consent for full diagnostic exports

This tool is authorized by the existing diag.run scope, which is granted by default and is presented on the consent page as merely “Run checks that change nothing” (internal/oauth/oauth.go:92-112). Consequently, every previously authorized assistant with the default read scopes gains access after upgrade to the newly exposed full API—including the unmasked backups and persistent credentials described by this change—without the owner ever consenting to that disclosure. Put these exports behind a new explicit scope, or otherwise require renewed consent with an accurate description.

Useful? React with 👍 / 👎.

input: object(map[string]any{
"path": map[string]any{"type": "string", "description": "Path from api_catalog; URL-encode each path parameter"},
"query": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "Existing API query parameters; all values are strings"},
"max_bytes": integer("Source bytes per page; larger answers return next_offset", 4096, protocol.AgentAPIPageBytes, 64<<10),
"offset": integer("Byte offset from next_offset", 0, 9007199254740991, 0),
"archive_member": map[string]any{"type": "string", "description": "For ZIP or tar.gz: . lists files; a listed name reads that member"},
"if_sha256": map[string]any{"type": "string", "description": "SHA-256 from a prior page; refuses a changed answer"},
}, "path"),
run: func(ctx context.Context, cc *callCtx, args json.RawMessage) (any, error) {
var a struct {
Path string `json:"path"`
Query map[string]string `json:"query"`
MaxBytes *int64 `json:"max_bytes"`
Offset int64 `json:"offset"`
IfSHA256 string `json:"if_sha256"`
ArchiveMember string `json:"archive_member"`
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
if !protocol.ValidAgentAPIPath(a.Path) {
return nil, userError("Choose a path from api_catalog without a query string or fragment. URL-encode each path parameter.")
}
if a.Offset < 0 {
return nil, userError("offset must be zero or a next_offset returned by the box.")
}
if a.IfSHA256 != "" {
b, err := hex.DecodeString(a.IfSHA256)
if err != nil || len(b) != 32 {
return nil, userError("if_sha256 must be the SHA-256 returned by the box.")
}
}
for name, value := range a.Query {
if name == "" || len(name) > 256 || len(value) > 2048 || strings.ContainsAny(name+value, "\x00\r\n") {
return nil, userError("Invalid query parameter.")
}
switch name {
case "since", "until", "since_ms", "until_ms", "ts":
ms, err := strconv.ParseInt(value, 10, 64)
if err != nil {
when, parseErr := time.Parse(time.RFC3339Nano, value)
if parseErr != nil {
return nil, userError("Time parameters must be Unix milliseconds or RFC 3339 with a timezone, such as 2026-10-18T15:00:00+02:00.")
}
ms = when.UnixMilli()
}
if ms < 0 {
return nil, userError("Time parameters must be at or after the Unix epoch.")
}
a.Query[name] = strconv.FormatInt(ms, 10)
}
}
limit := int64(64 << 10)
if a.MaxBytes != nil {
if *a.MaxBytes < 4096 || *a.MaxBytes > protocol.AgentAPIPageBytes {
return nil, userError("max_bytes must be between 4096 and 131072 per page.")
}
limit = *a.MaxBytes
}
return diagnosticAPIRead(ctx, cc, protocol.APIRead{Path: a.Path, Query: a.Query, MaxBytes: limit, Offset: a.Offset, IfSHA256: a.IfSHA256, ArchiveMember: a.ArchiveMember})
},
},
}
}

func diagnosticAPIRead(ctx context.Context, cc *callCtx, read protocol.APIRead) (any, error) {
conn, err := cc.conn(ctx)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, protocol.AgentAPIReadTimeout)
defer cancel()
head, body, err := conn.apiRead(ctx, read)
if err != nil {
var refusal boxError
if errors.As(err, &refusal) && refusal.Code == protocol.ErrScopeDenied {
return nil, userError("The box refused api.read (E_SCOPE_DENIED). Use health_get to check its Core version: it needs a release with full diagnostic API reads, and its grant must include " + readScope(protocol.MsgAPIRead) + ".")
}
return nil, words(err)
}
var data map[string]any
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
if !json.Valid(body) || dec.Decode(&data) != nil || data == nil {
return nil, userError("The box's diagnostic answer was not a JSON object.")
}
// Preserve the box's technical evidence, including exact json.Number
// values. Existing typed reads retain their own display sanitization.
return map[string]any{"path": read.Path, "status": head.Status, "response": data}, nil
}

// apiRead collects the bounded api.* stream. A missing head, a skipped
// sequence or a truncated end never becomes a successful partial answer.
func (c *agentConn) apiRead(ctx context.Context, q protocol.APIRead) (protocol.APIHeadMsg, []byte, error) {
release, err := c.take(ctx)
if err != nil {
return protocol.APIHeadMsg{}, nil, err
}
defer release()
id := c.newID()
ch := c.register(id)
defer c.unregister(id)
if err := c.send(ctx, protocol.MsgAPIRead, &id, q); err != nil {
return protocol.APIHeadMsg{}, nil, err
}
var head protocol.APIHeadMsg
var body []byte
var next uint32
started := false
bad := func() (protocol.APIHeadMsg, []byte, error) {
c.close(errEnded)
return protocol.APIHeadMsg{}, nil, userError("The box's diagnostic response stream was incomplete. Retry the read.")
}
for {
select {
case env := <-ch:
switch env.T {
case protocol.MsgAPIHead:
if started || cbor.Unmarshal(env.B, &head) != nil || head.Status < 100 || head.Status > 599 {
return bad()
}
started = true
case protocol.MsgAPIChunk:
var chunk protocol.APIChunk
if !started || cbor.Unmarshal(env.B, &chunk) != nil || chunk.Seq != next || len(body)+len(chunk.Data) > protocol.AgentAPIMaxBytes {
return bad()
}
next++
body = append(body, chunk.Data...)
case protocol.MsgAPIEnd:
var end protocol.APIEnd
if !started || cbor.Unmarshal(env.B, &end) != nil || end.Bytes != int64(len(body)) || head.Len != nil && *head.Len != end.Bytes {
return bad()
}
c.p.pace(c.home).spend(len(body), c.p.now())
if end.Truncated {
return head, nil, userError("The diagnostic wire answer was truncated. Reduce max_bytes or narrow the query and retry. No partial data was returned.")
}
return head, body, nil
case protocol.MsgError:
return head, nil, decodeBoxError(env.B)
default:
return bad()
}
case <-c.done:
return head, nil, c.closeErr
case <-ctx.Done():
c.close(errTimeout)
return head, nil, errTimeout
}
}
}
Loading
Loading