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

@@ -29,6 +29,7 @@ import (
"github.com/loyaly/behavision-agent/pkg/cameras"
"github.com/loyaly/behavision-agent/pkg/config"
"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/paths"
"github.com/loyaly/behavision-agent/pkg/spool"
@@ -38,8 +39,15 @@ var version = "dev"
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "behavision-agent %s\n\nusage: %s <run|status|paths>\n",
version, filepath.Base(os.Args[0]))
fmt.Fprintf(os.Stderr, `behavision-agent %s
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()
@@ -53,6 +61,8 @@ func main() {
err = cmdRun()
case "status":
err = cmdStatus()
case "claim":
err = cmdClaim(flag.Args()[1:])
case "paths":
err = cmdPaths()
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 {
return json.NewEncoder(os.Stdout).Encode(map[string]string{
"version": version,