devops-bot-sdk 1.4.116__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.116 → devops_bot_sdk-1.4.118}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
  4. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/pyproject.toml +4 -1
  5. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/__init__.py +3 -3
  6. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agentd.py +6 -0
  7. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/prompt_studio.py +104 -0
  8. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/client.py +84 -1
  9. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/config.py +68 -0
  10. devops_bot_sdk-1.4.118/sdk/platform_compat.py +48 -0
  11. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/run_auto.py +6 -0
  12. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/api.py +116 -0
  13. devops_bot_sdk-1.4.118/sdk/ui/ip_allowlist.py +110 -0
  14. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/security.py +9 -2
  15. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/server.py +16 -3
  16. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/static/app.js +52 -11
  17. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/static/prompt-studio.html +3 -2
  18. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/README.md +0 -0
  19. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  20. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  21. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/requires.txt +0 -0
  22. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  23. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/__init__.py +0 -0
  24. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/builder.py +0 -0
  25. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/chat.py +0 -0
  26. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/connectors.py +0 -0
  27. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/engines/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/engines/claude_code.py +0 -0
  29. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/engines/http.py +0 -0
  30. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/engines/mcp.py +0 -0
  31. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/engines/n8n.py +0 -0
  32. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/engines/shell.py +0 -0
  33. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/integrations.py +0 -0
  34. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/inventory.py +0 -0
  35. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/library/__init__.py +0 -0
  36. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/manifest.py +0 -0
  37. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/prompt_template.py +0 -0
  38. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/prompt_vars.py +0 -0
  39. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/run_log.py +0 -0
  40. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/secrets.py +0 -0
  41. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/usage.py +0 -0
  42. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/agents/varstore.py +0 -0
  43. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/cli.py +0 -0
  44. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/collectors/__init__.py +0 -0
  45. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/collectors/files.py +0 -0
  46. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/collectors/process.py +0 -0
  47. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/collectors/screenshot.py +0 -0
  48. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/crucial.py +0 -0
  49. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/design_verify/__init__.py +0 -0
  50. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/design_verify/assets.py +0 -0
  51. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/design_verify/bootstrap.py +0 -0
  52. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/design_verify/browser.py +0 -0
  53. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/design_verify/compare.py +0 -0
  54. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/design_verify/loop.py +0 -0
  55. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/exceptions.py +0 -0
  56. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/git_ops.py +0 -0
  57. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/graphify.py +0 -0
  58. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/hooks/__init__.py +0 -0
  59. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/hooks/crucial_guard.py +0 -0
  60. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ipc/__init__.py +0 -0
  61. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ipc/electron_bridge.py +0 -0
  62. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ipc/handlers.py +0 -0
  63. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/local_exec.py +0 -0
  64. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/models/__init__.py +0 -0
  65. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/models/envelope.py +0 -0
  66. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/models/requests.py +0 -0
  67. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/models/responses.py +0 -0
  68. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/models/snapshots.py +0 -0
  69. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/py.typed +0 -0
  70. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/sse.py +0 -0
  71. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/test.py +0 -0
  72. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/test_pipeline.py +0 -0
  73. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/__init__.py +0 -0
  74. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/dashboard.py +0 -0
  75. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/jobs.py +0 -0
  76. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/runner.py +0 -0
  77. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/service.py +0 -0
  78. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/setup_manager.py +0 -0
  79. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/static/agents.html +0 -0
  80. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/static/custom-agents.html +0 -0
  81. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/static/default-agents.html +0 -0
  82. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/static/index.html +0 -0
  83. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/static/runs.html +0 -0
  84. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/static/setup.html +0 -0
  85. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/static/styles.css +0 -0
  86. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/ui/static/tickets.html +0 -0
  87. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/sdk/updater.py +0 -0
  88. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/setup.cfg +0 -0
  89. {devops_bot_sdk-1.4.116 → devops_bot_sdk-1.4.118}/tests/test_agent_chat_features.py +0 -0
  90. {devops_bot_sdk-1.4.116 → 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.116
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.116
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
@@ -16,6 +16,7 @@ sdk/exceptions.py
16
16
  sdk/git_ops.py
17
17
  sdk/graphify.py
18
18
  sdk/local_exec.py
19
+ sdk/platform_compat.py
19
20
  sdk/py.typed
20
21
  sdk/run_auto.py
21
22
  sdk/sse.py
@@ -66,6 +67,7 @@ sdk/models/snapshots.py
66
67
  sdk/ui/__init__.py
67
68
  sdk/ui/api.py
68
69
  sdk/ui/dashboard.py
70
+ sdk/ui/ip_allowlist.py
69
71
  sdk/ui/jobs.py
70
72
  sdk/ui/runner.py
71
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.116"
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.116
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.116"
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 (
@@ -618,6 +618,12 @@ def status() -> None:
618
618
 
619
619
 
620
620
  def main() -> None:
621
+ # macOS: keep the background daemon from aborting ("Python quit unexpectedly")
622
+ # when it does networking after the double-fork. May re-exec once; must run
623
+ # before any fork or objc-linked import.
624
+ from sdk.platform_compat import ensure_macos_fork_safety
625
+ ensure_macos_fork_safety()
626
+
621
627
  argv = sys.argv[1:]
622
628
  if argv and argv[0] == "start":
623
629
  foreground = "--foreground" in argv[1:] or "-f" in argv[1:]
@@ -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
 
@@ -0,0 +1,48 @@
1
+ """Cross-platform runtime shims used by the CLI entry points.
2
+
3
+ Kept dependency-free and import-cheap: these run at the very top of ``main()``
4
+ BEFORE any networking/objc-linked module is imported, and one of them re-execs
5
+ the interpreter — so it must not drag in heavy imports of its own.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+
12
+
13
+ def ensure_macos_fork_safety() -> None:
14
+ """Stop macOS from killing our background daemons with a native abort.
15
+
16
+ ``agentos-auto`` / ``agentos-agentd`` double-fork (fork + setsid, no exec)
17
+ into a background daemon that then does networking (httpx → CFNetwork /
18
+ SystemConfiguration) and TLS. When the Objective-C runtime detects objc use
19
+ after a bare ``fork()`` with no ``exec()`` it calls ``abort()`` — which macOS
20
+ surfaces as the "Python quit unexpectedly" crash dialog. Exporting
21
+ ``OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES`` disables that guard.
22
+
23
+ The objc runtime reads that variable when its dylib is first mapped (that is
24
+ interpreter startup on framework Python builds), so setting it via
25
+ ``os.environ`` mid-run is too late to help the CURRENT process. We set it and
26
+ re-exec a FRESH interpreter ONCE, so the replacement process — and every
27
+ daemon it later forks — starts with the variable already in place. We also
28
+ short-circuit the system-proxy (``_scproxy``) lookup that most often triggers
29
+ the objc path on the first outbound request.
30
+
31
+ No-op off macOS, and idempotent: the "already YES" check is the re-exec guard
32
+ that prevents an exec loop.
33
+ """
34
+ if sys.platform != "darwin":
35
+ return
36
+ if os.environ.get("OBJC_DISABLE_INITIALIZE_FORK_SAFETY") == "YES":
37
+ return # already applied on this process → don't re-exec again
38
+ os.environ["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES"
39
+ os.environ.setdefault("no_proxy", "*")
40
+ try:
41
+ # os.execv inherits the just-modified os.environ, so the fresh process
42
+ # sees the variable from its first instruction. argv[0] is a runnable
43
+ # Python file for both a console-script launch and `python -m ...`.
44
+ os.execv(sys.executable, [sys.executable, sys.argv[0], *sys.argv[1:]])
45
+ except Exception: # noqa: BLE001
46
+ # Re-exec failed (unusual argv[0], permissions). The env var is at least
47
+ # set for child processes we spawn — carry on rather than refuse to start.
48
+ pass
@@ -544,6 +544,12 @@ def _run_loop(workspace_root: str | None, only_keys: list[str] | None = None,
544
544
 
545
545
 
546
546
  def main() -> None:
547
+ # macOS: keep the background daemon from aborting ("Python quit unexpectedly")
548
+ # when it does networking after the double-fork. May re-exec once; must run
549
+ # before any fork or objc-linked import.
550
+ from sdk.platform_compat import ensure_macos_fork_safety
551
+ ensure_macos_fork_safety()
552
+
547
553
  argv = sys.argv[1:]
548
554
  cmd = argv[0] if argv and not argv[0].startswith("-") else "run"
549
555
 
@@ -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__":
@@ -225,7 +225,7 @@
225
225
  let toolsNeeded = []; // AI-assessed; the user never sees or sets these
226
226
  let lastInputs = []; // input DEFINITIONS from analyze/manual-add (for saving)
227
227
  let lastOutputs = [];
228
- let lastSecrets = []; // API-key names detected by analyze (values collected at save)
228
+ let lastSecrets = []; // API-key names (analyze-detected or hand-added; values collected at save)
229
229
  let studioJob = null; // id of the in-flight analyze/review/run job
230
230
 
231
231
  function showStop(on) {
@@ -276,17 +276,30 @@
276
276
  $("save-panel").style.display = "";
277
277
  }
278
278
 
279
- // Required API keys detected by analyze collected here, stored (encrypted)
280
- // when the agent is saved. Values are password inputs; names are fixed.
279
+ // Department defaults resolve their keys from the backend/host env, not a local
280
+ // store so the value inputs are irrelevant (and hidden) in that scope.
281
+ function secretsHidden() {
282
+ return $("sa-scope") && $("sa-scope-field").style.display !== "none"
283
+ && $("sa-scope").value === "department";
284
+ }
285
+ // One secret row: env-var name (+ optional description) + masked value + remove.
286
+ function secretRowHTML(s, value) {
287
+ const desc = s.description ? ` <span class="muted" style="font-weight:400">— ${esc(s.description)}</span>` : "";
288
+ return `<div class="field" data-secret-row="${esc(s.name)}"><label>${esc(s.name)}${desc}` +
289
+ ` <a href="#" class="secret-rm" data-rmsecret="${esc(s.name)}" style="margin-left:6px;font-weight:400;font-size:12px">remove</a></label>` +
290
+ `<input class="secret-in" type="password" data-secret="${esc(s.name)}" placeholder="paste value (optional — hidden)" autocomplete="off" value="${esc(value || "")}" /></div>`;
291
+ }
292
+ // API keys detected by analyze OR added by hand — collected here, stored
293
+ // (encrypted) when the agent is saved. Shown once analysis has run so the user
294
+ // can add a key even when the AI detected none.
281
295
  function renderSecrets() {
282
296
  const panel = $("studio-secrets-panel"), box = $("studio-secrets");
283
297
  if (!panel || !box) return;
284
- if (!lastSecrets.length) { panel.style.display = "none"; box.innerHTML = ""; return; }
298
+ if (secretsHidden()) { panel.style.display = "none"; return; }
285
299
  panel.style.display = "";
286
- box.innerHTML = lastSecrets.map(s =>
287
- `<div class="field"><label>${esc(s.name)}${s.description ? ` <span class="muted" style="font-weight:400">— ${esc(s.description)}</span>` : ""}</label>
288
- <input class="secret-in" type="password" data-secret="${esc(s.name)}" placeholder="paste value (optionalhidden)" autocomplete="off" /></div>`
289
- ).join("");
300
+ box.innerHTML = lastSecrets.length
301
+ ? lastSecrets.map(s => secretRowHTML(s, "")).join("")
302
+ : "<div class='muted' data-empty>No API keys detectedadd one below if this task calls an external service.</div>";
290
303
  }
291
304
 
292
305
  function collectSecrets() {
@@ -391,6 +404,35 @@
391
404
  $("studio-inputs").appendChild(wrap.firstChild);
392
405
  });
393
406
 
407
+ // Let the user add an API key the AI missed. Names are env-var style
408
+ // (UPPER_SNAKE); the value is optional here and can be filled in later.
409
+ $("sp-add-secret").addEventListener("click", () => {
410
+ const raw = (window.prompt("Environment-variable NAME for this key (e.g. OPENAI_API_KEY):") || "").trim();
411
+ if (!raw) return;
412
+ const name = raw.toUpperCase().replace(/[^A-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
413
+ if (!name) { toast("Use an env-var name like OPENAI_API_KEY", "err"); return; }
414
+ if (lastSecrets.some(s => s.name === name)) { toast("That secret already exists", "err"); return; }
415
+ const desc = (window.prompt("What is it for? (optional)") || "").trim();
416
+ lastSecrets.push({ name, description: desc });
417
+ const empty = $("studio-secrets").querySelector("[data-empty]");
418
+ if (empty) empty.remove();
419
+ const wrap = document.createElement("div");
420
+ wrap.innerHTML = secretRowHTML({ name, description: desc }, "");
421
+ $("studio-secrets").appendChild(wrap.firstChild);
422
+ });
423
+
424
+ // Drop a secret (AI-detected over-reach or one added by mistake).
425
+ $("studio-secrets").addEventListener("click", (e) => {
426
+ const a = e.target.closest(".secret-rm");
427
+ if (!a) return;
428
+ e.preventDefault();
429
+ const nm = a.dataset.rmsecret;
430
+ lastSecrets = lastSecrets.filter(s => s.name !== nm);
431
+ const row = $("studio-secrets").querySelector(`[data-secret-row="${nm}"]`);
432
+ if (row) row.remove();
433
+ if (!lastSecrets.length) renderSecrets(); // restore the "add one below" hint
434
+ });
435
+
394
436
  async function pollRunInto(promise) {
395
437
  $("sp-run-status").textContent = "Running… (safe to switch pages — it keeps running)";
396
438
  $("resp-panel").style.display = "";
@@ -440,9 +482,8 @@
440
482
  // when that scope is picked (they resolve keys from the backend/host env).
441
483
  if ($("sa-scope")) $("sa-scope").addEventListener("change", () => {
442
484
  const panel = $("studio-secrets-panel");
443
- if (panel && lastSecrets.length) {
444
- panel.style.display = $("sa-scope").value === "department" ? "none" : "";
445
- }
485
+ if (!panel || $("io-panel").style.display === "none") return; // only after analysis
486
+ panel.style.display = secretsHidden() ? "none" : "";
446
487
  });
447
488
 
448
489
  // Turn this Studio session into a reusable agent (custom or department default).
@@ -43,12 +43,13 @@
43
43
  <button class="btn" id="sp-add-input" type="button" style="margin-top:6px">+ Add input</button>
44
44
 
45
45
  <div id="studio-secrets-panel" style="display:none;margin-top:14px">
46
- <div style="font-weight:600;margin-bottom:2px">API keys required <span class="muted" style="font-weight:400">(detected by AI)</span></div>
46
+ <div style="font-weight:600;margin-bottom:2px">API keys &amp; secrets <span class="muted" style="font-weight:400">(detected by AI — add any it missed)</span></div>
47
47
  <p class="muted" style="margin:0 0 .5em;font-size:12px">
48
48
  Stored <b>encrypted on this machine</b> when you save the agent, reused every run.
49
- Leave blank to fill in later on the Custom Agents page.
49
+ Leave a value blank to fill in later on the Custom Agents page.
50
50
  </p>
51
51
  <div id="studio-secrets"></div>
52
+ <button class="btn" id="sp-add-secret" type="button" style="margin-top:6px">+ Add secret</button>
52
53
  </div>
53
54
  <div style="margin-top:10px">
54
55
  <button class="btn primary" id="sp-run">Run</button>