pipx, subagent paths, alpine directives, CSRF allowed origins autoset

frdel committed Dec 19, 2025 at 11:30 UTC dd018d66a49e79259d443b7655375eadd885f5e4
7 files changed +128 -80
agent.py
+3 -3
@@ -619,14 +619,14 @@ class Agent:
619 return system_prompt
620
621 def parse_prompt(self, _prompt_file: str, **kwargs):
622 - dirs = subagents.get_agent_paths_chain(self, "prompts")
622 + dirs = subagents.get_paths(self, "prompts")
623 prompt = files.parse_file(
624 _prompt_file, _directories=dirs, _agent=self, **kwargs
625 )
626 return prompt
627
628 def read_prompt(self, file: str, **kwargs) -> str:
629 - dirs = subagents.get_agent_paths_chain(self, "prompts")
629 + dirs = subagents.get_paths(self, "prompts")
630 prompt = files.read_prompt_file(file, _directories=dirs, _agent=self, **kwargs)
631 prompt = files.remove_code_fences(prompt)
632 return prompt
@@ -958,7 +958,7 @@ class Agent:
958 classes = []
959
960 # search for tools in agent's folder hierarchy
961 - paths = subagents.get_agent_paths_chain(self, "tools", name + ".py", default_root="python")
961 + paths = subagents.get_paths(self, "tools", name + ".py", default_root="python")
962 for path in paths:
963 try:
964 classes = extract_tools.load_classes_from_file(path, Tool) # type: ignore[arg-type]
docker/base/fs/ins/install_python.sh
+1 -1
@@ -20,7 +20,7 @@ python3.13 -m venv /opt/venv
20 source /opt/venv/bin/activate
21
22 # upgrade pip and install static packages
23 -pip install --no-cache-dir --upgrade pip ipython requests
23 +pip install --no-cache-dir --upgrade pip pipx ipython requests
24
25 echo "====================PYTHON PYVENV===================="
26
python/api/csrf_token.py
+38 -2
@@ -11,6 +11,8 @@ from python.helpers.api import (
11 from python.helpers import runtime, dotenv, login
12 import fnmatch
13
14 +ALLOWED_ORIGINS_KEY = "ALLOWED_ORIGINS"
15 +
16
17 class GetCsrfToken(ApiHandler):
18
@@ -44,9 +46,11 @@ class GetCsrfToken(ApiHandler):
46 }
47
48 async def check_allowed_origin(self, request: Request):
47 - # if login is required, this che
49 + # if login is required, this check is unnecessary
50 if login.is_login_required():
51 return {"ok": True, "origin": "", "allowed_origins": ""}
52 + # initialize allowed origins if not yet set
53 + self.initialize_allowed_origins(request)
54 # otherwise, check if the origin is allowed
55 return await self.is_allowed_origin(request)
56
@@ -66,6 +70,7 @@ class GetCsrfToken(ApiHandler):
70 )
71 return {"ok": match, "origin": origin, "allowed_origins": allowed_origins}
72
73 +
74 def get_origin_from_request(self, request: Request):
75 # get from origin
76 r = request.headers.get("Origin") or request.environ.get("HTTP_ORIGIN")
@@ -88,7 +93,7 @@ class GetCsrfToken(ApiHandler):
93 # get the allowed origins from the environment
94 allowed_origins = [
95 origin.strip()
91 - for origin in (dotenv.get_dotenv_value("ALLOWED_ORIGINS") or "").split(",")
96 + for origin in (dotenv.get_dotenv_value(ALLOWED_ORIGINS_KEY) or "").split(",")
97 if origin.strip()
98 ]
99
@@ -110,3 +115,34 @@ class GetCsrfToken(ApiHandler):
115
116 def get_default_allowed_origins(self) -> list[str]:
117 return ["*://localhost:*", "*://127.0.0.1:*", "*://0.0.0.0:*"]
118 +
119 + def initialize_allowed_origins(self, request: Request):
120 + """
121 + If A0 is hosted on a server, add the first visit origin to ALLOWED_ORIGINS.
122 + This simplifies deployment process as users can access their new instance without
123 + additional setup while keeping it secure.
124 + """
125 + # dotenv value is already set, do nothing
126 + denv = dotenv.get_dotenv_value(ALLOWED_ORIGINS_KEY)
127 + if denv:
128 + return
129 +
130 + # get the origin from the request
131 + req_origin = self.get_origin_from_request(request)
132 + if not req_origin:
133 + return
134 +
135 + # check if the origin is allowed by default
136 + allowed_origins = self.get_default_allowed_origins()
137 + match = any(
138 + fnmatch.fnmatch(req_origin, allowed_origin)
139 + for allowed_origin in allowed_origins
140 + )
141 + if match:
142 + return
143 +
144 + # if not, add it to the allowed origins
145 + allowed_origins.append(req_origin)
146 + dotenv.save_dotenv_value(ALLOWED_ORIGINS_KEY, ",".join(allowed_origins))
147 +
148 +
\ No newline at end of file
python/extensions/agent_init/_15_load_profile_settings.py
+1 -1
@@ -10,7 +10,7 @@ class LoadProfileSettings(Extension):
10 if not self.agent or not self.agent.config.profile:
11 return
12
13 - config_files = subagents.get_agent_paths_chain(self.agent, "settings.json", include_default=False)
13 + config_files = subagents.get_paths(self.agent, "settings.json", include_default=False)
14
15 settings_override = {}
16 for settings_path in config_files:
python/helpers/extension.py
+1 -1
@@ -30,7 +30,7 @@ async def call_extensions(
30 from python.helpers import projects, subagents
31
32 # search for extension folders in all agent's paths
33 - paths = subagents.get_agent_paths_chain(agent, "extensions", extension_point, default_root="python")
33 + paths = subagents.get_paths(agent, "extensions", extension_point, default_root="python")
34 all_exts = [cls for path in paths for cls in _get_extensions(path)]
35
36 # merge: first ocurrence of file name is the override
python/helpers/subagents.py
+40 -61
@@ -246,84 +246,63 @@ def get_available_agents_dict(
246 return filtered_agents
247
248
249 -def get_agent_paths(
250 - agent: "Agent", *subpaths, must_exist_completely: bool = True
251 -) -> list[str]:
252 - """Returns list of possible paths for the given agent and subpaths. Order is from lowest priority (global)."""
253 -
254 - if not agent or not agent.config.profile:
255 - return []
256 - from python.helpers import projects
257 -
258 - project_name = projects.get_context_project_name(agent.context)
259 - return get_agent_profile_paths(
260 - agent.config.profile,
261 - project_name,
262 - *subpaths,
263 - must_exist_completely=must_exist_completely,
264 - )
265 -
266 -
267 -def get_agent_paths_chain(
249 +def get_paths(
250 agent: "Agent|None",
251 *subpaths,
270 - must_exist_completely: bool = True,
252 + must_exist_completely: bool = True,
253 include_project: bool = True,
254 include_user: bool = True,
255 include_default: bool = True,
256 default_root: str = "",
257 ) -> list[str]:
258 """Returns list of file paths for the given agent and subpaths, searched in order of priority:
277 - project/agents/, usr/agents/, agents/, project/, usr/, default."""
278 - from python.helpers import projects
259 + project/agents/, project/, usr/agents/, agents/, usr/, default."""
260 + paths: list[str] = []
261 + check_subpaths = subpaths if must_exist_completely else []
262 + profile_name = agent.config.profile if agent and agent.config.profile else ""
263 + project_name = ""
264
280 - if agent and agent.config.profile:
281 - project_name = projects.get_context_project_name(agent.context)
282 - paths = get_agent_profile_paths(
283 - agent.config.profile,
284 - project_name,
285 - *subpaths,
286 - must_exist_completely=must_exist_completely,
287 - )
288 - list.reverse(paths) # reverse for proper priority
289 - else:
290 - paths = []
291 - project_name = ""
265 + if include_project and agent:
266 + from python.helpers import projects
267
293 - if include_project and project_name:
294 - path = projects.get_project_meta_folder(project_name, *subpaths)
295 - if (not must_exist_completely) or files.exists(path):
268 + project_name = projects.get_context_project_name(agent.context) or ""
269 +
270 + if project_name and profile_name:
271 + # project/agents/<profile>/...
272 + project_agent_dir = projects.get_project_meta_folder(
273 + project_name, "agents", profile_name
274 + )
275 + if files.exists(files.get_abs_path(project_agent_dir, *check_subpaths)):
276 + paths.append(files.get_abs_path(project_agent_dir, *subpaths))
277 +
278 + if project_name:
279 + # project/.a0proj/...
280 + path = projects.get_project_meta_folder(project_name, *subpaths)
281 + if (not must_exist_completely) or files.exists(path):
282 + paths.append(path)
283 +
284 + if profile_name:
285 +
286 + # usr/agents/<profile>/...
287 + path = files.get_abs_path(USER_AGENTS_DIR, profile_name, *subpaths)
288 + if (not must_exist_completely) or files.exists(files.get_abs_path(USER_AGENTS_DIR, profile_name, *check_subpaths)):
289 paths.append(path)
290 +
291 + # agents/<profile>/...
292 + path = files.get_abs_path(DEFAULT_AGENTS_DIR, profile_name, *subpaths)
293 + if (not must_exist_completely) or files.exists(files.get_abs_path(DEFAULT_AGENTS_DIR, profile_name, *check_subpaths)):
294 + paths.append(path)
295 +
296 if include_user:
297 + # usr/...
298 path = files.get_abs_path(USER_DIR, *subpaths)
299 if (not must_exist_completely) or files.exists(path):
300 paths.append(path)
301 +
302 if include_default:
303 + # default_root/...
304 path = files.get_abs_path(default_root, *subpaths)
305 if (not must_exist_completely) or files.exists(path):
306 paths.append(path)
305 - return paths
307
307 -
308 -def get_agent_profile_paths(
309 - name: str,
310 - project_name: str | None = None,
311 - *subpaths,
312 - must_exist_completely: bool = True,
313 -) -> list[str]:
314 - result = []
315 - check_subpaths = subpaths if must_exist_completely else []
316 -
317 - if files.exists(files.get_abs_path(DEFAULT_AGENTS_DIR, name, *check_subpaths)):
318 - result.append(files.get_abs_path(DEFAULT_AGENTS_DIR, name, *subpaths))
319 - if files.exists(files.get_abs_path(USER_AGENTS_DIR, name, *check_subpaths)):
320 - result.append(files.get_abs_path(USER_AGENTS_DIR, name, *subpaths))
321 - if project_name:
322 - from python.helpers import projects
323 -
324 - project_agent_dir = projects.get_project_meta_folder(
325 - project_name, "agents", name
326 - )
327 - if files.exists(files.get_abs_path(project_agent_dir, *check_subpaths)):
328 - result.append(files.get_abs_path(project_agent_dir, *subpaths))
329 - return result
308 + return paths
webui/js/initFw.js
+44 -11
@@ -10,15 +10,48 @@ await import("../vendor/alpine/alpine.min.js");
10
11 // add x-destroy directive to alpine
12 Alpine.directive(
13 - "destroy",
14 - (el, { expression }, { evaluateLater, cleanup }) => {
15 - const onDestroy = evaluateLater(expression);
16 - cleanup(() => onDestroy());
17 - }
18 -);
13 + "destroy",
14 + (_el, { expression }, { evaluateLater, cleanup }) => {
15 + const onDestroy = evaluateLater(expression);
16 + cleanup(() => onDestroy());
17 + }
18 + );
19
20 -// add x-create directive to alpine
21 -Alpine.directive("create", (_el, { expression }, { evaluateLater }) => {
22 - const onCreate = evaluateLater(expression);
23 - onCreate();
24 -});
20 + // add x-create directive to alpine
21 + Alpine.directive(
22 + "create",
23 + (_el, { expression }, { evaluateLater }) => {
24 + const onCreate = evaluateLater(expression);
25 + onCreate();
26 + }
27 + );
28 +
29 + // run every second if the component is active
30 + Alpine.directive(
31 + "every-second",
32 + (_el, { expression }, { evaluateLater, cleanup }) => {
33 + const onTick = evaluateLater(expression);
34 + const intervalId = setInterval(() => onTick(), 1000);
35 + cleanup(() => clearInterval(intervalId));
36 + }
37 + );
38 +
39 + // run every minute if the component is active
40 + Alpine.directive(
41 + "every-minute",
42 + (_el, { expression }, { evaluateLater, cleanup }) => {
43 + const onTick = evaluateLater(expression);
44 + const intervalId = setInterval(() => onTick(), 60_000);
45 + cleanup(() => clearInterval(intervalId));
46 + }
47 + );
48 +
49 + // run every hour if the component is active
50 + Alpine.directive(
51 + "every-hour",
52 + (_el, { expression }, { evaluateLater, cleanup }) => {
53 + const onTick = evaluateLater(expression);
54 + const intervalId = setInterval(() => onTick(), 3_600_000);
55 + cleanup(() => clearInterval(intervalId));
56 + }
57 + );