devops-bot-sdk 1.6.4__tar.gz → 1.6.8__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 (111) hide show
  1. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
  4. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agentd.py +8 -6
  6. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/chat.py +56 -10
  7. devops_bot_sdk-1.6.8/sdk/agents/engines/claude_code.py +122 -0
  8. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/manifest.py +59 -2
  9. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/secrets.py +9 -1
  10. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/ticket_run.py +15 -14
  11. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/cli.py +14 -22
  12. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/config.py +64 -6
  13. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/design_verify/loop.py +5 -4
  14. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/local_exec.py +23 -11
  15. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_agent_run_ticket.py +56 -14
  16. devops_bot_sdk-1.6.8/tests/test_agent_workspace_settings.py +140 -0
  17. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_agents_connectors_builder.py +16 -2
  18. devops_bot_sdk-1.6.8/tests/test_allowed_tools_config.py +102 -0
  19. devops_bot_sdk-1.6.4/sdk/agents/engines/claude_code.py +0 -61
  20. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/README.md +0 -0
  21. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  22. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  23. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/devops_bot_sdk.egg-info/requires.txt +0 -0
  24. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  25. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/pyproject.toml +0 -0
  26. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/__init__.py +0 -0
  27. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/activity.py +0 -0
  28. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/builder.py +0 -0
  29. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/connectors.py +0 -0
  30. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/engines/__init__.py +0 -0
  31. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/engines/http.py +0 -0
  32. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/engines/mcp.py +0 -0
  33. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/engines/n8n.py +0 -0
  34. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/engines/shell.py +0 -0
  35. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/integrations.py +0 -0
  36. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/inventory.py +0 -0
  37. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/library/__init__.py +0 -0
  38. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/prompt_studio.py +0 -0
  39. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/prompt_template.py +0 -0
  40. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/prompt_vars.py +0 -0
  41. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/run_log.py +0 -0
  42. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/usage.py +0 -0
  43. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/agents/varstore.py +0 -0
  44. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/client.py +0 -0
  45. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/collectors/__init__.py +0 -0
  46. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/collectors/files.py +0 -0
  47. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/collectors/process.py +0 -0
  48. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/collectors/screenshot.py +0 -0
  49. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/crucial.py +0 -0
  50. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/design_verify/__init__.py +0 -0
  51. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/design_verify/assets.py +0 -0
  52. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/design_verify/bootstrap.py +0 -0
  53. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/design_verify/browser.py +0 -0
  54. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/design_verify/compare.py +0 -0
  55. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/exceptions.py +0 -0
  56. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/git_ops.py +0 -0
  57. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/github_ingest.py +0 -0
  58. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/graphify.py +0 -0
  59. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/hooks/__init__.py +0 -0
  60. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/hooks/crucial_guard.py +0 -0
  61. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ipc/__init__.py +0 -0
  62. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ipc/electron_bridge.py +0 -0
  63. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ipc/handlers.py +0 -0
  64. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/models/__init__.py +0 -0
  65. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/models/envelope.py +0 -0
  66. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/models/requests.py +0 -0
  67. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/models/responses.py +0 -0
  68. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/models/snapshots.py +0 -0
  69. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/platform_compat.py +0 -0
  70. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/py.typed +0 -0
  71. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/run_auto.py +0 -0
  72. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/sse.py +0 -0
  73. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/test.py +0 -0
  74. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/test_pipeline.py +0 -0
  75. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/__init__.py +0 -0
  76. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/api.py +0 -0
  77. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/dashboard.py +0 -0
  78. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/health.py +0 -0
  79. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/ip_allowlist.py +0 -0
  80. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/jobs.py +0 -0
  81. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/models.py +0 -0
  82. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/runner.py +0 -0
  83. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/security.py +0 -0
  84. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/server.py +0 -0
  85. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/service.py +0 -0
  86. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/session.py +0 -0
  87. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/setup_manager.py +0 -0
  88. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/static/agents.html +0 -0
  89. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/static/app.js +0 -0
  90. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/static/custom-agents.html +0 -0
  91. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/static/default-agents.html +0 -0
  92. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/static/index.html +0 -0
  93. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/static/login.html +0 -0
  94. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/static/runs.html +0 -0
  95. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/static/setup.html +0 -0
  96. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/static/styles.css +0 -0
  97. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/ui/static/tickets.html +0 -0
  98. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/updater.py +0 -0
  99. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/sdk/vps_health.py +0 -0
  100. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/setup.cfg +0 -0
  101. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_agent_activity.py +0 -0
  102. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_agent_chat_features.py +0 -0
  103. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_agent_listing.py +0 -0
  104. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_agent_prompt_async.py +0 -0
  105. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_agent_prompt_models.py +0 -0
  106. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_git_action.py +0 -0
  107. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_github_ingest.py +0 -0
  108. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_health.py +0 -0
  109. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_otp_login.py +0 -0
  110. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_updater_extras.py +0 -0
  111. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.8}/tests/test_vps_health.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.6.4
3
+ Version: 1.6.8
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.6.4
3
+ Version: 1.6.8
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
@@ -97,7 +97,9 @@ tests/test_agent_listing.py
97
97
  tests/test_agent_prompt_async.py
98
98
  tests/test_agent_prompt_models.py
99
99
  tests/test_agent_run_ticket.py
100
+ tests/test_agent_workspace_settings.py
100
101
  tests/test_agents_connectors_builder.py
102
+ tests/test_allowed_tools_config.py
101
103
  tests/test_git_action.py
102
104
  tests/test_github_ingest.py
103
105
  tests/test_health.py
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.6.4
3
+ Version: 1.6.8
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.6.4" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.6.8" # 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
@@ -543,7 +543,7 @@ class AgentDaemon:
543
543
  validate) is logged and skipped — it must never prevent PERSONAL
544
544
  agents from loading.
545
545
  """
546
- from sdk.agents.manifest import AgentManifest
546
+ from sdk.agents.manifest import manifest_from_backend_row
547
547
 
548
548
  try:
549
549
  rows = await self.client.list_department_agents()
@@ -555,13 +555,15 @@ class AgentDaemon:
555
555
  for row in rows:
556
556
  if row.get("status") != "active":
557
557
  continue
558
- try:
559
- manifests.append(AgentManifest(**row["manifest"]))
560
- except Exception as exc: # noqa: BLE001
558
+ # Prompt-based rows: the `manifest` column this used to read was
559
+ # dropped from the table, so every row raised KeyError here.
560
+ manifest = manifest_from_backend_row(row)
561
+ if manifest is None:
561
562
  logger.warning(
562
- "agentd.department_agent_manifest_invalid name=%s err=%s",
563
- row.get("name"), exc,
563
+ "agentd.department_agent_not_runnable name=%s", row.get("name"),
564
564
  )
565
+ continue
566
+ manifests.append(manifest)
565
567
  return manifests
566
568
 
567
569
  async def watch_tickets(self) -> None:
@@ -41,14 +41,60 @@ def _tee_events(log_path, on_event: "EventHandler | None") -> "EventHandler":
41
41
  EventHandler = Callable[[dict], Awaitable[None]]
42
42
 
43
43
 
44
- def _find_agent(name: str) -> AgentManifest:
45
- match = next((m for m in load_agents() if m.name == name), None)
46
- if match is None:
47
- raise LookupError(
48
- f"no local agent named '{name}' — install it first "
49
- f"(agentos agent add / default-agents activate)"
50
- )
51
- return match
44
+ def _find_agent(name: str) -> AgentManifest | None:
45
+ """A LOCAL manifest by name (``~/.agentos/agents``), or None."""
46
+ return next((m for m in load_agents() if m.name == name), None)
47
+
48
+
49
+ async def resolve_agent(name: str, client=None) -> AgentManifest:
50
+ """Find the agent to run: local manifest → your backend agents → department.
51
+
52
+ The BACKEND is where agents live now — they are authored in the web app, and
53
+ nothing writes ``~/.agentos/agents`` anymore. Resolving only locally meant
54
+ every agent the UI could list was unrunnable ("no local agent named …"), for
55
+ department defaults as much as personal ones. A local manifest still wins when
56
+ present, so a hand-written one keeps overriding its backend namesake.
57
+
58
+ ``client`` lets a caller that already has one pass it in rather than having a
59
+ second one built here behind its back.
60
+
61
+ Raises ``LookupError`` naming every place that was checked and why.
62
+ """
63
+ local = _find_agent(name)
64
+ if local is not None:
65
+ return local
66
+
67
+ from sdk.agents.manifest import manifest_from_backend_row
68
+
69
+ if client is None:
70
+ from sdk.client import BackendClient
71
+ client = BackendClient.from_config()
72
+
73
+ reasons: list[str] = []
74
+ for label, attr in (("your agents", "list_custom_agents"),
75
+ ("department defaults", "list_department_agents")):
76
+ fetch = getattr(client, attr, None)
77
+ if fetch is None:
78
+ continue
79
+ try:
80
+ rows = await fetch() or []
81
+ except Exception as exc: # noqa: BLE001 — a fetch failure is not "not found"
82
+ reasons.append(f"{label} could not be fetched ({str(exc) or type(exc).__name__})")
83
+ continue
84
+ for row in rows:
85
+ if row.get("name") == name:
86
+ manifest = manifest_from_backend_row(row)
87
+ if manifest is not None:
88
+ return manifest
89
+ reasons.append(
90
+ f"'{name}' is in your {label} but its prompt is empty, so there "
91
+ f"is no instruction to run — add one in the web app"
92
+ )
93
+
94
+ detail = "; ".join(reasons) if reasons else (
95
+ "not in ~/.agentos/agents/, your backend agents, or your department defaults"
96
+ )
97
+ raise LookupError(f"no agent named '{name}' — {detail}")
52
98
 
53
99
 
54
100
  def _render_conversation(history: list[dict] | None, message: str) -> str:
@@ -87,7 +133,7 @@ async def chat_with_agent(
87
133
  ``files_changed``, ``error``). ``claude_code``/``model`` engines run through
88
134
  the local Claude executor; ``shell`` runs the composed prompt as a command.
89
135
  """
90
- manifest = _find_agent(name)
136
+ manifest = await resolve_agent(name)
91
137
 
92
138
  skills = load_skills()
93
139
  skill_text = resolve_skill_text(manifest, skills)
@@ -164,7 +210,7 @@ async def run_agent(
164
210
  ``{{variable}}`` is left unfilled — the UI gates the Run button on this, and
165
211
  this is the server-side backstop.
166
212
  """
167
- manifest = _find_agent(name)
213
+ manifest = await resolve_agent(name)
168
214
 
169
215
  skills = load_skills()
170
216
  skill_text = resolve_skill_text(manifest, skills)
@@ -0,0 +1,122 @@
1
+ """claude_code engine -- a thin adapter from an agent manifest onto ``sdk.local_exec.run_claude_local``."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable
8
+
9
+ from sdk import config
10
+ from sdk.local_exec import run_claude_local
11
+
12
+ if TYPE_CHECKING:
13
+ from sdk.agents.manifest import AgentManifest
14
+
15
+ logger = logging.getLogger("sdk.agents.engines.claude_code")
16
+
17
+ # Same shape as sdk.local_exec.EventHandler -- kept local so this module doesn't
18
+ # need to reach into local_exec for a type alias.
19
+ EventHandler = Callable[[dict], Awaitable[None]]
20
+
21
+ # Directory every agent without a real code workspace runs in.
22
+ AGENT_RUNS_DIR = Path.home() / ".agentos" / "runs"
23
+
24
+
25
+ def ensure_agent_workspace(agent_name: str) -> Path:
26
+ """The working directory for ``agent_name``, with its Claude settings in place.
27
+
28
+ Returns ``~/.agentos/runs/<agent>`` and makes sure
29
+ ``<that>/.claude/settings.json`` grants the configured tools
30
+ (``config.get_allowed_tools()``) under ``permissions.allow``.
31
+
32
+ Why write it at all when ``run_claude_local`` already passes
33
+ ``--allowedTools``: that flag covers the headless run, but the run directory IS
34
+ a Claude Code project, so anything reading project settings — a resumed
35
+ session, a nested/interactive invocation, a hook — sees the same grants instead
36
+ of falling back to prompting in a place where nobody can answer.
37
+
38
+ MERGES rather than overwrites: existing keys and any extra entries already in
39
+ ``permissions.allow`` are preserved, so a hand-added rule survives, and a tool
40
+ added to the configured list later shows up on the next run. Best-effort — a
41
+ write failure is logged and the run proceeds on ``--allowedTools`` alone.
42
+
43
+ Applies to CUSTOM and DEPARTMENT agents alike: both arrive as prompt-based
44
+ backend rows and both run through here.
45
+ """
46
+ workspace = AGENT_RUNS_DIR / agent_name
47
+ workspace.mkdir(parents=True, exist_ok=True)
48
+
49
+ settings_path = workspace / ".claude" / "settings.json"
50
+ try:
51
+ settings: dict = {}
52
+ if settings_path.exists():
53
+ try:
54
+ loaded = json.loads(settings_path.read_text(encoding="utf-8"))
55
+ if isinstance(loaded, dict):
56
+ settings = loaded
57
+ except (OSError, json.JSONDecodeError) as exc:
58
+ # A corrupt file silently disables ALL settings from it, so replace
59
+ # it rather than leave the agent running with none.
60
+ logger.warning("rewriting unparseable %s: %s", settings_path, exc)
61
+
62
+ permissions = settings.get("permissions")
63
+ if not isinstance(permissions, dict):
64
+ permissions = {}
65
+ allow = [t for t in (permissions.get("allow") or []) if isinstance(t, str)]
66
+ for tool in config.get_allowed_tools():
67
+ if tool not in allow:
68
+ allow.append(tool)
69
+ permissions["allow"] = allow
70
+ settings["permissions"] = permissions
71
+
72
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
73
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
74
+ except OSError as exc:
75
+ logger.warning("could not write %s: %s", settings_path, exc)
76
+
77
+ return workspace
78
+
79
+
80
+ async def run(
81
+ manifest: "AgentManifest",
82
+ skill_text: str,
83
+ mcp_config: dict[str, Any] | None,
84
+ project_path: str | None = None,
85
+ on_event: EventHandler | None = None,
86
+ extra_env: dict[str, str] | None = None,
87
+ ) -> dict[str, Any]:
88
+ """Run a custom agent's manifest through the local ``claude`` CLI.
89
+
90
+ This does not reimplement any of ``run_claude_local``'s subprocess handling,
91
+ stream-json parsing, or crucial-hook logic -- it only assembles the prompt
92
+ and a working directory, then delegates.
93
+
94
+ ``skill_text`` is expected to already be the resolved skill body (see
95
+ ``sdk.agents.manifest.resolve_skill_text``), concatenated by the caller
96
+ where needed. To be safe, the final prompt is (re-)assembled here from
97
+ ``manifest.prompt`` plus, when ``skill_text`` is truthy, two newlines and
98
+ the skill body.
99
+ """
100
+ full_prompt = manifest.prompt
101
+ if skill_text:
102
+ full_prompt = f"{full_prompt}\n\n{skill_text}"
103
+
104
+ if project_path is None:
105
+ # Most custom agents (e.g. "summarise my PRs daily") have no real code
106
+ # workspace -- Claude Code still needs a valid current-working-directory
107
+ # to run in, so fall back to a per-agent scratch directory.
108
+ project_path = str(ensure_agent_workspace(manifest.name))
109
+
110
+ # mcp_config: accepted here for forward compatibility only. Wiring an
111
+ # --mcp-config flag into run_claude_local's subprocess command is a later
112
+ # follow-up, once run_claude_local itself grows an mcp_config parameter --
113
+ # this module does not attempt to work around that in the meantime, so the
114
+ # argument is currently unused.
115
+
116
+ return await run_claude_local(
117
+ prompt=full_prompt,
118
+ project_path=project_path,
119
+ allowed_tools=manifest.allowed_tools or None,
120
+ on_event=on_event,
121
+ extra_env=extra_env,
122
+ )
@@ -23,8 +23,14 @@ INTEGRATIONS_DIR = Path.home() / ".agentos" / "integrations"
23
23
  # Full-string match: ${ENV_VAR_NAME} — no inline secrets allowed in integration env.
24
24
  _ENV_PLACEHOLDER_RE = re.compile(r"^\$\{[A-Za-z_][A-Za-z0-9_]*\}$")
25
25
 
26
- # Agent names: letters, digits, hyphen, underscore only.
27
- _AGENT_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
26
+ # Agent names. Agents are authored in the WEB APP now, where names are free text
27
+ # "AI Research Agent", "Jira & Confluence Project Management Agent". A rule of
28
+ # `[A-Za-z0-9_-]+` rejected every one of them, so a backend agent could not be
29
+ # materialized into a manifest and therefore could not be run at all. Spaces and
30
+ # ordinary punctuation are allowed; what stays banned is anything that could
31
+ # escape a path or break a log line, since the name is used to derive filenames
32
+ # (see agents/secrets.py, varstore.py, run_log.py — all of which sanitize too).
33
+ _AGENT_NAME_RE = re.compile(r"^[^\x00-\x1f/\\<>:\"|?*]+$")
28
34
 
29
35
  # Flags an inline secret like `token: abc123...` in free-text fields while
30
36
  # allowing the ${ENV_VAR} placeholder form. Mirrors the backend
@@ -210,6 +216,57 @@ def assert_secret_free(obj: object, _where: str = "payload") -> None:
210
216
  )
211
217
 
212
218
 
219
+ def manifest_from_backend_row(row: dict) -> AgentManifest | None:
220
+ """Materialize a runnable manifest from a backend prompt-based ``agents`` row.
221
+
222
+ Agents are authored in the web app, so the row — not a local YAML file — is
223
+ what the engine has to run. The row shape (docs/DATABASE_SCHEMA.md §4) is
224
+ ``{name, status, prompt: {system_prompt, variables:[…]}, skills:[{name,
225
+ description, …}]}``; there is no ``engine``/``trigger``/``manifest`` column
226
+ anymore, so those are supplied here:
227
+
228
+ * ``engine="claude_code"`` — the only engine a web-app agent targets.
229
+ * ``trigger=manual`` — the row carries no trigger; scheduled and
230
+ ticket-label dispatch stay a local-manifest feature.
231
+
232
+ Each attached skill's description is folded into the prompt rather than left
233
+ in ``manifest.skills``: skill names on the row refer to backend ``skills``
234
+ rows, which ``resolve_skill_text`` (local ``~/.agentos/skills``) would drop as
235
+ dangling references, silently losing the instruction text.
236
+
237
+ Returns ``None`` if the row cannot make a valid manifest (no name, or an empty
238
+ / too-short prompt), so a malformed row is skipped rather than raising.
239
+ """
240
+ name = str((row or {}).get("name") or "").strip()
241
+ if not name:
242
+ return None
243
+
244
+ prompt_obj = row.get("prompt") or {}
245
+ if not isinstance(prompt_obj, dict):
246
+ prompt_obj = {}
247
+ prompt = str(prompt_obj.get("system_prompt") or "").strip()
248
+
249
+ for skill in row.get("skills") or []:
250
+ if not isinstance(skill, dict):
251
+ continue
252
+ s_name = str(skill.get("name") or "").strip()
253
+ s_body = str(skill.get("description") or "").strip()
254
+ if s_name and s_body:
255
+ prompt = f"{prompt}\n\n## Skill: {s_name}\n\n{s_body}"
256
+
257
+ try:
258
+ return AgentManifest(
259
+ name=name,
260
+ description=prompt[:200],
261
+ engine="claude_code",
262
+ trigger=TriggerSpec(type="manual"),
263
+ prompt=prompt,
264
+ )
265
+ except ValidationError as exc:
266
+ logger.warning("backend agent '%s' is not runnable: %s", name, exc)
267
+ return None
268
+
269
+
213
270
  def load_agents(dir: Path = AGENTS_DIR) -> list[AgentManifest]:
214
271
  """Load every agent manifest under `dir`.
215
272
 
@@ -34,7 +34,15 @@ SECRETS_DIR = Path.home() / ".agentos" / "agent_secrets"
34
34
 
35
35
 
36
36
  def _path(agent_name: str) -> Path:
37
- return SECRETS_DIR / f"{agent_name}.json.enc"
37
+ """Store file for an agent's keys.
38
+
39
+ The name is sanitized because agent names are free text from the web app now
40
+ ("Jira & Confluence Project Management Agent") — an unsanitized name
41
+ containing a path separator would resolve outside SECRETS_DIR. Mirrors
42
+ ``varstore._path`` so the two stores key the same way.
43
+ """
44
+ safe = "".join(c for c in (agent_name or "") if c.isalnum() or c in "-_") or "agent"
45
+ return SECRETS_DIR / f"{safe}.json.enc"
38
46
 
39
47
 
40
48
  def load_secrets(agent_name: str) -> dict[str, str]:
@@ -25,15 +25,19 @@ async def resolve_manifest(
25
25
  ) -> "AgentManifest | None":
26
26
  """Find the agent manifest to run, by ``scope``.
27
27
 
28
- * ``custom`` (default) — a personal agent from the local manifests
29
- (``~/.agentos/agents``).
30
28
  * ``department`` — an ACTIVE department default pulled from the backend
31
- (``list_department_agents``), materialized read-only (never written back),
32
- exactly as ``agentd._load_department_agents`` does.
29
+ (``list_department_agents``) and materialized read-only (never written back).
30
+ * ``custom`` (default) — a local manifest, then the caller's own backend
31
+ agents, then their department defaults, via ``chat.resolve_agent``.
33
32
 
34
- Returns None when no matching agent is found.
33
+ Backend rows are prompt-based (``prompt.system_prompt`` + attached skills);
34
+ the ``manifest`` column this used to read no longer exists on the table, so
35
+ reading it raised ``KeyError`` and made every department agent unrunnable.
36
+
37
+ Returns None when no matching agent is found, so the caller reports it.
35
38
  """
36
- from sdk.agents.manifest import AgentManifest, load_agents
39
+ from sdk.agents.chat import resolve_agent
40
+ from sdk.agents.manifest import manifest_from_backend_row
37
41
 
38
42
  name = (name or "").strip()
39
43
  if scope == "department":
@@ -43,16 +47,13 @@ async def resolve_manifest(
43
47
  return None
44
48
  for row in rows:
45
49
  if row.get("name") == name and row.get("status") == "active":
46
- try:
47
- return AgentManifest(**row["manifest"])
48
- except Exception: # noqa: BLE001 — invalid manifest → not runnable
49
- return None
50
+ return manifest_from_backend_row(row)
50
51
  return None
51
52
 
52
- for m in load_agents():
53
- if m.name == name:
54
- return m
55
- return None
53
+ try:
54
+ return await resolve_agent(name, client)
55
+ except LookupError:
56
+ return None
56
57
 
57
58
 
58
59
  async def run_on_ticket(
@@ -333,35 +333,27 @@ def _agent_run_ticket(name: str, flags: list[str]) -> None:
333
333
 
334
334
 
335
335
  def _agent_run(name: str) -> None:
336
- from sdk.agents.manifest import AgentManifest, load_agents
336
+ """`agentos agent run NAME` — dispatch one run locally.
337
+
338
+ Resolution order is shared with the UI and `agent chat` (see
339
+ ``sdk.agents.chat.resolve_agent``): a local manifest wins, then the caller's
340
+ own backend agents, then their department defaults. It used to check local
341
+ manifests and department rows only — and read a `manifest` column the
342
+ prompt-based `agents` table no longer has — so nothing authored in the web app
343
+ could be run.
344
+ """
345
+ from sdk.agents.chat import resolve_agent
337
346
  from sdk.client import BackendClient
338
347
  from sdk import agentd
339
348
 
340
349
  client = BackendClient.from_config()
341
-
342
- matched = next((m for m in load_agents() if m.name == name), None)
343
- if matched is not None:
344
- asyncio.run(agentd._dispatch_agent_run(matched, client))
345
- return
346
-
347
- # Not a local personal agent -- check this user's department defaults
348
- # (read-only, pulled from the backend; see AgentDaemon._load_department_agents).
349
350
  try:
350
- dept_row = next(
351
- (r for r in asyncio.run(client.list_department_agents()) if r.get("name") == name),
352
- None,
353
- )
354
- except Exception as exc: # noqa: BLE001
355
- print(f"error: could not fetch department agents ({exc})")
356
- sys.exit(1)
357
-
358
- if dept_row is None:
359
- print(f"error: no agent named '{name}' (checked ~/.agentos/agents/ and "
360
- f"this user's department default agents)")
351
+ manifest = asyncio.run(resolve_agent(name, client))
352
+ except LookupError as exc:
353
+ print(f"error: {exc}")
361
354
  sys.exit(1)
362
355
 
363
- dept_manifest = AgentManifest(**dept_row["manifest"])
364
- asyncio.run(agentd._dispatch_agent_run(dept_manifest, client))
356
+ asyncio.run(agentd._dispatch_agent_run(manifest, client))
365
357
 
366
358
 
367
359
  def _agent_logs(name: str) -> None:
@@ -156,20 +156,29 @@ def _dotenv_value(key: str) -> str:
156
156
  _DEFAULT_ALLOWED_IPS: list[str] = ["20.12.201.148"]
157
157
 
158
158
 
159
- def _parse_ip_list(raw: str) -> list[str]:
160
- """Split an IP allowlist string, tolerant of JSON-ish list syntax.
159
+ def _parse_token_list(raw: str) -> list[str]:
160
+ """Split a comma/space-separated list, tolerant of JSON-ish syntax.
161
161
 
162
- Accepts ``"1.2.3.4, 5.6.7.8"``, ``1.2.3.4 5.6.7.8``, or ``["1.2.3.4","5.6.7.8"]``.
162
+ Accepts ``"a, b"``, ``a b``, or ``["a","b"]``. De-duplicates, order-preserving.
163
+ Used for every list-shaped setting (IP allowlist, tool allowlist).
163
164
  """
164
165
  raw = (raw or "").strip().strip("[]")
165
166
  out: list[str] = []
166
167
  for token in re.split(r"[,\s]+", raw):
167
- ip = token.strip().strip('"').strip("'")
168
- if ip and ip not in out:
169
- out.append(ip)
168
+ item = token.strip().strip('"').strip("'")
169
+ if item and item not in out:
170
+ out.append(item)
170
171
  return out
171
172
 
172
173
 
174
+ def _parse_ip_list(raw: str) -> list[str]:
175
+ """Split an IP allowlist string, tolerant of JSON-ish list syntax.
176
+
177
+ Accepts ``"1.2.3.4, 5.6.7.8"``, ``1.2.3.4 5.6.7.8``, or ``["1.2.3.4","5.6.7.8"]``.
178
+ """
179
+ return _parse_token_list(raw)
180
+
181
+
173
182
  def get_allowed_ips() -> list[str]:
174
183
  """Client IPs permitted to reach the ``agentos-ui`` API (``/api/ui/*``).
175
184
 
@@ -201,6 +210,55 @@ def get_allowed_ips() -> list[str]:
201
210
  return out
202
211
 
203
212
 
213
+ # Built-in baseline of tools a headless agent run may use. Kept here (not in
214
+ # local_exec) so it is one configurable setting rather than a literal buried in the
215
+ # spawn code: the set WILL grow, and a new tool must be addable without editing
216
+ # and re-releasing the SDK — see get_allowed_tools().
217
+ _DEFAULT_ALLOWED_TOOLS: list[str] = [
218
+ "Read", "Write", "Edit", "Glob", "Grep", "Bash", "Skill", "WebSearch", "WebFetch",
219
+ ]
220
+
221
+
222
+ def get_allowed_tools() -> list[str]:
223
+ """Tools a headless ``claude -p`` agent run is allowed to use.
224
+
225
+ A headless run cannot answer a permission prompt, so anything missing from
226
+ this list is silently UNAVAILABLE to the agent — which is how a research agent
227
+ ended up reporting it could not search the web. The baseline therefore covers
228
+ the file, shell, skill, and web tools, and everything else is configuration.
229
+
230
+ Sources, merged additively (all optional), each accepting a comma/space-separated
231
+ or bracketed list:
232
+ - the built-in baseline ``_DEFAULT_ALLOWED_TOOLS``
233
+ - ``.env`` key ``AGENTOS_ALLOWED_TOOLS`` (``~/.agentos/.env`` or CWD ``.env``)
234
+ - process env ``AGENTOS_ALLOWED_TOOLS``
235
+ - config.toml key ``allowed_tools``
236
+
237
+ Additive, like ``get_allowed_ips``: naming a tool ANYWHERE adds it, so granting
238
+ a newly-shipped Claude Code tool is a config line on the box, not a code change
239
+ and a release. Read at spawn time, so an edit applies to the next run without
240
+ restarting the daemon or the UI server. MCP/plugin tools are NOT listed here —
241
+ they are discovered per-machine at spawn time (see
242
+ ``local_exec._discover_mcp_allow_entries``).
243
+
244
+ To run with a REDUCED set, pass ``allowed_tools`` explicitly to
245
+ ``local_exec.run_claude_local`` (an agent manifest's ``allowed_tools`` does
246
+ exactly that) — that overrides this list rather than extending it.
247
+ """
248
+ sources = [
249
+ ",".join(_DEFAULT_ALLOWED_TOOLS),
250
+ _dotenv_value("AGENTOS_ALLOWED_TOOLS"),
251
+ os.environ.get("AGENTOS_ALLOWED_TOOLS", ""),
252
+ _load_raw().get("allowed_tools", ""),
253
+ ]
254
+ out: list[str] = []
255
+ for src in sources:
256
+ for tool in _parse_token_list(src):
257
+ if tool not in out:
258
+ out.append(tool)
259
+ return out
260
+
261
+
204
262
  class PermanentAllowlistEntry(ValueError):
205
263
  """Raised when trying to disable an IP that is permanently whitelisted via ``.env``."""
206
264
 
@@ -24,7 +24,7 @@ from dataclasses import dataclass, field
24
24
  from pathlib import Path
25
25
  from typing import Awaitable, Callable
26
26
 
27
- from sdk.local_exec import DEFAULT_ALLOWED_TOOLS, run_claude_local
27
+ from sdk.local_exec import run_claude_local
28
28
 
29
29
  from .bootstrap import ensure_agent_browser
30
30
  from .browser import BrowserError, diff_baseline, render_screenshot
@@ -67,9 +67,10 @@ async def _default_coder(project_path: str, correction_prompt: str) -> None:
67
67
  "unrelated code.\n\n"
68
68
  f"{correction_prompt}"
69
69
  )
70
- result = await run_claude_local(
71
- prompt, project_path, allowed_tools=DEFAULT_ALLOWED_TOOLS
72
- )
70
+ # allowed_tools omitted on purpose: run_claude_local then uses the configured
71
+ # allowlist (config.get_allowed_tools), so this run picks up any tool the box
72
+ # grants instead of a snapshot taken at import.
73
+ result = await run_claude_local(prompt, project_path)
73
74
  if not result.get("ok"):
74
75
  raise RuntimeError(f"correction run failed: {result.get('error')}")
75
76
 
@@ -28,15 +28,22 @@ from datetime import datetime, timedelta
28
28
  from pathlib import Path
29
29
  from typing import Any, Awaitable, Callable
30
30
 
31
- # Default tool allowlist. Bash is included so the agent runs ALL its commands
32
- # locally — git clone, install, build, test, and `gh pr create` for PRs — on the
33
- # user's machine, not the server. ``Skill`` lets the agent invoke installed
34
- # plugin/user Skills (e.g. the Figma skills), which DO load in headless ``-p`` runs
35
- # even when a plugin's MCP server can't authenticate there. MCP/plugin *tools* are
36
- # NOT hard-coded here they are discovered per-machine and appended at spawn time;
37
- # see ``_discover_mcp_allow_entries`` / ``_local_mcp_servers`` and their use in
38
- # ``run_claude_local``.
39
- DEFAULT_ALLOWED_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "Skill"]
31
+ from sdk import config # tool allowlist lives there (config.get_allowed_tools)
32
+
33
+ # The tool allowlist is a SETTING, not a literal here: see
34
+ # ``sdk.config.get_allowed_tools`` for the baseline and the ways to extend it
35
+ # (.env / process env ``AGENTOS_ALLOWED_TOOLS`` / config.toml ``allowed_tools``).
36
+ # It is read at spawn time inside ``run_claude_local`` so a change applies to the
37
+ # next run without a restart, and so granting a newly-shipped Claude Code tool
38
+ # never needs an SDK edit and release.
39
+ #
40
+ # Bash is in the baseline so the agent runs ALL its commands locally — git clone,
41
+ # install, build, test, and `gh pr create` for PRs — on the user's machine, not the
42
+ # server. ``Skill`` lets it invoke installed plugin/user Skills (e.g. the Figma
43
+ # skills), which DO load in headless ``-p`` runs even when a plugin's MCP server
44
+ # can't authenticate there. MCP/plugin *tools* are not configured there either —
45
+ # they are discovered per-machine and appended at spawn time; see
46
+ # ``_discover_mcp_allow_entries`` / ``_local_mcp_servers`` below.
40
47
 
41
48
  # Run limits. The OLD design capped TOTAL runtime at 30 min, which killed long
42
49
  # but healthy runs (big builds, many steps) mid-work. Instead we use an IDLE
@@ -445,7 +452,8 @@ async def run_claude_local(
445
452
  Spawns ``claude -p <prompt> --output-format stream-json …`` with the working
446
453
  directory set to the project so all edits land on the real local files.
447
454
  ``--permission-mode acceptEdits`` lets file edits apply unattended (no
448
- interactive prompt); read/write/edit are the only tools enabled by default.
455
+ interactive prompt). The tools enabled come from ``config.get_allowed_tools()``
456
+ unless ``allowed_tools`` is given, which replaces that list.
449
457
 
450
458
  ``on_event`` (optional, async) is called for each streamed JSON event — used
451
459
  by the sidecar to forward live progress (tool calls, assistant text) to the
@@ -477,7 +485,11 @@ async def run_claude_local(
477
485
  path = Path(project_path).expanduser()
478
486
  path.mkdir(parents=True, exist_ok=True)
479
487
 
480
- tools = list(allowed_tools or DEFAULT_ALLOWED_TOOLS)
488
+ # Read the allowlist per spawn (not at import) so a config/.env change takes
489
+ # effect on the next run — the UI server and agentd are long-lived processes.
490
+ # An explicit `allowed_tools` (e.g. a manifest's) REPLACES it, so an agent can
491
+ # still be restricted to fewer tools than the configured set.
492
+ tools = list(allowed_tools or config.get_allowed_tools())
481
493
  # Append per-server MCP allow-entries so installed+authenticated plugins/MCP
482
494
  # servers are usable in this headless run. Discovered once and cached; no-ops
483
495
  # (adds nothing) when no servers are configured or discovery fails, so a machine