Three reported from a colleague's machine, plus one the fixing uncovered. Every one produced a message that was true and useless. ## behavision-setup chose the Python least likely to work findPython walked 3.14, 3.13, 3.12, 3.11, 3.10 and took the first hit - a floor with NO ceiling, which is exactly backwards. The newest Python on a machine is the one least likely to have binary wheels. It picked 3.14, pip found no numpy wheel for cp314, fell back to building numpy from source and produced "Unknown compiler(s)"; once the operator had installed Xcode's command line tools to get past that, ten minutes of compiling ended in "<arm_neon.h> is intended only for ARM and AArch64 targets". maxMinor refuses in one line before anything is downloaded, and "too new" is a different message from "too old" - telling somebody holding Python 3.14 that no Python was found sends them to install a newer one, which is the direction that just failed. ## numpy<2.0 was the cap; OpenCV was the hazard Widening it needed proof, and the proof found something else. Nine runs of the detector guard per combination, one machine, one sitting: numpy 1.26 / cv2 4.11 9 passed, 0 crashed numpy 2.0 / cv2 4.11 8 passed, 1 crashed numpy 1.26 / cv2 4.14 3 passed, 6 crashed numpy 2.0 / cv2 4.14 2 passed, 7 crashed numpy is not the variable; OpenCV is - the third row is numpy 1.26. The crash was test_a_shared_detector_really_does_race, which races a shared cv2.FaceDetectorYN on purpose. That is undefined behaviour in C++: 4.11 usually turned it into an exception, 4.14 usually turns it into a segfault, and 4.11 crashing once says the hazard was always there. It never reached the product - Engine._build_worker builds a detector per camera. It reached the suite: two runs in three died with no failing assertion in them. The race runs in a subprocess now, and one clean attempt proves nothing, so the premise holds if any of several attempts misbehaves. 226 passed / 2 skipped on numpy 2.0.2, five runs of five. opencv stays capped below 5: everything above was measured on 4.x, and an uncapped >=4.8.1 gives every NEW install a major release this project has never run a real camera through. ## One MQTT client id for a whole shop, so two PCs fought over it behavision-<client>-<site> is the same string on every computer claimed to one site. MQTT requires unique client ids and a broker enforces it by disconnecting the older session, so the colleague's Mac and the shop's own till took turns kicking each other off: broker connected / broker connection lost: EOF / broker connected / EOF ... The damage is not confined to the new machine. The till is the other half of that loop, so signing in on a laptop to look at the product stops a live shop delivering visits - and from each end it reads as an unstable network. MQTTClientID() appends a per-installation id, minted on first load and written back so an existing install gets one without anybody doing anything. The site stays in the name because that is what a broker log is read by. An unwritable config falls back to a per-run id rather than a shared one. ## "no such file or directory" for an engine nobody had installed Pressing Start went straight to the supervisor, which reported what exec reported: a 200-character path ending in "no such file or directory". Every word true, none of it saying "run the setup tool" - the startup path had that sentence, in a log file nobody on a shop counter opens. engineMissing() is the one function the startup path, the Start button and the status panel all consult. It also names App Translocation, which was in that path and is unguessable: macOS runs a downloaded unsigned app from a random read-only copy, so relative paths resolve inside it and an install there would not survive a restart. The product is unsigned, so that is the normal first-run state on every Mac, not an edge case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
265 lines
9.2 KiB
Go
265 lines
9.2 KiB
Go
// Package config holds the agent's own settings: which tenant and site this
|
|
// install belongs to, how to reach the broker, and how to launch the engine.
|
|
//
|
|
// Kept separate from the engine's YAML on purpose. That file describes
|
|
// recognition — thresholds, cameras, gates — and is edited by whoever tunes a
|
|
// site. This one describes identity and connectivity, is written by the
|
|
// installer and the login flow, and holds a secret.
|
|
package config
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
)
|
|
|
|
// protectedPrefix marks a value that went through DPAPI, so a config written
|
|
// on Windows is never mistaken for a plaintext dev one and vice versa.
|
|
const protectedPrefix = "dpapi:"
|
|
|
|
// Config is the agent's on-disk settings.
|
|
type Config struct {
|
|
// Tenant identity. The server keys everything on these.
|
|
ClientID string `json:"client_id"`
|
|
SiteID string `json:"site_id"`
|
|
SiteName string `json:"site_name"`
|
|
|
|
// Broker.
|
|
BrokerURL string `json:"broker_url"`
|
|
BrokerUsername string `json:"broker_username"`
|
|
BrokerPassword string `json:"broker_password"` // protected at rest
|
|
// Pins the broker's issuer. Empty uses the system roots, which is what a
|
|
// Let's Encrypt certificate needs; a private CA is pinned by path.
|
|
BrokerCAFile string `json:"broker_ca_file"`
|
|
|
|
// Engine process.
|
|
EngineExe string `json:"engine_exe"`
|
|
// EngineDir is the working directory the engine is launched IN. Empty
|
|
// means the install root.
|
|
//
|
|
// It exists because nothing set it, so the engine inherited whatever
|
|
// launched the app - and for an app started by double-clicking its
|
|
// bundle that is "/", not anywhere useful. The symptom on macOS was
|
|
// `python: No module named behavision` repeating forever: the dev engine
|
|
// is `-m behavision`, which resolves against the working directory. The
|
|
// same app started from a terminal in the repo worked, which is exactly
|
|
// the shape of a bug that survives every test run by a developer.
|
|
EngineDir string `json:"engine_dir,omitempty"`
|
|
EngineArgs []string `json:"engine_args"`
|
|
APIBase string `json:"api_base"`
|
|
APIUser string `json:"api_user"`
|
|
APIPassword string `json:"api_password"` // protected at rest
|
|
|
|
// Session, so a shop PC that reboots overnight is not a login every
|
|
// morning. Protected at rest like every other secret here.
|
|
SessionToken string `json:"session_token"`
|
|
SessionRefresh string `json:"session_refresh"`
|
|
SessionEmail string `json:"session_email"`
|
|
|
|
// CloudBase is the server this site reports to; AgentToken is this PC's
|
|
// own credential there, issued once at enrolment.
|
|
//
|
|
// Deliberately not the same secret as BrokerPassword: they authenticate
|
|
// different things - one says this site may publish events, the other that
|
|
// it may ask the API for something - so rotating either must not break the
|
|
// other. Protected at rest like every other secret here.
|
|
CloudBase string `json:"cloud_base"`
|
|
AgentToken string `json:"agent_token"`
|
|
|
|
// Standalone marks a PC deliberately run on its own: cameras, recognition
|
|
// and the local gallery, with nothing reported to head office.
|
|
//
|
|
// It exists so that "not linked yet" and "not going to be linked" are
|
|
// different states. Without it every install was blocked on an enrolment
|
|
// code, so a shop with one PC and no head office could not add a camera at
|
|
// all - the software refused to do the thing it is for until a server it
|
|
// does not need had issued it a credential.
|
|
Standalone bool `json:"standalone"`
|
|
|
|
// Queue.
|
|
SpoolMax int `json:"spool_max"`
|
|
|
|
// InstallID distinguishes THIS installation from every other one claimed
|
|
// to the same site. See MQTTClientID.
|
|
InstallID string `json:"install_id,omitempty"`
|
|
|
|
path string
|
|
}
|
|
|
|
// Defaults returns a config that runs a locally installed engine.
|
|
//
|
|
// EngineExe is relative to the install root - the directory holding this
|
|
// executable - and names the installed layout: the engine is a PyInstaller
|
|
// one-FOLDER build, so it brings its own DLLs and cannot simply sit beside the
|
|
// app. Windows filenames are case-insensitive too, so `Behavision.exe` (the
|
|
// app) and `behavision.exe` (the engine) could not share a directory even if
|
|
// it were tidy to.
|
|
func Defaults() Config {
|
|
exe := filepath.Join("engine", "behavision")
|
|
if runtime.GOOS == "windows" {
|
|
exe += ".exe"
|
|
}
|
|
return Config{
|
|
EngineExe: exe,
|
|
EngineArgs: []string{"run"},
|
|
APIBase: "http://127.0.0.1:8010",
|
|
SpoolMax: 50000,
|
|
}
|
|
}
|
|
|
|
// Load reads the config, decrypting secrets. A missing file is not an error:
|
|
// a fresh install has none until the operator logs in, and failing to start
|
|
// because of that would leave them with no UI to log in from.
|
|
func Load(path string) (Config, error) {
|
|
cfg := Defaults()
|
|
cfg.path = path
|
|
blob, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return cfg, nil
|
|
}
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
if err := json.Unmarshal(blob, &cfg); err != nil {
|
|
return cfg, fmt.Errorf("config %s: %w", path, err)
|
|
}
|
|
cfg.path = path
|
|
// Minted on first load and written back, so an installation that predates
|
|
// this field gets one without anybody doing anything. Best effort: a
|
|
// read-only config still yields a working id for this run, it is simply
|
|
// not the same one next time.
|
|
if cfg.InstallID == "" {
|
|
cfg.InstallID = newInstallID()
|
|
_ = cfg.Save(path)
|
|
}
|
|
for _, field := range []*string{&cfg.BrokerPassword, &cfg.APIPassword,
|
|
&cfg.SessionToken, &cfg.SessionRefresh, &cfg.AgentToken} {
|
|
plain, err := reveal(*field)
|
|
if err != nil {
|
|
// A secret that cannot be decrypted usually means the config was
|
|
// copied from another machine - DPAPI is machine-scoped. Blank it
|
|
// rather than failing: the operator can log in again, but they
|
|
// cannot fix a process that will not start.
|
|
*field = ""
|
|
continue
|
|
}
|
|
*field = plain
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// Save writes the config atomically, protecting secrets on the way out.
|
|
func (c Config) Save(path string) error {
|
|
if path == "" {
|
|
path = c.path
|
|
}
|
|
if path == "" {
|
|
return fmt.Errorf("config: no path to save to")
|
|
}
|
|
out := c
|
|
out.path = ""
|
|
for _, field := range []*string{&out.BrokerPassword, &out.APIPassword,
|
|
&out.SessionToken, &out.SessionRefresh, &out.AgentToken} {
|
|
hidden, err := conceal(*field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*field = hidden
|
|
}
|
|
blob, err := json.MarshalIndent(out, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
// Temp-then-rename: a crash mid-write must not leave a config that parses
|
|
// as valid but is half old and half new.
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, blob, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
// Configured reports whether this install has been claimed by a tenant yet.
|
|
// The UI shows a login screen until it has.
|
|
func (c Config) Configured() bool {
|
|
return c.ClientID != "" && c.SiteID != "" && c.BrokerURL != ""
|
|
}
|
|
|
|
// SecretsProtected is false on a dev machine, where secrets are stored as-is.
|
|
// Surfaced rather than hidden so nobody ships a build believing otherwise.
|
|
func SecretsProtected() bool { return protectionAvailable() }
|
|
|
|
func conceal(plain string) (string, error) {
|
|
if plain == "" || !protectionAvailable() {
|
|
return plain, nil
|
|
}
|
|
blob, err := protect([]byte(plain))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return protectedPrefix + base64.StdEncoding.EncodeToString(blob), nil
|
|
}
|
|
|
|
func reveal(stored string) (string, error) {
|
|
if !strings.HasPrefix(stored, protectedPrefix) {
|
|
return stored, nil
|
|
}
|
|
blob, err := base64.StdEncoding.DecodeString(
|
|
strings.TrimPrefix(stored, protectedPrefix))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
plain, err := unprotect(blob)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plain), nil
|
|
}
|
|
|
|
// MQTTClientID names this INSTALLATION, not this site.
|
|
//
|
|
// It was `behavision-<client>-<site>`, which is the same string on every
|
|
// computer claimed to one shop. MQTT requires client ids to be unique and a
|
|
// broker enforces it by disconnecting the older session when a new one
|
|
// arrives with the same id - so two machines on one site take turns kicking
|
|
// each other off, forever. Measured on a second Mac claimed to a live shop:
|
|
//
|
|
// broker connected / broker connection lost: EOF / broker connected / ...
|
|
//
|
|
// The damage is not confined to the new machine. The shop's own till is the
|
|
// other half of that loop, so somebody signing in on a laptop to look at the
|
|
// product stops the shop delivering visits - and nothing at either end says
|
|
// why, because from each side it reads as an unstable network.
|
|
//
|
|
// The site stays in the id because it is what a broker log is read by, and
|
|
// the random half is short for the same reason. `CleanSession(true)` means
|
|
// there is no session state for a changed id to strand.
|
|
func (c Config) MQTTClientID() string {
|
|
id := c.InstallID
|
|
if id == "" {
|
|
// A config that could not be written still has to produce a UNIQUE
|
|
// id, or this falls straight back into the collision it exists to
|
|
// prevent. Per-run is the right failure: the connection works and the
|
|
// only cost is a new name in the broker's log after a restart.
|
|
id = newInstallID()
|
|
}
|
|
return "behavision-" + c.ClientID + "-" + c.SiteID + "-" + id
|
|
}
|
|
|
|
func newInstallID() string {
|
|
b := make([]byte, 4)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "x"
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|