diff --git a/.changeset/mcp-diagnostic-api-reads.md b/.changeset/mcp-diagnostic-api-reads.md new file mode 100644 index 000000000..87a4cfb3d --- /dev/null +++ b/.changeset/mcp-diagnostic-api-reads.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Give authorized MCP sessions the full diagnostic read API, including the energy ledger, raw series, logs, configuration, driver source and support exports. Keep technical names and addresses, list the connected box's routes, and page large answers without a total size limit. Stop reads when access ends. diff --git a/contract/registry.yaml b/contract/registry.yaml index ca5bdb6cc..0ae5357af 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -141,7 +141,7 @@ scopes: - { name: ftw.members.write, desc: Invite and revoke } - { name: ftw.logs.read, desc: Log excerpts the box redacts } - { name: ftw.config.read, desc: Configuration the box redacts } - - { name: ftw.diag.run, desc: Checks that change nothing } + - { name: ftw.diag.run, desc: Full technical diagnostics and read API } - { name: ftw.dispatch.hold, desc: Hold energy assets for a capped time } - { name: ftw.goals.request, desc: Ask an owner to change a durable goal } # Reserved: no op asks for an update yet, and no role but owner carries it. @@ -445,6 +445,7 @@ reads: - { msg: logs.get, scope: ftw.logs.read, desc: A log excerpt the box redacts } - { msg: config.get, scope: ftw.config.read, desc: A configuration section the box redacts } - { msg: diag.run, scope: ftw.diag.run, desc: A check that changes nothing } + - { msg: api.read, const: MsgAPIRead, scope: ftw.diag.run, desc: Read the full technical diagnostic API } # --------------------------------------------------------------------------- # Cars. A car belongs to its owner, not to the home, and visits chargers. The diff --git a/go/internal/api/api_agent_archive.go b/go/internal/api/api_agent_archive.go new file mode 100644 index 000000000..c15976dee --- /dev/null +++ b/go/internal/api/api_agent_archive.go @@ -0,0 +1,161 @@ +package api + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "io" + "net/http" + "os" + "strings" +) + +// Archive reads stay on the box. Spool a generated export to a private temp +// file, inspect entries without extracting paths, and remove it on return. +// The existing page writer keeps only the requested member's byte page. +func (s *Server) serveAgentArchive(w *agentReadBuffer, r *http.Request, member string) { + file, err := os.CreateTemp("", "ftw-diagnostic-*") + if err != nil { + writeJSON(w, 503, map[string]string{"error": "could not open diagnostic export"}) + return + } + defer os.Remove(file.Name()) + defer file.Close() + spool := &agentArchiveSpool{ctx: r.Context(), file: file, header: make(http.Header), status: 200} + s.serveAgentHTTP(spool, r) + if spool.err != nil || r.Context().Err() != nil { + writeJSON(w, 503, map[string]string{"error": "could not finish diagnostic export"}) + return + } + if _, err = file.Seek(0, io.SeekStart); err != nil { + writeJSON(w, 500, map[string]string{"error": "could not read diagnostic export"}) + return + } + // Preserve the handler's error answer rather than trying to open it as an archive. + if spool.status != 200 { + w.Header().Set("Content-Type", spool.header.Get("Content-Type")) + w.WriteHeader(spool.status) + _, _ = io.Copy(w, file) + return + } + var files []map[string]any + found := false + relative := func(name string) string { + _, rest, ok := strings.Cut(name, "/") + if ok && rest != "" { + return rest + } + return name + } + send := func(name string, size int64, open func() (io.ReadCloser, error)) error { + name = relative(name) + if member == "." { + files = append(files, map[string]any{"name": name, "bytes": size}) + return nil + } + if name != member { + return nil + } + body, err := open() + if err != nil { + return err + } + defer body.Close() + found = true + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, err = io.Copy(w, body) + return err + } + switch strings.Split(spool.header.Get("Content-Type"), ";")[0] { + case "application/zip": + stat, e := file.Stat() + if e != nil { + err = e + break + } + archive, e := zip.NewReader(file, stat.Size()) + if e != nil { + err = e + break + } + for _, entry := range archive.File { + if entry.FileInfo().IsDir() { + continue + } + entry := entry + err = send(entry.Name, int64(entry.UncompressedSize64), entry.Open) + if err != nil || found { + break + } + } + case "application/gzip", "application/x-gzip": + gz, e := gzip.NewReader(file) + if e != nil { + err = e + break + } + defer gz.Close() + archive := tar.NewReader(gz) + for { + header, e := archive.Next() + if e == io.EOF { + break + } + if e != nil { + err = e + break + } + if header.Typeflag != tar.TypeReg { + continue + } + err = send(header.Name, header.Size, func() (io.ReadCloser, error) { return io.NopCloser(archive), nil }) + if err != nil || found { + break + } + } + default: + writeJSON(w, 415, map[string]string{"error": "archive_member requires a ZIP or tar.gz export; omit it to read the original bytes"}) + return + } + if err != nil { + w.failed = err + return + } + if member == "." { + writeJSON(w, 200, map[string]any{"files": files}) + return + } + if !found { + writeJSON(w, 404, map[string]string{"error": "archive member not found; use archive_member '.' to list files"}) + } +} + +type agentArchiveSpool struct { + ctx context.Context + file *os.File + header http.Header + status int + wrote bool + err error +} + +func (s *agentArchiveSpool) Header() http.Header { return s.header } +func (s *agentArchiveSpool) WriteHeader(status int) { + if !s.wrote { + s.status, s.wrote = status, true + } +} +func (s *agentArchiveSpool) Write(body []byte) (int, error) { + if s.err != nil { + return 0, s.err + } + if err := s.ctx.Err(); err != nil { + s.err = err + return 0, err + } + s.WriteHeader(200) + n, err := s.file.Write(body) + s.err = err + return n, err +} diff --git a/go/internal/api/api_agent_catalog.go b/go/internal/api/api_agent_catalog.go new file mode 100644 index 000000000..c5d0e6e9c --- /dev/null +++ b/go/internal/api/api_agent_catalog.go @@ -0,0 +1,156 @@ +package api + +import ( + "net/http" + "net/url" + "regexp" + "runtime/debug" + "sort" + "strings" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" +) + +// The router owns eligibility. These hints only describe queries; they never +// decide which Read routes a connected assistant can reach. +var agentAPIHints = []protocol.AgentAPIRoute{ + {Path: "/api/status", Description: "Current measurements, control state and freshness"}, + {Path: "/api/health", Description: "Box health"}, + {Path: "/api/system/info", Description: "Runtime and host diagnostics, including host details"}, + {Path: "/api/storage/inventory", Description: "Storage use and retention"}, + {Path: "/api/components", Description: "Component versions and state"}, + {Path: "/api/components/history", Description: "Component changes"}, + {Path: "/api/drivers", Description: "Driver diagnostics by configured name"}, + {Path: "/api/data-sources", Description: "Available data sources and freshness"}, + {Path: "/api/battery_models", Description: "Learned battery model and efficiency"}, + {Path: "/api/loadmodel", Description: "Load forecast model and learning"}, + {Path: "/api/pvmodel", Description: "Solar forecast model and learning"}, + {Path: "/api/forecast", Description: "Load and solar forecasts", Query: map[string]string{"range": "Time ahead, such as 24h"}}, + {Path: "/api/prices", Description: "Stored import and export prices", Query: map[string]string{"range": "Time ahead, such as 24h", "since_ms": "Unix milliseconds", "until_ms": "Unix milliseconds"}}, + {Path: "/api/planner/prefs", Description: "Planner preferences"}, + {Path: "/api/mpc/plan", Description: "Full current plan and per-asset slots"}, + {Path: "/api/mpc/diagnose", Description: "Current planner inputs, constraints and result"}, + {Path: "/api/mpc/diagnose/history", Description: "Recorded planning decisions", Query: map[string]string{"since": "Unix milliseconds", "until": "Unix milliseconds", "limit": "Maximum snapshots, up to 5000"}}, + {Path: "/api/mpc/diagnose/at", Description: "The planning snapshot active at an incident", Query: map[string]string{"ts": "Required incident time, Unix milliseconds"}}, + {Path: "/api/history", Description: "Recorded site measurements", Query: map[string]string{"range": "Time back, such as 1h", "points": "Maximum rows"}}, + {Path: "/api/energy/daily", Description: "Daily measured energy and costs", Query: map[string]string{"days": "Number of days"}}, + {Path: "/api/energy/assets", Description: "Energy-ledger assets by configured name"}, + {Path: "/api/energy/history", Description: "Energy-ledger entries in Wh, including flow, source, quality, provenance and sample count", Query: map[string]string{"scope": "system or asset", "asset_id": "Asset id from /api/energy/assets", "since": "Unix milliseconds", "until": "Unix milliseconds", "bucket": "Bucket duration, at least 5m", "limit": "Maximum buckets, up to 5000"}}, + {Path: "/api/savings/daily", Description: "Daily cost comparison and coverage", Query: map[string]string{"days": "Number of days"}}, + {Path: "/api/series/catalog", Description: "Stored drivers, metric names and units"}, + {Path: "/api/series", Description: "One driver's measured series with min, max, sample count, resolution and bounds", Query: map[string]string{"driver": "Required driver name from /api/series/catalog", "metric": "Required metric name or comma-separated names from the catalogue", "range": "Time back, such as 6h", "since": "Unix milliseconds", "until": "Unix milliseconds", "points": "Maximum points per metric"}}, +} + +// These GET handlers return diagnostic data despite their Local tier for +// phone passthrough. OAuth GETs start or complete a credential exchange and +// are not reads. Configure and Actuate handlers keep their command path. +var agentLocalReads = map[string]bool{ + "/api/config": true, + "/api/drivers/{id}/source": true, + "/api/drivers/{name}/logs": true, + "/api/logs": true, + "/api/support/dump": true, + "/api/support/report": true, + "/api/ocpp/chargers": true, + "/api/backups/{id}": true, + "/api/agents": true, + "/api/agents/audit": true, +} + +func agentReadMark(pattern string, mark routeMark) bool { + fields := strings.Fields(pattern) + return len(fields) == 2 && fields[0] == http.MethodGet && !mark.static && + (mark.tier == apiauth.TierRead || mark.tier == apiauth.TierLocal && agentLocalReads[fields[1]]) +} + +func (s *Server) agentReadAllowed(r *http.Request) bool { + if r.Method != http.MethodGet { + return false + } + _, pattern := s.mux.Handler(r) + mark, ok := s.marks[pattern] + return ok && agentReadMark(pattern, mark) +} + +func (s *Server) agentReadCatalog() []protocol.AgentAPIRoute { + routes := make([]protocol.AgentAPIRoute, 0, len(s.marks)) + for pattern, mark := range s.marks { + if !agentReadMark(pattern, mark) { + continue + } + path := strings.Fields(pattern)[1] + route := protocol.AgentAPIRoute{Path: path, Scope: protocol.ScopeDiagRun, Description: "Read " + path} + for _, hint := range agentAPIHints { + if hint.Path == path { + route.Description, route.Query = hint.Description, hint.Query + break + } + } + if query, ok := agentAPIQueryHints[path]; ok { + route.Query = query + } + routes = append(routes, route) + } + sort.Slice(routes, func(i, j int) bool { return routes[i].Path < routes[j].Path }) + return routes +} + +var agentAPIQueryHints = map[string]map[string]string{ + "/api/components/history": {"id": "Component id", "kind": "Component kind", "limit": "Maximum changes"}, + "/api/data-sources": {"lat": "Latitude", "lon": "Longitude"}, + "/api/device_repository/catalog": {"channel": "Driver channel"}, + "/api/notifications/history": {"limit": "Maximum notifications"}, + "/api/logs": {"limit": "Maximum log entries, up to 5000"}, + "/api/drivers/{name}/logs": {"limit": "Maximum log entries, up to 2000"}, + "/api/ev/status": {"driver": "Configured driver name"}, + "/api/research/load/dump": {"days": "Days of original observations, 1 to 365; tar.gz export"}, + "/api/agents/audit": {"before": "Audit sequence before which to page", "n": "Maximum audit rows"}, + "/api/series": {"driver": "Configured driver name", "metric": "Metric or comma-separated metrics from /api/series/catalog", "range": "Time back, such as 6h", "since": "Unix milliseconds", "until": "Unix milliseconds", "points": "Maximum points per metric; 0 returns stored rows", "format": "json (default) or csv"}, + "/api/energy/history.csv": {"scope": "system or asset", "asset_id": "Asset id from /api/energy/assets", "since": "Unix milliseconds", "until": "Unix milliseconds", "bucket": "Bucket duration, at least 5m", "limit": "Maximum buckets, up to 5000"}, +} + +// This context is requested for technical debugging, not for routine status, +// history or control. Source links name a recorded build or a declared +// release; a default-branch fallback says explicitly that it may differ. +func (s *Server) agentDebugContext() map[string]any { + ref, kind, revision, modified := "master", "default branch; may differ from the running build", "", false + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + if setting.Key == "vcs.revision" { + revision = setting.Value + } + if setting.Key == "vcs.modified" { + modified = setting.Value == "true" + } + } + } + if revision != "" { + ref, kind = revision, "recorded build revision" + } + if regexp.MustCompile(`^v?0\.[0-9]+\.[0-9]+(?:-beta\.[0-9]+)?$`).MatchString(s.deps.Version) { + ref, kind = "v"+strings.TrimPrefix(s.deps.Version, "v"), "declared release tag" + } + tree := "https://github.com/srcfl/ftw/tree/" + url.PathEscape(ref) + return map[string]any{ + "core": map[string]any{"version": s.deps.Version, "revision": revision, "modified": modified, "source_ref_kind": kind, + "repository": "https://github.com/srcfl/ftw", "source": tree, + "api": tree + "/go/internal/api", "energy_history": tree + "/go/internal/state", "planner": tree + "/go/internal/mpc", + "driver_host": tree + "/go/internal/drivers", "bundled_driver_pin": tree + "/drivers/BUNDLED_SOURCE.json", "issues": "https://github.com/srcfl/ftw/issues"}, + "related_sources": []map[string]string{ + {"part": "Editable Lua drivers", "repository": "https://github.com/srcfl/device-drivers", "running_source": "Read /api/drivers/catalog, /api/drivers/{id}/source and /api/device_repository/drivers/{id}/versions for installed code and hashes"}, + {"part": "FTW Cloud and MCP", "repository": "https://github.com/srcfl/ftw-cloud", "running_source": "https://cloud.ftw.energy/source", "revision": "The Cloud HTTP response names X-Source-Revision"}, + {"part": "Web client", "repository": "https://github.com/srcfl/ftw-webapp"}, + {"part": "Native apps", "repository": "https://github.com/srcfl/ftw-app"}, + {"part": "Compiled Energyplan solver", "repository": "https://github.com/srcfl/energyplan", "access": "Private repository; Core carries binaries and validates their plans"}, + }, + "workflow": []string{ + "Record the symptom, expected behavior, incident time and timezone. Separate measured facts from guesses.", + "Read /api/health, /api/support/report and /api/components for versions, clock, freshness and failures.", + "Use /api/energy/history for Wh and provenance, /api/series for original measurements, and /api/mpc/diagnose/history then /api/mpc/diagnose/at for the plan active at the incident.", + "Inspect configuration, driver details, driver source and logs as needed. Compare with source for the running version; default-branch code may differ.", + "For a bug report, include the Core version and source reference, equipment and driver versions, reproduction steps, expected and observed results, incident time, exact API queries and relevant logs or snapshots. Mark data gaps and whether evidence came from real equipment, a simulator or a test.", + "Use the existing control and goal tools when an action is needed. Reading a diagnostic view does not issue a command. Prepare a bug report for the user; do not publish it without their request.", + }, + } +} diff --git a/go/internal/api/api_agent_reads.go b/go/internal/api/api_agent_reads.go new file mode 100644 index 000000000..7f790d6e7 --- /dev/null +++ b/go/internal/api/api_agent_reads.go @@ -0,0 +1,181 @@ +package api + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "hash" + "net/http" + "strconv" + "unicode/utf8" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" +) + +// ServeAgentRead gives a granted diagnostic session all technical reads. +// It keeps names, addresses, source code and measured data as the handlers +// return them. Existing handlers still mask saved credentials where they do +// so on the box page. api.req and all write handlers remain separate. +func (s *Server) ServeAgentRead(w http.ResponseWriter, request *http.Request, read protocol.APIRead) { + caller, named := apiauth.FromRequest(request) + if !named || !apiauth.AgentRole(caller.Role) || !caller.Scopes.Has(protocol.ScopeDiagRun) || request.Method != http.MethodGet { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "this diagnostic read is not granted"}) + return + } + if request.URL.Path == protocol.AgentAPICatalogPath { + writeJSON(w, http.StatusOK, map[string]any{ + "routes": s.agentReadCatalog(), "requiredScope": protocol.ScopeDiagRun, + "maxPageBytes": protocol.AgentAPIPageBytes, + "troubleshooting": s.agentDebugContext(), + "queryPolicy": "Use the existing API's parameters. Query hints are not a restriction.", + "timeUnit": "Time query parameters since, until, since_ms, until_ms and ts use Unix milliseconds", + "pagination": "Large answers use byte pages. Pass next_offset as offset and sha256 as if_sha256 to detect a changed answer.", + }) + return + } + if !s.agentReadAllowed(request) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "this route is not a diagnostic read; settings and control use their command tools"}) + return + } + if read.Offset < 0 || read.MaxBytes < 0 || read.MaxBytes > protocol.AgentAPIPageBytes { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid diagnostic page"}) + return + } + pageBytes := read.MaxBytes + if pageBytes == 0 { + pageBytes = 64 << 10 + } + answer := &agentReadBuffer{ctx: request.Context(), header: make(http.Header), status: http.StatusOK, + offset: read.Offset, limit: pageBytes, digest: sha256.New()} + + // DiagRun covers all diagnostic reads, including pairing and vehicle + // status. Add only these read scopes to the handler context; this does + // not change the stored grant, role, caller or any command authority. + scopes := append(caller.Scopes.Names(), protocol.ScopeMembersRead, protocol.ScopeCarsRead) + caller.Scopes = apiauth.NewScopeSet(scopes...) + request = request.WithContext(apiauth.WithCaller(request.Context(), caller)) + if read.ArchiveMember != "" { + s.serveAgentArchive(answer, request, read.ArchiveMember) + } else { + s.serveAgentHTTP(answer, request) + } + if answer.failed != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "could not read the archive member completely"}) + return + } + if request.Context().Err() != nil { + return + } + digest := hex.EncodeToString(answer.digest.Sum(nil)) + if read.IfSHA256 != "" && read.IfSHA256 != digest { + writeJSON(w, http.StatusConflict, map[string]string{"error": "the answer changed between pages; start again or narrow the query"}) + return + } + if read.Offset > answer.total { + writeJSON(w, http.StatusRequestedRangeNotSatisfiable, map[string]string{"error": "offset exceeds the answer length"}) + return + } + body := answer.body.Bytes() + more := read.Offset+int64(len(body)) < answer.total + complete := read.Offset == 0 && !more + encoding := "base64" + var data any = base64.StdEncoding.EncodeToString(body) + if complete && json.Valid(body) { + encoding, data = "json", json.RawMessage(body) + } else if utf8.Valid(body) && !agentBinaryMedia(answer.header.Get("Content-Type")) { + encoding, data = "utf-8", string(body) + } + result := map[string]any{"content_type": answer.header.Get("Content-Type"), "encoding": encoding, "body": data, + "offset": read.Offset, "total_bytes": answer.total, "sha256": digest, "complete": complete, "more": more} + if more { + result["next_offset"] = read.Offset + int64(len(body)) + } + writeJSON(w, answer.status, result) +} + +// The LAN administration handlers have presence checks as well as data +// reads. Project their existing read results here without claiming that this +// remote caller is on the LAN or is an owner, and without admitting mutations. +func (s *Server) serveAgentHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/agents", "/api/agents/audit": + if s.deps.Agents == nil { + writeJSON(w, 503, map[string]string{"error": "agents are not available on this box"}) + return + } + a := s.deps.Agents + if r.URL.Path == "/api/agents/audit" { + before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64) + n, _ := strconv.Atoi(r.URL.Query().Get("n")) + rows, more, err := a.Audit(before, n) + if err != nil { + agentError(w, err) + return + } + writeJSON(w, 200, protocol.AuditPage{Rows: nonNil(rows), More: more}) + return + } + facts := a.Facts() + facts.Widen = false + writeJSON(w, 200, agentsPage{AgentList: protocol.AgentList{Agents: nonNil(a.Agents()), Requests: nonNil(a.Requests()), Keys: nonNil(a.ApprovalKeys())}, Box: facts, Registry: registryForPage()}) + case "/api/cloud", "/api/cloud/preview": + link, ok := s.cloudLink(w) + if !ok { + return + } + if r.URL.Path == "/api/cloud" { + writeJSON(w, 200, link.Status()) + } else { + writeJSON(w, 200, link.Preview()) + } + default: + s.ServeHTTP(w, r) + } +} + +// Retain one byte page while hashing and counting the whole answer. A large +// history export or backup has no total-byte cutoff. Context cancellation +// stops a handler that keeps writing after the session ends. +type agentReadBuffer struct { + ctx context.Context + header http.Header + status int + body bytes.Buffer + wrote bool + offset, limit, total int64 + digest hash.Hash + failed error +} + +func (b *agentReadBuffer) Header() http.Header { return b.header } +func (b *agentReadBuffer) WriteHeader(status int) { + if !b.wrote { + b.status, b.wrote = status, true + } +} +func (b *agentReadBuffer) Write(body []byte) (int, error) { + if err := b.ctx.Err(); err != nil { + return 0, err + } + b.WriteHeader(http.StatusOK) + _, _ = b.digest.Write(body) + start := min(int64(len(body)), max(int64(0), b.offset-b.total)) + count := min(int64(len(body))-start, b.limit-int64(b.body.Len())) + if count > 0 { + _, _ = b.body.Write(body[start : start+count]) + } + b.total += int64(len(body)) + return len(body), nil +} + +func agentBinaryMedia(media string) bool { + switch media { + case "application/zip", "application/gzip", "application/x-gzip", "application/octet-stream": + return true + } + return false +} diff --git a/go/internal/api/api_agent_reads_test.go b/go/internal/api/api_agent_reads_test.go new file mode 100644 index 000000000..507d1b816 --- /dev/null +++ b/go/internal/api/api_agent_reads_test.go @@ -0,0 +1,265 @@ +package api + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" +) + +func agentReadRequest(path string, scopes ...string) *http.Request { + r := httptest.NewRequest(http.MethodGet, path, nil) + return r.WithContext(apiauth.WithCaller(r.Context(), apiauth.Caller{Kind: apiauth.KindAgent, Role: apiauth.RoleAgent, Subject: "agent:test", Scopes: apiauth.NewScopeSet(scopes...)})) +} +func diagnosticRead(t *testing.T, srv *Server, path string, read protocol.APIRead) (*httptest.ResponseRecorder, map[string]json.RawMessage) { + t.Helper() + answer := httptest.NewRecorder() + srv.ServeAgentRead(answer, agentReadRequest(path, protocol.ScopeDiagRun), read) + var doc map[string]json.RawMessage + if err := json.Unmarshal(answer.Body.Bytes(), &doc); err != nil { + t.Fatalf("invalid answer: %d %s", answer.Code, answer.Body.String()) + } + return answer, doc +} +func TestAgentReadsEnergyEvidenceWithTechnicalIDs(t *testing.T) { + st, base, asset := seedAPIEnergyHistory(t) + t.Cleanup(func() { _ = st.Close() }) + srv := New(&Deps{State: st}) + catalog, _ := diagnosticRead(t, srv, "/api/energy/assets", protocol.APIRead{}) + if catalog.Code != 200 || !strings.Contains(catalog.Body.String(), "Site meter") || !strings.Contains(catalog.Body.String(), "maker:api-meter") { + t.Fatalf("technical data lost: %s", catalog.Body.String()) + } + q := url.Values{"scope": {"asset"}, "asset_id": {asset}, "since": {formatInt64(base)}, "until": {formatInt64(base + 300_000)}, "bucket": {"5m"}} + answer, doc := diagnosticRead(t, srv, "/api/energy/history?"+q.Encode(), protocol.APIRead{}) + var body struct { + Points []struct { + AssetID string `json:"asset_id"` + Energy float64 `json:"energy_wh"` + Quality string `json:"quality"` + Source string `json:"source"` + Provenance string `json:"provenance"` + } `json:"points"` + } + if answer.Code != 200 || json.Unmarshal(doc["body"], &body) != nil || len(body.Points) == 0 { + t.Fatalf("ledger: %s", answer.Body.String()) + } + var measured float64 + for _, p := range body.Points { + if p.AssetID != asset { + t.Fatal("id changed") + } + if p.Quality == "measured" { + measured += p.Energy + if p.Source != "hardware_counter" || p.Provenance != "counter" { + t.Fatalf("provenance changed: %+v", p) + } + } + } + if measured != 12 { + t.Fatalf("energy=%v, want 12 Wh", measured) + } + csv, doc := diagnosticRead(t, srv, "/api/energy/history.csv?"+q.Encode(), protocol.APIRead{}) + if csv.Code != 200 || !bytes.Contains(doc["body"], []byte("provenance")) || !bytes.Contains(doc["encoding"], []byte("utf-8")) { + t.Fatalf("CSV: %s", csv.Body.String()) + } +} +func TestDiagnosticCatalogueCoversAllReadAndLocalDiagnosticViews(t *testing.T) { + srv := New(&Deps{}) + catalog := map[string]bool{} + for _, route := range srv.agentReadCatalog() { + catalog[route.Path] = true + } + for pattern, mark := range srv.marks { + fields := strings.Fields(pattern) + if len(fields) != 2 || fields[0] != "GET" || mark.static { + continue + } + path := fields[1] + if mark.tier == Read && !catalog[path] { + t.Errorf("missing Read route %s", path) + } + if mark.tier == Local && !catalog[path] && !strings.HasPrefix(path, "/api/oauth/") { + t.Errorf("unclassified Local GET %s", path) + } + } + for _, path := range []string{"/api/config", "/api/logs", "/api/drivers/{id}/source", "/api/support/dump", "/api/support/report", "/api/agents/audit", "/api/backups/{id}", "/api/loadpoints/{id}/battery_boost", "/api/research/load/dump"} { + if !catalog[path] { + t.Errorf("missing %s", path) + } + } + for _, path := range []string{"/api/scan", "/api/version/check", "/api/oauth/myuplink/start", "/api/oauth/myuplink/callback"} { + if catalog[path] { + t.Errorf("side effect in catalogue: %s", path) + } + answer, _ := diagnosticRead(t, srv, path, protocol.APIRead{}) + if answer.Code != 403 { + t.Errorf("side-effect route %s accepted: %d", path, answer.Code) + } + } + srv.handle("GET /api/future/{id}", Read, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]string{"id": r.PathValue("id"), "query": r.URL.Query().Get("new_parameter")}) + }) + answer, _ := diagnosticRead(t, srv, "/api/future/charger-192.168.1.9?new_parameter=yes", protocol.APIRead{}) + if answer.Code != 200 || !strings.Contains(answer.Body.String(), "charger-192.168.1.9") || !strings.Contains(answer.Body.String(), "yes") { + t.Fatalf("future read lost data: %s", answer.Body.String()) + } + for _, scope := range []string{"", protocol.ScopeHistoryRead} { + denied := httptest.NewRecorder() + srv.ServeAgentRead(denied, agentReadRequest("/api/future/x", scope), protocol.APIRead{}) + if denied.Code != 403 { + t.Fatal("diagnostic grant not checked") + } + } + mutation := agentReadRequest("/api/config", protocol.ScopeDiagRun) + mutation.Method = http.MethodPost + denied := httptest.NewRecorder() + srv.ServeAgentRead(denied, mutation, protocol.APIRead{}) + if denied.Code != 403 { + t.Fatal("write accepted") + } + t.Logf("Core exposes %d diagnostic routes", len(catalog)) +} +func TestDiagnosticPagesRetainEveryByteOfLargeAnswers(t *testing.T) { + srv := New(&Deps{}) + original := []byte(strings.Repeat("charger 192.168.1.9 9007199254740993\n", 40000) + "\x00\xff") + current := original + srv.handle("GET /api/test/export", Read, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(current) + }) + sum := sha256.Sum256(original) + digest := hex.EncodeToString(sum[:]) + var joined []byte + var offset int64 + for { + answer, doc := diagnosticRead(t, srv, "/api/test/export", protocol.APIRead{Offset: offset, MaxBytes: protocol.AgentAPIPageBytes, IfSHA256: digest}) + if answer.Code != 200 { + t.Fatalf("page: %s", answer.Body.String()) + } + var encoding, text string + _ = json.Unmarshal(doc["encoding"], &encoding) + _ = json.Unmarshal(doc["body"], &text) + part := []byte(text) + if encoding == "base64" { + var err error + part, err = base64.StdEncoding.DecodeString(text) + if err != nil { + t.Fatal(err) + } + } + joined = append(joined, part...) + var more bool + _ = json.Unmarshal(doc["more"], &more) + if !more { + break + } + _ = json.Unmarshal(doc["next_offset"], &offset) + } + if !bytes.Equal(joined, original) { + t.Fatal("paged bytes changed") + } + current = []byte("changed") + answer, doc := diagnosticRead(t, srv, "/api/test/export", protocol.APIRead{Offset: 1, IfSHA256: digest}) + if answer.Code != 409 || doc["body"] != nil { + t.Fatal("changed pages accepted") + } +} + +func TestDiagnosticStartIncludesVersionedSourceAndBugEvidence(t *testing.T) { + srv := New(&Deps{Version: "0.151.0-beta.1"}) + answer, _ := diagnosticRead(t, srv, protocol.AgentAPICatalogPath, protocol.APIRead{}) + for _, text := range []string{"https://github.com/srcfl/ftw/tree/v0.151.0-beta.1", "https://github.com/srcfl/device-drivers", "https://cloud.ftw.energy/source", "reproduction steps", "timezone", "real equipment", "/api/support/report"} { + if !strings.Contains(answer.Body.String(), text) { + t.Errorf("missing debugging context %q", text) + } + } +} + +func TestDiagnosticArchiveMembersCanBeReadWithoutExternalTools(t *testing.T) { + for _, kind := range []string{"zip", "tar.gz"} { + t.Run(kind, func(t *testing.T) { + var archive bytes.Buffer + evidence := []byte("driver charger: 192.168.1.9; counter=9007199254740993\n") + media := "application/zip" + if kind == "zip" { + writer := zip.NewWriter(&archive) + file, err := writer.Create("ftw-support-stamp/logs/global.log") + if err != nil { + t.Fatal(err) + } + _, _ = file.Write(evidence) + if err = writer.Close(); err != nil { + t.Fatal(err) + } + } else { + media = "application/gzip" + gz := gzip.NewWriter(&archive) + writer := tar.NewWriter(gz) + if err := writer.WriteHeader(&tar.Header{Name: "ftw-research-stamp/logs/global.log", Size: int64(len(evidence)), Mode: 0600}); err != nil { + t.Fatal(err) + } + _, _ = writer.Write(evidence) + _ = writer.Close() + _ = gz.Close() + } + srv := New(&Deps{}) + srv.handle("GET /api/test/archive", Read, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", media) + _, _ = w.Write(archive.Bytes()) + }) + list, _ := diagnosticRead(t, srv, "/api/test/archive", protocol.APIRead{ArchiveMember: "."}) + if list.Code != 200 || !strings.Contains(list.Body.String(), "logs/global.log") { + t.Fatalf("archive list: %s", list.Body.String()) + } + answer, doc := diagnosticRead(t, srv, "/api/test/archive", protocol.APIRead{ArchiveMember: "logs/global.log"}) + var text string + _ = json.Unmarshal(doc["body"], &text) + if answer.Code != 200 || text != string(evidence) { + t.Fatalf("archive evidence changed: %s", answer.Body.String()) + } + absent, _ := diagnosticRead(t, srv, "/api/test/archive", protocol.APIRead{ArchiveMember: "../not-a-file"}) + if absent.Code != 404 { + t.Fatal("archive member lookup escaped its file list") + } + }) + } +} + +func TestDiagnosticArchiveCorruptionReturnsNoPartialMember(t *testing.T) { + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + file, err := writer.CreateHeader(&zip.FileHeader{Name: "root/evidence.txt", Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + evidence := []byte("original evidence must not leave as a successful partial read") + _, _ = file.Write(evidence) + _ = writer.Close() + corrupt := append([]byte(nil), archive.Bytes()...) + at := bytes.Index(corrupt, evidence) + if at < 0 { + t.Fatal("test member not found") + } + corrupt[at] ^= 0xff + srv := New(&Deps{}) + srv.handle("GET /api/test/corrupt", Read, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(corrupt) + }) + answer, doc := diagnosticRead(t, srv, "/api/test/corrupt", protocol.APIRead{ArchiveMember: "evidence.txt"}) + if answer.Code != 502 || doc["body"] != nil { + t.Fatalf("corrupt member returned data: %s", answer.Body.String()) + } +} diff --git a/go/internal/appproto/agentapi.go b/go/internal/appproto/agentapi.go new file mode 100644 index 000000000..80ca8c669 --- /dev/null +++ b/go/internal/appproto/agentapi.go @@ -0,0 +1,85 @@ +package appproto + +import ( + "context" + "net/http" + "sync" + + "github.com/srcfl/ftw/go/protocol" +) + +type agentReadGateway interface { + ServeAgentRead(http.ResponseWriter, *http.Request, protocol.APIRead) +} + +func (h *Handler) onAPIRead(env Envelope) error { + if env.ID == nil { + return nil + } + read, err := protocol.DecodeAPIRead(env.B) + if err != nil { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T}) + } + known := protocol.ValidAgentAPIPath(read.Path) + request := APIReq{Method: APIGet, Path: read.Path, Query: read.Query, MaxBytes: read.MaxBytes} + if !known || badAPIRequest(request) != "" || read.MaxBytes < 0 || read.MaxBytes > protocol.AgentAPIPageBytes || read.Offset < 0 { + return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T}) + } + gateway, available := h.cfg.API.(agentReadGateway) + if !available { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"subsystem": "api"}) + } + if !h.apiBusy.CompareAndSwap(false, true) { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"reason": "busy"}) + } + id := *env.ID + go func() { + var once sync.Once + free := func() { once.Do(func() { h.apiBusy.Store(false) }) } + defer free() + ctx, cancel := context.WithTimeout(h.ctx, apiHandlerTimeout) + defer cancel() + if h.isAgent() { + _, refusal, end := h.agentGate() + if refusal != nil { + _ = h.sendError(&id, *refusal) + if end != "" { + _ = h.Terminate(end) + } + return + } + } + caller, enrolled := h.liveCaller() + if !enrolled || !caller.Scopes.Has(protocol.ScopeDiagRun) { + _ = h.refuse(&id, ErrScopeDenied, map[string]any{"needScope": protocol.ScopeDiagRun}) + return + } + req := buildAPIRequest(ctx, caller, request) + writer := &apiWriter{h: h, id: id, ctx: ctx, max: protocol.AgentAPIMaxBytes, free: free, header: http.Header{}, buf: make([]byte, 0, APIChunkBytes)} + writer.recheck = func() *ErrorBody { + if h.isAgent() { + if _, refusal, _ := h.agentGate(); refusal != nil { + return refusal + } + } + current, ok := h.liveCaller() + if !ok || !current.Scopes.Has(protocol.ScopeDiagRun) { + return &ErrorBody{Code: ErrScopeDenied, Args: map[string]any{"needScope": protocol.ScopeDiagRun}} + } + return nil + } + func() { + defer func() { + if recover() != nil { + writer.WriteHeader(http.StatusInternalServerError) + _, _ = writer.Write([]byte(`{"error":"diagnostic read failed"}`)) + } + }() + gateway.ServeAgentRead(writer, req, read) + }() + if err := writer.finish(); err != nil { + h.log.Warn("diagnostic API read failed", "err", err) + } + }() + return nil +} diff --git a/go/internal/appproto/agentapi_test.go b/go/internal/appproto/agentapi_test.go new file mode 100644 index 000000000..fda30d823 --- /dev/null +++ b/go/internal/appproto/agentapi_test.go @@ -0,0 +1,88 @@ +package appproto + +import ( + "net/http" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" +) + +type diagnosticGateway struct { + stubAPI + read func(http.ResponseWriter, *http.Request) +} + +func (g *diagnosticGateway) ServeAgentRead(w http.ResponseWriter, r *http.Request, _ protocol.APIRead) { + g.read(w, r) +} + +func TestAnAgentDiagnosticReadNeedsOnlyItsDiagnosticGrant(t *testing.T) { + for _, hasDiagnostic := range []bool{false, true} { + t.Run(map[bool]string{false: "missing diagnostic grant", true: "granted"}[hasDiagnostic], func(t *testing.T) { + r := newAgentRig(t) + scopes := []string{protocol.ScopeHistoryRead} + if hasDiagnostic { + scopes = []string{protocol.ScopeDiagRun} + } + row, _ := r.grant(apiauth.RoleAgent, scopes, nil, protocol.Caps{}, time.Hour, protocol.Principal{Client: "ChatGPT"}) + h, rec := r.agentSession(row) + t.Cleanup(h.Close) + called := make(chan struct{}, 1) + h.cfg.API = &diagnosticGateway{stubAPI: stubAPI{facts: apiauth.RouteFacts{Tier: apiauth.TierRead}}, read: func(w http.ResponseWriter, request *http.Request) { + called <- struct{}{} + if request.Method != http.MethodGet || request.URL.Path != "/api/energy/history" { + t.Error("wrong diagnostic request") + } + _, _ = w.Write([]byte(`{"energy_wh":12}`)) + }} + deliver(t, h, protocol.MsgAPIRead, ptrU32(1), protocol.APIRead{Path: "/api/energy/history"}) + if !hasDiagnostic { + if got := body[ErrorBody](t, waitFor(t, rec, MsgError)); got.Code != ErrScopeDenied { + t.Fatalf("missing scope: %+v", got) + } + select { + case <-called: + t.Fatal("a denied read reached its handler") + default: + } + return + } + end := body[APIEnd](t, waitFor(t, rec, MsgAPIEnd)) + if end.Truncated || string(apiChunks(rec)) != `{"energy_wh":12}` { + t.Fatalf("diagnostic stream: %+v %s", end, apiChunks(rec)) + } + }) + } +} + +func TestRevocationStopsADiagnosticReadBeforeItsAnswer(t *testing.T) { + r := newAgentRig(t) + row, _ := r.grant(apiauth.RoleAgent, []string{protocol.ScopeDiagRun, protocol.ScopeHistoryRead}, nil, protocol.Caps{}, time.Hour, protocol.Principal{}) + h, rec := r.agentSession(row) + t.Cleanup(h.Close) + started, release := make(chan struct{}), make(chan struct{}) + h.cfg.API = &diagnosticGateway{stubAPI: stubAPI{facts: apiauth.RouteFacts{Tier: apiauth.TierRead}}, read: func(w http.ResponseWriter, _ *http.Request) { + close(started) + <-release + _, _ = w.Write([]byte(`{"must_not_leave":123}`)) + }} + deliver(t, h, protocol.MsgAPIRead, ptrU32(1), protocol.APIRead{Path: "/api/energy/history"}) + select { + case <-started: + case <-time.After(2 * time.Second): + close(release) + t.Fatal("diagnostic handler did not start") + } + if err := r.access.RevokeAgent(row.ID); err != nil { + t.Fatal(err) + } + close(release) + if got := body[ErrorBody](t, waitFor(t, rec, MsgError)); got.Code != ErrGrantRevoked { + t.Fatalf("revoked read: %+v", got) + } + if rec.has(MsgAPIHead) || len(apiChunks(rec)) != 0 { + t.Fatal("a revoked read returned data") + } +} diff --git a/go/internal/appproto/agents.go b/go/internal/appproto/agents.go index 7bba69b26..fddb94646 100644 --- a/go/internal/appproto/agents.go +++ b/go/internal/appproto/agents.go @@ -786,6 +786,8 @@ func (h *Handler) onRead(env Envelope) error { } } switch env.T { + case protocol.MsgAPIRead: + return h.onAPIRead(env) case protocol.MsgHealthGet: return h.onHealthGet(env) case protocol.MsgDriversGet: diff --git a/go/internal/appproto/gencontract/gencontract.go b/go/internal/appproto/gencontract/gencontract.go index ca88d1d8a..de1d8d4a8 100644 --- a/go/internal/appproto/gencontract/gencontract.go +++ b/go/internal/appproto/gencontract/gencontract.go @@ -76,6 +76,7 @@ type Registry struct { } `yaml:"agent_args"` Reads []struct { Msg string `yaml:"msg"` + Const string `yaml:"const"` Scope string `yaml:"scope"` Desc string `yaml:"desc"` } `yaml:"reads"` @@ -419,6 +420,11 @@ func writeAgentArgs(b *bytes.Buffer, reg Registry) { // writeReads emits each read an agent may send with the scope it needs. func writeReads(b *bytes.Buffer, reg Registry) { + for _, r := range reg.Reads { + if r.Const != "" { + fmt.Fprintf(b, "const %s = %q\n\n", r.Const, r.Msg) + } + } b.WriteString("// RegistryReads maps each read an agent may send to the scope it needs.\n") b.WriteString("var RegistryReads = map[string]string{\n") for _, r := range reg.Reads { diff --git a/go/internal/appproto/passthrough.go b/go/internal/appproto/passthrough.go index a79c4e771..79fab9039 100644 --- a/go/internal/appproto/passthrough.go +++ b/go/internal/appproto/passthrough.go @@ -475,6 +475,9 @@ type apiWriter struct { ctx context.Context max int64 free func() + // recheck is set only for a scoped diagnostic read. It runs before + // each response frame so a narrowed grant stops a read in flight. + recheck func() *ErrorBody header http.Header buf []byte @@ -493,6 +496,9 @@ func (w *apiWriter) WriteHeader(status int) { if w.wroteHead || w.refused { return } + if w.checkGrant() != nil { + return + } if w.ctx.Err() != nil { // Out of time, or the session is gone. Either way the status must not // go out: once one has, the app is committed to it, and finish is @@ -573,6 +579,9 @@ func (w *apiWriter) Write(p []byte) (int, error) { } func (w *apiWriter) flush(n int) error { + if err := w.checkGrant(); err != nil { + return err + } chunk := APIChunk{Seq: w.seq, Data: w.buf[:n]} w.seq++ if err := w.h.sendBulk(MsgAPIChunk, &w.id, chunk); err != nil { @@ -583,6 +592,21 @@ func (w *apiWriter) flush(n int) error { return nil } +func (w *apiWriter) checkGrant() error { + if w.recheck == nil { + return nil + } + if refusal := w.recheck(); refusal != nil { + w.refused = true + w.sendErr = w.h.sendError(&w.id, *refusal) + if w.sendErr != nil { + return w.sendErr + } + return errAPIStopped + } + return nil +} + // finish closes the answer. func (w *apiWriter) finish() error { if w.h.ctx.Err() != nil { diff --git a/go/protocol/agentapi.go b/go/protocol/agentapi.go new file mode 100644 index 000000000..9a070da47 --- /dev/null +++ b/go/protocol/agentapi.go @@ -0,0 +1,47 @@ +package protocol + +import ( + "path" + "strings" + "unicode" +) + +// APIRead is a diagnostic GET, with no method, body, headers or write path. +// Offset and MaxBytes select a byte page. IfSHA256 prevents joining pages +// from different answers. The answer uses api.head/chunk/end, carrying JSON. +type APIRead struct { + Path string `cbor:"path"` + Query map[string]string `cbor:"query,omitempty"` + MaxBytes int64 `cbor:"maxBytes,omitempty"` + Offset int64 `cbor:"offset,omitempty"` + IfSHA256 string `cbor:"ifSHA256,omitempty"` + ArchiveMember string `cbor:"archiveMember,omitempty"` +} + +func DecodeAPIRead(body []byte) (APIRead, error) { + var read APIRead + err := strictDec.Unmarshal(body, &read) + return read, err +} + +const ( + AgentAPIMaxBytes = 1 << 20 // Complete JSON wire answer, including escaped text. + AgentAPIPageBytes = 128 << 10 // Source bytes per page; not a total data limit. + AgentAPICatalogPath = "/api/diagnostics/catalog" +) + +// AgentAPIRoute describes a route registered by the connected Core version. +// Query is guidance; the existing HTTP handler owns parameter validation. +type AgentAPIRoute struct { + Path string `json:"path"` + Scope string `json:"scope"` + Description string `json:"description"` + Query map[string]string `json:"query,omitempty"` +} + +// ValidAgentAPIPath validates syntax only. Core's router decides whether the +// path is a read; Cloud does not keep a second list of the box's API. +func ValidAgentAPIPath(value string) bool { + return strings.HasPrefix(value, "/api/") && path.Clean(value) == value && + !strings.ContainsAny(value, "?%#\\") && strings.IndexFunc(value, unicode.IsControl) < 0 +} diff --git a/go/protocol/agentapi_test.go b/go/protocol/agentapi_test.go new file mode 100644 index 000000000..934dd9982 --- /dev/null +++ b/go/protocol/agentapi_test.go @@ -0,0 +1,46 @@ +package protocol + +import ( + "encoding/hex" + "testing" +) + +func TestDiagnosticAPIReadVector(t *testing.T) { + // CBOR map(1), text(4) path, text(19) /api/energy/history. + body, err := hex.DecodeString("a16470617468732f6170692f656e657267792f686973746f7279") + if err != nil { + t.Fatal(err) + } + read, err := DecodeAPIRead(body) + if err != nil || read.Path != "/api/energy/history" || read.MaxBytes != 0 { + t.Fatalf("read vector: %+v %v", read, err) + } + // A method field cannot add a write path to this read. + bad, _ := hex.DecodeString("a26470617468732f6170692f656e657267792f686973746f7279666d6574686f6464504f5354") + if _, err := DecodeAPIRead(bad); err == nil { + t.Fatal("unknown method field accepted") + } +} + +func TestDiagnosticPageVector(t *testing.T) { + body, err := hex.DecodeString("a46470617468732f6170692f656e657267792f686973746f7279666f6666736574187b686d617842797465731a000100006d617263686976654d656d626572612e") + if err != nil { + t.Fatal(err) + } + read, err := DecodeAPIRead(body) + if err != nil || read.Offset != 123 || read.MaxBytes != 65536 || read.ArchiveMember != "." { + t.Fatalf("page vector: %+v %v", read, err) + } +} +func TestDiagnosticPathSyntaxDoesNotKeepAnAPIList(t *testing.T) { + for _, value := range []string{"/api/future/read", "/api/drivers/charger-192.168.1.9/source"} { + if !ValidAgentAPIPath(value) { + t.Errorf("valid path refused: %s", value) + } + } + for _, value := range []string{"/status", "/api/../config", "/api/status?x=1", "/api/status#x", "/api/a%2fb", "/api/a\\b", "/api/a\n"} { + if ValidAgentAPIPath(value) { + t.Errorf("invalid path accepted: %q", value) + } + } +} diff --git a/go/protocol/contract_gen.go b/go/protocol/contract_gen.go index 6f5d07a89..549cf6760 100644 --- a/go/protocol/contract_gen.go +++ b/go/protocol/contract_gen.go @@ -183,7 +183,7 @@ const ( ScopeLogsRead = "ftw.logs.read" // ScopeConfigRead — Configuration the box redacts. ScopeConfigRead = "ftw.config.read" - // ScopeDiagRun — Checks that change nothing. + // ScopeDiagRun — Full technical diagnostics and read API. ScopeDiagRun = "ftw.diag.run" // ScopeDispatchHold — Hold energy assets for a capped time. ScopeDispatchHold = "ftw.dispatch.hold" @@ -348,6 +348,8 @@ var RegistryAgentArgs = map[string][]AgentArg{ }, } +const MsgAPIRead = "api.read" + // RegistryReads maps each read an agent may send to the scope it needs. var RegistryReads = map[string]string{ // live.get — Readings and freshness now. @@ -374,6 +376,8 @@ var RegistryReads = map[string]string{ "config.get": "ftw.config.read", // diag.run — A check that changes nothing. "diag.run": "ftw.diag.run", + // api.read — Read the full technical diagnostic API. + "api.read": "ftw.diag.run", } // Error codes. The box sends the code and machine-readable args; the app