Files
privatae-bastion/client-go/contextprep.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

472 lines
23 KiB
Go

package main
// contextprep.go — what this machine can turn into TEXT.
//
// OCR and the other common data-preparation tools belong here because
// preparing context is the bastion's whole job. A video decoder, likewise, is
// a user thing — never a platform service — so it has to be something the user
// runs.
//
// The principle that makes those one family: ANYTHING THAT TURNS A USER'S
// BYTES INTO THEIR CONTENT RUNS ON THEIR HARDWARE. Same reason embeddings went
// to the bastion, and stronger — an embedding is a vector, but OCR output is
// the literal text of their document and a transcript is the literal words
// they said.
//
// This file is the ADVERTISEMENT half only: which of those tools this box
// actually has. It never runs one on a document; that is the ctx_extract op.
//
// ── why one capability with a list, and not one token per tool ──────────────
//
// The host's question is always "can this box turn THIS mime into text", which
// is a lookup, not six separate checks. The heartbeat already carries lists
// beside capabilities for exactly this (models, llm_models, schedulers —
// agents.go:448-479), so the shape exists; six tokens would be six flag days
// for no gain.
//
// ── why the probe RUNS the tool ─────────────────────────────────────────────
//
// detectRuntime (harness.go:580) runs `docker ps -q` and takes the exit code
// rather than looking for a file, because a binary on PATH that cannot execute
// is not a capability. Same rule here.
//
// MEASURED, and it is why the version is read from BOTH streams:
// `pdftotext -v` writes its banner to STDERR and exits 0, while `tesseract
// --version` and `ffmpeg -version` write to STDOUT. runCmd returns clean
// stdout on success, so a version read through it reports poppler as blank
// while poppler works perfectly. The CAPABILITY decision is the exit code
// alone; the version string is for the operator's eyes and must not be allowed
// to influence it.
//
// ── quiet vs blind ─────────────────────────────────────────────────────────
//
// A box that has no tools and a box that could not look must not read the same
// to the host. That distinction is already made three times in this heartbeat
// (agent-observe, computer-observe, scheduler-observe — agents.go:423-444), so
// the scan report travels the way schedScanReport does.
import (
"os/exec"
"strings"
"sync"
"time"
)
// prepCapability is the single token. Additive: agents.go:359-360 records that
// hosts predating a capability ignore one they do not know, so this needs no
// host-side flag day and an older host simply never offers the feature.
const prepCapability = "context-prep"
// prepGateOp is the op name this capability is gated by, and the name is
// LOAD-BEARING. capsAllow maps an op to a family through opFamily
// (anchor.go:411-427), and opFamily returns "" for a name it does not
// recognise — whereupon capsAllow ALLOWS it (anchor.go:476-479). So an op
// called "prep_extract" would be UNGATED on an anchored box, silently. The
// "ctx_" prefix puts it in the dcn family, beside ctx_* and backup_fetch,
// which is where it belongs on the merits too: like backup_fetch it pulls the
// host's bytes onto the user's machine. Nothing in anchor.go needs to change
// and no seventh caps family has to be invented.
//
// prepGateOpFamily is asserted against opFamily in the test, so renaming the
// op without thinking about the family fails a gate instead of quietly opening
// this lane to every member pairing.
const prepGateOp = "ctx_extract"
const prepGateOpFamily = "dcn"
// prepCacheFor matches scanSchedulers' cadence (schedulers.go:435) for the
// same reason given there: the heartbeat is every 60 s, this is a handful of
// shell-outs, and installing a tool should show up within minutes without
// anyone restarting the daemon.
const prepCacheFor = 10 * time.Minute
// prepTool is one thing this box might be able to do, the binary that would do
// it, and an argv that proves the binary RUNS.
//
// EVERY probe argv below was executed against the real tool and
// its exit code recorded — none is copied from documentation. A tool whose
// interface has not been verified does not belong in this table: advertising
// it would be the exact dishonesty the quiet-vs-blind rule exists to prevent.
type prepTool struct {
// Name is what the HOST asks for. It names the job, not the program, so
// swapping poppler for something else later is not a host-visible change.
Name string
Bin string
Args []string
// Builtin marks a job THIS BINARY does, with no program on the machine to
// probe: office-text reads docx/xlsx/pptx out of Go's own archive/zip and
// encoding/xml, so there is nothing to install and nothing to exec.
//
// It does not weaken the honesty rule, it moves where the rule bites. A
// builtin still has to PROVE itself below, and office-text's Prove runs the
// whole reader over a package assembled in memory. What a builtin removes is
// the "is it installed" question, which for a builtin has exactly one answer.
Builtin bool
// Needs are the OTHER binaries this job cannot run without, each with an
// argv that proves IT runs. pdf-ocr rasterizes with poppler before it hands
// a page to the engine, and a box holding one of the two must not advertise
// the job. Probed exactly like Bin, after it.
Needs []prepTool
// Prove, when set, is the LAST word on availability and overrides nothing
// before it — every binary must still answer first. It exists because an
// exit code is sometimes a PROXY for the capability rather than the
// capability: `tesseract --version` and `tesseract --list-langs` both exit 0
// on a machine with no language data installed, which cannot read a word
// (measured, contextocr.go's header). Returning a REASON is the point: "the
// engine is here and has no language data" and "there is no engine" are
// different answers that the user fixes with different commands.
Prove func() (bool, string)
// Langs, when set, is what this job can read. It travels beside the tool so
// a user can see which language pack their machine is using without asking
// it anything.
Langs func() []string
// Models, when set, is the MODEL FILES this job can load, best-first.
//
// Unlike Langs this is not merely informational, and transcription is why it
// exists. "I have poppler" is the whole answer for a PDF; "I have a speech
// engine" is not the answer for a recording, because base.en and
// large-v3-turbo are three times apart in error rate on hard audio and eleven
// times apart in CPU (measured, contextstt.go). A host choosing a machine for
// a recording is choosing a model whether it knows it or not, so the list
// travels and the host may name one.
Models func() []string
}
// prepToolTable is every job this build knows how to probe for.
//
// DELIBERATELY ABSENT, and each for a stated reason rather than an oversight:
// - transcribe: the implementation is not chosen. whisper.cpp (a binary),
// openai-whisper (python) and faster-whisper (a library) differ in what the
// user must install, not merely in speed, so the pick is partly a product
// call. The rule is to benchmark rather than pick from reputation; until that
// runs there is no argv to verify and nothing honest to advertise.
//
// Adding it means verifying its argv the same way, not appending a row.
var prepToolTable = []prepTool{
// Text PDFs — the largest gap by volume, and no OCR needed for them.
// -v exits 0 and prints to stderr (measured).
{Name: "pdf-text", Bin: "pdftotext", Args: []string{"-v"}},
// OCR of a picture: the literal words on it, read by the engine.
//
// Args is the cheap "does the binary run" gate; Prove is what actually
// decides, because --version exits 0 on a machine with no language data.
// contextocr.go's header has the measurement.
{Name: "ocr", Bin: "tesseract", Args: []string{"--version"},
Prove: ocrProve, Langs: ocrLangs},
// A scanned PDF: its own text layer where it has one, the engine where it
// does not. Needs BOTH poppler tools as well as the engine — the layer pass
// reads the text and carries the page count, the rasterizer draws the pages
// that need the engine — which is why this is a separate job from "ocr" and
// not the same one pointed at a PDF.
//
// Bin is the engine rather than poppler on purpose: a box with no tesseract
// reports this job as simply absent (it also has no "ocr"), while a box WITH
// the engine and no pdftoppm reports it as blocked and names the missing
// piece. The first binary probed is the one whose absence is uninteresting.
{Name: "pdf-ocr", Bin: "tesseract", Args: []string{"--version"},
Needs: []prepTool{
{Name: "rasterize", Bin: "pdftoppm", Args: []string{"-v"}},
{Name: "pdf-layer", Bin: "pdftotext", Args: []string{"-v"}},
},
Prove: ocrProve, Langs: ocrLangs},
// Office documents: docx, xlsx and pptx, read by THIS BINARY.
//
// soffice was unverified, so it was installed and run
// before this row was written. It is not used, and contextoffice.go's header
// carries the four measurements that decided it — the one that matters most
// here is that two concurrent headless conversions against one user profile
// lose one of the pair in 5 of 5 trials, silently, with nothing on either
// stream. This box runs mgmtWorkerCount management workers.
//
// No Bin and no Args because there is no program: these three formats are
// ZIPs of XML and Go's standard library reads both halves. THE USER INSTALLS
// NOTHING, which on a lane that runs on their own machine is the criterion
// that decides between two tools that would both work.
{Name: "office-text", Builtin: true, Prove: officeProve},
// A clip's FRAMES (contextframes.go). Two binaries, because the job is two
// steps: ffprobe reads the container's keyframe index without decoding
// anything, and ffmpeg then decodes only the frames the plan asks for. A box
// with one and not the other cannot finish the job, so Needs carries the
// second — the same structure pdf-ocr uses for its rasterizer.
//
// Both argvs were run against the real binaries (ffmpeg 8.0.1):
// each exits 0 and banners to stdout.
//
// This job's answer is PICTURES, not text, so its runner is frameTools
// rather than extractArgv or extractMulti — see contextframes.go for why the
// reply has a second shape instead of a text field holding base64.
{Name: ctxFramesTool, Bin: "ffmpeg", Args: []string{"-version"},
Needs: []prepTool{
{Name: "probe", Bin: "ffprobe", Args: []string{"-version"}},
}},
// SPEECH TO TEXT — the words the user actually said (contextstt.go).
//
// Two rows for one engine, and the split is the same one pdf-text/pdf-ocr
// makes: whisper-cli reads wav, mp3, flac and ogg BY ITSELF and FAILS on mp4
// and m4a (measured against the real binary; its own help prints the list).
// So a box with the engine alone can transcribe every voice memo the user
// owns, and a box with ffmpeg as well can also transcribe a clip's audio
// track. One row would either refuse the first box or lie to the second.
//
// `--version` exits 0 in 0.01s and banners to stdout (measured). It is the
// cheap gate only: Prove is what decides, because an installed engine WITH NO
// MODEL also exits 0 — and that is the normal state after installing it, the
// Ubuntu 26.04 package shipping no model file at all. Prove loads a model and
// transcribes a known input, and it TIMES that run, which is where this job's
// declared ceiling comes from.
{Name: sttTool, Bin: sttBin, Args: []string{"--version"},
Prove: sttProve, Models: sttModelsReport},
// Bin is the ENGINE rather than ffmpeg on purpose, the same way pdf-ocr's is
// tesseract rather than poppler: a box with no whisper-cli reports this job
// as simply absent (it has no "transcribe" either), while a box WITH the
// engine and no ffmpeg reports it as blocked and names the missing piece —
// which is the sentence that tells an mp4 user what to install.
{Name: sttAVTool, Bin: sttBin, Args: []string{"--version"},
Needs: []prepTool{
{Name: "demux", Bin: "ffmpeg", Args: []string{"-version"}},
},
Prove: sttProve, Models: sttModelsReport},
}
// prepToolReport is one AVAILABLE tool as the host sees it.
type prepToolReport struct {
Name string `json:"name"`
Bin string `json:"bin"`
// Version is the tool's own first line, best-effort and for the operator's
// eyes only. Empty is NOT a failure signal — see the stream note above.
Version string `json:"version,omitempty"`
// Langs is what this job can read, when reading is language-shaped. Only OCR
// fills it in. It is informational: the box picks the language it uses and
// reports it in the job's result; this is so a user can see which packs are
// installed without running anything.
Langs []string `json:"langs,omitempty"`
// Models is what this job can load, best-first, when the job is one whose
// answer depends on a model. Only transcription fills it in.
//
// The FIRST entry is what runs when the host names none. The host may name
// one from this list in the job (ctxExtractReq.Model) and the reply says
// which one ACTUALLY ran (ctxExtractResp.Model), so a transcript's accuracy
// is a recorded fact rather than something inferred from whatever the machine
// happened to hold at the time.
Models []string `json:"models,omitempty"`
// Builtin says this job needs nothing installed — the binary does it. The
// host shows it so a user comparing two machines can see that one of these
// jobs is available on every box running this version and the others depend
// on what they have put on the machine.
Builtin bool `json:"builtin,omitempty"`
// CeilingS is the SECONDS this box will spend on this job before it stops,
// and unlike Langs it is NOT informational — the host's dispatch wait is
// built from it.
//
// THE NUMBER ORIGINATES HERE AND TRAVELS; there is no copy of it on the
// host. It was briefly a table on the box and a mirrored table on the host
// with a comment on each side asserting their relationship, which is the
// shape that had ALREADY failed once in this lane — the pull path's two
// deadline constants were in the wrong order across the whole domain while
// both comments claimed otherwise.
//
// The exposure a mirror leaves points the way its mitigation does not. A
// host's fallback for a job it has never heard of can only be the largest
// ceiling THE HOST knows. The bastion publishes separately and users update
// on their own schedule, so a box shipping a transcription job bounded at
// ten minutes — an hour of audio on a CPU is not a 150-second job — against
// a host that has not been updated gives: host waits 170 s, box works 600 s,
// and the user is told their machine did not answer by the side that quit
// first, with the transcript discarded when it arrives. That is exactly the
// failure the pull path was rebuilt to eliminate.
//
// So: the box declares what each job costs, the host waits that plus a round
// trip, and the host GRANTS the same number back in the job as
// extract_budget, which the box clamps DOWN to and never up — the shape
// pull_budget already proved one change earlier. A job that does not exist
// yet is covered by construction rather than by anyone remembering.
CeilingS float64 `json:"ceiling_s"`
}
// prepToolBlocked is a job whose PROGRAM is on this machine but which still
// cannot run, with the reason in plain language.
//
// This is the whole quiet-vs-blind distinction for tools. Without it a box with
// no OCR engine and a box with the engine and no language data are the same
// silence — the same empty tool list, the same "none of your machines can do
// this" — and the two are fixed by different commands. Reported for a tool
// whose FIRST binary answered, so a machine with none of the software installed
// stays quiet rather than listing everything it has never had.
type prepToolBlocked struct {
Name string `json:"name"`
Bin string `json:"bin"`
Reason string `json:"reason"`
}
// prepScanReport is how much was looked at, so "no tools" from a box that
// probed three binaries is visibly different from a box that probed none.
type prepScanReport struct {
// Supported is whether this build can probe at all. Always true here (the
// probe is exec, which every platform has); it exists so a future build
// that cannot probe on some platform has an honest way to say so, the way
// schedScanReport.Supported does.
Supported bool `json:"supported"`
// Probed is how many tools were tried, Found how many answered.
Probed int `json:"probed"`
Found int `json:"found"`
// Blocked is every job that is ALMOST here — its program is installed and
// something else is missing. See prepToolBlocked. An older host ignores the
// field; a newer one turns it into the sentence that tells the user what to
// install.
Blocked []prepToolBlocked `json:"blocked,omitempty"`
// Pull is whether this box can FETCH a source instead of being handed it in
// the management frame (contextpull.go). It rides the scan report, not
// prep_tools, because the transport is a property of the BOX's extraction
// lane and not of any one tool — every tool on a box that can pull can be
// fed by a pull.
//
// It is what keeps the host honest across versions: a bastion that predates
// this reports no field, the host reads that as false, and it keeps sending
// documents inline instead of handing out a ticket that box could not
// redeem. Advertising it means "this box COULD fetch", never "will" — the
// host still decides, per job, which transport to use.
Pull bool `json:"pull"`
}
var prepCache = struct {
sync.Mutex
at time.Time
tools []prepToolReport
scan prepScanReport
}{}
// probePrepTool runs one tool's proving argv. Returns whether it ran, and its
// first output line if there was one.
//
// Both streams are read because the banner's stream varies by tool (measured
// above). The exit code is the ONLY input to the availability decision — a
// tool that runs but prints nothing is available.
func probePrepTool(t prepTool) (bool, string) {
ok, ver, _ := probePrepToolDetail(t)
return ok, ver
}
// probePrepToolDetail is the full answer: available, its version, and — when it
// is NOT available but its program is installed — why not.
//
// The order is deliberate and each step is a strictly better authority than the
// one before it: the program runs, then everything it depends on runs, then the
// job is actually PERFORMED on a known input. A tool with no Prove stops after
// the first two, which is right for pdftotext (there is no second thing it could
// be missing) and wrong for tesseract (there is, and it exits 0 anyway).
func probePrepToolDetail(t prepTool) (bool, string, string) {
if t.Builtin {
// No program, so the first two steps have nothing to ask. The THIRD one
// still runs and is the whole probe: Prove performs the job on a known
// input and compares the answer. A builtin is never "not installed", but
// it can still be broken, and a broken one must not be advertised.
if t.Prove != nil {
if ok, why := t.Prove(); !ok {
return false, "", why
}
}
return true, "built in — nothing to install", ""
}
out, err := runCmd(5*time.Second, t.Bin, t.Args...)
if err != nil {
// A non-zero exit and a missing binary both land here, and they are NOT
// the same thing to the user. An absent program is quiet — there is
// nothing to say about software nobody installed. A present one that
// will not run is named.
if _, lerr := exec.LookPath(t.Bin); lerr == nil {
return false, "", t.Bin + " is installed on this machine but did not run"
}
return false, "", ""
}
for _, n := range t.Needs {
if _, nerr := runCmd(5*time.Second, n.Bin, n.Args...); nerr != nil {
return false, "", t.Bin + " works but " + n.Bin + " is missing or will not run, " +
"and this job needs both"
}
}
if t.Prove != nil {
if ok, why := t.Prove(); !ok {
return false, "", why
}
}
// Success: runCmd gave clean stdout, which is EMPTY for tools that banner
// to stderr. Absent output is not absent capability, so report available
// with no version rather than probing again for a cosmetic string.
return true, firstLine(out), ""
}
// firstLine is the tool's own first line of output, trimmed and length-capped
// so a chatty or hostile binary cannot inflate the heartbeat body.
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
s = s[:i]
}
if len(s) > 120 {
s = s[:120]
}
return strings.TrimSpace(s)
}
// scanPrepTools is the heartbeat's entry point: probe every known tool, cached.
func scanPrepTools() ([]prepToolReport, prepScanReport) {
prepCache.Lock()
defer prepCache.Unlock()
if !prepCache.at.IsZero() && time.Since(prepCache.at) < prepCacheFor {
// Tools are cached; the transport is not. It flips exactly once, when
// the daemon wires its tunnel client, and a box that probed before that
// would otherwise keep reporting pull=false for up to prepCacheFor —
// telling the host to send documents inline on a machine that can fetch.
scan := prepCache.scan
scan.Pull = ctxPullConfigured()
return prepCache.tools, scan
}
tools := []prepToolReport{}
blocked := []prepToolBlocked{}
for _, t := range prepToolTable {
ok, ver, why := probePrepToolDetail(t)
if !ok {
if why != "" {
blocked = append(blocked, prepToolBlocked{Name: t.Name, Bin: t.Bin, Reason: why})
}
continue
}
// The ceiling is read from the SAME function the job is actually run
// under (ctxExtractTimeoutFor), never from a second literal beside it —
// the declaration and the enforcement have to be one value or the
// declaration is just another mirror, one file further away.
r := prepToolReport{Name: t.Name, Bin: t.Bin, Version: ver,
Builtin: t.Builtin,
CeilingS: ctxExtractTimeoutFor(t.Name).Seconds()}
if t.Langs != nil {
r.Langs = t.Langs()
}
if t.Models != nil {
r.Models = t.Models()
}
tools = append(tools, r)
}
// Pull is read LIVE, not cached with the tool probes: it flips once when the
// daemon's tunnel client is wired (run.go), and a box that reported false in
// the first beat must not keep saying so for prepCacheFor afterwards.
scan := prepScanReport{Supported: true, Probed: len(prepToolTable), Found: len(tools),
Blocked: blocked, Pull: ctxPullConfigured()}
prepCache.at, prepCache.tools, prepCache.scan = time.Now(), tools, scan
return tools, scan
}
// prepCapable says whether the capability travels for THIS pairing.
//
// Two conditions, both required, and the caps one is not optional politeness:
// on an anchored box a member pairing without the dcn family will be refused
// at the op, so advertising to it would be a control that fails after the user
// has already chosen it — the reason mirror.go:63-67 gives for gating its own
// advertisement. Withheld means the probe is skipped entirely, matching how
// the inference lanes behave at agents.go:351-358.
func prepCapable(self string) ([]prepToolReport, prepScanReport, bool) {
if capsAllow(self, prepGateOp) != "" {
return nil, prepScanReport{}, false
}
tools, scan := scanPrepTools()
return tools, scan, len(tools) > 0
}