mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-13 06:45:26 -04:00
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>
86 lines
3.5 KiB
Go
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] + "…"
|
|
}
|