The agent read the engine's generated credential file once, at startup. On a brand new install that file does not exist yet: the agent starts the engine, and the engine writes its credential seconds later. So the agent held an empty credential for the life of the process and every call it makes - health, stats, camera sync, the embedding for a visit - came back 401, with a tray showing a red engine that was running perfectly. Measured on a fresh state directory today: three 401s, no camera ever reconciled, and the engine left running the YAML-seeded main stream instead of the sub-stream head office holds. The install script hid this on Windows because setup runs the engine once before the app starts. config.Creds resolves lazily and re-reads on a rejection; the camera client, the supervisor and the desktop app's engine client all retry once when it changes. A configured BEHAVISION_API_USER is never re-read - an operator who set one means it. Tests pin the actual first-run ordering. Also adds demo/, a one-screen live console for showing the whole chain: camera, the six steps with a measured camera-to-cloud latency, the customer editable in place, and the raw JSON a phone and a dashboard receive from production side by side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
121 lines
3.8 KiB
Go
121 lines
3.8 KiB
Go
package config
|
|
|
|
import (
|
|
"bufio"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// EngineCredentials reads the Basic credentials the engine generated for
|
|
// itself, from the file it writes them to.
|
|
//
|
|
// The engine only invents a credential when none is configured and its API
|
|
// listens on a routable address - which is the DEFAULT configuration, so this
|
|
// is the ordinary case and not an edge one. `paths.APICredentials` has existed
|
|
// since the agent was written, with a comment saying the agent reads the file
|
|
// "rather than storing a second copy, so a regenerated credential does not
|
|
// silently break the tray". Nothing read it. On a stock install the agent's
|
|
// api_user was therefore empty and every call it makes to the engine - health,
|
|
// stats, camera sync, embeddings for a visit - came back 401: the tray red, the
|
|
// cameras never reconciled, and no error anywhere saying why.
|
|
//
|
|
// A missing or unreadable file is not an error. A PC where the operator set
|
|
// BEHAVISION_API_USER has no such file and needs none.
|
|
func EngineCredentials(path string) (user, password string) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
defer f.Close()
|
|
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
// `key=value`, and `key: value` too: the file is also read by people,
|
|
// and which separator the engine used is not worth a support call.
|
|
line := strings.TrimSpace(sc.Text())
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
k, v, ok = strings.Cut(line, ":")
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(k) {
|
|
case "username":
|
|
user = strings.TrimSpace(v)
|
|
case "password":
|
|
password = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return user, password
|
|
}
|
|
|
|
// WithEngineCredentials fills in the engine's Basic credentials from the file
|
|
// when the config carries none. Configured values always win: an operator who
|
|
// set BEHAVISION_API_USER means it.
|
|
func (c Config) WithEngineCredentials(path string) Config {
|
|
if c.APIUser != "" || c.APIPassword != "" {
|
|
return c
|
|
}
|
|
c.APIUser, c.APIPassword = EngineCredentials(path)
|
|
return c
|
|
}
|
|
|
|
// Creds resolves the engine's Basic credentials, re-reading the file when it
|
|
// has none.
|
|
//
|
|
// Reading once at startup is wrong on a fresh install, and that is the case
|
|
// that matters: the agent starts the engine, the engine generates its
|
|
// credential and writes the file a few seconds later, and an agent that read
|
|
// the file before that holds "" forever. Every call it makes - health, stats,
|
|
// camera sync, the embedding for a visit - then comes back 401 for the life of
|
|
// the process, on a brand new shop PC, with the tray showing a red engine that
|
|
// is running perfectly. Measured on a fresh state directory: three 401s and no
|
|
// camera ever reconciled.
|
|
//
|
|
// A configured credential is never re-read: an operator who set
|
|
// BEHAVISION_API_USER means it.
|
|
type Creds struct {
|
|
path string
|
|
mu sync.Mutex
|
|
user string
|
|
pass string
|
|
fixed bool
|
|
}
|
|
|
|
// NewCreds takes whatever the config already has. Non-empty means configured,
|
|
// and is used unchanged.
|
|
func NewCreds(path, user, password string) *Creds {
|
|
c := &Creds{path: path, user: user, pass: password}
|
|
c.fixed = user != "" || password != ""
|
|
return c
|
|
}
|
|
|
|
// Get returns the current pair, reading the file if it has nothing yet.
|
|
func (c *Creds) Get() (string, string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.user == "" && !c.fixed {
|
|
c.user, c.pass = EngineCredentials(c.path)
|
|
}
|
|
return c.user, c.pass
|
|
}
|
|
|
|
// Refresh re-reads the file after a rejection and reports whether the pair
|
|
// changed. Callers retry once when it did - which covers both the fresh-install
|
|
// race and a credential the engine regenerated under a running agent.
|
|
func (c *Creds) Refresh() bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.fixed {
|
|
return false
|
|
}
|
|
u, p := EngineCredentials(c.path)
|
|
if u == c.user && p == c.pass {
|
|
return false
|
|
}
|
|
c.user, c.pass = u, p
|
|
return u != ""
|
|
}
|