From 16f0e69cec7a47be3ff81c2a4a1a783869597e18 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Thu, 24 Sep 2026 13:40:28 +0530 Subject: [PATCH] A fresh shop PC could never authenticate to its own engine 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 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- .gitignore | 1 + agent/main.go | 7 +- agent/pkg/cameras/clients.go | 22 +- agent/pkg/config/credentials.go | 58 ++++ agent/pkg/config/credentials_firstrun_test.go | 77 +++++ agent/pkg/engine/supervisor.go | 19 +- demo/console.html | 251 ++++++++++++++ demo/console.py | 308 ++++++++++++++++++ desktop/app.go | 11 +- desktop/internal/local/client.go | 15 +- 10 files changed, 758 insertions(+), 11 deletions(-) create mode 100644 agent/pkg/config/credentials_firstrun_test.go create mode 100644 demo/console.html create mode 100644 demo/console.py 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 {