devops-bot-sdk 1.4.119__tar.gz → 1.4.124__tar.gz

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 (89) hide show
  1. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/__init__.py +2 -2
  4. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/inventory.py +45 -33
  5. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/config.py +69 -0
  6. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/api.py +133 -52
  7. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/ip_allowlist.py +16 -10
  8. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/static/app.js +21 -7
  9. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/README.md +0 -0
  10. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  11. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  12. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  13. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/devops_bot_sdk.egg-info/requires.txt +0 -0
  14. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  15. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/pyproject.toml +0 -0
  16. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agentd.py +0 -0
  17. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/__init__.py +0 -0
  18. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/builder.py +0 -0
  19. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/chat.py +0 -0
  20. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/connectors.py +0 -0
  21. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/engines/__init__.py +0 -0
  22. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/engines/claude_code.py +0 -0
  23. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/engines/http.py +0 -0
  24. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/engines/mcp.py +0 -0
  25. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/engines/n8n.py +0 -0
  26. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/engines/shell.py +0 -0
  27. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/integrations.py +0 -0
  28. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/library/__init__.py +0 -0
  29. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/manifest.py +0 -0
  30. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/prompt_studio.py +0 -0
  31. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/prompt_template.py +0 -0
  32. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/prompt_vars.py +0 -0
  33. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/run_log.py +0 -0
  34. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/secrets.py +0 -0
  35. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/usage.py +0 -0
  36. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/agents/varstore.py +0 -0
  37. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/cli.py +0 -0
  38. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/client.py +0 -0
  39. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/collectors/__init__.py +0 -0
  40. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/collectors/files.py +0 -0
  41. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/collectors/process.py +0 -0
  42. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/collectors/screenshot.py +0 -0
  43. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/crucial.py +0 -0
  44. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/design_verify/__init__.py +0 -0
  45. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/design_verify/assets.py +0 -0
  46. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/design_verify/bootstrap.py +0 -0
  47. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/design_verify/browser.py +0 -0
  48. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/design_verify/compare.py +0 -0
  49. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/design_verify/loop.py +0 -0
  50. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/exceptions.py +0 -0
  51. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/git_ops.py +0 -0
  52. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/graphify.py +0 -0
  53. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/hooks/__init__.py +0 -0
  54. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/hooks/crucial_guard.py +0 -0
  55. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ipc/__init__.py +0 -0
  56. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ipc/electron_bridge.py +0 -0
  57. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ipc/handlers.py +0 -0
  58. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/local_exec.py +0 -0
  59. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/models/__init__.py +0 -0
  60. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/models/envelope.py +0 -0
  61. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/models/requests.py +0 -0
  62. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/models/responses.py +0 -0
  63. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/models/snapshots.py +0 -0
  64. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/platform_compat.py +0 -0
  65. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/py.typed +0 -0
  66. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/run_auto.py +0 -0
  67. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/sse.py +0 -0
  68. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/test.py +0 -0
  69. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/test_pipeline.py +0 -0
  70. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/__init__.py +0 -0
  71. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/dashboard.py +0 -0
  72. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/jobs.py +0 -0
  73. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/runner.py +0 -0
  74. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/security.py +0 -0
  75. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/server.py +0 -0
  76. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/service.py +0 -0
  77. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/setup_manager.py +0 -0
  78. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/static/agents.html +0 -0
  79. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/static/custom-agents.html +0 -0
  80. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/static/default-agents.html +0 -0
  81. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/static/index.html +0 -0
  82. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/static/runs.html +0 -0
  83. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/static/setup.html +0 -0
  84. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/static/styles.css +0 -0
  85. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/ui/static/tickets.html +0 -0
  86. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/sdk/updater.py +0 -0
  87. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/setup.cfg +0 -0
  88. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/tests/test_agent_chat_features.py +0 -0
  89. {devops_bot_sdk-1.4.119 → devops_bot_sdk-1.4.124}/tests/test_agents_connectors_builder.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.119
3
+ Version: 1.4.124
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.119
3
+ Version: 1.4.124
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.119
3
+ Version: 1.4.124
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.119" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.4.124" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
34
34
  __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
35
35
 
36
36
  from sdk.client import BackendClient
@@ -16,22 +16,39 @@ import subprocess
16
16
  from pathlib import Path
17
17
 
18
18
  from sdk.agents import usage as usage_mod
19
- from sdk.agents.manifest import load_agents, load_skills
20
- from sdk.agents.prompt_vars import extract_variables
19
+ from sdk.agents.manifest import load_skills
21
20
 
22
21
  logger = logging.getLogger("sdk.agents.inventory")
23
22
 
24
23
 
25
- def _custom_agents() -> list[dict]:
26
- out: list[dict] = []
27
- for m in load_agents():
28
- out.append({
29
- "name": m.name, "engine": m.engine, "trigger": m.trigger.type,
30
- "description": m.description, "integrations": list(m.integrations),
31
- "skills": list(m.skills), "variables": extract_variables(m.prompt),
32
- "source": "custom",
33
- })
34
- return out
24
+ def _map_backend_agent(row: dict, source: str) -> dict:
25
+ """Project a backend prompt-based ``agents`` row into the inventory shape."""
26
+ prompt = row.get("prompt") or {}
27
+ sys_prompt = prompt.get("system_prompt", "") if isinstance(prompt, dict) else ""
28
+ pvars = prompt.get("variables", []) if isinstance(prompt, dict) else []
29
+ return {
30
+ "name": row.get("name"),
31
+ "description": sys_prompt[:200],
32
+ "variables": [v.get("name") for v in pvars if isinstance(v, dict) and v.get("name")],
33
+ "skills": row.get("skills", []),
34
+ "status": row.get("status", "active"),
35
+ "source": source,
36
+ }
37
+
38
+
39
+ async def _custom_agents() -> list[dict]:
40
+ """The caller's custom agents (``relation_type=person``) from the backend.
41
+
42
+ Backend is the source of truth — agents are authored in the web app and read
43
+ via GET /api/v1/agents. Best-effort: degrades to empty on any error.
44
+ """
45
+ try:
46
+ from sdk.client import BackendClient
47
+ rows = await BackendClient.from_config().list_custom_agents()
48
+ return [_map_backend_agent(r, "custom") for r in rows]
49
+ except Exception as exc: # noqa: BLE001
50
+ logger.debug("custom-agent inventory failed: %s", exc)
51
+ return []
35
52
 
36
53
 
37
54
  def _skills() -> list[dict]:
@@ -101,31 +118,26 @@ def _claude_subagents() -> list[dict]:
101
118
 
102
119
 
103
120
  async def _default_agents() -> list[dict]:
104
- """Department default agents from the bundled SDK library (best-effort).
121
+ """Department default agents (``relation_type=department`` + org-wide common)
122
+ from the backend (best-effort).
105
123
 
106
- Resolves the caller's department by *name* (the ``title`` on the backend
107
- profile, e.g. "Machine Learning") to a library slug and loads that
108
- department's templates locally — no AI-backend ``/departments/{id}/
109
- default-agents`` call.
124
+ Reads the live rows via GET /api/v1/department-agents — the source of truth —
125
+ rather than the bundled SDK library. Degrades to empty on any error.
110
126
  """
111
127
  try:
112
128
  from sdk.client import BackendClient
113
- from sdk.agents.library import (
114
- load_common_templates, load_department, resolve_department_slug,
115
- )
116
-
117
129
  client = BackendClient.from_config()
118
- profile = await client.profile()
119
- title = profile.department_title or ""
120
- slug = resolve_department_slug(title)
121
- # A resolved department already includes the common agents; a department
122
- # with no library entry still surfaces the common defaults.
123
- templates = load_department(slug) if slug else load_common_templates(title=title)
124
- return [{"name": t.manifest.name, "engine": t.manifest.engine,
125
- "description": t.manifest.description, "source": "default",
126
- "department": title,
127
- "department_id": profile.department_id,
128
- "min_role_tier": t.min_role_tier} for t in templates]
130
+ try:
131
+ title = (await client.profile()).department_title or ""
132
+ except Exception: # noqa: BLE001 — department label is cosmetic
133
+ title = ""
134
+ rows = await client.list_department_agents()
135
+ out = []
136
+ for r in rows:
137
+ entry = _map_backend_agent(r, "default")
138
+ entry["department"] = title
139
+ out.append(entry)
140
+ return out
129
141
  except Exception as exc: # noqa: BLE001
130
142
  logger.debug("default-agent inventory failed: %s", exc)
131
143
  return []
@@ -133,7 +145,7 @@ async def _default_agents() -> list[dict]:
133
145
 
134
146
  async def build_inventory() -> dict:
135
147
  """The full snapshot: agents, subagents, tools, plugins + per-agent usage."""
136
- customs = _custom_agents()
148
+ customs = await _custom_agents()
137
149
  usage = usage_mod.summary()
138
150
  # Attach utilisation to each custom agent.
139
151
  for a in customs:
@@ -190,6 +190,75 @@ def get_allowed_ips() -> list[str]:
190
190
  return out
191
191
 
192
192
 
193
+ class PermanentAllowlistEntry(ValueError):
194
+ """Raised when trying to disable an IP that is permanently whitelisted via ``.env``."""
195
+
196
+
197
+ def permanent_allowed_ips() -> list[str]:
198
+ """IPs/CIDRs whitelisted **permanently** — from ``.env`` (``~/.agentos/.env`` or
199
+ CWD) or the ``AGENTOS_UI_ALLOWED_IPS`` process env var.
200
+
201
+ These are the fixed baseline: they are ALWAYS allowed and **cannot be disabled**
202
+ through the API — change them by editing ``.env`` and restarting.
203
+ """
204
+ out: list[str] = []
205
+ for src in (_dotenv_value("AGENTOS_UI_ALLOWED_IPS"),
206
+ os.environ.get("AGENTOS_UI_ALLOWED_IPS", "")):
207
+ for ip in _parse_ip_list(src):
208
+ if ip not in out:
209
+ out.append(ip)
210
+ return out
211
+
212
+
213
+ def runtime_allowed_ips() -> list[str]:
214
+ """IPs/CIDRs whitelisted **at runtime** (config.toml ``allowed_ips``) — added by
215
+ the whitelist endpoint and removable by the disable endpoint. They stay in the
216
+ allowlist until disabled."""
217
+ return _parse_ip_list(_load_raw().get("allowed_ips", ""))
218
+
219
+
220
+ def add_allowed_ip(ip: str) -> list[str]:
221
+ """Whitelist an IP/CIDR at RUNTIME (persisted to config.toml ``allowed_ips``).
222
+
223
+ Idempotent. The middleware re-reads the allowlist per request, so the IP is
224
+ honoured immediately (no restart) and stays until disabled. An IP already
225
+ permanently whitelisted via ``.env`` is left as-is (no runtime duplicate).
226
+ Returns the merged allowlist.
227
+ """
228
+ ip = (ip or "").strip()
229
+ if not ip:
230
+ raise ValueError("ip is required")
231
+ if ip in permanent_allowed_ips(): # already permanent — nothing to add
232
+ return get_allowed_ips()
233
+ raw = _load_raw() # preserves token_enc / base_url / etc.
234
+ entries = _parse_ip_list(raw.get("allowed_ips", ""))
235
+ if ip not in entries:
236
+ entries.append(ip)
237
+ raw["allowed_ips"] = ", ".join(entries)
238
+ _save_raw(raw)
239
+ return get_allowed_ips()
240
+
241
+
242
+ def remove_allowed_ip(ip: str) -> list[str]:
243
+ """Disable a RUNTIME-whitelisted IP/CIDR (remove from config.toml ``allowed_ips``).
244
+
245
+ Raises :class:`PermanentAllowlistEntry` if the IP is permanently whitelisted via
246
+ ``.env`` — those are never removable through the API (edit ``.env`` instead).
247
+ Returns the merged allowlist.
248
+ """
249
+ ip = (ip or "").strip()
250
+ if ip in permanent_allowed_ips():
251
+ raise PermanentAllowlistEntry(
252
+ f"{ip} is permanently whitelisted via .env (AGENTOS_UI_ALLOWED_IPS) — "
253
+ "it cannot be disabled through the API; edit .env and restart to change it."
254
+ )
255
+ raw = _load_raw()
256
+ entries = [x for x in _parse_ip_list(raw.get("allowed_ips", "")) if x != ip]
257
+ raw["allowed_ips"] = ", ".join(entries)
258
+ _save_raw(raw)
259
+ return get_allowed_ips()
260
+
261
+
193
262
  def get_branch_name_template() -> str | None:
194
263
  """Org-wide branch-naming convention, if configured in config.toml.
195
264
 
@@ -59,6 +59,67 @@ async def status() -> dict:
59
59
  }
60
60
 
61
61
 
62
+ # ── IP allowlist management ─────────────────────────────────────────────────
63
+ # Whitelist a client IP for THIS SDK. A whitelisted IP passes the IP gate for
64
+ # EVERY path (all /api/ui/* endpoints + the UI + /docs); data endpoints still
65
+ # additionally require the co_ token. Persisted to config.toml `allowed_ips`; the
66
+ # middleware re-reads it per request so a new IP works immediately (no restart).
67
+ # The backend proxies here (POST /api/v1/sdk/whitelist-ip) to open access for an IP.
68
+
69
+ def _valid_ip_or_cidr(value: str) -> bool:
70
+ import ipaddress
71
+ try:
72
+ ipaddress.ip_network(value, strict=False)
73
+ return True
74
+ except ValueError:
75
+ return False
76
+
77
+
78
+ @router.get("/allowlist", dependencies=[_auth])
79
+ async def get_allowlist() -> dict:
80
+ """This SDK's allowlist, split into permanent vs runtime.
81
+
82
+ - ``permanent`` — from ``.env`` (AGENTOS_UI_ALLOWED_IPS); always allowed, not
83
+ disableable via the API.
84
+ - ``runtime`` — whitelisted on request; stay until disabled.
85
+ (loopback + this host's own IPs are always allowed, implicitly.)
86
+ """
87
+ from sdk.config import get_allowed_ips, permanent_allowed_ips, runtime_allowed_ips
88
+ return {
89
+ "allowed_ips": get_allowed_ips(),
90
+ "permanent": permanent_allowed_ips(),
91
+ "runtime": runtime_allowed_ips(),
92
+ }
93
+
94
+
95
+ @router.post("/allowlist", dependencies=[_auth])
96
+ async def add_to_allowlist(body: dict) -> dict:
97
+ """Whitelist an IP/CIDR for this SDK at runtime. Body: ``{ip}``. Idempotent.
98
+
99
+ Opens the IP gate for that client across every path (all endpoints + UI + docs)
100
+ until it's disabled. Effective immediately (the middleware re-reads per request).
101
+ """
102
+ from sdk.config import add_allowed_ip
103
+
104
+ ip = str(body.get("ip", "")).strip()
105
+ if not ip:
106
+ raise HTTPException(status_code=400, detail="ip is required.")
107
+ if not _valid_ip_or_cidr(ip):
108
+ raise HTTPException(status_code=400, detail=f"invalid IP or CIDR: {ip!r}")
109
+ return {"added": ip, "allowed_ips": add_allowed_ip(ip)}
110
+
111
+
112
+ @router.delete("/allowlist", dependencies=[_auth])
113
+ async def remove_from_allowlist(ip: str = Query(...)) -> dict:
114
+ """Disable a runtime-whitelisted IP/CIDR. Permanent (``.env``) entries → 400."""
115
+ from sdk.config import remove_allowed_ip, PermanentAllowlistEntry
116
+
117
+ try:
118
+ return {"removed": ip, "allowed_ips": remove_allowed_ip(ip)}
119
+ except PermanentAllowlistEntry as exc:
120
+ raise HTTPException(status_code=400, detail=str(exc))
121
+
122
+
62
123
  @router.post("/configure", dependencies=[_auth])
63
124
  async def configure(body: dict) -> dict:
64
125
  from sdk import config as sdk_config
@@ -204,75 +265,95 @@ async def connectors() -> dict:
204
265
 
205
266
  @router.get("/agents", dependencies=[_auth])
206
267
  async def agents() -> dict:
207
- """Locally-installed agents (name, engine, trigger, integrations, variables)."""
208
- from sdk.agents.manifest import load_agents
209
- from sdk.agents.prompt_vars import extract_variables
268
+ """The caller's custom agents (``relation_type=person``) from the backend.
210
269
 
211
- return {
212
- "agents": [
213
- {"name": m.name, "engine": m.engine, "trigger": m.trigger.type,
214
- "integrations": m.integrations, "description": m.description,
215
- "variables": extract_variables(m.prompt)}
216
- for m in load_agents()
217
- ]
270
+ Backend is the source of truth — agents are authored in the web app and read
271
+ here via GET /api/v1/agents (prompt-based shape: prompt = {system_prompt,
272
+ variables:[…]}; skills = [{name,…}]). Best-effort: on backend error the list
273
+ is empty with an ``error`` string the UI can surface.
274
+ """
275
+ from sdk.client import BackendClient
276
+ from sdk.exceptions import BackendFeatureMissing
277
+
278
+ try:
279
+ rows = await BackendClient.from_config().list_custom_agents()
280
+ except BackendFeatureMissing:
281
+ return {"agents": [], "error": ("Custom agents aren't available on this "
282
+ "backend — it may be running an older version.")}
283
+ except Exception as exc: # noqa: BLE001 — degrade to empty, surface the reason
284
+ return {"agents": [], "error": str(exc)}
285
+ return {"agents": rows, "error": ""}
286
+
287
+
288
+ def _flat_agent(row: dict, source: str, *, chattable: bool, department_id: str = "") -> dict:
289
+ """Flatten a backend prompt-based ``agents`` row into the picker shape the UI
290
+ expects (``name``/``description``/``variables``/``skills``/``source``)."""
291
+ prompt = row.get("prompt") or {}
292
+ sys_prompt = prompt.get("system_prompt", "") if isinstance(prompt, dict) else ""
293
+ pvars = prompt.get("variables", []) if isinstance(prompt, dict) else []
294
+ entry = {
295
+ "name": row.get("name"),
296
+ "description": sys_prompt[:200],
297
+ "variables": [v.get("name") for v in pvars if isinstance(v, dict) and v.get("name")],
298
+ "skills": row.get("skills", []),
299
+ "status": row.get("status", "active"),
300
+ "source": source,
301
+ "chattable": chattable,
218
302
  }
303
+ if department_id:
304
+ entry["department_id"] = department_id
305
+ return entry
219
306
 
220
307
 
221
308
  @router.get("/agents/all", dependencies=[_auth])
222
309
  async def agents_all(department_id: str = Query("")) -> dict:
223
- """Every agent a person can pick to chat: local custom + department defaults.
224
-
225
- Custom agents are chattable immediately; a default must be activated (cloned
226
- locally) first — surfaced via ``chattable`` and ``source``. Each entry carries
227
- its detected ``{{variables}}`` so the UI can render input fields. Defaults are
228
- best-effort (empty if the backend is unreachable).
310
+ """Every agent a person can pick to chat: their custom agents + department
311
+ defaults, both read from the backend (source of truth).
312
+
313
+ Custom agents (``relation_type=person``) come from GET /api/v1/agents; defaults
314
+ (``relation_type=department`` + org-wide common) from GET /api/v1/department-agents.
315
+ Custom wins on a name collision. Each entry carries its detected variables so
316
+ the UI can render input fields. Both are best-effort — an unreachable backend
317
+ yields an empty list plus an error string rather than a hard failure.
229
318
  """
230
- from sdk.agents.manifest import load_agents
231
- from sdk.agents.prompt_vars import extract_variables
232
-
233
- locals_ = load_agents()
234
- local_names = {m.name for m in locals_}
235
- out = [
236
- {"name": m.name, "engine": m.engine, "description": m.description,
237
- "variables": extract_variables(m.prompt), "source": "custom",
238
- "chattable": True}
239
- for m in locals_
240
- ]
319
+ from sdk.client import BackendClient
320
+ from sdk.exceptions import BackendFeatureMissing
241
321
 
322
+ out: list[dict] = []
323
+ seen: set[str] = set()
242
324
  dept = department_id
243
325
  defaults_error = ""
326
+
244
327
  try:
245
- from sdk.client import BackendClient
246
- from sdk.exceptions import BackendFeatureMissing
247
328
  client = BackendClient.from_config()
248
329
  if not dept:
249
330
  profile = await client.bootstrap()
250
331
  dept = getattr(profile, "department_id", None) or ""
251
- if dept:
252
- try:
253
- # Department defaults are now plain `agents` rows (relation_type=
254
- # department), read from /department-agents in the new prompt-based
255
- # schema: prompt = {system_prompt, variables:[{name,...}]}.
256
- rows = await client.list_department_agents()
257
- except BackendFeatureMissing:
258
- rows = []
259
- defaults_error = ("Department default agents aren't available on this "
260
- "backend — it may be running an older version.")
261
- for r in rows:
332
+
333
+ # Custom agents first so they win any name collision with a default.
334
+ try:
335
+ for r in await client.list_custom_agents():
336
+ nm = r.get("name")
337
+ if not nm or nm in seen:
338
+ continue
339
+ seen.add(nm)
340
+ out.append(_flat_agent(r, "custom", chattable=True))
341
+ except BackendFeatureMissing:
342
+ defaults_error = ("Custom agents aren't available on this backend — "
343
+ "it may be running an older version.")
344
+
345
+ # Department defaults (a default must be activated/cloned before it runs).
346
+ try:
347
+ for r in await client.list_department_agents():
262
348
  nm = r.get("name")
263
- if not nm or nm in local_names:
349
+ if not nm or nm in seen:
264
350
  continue
265
- prompt = r.get("prompt") or {}
266
- sys_prompt = prompt.get("system_prompt", "") if isinstance(prompt, dict) else ""
267
- pvars = prompt.get("variables", []) if isinstance(prompt, dict) else []
268
- out.append({
269
- "name": nm,
270
- "description": sys_prompt[:200],
271
- "variables": [v.get("name") for v in pvars if isinstance(v, dict) and v.get("name")],
272
- "skills": r.get("skills", []),
273
- "source": "default", "department_id": dept, "chattable": False,
274
- })
275
- except Exception: # noqa: BLE001 — defaults are best-effort
351
+ seen.add(nm)
352
+ out.append(_flat_agent(r, "default", chattable=False, department_id=dept))
353
+ except BackendFeatureMissing:
354
+ defaults_error = ("Department default agents aren't available on this "
355
+ "backend — it may be running an older version.")
356
+ except Exception: # noqa: BLE001 — best-effort; return whatever we gathered
276
357
  pass
277
358
 
278
359
  return {"agents": out, "department_id": dept, "defaults_error": defaults_error}
@@ -71,18 +71,24 @@ class IPAllowlistMiddleware(BaseHTTPMiddleware):
71
71
 
72
72
  def __init__(self, app, allowed: list[str] | None = None) -> None:
73
73
  super().__init__(app)
74
- # Resolve the allowlist once at startup. Callers can pass an explicit list
75
- # (tests); otherwise we read env + config.toml.
76
- if allowed is None:
74
+ # `allowed` set explicitly (tests) → fixed list. Otherwise the configured
75
+ # allowlist is read FRESH per request (see `_configured_nets`) so an IP
76
+ # whitelisted at runtime via /api/ui/allowlist takes effect immediately —
77
+ # no restart. This host's own IPs are static, so compute them once.
78
+ self._explicit = allowed
79
+ self._local_nets = [n for n in (_parse(a) for a in _local_ips()) if n is not None]
80
+ self._trust_xff = os.environ.get("AGENTOS_UI_TRUST_FORWARDED_FOR", "") in ("1", "true", "TRUE", "yes")
81
+
82
+ def _configured_nets(self) -> list:
83
+ if self._explicit is not None:
84
+ entries = self._explicit
85
+ else:
77
86
  try:
78
87
  from sdk.config import get_allowed_ips
79
- allowed = get_allowed_ips()
88
+ entries = get_allowed_ips()
80
89
  except Exception: # noqa: BLE001 — never let config break the server
81
- allowed = []
82
- # Configured allowlist + this host's own IPs, all as networks.
83
- entries = list(allowed) + list(_local_ips())
84
- self._networks = [n for n in (_parse(a) for a in entries) if n is not None]
85
- self._trust_xff = os.environ.get("AGENTOS_UI_TRUST_FORWARDED_FOR", "") in ("1", "true", "TRUE", "yes")
90
+ entries = []
91
+ return [n for n in (_parse(a) for a in entries) if n is not None]
86
92
 
87
93
  def _client_ip(self, request: Request) -> str:
88
94
  if self._trust_xff:
@@ -98,7 +104,7 @@ class IPAllowlistMiddleware(BaseHTTPMiddleware):
98
104
  return False
99
105
  if _loopback(ip):
100
106
  return True
101
- return any(ip in net for net in self._networks)
107
+ return any(ip in net for net in (self._local_nets + self._configured_nets()))
102
108
 
103
109
  async def dispatch(self, request: Request, call_next):
104
110
  # Guard EVERY path — nothing on this server is public.
@@ -650,26 +650,40 @@
650
650
  try {
651
651
  const d = await api("/api/ui/agents");
652
652
  const rows = d.agents || [];
653
+ if (!rows.length && d.error) {
654
+ $("local-agents").innerHTML = `<div class="empty">${esc(d.error)}</div>`;
655
+ const kaSel0 = $("key-agent");
656
+ if (kaSel0) { kaSel0.innerHTML = `<option value="">(no agents yet)</option>`; onAgentSelect(); }
657
+ return;
658
+ }
653
659
  caMeta = {}; rows.forEach(a => { caMeta[a.name] = a; });
654
660
  const kaSel = $("key-agent");
655
661
  if (kaSel) {
656
662
  kaSel.innerHTML = rows.length
657
- ? rows.map(a => `<option value="${esc(a.name)}">${esc(a.name)} (${esc(a.engine)})</option>`).join("")
663
+ ? rows.map(a => `<option value="${esc(a.name)}">${esc(a.name)}</option>`).join("")
658
664
  : `<option value="">(no agents yet)</option>`;
659
665
  onAgentSelect();
660
666
  }
661
- $("local-agents").innerHTML = rows.length ? rows.map(a =>
662
- `<div class="agent-card">
663
- <div class="agent-head"><span class="agent-name">${esc(a.name)}</span><span class="pill on">${esc(a.engine)}</span></div>
664
- <div class="agent-meta">${esc(a.trigger)}${a.integrations.length ? " · " + a.integrations.map(esc).join(", ") : ""}${a.variables && a.variables.length ? " · " + a.variables.length + " input(s)" : ""}</div>
665
- <div class="agent-desc">${esc(a.description || "")}</div>
667
+ $("local-agents").innerHTML = rows.length ? rows.map(a => {
668
+ const prompt = a.prompt || {};
669
+ const summary = (prompt.system_prompt || "").trim().slice(0, 200);
670
+ const nvars = (prompt.variables || []).length;
671
+ const nskills = (a.skills || []).length;
672
+ return `<div class="agent-card">
673
+ <div class="agent-head">
674
+ <span class="agent-name">${esc(a.name)}</span>
675
+ <span class="pill ${a.status === "disabled" ? "off" : "on"}">${esc(a.status || "active")}</span>
676
+ </div>
677
+ <div class="agent-meta">${nvars} variable(s) · ${nskills} skill(s)</div>
678
+ <div class="agent-desc">${esc(summary)}</div>
666
679
  <div class="agent-actions">
667
680
  <a class="btn primary" href="/agents.html?agent=${encodeURIComponent(a.name)}">Run</a>
668
681
  <button class="btn" data-enable="${esc(a.name)}">Enable</button>
669
682
  <button class="btn" data-disable="${esc(a.name)}">Disable</button>
670
683
  <button class="btn danger" data-del="${esc(a.name)}">Delete</button>
671
684
  </div>
672
- </div>`).join("")
685
+ </div>`;
686
+ }).join("")
673
687
  : "<div class='empty'>No custom agents assigned to you yet. Agents are created in the web app.</div>";
674
688
 
675
689
  $("local-agents").querySelectorAll("[data-enable]").forEach(b => b.onclick = () => setStatus(b.dataset.enable, "active"));