Files
Behavision/agent/pkg/config/config.go
Suriyakumarvijayanayagam 0b29dd4a50 The engine inherited whatever directory launched the app
Nothing ever set the child's working directory, so it took the parent's -
and an app started by double-clicking its bundle is handed "/", not
anywhere useful. On macOS the symptom was
`python: No module named behavision` repeating forever, because the dev
engine is invoked as `-m behavision` and that resolves against the
working directory.

The same app launched from a terminal inside the repo worked perfectly,
which is exactly the shape of a bug that survives every test a developer
runs. It only appeared when the app was started the way a user starts
one.

Config.EngineDir, empty meaning the install root, set by both launchers -
the desktop app and the headless agent, which had identical code and the
identical omission. It matters beyond this case: the shipped Windows
engine is a one-folder PyInstaller build whose relative paths should
resolve beside itself rather than beside Explorer's idea of a current
directory.

Verified by double-clicking the bundle with nothing in the environment:
engine up on 8010 (401, gated), w600k_r50 on CoreML, gallery 5/5
embeddings usable and none stranded, both office cameras connected and
streaming, and head office reporting cameras 2/2 one heartbeat later.

Two things that showed up while proving it, both the product being
honest rather than faults:

- The camera at .121 was genuinely unreachable for several minutes, and
  last_error said so in words an installer can act on - "cannot reach
  192.168.1.121:554 - No route" - rather than `connected: false`. That
  field was added yesterday for precisely this.
- Head office briefly showed cameras 0/1 against a local 2/2. That is a
  60-second heartbeat, not a disagreement; the next one read 2/2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 12:42:55 +05:30

213 lines
7.1 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 (
"encoding/base64"
"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"`
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
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
}