A headless PC can be claimed, and a refused broker says so

Both found by operating the stack rather than writing it: the local
processes were OOM-killed and bringing them back hit two gaps.

The headless agent had no way to be claimed at all. Bootstrap lived only
in desktop/internal/cloud, so the one configuration the agent binary
exists for - a back-office PC with no window - could only be onboarded by
hand-editing agent.json, which is the state the desktop's Setup screen was
built to end. `behavision-agent claim <code>` closes it; the CLI joins its
arguments because the code is printed in groups for reading aloud and an
operator pasting it will paste the spaces too.

Second: after the site's broker password was re-rolled, mosquitto logged
"not authorised" while the agent logged "timed out". Those need opposite
actions - re-link this PC, or go and look at the network - and paho's
SetConnectRetry collapses them, because it retries internally and the
connect token never completes. describeStall asks whether a TCP socket
opens at all, and says what is known rather than guessing at a reason the
broker never gives.

Verified end to end: minted a code from the platform as the owner,
claimed with the new command, broker connected, and the shop went to
online: true with 1/1 cameras on w600k_r50.

Also corrects this machine's memory in CLAUDE.md from 16 GB to 8 GB. It
feeds the model-fallback reasoning, and the local gallery already holds
17 embeddings tagged w600k_mbf beside 19 tagged w600k_r50 - the fallback
has silently fired before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
2026-09-04 13:32:38 +05:30
parent e0ceb14589
commit 2cd7a78ddc
6 changed files with 336 additions and 5 deletions

View File

@@ -152,7 +152,8 @@ own camera, same-person similarity p05 0.719 vs 0.620) → `arcface_int8.onnx`
the chain. AdaFace slots are wired but empty: drop a converted the chain. AdaFace slots are wired but empty: drop a converted
`adaface_ir50.onnx` in and it is picked up, BGR channel order already `adaface_ir50.onnx` in and it is picked up, BGR channel order already
handled (see `color_order_for`). The dev machine is handled (see `color_order_for`). The dev machine is
memory-starved (16 GB, often < 1.5 GB free): the 260 MB model fails with memory-starved (**8 GB**, measured 0.45 GB free with a browser and Docker
open): the 260 MB model fails with
"bad allocation"; int8 quantization of it segfaulted (OOM). w600k_mbf comes "bad allocation"; int8 quantization of it segfaulted (OOM). w600k_mbf comes
from InsightFace buffalo_sc; genderage.onnx from buffalo_l. On failure, from InsightFace buffalo_sc; genderage.onnx from buffalo_l. On failure,
the encoder retries loading with `ORT_DISABLE_ALL` graph optimization. the encoder retries loading with `ORT_DISABLE_ALL` graph optimization.
@@ -2257,3 +2258,57 @@ Verified against the real office camera with no object storage configured: a
90,587-byte frame stored in Postgres, served as `image/jpeg` to a signed-in 90,587-byte frame stored in Postgres, served as `image/jpeg` to a signed-in
user, **401 without a session**, and rendered on both the Cameras and Shops user, **401 without a session**, and rendered on both the Cameras and Shops
cards. cards.
## Claiming a headless PC, and telling a refused broker from an absent one
Both found by the local stack falling over on a memory-starved machine and
needing to be brought back — the kind of thing that only surfaces when the
software is operated rather than written.
### `behavision-agent claim <code>`
The desktop app has had a Setup screen since enrolment was built. The **headless
agent had nothing**: `Bootstrap` lived only in `desktop/internal/cloud`, so the
one configuration the agent binary exists for — a back-office PC with no window
— could not be claimed at all. The only route was hand-editing `agent.json`,
which is exactly the state that Setup screen was built to end.
`agent/pkg/enrol` is that call, and the CLI joins its arguments rather than
demanding quotes: the code is printed in groups so it can be read aloud, and an
operator pasting it will paste the spaces too. It clears `Standalone`, and a
config that fails to save is **reported** — a claim that is not on disk works
until the next restart and then silently is not claimed, which looks exactly
like a wrong code.
### A rejected connection and an unreachable broker are not the same fault
Measured, on the real stack: after the site's broker password was re-rolled,
mosquitto logged `not authorised` while the agent logged **`connect to
tcp://... timed out`**. Those need opposite actions — re-link this PC, or go and
look at the network — and paho's `SetConnectRetry` is why they collapse into
one: it retries internally, so the connect token never completes and *every*
failure arrives as a timeout.
`describeStall` asks the one question that separates them: can a TCP socket be
opened to the broker at all? Reachable-but-not-accepted names the likely cause
and the command to fix it; unreachable says to check the network. It does not
claim to know the exact reason — the broker does not tell a rejected client why,
and a TLS failure looks the same from here — so it reports what is known rather
than guessing. Same rule as `artifact` vs `no_faces` in the commissioning
verdicts, and the `connected` pointer being three states rather than two.
`brokerHostPort` parses with `net/url`, never by scanning for the first `:` —
this package has already been bitten once by IPv6 literals being bracketed and
full of them.
### The dev machine is 8 GB, not 16
Corrected in this file, because it feeds a real decision. Measured while the
stack was up: **0.45 GB free** with a browser and Docker Desktop open, and
Docker alone is allocated 4 GB of the 8. The engine's steady state is only
~260 MB, so the OOM kill happened during a build (npm + go + Docker at once),
not in normal running — but the margin is what makes `/api/health` reporting
`recognition_model` worth checking after every restart. The local gallery
already holds **17 embeddings tagged `w600k_mbf` and 19 tagged `w600k_r50`**:
proof that the fallback has silently fired before, and that model-tagging is
what stopped it corrupting anything.

2
RUN.md
View File

@@ -251,7 +251,7 @@ the frozen engine once to prove it runs, and compiles the installer.
3. Launch from the Start menu. **Set this PC up on its own** — no code needed. 3. Launch from the Start menu. **Set this PC up on its own** — no code needed.
4. Cameras → Add camera → pick the make → Test connection → Save. The feed must 4. Cameras → Add camera → pick the make → Test connection → Save. The feed must
appear with no restart. appear with no restart.
5. Check `/api/health` reports `recognition_model`. On a 16 GB machine the 5. Check `/api/health` reports `recognition_model`. On a small machine the
166 MB r50 can lose the fallback chain to the 13 MB mbf, and embeddings are 166 MB r50 can lose the fallback chain to the 13 MB mbf, and embeddings are
model-tagged, so which one wins decides whether a gallery carries over. model-tagged, so which one wins decides whether a gallery carries over.
6. Sign out of the tray (Quit) — recognition must stop with it. Reboot; the app 6. Sign out of the tray (Quit) — recognition must stop with it. Reboot; the app

View File

@@ -29,6 +29,7 @@ import (
"github.com/loyaly/behavision-agent/pkg/cameras" "github.com/loyaly/behavision-agent/pkg/cameras"
"github.com/loyaly/behavision-agent/pkg/config" "github.com/loyaly/behavision-agent/pkg/config"
"github.com/loyaly/behavision-agent/pkg/engine" "github.com/loyaly/behavision-agent/pkg/engine"
"github.com/loyaly/behavision-agent/pkg/enrol"
"github.com/loyaly/behavision-agent/pkg/mqtt" "github.com/loyaly/behavision-agent/pkg/mqtt"
"github.com/loyaly/behavision-agent/pkg/paths" "github.com/loyaly/behavision-agent/pkg/paths"
"github.com/loyaly/behavision-agent/pkg/spool" "github.com/loyaly/behavision-agent/pkg/spool"
@@ -38,8 +39,15 @@ var version = "dev"
func main() { func main() {
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(os.Stderr, "behavision-agent %s\n\nusage: %s <run|status|paths>\n", fmt.Fprintf(os.Stderr, `behavision-agent %s
version, filepath.Base(os.Args[0]))
usage: %s <command>
run supervise the engine and report to head office (default)
claim <code> link this PC to a shop, using an installation code
status what this PC is and whether it is claimed
paths where this install reads and writes
`, version, filepath.Base(os.Args[0]))
} }
flag.Parse() flag.Parse()
@@ -53,6 +61,8 @@ func main() {
err = cmdRun() err = cmdRun()
case "status": case "status":
err = cmdStatus() err = cmdStatus()
case "claim":
err = cmdClaim(flag.Args()[1:])
case "paths": case "paths":
err = cmdPaths() err = cmdPaths()
default: default:
@@ -64,6 +74,67 @@ func main() {
} }
} }
// cmdClaim is the headless half of onboarding.
//
// The desktop app has had a Setup screen for this; a back-office PC with no
// window had nothing at all, so the only way to claim one was to hand-edit
// agent.json - which is the state that screen was built to end.
func cmdClaim(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: behavision-agent claim <installation code>\n" +
"Ask whoever manages your shops for one - they can create it from\n" +
"the Behavision platform, under the shop.")
}
// Joined rather than requiring quotes: the code is printed in groups for
// reading aloud, and an operator pasting it will paste the spaces too.
code := strings.Join(args, "")
if err := paths.EnsureState(); err != nil {
return err
}
cfg, err := config.Load(paths.AgentConfig())
if err != nil {
return err
}
base := cfg.CloudBase
if v := os.Getenv("BEHAVISION_CLOUD"); v != "" {
base = v
}
if base == "" {
base = "https://mcp.loyaly.ai"
}
b, err := enrol.Claim(context.Background(), base, code)
if err != nil {
return err
}
// The slugs, not the uuids: the topic prefix is <client>.<site> and the
// broker's ACL is written against exactly that username.
cfg.ClientID = b.ClientSlug
cfg.SiteID = b.SiteSlug
cfg.SiteName = b.SiteName
cfg.BrokerURL = b.MQTTURL
cfg.BrokerUsername = b.MQTTUser
cfg.BrokerPassword = b.MQTTPass
cfg.AgentToken = b.AgentToken
cfg.CloudBase = base
// A PC that was running on its own and has now been linked is no longer
// standalone.
cfg.Standalone = false
if err := cfg.Save(paths.AgentConfig()); err != nil {
// Reported, never swallowed: a claim that is not on disk works until
// the next restart and then silently is not claimed any more, which
// looks exactly like a wrong code.
return fmt.Errorf("could not save the settings: %w", err)
}
fmt.Printf("linked to %s (%s.%s)\n", b.SiteName, b.ClientSlug, b.SiteSlug)
fmt.Printf("settings written to %s\n", paths.AgentConfig())
fmt.Println("restart the agent for it to take effect.")
return nil
}
func cmdPaths() error { func cmdPaths() error {
return json.NewEncoder(os.Stdout).Encode(map[string]string{ return json.NewEncoder(os.Stdout).Encode(map[string]string{
"version": version, "version": version,

92
agent/pkg/enrol/enrol.go Normal file
View File

@@ -0,0 +1,92 @@
// Package enrol links a PC to a shop, using the one-shot code an operator is
// given.
//
// It existed only inside the desktop app, which meant a HEADLESS install - a
// back-office PC with no window, the configuration the agent binary is for -
// could not be claimed at all. The only route was hand-editing agent.json,
// which is exactly the state the desktop's Setup screen was built to end.
//
// The endpoint behind this is deliberately unauthenticated: the PC doing it has
// nobody signed in yet, and requiring a login would mean shipping a password to
// every shop that installs the software.
package enrol
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Bootstrap is what the server hands back: which shop this PC is, and the
// credentials it needs to say so.
type Bootstrap struct {
ClientSlug string `json:"client_slug"`
SiteSlug string `json:"site_slug"`
SiteName string `json:"site_name"`
MQTTURL string `json:"mqtt_url"`
MQTTUser string `json:"mqtt_username"`
MQTTPass string `json:"mqtt_password"`
CAPem string `json:"ca_pem,omitempty"`
AgentToken string `json:"agent_token"`
}
// Claim redeems an installation code.
//
// The code is read aloud down a phone and photographed off screens, so what is
// typed here can be as untidy as it needs to be: the server strips spaces,
// dashes and case at its end. Sending it as typed keeps ONE implementation of
// that normalisation, on the side that also issued the code - two would
// eventually disagree and hash to something the redeemer never produces.
func Claim(ctx context.Context, base, code string) (Bootstrap, error) {
var out Bootstrap
base = strings.TrimRight(base, "/")
if base == "" {
return out, fmt.Errorf("no server address configured (set cloud_base or BEHAVISION_CLOUD)")
}
body, err := json.Marshal(map[string]string{"site_token": code})
if err != nil {
return out, err
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
base+"/api/agent/enrol", bytes.NewReader(body))
if err != nil {
return out, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return out, fmt.Errorf("could not reach %s: %w", base, err)
}
defer resp.Body.Close()
blob, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode != http.StatusOK {
// The server answers unknown, expired and already-used identically on
// purpose - the difference only helps somebody guessing codes, and the
// operator's next step is the same in all three cases. Its own words
// are passed through rather than reworded here.
var e struct {
Message string `json:"message"`
}
_ = json.Unmarshal(blob, &e)
if e.Message != "" {
return out, fmt.Errorf("%s", e.Message)
}
return out, fmt.Errorf("head office: %s", resp.Status)
}
if err := json.Unmarshal(blob, &out); err != nil {
return out, err
}
if out.SiteSlug == "" || out.MQTTURL == "" {
return out, fmt.Errorf("head office returned an incomplete setup")
}
return out, nil
}

View File

@@ -16,6 +16,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"net"
"net/url"
neturl "net/url" neturl "net/url"
"os" "os"
"strings" "strings"
@@ -104,7 +106,13 @@ func NewClient(opts ClientOptions) (*Client, error) {
tok := c.client.Connect() tok := c.client.Connect()
if !tok.WaitTimeout(20 * time.Second) { if !tok.WaitTimeout(20 * time.Second) {
return c, fmt.Errorf("mqtt: connect to %s timed out", opts.BrokerURL) // SetConnectRetry means paho retries internally and this token never
// completes, so a REFUSED connection and an UNREACHABLE broker both
// arrive here as a timeout. They need opposite actions - re-link this
// PC, or go and look at the network - and reporting both as "timed
// out" sent the diagnosis to the wrong place. Measured: mosquitto
// logged "not authorised" while the agent logged a timeout.
return c, fmt.Errorf("mqtt: %s", describeStall(opts.BrokerURL))
} }
if err := tok.Error(); err != nil { if err := tok.Error(); err != nil {
return c, fmt.Errorf("mqtt: connect to %s: %w", opts.BrokerURL, err) return c, fmt.Errorf("mqtt: connect to %s: %w", opts.BrokerURL, err)
@@ -112,6 +120,47 @@ func NewClient(opts ClientOptions) (*Client, error) {
return c, nil return c, nil
} }
// describeStall says which of the two failures this is, by asking the one
// question that separates them: can we open a socket to the broker at all?
//
// It cannot name the exact reason - the broker does not tell a rejected client
// why, and a TLS failure looks the same from here - so it says what is known
// and what to check, rather than guessing. Being reachable but not accepted is
// overwhelmingly a credential this PC no longer has, which is what happens when
// a site is re-provisioned.
func describeStall(brokerURL string) string {
host := brokerHostPort(brokerURL)
if host == "" {
return fmt.Sprintf("connect to %s timed out", brokerURL)
}
conn, err := net.DialTimeout("tcp", host, 5*time.Second)
if err != nil {
return fmt.Sprintf("cannot reach the broker at %s: %v - check the "+
"network and that the broker is running", host, err)
}
_ = conn.Close()
return fmt.Sprintf("the broker at %s is reachable but did not accept this "+
"PC - usually its credentials are no longer valid; re-link it with "+
"`behavision-agent claim <code>`", host)
}
// brokerHostPort extracts host:port for the reachability probe. Parsed with
// net/url, never by scanning for the first ":" - an IPv6 literal is bracketed
// and full of them.
func brokerHostPort(brokerURL string) string {
u, err := url.Parse(brokerURL)
if err != nil || u.Host == "" {
return ""
}
if u.Port() != "" {
return u.Host
}
if strings.HasPrefix(brokerURL, "tls://") || strings.HasPrefix(brokerURL, "ssl://") {
return net.JoinHostPort(u.Hostname(), "8883")
}
return net.JoinHostPort(u.Hostname(), "1883")
}
// Publish sends one message at QoS 1 and waits for the broker's PUBACK. // Publish sends one message at QoS 1 and waits for the broker's PUBACK.
// //
// QoS 1, not 0 or 2. At QoS 0 the broker never confirms, so the pump would ack // QoS 1, not 0 or 2. At QoS 0 the broker never confirms, so the pump would ack

View File

@@ -1,6 +1,7 @@
package mqtt package mqtt
import ( import (
"net"
"strings" "strings"
"testing" "testing"
) )
@@ -102,3 +103,66 @@ func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
func writeFile(path, content string) error { func writeFile(path, content string) error {
return osWriteFile(path, []byte(content), 0o600) return osWriteFile(path, []byte(content), 0o600)
} }
// "The broker refused this PC" and "the broker is not there" need opposite
// actions - re-link this PC, or go and look at the network - and paho's
// connect-retry makes both arrive as a timeout. Measured on a real broker:
// mosquitto logged "not authorised" while the agent logged a timeout, which
// sent the diagnosis to the wrong place.
func TestARefusedBrokerIsNotDescribedAsUnreachable(t *testing.T) {
// A listener that accepts TCP and then says nothing is exactly what a
// broker rejecting a client looks like from out here.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
_ = c
}
}()
got := describeStall("tcp://" + ln.Addr().String())
if !strings.Contains(got, "reachable but did not accept") {
t.Fatalf("a reachable broker was described as unreachable: %s", got)
}
if !strings.Contains(got, "claim") {
t.Errorf("the message does not say what to do about it: %s", got)
}
}
func TestAnAbsentBrokerIsDescribedAsUnreachable(t *testing.T) {
// Bound and immediately closed, so the port is certainly nobody's.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
ln.Close()
got := describeStall("tcp://" + addr)
if !strings.Contains(got, "cannot reach the broker") {
t.Fatalf("an absent broker was not described as unreachable: %s", got)
}
}
// An IPv6 literal is bracketed and full of colons, so scanning for the first
// one gives "[". The same bug this package already fixed once for broker URLs.
func TestTheProbeAddressHandlesIPv6AndDefaultPorts(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"tcp://127.0.0.1:51883", "127.0.0.1:51883"},
{"tcp://[::1]:1883", "[::1]:1883"},
{"tcp://broker.example", "broker.example:1883"},
{"tls://broker.example", "broker.example:8883"},
{"tls://[2001:db8::1]:8884", "[2001:db8::1]:8884"},
} {
if got := brokerHostPort(tc.in); got != tc.want {
t.Errorf("brokerHostPort(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}