Derive plugin names; refactor API helpers

Add robust plugin name derivation and clean up API helper code. - helpers/git.py: add giturlparse dependency and extract_author_repo(url) to reliably extract owner/repo from git URLs (strips auth and validates). - plugins/plugin_installer/helpers/install.py: replace the old sanitize function with two derivation helpers: _derive_git_plugin_name (normalizes owner/repo into a safe plugin ID) and _derive_zip_plugin_name (determine name from zip contents or uploaded filename). Import regex and use extract_author_repo; switch import to clear_plugin_cache and remove redundant cache clears on intermediate failures. - requirements.txt: add giturlparse==0.14.0. - webui/js/api.js: deduplicate and move extensions/URL normalization helpers, add redirect(response) helper to centralize login redirect handling, normalize CSRF cookie secure flag formatting, and minor whitespace/logic cleanup. These changes improve reliability of plugin ID inference from git URLs/archives and simplify/centralize client-side API helper logic.

frdel committed Mar 9, 2026 at 15:59 UTC 6dde8c2e74be253844eb134624a73a5e4db42165
4 files changed +85 -53
helpers/git.py
+14
@@ -1,4 +1,5 @@
1 from git import Repo
2 +from giturlparse import parse
3 from datetime import datetime
4 import os
5 import subprocess
@@ -20,6 +21,19 @@ def strip_auth_from_url(url: str) -> str:
21 return urlunparse((parsed.scheme, clean_netloc, parsed.path, '', '', ''))
22
23
24 +def extract_author_repo(url: str) -> tuple[str, str]:
25 + parsed = parse(strip_auth_from_url(url.strip()))
26 + author = (parsed.owner or "").strip()
27 + repo = (parsed.repo or parsed.name or "").strip()
28 + if not parsed.valid or not author or not repo:
29 + raise ValueError("Could not derive plugin name from URL")
30 + if repo.endswith(".git"):
31 + repo = repo[:-4]
32 + if not author or not repo:
33 + raise ValueError("Could not derive plugin name from URL")
34 + return author, repo
35 +
36 +
37 def get_git_info():
38 # Get the current working directory (assuming the repo is in the same folder as the script)
39 repo_path = files.get_base_dir()
plugins/plugin_installer/helpers/install.py
+29 -16
@@ -2,6 +2,7 @@ from __future__ import annotations
2
3 import json
4 import os
5 +import re
6 import shutil
7 import time
8 import urllib.request
@@ -11,12 +12,13 @@ from pathlib import Path
12 from typing import Any, Optional
13
14 from helpers import files
15 +from helpers.git import extract_author_repo
16 from helpers import yaml as yaml_helper
17 from helpers.plugins import (
18 META_FILE_NAME,
19 PluginMetadata,
20 get_plugins_list,
19 - invalidate_plugin_cache,
21 + clear_plugin_cache,
22 )
23 from werkzeug.datastructures import FileStorage
24 from werkzeug.utils import secure_filename
@@ -26,18 +28,31 @@ def _get_user_plugins_dir() -> str:
28 return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
29
30
29 - def _sanitize_plugin_name(name: str) -> str:
30 - """Validate and sanitize a plugin directory name.
31 - Converts dots and dashes to underscores for Python import compatibility.
32 - Raises ValueError if the name is unsafe for filesystem use."""
33 - name = name.strip().strip(".")
34 - name = re.sub(r"[-.]", "_", name)
35 - if not name or not _SAFE_NAME_RE.match(name):
36 - raise ValueError(
37 - f"Invalid plugin name: '{name}'. "
38 - "Names must start with a letter or digit and contain only letters, digits, or underscores."
39 - )
40 - return name
31 +def _derive_git_plugin_name(url: str) -> str:
32 + """Derive the canonical plugin ID from a Git repository URL."""
33 + parts = extract_author_repo(url)
34 + repo_name = "__".join(
35 + cleaned
36 + for part in parts
37 + if (cleaned := re.sub(r"_+", "_", re.sub(r"[^0-9A-Za-z]+", "_", part)).strip("_"))
38 + )
39 + if not repo_name:
40 + raise ValueError("Could not derive plugin name from URL")
41 + return repo_name
42 +
43 +
44 +def _derive_zip_plugin_name(
45 + plugin_root: str, extract_dir: str, original_filename: str | None
46 +) -> str:
47 + """Resolve the plugin ID for an uploaded ZIP archive."""
48 + if plugin_root != extract_dir:
49 + return os.path.basename(plugin_root)
50 +
51 + upload_name = Path((original_filename or "").strip()).name
52 + plugin_name = Path(upload_name).stem
53 + if not plugin_name:
54 + raise ValueError("Could not derive plugin name from uploaded filename")
55 + return plugin_name
56
57
58 def validate_plugin_dir(path: str) -> PluginMetadata:
@@ -157,7 +172,6 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
172 except Exception as e:
173 # Cleanup partial clone
174 shutil.rmtree(dest, ignore_errors=True)
160 - clear_plugin_cache()
175 raise ValueError(f"Git clone failed: {e}") from e
176
177 try:
@@ -165,7 +179,6 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
179 except ValueError:
180 # No plugin.yaml — remove cloned repo
181 shutil.rmtree(dest, ignore_errors=True)
168 - clear_plugin_cache()
182 raise
183
184 clear_plugin_cache()
@@ -210,4 +223,4 @@ def fetch_plugin_index() -> dict:
223 req = urllib.request.Request(index_url, headers={"User-Agent": "AgentZero"})
224 with urllib.request.urlopen(req, timeout=30) as resp:
225 data = json.loads(resp.read().decode())
213 - return data
226 + return data
\ No newline at end of file
requirements.txt
+1
@@ -10,6 +10,7 @@ flask[async]==3.0.3
10 flask-basicauth==0.2.0
11 flaredantic==0.1.5
12 GitPython==3.1.43
13 +giturlparse==0.14.0
14 inputimeout==1.0.4
15 kokoro>=0.9.2
16 simpleeval==1.0.3
webui/js/api.js
+41 -37
@@ -1,22 +1,3 @@
1 -let _extensionsModule = null;
2 -
3 -async function _getExtensions() {
4 - if (!_extensionsModule) _extensionsModule = await import("./extensions.js");
5 - return _extensionsModule;
6 -}
7 -
8 -async function _shouldCallApiExtensions(apiUrl) {
9 - const extensions = await _getExtensions();
10 - const excluded = extensions.API_EXTENSION_EXCLUDED_ENDPOINTS;
11 - return !(excluded instanceof Set && excluded.has(apiUrl));
12 -}
13 -
14 -function _normalizeApiUrl(url) {
15 - return url.startsWith("/api/") || url.startsWith("api/")
16 - ? `/${url.replace(/^\/+/, "")}`
17 - : `/api/${url.replace(/^\/+/, "")}`;
18 -}
19 -
1 /**
2 * Call a JSON-in JSON-out API endpoint
3 * Data is automatically serialized
@@ -61,7 +42,7 @@ export async function callJsonApi(endpoint, data) {
42 }
43
44 if (ctx.error) throw ctx.error;
64 -
45 +
46 return ctx.result;
47 }
48
@@ -128,15 +109,10 @@ export async function fetchApi(url, request) {
109 // retry the request with new token
110 csrfToken = null;
111 return await _wrap(false);
131 - } else if (finalResponse.redirected && finalResponse.url.endsWith("/login")) {
132 - // redirect to login (origin check prevents open redirect)
133 - const _redirectUrl = new URL(finalResponse.url);
134 - if (_redirectUrl.origin === window.location.origin) {
135 - window.location.href = finalResponse.url;
136 - }
137 - return;
112 }
113
114 + if (redirect(finalResponse)) return;
115 +
116 // return the response
117 return finalResponse;
118 }
@@ -220,14 +196,8 @@ export async function getCsrfToken() {
196 }
197 }
198
223 - if (response.redirected && response.url.endsWith("/login")) {
224 - // redirect to login (origin check prevents open redirect)
225 - const _redirectUrl = new URL(response.url);
226 - if (_redirectUrl.origin === window.location.origin) {
227 - window.location.href = response.url;
228 - }
229 - return;
230 - }
199 + if (redirect(response)) return;
200 +
201 const json = await response.json();
202 if (json.ok) {
203 const runtimeId =
@@ -247,13 +217,17 @@ export async function getCsrfToken() {
217 : null;
218 const cookieRuntimeId = runtimeId || injectedRuntimeId;
219 if (cookieRuntimeId) {
250 - const _secureFlag = window.location.protocol === 'https:' ? '; Secure' : '';
220 + const _secureFlag =
221 + window.location.protocol === "https:" ? "; Secure" : "";
222 document.cookie = `csrf_token_${cookieRuntimeId}=${csrfToken}; SameSite=Strict; Path=/${_secureFlag}`;
223 } else {
224 console.warn("CSRF runtime id missing; skipping cookie name binding.");
225 }
226 const elapsedMs = Date.now() - startedAt;
256 - if (elapsedMs > CSRF_SLOW_WARN_MS && globalThis.runtimeInfo?.isDevelopment) {
227 + if (
228 + elapsedMs > CSRF_SLOW_WARN_MS &&
229 + globalThis.runtimeInfo?.isDevelopment
230 + ) {
231 console.warn(`CSRF token request took ${elapsedMs}ms`);
232 }
233 return csrfToken;
@@ -269,3 +243,33 @@ export async function getCsrfToken() {
243 csrfTokenPromise = null;
244 }
245 }
246 +
247 +
248 +
249 +let _extensionsModule = null;
250 +
251 +async function _getExtensions() {
252 + if (!_extensionsModule) _extensionsModule = await import("./extensions.js");
253 + return _extensionsModule;
254 +}
255 +
256 +async function _shouldCallApiExtensions(apiUrl) {
257 + const extensions = await _getExtensions();
258 + const excluded = extensions.API_EXTENSION_EXCLUDED_ENDPOINTS;
259 + return !(excluded instanceof Set && excluded.has(apiUrl));
260 +}
261 +
262 +function _normalizeApiUrl(url) {
263 + return url.startsWith("/api/") || url.startsWith("api/")
264 + ? `/${url.replace(/^\/+/, "")}`
265 + : `/api/${url.replace(/^\/+/, "")}`;
266 +}
267 +
268 +function redirect(response) {
269 + if (!(response.redirected && response.url.endsWith("/login"))) return false;
270 + const _redirectUrl = new URL(response.url);
271 + if (_redirectUrl.origin === window.location.origin) {
272 + window.location.href = response.url;
273 + }
274 + return true;
275 +}
\ No newline at end of file