From fbd20852ddeb3de3803738a2936d8873166fd9a0 Mon Sep 17 00:00:00 2001 From: Gauthier Jolly Date: Wed, 13 May 2026 22:26:05 +0200 Subject: [PATCH 1/2] feat(tools): add apt/dpkg observability tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three MCP tools so an agent can answer "is this host up to date?": - apt_update_status — one-call summary: pending updates, security updates, reboot-required + triggering packages, last apt-update age. - list_upgradable_packages — per-package upgrade detail with origin, suite, and security flag, plus security_only / name_pattern filters. - list_installed_packages — dpkg inventory parsed from /var/lib/dpkg/ status, with name_pattern / limit filters. Excludes residual config-files entries (deinstall status). Implementation lives in a new internal/aptdb package that mirrors the procfs/sysfs pattern: pure-Go parsers, no os/exec. It reads dpkg status, walks /var/lib/apt/lists for *_Packages indexes (uncompressed and gzip), joins them with sibling Release / InRelease files for Origin/Suite, and computes upgrades via a from-scratch dpkg version comparator (deb-version(7): epoch / upstream / revision with tilde-ordering and mixed alpha-numeric segments). Two correctness notes baked in: - InRelease files wrap the Release stanza in a PGP signed-message envelope; the parser skips the envelope header so Origin/Suite are picked up instead of "Hash: SHA512". - Suites ending in -backports are filtered out of upgrade candidates, matching apt's default pin priority 100 (Ubuntu backports never upgrade automatically). This brings the count in line with `apt list --upgradable`; the only remaining divergence on a typical Ubuntu host is ESM-pinned packages, which would need apt-preferences parsing to handle exactly. Snap confinement: /var/lib/dpkg and /var/lib/apt are not visible inside strict confinement under the existing *-observe plugs, so the snap declares the system-backup plug (read-only host filesystem view at /var/lib/snapd/hostfs). Manual-connect like log-observe today. The mcpserver picks the hostfs root when snapconf.InSnap() is true and "/" otherwise. CLAUDE.md's plug rule is updated to admit read-only host-fs plugs alongside *-observe. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 5 +- internal/aptdb/aptdb.go | 458 ++++++++++++++++++ internal/aptdb/aptdb_test.go | 134 +++++ internal/aptdb/testdata/empty-root/.keep | 0 ....com_ubuntu_dists_noble-security_InRelease | 19 + ..._noble-security_main_binary-amd64_Packages | 7 + ...ntu.com_ubuntu_dists_noble-updates_Release | 11 + ...s_noble-updates_main_binary-amd64_Packages | 23 + .../aptdb/testdata/root/var/lib/dpkg/status | 54 +++ .../testdata/root/var/run/reboot-required | 1 + .../root/var/run/reboot-required.pkgs | 3 + internal/aptdb/version.go | 137 ++++++ internal/aptdb/version_test.go | 80 +++ internal/mcpserver/server.go | 14 + internal/tools/apt.go | 222 +++++++++ internal/tools/tools.go | 6 + snap/snapcraft.yaml | 4 +- 17 files changed, 1175 insertions(+), 3 deletions(-) create mode 100644 internal/aptdb/aptdb.go create mode 100644 internal/aptdb/aptdb_test.go create mode 100644 internal/aptdb/testdata/empty-root/.keep create mode 100644 internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-security_InRelease create mode 100644 internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-security_main_binary-amd64_Packages create mode 100644 internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-updates_Release create mode 100644 internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-updates_main_binary-amd64_Packages create mode 100644 internal/aptdb/testdata/root/var/lib/dpkg/status create mode 100644 internal/aptdb/testdata/root/var/run/reboot-required create mode 100644 internal/aptdb/testdata/root/var/run/reboot-required.pkgs create mode 100644 internal/aptdb/version.go create mode 100644 internal/aptdb/version_test.go create mode 100644 internal/tools/apt.go diff --git a/CLAUDE.md b/CLAUDE.md index ccc83ff..b9e589a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,9 @@ curl -sS -H 'Authorization: Bearer devtoken' http://127.0.0.1:18765/healthz ## Architecture rules -- **Snap plugs are the safety boundary.** Only `*-observe` and `network-bind`. - Never add a `*-control` plug, `home`, or `system-files` write rules. +- **Snap plugs are the safety boundary.** Only `*-observe`, `network-bind`, + and read-only host-fs plugs (`system-backup`). Never add a `*-control` plug, + `home`, or `system-files` write rules. - **All `os/exec` goes through `internal/exectool`.** Fixed argv, 10s timeout, 4 MiB stdout cap, `LC_ALL=C`, hardcoded `PATH`. Do not call `exec` directly from tools. diff --git a/internal/aptdb/aptdb.go b/internal/aptdb/aptdb.go new file mode 100644 index 0000000..5621c61 --- /dev/null +++ b/internal/aptdb/aptdb.go @@ -0,0 +1,458 @@ +// Package aptdb parses dpkg and apt on-disk state read-only. It does no +// shelling out: status, Packages index, and Release files are read directly, +// which keeps the snap's plug surface limited to filesystem-read interfaces +// (system-backup) instead of needing an apt frontend. +package aptdb + +import ( + "bufio" + "compress/gzip" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// Root points at the filesystem root containing /var/lib/dpkg and /var/lib/apt. +// In the dev/host case this is "/"; inside the strictly-confined snap it is +// "/var/lib/snapd/hostfs" so the system-backup plug's host view is used. +type Root struct{ Path string } + +// Default is the system aptdb at /. +var Default = Root{Path: "/"} + +// NewRoot returns a Root rooted at the given path. +func NewRoot(path string) Root { return Root{Path: path} } + +func (r Root) file(parts ...string) string { + return filepath.Join(append([]string{r.Path}, parts...)...) +} + +// Package is one stanza from /var/lib/dpkg/status. +type Package struct { + Name string + Version string + Architecture string + Status string + Source string + Section string + Priority string +} + +// AvailableEntry is one stanza from an apt Packages index, annotated with the +// Origin/Suite/Component of the index it came from. +type AvailableEntry struct { + Version string + Architecture string + Origin string + Suite string + Component string + Filename string // source index file (debug aid) +} + +// UpgradeInfo is one installed package whose candidate (highest available) +// version is strictly greater than the installed version. +type UpgradeInfo struct { + Name string + Architecture string + InstalledVersion string + CandidateVersion string + Origin string + Suite string + Security bool +} + +// InstalledPackages returns every dpkg stanza whose status contains +// "installed" (i.e. excludes "deinstall ok config-files" etc.). +func (r Root) InstalledPackages() ([]Package, error) { + f, err := os.Open(r.file("var/lib/dpkg/status")) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + var out []Package + err = readStanzas(f, func(s stanza) { + status := s["Status"] + if !strings.Contains(status, "installed") || strings.HasPrefix(status, "deinstall") { + return + } + out = append(out, Package{ + Name: s["Package"], + Version: s["Version"], + Architecture: s["Architecture"], + Status: status, + Source: s["Source"], + Section: s["Section"], + Priority: s["Priority"], + }) + }) + if err != nil { + return nil, err + } + return out, nil +} + +// AvailablePackages walks /var/lib/apt/lists and returns every (name, arch) +// candidate keyed by package name. Each candidate is annotated with the +// Origin/Suite of its index's Release file (so we can flag security pockets). +func (r Root) AvailablePackages() (map[string][]AvailableEntry, error) { + dir := r.file("var/lib/apt/lists") + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return map[string][]AvailableEntry{}, nil + } + return nil, err + } + + releases := loadReleaseFiles(dir, entries) + out := make(map[string][]AvailableEntry, 4096) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !isPackagesIndex(name) { + continue + } + origin, suite, component := lookupRelease(name, releases) + if err := parsePackagesIndex(filepath.Join(dir, name), origin, suite, component, out); err != nil { + return nil, err + } + } + return out, nil +} + +// UpgradablePackages joins installed × available and returns one entry per +// installed package whose best candidate is strictly newer. Security flag is +// set when the chosen candidate's Suite ends in "-security". +func (r Root) UpgradablePackages() ([]UpgradeInfo, error) { + installed, err := r.InstalledPackages() + if err != nil { + return nil, err + } + available, err := r.AvailablePackages() + if err != nil { + return nil, err + } + + var out []UpgradeInfo + for _, p := range installed { + cands, ok := available[p.Name] + if !ok { + continue + } + var best *AvailableEntry + var bestSecurity bool + for i := range cands { + c := &cands[i] + if c.Architecture != p.Architecture && c.Architecture != "all" { + continue + } + // Skip suites pinned below 500 by apt's defaults. The only + // well-known case is *-backports (Ubuntu pins them to 100). This + // keeps the count aligned with `apt list --upgradable`, which is + // what users actually compare against. + if strings.HasSuffix(c.Suite, "-backports") { + continue + } + if CompareVersions(c.Version, p.Version) <= 0 { + continue + } + if best == nil || CompareVersions(c.Version, best.Version) > 0 { + best = c + bestSecurity = isSecuritySuite(c.Suite) + continue + } + // Same version available in multiple pockets: prefer the security + // one so the security flag isn't lost. + if CompareVersions(c.Version, best.Version) == 0 && isSecuritySuite(c.Suite) { + best = c + bestSecurity = true + } + } + if best == nil { + continue + } + out = append(out, UpgradeInfo{ + Name: p.Name, + Architecture: p.Architecture, + InstalledVersion: p.Version, + CandidateVersion: best.Version, + Origin: best.Origin, + Suite: best.Suite, + Security: bestSecurity, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// LastUpdate returns the most recent successful apt-update timestamp. Falls +// back to the mtime of /var/lib/apt/lists/ when the success stamp is absent. +func (r Root) LastUpdate() (time.Time, error) { + if st, err := os.Stat(r.file("var/lib/apt/periodic/update-success-stamp")); err == nil { + return st.ModTime(), nil + } + st, err := os.Stat(r.file("var/lib/apt/lists")) + if err != nil { + return time.Time{}, err + } + return st.ModTime(), nil +} + +// RebootRequired returns whether /var/run/reboot-required exists and, if so, +// the list of triggering packages from /var/run/reboot-required.pkgs (deduped). +func (r Root) RebootRequired() (bool, []string, error) { + _, err := os.Stat(r.file("var/run/reboot-required")) + if errors.Is(err, fs.ErrNotExist) { + return false, nil, nil + } + if err != nil { + return false, nil, err + } + b, err := os.ReadFile(r.file("var/run/reboot-required.pkgs")) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return true, nil, nil + } + return true, nil, err + } + seen := map[string]bool{} + var pkgs []string + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || seen[line] { + continue + } + seen[line] = true + pkgs = append(pkgs, line) + } + sort.Strings(pkgs) + return true, pkgs, nil +} + +// -- internal helpers -------------------------------------------------------- + +type stanza map[string]string + +// readStanzas walks an RFC822-style file (dpkg status, apt Packages indexes) +// stanza-by-stanza. Continuation lines (leading space) are folded back into +// the previous field. +func readStanzas(r io.Reader, emit func(stanza)) error { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 64<<10), 4<<20) + cur := stanza{} + var lastKey string + for sc.Scan() { + line := sc.Text() + if line == "" { + if len(cur) > 0 { + emit(cur) + cur = stanza{} + lastKey = "" + } + continue + } + if line[0] == ' ' || line[0] == '\t' { + if lastKey != "" { + cur[lastKey] += "\n" + strings.TrimSpace(line) + } + continue + } + colon := strings.IndexByte(line, ':') + if colon < 0 { + continue + } + key := line[:colon] + val := strings.TrimSpace(line[colon+1:]) + cur[key] = val + lastKey = key + } + if len(cur) > 0 { + emit(cur) + } + return sc.Err() +} + +// isPackagesIndex picks out compiled apt index files. We accept both +// uncompressed "*_Packages" and gzip "*_Packages.gz". apt also caches lz4 +// variants in some configurations — we skip those to stay dependency-free. +func isPackagesIndex(name string) bool { + switch { + case strings.HasSuffix(name, "_Packages"): + return true + case strings.HasSuffix(name, "_Packages.gz"): + return true + } + return false +} + +// parsePackagesIndex reads one apt Packages file (gz or plain) and appends +// every (name, version, arch) into out. +func parsePackagesIndex(path, origin, suite, component string, out map[string][]AvailableEntry) error { + f, err := os.Open(path) //nolint:gosec // path comes from a fixed directory walk + if err != nil { + return err + } + defer func() { _ = f.Close() }() + var rdr io.Reader = f + if strings.HasSuffix(path, ".gz") { + gz, gerr := gzip.NewReader(f) + if gerr != nil { + return gerr + } + defer func() { _ = gz.Close() }() + rdr = gz + } + return readStanzas(rdr, func(s stanza) { + name := s["Package"] + if name == "" { + return + } + out[name] = append(out[name], AvailableEntry{ + Version: s["Version"], + Architecture: s["Architecture"], + Origin: origin, + Suite: suite, + Component: component, + Filename: filepath.Base(path), + }) + }) +} + +// loadReleaseFiles indexes every *_Release / *_InRelease file in dir by the +// shared prefix used by their sibling Packages files. apt names indexes like +// +// _dists___binary-_Packages +// +// and the matching release file is +// +// _dists__{In,}Release +// +// so we key by the "_dists_" prefix. +func loadReleaseFiles(dir string, entries []os.DirEntry) map[string]releaseInfo { + out := map[string]releaseInfo{} + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + var prefix string + switch { + case strings.HasSuffix(name, "_InRelease"): + prefix = strings.TrimSuffix(name, "_InRelease") + case strings.HasSuffix(name, "_Release"): + prefix = strings.TrimSuffix(name, "_Release") + default: + continue + } + info, err := parseReleaseFile(filepath.Join(dir, name)) + if err != nil { + continue + } + // Prefer InRelease (signed inline) when both are present, but only if + // we haven't recorded anything yet for this prefix. + if _, exists := out[prefix]; !exists { + out[prefix] = info + } + } + return out +} + +type releaseInfo struct { + Origin string + Suite string +} + +func parseReleaseFile(path string) (releaseInfo, error) { + f, err := os.Open(path) //nolint:gosec // fixed directory walk + if err != nil { + return releaseInfo{}, err + } + defer func() { _ = f.Close() }() + out := releaseInfo{} + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 64<<10), 4<<20) + // InRelease wraps the Release content in a PGP signed-message envelope: + // -----BEGIN PGP SIGNED MESSAGE----- + // Hash: SHA512 + // <-- blank line: end of PGP header + // Origin: Ubuntu + // ... + // We detect the PGP armor and skip until the blank separator before + // parsing. Plain Release files have no envelope and are parsed directly. + inEnvelope := false + pastEnvelopeHeader := false + for sc.Scan() { + line := sc.Text() + if !inEnvelope && !pastEnvelopeHeader && strings.HasPrefix(line, "-----BEGIN PGP") { + inEnvelope = true + continue + } + if inEnvelope && !pastEnvelopeHeader { + if line == "" { + pastEnvelopeHeader = true + } + continue + } + if line == "" { + // End of the Release header stanza; everything after is file hashes + // (potentially megabytes) and a PGP signature. + break + } + if strings.HasPrefix(line, "-----BEGIN PGP SIGNATURE-----") { + break + } + k, v, ok := strings.Cut(line, ":") + if !ok { + continue + } + v = strings.TrimSpace(v) + switch k { + case "Origin": + out.Origin = v + case "Suite": + out.Suite = v + case "Codename": + if out.Suite == "" { + out.Suite = v + } + } + } + return out, sc.Err() +} + +// lookupRelease finds the Release file whose prefix matches a Packages file +// name, returning Origin, Suite, and the component segment from the filename. +func lookupRelease(packagesFile string, releases map[string]releaseInfo) (origin, suite, component string) { + trimmed := strings.TrimSuffix(packagesFile, ".gz") + trimmed = strings.TrimSuffix(trimmed, "_Packages") + // trimmed = "_dists___binary-" + // Strip the trailing "__binary-" to get the release prefix. + bIdx := strings.LastIndex(trimmed, "_binary-") + if bIdx < 0 { + return "", "", "" + } + withoutArch := trimmed[:bIdx] + cIdx := strings.LastIndexByte(withoutArch, '_') + if cIdx < 0 { + return "", "", "" + } + component = withoutArch[cIdx+1:] + prefix := withoutArch[:cIdx] + info := releases[prefix] + return info.Origin, info.Suite, component +} + +// isSecuritySuite returns true for Ubuntu/Debian security pockets. Ubuntu uses +// "-security"; Debian uses "-security" or the +// "/updates" archive (also surfaced as suite "-security" +// in modern setups). +func isSecuritySuite(s string) bool { + return strings.HasSuffix(s, "-security") +} diff --git a/internal/aptdb/aptdb_test.go b/internal/aptdb/aptdb_test.go new file mode 100644 index 0000000..5c96618 --- /dev/null +++ b/internal/aptdb/aptdb_test.go @@ -0,0 +1,134 @@ +package aptdb + +import ( + "sort" + "testing" +) + +func TestInstalledPackages(t *testing.T) { + r := NewRoot("testdata/root") + pkgs, err := r.InstalledPackages() + if err != nil { + t.Fatalf("InstalledPackages: %v", err) + } + names := make([]string, 0, len(pkgs)) + for _, p := range pkgs { + names = append(names, p.Name) + } + sort.Strings(names) + + // "removed-pkg" must be excluded (deinstall ok config-files). + // "held-pkg" must be included (hold ok installed contains "installed"). + want := []string{"bash", "held-pkg", "linux-image-6.8.0-45-generic", "openssl"} + if len(names) != len(want) { + t.Fatalf("got %d packages (%v), want %d (%v)", len(names), names, len(want), want) + } + for i := range want { + if names[i] != want[i] { + t.Errorf("package[%d] = %q, want %q", i, names[i], want[i]) + } + } +} + +func TestAvailablePackages(t *testing.T) { + r := NewRoot("testdata/root") + avail, err := r.AvailablePackages() + if err != nil { + t.Fatalf("AvailablePackages: %v", err) + } + // openssl should appear twice (updates + security), bash once, kernel once. + if got := len(avail["openssl"]); got != 2 { + t.Errorf("openssl candidate count = %d, want 2", got) + } + if got := len(avail["bash"]); got != 1 { + t.Errorf("bash candidate count = %d, want 1", got) + } + // Security index must be tagged Suite=noble-security. + foundSecurity := false + for _, c := range avail["openssl"] { + if c.Suite == "noble-security" && c.Origin == "Ubuntu" { + foundSecurity = true + } + } + if !foundSecurity { + t.Errorf("openssl: no noble-security candidate found, got %+v", avail["openssl"]) + } +} + +func TestUpgradablePackages(t *testing.T) { + r := NewRoot("testdata/root") + up, err := r.UpgradablePackages() + if err != nil { + t.Fatalf("UpgradablePackages: %v", err) + } + // bash 5.2.21-2ubuntu4 → 5.2.21-2ubuntu4.1 (updates, not security) + // openssl 3.0.13-0ubuntu3.4 → 3.0.13-0ubuntu3.5 (security wins over updates) + // kernel is at the same version on both sides → no upgrade + // held-pkg has no candidate → no upgrade + if got := len(up); got != 2 { + t.Fatalf("got %d upgrades (%+v), want 2", len(up), up) + } + byName := map[string]UpgradeInfo{} + for _, u := range up { + byName[u.Name] = u + } + if u := byName["bash"]; u.CandidateVersion != "5.2.21-2ubuntu4.1" || u.Security { + t.Errorf("bash upgrade = %+v, want candidate 5.2.21-2ubuntu4.1, security=false", u) + } + if u := byName["openssl"]; u.CandidateVersion != "3.0.13-0ubuntu3.5" || !u.Security { + t.Errorf("openssl upgrade = %+v, want candidate 3.0.13-0ubuntu3.5, security=true", u) + } +} + +func TestRebootRequired(t *testing.T) { + r := NewRoot("testdata/root") + required, pkgs, err := r.RebootRequired() + if err != nil { + t.Fatalf("RebootRequired: %v", err) + } + if !required { + t.Errorf("required = false, want true") + } + // Deduped + sorted: linux-base, linux-image-6.8.0-45-generic + want := []string{"linux-base", "linux-image-6.8.0-45-generic"} + if len(pkgs) != len(want) { + t.Fatalf("got pkgs %v, want %v", pkgs, want) + } + for i := range want { + if pkgs[i] != want[i] { + t.Errorf("pkg[%d] = %q, want %q", i, pkgs[i], want[i]) + } + } +} + +func TestRebootRequired_Absent(t *testing.T) { + r := NewRoot("testdata/empty-root") + required, pkgs, err := r.RebootRequired() + if err != nil { + t.Fatalf("RebootRequired (empty root): %v", err) + } + if required { + t.Errorf("required = true on empty root, want false") + } + if len(pkgs) != 0 { + t.Errorf("pkgs = %v on empty root, want empty", pkgs) + } +} + +func TestAvailablePackages_MissingDir(t *testing.T) { + r := NewRoot("testdata/empty-root") + avail, err := r.AvailablePackages() + if err != nil { + t.Fatalf("AvailablePackages (empty root): %v", err) + } + if len(avail) != 0 { + t.Errorf("got %d entries on empty root, want 0", len(avail)) + } +} + +func TestLastUpdate(t *testing.T) { + r := NewRoot("testdata/root") + if _, err := r.LastUpdate(); err != nil { + t.Fatalf("LastUpdate: %v", err) + } +} diff --git a/internal/aptdb/testdata/empty-root/.keep b/internal/aptdb/testdata/empty-root/.keep new file mode 100644 index 0000000..e69de29 diff --git a/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-security_InRelease b/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-security_InRelease new file mode 100644 index 0000000..c7e8b23 --- /dev/null +++ b/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-security_InRelease @@ -0,0 +1,19 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +Origin: Ubuntu +Label: Ubuntu +Suite: noble-security +Version: 24.04 +Codename: noble +Date: Thu, 01 May 2026 12:00:00 UTC +Architectures: amd64 +Components: main +Description: Ubuntu Noble Security Updates +MD5Sum: + d41d8cd98f00b204e9800998ecf8427e 0 main/binary-amd64/Packages + +-----BEGIN PGP SIGNATURE----- + +iQGzBAEBCgAdFiEEdiCEPSE+1zSv5Xs8WLuiE2HRPaQFAmZxxxxxxxx +-----END PGP SIGNATURE----- diff --git a/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-security_main_binary-amd64_Packages b/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-security_main_binary-amd64_Packages new file mode 100644 index 0000000..5977fa8 --- /dev/null +++ b/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-security_main_binary-amd64_Packages @@ -0,0 +1,7 @@ +Package: openssl +Architecture: amd64 +Version: 3.0.13-0ubuntu3.5 +Priority: optional +Section: utils +Filename: pool/main/o/openssl/openssl_3.0.13-0ubuntu3.5_amd64.deb +Description: Secure Sockets Layer toolkit (security) diff --git a/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-updates_Release b/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-updates_Release new file mode 100644 index 0000000..222f7fa --- /dev/null +++ b/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-updates_Release @@ -0,0 +1,11 @@ +Origin: Ubuntu +Label: Ubuntu +Suite: noble-updates +Version: 24.04 +Codename: noble +Date: Thu, 01 May 2026 12:00:00 UTC +Architectures: amd64 +Components: main +Description: Ubuntu Noble Updates +MD5Sum: + d41d8cd98f00b204e9800998ecf8427e 0 main/binary-amd64/Packages diff --git a/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-updates_main_binary-amd64_Packages b/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-updates_main_binary-amd64_Packages new file mode 100644 index 0000000..2d1e145 --- /dev/null +++ b/internal/aptdb/testdata/root/var/lib/apt/lists/archive.ubuntu.com_ubuntu_dists_noble-updates_main_binary-amd64_Packages @@ -0,0 +1,23 @@ +Package: bash +Architecture: amd64 +Version: 5.2.21-2ubuntu4.1 +Priority: required +Section: shells +Filename: pool/main/b/bash/bash_5.2.21-2ubuntu4.1_amd64.deb +Description: GNU Bourne Again SHell + +Package: linux-image-6.8.0-45-generic +Architecture: amd64 +Version: 6.8.0-45.45 +Priority: optional +Section: kernel +Filename: pool/main/l/linux-signed/linux-image-6.8.0-45-generic_6.8.0-45.45_amd64.deb +Description: Signed kernel image (no upgrade) + +Package: openssl +Architecture: amd64 +Version: 3.0.13-0ubuntu3.5 +Priority: optional +Section: utils +Filename: pool/main/o/openssl/openssl_3.0.13-0ubuntu3.5_amd64.deb +Description: Secure Sockets Layer toolkit diff --git a/internal/aptdb/testdata/root/var/lib/dpkg/status b/internal/aptdb/testdata/root/var/lib/dpkg/status new file mode 100644 index 0000000..788966b --- /dev/null +++ b/internal/aptdb/testdata/root/var/lib/dpkg/status @@ -0,0 +1,54 @@ +Package: bash +Status: install ok installed +Priority: required +Section: shells +Installed-Size: 1864 +Maintainer: Ubuntu Developers +Architecture: amd64 +Multi-Arch: foreign +Version: 5.2.21-2ubuntu4 +Replaces: bash-completion (<< 20060301-0), bash-doc (<= 2.05-1) +Depends: base-files (>= 2.1.12), debianutils (>= 5.6-0.1) +Description: GNU Bourne Again SHell + Bash is an sh-compatible command language interpreter that executes + commands read from the standard input or from a file. + +Package: openssl +Status: install ok installed +Priority: optional +Section: utils +Installed-Size: 2095 +Maintainer: Ubuntu Developers +Architecture: amd64 +Source: openssl +Version: 3.0.13-0ubuntu3.4 +Depends: libc6 (>= 2.34), libssl3t64 (= 3.0.13-0ubuntu3.4) +Description: Secure Sockets Layer toolkit - cryptographic utility + +Package: linux-image-6.8.0-45-generic +Status: install ok installed +Priority: optional +Section: kernel +Installed-Size: 384000 +Maintainer: Ubuntu Kernel Team +Architecture: amd64 +Source: linux-signed +Version: 6.8.0-45.45 +Description: Signed kernel image generic + +Package: removed-pkg +Status: deinstall ok config-files +Priority: optional +Section: misc +Architecture: amd64 +Version: 1.0-1 +Config-Version: 1.0-1 +Description: A removed package, should not appear + +Package: held-pkg +Status: hold ok installed +Priority: optional +Section: misc +Architecture: amd64 +Version: 2.0-1 +Description: A held package, still installed diff --git a/internal/aptdb/testdata/root/var/run/reboot-required b/internal/aptdb/testdata/root/var/run/reboot-required new file mode 100644 index 0000000..e088937 --- /dev/null +++ b/internal/aptdb/testdata/root/var/run/reboot-required @@ -0,0 +1 @@ +*** System restart required *** diff --git a/internal/aptdb/testdata/root/var/run/reboot-required.pkgs b/internal/aptdb/testdata/root/var/run/reboot-required.pkgs new file mode 100644 index 0000000..254dd67 --- /dev/null +++ b/internal/aptdb/testdata/root/var/run/reboot-required.pkgs @@ -0,0 +1,3 @@ +linux-image-6.8.0-45-generic +linux-base +linux-image-6.8.0-45-generic diff --git a/internal/aptdb/version.go b/internal/aptdb/version.go new file mode 100644 index 0000000..9a030c8 --- /dev/null +++ b/internal/aptdb/version.go @@ -0,0 +1,137 @@ +package aptdb + +import ( + "strconv" + "strings" +) + +// CompareVersions implements the dpkg version comparison algorithm (see +// deb-version(7)). It returns -1, 0, or 1 if a is less than, equal to, or +// greater than b. Tilde (~) sorts before everything including the empty +// string; non-letter non-digit characters sort after letters. +func CompareVersions(a, b string) int { + ea, ua, ra := split(a) + eb, ub, rb := split(b) + if c := compareInt(ea, eb); c != 0 { + return c + } + if c := compareUpstream(ua, ub); c != 0 { + return c + } + return compareUpstream(ra, rb) +} + +// split returns (epoch, upstream, revision). Epoch defaults to 0 when absent; +// revision defaults to empty. +func split(v string) (epoch int, upstream, revision string) { + if colon := strings.IndexByte(v, ':'); colon >= 0 { + if n, err := strconv.Atoi(v[:colon]); err == nil { + epoch = n + v = v[colon+1:] + } + } + if dash := strings.LastIndexByte(v, '-'); dash >= 0 { + return epoch, v[:dash], v[dash+1:] + } + return epoch, v, "" +} + +func compareInt(a, b int) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + } + return 0 +} + +// compareUpstream compares two upstream/revision strings byte-by-byte, +// alternating between non-digit and digit runs as dpkg does. +func compareUpstream(a, b string) int { + for len(a) > 0 || len(b) > 0 { + // Non-digit prefix. + i := 0 + for i < len(a) && !isDigit(a[i]) { + i++ + } + j := 0 + for j < len(b) && !isDigit(b[j]) { + j++ + } + if c := compareNonDigit(a[:i], b[:j]); c != 0 { + return c + } + a, b = a[i:], b[j:] + + // Digit prefix. + i = 0 + for i < len(a) && isDigit(a[i]) { + i++ + } + j = 0 + for j < len(b) && isDigit(b[j]) { + j++ + } + if c := compareDigits(a[:i], b[:j]); c != 0 { + return c + } + a, b = a[i:], b[j:] + } + return 0 +} + +// compareNonDigit applies dpkg's modified lexicographic order: ~ < (empty) < +// letters < other non-digit characters. +func compareNonDigit(a, b string) int { + for k := 0; k < len(a) || k < len(b); k++ { + var ca, cb byte + if k < len(a) { + ca = a[k] + } + if k < len(b) { + cb = b[k] + } + if ca == cb { + continue + } + return compareInt(weight(ca), weight(cb)) + } + return 0 +} + +// weight maps a byte to its sort position. Tilde sorts before "nothing" +// (weight -1 below the empty-string weight of 0); letters keep their byte +// value; other bytes are pushed above all letters. +func weight(c byte) int { + switch { + case c == 0: + return 0 + case c == '~': + return -1 + case isLetter(c): + return int(c) + default: + return int(c) + 256 + } +} + +// compareDigits compares numeric runs as integers, trimming any leading +// zeros. An empty run is treated as 0. +func compareDigits(a, b string) int { + a = strings.TrimLeft(a, "0") + b = strings.TrimLeft(b, "0") + if len(a) != len(b) { + return compareInt(len(a), len(b)) + } + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 +} + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } +func isLetter(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } diff --git a/internal/aptdb/version_test.go b/internal/aptdb/version_test.go new file mode 100644 index 0000000..85d3d74 --- /dev/null +++ b/internal/aptdb/version_test.go @@ -0,0 +1,80 @@ +package aptdb + +import "testing" + +func TestCompareVersions(t *testing.T) { + // Cases drawn from the dpkg test corpus and deb-version(7). + cases := []struct { + a, b string + want int + }{ + {"1.0", "1.0", 0}, + {"1.0", "1.1", -1}, + {"1.1", "1.0", 1}, + + // Epoch dominates upstream. + {"1:1.0", "2.0", 1}, + {"2.0", "1:1.0", -1}, + {"1:1.0", "1:1.0", 0}, + + // Tilde sorts before empty (pre-release semantics). + {"1.0~rc1", "1.0", -1}, + {"1.0", "1.0~rc1", 1}, + {"1.0~~", "1.0~", -1}, + {"1.0~~a", "1.0~", -1}, + + // Numeric segments compare as numbers, not lexicographically. + {"1.10", "1.9", 1}, + {"1.9", "1.10", -1}, + {"1.0.10", "1.0.9", 1}, + + // Letters compare in ASCII order; non-letters sort after. + {"1.0a", "1.0b", -1}, + {"1.0b", "1.0a", 1}, + {"1.0a", "1.0+", -1}, // letter < other non-digit + {"1.0+", "1.0a", 1}, + + // Revision (after the last dash). + {"1.0-1", "1.0-2", -1}, + {"1.0-1ubuntu1", "1.0-1ubuntu2", -1}, + {"1.0-1ubuntu1", "1.0-1", 1}, + + // Leading-zero numeric segments compare equal to their unpadded forms. + {"1.01", "1.1", 0}, + {"1.001", "1.1", 0}, + + // Real Ubuntu-style cases. + {"2.39-0ubuntu8.4", "2.39-0ubuntu8.5", -1}, + {"5.15.0-91.101", "5.15.0-92.102", -1}, + {"1:2.34.1-1ubuntu1.11", "1:2.34.1-1ubuntu1.12", -1}, + } + + for _, c := range cases { + got := CompareVersions(c.a, c.b) + if got != c.want { + t.Errorf("CompareVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +func TestSplit(t *testing.T) { + cases := []struct { + in string + wantEpoch int + wantUpstream string + wantRevision string + }{ + {"1.0", 0, "1.0", ""}, + {"1.0-1", 0, "1.0", "1"}, + {"1:2.0-3ubuntu1", 1, "2.0", "3ubuntu1"}, + {"1:2.0", 1, "2.0", ""}, + {"1.2-3-4", 0, "1.2-3", "4"}, // last dash wins for revision + } + for _, c := range cases { + e, u, r := split(c.in) + if e != c.wantEpoch || u != c.wantUpstream || r != c.wantRevision { + t.Errorf("split(%q) = (%d, %q, %q), want (%d, %q, %q)", + c.in, e, u, r, c.wantEpoch, c.wantUpstream, c.wantRevision) + } + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 8f61977..839a67c 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -16,9 +16,11 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/gjolly/fleetmind/internal/aptdb" "github.com/gjolly/fleetmind/internal/exectool" "github.com/gjolly/fleetmind/internal/fleet" "github.com/gjolly/fleetmind/internal/procfs" + "github.com/gjolly/fleetmind/internal/snapconf" "github.com/gjolly/fleetmind/internal/sysfs" "github.com/gjolly/fleetmind/internal/tools" "github.com/gjolly/fleetmind/internal/webui" @@ -90,6 +92,7 @@ func New(cfg Config) (*Server, error) { Exec: exectool.NewRunner(), ProcFS: procfs.Default, SysFS: sysfs.Default, + AptDB: aptDBRoot(), Logger: cfg.Logger, Fleet: fleetReg, FleetToken: cfg.Token, @@ -243,3 +246,14 @@ func (s *Server) Serve(ctx context.Context) error { return err } } + +// aptDBRoot returns the filesystem root the apt tools should read from. +// Inside a strictly-confined snap, /var/lib/dpkg and /var/lib/apt are not +// exposed by the snap's mount namespace; the system-backup plug exposes the +// host at /var/lib/snapd/hostfs instead. +func aptDBRoot() aptdb.Root { + if snapconf.InSnap() { + return aptdb.NewRoot("/var/lib/snapd/hostfs") + } + return aptdb.Default +} diff --git a/internal/tools/apt.go b/internal/tools/apt.go new file mode 100644 index 0000000..d8b145e --- /dev/null +++ b/internal/tools/apt.go @@ -0,0 +1,222 @@ +package tools + +import ( + "context" + "fmt" + "regexp" + "sort" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// pkgNameRE permits the substring filter we accept on apt tool inputs. The +// dpkg package name grammar (a-z 0-9 + - .) plus uppercase for case-insensitive +// matching. We never compile this as a user regex — it's a substring filter — +// but the screen rejects shell metacharacters and control bytes up front. +var pkgNameRE = regexp.MustCompile(`^[a-zA-Z0-9._+\-]{1,64}$`) + +// -------- apt_update_status ------------------------------------------------- + +type aptUpdateStatusIn struct{} + +type aptUpdateStatusOut struct { + UpdatesPending int `json:"updates_pending"` + SecurityUpdatesPending int `json:"security_updates_pending"` + RebootRequired bool `json:"reboot_required"` + RebootRequiredPackages []string `json:"reboot_required_packages,omitempty"` + LastAptUpdate time.Time `json:"last_apt_update"` + LastAptUpdateAgeSec int64 `json:"last_apt_update_age_sec"` +} + +// -------- list_upgradable_packages ----------------------------------------- + +type listUpgradableIn struct { + SecurityOnly bool `json:"security_only,omitempty" jsonschema:"only return packages with security updates"` + NamePattern string `json:"name_pattern,omitempty" jsonschema:"optional case-insensitive substring filter on package name (1-64 chars, [a-zA-Z0-9._+-])"` + Limit int `json:"limit,omitempty" jsonschema:"max packages to return (default 500, max 2000)"` +} + +type upgradablePackage struct { + Name string `json:"name"` + Architecture string `json:"architecture"` + InstalledVersion string `json:"installed_version"` + CandidateVersion string `json:"candidate_version"` + Origin string `json:"origin,omitempty"` + Suite string `json:"suite,omitempty"` + Security bool `json:"security"` +} + +type listUpgradableOut struct { + Count int `json:"count"` + Packages []upgradablePackage `json:"packages"` +} + +// -------- list_installed_packages ------------------------------------------- + +type listInstalledIn struct { + NamePattern string `json:"name_pattern,omitempty" jsonschema:"optional case-insensitive substring filter on package name (1-64 chars, [a-zA-Z0-9._+-])"` + Limit int `json:"limit,omitempty" jsonschema:"max packages to return (default 1000, max 5000)"` +} + +type installedPackage struct { + Name string `json:"name"` + Version string `json:"version"` + Architecture string `json:"architecture"` + Status string `json:"status"` + Source string `json:"source,omitempty"` + Section string `json:"section,omitempty"` +} + +type listInstalledOut struct { + Count int `json:"count"` + Packages []installedPackage `json:"packages"` +} + +// --------------------------------------------------------------------------- + +func registerApt(s *mcp.Server, d Deps) { + mcp.AddTool(s, &mcp.Tool{ + Name: "apt_update_status", + Description: "One-call answer to \"is this host up to date?\". Returns counts of " + + "pending apt updates (total and security), whether a reboot is required, the " + + "packages that triggered the reboot flag, and the timestamp of the last " + + "successful apt-update. Reads /var/lib/dpkg/status and /var/lib/apt/lists/ " + + "directly — inside the snap this requires the system-backup interface to be " + + "connected (`snap connect fleetmind:system-backup`).", + }, func(_ context.Context, _ *mcp.CallToolRequest, _ aptUpdateStatusIn) (*mcp.CallToolResult, aptUpdateStatusOut, error) { + upgrades, err := d.AptDB.UpgradablePackages() + if err != nil { + return nil, aptUpdateStatusOut{}, fmt.Errorf("upgradable: %w", err) + } + security := 0 + for _, u := range upgrades { + if u.Security { + security++ + } + } + required, rebootPkgs, err := d.AptDB.RebootRequired() + if err != nil { + return nil, aptUpdateStatusOut{}, fmt.Errorf("reboot-required: %w", err) + } + last, err := d.AptDB.LastUpdate() + if err != nil { + return nil, aptUpdateStatusOut{}, fmt.Errorf("last-update: %w", err) + } + out := aptUpdateStatusOut{ + UpdatesPending: len(upgrades), + SecurityUpdatesPending: security, + RebootRequired: required, + RebootRequiredPackages: rebootPkgs, + LastAptUpdate: last, + LastAptUpdateAgeSec: int64(time.Since(last).Seconds()), + } + rebootStr := "no" + if required { + rebootStr = "yes" + } + return textResult("%d updates pending (%d security), reboot required: %s", + out.UpdatesPending, out.SecurityUpdatesPending, rebootStr), out, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "list_upgradable_packages", + Description: "Packages with an available upgrade. Each entry includes installed " + + "and candidate versions, the source pocket (e.g. noble-security, noble-updates) " + + "and a security flag derived from the suite name. Filter to security-only or " + + "by name substring.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in listUpgradableIn) (*mcp.CallToolResult, listUpgradableOut, error) { + limit, err := boundedLimit(in.Limit, 500, 2000) + if err != nil { + return nil, listUpgradableOut{}, err + } + if in.NamePattern != "" && !pkgNameRE.MatchString(in.NamePattern) { + return nil, listUpgradableOut{}, fmt.Errorf("invalid name_pattern %q", in.NamePattern) + } + upgrades, err := d.AptDB.UpgradablePackages() + if err != nil { + return nil, listUpgradableOut{}, fmt.Errorf("upgradable: %w", err) + } + needle := strings.ToLower(in.NamePattern) + out := listUpgradableOut{Packages: make([]upgradablePackage, 0, len(upgrades))} + for _, u := range upgrades { + if in.SecurityOnly && !u.Security { + continue + } + if needle != "" && !strings.Contains(strings.ToLower(u.Name), needle) { + continue + } + out.Packages = append(out.Packages, upgradablePackage{ + Name: u.Name, + Architecture: u.Architecture, + InstalledVersion: u.InstalledVersion, + CandidateVersion: u.CandidateVersion, + Origin: u.Origin, + Suite: u.Suite, + Security: u.Security, + }) + } + sort.SliceStable(out.Packages, func(i, j int) bool { return out.Packages[i].Name < out.Packages[j].Name }) + if len(out.Packages) > limit { + out.Packages = out.Packages[:limit] + } + out.Count = len(out.Packages) + return textResult("%d upgradable package(s)", out.Count), out, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "list_installed_packages", + Description: "Installed-package inventory parsed from /var/lib/dpkg/status. " + + "Entries with \"deinstall\" status (residual config-files) are excluded. Filter " + + "by name substring; limit defaults to 1000 entries to keep results bounded.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in listInstalledIn) (*mcp.CallToolResult, listInstalledOut, error) { + limit, err := boundedLimit(in.Limit, 1000, 5000) + if err != nil { + return nil, listInstalledOut{}, err + } + if in.NamePattern != "" && !pkgNameRE.MatchString(in.NamePattern) { + return nil, listInstalledOut{}, fmt.Errorf("invalid name_pattern %q", in.NamePattern) + } + pkgs, err := d.AptDB.InstalledPackages() + if err != nil { + return nil, listInstalledOut{}, fmt.Errorf("installed: %w", err) + } + needle := strings.ToLower(in.NamePattern) + out := listInstalledOut{Packages: make([]installedPackage, 0, len(pkgs))} + for _, p := range pkgs { + if needle != "" && !strings.Contains(strings.ToLower(p.Name), needle) { + continue + } + out.Packages = append(out.Packages, installedPackage{ + Name: p.Name, + Version: p.Version, + Architecture: p.Architecture, + Status: p.Status, + Source: p.Source, + Section: p.Section, + }) + } + sort.SliceStable(out.Packages, func(i, j int) bool { return out.Packages[i].Name < out.Packages[j].Name }) + if len(out.Packages) > limit { + out.Packages = out.Packages[:limit] + } + out.Count = len(out.Packages) + return textResult("%d installed package(s)", out.Count), out, nil + }) +} + +// boundedLimit returns in if 0 0, got %d", in) + } + if in > max { + return 0, fmt.Errorf("limit must be <= %d, got %d", max, in) + } + return in, nil +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go index cd3d743..03c1916 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -9,6 +9,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/gjolly/fleetmind/internal/aptdb" "github.com/gjolly/fleetmind/internal/exectool" "github.com/gjolly/fleetmind/internal/fleet" "github.com/gjolly/fleetmind/internal/procfs" @@ -22,6 +23,7 @@ type Deps struct { Exec *exectool.Runner ProcFS procfs.Root SysFS sysfs.Root + AptDB aptdb.Root Logger *slog.Logger // Fleet is the local fleet registry. Nil when fleet mode is disabled — // list_fleet and fleet_query are still registered but report disabled. @@ -59,6 +61,9 @@ var AllToolNames = []string{ "list_systemd_units", "unit_status", "list_timers", + "apt_update_status", + "list_upgradable_packages", + "list_installed_packages", "list_fleet", "fleet_query", } @@ -80,6 +85,7 @@ func RegisterAll(s *mcp.Server, d Deps) { registerLogs(s, d) registerBoot(s, d) registerSystemd(s, d) + registerApt(s, d) registerFleet(s, d) registerFleetQuery(s, d) } diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index ab06c4b..d3d6d6a 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -17,10 +17,11 @@ description: | Then point your MCP client at http://127.0.0.1:8765/mcp with header "Authorization: Bearer ". - Journal and kernel-log tools require manual interface connection: + Journal, kernel-log and apt tools require manual interface connection: sudo snap connect fleetmind:log-observe sudo snap connect fleetmind:kernel-module-observe + sudo snap connect fleetmind:system-backup license: Apache-2.0 confinement: strict @@ -41,6 +42,7 @@ apps: - network-observe - log-observe - kernel-module-observe + - system-backup slots: - systemd-bus From 06c5f5dd4e9af0f08602ba2799ab61ed999a813f Mon Sep 17 00:00:00 2001 From: Gauthier Jolly Date: Wed, 13 May 2026 22:51:16 +0200 Subject: [PATCH 2/2] fix(webui): surface structuredContent in tool results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toolResultText only extracted text blocks from result.content[], so any tool whose payload lives in structuredContent (every FleetMind list tool) was invisible to both the chat agent and the operator card — the LLM only ever saw the "N items" one-line summary. Return the pretty-printed structuredContent when present, fall back to text blocks otherwise. When both are present the summary text just duplicates fields already in the structured payload (e.g. "3 installed package(s)" vs {"count": 3, ...}), so skipping it keeps the LLM's tool_result tight. Co-Authored-By: Claude Opus 4.7 --- internal/webui/static/app.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/webui/static/app.js b/internal/webui/static/app.js index 68b0c46..e1a52b6 100644 --- a/internal/webui/static/app.js +++ b/internal/webui/static/app.js @@ -603,6 +603,15 @@ const chat = (() => { function toolResultText(result) { if (!result) return ""; + // FleetMind tools return a one-line summary in content[] and the typed + // payload in structuredContent. When both are present the summary just + // duplicates fields already in the structured payload (e.g. "3 items" + // vs {"count": 3, ...}) — prefer the structured form and skip the text + // to keep the LLM's tool_result tight. + const sc = result.structuredContent; + if (sc && typeof sc === "object" && Object.keys(sc).length > 0) { + return JSON.stringify(sc, null, 2); + } if (Array.isArray(result.content)) { return result.content .map((c) => (c.type === "text" ? c.text : JSON.stringify(c)))