create-workframe 0.1.14 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/scripts/sync-canonical-to-package.mjs +3 -0
  4. package/workframe-api/activity_feed.py +53 -5
  5. package/workframe-api/api_errors.py +126 -0
  6. package/workframe-api/chat_bind.py +8 -1
  7. package/workframe-api/chat_stream.py +70 -26
  8. package/workframe-api/crew_registry.py +3 -6
  9. package/workframe-api/google_auth.py +20 -12
  10. package/workframe-api/handler_modules/handler_auth.py +66 -21
  11. package/workframe-api/handler_modules/handler_chat.py +25 -0
  12. package/workframe-api/handler_modules/handler_install.py +45 -0
  13. package/workframe-api/handler_modules/handler_provider.py +1 -0
  14. package/workframe-api/handler_modules/handler_workspace.py +30 -16
  15. package/workframe-api/hermes_admin.py +9 -2
  16. package/workframe-api/hermes_profiles.py +25 -5
  17. package/workframe-api/install_api.py +38 -4
  18. package/workframe-api/lane_bindings.py +24 -0
  19. package/workframe-api/llm_error_glossary.py +58 -4
  20. package/workframe-api/mention_invoke.py +89 -27
  21. package/workframe-api/model_surface.py +1 -1
  22. package/workframe-api/provider_bindings.py +44 -2
  23. package/workframe-api/provider_bootstrap.py +9 -0
  24. package/workframe-api/provider_catalog.py +3 -3
  25. package/workframe-api/rooms.py +3 -0
  26. package/workframe-api/route_registry.py +1 -0
  27. package/workframe-api/run_ledger.py +52 -8
  28. package/workframe-api/runtime_cohort.py +11 -1
  29. package/workframe-api/server.py +25 -1
  30. package/workframe-api/stack_config.py +4 -1
  31. package/workframe-api/test_api_errors.py +33 -0
  32. package/workframe-api/test_enrich_room_messages.py +72 -0
  33. package/workframe-api/test_exception_hygiene.py +4 -2
  34. package/workframe-api/test_hermes_admin.py +7 -0
  35. package/workframe-api/test_install_wizard_resume.py +39 -13
  36. package/workframe-api/test_model_surface_consistency.py +118 -100
  37. package/workframe-api/test_profile_model_yaml.py +4 -1
  38. package/workframe-api/test_provider_bindings.py +10 -0
  39. package/workframe-api/test_run_ledger.py +2 -1
  40. package/workframe-api/test_server_reexports.py +4 -0
  41. package/workframe-api/test_user_llm_prefs.py +99 -0
  42. package/workframe-api/time-overlay-split.py +58 -0
  43. package/workframe-api/time-ttft-quick.py +135 -0
  44. package/workframe-api/time-ttft.py +250 -0
  45. package/workframe-api/time-wf-stream.py +145 -0
  46. package/workframe-api/turn_overlay.py +96 -23
  47. package/workframe-api/updates.py +1 -2
  48. package/workframe-api/user_prefs.py +26 -5
  49. package/workframe-api/workspace_bootstrap.py +16 -10
  50. package/workframe-ui/public/assets/{arc-BT9iZUSd.js → arc-BARqpbNX.js} +1 -1
  51. package/workframe-ui/public/assets/architecture-7EHR7CIX-CYE89P6J.js +1 -0
  52. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-CaEDd3np.js → architectureDiagram-3BPJPVTR-bbpW6hfw.js} +1 -1
  53. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-DHJhMrIS.js → blockDiagram-GPEHLZMM-aG0oR9eA.js} +1 -1
  54. package/workframe-ui/public/assets/brave-B885JYnH.svg +11 -0
  55. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Cdyz9hIV.js → c4Diagram-AAUBKEIU-BDi4E5jK.js} +1 -1
  56. package/workframe-ui/public/assets/channel-B4ex0Z2H.js +1 -0
  57. package/workframe-ui/public/assets/{chunk-2J33WTMH-BbZx76LX.js → chunk-2J33WTMH-BBU5tRNe.js} +1 -1
  58. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DcSxjTIP.js → chunk-3OPIFGDE-SKhe5YD8.js} +1 -1
  59. package/workframe-ui/public/assets/{chunk-4BX2VUAB-B7F-kTY1.js → chunk-4BX2VUAB-D4uKMCPc.js} +1 -1
  60. package/workframe-ui/public/assets/{chunk-55IACEB6-BwwEiRTK.js → chunk-55IACEB6-SnqJA0wG.js} +1 -1
  61. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DAi_NksX.js → chunk-5ZQYHXKU-ZyBWNsT2.js} +1 -1
  62. package/workframe-ui/public/assets/{chunk-727SXJPM-BIaF4XCN.js → chunk-727SXJPM-DbxwKsiK.js} +1 -1
  63. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DjYSk1dG.js → chunk-AQP2D5EJ-Bm2iWcL0.js} +1 -1
  64. package/workframe-ui/public/assets/{chunk-BSJP7CBP-_twnyfgV.js → chunk-BSJP7CBP-CGwyQh1d.js} +1 -1
  65. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-CkNmdy5s.js → chunk-CSCIHK7Q-XWSO96VW.js} +1 -1
  66. package/workframe-ui/public/assets/{chunk-FMBD7UC4-CjLqd-gl.js → chunk-FMBD7UC4-D0OTpUqL.js} +1 -1
  67. package/workframe-ui/public/assets/{chunk-KSCS5N6A-_6oNqFjj.js → chunk-KSCS5N6A-CM75wJ6j.js} +1 -1
  68. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CeO9Am_D.js → chunk-L5ZTLDWV-BCgtwaGP.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-LZXEDZCA-sRM4YeIe.js → chunk-LZXEDZCA-CZBc86Td.js} +2 -2
  70. package/workframe-ui/public/assets/{chunk-ND2GUHAM-CH8tMb21.js → chunk-ND2GUHAM-DIq9-Qtg.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-NZK2D7GU-DAI1bsII.js → chunk-NZK2D7GU-BTOOFYOR.js} +1 -1
  72. package/workframe-ui/public/assets/{chunk-O5CBEL6O-B2e-5a2G.js → chunk-O5CBEL6O-CYriwTot.js} +1 -1
  73. package/workframe-ui/public/assets/chunk-QZHKN3VN-sPK7umZx.js +1 -0
  74. package/workframe-ui/public/assets/chunk-WU5MYG2G-DtLDSmiK.js +1 -0
  75. package/workframe-ui/public/assets/{chunk-XPW4576I-DM2-0q37.js → chunk-XPW4576I-6iEhdiBi.js} +1 -1
  76. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DWLy8pDS.js +1 -0
  77. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DWLy8pDS.js +1 -0
  78. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-BnbbewLv.js → cose-bilkent-S5V4N54A-C716vjLB.js} +1 -1
  79. package/workframe-ui/public/assets/{dagre-BM42HDAG-C4zmB1Vt.js → dagre-BM42HDAG-Cs-MNVkM.js} +1 -1
  80. package/workframe-ui/public/assets/{diagram-2AECGRRQ-n-BJmsiC.js → diagram-2AECGRRQ-BqoyTWyW.js} +1 -1
  81. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzQGQnZi.js → diagram-5GNKFQAL-CDRTV5fY.js} +1 -1
  82. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Cx_De-wS.js → diagram-KO2AKTUF-u9NjixEi.js} +1 -1
  83. package/workframe-ui/public/assets/{diagram-LMA3HP47-CIOI3Lok.js → diagram-LMA3HP47-rDd6T2os.js} +1 -1
  84. package/workframe-ui/public/assets/{diagram-OG6HWLK6-DkRuPLWY.js → diagram-OG6HWLK6-Bl4VX9RO.js} +1 -1
  85. package/workframe-ui/public/assets/{dist-XhCT1Q-V.js → dist-DSCcbFgV.js} +1 -1
  86. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-C3GwhDU1.js → erDiagram-TEJ5UH35-C9Nv-5nl.js} +1 -1
  87. package/workframe-ui/public/assets/eventmodeling-FCH6USID-B7kChV1b.js +1 -0
  88. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CLWH8z2e.js → flowDiagram-I6XJVG4X-D8Xhbd7s.js} +1 -1
  89. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-D4h66Vy3.js → ganttDiagram-6RSMTGT7-CMycBZqm.js} +1 -1
  90. package/workframe-ui/public/assets/gemini-Cq-0-rmr.svg +105 -0
  91. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-IW5F-Q6c.js → gitGraph-WXDBUCRP-DnIro8tZ.js} +1 -1
  92. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BjNIbmRM.js → gitGraphDiagram-PVQCEYII-B74H9ISZ.js} +1 -1
  93. package/workframe-ui/public/assets/index-BLVAsAeO.css +1 -0
  94. package/workframe-ui/public/assets/index-s4167RRh.js +132 -0
  95. package/workframe-ui/public/assets/{info-J43DQDTF-CcJspM79.js → info-J43DQDTF-B6mVw1B6.js} +1 -1
  96. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-DP4xR9jt.js → infoDiagram-5YYISTIA-Ddtfq_xy.js} +1 -1
  97. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-D4cZb21V.js → ishikawaDiagram-YF4QCWOH-DBb6veAL.js} +1 -1
  98. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DsLuHkIJ.js → journeyDiagram-JHISSGLW-DMgdeS5h.js} +1 -1
  99. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Wu65nOsR.js → kanban-definition-UN3LZRKU-DiV_LoDN.js} +1 -1
  100. package/workframe-ui/public/assets/{line-ByBDzRpy.js → line-BR9uEes_.js} +1 -1
  101. package/workframe-ui/public/assets/{linear-DzXi2vnq.js → linear-BhjkJfy9.js} +1 -1
  102. package/workframe-ui/public/assets/{mermaid-parser.core-CaB-1Ckn.js → mermaid-parser.core-CEWtaJIq.js} +2 -2
  103. package/workframe-ui/public/assets/{mermaid.core-D7HTHC17.js → mermaid.core-B6xKLKp-.js} +3 -3
  104. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BfKsC9aj.js → mindmap-definition-RKZ34NQL-DypZm9OU.js} +1 -1
  105. package/workframe-ui/public/assets/{packet-YPE3B663-ClpU98TO.js → packet-YPE3B663-CCxb0WDS.js} +1 -1
  106. package/workframe-ui/public/assets/{pie-LRSECV5Y-KeOyY_-u.js → pie-LRSECV5Y-pTkr4g8B.js} +1 -1
  107. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CUpjnzbs.js → pieDiagram-4H26LBE5-BventgTb.js} +1 -1
  108. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CJdPTszF.js → quadrantDiagram-W4KKPZXB-BQnvFJYk.js} +1 -1
  109. package/workframe-ui/public/assets/{radar-GUYGQ44K-C9BpandZ.js → radar-GUYGQ44K-EX9Pzmli.js} +1 -1
  110. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-CHDZPwry.js → requirementDiagram-4Y6WPE33-Bnw4xecM.js} +1 -1
  111. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CMJJh91s.js → sankeyDiagram-5OEKKPKP-spHh-EYp.js} +1 -1
  112. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BsV4GppP.js → sequenceDiagram-3UESZ5HK-CC97Qcbf.js} +1 -1
  113. package/workframe-ui/public/assets/{src-WHks82Up.js → src-DRcBNyeZ.js} +1 -1
  114. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-eSX9P8z0.js → stateDiagram-AJRCARHV-C8r8rx81.js} +1 -1
  115. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CnedwxVT.js +1 -0
  116. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-Dg1UkhGo.js → timeline-definition-PNZ67QCA-CA4JxR_w.js} +1 -1
  117. package/workframe-ui/public/assets/{treeView-BLDUP644-B7yGsBuj.js → treeView-BLDUP644-BuGSo6xP.js} +1 -1
  118. package/workframe-ui/public/assets/{treemap-LRROVOQU-j_G2oyQ_.js → treemap-LRROVOQU-JyJMArE9.js} +1 -1
  119. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BRrT0jqC.js → vennDiagram-CIIHVFJN-BZAUo7xL.js} +1 -1
  120. package/workframe-ui/public/assets/{wardley-L42UT6IY-DEdUqez2.js → wardley-L42UT6IY-CMgm8t9M.js} +1 -1
  121. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-C-bBK9Bb.js → wardleyDiagram-YWT4CUSO-CqI1c2zN.js} +1 -1
  122. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DhH5xuR2.js → xychartDiagram-2RQKCTM6-DhTdH-jJ.js} +1 -1
  123. package/workframe-ui/public/index.html +2 -2
  124. package/workframe-ui/public/workframe-build.json +3 -3
  125. package/docs/security.md +0 -85
  126. package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +0 -1
  127. package/workframe-ui/public/assets/channel-3M69ekE5.js +0 -1
  128. package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +0 -1
  129. package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +0 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +0 -1
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +0 -1
  132. package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +0 -1
  133. package/workframe-ui/public/assets/index-DL2vJP4L.css +0 -1
  134. package/workframe-ui/public/assets/index-ElS_D59P.js +0 -130
  135. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +0 -1
@@ -16,6 +16,7 @@ from typing import Any
16
16
  import auth_rate_limit
17
17
  import google_auth
18
18
  import stack_config
19
+ import zk_auth as _zk
19
20
  from email_sender import APP_BASE_URL
20
21
 
21
22
 
@@ -188,6 +189,13 @@ class AuthRoutesMixin:
188
189
  self._log_audit("login_denied_private", "user", email, str(deny_meta.get("error") or ""))
189
190
  self._json(403, {"ok": False, **deny_meta})
190
191
  return
192
+ import install_api
193
+
194
+ install_allowed, install_deny = install_api.install_auth_email_allowed(email)
195
+ if not install_allowed:
196
+ self._log_audit("login_denied_install", "user", email, str(install_deny.get("error") or ""))
197
+ self._json(403, {"ok": False, **install_deny})
198
+ return
191
199
  try:
192
200
  base = APP_BASE_URL.strip().lower()
193
201
  loopback = base.startswith("http://127.0.0.1") or base.startswith("http://localhost")
@@ -230,6 +238,13 @@ class AuthRoutesMixin:
230
238
  self._log_audit("login_denied_private", "user", email, str(deny_meta.get("error") or ""))
231
239
  self._json(403, {"ok": False, **deny_meta})
232
240
  return
241
+ import install_api
242
+
243
+ install_allowed, install_deny = install_api.install_auth_email_allowed(email)
244
+ if not install_allowed:
245
+ self._log_audit("login_denied_install", "user", email, str(install_deny.get("error") or ""))
246
+ self._json(403, {"ok": False, **install_deny})
247
+ return
233
248
  try:
234
249
  result = _zk.verify_email_code(email, code)
235
250
  except ValueError as exc:
@@ -246,6 +261,28 @@ class AuthRoutesMixin:
246
261
  self._ensure_user(result["user_id"], email, email)
247
262
  if srv._install_window_open():
248
263
  stack_config.mark_install_admin_verified(email)
264
+ try:
265
+ conn = srv._workframe_db()
266
+ ws = conn.execute(
267
+ """
268
+ SELECT id FROM workspaces
269
+ WHERE slug = 'default' AND deleted_at IS NULL
270
+ LIMIT 1
271
+ """,
272
+ ).fetchone()
273
+ if ws:
274
+ ws_id = str(ws["id"])
275
+ srv._promote_workspace_owner_if_unclaimed(conn, ws_id, result["user_id"])
276
+ conn.execute(
277
+ "UPDATE users SET role = 'owner', updated_at = ? WHERE id = ?",
278
+ (str(int(time.time())), result["user_id"]),
279
+ )
280
+ conn.commit()
281
+ conn.close()
282
+ if not ws:
283
+ self._first_owner_bootstrap(result["user_id"], email, email)
284
+ except Exception:
285
+ pass
249
286
  self._log_audit("login", "session", result["session_id"],
250
287
  f"user={result['user_id']} new={result['is_new_user']}")
251
288
  me_payload = srv._session_profile_payload(result["user_id"])
@@ -384,53 +421,58 @@ class AuthRoutesMixin:
384
421
  srv._log_handler_error("GET /api/auth/google/callback", exc)
385
422
  self._json(401, {"ok": False, "error": str(exc)})
386
423
 
424
+ def _redirect_provider_connect(self, provider: str, *, ok: bool, message: str = "") -> None:
425
+ status = "ok" if ok else "error"
426
+ detail = message or ("connected" if ok else "failed")
427
+ location = (
428
+ f"{APP_BASE_URL.rstrip('/')}/?provider_connect={urllib.parse.quote(provider)}"
429
+ f"&status={status}&message={urllib.parse.quote(detail)}"
430
+ )
431
+ self.send_response(302)
432
+ self.send_header("Location", location)
433
+ self.end_headers()
434
+
387
435
  def _route_get_oauth_github_callback(self, qs: dict[str, list[str]]) -> None:
388
436
  srv = _srv()
389
437
  user_id = str(getattr(self, "auth_user", "") or "")
390
438
  if not user_id:
391
- self._json(401, {"ok": False, "error": "no_session"})
439
+ self._redirect_provider_connect("github", ok=False, message="no_session")
392
440
  return
393
441
  code = qs.get("code", [""])[0]
394
442
  state = qs.get("state", [""])[0]
395
443
  result = srv._complete_github_oauth(user_id, str(code or ""), str(state or ""))
396
- status = "ok" if result.get("ok") else "error"
397
- message = urllib.parse.quote(str(result.get("error") or "connected"))
398
- location = f"{APP_BASE_URL.rstrip('/')}/?provider_connect=github&status={status}&message={message}"
399
- self.send_response(302)
400
- self.send_header("Location", location)
401
- self.end_headers()
444
+ if result.get("ok"):
445
+ self._redirect_provider_connect("github", ok=True)
446
+ return
447
+ self._redirect_provider_connect("github", ok=False, message=str(result.get("error") or "failed"))
402
448
 
403
449
  def _route_get_oauth_discord_callback(self, qs: dict[str, list[str]]) -> None:
404
450
  srv = _srv()
405
451
  user_id = str(getattr(self, "auth_user", "") or "")
406
452
  if not user_id:
407
- self._json(401, {"ok": False, "error": "no_session"})
453
+ self._redirect_provider_connect("discord", ok=False, message="no_session")
408
454
  return
409
455
  code = qs.get("code", [""])[0]
410
456
  state = qs.get("state", [""])[0]
411
457
  result = srv._complete_discord_oauth(user_id, str(code or ""), str(state or ""))
412
- status = "ok" if result.get("ok") else "error"
413
- message = urllib.parse.quote(str(result.get("error") or "connected"))
414
- location = f"{APP_BASE_URL.rstrip('/')}/?provider_connect=discord&status={status}&message={message}"
415
- self.send_response(302)
416
- self.send_header("Location", location)
417
- self.end_headers()
458
+ if result.get("ok"):
459
+ self._redirect_provider_connect("discord", ok=True)
460
+ return
461
+ self._redirect_provider_connect("discord", ok=False, message=str(result.get("error") or "failed"))
418
462
 
419
463
  def _route_get_oauth_stripe_callback(self, qs: dict[str, list[str]]) -> None:
420
464
  srv = _srv()
421
465
  user_id = str(getattr(self, "auth_user", "") or "")
422
466
  if not user_id:
423
- self._json(401, {"ok": False, "error": "no_session"})
467
+ self._redirect_provider_connect("stripe", ok=False, message="no_session")
424
468
  return
425
469
  code = qs.get("code", [""])[0]
426
470
  state = qs.get("state", [""])[0]
427
471
  result = srv._complete_stripe_oauth(user_id, str(code or ""), str(state or ""))
428
- status = "ok" if result.get("ok") else "error"
429
- message = urllib.parse.quote(str(result.get("error") or "connected"))
430
- location = f"{APP_BASE_URL.rstrip('/')}/?provider_connect=stripe&status={status}&message={message}"
431
- self.send_response(302)
432
- self.send_header("Location", location)
433
- self.end_headers()
472
+ if result.get("ok"):
473
+ self._redirect_provider_connect("stripe", ok=True)
474
+ return
475
+ self._redirect_provider_connect("stripe", ok=False, message=str(result.get("error") or "failed"))
434
476
 
435
477
  def _route_get_me(self, qs: dict[str, list[str]]) -> None:
436
478
  srv = _srv()
@@ -475,6 +517,9 @@ class AuthRoutesMixin:
475
517
  if not user_id:
476
518
  self._json(401, {"ok": False, "error": "no_session"})
477
519
  return
520
+ if not srv._role_allows(self, srv.OWNER_ADMIN_ROLES):
521
+ self._json(403, {"ok": False, "error": "native_agent_identity_owner_only"})
522
+ return
478
523
  native_slug = str(srv.NATIVE_PROFILE or "workframe-agent")
479
524
  runtime = srv._runtime_profile_slug(user_id, native_slug)
480
525
  soul = (
@@ -223,6 +223,17 @@ class ChatRoutesMixin:
223
223
  self._json(400, {"error": "model required"})
224
224
  return
225
225
  billing_provider = str(body.get("billing_provider", "")).strip()
226
+ # A member may tune the model used by their proxy, but cannot mutate
227
+ # the shared agent template. The template remains the default model
228
+ # owned by the agent; this maps ordinary member writes to u-* runtime.
229
+ if profile and not selection_only and not srv._is_runtime_profile_slug(profile):
230
+ if not srv._role_allows(self, srv.OWNER_ADMIN_ROLES):
231
+ if not workspace_id:
232
+ workspaces = srv._get_user_workspaces(user_id)
233
+ current = srv._resolve_current_workspace(user_id, workspaces)
234
+ workspace_id = str((current or {}).get("id") or (workspaces[0] or {}).get("id") or "")
235
+ profile = srv._runtime_profile_slug(user_id, profile)
236
+ srv.ensure_runtime_profile(profile, srv._runtime_template_slug(profile), user_id, workspace_id)
226
237
  self._json(
227
238
  200,
228
239
  hermes_model_set(
@@ -256,6 +267,14 @@ class ChatRoutesMixin:
256
267
  chain = body.get("chain", [])
257
268
  selection_only = bool(body.get("selection_only"))
258
269
  user_id = str(getattr(self, "auth_user", "") or "")
270
+ if profile and not selection_only and not srv._is_runtime_profile_slug(profile):
271
+ if not srv._role_allows(self, srv.OWNER_ADMIN_ROLES):
272
+ if not workspace_id:
273
+ workspaces = srv._get_user_workspaces(user_id)
274
+ current = srv._resolve_current_workspace(user_id, workspaces)
275
+ workspace_id = str((current or {}).get("id") or (workspaces[0] or {}).get("id") or "")
276
+ profile = srv._runtime_profile_slug(user_id, profile)
277
+ srv.ensure_runtime_profile(profile, srv._runtime_template_slug(profile), user_id, workspace_id)
259
278
  if not isinstance(chain, list):
260
279
  self._json(400, {"error": "chain must be a list"})
261
280
  return
@@ -417,6 +436,9 @@ class ChatRoutesMixin:
417
436
  self._json(400, {"error": "name required"})
418
437
  return
419
438
  model = str(body.get("model") or "").strip()
439
+ if not model:
440
+ self._json(400, {"error": "model_required"})
441
+ return
420
442
  description = str(body.get("description") or "").strip()
421
443
  clone_from = str(body.get("clone_from") or "").strip()
422
444
  soul = str(body.get("soul") or "").strip()
@@ -524,6 +546,9 @@ class ChatRoutesMixin:
524
546
  return
525
547
  template = resolve_validated_profile(profile_bootstrap_post.group(1))
526
548
  user_id = str(getattr(self, "auth_user", "") or "").strip()
549
+ if srv._is_native_profile(template) and not srv._role_allows(self, srv.OWNER_ADMIN_ROLES):
550
+ self._json(403, {"ok": False, "error": "native_agent_identity_owner_only"})
551
+ return
527
552
  workspace_id = str(body.get("workspace_id") or "").strip()
528
553
  if not workspace_id and user_id:
529
554
  workspaces = srv._get_user_workspaces(user_id)
@@ -3,11 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import base64
6
+ import sqlite3
6
7
  from typing import Any
7
8
 
8
9
  import install_api
9
10
  import site_meta
10
11
  import stack_config
12
+ import zk_auth as _zk
11
13
  from supervisor_client import _maybe_sync_compose_public_url, _supervisor_ready, _supervisor_request
12
14
 
13
15
 
@@ -58,6 +60,49 @@ class InstallRoutesMixin:
58
60
  url = (qs.get("url") or [""])[0] or stack_config.get_stack_config().get("app_base_url") or ""
59
61
  self._json(200, install_api.publish_hints_payload(str(url)))
60
62
 
63
+ def _route_post_install_register_admin(self, body: dict) -> None:
64
+ srv = _srv()
65
+ if not srv._install_window_open():
66
+ self._json(403, {"ok": False, "error": "install_closed"})
67
+ return
68
+ if stack_config.install_admin_verified():
69
+ self._json(409, {"ok": False, "error": "already_verified"})
70
+ return
71
+ email = str(body.get("email") or "").strip().lower()
72
+ if not email or "@" not in email:
73
+ self._json(400, {"ok": False, "error": "email_required"})
74
+ return
75
+ display = str(body.get("display_name") or "").strip() or email.split("@", 1)[0]
76
+ stack_config.patch_stack_config({"smtp": {"admin_email": email}, "wizard_step": "welcome"})
77
+ allowed, deny_meta = install_api.install_auth_email_allowed(email)
78
+ if not allowed:
79
+ self._json(403, {"ok": False, **deny_meta})
80
+ return
81
+ try:
82
+ result = _zk.create_session_for_email(email)
83
+ except (RuntimeError, OSError, sqlite3.Error) as exc:
84
+ srv._log_handler_error("POST /api/install/register-admin", exc)
85
+ self._json(500, {"ok": False, "error": str(exc)})
86
+ return
87
+ user_id = str(result["user_id"])
88
+ self.auth_user = user_id
89
+ self._ensure_user(user_id, display, email)
90
+ self._first_owner_bootstrap(user_id, display, email)
91
+ use_secure = srv._session_cookie_secure()
92
+ cookie_val = _zk.session_cookie_value(result["session_id"], secure=use_secure)
93
+ me_payload = srv._session_profile_payload(user_id)
94
+ self._json(
95
+ 200,
96
+ {
97
+ "ok": True,
98
+ "user_id": user_id,
99
+ "session_id": result["session_id"],
100
+ "refresh_token": result["refresh_token"],
101
+ **(me_payload or {}),
102
+ },
103
+ extra_headers=[("Set-Cookie", cookie_val)],
104
+ )
105
+
61
106
  def _route_post_install_email_test(self, body: dict) -> None:
62
107
  srv = _srv()
63
108
  if not srv._install_window_open():
@@ -190,6 +190,7 @@ class ProviderRoutesMixin:
190
190
  except sqlite3.Error as exc:
191
191
  self._json(500, {"ok": False, "error": f"db_error: {exc}"})
192
192
  return
193
+ srv._invalidate_user_llm_picker_cache(user_id)
193
194
  self._log_audit("credential_stored", "credential_binding", cred_id, f"provider={provider}")
194
195
  health: dict[str, Any] = {}
195
196
  if provider == "openrouter":
@@ -13,6 +13,7 @@ import urllib.parse
13
13
  import uuid
14
14
 
15
15
  import activity_feed
16
+ import api_errors
16
17
  import chat_bind
17
18
  from email_sender import APP_BASE_URL, send_branded_invite_email
18
19
 
@@ -402,14 +403,13 @@ class WorkspaceRoutesMixin:
402
403
  return
403
404
  try:
404
405
  self._json(200, srv.room_chat_bind(rid, body, user_id))
405
- except ValueError as exc:
406
- err = str(exc)
407
- if "not_found" in err:
408
- self._json(404, {"ok": False, "error": err})
409
- elif "denied" in err or "forbidden" in err:
410
- self._json(403, {"ok": False, "error": err})
411
- else:
412
- self._json(400, {"ok": False, "error": err})
406
+ except (ValueError, OSError) as exc:
407
+ payload = api_errors.public_api_error_payload(exc, context="room_bind")
408
+ status = api_errors.http_status_for_code(str(payload.get("error") or ""))
409
+ self._json(status, {"ok": False, **payload})
410
+ except Exception as exc: # noqa: BLE001 urllib.URLError etc.
411
+ payload = api_errors.public_api_error_payload(exc, context="room_bind")
412
+ self._json(500, {"ok": False, **payload})
413
413
 
414
414
  def _route_pattern_post_room_sessions_activate(self, path: str, body: dict) -> None:
415
415
  srv = _srv()
@@ -437,13 +437,9 @@ class WorkspaceRoutesMixin:
437
437
  binding_version=binding_version,
438
438
  )
439
439
  except ValueError as exc:
440
- err = str(exc)
441
- if "not_found" in err:
442
- self._json(404, {"ok": False, "error": err})
443
- elif "denied" in err:
444
- self._json(403, {"ok": False, "error": err})
445
- else:
446
- self._json(400, {"ok": False, "error": err})
440
+ payload = api_errors.public_api_error_payload(exc, context="room_session_activate")
441
+ status = api_errors.http_status_for_code(str(payload.get("error") or ""))
442
+ self._json(status, {"ok": False, **payload})
447
443
  return
448
444
  self._json(200, payload)
449
445
 
@@ -1024,6 +1020,13 @@ class WorkspaceRoutesMixin:
1024
1020
  conn.close()
1025
1021
  self._json(404, {"ok": False, "error": "invite_not_found_or_expired"})
1026
1022
  return
1023
+ invite_email = str(inv["email"] or "").strip().lower()
1024
+ user_row = conn.execute("SELECT email FROM users WHERE id = ?", (user_id,)).fetchone()
1025
+ user_email = str(user_row["email"] or "").strip().lower() if user_row else ""
1026
+ if not user_email or user_email != invite_email:
1027
+ conn.close()
1028
+ self._json(403, {"ok": False, "error": "invite_email_mismatch"})
1029
+ return
1027
1030
  if int(inv["expires_at"]) < int(time.time()):
1028
1031
  conn.close()
1029
1032
  self._json(410, {"ok": False, "error": "invite_expired"})
@@ -1048,6 +1051,15 @@ class WorkspaceRoutesMixin:
1048
1051
  (user_id, now_ts, now_ts, inv["id"]))
1049
1052
  conn.commit()
1050
1053
  conn.close()
1054
+ # Re-acceptance is also a repair path. Do not leave an existing
1055
+ # member without the per-user Hermes proxy runtimes.
1056
+ try:
1057
+ if not srv._provision_invited_member_agent_runtimes(wid, user_id):
1058
+ self._json(500, {"ok": False, "error": "runtime_provision_failed"})
1059
+ return
1060
+ except Exception as exc: # noqa: BLE001
1061
+ self._json(500, {"ok": False, "error": f"runtime_provision_failed: {exc}"})
1062
+ return
1051
1063
  srv._set_user_current_workspace(user_id, wid)
1052
1064
  self._json(200, {"ok": True, "already_member": True, "workspace_id": wid, "room": room_join, "dm_room": dm_room})
1053
1065
  return
@@ -1069,7 +1081,9 @@ class WorkspaceRoutesMixin:
1069
1081
  except sqlite3.Error as exc:
1070
1082
  self._json(500, {"error": f"db_error: {exc}"})
1071
1083
  return
1072
- srv._provision_invited_member_agent_runtimes(wid, user_id)
1084
+ if not srv._provision_invited_member_agent_runtimes(wid, user_id):
1085
+ self._json(500, {"ok": False, "error": "runtime_provision_failed"})
1086
+ return
1073
1087
  srv._set_user_current_workspace(user_id, wid)
1074
1088
  self._log_audit("invite_accepted", "workspace_invite", inv["id"], f"workspace={wid} user={user_id}")
1075
1089
  self._json(
@@ -232,7 +232,7 @@ def _resolve_command(token: str) -> dict[str, Any] | None:
232
232
  return None
233
233
  needle = token if token.startswith("/") else f"/{token}"
234
234
  for entry in HERMES_COMMANDS:
235
- if entry.get("wip", False):
235
+ if entry.get("wip", False) or entry.get("dispatch") in {"gateway", "client:noOp"}:
236
236
  continue
237
237
  if entry["name"] == needle or needle in entry.get("aliases", []):
238
238
  return entry
@@ -653,7 +653,14 @@ def hermes_commands_catalog() -> dict[str, Any]:
653
653
  they aren't returned here. The UI never sees a half-implemented
654
654
  command; either it's wired end-to-end or it's invisible.
655
655
  """
656
- visible = [e for e in HERMES_COMMANDS if not e.get("wip", False)]
656
+ # One-shot gateway execution is not equivalent to Hermes' interactive TUI
657
+ # command loop. Only expose commands with a native Workframe handler; add a
658
+ # gateway command after an end-to-end test proves its behavior here.
659
+ visible = [
660
+ entry for entry in HERMES_COMMANDS
661
+ if not entry.get("wip", False)
662
+ and entry.get("dispatch") not in {"gateway", "client:noOp"}
663
+ ]
657
664
  grouped: dict[str, list[dict[str, Any]]] = {}
658
665
  for entry in visible:
659
666
  grouped.setdefault(entry["category"], []).append(
@@ -53,19 +53,31 @@ def _hermes_data_uid_gid() -> tuple[int, int]:
53
53
  try:
54
54
  st = _srv().HERMES_DATA.stat()
55
55
  return int(st.st_uid), int(st.st_gid)
56
- except OSError:
56
+ except (AttributeError, OSError):
57
57
  return 10000, 10000
58
58
 
59
59
 
60
+ def _ensure_profiles_dir_ready() -> None:
61
+ """Gateway creates runtime profiles — parent dir must be writable by hermes uid."""
62
+ profiles = _srv().HERMES_DATA / "profiles"
63
+ profiles.mkdir(parents=True, exist_ok=True)
64
+ uid, gid = _hermes_data_uid_gid()
65
+ try:
66
+ os.chown(profiles, uid, gid)
67
+ except (AttributeError, OSError):
68
+ pass
69
+
70
+
60
71
  def _chown_profile_tree(prof_dir: Path) -> None:
61
72
  """Gateway runs as the Agents volume owner — seeded files must match."""
73
+ _ensure_profiles_dir_ready()
62
74
  uid, gid = _hermes_data_uid_gid()
63
75
  prof_dir.mkdir(parents=True, exist_ok=True)
64
76
  (prof_dir / "logs").mkdir(parents=True, exist_ok=True)
65
77
  for path in [prof_dir, *prof_dir.rglob("*")]:
66
78
  try:
67
79
  os.chown(path, uid, gid)
68
- except OSError:
80
+ except (AttributeError, OSError):
69
81
  pass
70
82
 
71
83
 
@@ -461,9 +473,13 @@ def _profile_identity_overlay(profile: str) -> str:
461
473
  prof = _srv().safe_profile_slug(profile)
462
474
  if _srv()._is_runtime_profile_slug(prof):
463
475
  template = _runtime_template_slug(prof)
464
- reg = {**_srv()._agent_registry_row(template), **_srv()._agent_registry_row(prof)}
476
+ # Keep runtime-local instructions separate from shared display
477
+ # identity. Name/role/tagline/avatar must come from the template.
478
+ reg = _srv()._agent_registry_row(template)
479
+ runtime_reg = _srv()._agent_registry_row(prof)
465
480
  else:
466
481
  reg = _srv()._agent_registry_row(prof)
482
+ runtime_reg = {}
467
483
  blocks: list[str] = []
468
484
  identity_lines: list[str] = []
469
485
  display = str(reg.get("display_name") or "").strip()
@@ -477,7 +493,7 @@ def _profile_identity_overlay(profile: str) -> str:
477
493
  identity_lines.append(f"- **Tagline:** {tagline}")
478
494
  if identity_lines:
479
495
  blocks.append("## User identity\n" + "\n".join(identity_lines))
480
- user_soul = str(reg.get("user_soul") or "").strip()
496
+ user_soul = str(runtime_reg.get("user_soul") or reg.get("user_soul") or "").strip()
481
497
  if user_soul:
482
498
  blocks.append("## Additional instructions\n" + user_soul)
483
499
  return "\n\n".join(blocks).strip()
@@ -735,6 +751,9 @@ def _agent_db_display_name(template_slug: str, workspace_id: str = "") -> str:
735
751
  ).fetchone()
736
752
  if row and str(row["display_name"] or "").strip():
737
753
  return str(row["display_name"]).strip()
754
+ except sqlite3.Error:
755
+ # Native profile seeding can precede schema creation on a clean install.
756
+ return ""
738
757
  finally:
739
758
  conn.close()
740
759
  return ""
@@ -1105,6 +1124,7 @@ def profile_create(
1105
1124
  results["steps"].extend(lane.get("steps") or [])
1106
1125
  except Exception as exc:
1107
1126
  results["steps"].append({"step": "bootstrap_dm_lane", "ok": False, "error": str(exc)})
1127
+ results["ok"] = False
1108
1128
  return results
1109
1129
 
1110
1130
  prof = _srv().safe_profile_slug(name)
@@ -1242,7 +1262,7 @@ def profile_create(
1242
1262
  except Exception as exc:
1243
1263
  results["steps"].append({"step": "bootstrap_dm_lane", "ok": False, "error": str(exc)})
1244
1264
 
1245
- results["ok"] = True
1265
+ results["ok"] = not any(step.get("ok") is False for step in results["steps"])
1246
1266
  return results
1247
1267
 
1248
1268
 
@@ -66,6 +66,31 @@ def install_window_open(db_path: str) -> bool:
66
66
  return not bool(stack_config.get_stack_config().get("install_complete"))
67
67
 
68
68
 
69
+ def install_auth_email_allowed(email: str) -> tuple[bool, dict[str, Any]]:
70
+ """During install, only the configured admin email may register until verified."""
71
+ normalized = str(email or "").strip().lower()
72
+ if not normalized or "@" not in normalized:
73
+ return False, {"error": "email required"}
74
+ if not install_window_open(""):
75
+ return True, {}
76
+ smtp = stack_config.get_stack_config().get("smtp") or {}
77
+ configured = str(smtp.get("admin_email") or "").strip().lower()
78
+ verified_admin = str(smtp.get("admin_email") or "").strip().lower() if stack_config.install_admin_verified() else ""
79
+ if verified_admin:
80
+ if normalized == verified_admin:
81
+ return True, {}
82
+ return False, {
83
+ "error": "install_admin_email_required",
84
+ "message": "Sign in with the admin email for this install.",
85
+ }
86
+ if configured and normalized != configured:
87
+ return False, {
88
+ "error": "install_admin_email_required",
89
+ "message": "Use the admin email entered at the start of setup.",
90
+ }
91
+ return True, {}
92
+
93
+
69
94
  def install_owner_claimed(db_path: str) -> bool:
70
95
  """True once the first install admin has verified and owns the default workspace."""
71
96
  try:
@@ -119,12 +144,19 @@ def _wizard_step_index(step: str) -> int:
119
144
 
120
145
  def _derive_install_wizard_step(db_path: str) -> str:
121
146
  cfg = stack_config.get_stack_config()
147
+ smtp = cfg.get("smtp") if isinstance(cfg.get("smtp"), dict) else {}
148
+ if not str(smtp.get("admin_email") or "").strip():
149
+ return "intro"
150
+ if not install_owner_claimed(db_path):
151
+ return "intro"
122
152
  mode = str(cfg.get("deployment_mode") or "").strip()
123
153
  if not mode:
124
154
  return "welcome"
125
155
  if mode == "public_multi_user" and not str(cfg.get("app_base_url") or "").strip():
126
156
  return "publish"
127
- if mode != "single_user_local" and not stack_config.install_admin_verified() and not install_owner_claimed(db_path):
157
+ if mode != "single_user_local" and not stack_config.install_admin_verified():
158
+ if stack_config.smtp_setup_complete():
159
+ return "admin_auth"
128
160
  return "smtp"
129
161
 
130
162
  settings = _default_workspace_settings(db_path)
@@ -142,16 +174,18 @@ def resolve_install_wizard_step(db_path: str) -> str:
142
174
  raw = stack_config.read_stack_raw()
143
175
  saved = str(raw.get("wizard_step") or "").strip()
144
176
  if saved in INSTALL_WIZARD_STEPS:
145
- if _wizard_step_index(derived) > _wizard_step_index(saved):
177
+ derived_idx = _wizard_step_index(derived)
178
+ saved_idx = _wizard_step_index(saved)
179
+ # Never resume ahead of incomplete gates (e.g. saved smtp before deployment).
180
+ if derived_idx >= 0 and saved_idx >= 0 and saved_idx != derived_idx:
146
181
  return derived
147
182
  return saved
148
183
  return derived
149
184
 
150
185
 
151
186
  def install_wizard_public_payload(db_path: str) -> dict[str, Any]:
152
- admin_verified = stack_config.install_admin_verified() or install_owner_claimed(db_path)
153
187
  return {
154
- "admin_verified": admin_verified,
188
+ "admin_verified": stack_config.install_admin_verified(),
155
189
  "resume_step": resolve_install_wizard_step(db_path),
156
190
  "owner_claimed": install_owner_claimed(db_path),
157
191
  }
@@ -76,6 +76,21 @@ def _binding_session_for(profile: str, source_id: str, client_id: str, binding_v
76
76
  row = _binding_row_for(profile, source_id, client_id, binding_version)
77
77
  return str((row or {}).get("session_id") or "").strip()
78
78
 
79
+
80
+ def _ensure_profile_api_for_session_create(
81
+ profile: str,
82
+ user_id: str,
83
+ workspace_id: str,
84
+ ) -> dict[str, Any]:
85
+ """WF-034: bind skips provider bootstrap; POST /api/sessions still needs a live gateway."""
86
+ return _srv().ensure_profile_api(
87
+ profile,
88
+ user_id,
89
+ workspace_id,
90
+ bootstrap_providers=False,
91
+ )
92
+
93
+
79
94
  def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "") -> dict[str, Any]:
80
95
  source_id = str(payload.get("source_id") or "ui").strip() or "ui"
81
96
  client_id = str(payload.get("client_id") or "default").strip() or "default"
@@ -167,6 +182,13 @@ def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "
167
182
  sid = str(existing_row["session_id"]).strip()
168
183
  if not sid:
169
184
  existing_row = None
185
+ if existing_row and _srv()._is_space_room(str(room["room_type"]), room["agent_profile_id"]):
186
+ # Legacy room mentions could have created one session per
187
+ # user's private runtime. Shared rooms must converge on the
188
+ # template/native Hermes profile before reusing a row.
189
+ gateway_sid = str(existing_row["gateway_session_id"] or "").strip()
190
+ if not gateway_sid.startswith(f"api:{hermes_prof}:"):
191
+ existing_row = None
170
192
  if existing_row:
171
193
  sid = str(existing_row["session_id"]).strip()
172
194
  gateway_sid = str(existing_row["gateway_session_id"] or f"api:{hermes_prof}:{sid}").strip()
@@ -211,6 +233,7 @@ def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "
211
233
  }
212
234
  new_id = f"wf_room_{room_id[:8]}_{int(time.time())}_{uuid.uuid4().hex[:6]}"
213
235
  requested_title = str(payload.get("title") or "").strip() or _srv()._default_session_title(template_prof)
236
+ lifecycle = _ensure_profile_api_for_session_create(hermes_prof, payer, workspace_id)
214
237
  status, data, session_title = _srv()._create_profile_session_via_api(hermes_prof, new_id, requested_title)
215
238
  if status >= 300:
216
239
  raise ValueError(f"create session failed: {data}")
@@ -265,6 +288,7 @@ def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "
265
288
  }
266
289
  new_id = f"wf_{source_id}_{client_id}_{int(time.time())}_{uuid.uuid4().hex[:6]}"
267
290
  requested_title = str(payload.get("title") or "").strip() or _srv()._default_session_title(template_prof)
291
+ lifecycle = _ensure_profile_api_for_session_create(hermes_prof, payer, workspace_id)
268
292
  status, data, session_title = _srv()._create_profile_session_via_api(hermes_prof, new_id, requested_title)
269
293
  if status >= 300:
270
294
  raise ValueError(f"create session failed: {data}")