Files
LocalAI/core/services/workerctl/request.go
T
Ettore Di Giacinto 64059cd7d7 feat(distributed): give agent workers a tunnel of their own
Phase 2 gated agent nodes out of tunnel credentials at the mint site.
That was right while nothing dialled into an agent worker: a credential
would have replaced nothing, and the gate was structural rather than a
second check that could drift. It is wrong now that the frontend needs to
reach an agent worker by RPC.

attachTunnelToken mints for backend and agent nodes and CLEARS for
anything else, through one tunnelEligible predicate rather than two
conditions that can be widened separately. ConnectHandler still never
reads NodeType, so an empty hash is still what refuses an ineligible
node.

An agent worker now starts a loopback control server behind the same
bearer check a backend worker uses, and holds one tunnel whose only
stream tag is http: it runs no backend processes, so the grpc tag has
nothing to route to and is not offered. Its MCP tool, MCP discovery and
backend.stop verbs are served from ONE implementation reached by both the
bus and the tunnel, so a frontend cannot get different bytes depending on
which carrier delivered.

The tunnel is an ADDITION. --nats-url is still required, and agent jobs,
MCP execution, MCP CI jobs and nodes.<id>.backend.stop all still travel
on the bus.

Absence semantics are unchanged. An agent node now has a real
node_connections row whose departure ages past the grace, so the node
type check in HealthMonitor.tunnelDeparted stopped being an optimisation
and became the rule; its comment says so, and the spec that pins it is
shown red under a mutation that deletes the check. The scheduler needed
no change: every placement query already filters node_type = backend, so
an agent node never reaches nodeMayTakeWork.

Shared rules moved to one site each. The request bounds, the POST-only
check and the unknown-path 404 live in workerctl and are called by both
worker packages; the bearer check that guards every extra route is one
function in core/services/nodes used by both server constructors.
workerctl.AllPaths splits into BackendPaths and AgentPaths, with AllPaths
as their deduped union, because a backend worker does not mount the agent
verbs and asserting otherwise would fail a correct worker.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-03 11:22:38 +00:00

86 lines
3.5 KiB
Go

package workerctl
import (
"io"
"net/http"
"unicode/utf8"
)
// The rules every worker control verb enforces on a request, and the one answer
// every worker gives for a path it does not serve.
//
// They live here, beside the path table both sides read, because there are now
// TWO kinds of worker mounting verbs under Prefix: a backend worker and an
// agent worker. Phase 3 found the same rule written at three sites in one file
// and pinned at one, which is how a verb ends up reading an unbounded body or
// answering a 500 where the frontend expects a 404. A second worker package
// with its own copy of these rules would be that finding again, across a
// package boundary where it is harder to see.
// MaxRequestBytes bounds a control request body.
//
// The largest real body is BackendInstallRequest.BackendGalleries, a serialized
// gallery list of a few hundred kilobytes. Eight megabytes is therefore not a
// size the protocol needs: it is a defence against a body that never ends,
// arriving on a boundary a worker now serves.
const MaxRequestBytes = 8 << 20
// MaxEchoedPathBytes bounds how much of an unknown control path the 404 body
// repeats back. The path is caller-controlled and the answer exists to be read
// in a log line, so a caller cannot make a worker echo a request-sized string
// into one.
const MaxEchoedPathBytes = 128
// ReadRequestBody enforces the two things every control verb requires of a
// request: that it is a POST, and that its body is bounded. It writes the
// refusal itself and reports false when it did.
//
// A GET is refused rather than served because a control verb is a command, and
// a liveness probe, a link prefetch or a browser address bar must not be able
// to stop a node.
func ReadRequestBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
if r.Method != http.MethodPost {
http.Error(w, "control verbs are POST only", http.StatusMethodNotAllowed)
return nil, false
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MaxRequestBytes))
if err != nil {
// A body a worker could not READ is not an answer about any backend, so
// it must not look like one: 400 is what the frontend maps onto "the
// request was rejected", never onto "that model is gone".
http.Error(w, "reading the control request body: "+err.Error(), http.StatusBadRequest)
return nil, false
}
return body, true
}
// WriteUnknownPath answers a path under Prefix that this worker mounts no
// handler for.
//
// The body says what happened, because a bare 404 through a tunnel is
// indistinguishable from a proxy fault, and the frontend reads this exact
// status as "this worker does not serve that verb"
// (nodes.ErrWorkerControlUnsupported) rather than as the worker being absent.
func WriteUnknownPath(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unknown worker control path "+truncate(r.URL.Path, MaxEchoedPathBytes), http.StatusNotFound)
}
// truncate bounds a caller-controlled string that is about to be echoed.
//
// It cuts on a rune boundary. A byte-wise cut can split a multi-byte rune, and
// the half rune then travels as a replacement character through every log and
// UI that reads it; phase 2 shipped exactly that defect on a refusal reason and
// pinned the rule afterwards. utf8.RuneStart is the same predicate the cluster
// package uses for it, so the two are one rule rather than two hand-rolled
// copies that can drift.
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "…"
}