devops-bot-sdk 1.4.117__tar.gz → 1.4.118__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 (90) hide show
  1. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/SOURCES.txt +1 -0
  4. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/pyproject.toml +4 -1
  5. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/__init__.py +3 -3
  6. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/prompt_studio.py +104 -0
  7. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/client.py +84 -1
  8. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/config.py +68 -0
  9. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/api.py +116 -0
  10. devops_bot_sdk-1.4.118/sdk/ui/ip_allowlist.py +110 -0
  11. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/security.py +9 -2
  12. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/server.py +16 -3
  13. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/README.md +0 -0
  14. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  15. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  16. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/requires.txt +0 -0
  17. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  18. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agentd.py +0 -0
  19. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/__init__.py +0 -0
  20. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/builder.py +0 -0
  21. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/chat.py +0 -0
  22. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/connectors.py +0 -0
  23. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/engines/__init__.py +0 -0
  24. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/engines/claude_code.py +0 -0
  25. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/engines/http.py +0 -0
  26. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/engines/mcp.py +0 -0
  27. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/engines/n8n.py +0 -0
  28. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/engines/shell.py +0 -0
  29. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/integrations.py +0 -0
  30. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/inventory.py +0 -0
  31. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/library/__init__.py +0 -0
  32. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/manifest.py +0 -0
  33. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/prompt_template.py +0 -0
  34. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/prompt_vars.py +0 -0
  35. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/run_log.py +0 -0
  36. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/secrets.py +0 -0
  37. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/usage.py +0 -0
  38. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/agents/varstore.py +0 -0
  39. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/cli.py +0 -0
  40. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/collectors/__init__.py +0 -0
  41. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/collectors/files.py +0 -0
  42. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/collectors/process.py +0 -0
  43. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/collectors/screenshot.py +0 -0
  44. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/crucial.py +0 -0
  45. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/design_verify/__init__.py +0 -0
  46. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/design_verify/assets.py +0 -0
  47. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/design_verify/bootstrap.py +0 -0
  48. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/design_verify/browser.py +0 -0
  49. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/design_verify/compare.py +0 -0
  50. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/design_verify/loop.py +0 -0
  51. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/exceptions.py +0 -0
  52. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/git_ops.py +0 -0
  53. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/graphify.py +0 -0
  54. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/hooks/__init__.py +0 -0
  55. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/hooks/crucial_guard.py +0 -0
  56. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ipc/__init__.py +0 -0
  57. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ipc/electron_bridge.py +0 -0
  58. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ipc/handlers.py +0 -0
  59. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/local_exec.py +0 -0
  60. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/models/__init__.py +0 -0
  61. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/models/envelope.py +0 -0
  62. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/models/requests.py +0 -0
  63. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/models/responses.py +0 -0
  64. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/models/snapshots.py +0 -0
  65. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/platform_compat.py +0 -0
  66. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/py.typed +0 -0
  67. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/run_auto.py +0 -0
  68. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/sse.py +0 -0
  69. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/test.py +0 -0
  70. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/test_pipeline.py +0 -0
  71. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/__init__.py +0 -0
  72. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/dashboard.py +0 -0
  73. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/jobs.py +0 -0
  74. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/runner.py +0 -0
  75. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/service.py +0 -0
  76. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/setup_manager.py +0 -0
  77. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/static/agents.html +0 -0
  78. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/static/app.js +0 -0
  79. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/static/custom-agents.html +0 -0
  80. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/static/default-agents.html +0 -0
  81. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/static/index.html +0 -0
  82. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/static/prompt-studio.html +0 -0
  83. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/static/runs.html +0 -0
  84. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/static/setup.html +0 -0
  85. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/static/styles.css +0 -0
  86. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/ui/static/tickets.html +0 -0
  87. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/sdk/updater.py +0 -0
  88. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/setup.cfg +0 -0
  89. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/tests/test_agent_chat_features.py +0 -0
  90. {devops_bot_sdk-1.4.117 → devops_bot_sdk-1.4.118}/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.117
3
+ Version: 1.4.118
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.117
3
+ Version: 1.4.118
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
@@ -67,6 +67,7 @@ sdk/models/snapshots.py
67
67
  sdk/ui/__init__.py
68
68
  sdk/ui/api.py
69
69
  sdk/ui/dashboard.py
70
+ sdk/ui/ip_allowlist.py
70
71
  sdk/ui/jobs.py
71
72
  sdk/ui/runner.py
72
73
  sdk/ui/security.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.117"
7
+ dynamic = ["version"] # single source of truth: sdk/__init__.py __version__
8
8
  description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -49,6 +49,9 @@ agentos-ui = "sdk.ui.server:main" # local web control panel (needs the
49
49
  Homepage = "https://agentos.io"
50
50
  Repository = "https://github.com/consultancy-outfit/DevOpsBot-SDK"
51
51
 
52
+ [tool.setuptools.dynamic]
53
+ version = { attr = "sdk.__version__" } # bump sdk/__init__.py → wheel + client + server all follow
54
+
52
55
  [tool.setuptools.packages.find]
53
56
  where = ["."]
54
57
  include = ["sdk*"]
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.117
3
+ Version: 1.4.118
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,8 +30,8 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.117"
34
- __author__ = "AgentOS"
33
+ __version__ = "1.4.118" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
34
+ __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
35
35
 
36
36
  from sdk.client import BackendClient
37
37
  from sdk.exceptions import (
@@ -92,6 +92,110 @@ def build_comments_prompt(system_prompt: str) -> str:
92
92
  return COMMENTS_INSTRUCTION.replace("__SYSTEM_PROMPT__", system_prompt.strip())
93
93
 
94
94
 
95
+ # ── Generate: name + type + job → a full system prompt with {{variables}} ────
96
+ # This is the claude_code engine's authoring instruction. It runs LOCALLY in the
97
+ # SDK (run_claude_local); the AI backend calls this SDK endpoint on the VPS.
98
+
99
+ GENERATE_INSTRUCTION = (
100
+ "You are an expert prompt engineer. Write the SYSTEM PROMPT for a reusable AI "
101
+ "agent, given its NAME, TYPE, and main JOB. Produce a single, self-contained "
102
+ "prompt that follows EXACTLY this structure and Markdown headings:\n"
103
+ "# Role\nYou are <NAME>, <one-line identity from the type and job>.\n"
104
+ "## Objective\n<one paragraph: the single outcome this agent must produce>\n"
105
+ "## Rules\n- <hard constraints and guardrails>\n"
106
+ "- Never embed secrets; reference credentials as ${ENV_VAR} placeholders.\n"
107
+ "## Workflow\n1. <ordered steps to do the job>\n"
108
+ "## Output\n<exactly what the agent returns and in what format>\n\n"
109
+ "VARIABLE RULE (critical): any value the user must supply AT RUN TIME must "
110
+ "appear in the prompt text as {{variable_name}} (double curly braces, "
111
+ "snake_case). Only create a variable when the job genuinely needs a per-run "
112
+ "input; a self-contained agent may have NONE. Do not invent placeholders for "
113
+ "things the agent can work out itself.\n"
114
+ "Return ONLY a single minified JSON object, no prose, of the exact shape:\n"
115
+ '{"system_prompt":"<full prompt text with \\n line breaks and {{vars}} inline>",'
116
+ '"variables":[{"name":"","label":"","description":"","type":"string"}]}\n'
117
+ "Every {{variable}} in system_prompt MUST have a matching entry in variables, "
118
+ "and vice-versa. If there are none, return \"variables\": [].\n\n"
119
+ "AGENT NAME: __NAME__\nAGENT TYPE: __TYPE__\nMAIN JOB: __JOB____CONTEXT__"
120
+ )
121
+
122
+ REGENERATE_INSTRUCTION = (
123
+ "You are an expert prompt engineer. REGENERATE and improve the EXISTING system "
124
+ "prompt below. The same rules as a fresh prompt apply: keep the exact structure "
125
+ "(# Role / ## Objective / ## Rules / ## Workflow / ## Output), keep "
126
+ "{{variable_name}} placeholders for every per-run input, and keep every "
127
+ "{{variable}} and the variables list in sync. Preserve the original intent and "
128
+ "any working variables; fix structure, clarity, missing sections, weak rules, "
129
+ "and vague output specs. Apply the FEEDBACK if given.\n"
130
+ "Return ONLY a single minified JSON object of the exact shape:\n"
131
+ '{"system_prompt":"...","variables":[{"name":"","label":"","description":"","type":"string"}]}\n\n'
132
+ "__META__FEEDBACK: __FEEDBACK__\n\nEXISTING SYSTEM PROMPT:\n```\n__PROMPT__\n```"
133
+ )
134
+
135
+
136
+ def build_generate_prompt(agent_name: str, agent_type: str, job: str,
137
+ context: str = "") -> str:
138
+ ctx = f"\nADDITIONAL CONTEXT: {context.strip()}" if context and context.strip() else ""
139
+ return (
140
+ GENERATE_INSTRUCTION
141
+ .replace("__NAME__", (agent_name or "").strip())
142
+ .replace("__TYPE__", (agent_type or "").strip())
143
+ .replace("__JOB__", (job or "").strip())
144
+ .replace("__CONTEXT__", ctx)
145
+ )
146
+
147
+
148
+ def build_regenerate_prompt(system_prompt: str, feedback: str = "",
149
+ agent_name: str = "", agent_type: str = "",
150
+ job: str = "") -> str:
151
+ meta_bits = []
152
+ if (agent_name or "").strip():
153
+ meta_bits.append(f"AGENT NAME: {agent_name.strip()}")
154
+ if (agent_type or "").strip():
155
+ meta_bits.append(f"AGENT TYPE: {agent_type.strip()}")
156
+ if (job or "").strip():
157
+ meta_bits.append(f"MAIN JOB: {job.strip()}")
158
+ meta = ("\n".join(meta_bits) + "\n") if meta_bits else ""
159
+ return (
160
+ REGENERATE_INSTRUCTION
161
+ .replace("__META__", meta)
162
+ .replace("__FEEDBACK__", (feedback or "").strip() or "(none — just improve quality)")
163
+ .replace("__PROMPT__", (system_prompt or "").strip())
164
+ )
165
+
166
+
167
+ def parse_generation(model_output: str) -> dict:
168
+ """Parse a generate/regenerate model reply into ``{system_prompt, variables}``.
169
+
170
+ Placeholders present in the produced prompt are authoritative for the variable
171
+ LIST (so prompt ↔ schema can't drift); the model's ``variables`` array only
172
+ supplies each one's label/description/type. Tolerant of fences/prose.
173
+ """
174
+ from sdk.agents.prompt_vars import extract_variables
175
+
176
+ data = _extract_json(model_output)
177
+ system_prompt = str(data.get("system_prompt", "")).strip() if isinstance(data, dict) else ""
178
+
179
+ meta: dict[str, dict] = {}
180
+ for v in (data.get("variables", []) if isinstance(data, dict) else []):
181
+ if isinstance(v, dict) and str(v.get("name", "")).strip():
182
+ meta[str(v["name"]).strip()] = v
183
+ elif isinstance(v, str) and v.strip():
184
+ meta[v.strip()] = {"name": v.strip()}
185
+
186
+ variables = []
187
+ for name in extract_variables(system_prompt):
188
+ m = meta.get(name, {})
189
+ vtype = str(m.get("type", "string")).strip() or "string"
190
+ variables.append({
191
+ "name": name,
192
+ "label": str(m.get("label") or name.replace("_", " ").title()).strip(),
193
+ "description": str(m.get("description", "")).strip(),
194
+ "type": vtype,
195
+ })
196
+ return {"system_prompt": system_prompt, "variables": variables}
197
+
198
+
95
199
  def _extract_json(model_output: str) -> dict:
96
200
  text = (model_output or "").strip()
97
201
  if "```" in text:
@@ -59,7 +59,7 @@ def _notify_status(task_id: str, status: str) -> None:
59
59
  pass
60
60
 
61
61
 
62
- SDK_VERSION = "1.4.116"
62
+ from sdk import __version__ as SDK_VERSION # single source: sdk/__init__.py
63
63
  _POLL_INTERVAL = 3.0
64
64
  _POLL_TIMEOUT = 600.0
65
65
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -1119,6 +1119,89 @@ class BackendClient:
1119
1119
  _check_status(resp.status_code, self._base_url)
1120
1120
  return resp.json()
1121
1121
 
1122
+ # ── Agent prompt generation ───────────────────────────────────────
1123
+
1124
+ async def generate_agent_prompt(
1125
+ self,
1126
+ agent_name: str,
1127
+ agent_type: str,
1128
+ job: str,
1129
+ context: str = "",
1130
+ ) -> dict:
1131
+ """POST /api/v1/agent-prompt/generate — draft a system prompt for an agent.
1132
+
1133
+ Given the agent's ``name``, ``type``, and main ``job`` (plain language), the
1134
+ backend returns a structured system prompt (Role/Objective/Rules/Workflow/
1135
+ Output) with ``{{variable}}`` placeholders for any runtime inputs, plus the
1136
+ extracted variable list. Shape::
1137
+
1138
+ {"agent_name": str, "agent_type": str, "system_prompt": str,
1139
+ "variables": [{"name","label","description","type"}], "model": str}
1140
+
1141
+ The ``{{variable}}`` convention matches ``sdk.agents.prompt_vars`` so the
1142
+ result feeds straight into the no-code builder / Prompt Studio.
1143
+ """
1144
+ url = self._url("/api/v1/agent-prompt/generate")
1145
+ payload = {
1146
+ "agent_name": agent_name,
1147
+ "agent_type": agent_type,
1148
+ "job": job,
1149
+ "context": context,
1150
+ }
1151
+ async with httpx.AsyncClient(timeout=60.0) as client:
1152
+ resp = await client.post(url, json=payload, headers=self._headers)
1153
+ if resp.status_code == 404:
1154
+ raise BackendFeatureMissing("Agent prompt generation", url)
1155
+ _check_status(resp.status_code, self._base_url)
1156
+ return resp.json()
1157
+
1158
+ async def regenerate_agent_prompt(
1159
+ self,
1160
+ system_prompt: str,
1161
+ feedback: str = "",
1162
+ agent_name: str = "",
1163
+ agent_type: str = "",
1164
+ job: str = "",
1165
+ ) -> dict:
1166
+ """POST /api/v1/agent-prompt/regenerate — improve an existing prompt.
1167
+
1168
+ Same response shape as :meth:`generate_agent_prompt`. ``feedback`` steers
1169
+ the rewrite; ``agent_name``/``agent_type``/``job`` are optional context.
1170
+ """
1171
+ url = self._url("/api/v1/agent-prompt/regenerate")
1172
+ payload = {
1173
+ "system_prompt": system_prompt,
1174
+ "feedback": feedback,
1175
+ "agent_name": agent_name,
1176
+ "agent_type": agent_type,
1177
+ "job": job,
1178
+ }
1179
+ async with httpx.AsyncClient(timeout=60.0) as client:
1180
+ resp = await client.post(url, json=payload, headers=self._headers)
1181
+ if resp.status_code == 404:
1182
+ raise BackendFeatureMissing("Agent prompt regeneration", url)
1183
+ _check_status(resp.status_code, self._base_url)
1184
+ return resp.json()
1185
+
1186
+ async def verify_agent_prompt(self, system_prompt: str) -> dict:
1187
+ """POST /api/v1/agent-prompt/verify — manual-trigger verification.
1188
+
1189
+ For prompts a user wrote by hand: checks the prompt against our standards
1190
+ (Role/Objective/Rules/Workflow/Output) and extracts variables + secrets for
1191
+ the next builder step. Shape::
1192
+
1193
+ {"valid": bool, "missing_required": [...], "missing_recommended": [...],
1194
+ "variables": [{"name","label","description","type"}],
1195
+ "secrets": [{"name","description"}]}
1196
+ """
1197
+ url = self._url("/api/v1/agent-prompt/verify")
1198
+ async with httpx.AsyncClient(timeout=60.0) as client:
1199
+ resp = await client.post(url, json={"system_prompt": system_prompt}, headers=self._headers)
1200
+ if resp.status_code == 404:
1201
+ raise BackendFeatureMissing("Agent prompt verification", url)
1202
+ _check_status(resp.status_code, self._base_url)
1203
+ return resp.json()
1204
+
1122
1205
  # ── Custom agents ─────────────────────────────────────────────────
1123
1206
 
1124
1207
  async def register_custom_agent(self, manifest: dict) -> dict:
@@ -13,6 +13,7 @@ import base64
13
13
  import hashlib
14
14
  import os
15
15
  import platform
16
+ import re
16
17
  import sys
17
18
  from pathlib import Path
18
19
  from typing import Any
@@ -122,6 +123,73 @@ def get_base_url() -> str:
122
123
  return load().get("base_url", AI_DEFAULT_URL)
123
124
 
124
125
 
126
+ def _dotenv_value(key: str) -> str:
127
+ """Read ``key`` from a ``.env`` file — dependency-free (no python-dotenv).
128
+
129
+ Looks in ``~/.agentos/.env`` first (where AgentOS lives on the VPS), then a
130
+ ``.env`` in the current working directory. Returns "" if not found. Values may
131
+ be quoted; surrounding quotes are stripped.
132
+ """
133
+ for path in (_CONFIG_DIR / ".env", Path.cwd() / ".env"):
134
+ try:
135
+ if not path.exists():
136
+ continue
137
+ for line in path.read_text().splitlines():
138
+ line = line.strip()
139
+ if not line or line.startswith("#") or "=" not in line:
140
+ continue
141
+ k, _, v = line.partition("=")
142
+ if k.strip() == key:
143
+ return v.strip().strip('"').strip("'")
144
+ except Exception: # noqa: BLE001 — a malformed .env must never break startup
145
+ continue
146
+ return ""
147
+
148
+
149
+ def _parse_ip_list(raw: str) -> list[str]:
150
+ """Split an IP allowlist string, tolerant of JSON-ish list syntax.
151
+
152
+ 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"]``.
153
+ """
154
+ raw = (raw or "").strip().strip("[]")
155
+ out: list[str] = []
156
+ for token in re.split(r"[,\s]+", raw):
157
+ ip = token.strip().strip('"').strip("'")
158
+ if ip and ip not in out:
159
+ out.append(ip)
160
+ return out
161
+
162
+
163
+ def get_allowed_ips() -> list[str]:
164
+ """Client IPs permitted to reach the ``agentos-ui`` API (``/api/ui/*``).
165
+
166
+ This is the allowlist for the *inbound* half of the two-way link: the AI
167
+ backend (and only it) may call back into the SDK server. Loopback is always
168
+ trusted separately — this list is for the AI-backend IP(s) you whitelist.
169
+
170
+ Sources, merged (all optional), each accepting a comma/space-separated or
171
+ bracketed list of strings:
172
+ - ``.env`` key ``AGENTOS_UI_ALLOWED_IPS`` (``~/.agentos/.env`` or CWD ``.env``)
173
+ - process env ``AGENTOS_UI_ALLOWED_IPS``
174
+ - config.toml key ``allowed_ips``
175
+
176
+ Returns a de-duplicated list (empty means "loopback only"). Supplying the
177
+ AI-backend IP here later is all that's needed to open the link — no code
178
+ change. CIDR/prefix entries are supported by the middleware.
179
+ """
180
+ sources = [
181
+ _dotenv_value("AGENTOS_UI_ALLOWED_IPS"),
182
+ os.environ.get("AGENTOS_UI_ALLOWED_IPS", ""),
183
+ _load_raw().get("allowed_ips", ""),
184
+ ]
185
+ out: list[str] = []
186
+ for src in sources:
187
+ for ip in _parse_ip_list(src):
188
+ if ip not in out:
189
+ out.append(ip)
190
+ return out
191
+
192
+
125
193
  def get_branch_name_template() -> str | None:
126
194
  """Org-wide branch-naming convention, if configured in config.toml.
127
195
 
@@ -837,6 +837,122 @@ async def prompt_run(body: dict) -> dict:
837
837
  return {"job_id": jobs.start(work())}
838
838
 
839
839
 
840
+ # ── Agent prompt generation (claude_code engine, runs LOCALLY on this VPS) ───
841
+ # The AI backend calls these over the IP-allowlisted /api/ui surface; the response
842
+ # is produced by the user's own authenticated `claude` CLI on this machine — no
843
+ # server-side LLM. Synchronous (server-to-server): the caller awaits the result.
844
+
845
+ async def _run_prompt_engine(prompt: str) -> str:
846
+ """Run Claude Code locally for a pure text/JSON authoring task, return its text."""
847
+ from pathlib import Path
848
+
849
+ from sdk.local_exec import run_claude_local
850
+
851
+ result = await run_claude_local(
852
+ prompt, str(Path.home() / ".agentos"), allowed_tools=["Read"],
853
+ )
854
+ if not result.get("ok"):
855
+ raise HTTPException(status_code=502, detail=result.get("error") or "generation failed")
856
+ return result.get("result", "") or ""
857
+
858
+
859
+ @router.post("/agent-prompt/generate", dependencies=[_auth])
860
+ async def agent_prompt_generate(body: dict) -> dict:
861
+ """name + type + job → a structured system prompt + {{variables}} (claude_code).
862
+
863
+ Body: ``agent_name``, ``agent_type``, ``job``, optional ``context``.
864
+ Returns ``{agent_name, agent_type, system_prompt, variables[], engine}``.
865
+ """
866
+ from sdk.agents.prompt_studio import build_generate_prompt, parse_generation
867
+
868
+ agent_name = str(body.get("agent_name", "")).strip()
869
+ agent_type = str(body.get("agent_type", "")).strip()
870
+ job = str(body.get("job", "")).strip()
871
+ if not agent_name or not agent_type or not job:
872
+ raise HTTPException(status_code=400, detail="agent_name, agent_type, and job are required.")
873
+
874
+ text = await _run_prompt_engine(
875
+ build_generate_prompt(agent_name, agent_type, job, str(body.get("context", "")))
876
+ )
877
+ parsed = parse_generation(text)
878
+ if not parsed["system_prompt"]:
879
+ raise HTTPException(status_code=502, detail="generation returned no system_prompt")
880
+ return {"agent_name": agent_name, "agent_type": agent_type, "engine": "claude_code", **parsed}
881
+
882
+
883
+ @router.post("/agent-prompt/regenerate", dependencies=[_auth])
884
+ async def agent_prompt_regenerate(body: dict) -> dict:
885
+ """Improve an existing prompt (optional feedback) → prompt + {{variables}}.
886
+
887
+ Body: ``system_prompt``, optional ``feedback``, ``agent_name``, ``agent_type``,
888
+ ``job``. Same response shape as /agent-prompt/generate.
889
+ """
890
+ from sdk.agents.prompt_studio import build_regenerate_prompt, parse_generation
891
+
892
+ system_prompt = str(body.get("system_prompt", "")).strip()
893
+ if not system_prompt:
894
+ raise HTTPException(status_code=400, detail="system_prompt is required.")
895
+
896
+ text = await _run_prompt_engine(build_regenerate_prompt(
897
+ system_prompt, str(body.get("feedback", "")),
898
+ str(body.get("agent_name", "")), str(body.get("agent_type", "")), str(body.get("job", "")),
899
+ ))
900
+ parsed = parse_generation(text)
901
+ if not parsed["system_prompt"]:
902
+ raise HTTPException(status_code=502, detail="regeneration returned no system_prompt")
903
+ return {"agent_name": str(body.get("agent_name", "")).strip(),
904
+ "agent_type": str(body.get("agent_type", "")).strip(),
905
+ "engine": "claude_code", **parsed}
906
+
907
+
908
+ @router.post("/agent-prompt/verify", dependencies=[_auth])
909
+ async def agent_prompt_verify(body: dict) -> dict:
910
+ """Manual-trigger flow: verify a user-written prompt, then extract vars + secrets.
911
+
912
+ Standards check is deterministic (our Role/Objective/Rules/Workflow/Output
913
+ template); secrets + variable metadata are inferred by Claude Code locally.
914
+ Body: ``system_prompt`` → ``{valid, missing_required, missing_recommended,
915
+ variables[], secrets[]}``.
916
+ """
917
+ from sdk.agents.prompt_studio import build_analyze_prompt, parse_analysis
918
+ from sdk.agents.prompt_template import check_system_prompt
919
+ from sdk.agents.prompt_vars import extract_variables
920
+
921
+ system_prompt = str(body.get("system_prompt", "")).strip()
922
+ if len(system_prompt) < 10:
923
+ raise HTTPException(status_code=400, detail="Write a system prompt first (a bit longer).")
924
+
925
+ standards = check_system_prompt(system_prompt)
926
+
927
+ # Enrichment (secrets + variable metadata) via local Claude Code — best-effort:
928
+ # a failure must not sink the deterministic verdict.
929
+ analysis: dict = {"inputs": [], "secrets": []}
930
+ try:
931
+ analysis = parse_analysis(await _run_prompt_engine(build_analyze_prompt(system_prompt)))
932
+ except HTTPException:
933
+ pass
934
+
935
+ meta = {str(i.get("name", "")).strip(): i for i in analysis.get("inputs", []) if isinstance(i, dict)}
936
+ variables = []
937
+ for name in extract_variables(system_prompt): # {{placeholders}} are authoritative
938
+ m = meta.get(name, {})
939
+ variables.append({
940
+ "name": name,
941
+ "label": str(m.get("label") or name.replace("_", " ").title()).strip(),
942
+ "description": str(m.get("description", "")).strip(),
943
+ "type": str(m.get("type", "string")).strip() or "string",
944
+ })
945
+
946
+ return {
947
+ "valid": not standards["missing_required"],
948
+ "missing_required": standards["missing_required"],
949
+ "missing_recommended": standards["missing_recommended"],
950
+ "variables": variables,
951
+ "secrets": analysis.get("secrets", []),
952
+ "engine": "claude_code",
953
+ }
954
+
955
+
840
956
  @router.get("/agents/form", dependencies=[_auth])
841
957
  async def agent_form(name: str = Query(...)) -> dict:
842
958
  """The saved Studio form (inputs/outputs/tools) for an agent, if any."""
@@ -0,0 +1,110 @@
1
+ """IP allowlist for the ``agentos-ui`` server — the inbound half of the two-way link.
2
+
3
+ The ENTIRE server (including ``/docs``, ``/openapi.json``, ``/healthz``, the panel
4
+ UI, and every ``/api/ui/*`` call) is reachable ONLY from:
5
+ - the machine it runs on — loopback (127.0.0.0/8, ::1) AND the host's own IP
6
+ addresses, so hitting it via the box's public IP works too;
7
+ - any IP/CIDR in ``AGENTOS_UI_ALLOWED_IPS`` or the config.toml ``allowed_ips``
8
+ key (see ``sdk.config.get_allowed_ips``) — i.e. the AI-backend IP you whitelist.
9
+
10
+ Every other client gets ``403`` for every path — nothing is public.
11
+
12
+ Behind a reverse proxy the direct peer is the proxy, not the real client. Set
13
+ ``AGENTOS_UI_TRUST_FORWARDED_FOR=1`` to instead honour the left-most
14
+ ``X-Forwarded-For`` entry — enable ONLY when a trusted proxy sets that header,
15
+ since clients can otherwise spoof it.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import ipaddress
20
+ import os
21
+ import socket
22
+
23
+
24
+ def _loopback(ip: ipaddress._BaseAddress) -> bool:
25
+ return ip.is_loopback
26
+
27
+
28
+ def _parse(value: str):
29
+ """Parse an IP or CIDR string into a network, or None if malformed."""
30
+ value = (value or "").strip()
31
+ if not value:
32
+ return None
33
+ try:
34
+ # strict=False so a bare host like "10.0.0.5" or "10.0.0.0/24" both work.
35
+ return ipaddress.ip_network(value, strict=False)
36
+ except ValueError:
37
+ return None
38
+
39
+
40
+ def _local_ips() -> set[str]:
41
+ """Best-effort set of THIS host's own IP addresses.
42
+
43
+ Lets the box reach the server through its own public/LAN IP (not just
44
+ 127.0.0.1) — "the system where it is installed" — without whitelisting it.
45
+ """
46
+ ips: set[str] = set()
47
+ try:
48
+ for info in socket.getaddrinfo(socket.gethostname(), None):
49
+ ips.add(info[4][0])
50
+ except Exception: # noqa: BLE001
51
+ pass
52
+ # The primary outbound IP (covers the usual public/LAN address even when the
53
+ # hostname doesn't resolve to it).
54
+ try:
55
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
56
+ s.connect(("8.8.8.8", 80))
57
+ ips.add(s.getsockname()[0])
58
+ s.close()
59
+ except Exception: # noqa: BLE001
60
+ pass
61
+ return ips
62
+
63
+
64
+ from starlette.middleware.base import BaseHTTPMiddleware
65
+ from starlette.requests import Request
66
+ from starlette.responses import JSONResponse
67
+
68
+
69
+ class IPAllowlistMiddleware(BaseHTTPMiddleware):
70
+ """Reject EVERY request from clients that aren't loopback, this host, or allowlisted."""
71
+
72
+ def __init__(self, app, allowed: list[str] | None = None) -> None:
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:
77
+ try:
78
+ from sdk.config import get_allowed_ips
79
+ allowed = get_allowed_ips()
80
+ 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")
86
+
87
+ def _client_ip(self, request: Request) -> str:
88
+ if self._trust_xff:
89
+ xff = request.headers.get("x-forwarded-for", "")
90
+ if xff:
91
+ return xff.split(",")[0].strip()
92
+ return (request.client.host if request.client else "") or ""
93
+
94
+ def _permitted(self, raw_ip: str) -> bool:
95
+ try:
96
+ ip = ipaddress.ip_address(raw_ip)
97
+ except ValueError:
98
+ return False
99
+ if _loopback(ip):
100
+ return True
101
+ return any(ip in net for net in self._networks)
102
+
103
+ async def dispatch(self, request: Request, call_next):
104
+ # Guard EVERY path — nothing on this server is public.
105
+ if not self._permitted(self._client_ip(request)):
106
+ return JSONResponse(
107
+ {"detail": "Your IP is not allowed to access this server."},
108
+ status_code=403,
109
+ )
110
+ return await call_next(request)
@@ -44,8 +44,15 @@ def token_matches(candidate: str | None) -> bool:
44
44
  def extract_token(headers, query_params) -> str | None:
45
45
  """Pull the token from an Authorization/X-header or the ?token= query param."""
46
46
  auth = headers.get("authorization") or headers.get("Authorization")
47
- if auth and auth.lower().startswith("bearer "):
48
- return auth[7:].strip()
47
+ if auth:
48
+ a = auth.strip()
49
+ if a.lower().startswith("bearer "):
50
+ return a[7:].strip()
51
+ # Raw ``co_<uuid>_<hex>`` apiTokenHash with no ``Bearer`` prefix — the form
52
+ # BackendClient and the devops-bot-ai proxy send. Accept it directly so the
53
+ # same token works against both the AI backend and this SDK server.
54
+ if a.startswith("co_"):
55
+ return a
49
56
  hdr = headers.get("x-agentos-ui-token") or headers.get("X-AgentOS-UI-Token")
50
57
  if hdr:
51
58
  return hdr.strip()
@@ -29,8 +29,17 @@ def create_app():
29
29
 
30
30
  from sdk.ui import dashboard
31
31
  from sdk.ui.api import router as api_router
32
-
33
- app = FastAPI(title="AgentOS Control Panel", docs_url=None, redoc_url=None)
32
+ from sdk.ui.ip_allowlist import IPAllowlistMiddleware
33
+
34
+ # Swagger at /docs (ReDoc /redoc, schema /openapi.json). NOTE: the whole server
35
+ # — docs included — is locked by the IP allowlist below; only this host and
36
+ # whitelisted IPs can reach ANY path (data calls also need the access token).
37
+ from sdk import __version__ as _sdk_version
38
+ app = FastAPI(title="AgentOS SDK", version=_sdk_version, docs_url="/docs", redoc_url="/redoc")
39
+ # Inbound half of the two-way link: only loopback, this host's own IPs, and
40
+ # whitelisted IPs (the AI backend) may reach the server AT ALL. Configure via
41
+ # AGENTOS_UI_ALLOWED_IPS or the config.toml `allowed_ips` key.
42
+ app.add_middleware(IPAllowlistMiddleware)
34
43
  app.include_router(api_router)
35
44
 
36
45
  @app.get("/")
@@ -133,7 +142,11 @@ def main() -> None:
133
142
  pass
134
143
 
135
144
  import uvicorn
136
- uvicorn.run(create_app(), host=args.host, port=args.port, log_level="warning")
145
+ # proxy_headers=False: the IP allowlist must decide on the REAL TCP peer, not a
146
+ # client-supplied X-Forwarded-For. XFF is honoured only when the operator opts
147
+ # in via AGENTOS_UI_TRUST_FORWARDED_FOR (handled in IPAllowlistMiddleware).
148
+ uvicorn.run(create_app(), host=args.host, port=args.port,
149
+ log_level="warning", proxy_headers=False)
137
150
 
138
151
 
139
152
  if __name__ == "__main__":