diff --git a/.gitignore b/.gitignore
index 36ea1ed..b814829 100644
--- a/.gitignore
+++ b/.gitignore
@@ -66,3 +66,4 @@ node_modules/
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
/behavision.egg-info/
/.prod/
+/.demo/
diff --git a/agent/main.go b/agent/main.go
index c1a8339..814168a 100644
--- a/agent/main.go
+++ b/agent/main.go
@@ -161,6 +161,7 @@ func cmdStatus() error {
// which is the default. Reading it here is what stops every call the agent
// makes to the engine coming back 401 on a stock install.
cfg = cfg.WithEngineCredentials(paths.APICredentials())
+ creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
if err != nil {
return err
@@ -169,7 +170,7 @@ func cmdStatus() error {
Command: func(context.Context) *exec.Cmd { return nil },
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
- User: cfg.APIUser, Password: cfg.APIPassword,
+ User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -204,6 +205,7 @@ func cmdRun() error {
// which is the default. Reading it here is what stops every call the agent
// makes to the engine coming back 401 on a stock install.
cfg = cfg.WithEngineCredentials(paths.APICredentials())
+ creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
// Opened before the engine starts: detections arriving in the first second
// must have somewhere to land.
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
@@ -244,7 +246,7 @@ func cmdRun() error {
LogWriter: logFile,
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
- User: cfg.APIUser, Password: cfg.APIPassword,
+ User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
})
ctx, stop := signal.NotifyContext(context.Background(),
@@ -279,6 +281,7 @@ func cmdRun() error {
cloud := cameras.NewCloudClient(cfg.CloudBase, cfg.AgentToken)
cloud.Upload = uploader.UploadBytes
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
+ eng.Creds = creds
go cameras.New(eng, cloud, logger).Run(ctx)
// The live relay, which uploads nothing until somebody at head office is
// actually watching a camera.
diff --git a/agent/pkg/cameras/clients.go b/agent/pkg/cameras/clients.go
index f1854d7..5a83fac 100644
--- a/agent/pkg/cameras/clients.go
+++ b/agent/pkg/cameras/clients.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/loyaly/behavision-agent/pkg/bridge"
+ "github.com/loyaly/behavision-agent/pkg/config"
)
// EngineClient talks to the recognition engine on this PC's loopback.
@@ -21,7 +22,12 @@ type EngineClient struct {
Base string
User string
Password string
- Client *http.Client
+ // Creds re-reads the engine's generated credential when one is rejected.
+ // Without it a fresh install is 401 for the life of the process: the agent
+ // starts the engine, and the engine writes its credential file seconds
+ // after the agent has already read (and failed to find) it.
+ Creds *config.Creds
+ Client *http.Client
}
func NewEngineClient(base, user, password string) *EngineClient {
@@ -50,14 +56,24 @@ func (e *EngineClient) do(ctx context.Context, method, path string, body, out an
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
- if e.User != "" {
- req.SetBasicAuth(e.User, e.Password)
+ user, pass := e.User, e.Password
+ if e.Creds != nil {
+ user, pass = e.Creds.Get()
+ }
+ if user != "" {
+ req.SetBasicAuth(user, pass)
}
resp, err := e.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
+ if resp.StatusCode == http.StatusUnauthorized && e.Creds != nil && e.Creds.Refresh() {
+ // The engine generated its credential after we last looked. Read it
+ // and try once more rather than failing for the life of the process.
+ resp.Body.Close()
+ return e.do(ctx, method, path, body, out)
+ }
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The engine's message, not just a status. "camera stored but failed to
// start: connection refused" is something an operator can act on;
diff --git a/agent/pkg/config/credentials.go b/agent/pkg/config/credentials.go
index 93b75a4..a2137cf 100644
--- a/agent/pkg/config/credentials.go
+++ b/agent/pkg/config/credentials.go
@@ -4,6 +4,7 @@ import (
"bufio"
"os"
"strings"
+ "sync"
)
// EngineCredentials reads the Basic credentials the engine generated for
@@ -60,3 +61,60 @@ func (c Config) WithEngineCredentials(path string) Config {
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 != ""
+}
diff --git a/agent/pkg/config/credentials_firstrun_test.go b/agent/pkg/config/credentials_firstrun_test.go
new file mode 100644
index 0000000..1c3c619
--- /dev/null
+++ b/agent/pkg/config/credentials_firstrun_test.go
@@ -0,0 +1,77 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// The sequence on a brand new shop PC, in order:
+//
+// agent starts -> file does not exist yet
+// agent starts the engine
+// engine generates its credential and writes the file
+// agent calls the engine -> must now succeed
+//
+// Read once at startup, the agent holds "" for the life of the process and
+// every engine call is 401: health, stats, camera sync, the embedding for a
+// visit. The tray shows a red engine that is running perfectly, and nothing
+// says why. Measured on a fresh state directory before this existed.
+func TestCredentialsArriveAfterTheAgentHasAlreadyLooked(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "api_credentials.txt")
+
+ creds := NewCreds(path, "", "") // nothing configured, file not there yet
+ if u, _ := creds.Get(); u != "" {
+ t.Fatalf("expected no credential before the engine has written one, got %q", u)
+ }
+
+ // the engine starts and writes its credential
+ if err := os.WriteFile(path, []byte("username=behavision\npassword=s3cret\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ // a 401 makes the agent look again
+ if !creds.Refresh() {
+ t.Fatal("Refresh did not pick up the credential the engine just wrote")
+ }
+ u, p := creds.Get()
+ if u != "behavision" || p != "s3cret" {
+ t.Fatalf("got %q/%q", u, p)
+ }
+}
+
+// An operator who set BEHAVISION_API_USER means it, and a file must never
+// override them.
+func TestAConfiguredCredentialIsNeverReplacedByTheFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "api_credentials.txt")
+ if err := os.WriteFile(path, []byte("username=generated\npassword=nope\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ creds := NewCreds(path, "chosen", "byhand")
+ if u, p := creds.Get(); u != "chosen" || p != "byhand" {
+ t.Fatalf("configured credential was replaced: %q/%q", u, p)
+ }
+ if creds.Refresh() {
+ t.Fatal("Refresh overrode a configured credential")
+ }
+}
+
+// A credential the engine regenerates under a running agent is picked up too -
+// the same mechanism, and the reason paths.APICredentials says the agent reads
+// the file "rather than storing a second copy".
+func TestARegeneratedCredentialIsPickedUp(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "api_credentials.txt")
+ os.WriteFile(path, []byte("username=behavision\npassword=old\n"), 0o600)
+ creds := NewCreds(path, "", "")
+ creds.Get()
+ os.WriteFile(path, []byte("username=behavision\npassword=new\n"), 0o600)
+ if !creds.Refresh() {
+ t.Fatal("a regenerated password was not picked up")
+ }
+ if _, p := creds.Get(); p != "new" {
+ t.Fatalf("still holding %q", p)
+ }
+}
diff --git a/agent/pkg/engine/supervisor.go b/agent/pkg/engine/supervisor.go
index a7df0c7..d25d63b 100644
--- a/agent/pkg/engine/supervisor.go
+++ b/agent/pkg/engine/supervisor.go
@@ -25,6 +25,8 @@ import (
"strconv"
"strings"
"sync"
+
+ "github.com/loyaly/behavision-agent/pkg/config"
"time"
)
@@ -59,6 +61,9 @@ type Options struct {
// no captured output is undiagnosable, which on a customer site means a
// site visit.
LogWriter io.Writer
+ // Creds re-reads the engine's generated credential when one is rejected,
+ // which is the ordinary case on a first run.
+ Creds *config.Creds
// HealthURL, StatsURL, User, Password address the engine's own API.
HealthURL string
StatsURL string
@@ -409,14 +414,24 @@ func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error {
if err != nil {
return err
}
- if s.opts.User != "" {
- req.SetBasicAuth(s.opts.User, s.opts.Password)
+ user, pass := s.opts.User, s.opts.Password
+ if s.opts.Creds != nil {
+ user, pass = s.opts.Creds.Get()
+ }
+ if user != "" {
+ req.SetBasicAuth(user, pass)
}
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
+ if resp.StatusCode == http.StatusUnauthorized && s.opts.Creds != nil && s.opts.Creds.Refresh() {
+ // See cameras.EngineClient: on a fresh install the engine writes its
+ // credential after the agent has already read for one.
+ resp.Body.Close()
+ return s.getJSON(ctx, url, out)
+ }
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s returned %s", url, resp.Status)
}
diff --git a/demo/console.html b/demo/console.html
new file mode 100644
index 0000000..05ffecf
--- /dev/null
+++ b/demo/console.html
@@ -0,0 +1,251 @@
+
+
+
+
+Behavision — live demo
+
+
+
+
+
+

+
Behavision — live demo
+
+
engine…
+
camera…
+
cloud…
+
+
+
+
+
+
The camera
+
+
![]()
+
waiting for the camera…
+
+
+
+
+
+
The customer type a name, then walk past again
+
+
Nobody yetWalk in front of the camera.
+
+
+
+
+
+
+
What just happened
+
WaitingEvery step below lights up as it really happens.
+
+
+
+
What the mobile app receives
+
GET /api/visits
+
…
+
+
+
+
What the dashboard receives
+
GET /api/reports/footfall
+
…
+
Both of these are the real production API at mcp.loyaly.ai, called from this
+ machine with a staff login — not a mock, and not the local engine.
+
+
+
+
+
+
+
diff --git a/demo/console.py b/demo/console.py
new file mode 100644
index 0000000..421f901
--- /dev/null
+++ b/demo/console.py
@@ -0,0 +1,308 @@
+"""A one-screen live demo of the whole Behavision chain, for showing someone.
+
+Run it on the shop PC (here, this Mac) while the engine and agent are running.
+It holds every credential itself and the browser holds none, so the page can be
+put on a projector without putting a token on it.
+
+What it shows, and why each part is there:
+
+- the live camera, so the person walking past sees themselves;
+- the CHAIN, measured rather than described: the engine recognised a face at
+ this instant, the same visit appeared in the cloud API this many seconds
+ later. That number is the product's claim, and it is computed here from two
+ independent sources rather than asserted;
+- the customer, editable - type a name, walk past again, watch the name come
+ back through the cloud instead of "Visitor 5";
+- the raw JSON a phone and a dashboard receive, side by side, because a
+ colleague's real question is "is this actually wired up or is it a mock".
+
+ .venv/bin/python demo/console.py # http://127.0.0.1:8099
+"""
+from __future__ import annotations
+
+import base64
+import json
+import os
+import threading
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+STATE = Path(os.environ.get("BEHAVISION_DATA_DIR", ROOT / ".demo"))
+CLOUD = os.environ.get("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")
+ENGINE = "http://127.0.0.1:8010"
+PORT = int(os.environ.get("DEMO_PORT", "8099"))
+
+# Whoever the demo signs in as. Staff on purpose: it is the weakest role that
+# can do everything the shop floor does, so nothing here is only possible
+# because we used an owner.
+EMAIL = os.environ.get("DEMO_EMAIL", "staff.demo@tenext.in")
+PASSWORD = os.environ.get("DEMO_PASSWORD", "admin@123")
+
+
+def engine_auth() -> str:
+ """The engine invents a Basic credential when none is configured, and
+ writes it here. Read it rather than keeping a second copy."""
+ f = STATE / "data" / "api_credentials.txt"
+ if not f.exists():
+ return ""
+ user = pw = ""
+ for line in f.read_text().splitlines():
+ # `key=value`, and `key: value` too - the engine writes one and people
+ # read the other, and which is which is not worth a support call.
+ if "=" in line or ":" in line:
+ k, v = line.split("=", 1) if "=" in line else line.split(":", 1)
+ if k.strip().lower() == "username":
+ user = v.strip()
+ elif k.strip().lower() == "password":
+ pw = v.strip()
+ if not user:
+ return ""
+ return "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
+
+
+def fetch(url: str, *, headers=None, body=None, method="GET", timeout=20):
+ req = urllib.request.Request(url, method=method,
+ data=json.dumps(body).encode() if body is not None else None,
+ headers={k: v for k, v in (headers or {}).items() if v})
+ if body is not None:
+ req.add_header("content-type", "application/json")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ raw = r.read()
+ return r.status, (json.loads(raw) if raw and r.headers.get("content-type", "").startswith("application/json") else raw)
+ except urllib.error.HTTPError as e:
+ raw = e.read()
+ try:
+ return e.code, json.loads(raw or b"{}")
+ except Exception:
+ return e.code, raw[:400]
+ except Exception as e:
+ return 0, {"error": str(e)}
+
+
+class Cloud:
+ """The signed-in session, refreshed when it expires."""
+
+ def __init__(self):
+ self.token = ""
+ self.lock = threading.Lock()
+
+ def sign_in(self) -> bool:
+ st, d = fetch(f"{CLOUD}/api/auth/login", method="POST",
+ body={"email": EMAIL, "password": PASSWORD, "device": "Demo console"})
+ if st == 200 and isinstance(d, dict):
+ self.token = d.get("access_token", "")
+ return True
+ return False
+
+ def call(self, path, method="GET", body=None, retry=True):
+ with self.lock:
+ if not self.token and not self.sign_in():
+ return 0, {"error": "cannot sign in to the platform"}
+ tok = self.token
+ st, d = fetch(f"{CLOUD}{path}", method=method, body=body,
+ headers={"authorization": f"Bearer {tok}"})
+ if st == 401 and retry:
+ with self.lock:
+ self.sign_in()
+ return self.call(path, method, body, retry=False)
+ return st, d
+
+
+cloud = Cloud()
+
+# The chain, as the watcher builds it. One dict per recognition, newest first.
+events: list[dict] = []
+events_lock = threading.Lock()
+
+
+def watch():
+ """Poll the engine's own event log and the cloud feed, and join them.
+
+ They are joined on the identity and the second, not on a shared id,
+ because the engine numbers identities locally and the server numbers them
+ per tenant - the two are deliberately different (see CLAUDE.md). What
+ matters for the demo is the LATENCY between one seeing a person and the
+ other, and that only needs the same person and the same moment.
+ """
+ seen_local: set[str] = set()
+ while True:
+ try:
+ auth = engine_auth()
+ st, d = fetch(f"{ENGINE}/api/events?limit=25", headers={"authorization": auth})
+ if st == 200 and isinstance(d, dict):
+ for e in d.get("events", []):
+ if e.get("type") not in ("person.new", "person.seen"):
+ continue
+ key = f"{e.get('ts')}|{e.get('camera_id')}|{(e.get('data') or {}).get('identity_id')}"
+ if key in seen_local:
+ continue
+ seen_local.add(key)
+ data = e.get("data") or {}
+ with events_lock:
+ events.insert(0, {
+ "key": key,
+ "at": time.time(),
+ "kind": "new" if e["type"] == "person.new" else "seen",
+ "engine": {
+ "label": data.get("label"),
+ "identity_id": data.get("identity_id"),
+ "similarity": data.get("similarity"),
+ "quality": data.get("quality"),
+ "gender": data.get("gender"),
+ "age": data.get("age"),
+ "camera": e.get("camera_id"),
+ "ts": e.get("ts"),
+ },
+ "cloud": None,
+ "latency": None,
+ })
+ del events[40:]
+ except Exception:
+ pass
+
+ # the other half: has the cloud got it yet?
+ try:
+ with events_lock:
+ pending = [e for e in events if e["cloud"] is None][:6]
+ if pending:
+ st, d = cloud.call("/api/visits?limit=12")
+ arrivals = (d or {}).get("arrivals", []) if isinstance(d, dict) else []
+ for e in pending:
+ for a in arrivals:
+ # same camera, and the cloud's visit is not older than
+ # the engine's sighting
+ if a.get("camera_id") != e["engine"]["camera"]:
+ continue
+ if a.get("visit_id") in [x["cloud"].get("visit_id") for x in events if x["cloud"]]:
+ continue
+ with events_lock:
+ e["cloud"] = {
+ "visit_id": a.get("visit_id"),
+ "visitor_id": a.get("visitor_id"),
+ "label": a.get("label"),
+ "ref": a.get("customer_ref") or a.get("ref"),
+ "is_new": a.get("is_new_visitor"),
+ "similarity": a.get("similarity"),
+ "site": a.get("site"),
+ "occurred_at": a.get("occurred_at"),
+ "image": a.get("image"),
+ }
+ e["latency"] = round(time.time() - e["at"], 1)
+ break
+ except Exception:
+ pass
+ time.sleep(1.0)
+
+
+def snapshot() -> dict:
+ """Everything the page draws, in one reply."""
+ auth = engine_auth()
+ _, health = fetch(f"{ENGINE}/api/health", headers={"authorization": auth})
+ _, stats = fetch(f"{ENGINE}/api/stats", headers={"authorization": auth})
+ st_v, visits = cloud.call("/api/visits?limit=3")
+ st_f, foot = cloud.call("/api/reports/footfall?from=%s&to=%s"
+ % (time.strftime("%Y-%m-%d", time.localtime(time.time() - 7 * 86400)),
+ time.strftime("%Y-%m-%d")))
+ with events_lock:
+ chain = json.loads(json.dumps(events[:8]))
+ cams = (stats or {}).get("cameras", []) if isinstance(stats, dict) else []
+ return {
+ "engine": {
+ "up": isinstance(health, dict) and bool(health.get("status")),
+ "model": (health or {}).get("recognition_model") if isinstance(health, dict) else None,
+ "cameras": [{"id": c.get("camera_id"), "connected": c.get("connected"),
+ "frames": c.get("frames"), "faces": c.get("faces")} for c in cams],
+ },
+ "cloud_ok": st_v == 200,
+ "chain": chain,
+ "mobile": {"request": "GET /api/visits?limit=3", "status": st_v, "body": visits},
+ "dashboard": {"request": "GET /api/reports/footfall?from=…&to=…", "status": st_f, "body": foot},
+ }
+
+
+def main():
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+ from urllib.parse import urlparse, parse_qs
+
+ page = (Path(__file__).parent / "console.html").read_bytes()
+
+ class H(BaseHTTPRequestHandler):
+ def log_message(self, *a): # quiet
+ pass
+
+ def _send(self, code, body, ctype="application/json"):
+ self.send_response(code)
+ self.send_header("content-type", ctype)
+ self.send_header("content-length", str(len(body)))
+ self.end_headers()
+ try:
+ self.wfile.write(body)
+ except (BrokenPipeError, ConnectionResetError):
+ pass
+
+ def do_GET(self):
+ u = urlparse(self.path)
+ if u.path == "/":
+ return self._send(200, page, "text/html; charset=utf-8")
+ if u.path == "/api/snapshot":
+ return self._send(200, json.dumps(snapshot()).encode())
+ if u.path == "/api/customer":
+ vid = parse_qs(u.query).get("id", [""])[0]
+ if not vid:
+ return self._send(400, b'{"error":"no id"}')
+ st, d = cloud.call(f"/api/visitors/{vid}/history?limit=8")
+ return self._send(200, json.dumps({"status": st, "history": d}).encode())
+ if u.path == "/camera.mjpeg":
+ cam = parse_qs(u.query).get("id", [""])[0]
+ return self._proxy_stream(f"{ENGINE}/api/cameras/{cam}/stream.mjpeg")
+ self._send(404, b'{"error":"no"}')
+
+ def do_POST(self):
+ u = urlparse(self.path)
+ n = int(self.headers.get("content-length", 0))
+ body = json.loads(self.rfile.read(n) or b"{}")
+ if u.path == "/api/profile":
+ vid = body.pop("id", "")
+ st, d = cloud.call(f"/api/visitors/{vid}/profile", method="PUT", body=body)
+ return self._send(200, json.dumps({"status": st, "body": d}).encode())
+ self._send(404, b'{"error":"no"}')
+
+ def _proxy_stream(self, url):
+ """The engine's MJPEG, relayed so the browser needs no credential.
+
+ The engine's API is Basic-authenticated with a credential it
+ generated locally; putting that in a page would hand the whole
+ biometric API to anyone who opened it.
+ """
+ try:
+ req = urllib.request.Request(url, headers={"authorization": engine_auth()})
+ up = urllib.request.urlopen(req, timeout=20)
+ except Exception:
+ return self._send(502, b'{"error":"camera not available"}')
+ self.send_response(200)
+ self.send_header("content-type", up.headers.get("content-type", "multipart/x-mixed-replace"))
+ self.end_headers()
+ try:
+ while True:
+ chunk = up.read(8192)
+ if not chunk:
+ break
+ self.wfile.write(chunk)
+ self.wfile.flush()
+ except Exception:
+ pass
+ finally:
+ up.close()
+
+ threading.Thread(target=watch, daemon=True).start()
+ print(f"\n Demo console → http://127.0.0.1:{PORT}\n")
+ print(f" engine {ENGINE} · cloud {CLOUD} · signed in as {EMAIL}\n")
+ ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/desktop/app.go b/desktop/app.go
index db94f46..8be79aa 100644
--- a/desktop/app.go
+++ b/desktop/app.go
@@ -66,7 +66,7 @@ func NewApp() *App {
return &App{
cfg: cfg,
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
- local: local.New(base, cfg.APIUser, cfg.APIPassword),
+ local: localWithCreds(base, cfg),
proxy: newStreamProxy(),
}
}
@@ -815,3 +815,12 @@ func envOr(key, def string) string {
}
return def
}
+
+// localWithCreds builds the engine client with a credential resolver, so a
+// first run - where the engine writes its credential after the app has looked
+// for it - recovers by itself instead of 401ing for the life of the process.
+func localWithCreds(base string, cfg agentcfg.Config) *local.Client {
+ c := local.New(base, cfg.APIUser, cfg.APIPassword)
+ c.Creds = agentcfg.NewCreds(agentpaths.APICredentials(), cfg.APIUser, cfg.APIPassword)
+ return c
+}
diff --git a/desktop/internal/local/client.go b/desktop/internal/local/client.go
index b650721..32a7d6d 100644
--- a/desktop/internal/local/client.go
+++ b/desktop/internal/local/client.go
@@ -10,6 +10,7 @@ import (
"context"
"encoding/json"
"fmt"
+ agentconfig "github.com/loyaly/behavision-agent/pkg/config"
"io"
"net/http"
"strings"
@@ -20,7 +21,11 @@ type Client struct {
Base string
User string
Password string
- http *http.Client
+ // Creds re-reads the engine's generated credential when one is rejected.
+ // On a first run the app starts the engine, and the engine writes that
+ // file seconds later - after the app has already looked for it.
+ Creds *agentconfig.Creds
+ http *http.Client
}
func New(base, user, password string) *Client {
@@ -48,8 +53,12 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
- if c.User != "" {
- req.SetBasicAuth(c.User, c.Password)
+ user, pass := c.User, c.Password
+ if c.Creds != nil {
+ user, pass = c.Creds.Get()
+ }
+ if user != "" {
+ req.SetBasicAuth(user, pass)
}
resp, err := c.http.Do(req)
if err != nil {