devops-bot-sdk 1.4.146__tar.gz → 1.4.148__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 (95) hide show
  1. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/__init__.py +2 -2
  4. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/ip_allowlist.py +40 -4
  5. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/server.py +16 -0
  6. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/service.py +189 -4
  7. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/updater.py +36 -1
  8. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/README.md +0 -0
  9. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  10. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  11. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  12. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/devops_bot_sdk.egg-info/requires.txt +0 -0
  13. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  14. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/pyproject.toml +0 -0
  15. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agentd.py +0 -0
  16. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/__init__.py +0 -0
  17. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/builder.py +0 -0
  18. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/chat.py +0 -0
  19. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/connectors.py +0 -0
  20. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/engines/__init__.py +0 -0
  21. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/engines/claude_code.py +0 -0
  22. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/engines/http.py +0 -0
  23. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/engines/mcp.py +0 -0
  24. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/engines/n8n.py +0 -0
  25. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/engines/shell.py +0 -0
  26. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/integrations.py +0 -0
  27. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/inventory.py +0 -0
  28. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/library/__init__.py +0 -0
  29. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/manifest.py +0 -0
  30. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/prompt_studio.py +0 -0
  31. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/prompt_template.py +0 -0
  32. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/prompt_vars.py +0 -0
  33. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/run_log.py +0 -0
  34. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/secrets.py +0 -0
  35. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/usage.py +0 -0
  36. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/agents/varstore.py +0 -0
  37. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/cli.py +0 -0
  38. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/client.py +0 -0
  39. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/collectors/__init__.py +0 -0
  40. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/collectors/files.py +0 -0
  41. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/collectors/process.py +0 -0
  42. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/collectors/screenshot.py +0 -0
  43. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/config.py +0 -0
  44. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/crucial.py +0 -0
  45. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/design_verify/__init__.py +0 -0
  46. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/design_verify/assets.py +0 -0
  47. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/design_verify/bootstrap.py +0 -0
  48. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/design_verify/browser.py +0 -0
  49. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/design_verify/compare.py +0 -0
  50. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/design_verify/loop.py +0 -0
  51. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/exceptions.py +0 -0
  52. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/git_ops.py +0 -0
  53. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/graphify.py +0 -0
  54. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/hooks/__init__.py +0 -0
  55. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/hooks/crucial_guard.py +0 -0
  56. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ipc/__init__.py +0 -0
  57. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ipc/electron_bridge.py +0 -0
  58. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ipc/handlers.py +0 -0
  59. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/local_exec.py +0 -0
  60. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/models/__init__.py +0 -0
  61. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/models/envelope.py +0 -0
  62. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/models/requests.py +0 -0
  63. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/models/responses.py +0 -0
  64. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/models/snapshots.py +0 -0
  65. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/platform_compat.py +0 -0
  66. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/py.typed +0 -0
  67. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/run_auto.py +0 -0
  68. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/sse.py +0 -0
  69. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/test.py +0 -0
  70. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/test_pipeline.py +0 -0
  71. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/__init__.py +0 -0
  72. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/api.py +0 -0
  73. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/dashboard.py +0 -0
  74. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/health.py +0 -0
  75. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/jobs.py +0 -0
  76. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/models.py +0 -0
  77. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/runner.py +0 -0
  78. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/security.py +0 -0
  79. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/setup_manager.py +0 -0
  80. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/static/agents.html +0 -0
  81. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/static/app.js +0 -0
  82. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/static/custom-agents.html +0 -0
  83. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/static/default-agents.html +0 -0
  84. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/static/index.html +0 -0
  85. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/static/runs.html +0 -0
  86. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/static/setup.html +0 -0
  87. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/static/styles.css +0 -0
  88. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/sdk/ui/static/tickets.html +0 -0
  89. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/setup.cfg +0 -0
  90. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/tests/test_agent_chat_features.py +0 -0
  91. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/tests/test_agent_prompt_models.py +0 -0
  92. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/tests/test_agents_connectors_builder.py +0 -0
  93. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/tests/test_git_action.py +0 -0
  94. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/tests/test_health.py +0 -0
  95. {devops_bot_sdk-1.4.146 → devops_bot_sdk-1.4.148}/tests/test_updater_extras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.146
3
+ Version: 1.4.148
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.146
3
+ Version: 1.4.148
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.146
3
+ Version: 1.4.148
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.146" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.4.148" # 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
@@ -20,16 +20,52 @@ import ipaddress
20
20
  import os
21
21
  import socket
22
22
 
23
+ _TRUE = ("1", "true", "yes", "on")
24
+
25
+
26
+ def _setting(name: str) -> str:
27
+ """Read an ``AGENTOS_UI_*`` toggle from the process env OR ``~/.agentos/.env``
28
+ (or a CWD ``.env``), so an operator can set it in the same file as the IP
29
+ allowlist without editing the service unit. Process env wins."""
30
+ val = os.environ.get(name, "").strip()
31
+ if val:
32
+ return val
33
+ try:
34
+ from sdk.config import _dotenv_value
35
+ return (_dotenv_value(name) or "").strip()
36
+ except Exception: # noqa: BLE001 — a config hiccup must never break the server
37
+ return ""
38
+
23
39
 
24
40
  def public_mode() -> bool:
25
41
  """Opt-in PUBLIC mode (``AGENTOS_UI_PUBLIC=1``).
26
42
 
27
43
  When set, the SDK is a public, token-authenticated server: the IP gate is
28
- lifted so Swagger (``/docs``) is browsable from any IP and every ``/api/ui/*``
29
- path relies SOLELY on the ``co_`` token (``require_token``) as its credential.
30
- Off by default — a plain launch stays locked to loopback + allowlisted IPs.
44
+ lifted so Swagger (``/docs``) is browsable from any IP over http and every
45
+ ``/api/ui/*`` path relies SOLELY on the ``co_`` token (``require_token``) as its
46
+ credential. Off by default — a plain launch stays locked to loopback +
47
+ allowlisted IPs. Read from the process env or ``~/.agentos/.env``.
48
+ """
49
+ return _setting("AGENTOS_UI_PUBLIC").lower() in _TRUE
50
+
51
+
52
+ def cors_origins() -> list[str] | None:
53
+ """Browser (CORS) origins to allow, or ``None`` to leave CORS disabled.
54
+
55
+ Enabled when the server is PUBLIC (``AGENTOS_UI_PUBLIC=1``) or explicitly via
56
+ ``AGENTOS_UI_CORS=1``. Origins come from ``AGENTOS_UI_CORS_ORIGINS`` (comma/space
57
+ separated); default ``["*"]`` (any origin). ``*`` is safe here because every
58
+ data route is guarded by the ``co_`` token in the ``Authorization`` header (no
59
+ cookies), so a permissive origin can't be abused via ambient credentials.
60
+
61
+ Needed so a browser (e.g. Swagger "Try it out" served from another origin) can
62
+ call the API — without it the browser blocks the response as a CORS error.
31
63
  """
32
- return os.environ.get("AGENTOS_UI_PUBLIC", "").strip().lower() in ("1", "true", "yes", "on")
64
+ enabled = public_mode() or _setting("AGENTOS_UI_CORS").lower() in _TRUE
65
+ if not enabled:
66
+ return None
67
+ raw = _setting("AGENTOS_UI_CORS_ORIGINS").replace(",", " ").split()
68
+ return raw or ["*"]
33
69
 
34
70
 
35
71
  def _loopback(ip: ipaddress._BaseAddress) -> bool:
@@ -41,6 +41,22 @@ def create_app():
41
41
  # this host's own IPs, and whitelisted IPs (the AI backend) may reach the server
42
42
  # AT ALL. Configure via AGENTOS_UI_ALLOWED_IPS or the config.toml `allowed_ips` key.
43
43
  app.add_middleware(IPAllowlistMiddleware)
44
+ # CORS (opt-in — on automatically in PUBLIC mode, or via AGENTOS_UI_CORS=1). Added
45
+ # AFTER the allowlist so it is the OUTERMOST layer: it answers browser preflight
46
+ # (OPTIONS) and attaches CORS headers to EVERY response — including a 403 from the
47
+ # allowlist — so the browser can read the real result instead of a bare "CORS
48
+ # error". Data routes stay guarded by the co_ token regardless.
49
+ from sdk.ui.ip_allowlist import cors_origins
50
+ _cors = cors_origins()
51
+ if _cors is not None:
52
+ from fastapi.middleware.cors import CORSMiddleware
53
+ app.add_middleware(
54
+ CORSMiddleware,
55
+ allow_origins=_cors,
56
+ allow_methods=["*"],
57
+ allow_headers=["*"],
58
+ allow_credentials=False, # token is in the Authorization header, not cookies
59
+ )
44
60
  app.include_router(api_router)
45
61
 
46
62
  @app.get("/")
@@ -17,6 +17,7 @@ Everything is best-effort and returns ``(ok, message)`` so the caller (the
17
17
  from __future__ import annotations
18
18
 
19
19
  import os
20
+ import re
20
21
  import shutil
21
22
  import subprocess
22
23
  import sys
@@ -30,11 +31,24 @@ WIN_TASK = "AgentOS Control Panel" # Windows scheduled-task name
30
31
 
31
32
 
32
33
  def _exe() -> str:
33
- """Absolute path to the ``agentos-ui`` console script in this venv."""
34
- bindir = Path(sys.executable).parent
34
+ """ABSOLUTE path to the ``agentos-ui`` console script.
35
+
36
+ Must be absolute: a service manager (systemd/launchd) does NOT search the
37
+ user's PATH for ExecStart, so a bare ``agentos-ui`` fails with 203/EXEC when
38
+ the script lives somewhere like ``~/.local/bin`` (a ``pip --user`` install) —
39
+ the common case on a VPS. Resolve, in order:
40
+ 1. next to the running interpreter (venv installs),
41
+ 2. on the current PATH (finds ``~/.local/bin`` at install time),
42
+ 3. bare name as a last resort (kept only so install never hard-fails).
43
+ """
35
44
  name = "agentos-ui.exe" if IS_WINDOWS else "agentos-ui"
36
- cand = bindir / name
37
- return str(cand if cand.exists() else name)
45
+ cand = Path(sys.executable).parent / name
46
+ if cand.exists():
47
+ return str(cand)
48
+ found = shutil.which(name)
49
+ if found:
50
+ return str(Path(found).resolve())
51
+ return name
38
52
 
39
53
 
40
54
  def _run(argv: list[str]) -> tuple[int, str]:
@@ -285,3 +299,174 @@ def restart() -> tuple[bool, str]:
285
299
  return (rc == 0), (f"restarted systemd service '{SERVICE_NAME}'." if rc == 0
286
300
  else out or f"systemctl restart {SERVICE_NAME} failed")
287
301
  return False, "no supported service manager found — restart the panel manually (`agentos-ui`)."
302
+
303
+
304
+ def _systemd_unit_candidates() -> list[Path]:
305
+ """Both places a systemd unit can live, system scope first."""
306
+ return [
307
+ Path("/etc/systemd/system") / f"{SERVICE_NAME}.service",
308
+ Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service",
309
+ ]
310
+
311
+
312
+ def _existing_systemd_unit() -> Path | None:
313
+ """The installed unit file, checking BOTH system and user scope regardless of
314
+ the current privilege — so a non-root ``agentos-auto update`` still finds a
315
+ system service installed via ``sudo agentos-ui install`` (and vice-versa)."""
316
+ for p in _systemd_unit_candidates():
317
+ try:
318
+ if p.exists():
319
+ return p
320
+ except OSError:
321
+ continue
322
+ return None
323
+
324
+
325
+ def _service_definition_path() -> Path:
326
+ """On-disk service definition — an EXISTING unit if there is one (either scope),
327
+ else the path we would CREATE at the current privilege."""
328
+ if IS_WINDOWS: # schtasks has no file; handled separately
329
+ return Path()
330
+ if IS_MAC:
331
+ return _mac_plist_path()
332
+ existing = _existing_systemd_unit()
333
+ if existing:
334
+ return existing
335
+ is_root = hasattr(os, "geteuid") and os.geteuid() == 0
336
+ base = Path("/etc/systemd/system") if is_root else Path.home() / ".config" / "systemd" / "user"
337
+ return base / f"{SERVICE_NAME}.service"
338
+
339
+
340
+ def _needs_root_for_existing() -> bool:
341
+ """True when a SYSTEM unit is installed but we're not root — so we must not
342
+ silently create a conflicting *user* unit; the caller should ask for sudo."""
343
+ if IS_WINDOWS or IS_MAC:
344
+ return False
345
+ is_root = hasattr(os, "geteuid") and os.geteuid() == 0
346
+ existing = _existing_systemd_unit()
347
+ return (existing is not None
348
+ and existing.parent == Path("/etc/systemd/system")
349
+ and not is_root)
350
+
351
+
352
+ def is_installed() -> bool:
353
+ """True when a managed agentos-ui service/unit exists (any platform, any scope).
354
+
355
+ Best-effort and never raises — used by the updater to decide whether an upgrade
356
+ should create, regenerate, or restart the service.
357
+ """
358
+ try:
359
+ if IS_WINDOWS:
360
+ rc, _ = _run(["schtasks", "/Query", "/TN", WIN_TASK])
361
+ return rc == 0
362
+ if IS_MAC:
363
+ return _mac_plist_path().exists()
364
+ return _existing_systemd_unit() is not None
365
+ except Exception: # noqa: BLE001
366
+ return False
367
+
368
+
369
+ def _is_active() -> bool:
370
+ """True when the managed service is currently running (read-only query)."""
371
+ try:
372
+ if IS_WINDOWS:
373
+ _, out = _run(["schtasks", "/Query", "/TN", WIN_TASK, "/FO", "LIST", "/V"])
374
+ return "Running" in out
375
+ if IS_MAC:
376
+ rc, out = _run(["launchctl", "list", _MAC_LABEL])
377
+ return rc == 0 and bool(out.strip())
378
+ existing = _existing_systemd_unit()
379
+ user = existing is not None and existing.parent.name == "user"
380
+ sysctl = ["systemctl", "--user"] if user else ["systemctl"]
381
+ _, out = _run([*sysctl, "is-active", SERVICE_NAME])
382
+ return out.strip() == "active"
383
+ except Exception: # noqa: BLE001
384
+ return False
385
+
386
+
387
+ def _installed_host_port() -> tuple[str, int]:
388
+ """Read the host/port the service was installed with, so a refresh preserves
389
+ them instead of resetting to the install() defaults. Falls back to 0.0.0.0:8765.
390
+ """
391
+ host, port = "0.0.0.0", 8765
392
+ text = ""
393
+ try:
394
+ if IS_WINDOWS:
395
+ _, text = _run(["schtasks", "/Query", "/TN", WIN_TASK, "/V", "/FO", "LIST"])
396
+ else:
397
+ p = _service_definition_path()
398
+ text = p.read_text() if p and p.exists() else ""
399
+ except Exception: # noqa: BLE001
400
+ text = ""
401
+ clean = re.sub(r"<[^>]+>", " ", text) # flatten plist <string> tags to spaces
402
+ m = re.search(r"--host\s+(\S+)", clean)
403
+ if m:
404
+ host = m.group(1).strip().strip('"')
405
+ m = re.search(r"--port\s+(\d+)", clean)
406
+ if m:
407
+ port = int(m.group(1))
408
+ return host, port
409
+
410
+
411
+ def refresh() -> tuple[bool, str]:
412
+ """Regenerate the service definition and restart it after an SDK upgrade.
413
+
414
+ ``agentos-auto update`` only pip-upgrades the package; the on-disk unit and the
415
+ running process are untouched. So a change to the generated unit (e.g. the
416
+ baked-in PATH that lets the service find the ``claude`` CLI) or to the code
417
+ never takes effect until this runs. Regenerating rewrites the unit with the
418
+ upgraded SDK's definition (preserving the installed host/port), and the restart
419
+ both applies the new unit and loads the new code.
420
+
421
+ No-op returning ``(False, msg)`` when no managed service is installed, so a
422
+ manually-launched panel is left alone.
423
+ """
424
+ if not is_installed():
425
+ return False, "no managed service installed — nothing to refresh."
426
+ if _needs_root_for_existing():
427
+ return False, ("the panel runs as a system service — re-run with sudo "
428
+ "(`sudo agentos-ui install`) to apply the update.")
429
+ host, port = _installed_host_port()
430
+ ok, msg = install(host, port) # rewrites unit/plist/task + daemon-reload
431
+ if not ok:
432
+ return False, f"could not regenerate service: {msg}"
433
+ # `enable --now` / launchctl load won't restart an ALREADY-running unit, so
434
+ # force a restart to apply the regenerated definition and load the new code.
435
+ rok, rmsg = restart()
436
+ return (True, f"service refreshed ({host}:{port}); {rmsg}" if rok
437
+ else f"service definition regenerated ({host}:{port}) but restart needs attention: {rmsg}")
438
+
439
+
440
+ def _service_disabled() -> bool:
441
+ return os.environ.get("AGENTOS_NO_SERVICE", "").strip().lower() in ("1", "true", "yes", "on")
442
+
443
+
444
+ def ensure(restart_running: bool = False) -> tuple[bool, str]:
445
+ """Make sure the panel service EXISTS and is running the current SDK.
446
+
447
+ This is what lets ``agentos-auto update`` yield a working 24/7 service without a
448
+ separate ``agentos-ui install`` step:
449
+
450
+ * not installed → create it (``install``)
451
+ * installed + code changed → ``refresh`` (regenerate unit + restart)
452
+ (pass ``restart_running=True`` after a real upgrade)
453
+ * installed + unchanged + running → leave it alone
454
+ * installed + unchanged + stopped → start it
455
+
456
+ Best-effort; opt out entirely with ``AGENTOS_NO_SERVICE=1`` (e.g. a box that
457
+ runs the panel manually or only uses the SDK as a library).
458
+ """
459
+ if _service_disabled():
460
+ return False, "AGENTOS_NO_SERVICE set — leaving the service unmanaged."
461
+ if not is_installed():
462
+ ok, msg = install() # create at the current privilege
463
+ return ok, (f"service created — {msg}" if ok else f"could not create service: {msg}")
464
+ if _needs_root_for_existing():
465
+ return False, ("a system service is installed — run `sudo agentos-ui install` "
466
+ "to apply updates and manage it.")
467
+ if restart_running:
468
+ return refresh()
469
+ if _is_active():
470
+ return True, "service already running."
471
+ rok, rmsg = restart() # installed but down → bring it back up
472
+ return rok, (f"service was down — started; {rmsg}" if rok else rmsg)
@@ -273,6 +273,37 @@ def self_update() -> bool:
273
273
  return False
274
274
 
275
275
 
276
+ def _ensure_service(quiet: bool = False, *, restart: bool = False) -> None:
277
+ """Ensure the panel service exists and runs the current SDK, after an update check.
278
+
279
+ Without this, ``agentos-auto update`` pip-upgrades the code but leaves the old
280
+ systemd unit / launchd plist and the old running process in place — so unit
281
+ changes (e.g. the baked-in PATH that lets the service find ``claude``) and new
282
+ code silently never take effect, and a box that never ran ``agentos-ui install``
283
+ has no service at all. ``service.ensure`` creates it when missing, refreshes it
284
+ after a real upgrade (``restart=True``), and starts it if it's down — so a plain
285
+ ``agentos-auto update`` reliably leaves ``systemctl status agentos-ui`` running.
286
+
287
+ Best-effort and never raises. Silent on a box without the ``[ui]`` extra (no
288
+ panel to manage) and opt-out via ``AGENTOS_NO_SERVICE=1``.
289
+
290
+ Safe here because ``auto_update`` runs from the short-lived ``agentos-auto``
291
+ process, not inside the long-running panel — restarting the panel can't kill
292
+ the updater mid-run.
293
+ """
294
+ try:
295
+ from sdk.ui import service
296
+ except Exception: # noqa: BLE001 — no [ui] extra installed → no panel service
297
+ return
298
+ try:
299
+ ok, msg = service.ensure(restart_running=restart)
300
+ if not quiet and msg:
301
+ print(f"↻ {msg}" if ok else f"(service: {msg})")
302
+ except Exception as exc: # noqa: BLE001 — an update must never fail on the service step
303
+ if not quiet:
304
+ print(f"(service check failed: {exc})")
305
+
306
+
276
307
  def auto_update(quiet: bool = False) -> tuple[bool, str, str | None]:
277
308
  """Check for a newer version and upgrade if one exists.
278
309
 
@@ -292,13 +323,16 @@ def auto_update(quiet: bool = False) -> tuple[bool, str, str | None]:
292
323
  if not is_github:
293
324
  latest = latest_pypi_version()
294
325
  if not latest or _vtuple(latest) <= _vtuple(cur):
326
+ _ensure_service(quiet, restart=False) # already current — just self-heal the service
295
327
  return (False, cur, latest)
296
328
  if not quiet:
297
329
  print(f"updating {_DIST} {cur} → {latest} …")
298
330
  ok = self_update()
299
331
  if not quiet and ok:
300
332
  print("✓ updated.")
301
- # silent on failure — continue on the existing version without noise
333
+ # Ensure the service exists + runs the new code (restart only on a real
334
+ # upgrade); also self-heals a missing/down service when already current.
335
+ _ensure_service(quiet, restart=ok)
302
336
  return (ok, cur, latest if ok else None)
303
337
 
304
338
  # GitHub path
@@ -313,6 +347,7 @@ def auto_update(quiet: bool = False) -> tuple[bool, str, str | None]:
313
347
  elif ok:
314
348
  print(f"✓ already up to date (v{cur}).")
315
349
  # silent on failure — continue on the existing version without noise
350
+ _ensure_service(quiet, restart=updated)
316
351
  return (updated, cur, after if updated else None)
317
352
 
318
353