git-ai-control 0.2.0
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.
- package/CHANGELOG.md +41 -0
- package/COMPATIBILITY.md +66 -0
- package/README.md +180 -0
- package/bin/git-ai-control.js +57 -0
- package/docs/images/git-ai-settings.jpg +0 -0
- package/docs/images/plugin-management.jpg +0 -0
- package/launchagents/com.git-ai.control-panel.plist.in +24 -0
- package/launchagents/com.git-ai.skill-usage-filter.plist.in +22 -0
- package/package.json +45 -0
- package/plugins/upload-filter/plugin_filter_runtime.py +560 -0
- package/policy.example.json +53 -0
- package/scripts/install.sh +241 -0
- package/scripts/uninstall.sh +91 -0
- package/server.py +534 -0
- package/static/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/static/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/static/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/static/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/static/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/static/assets/index-DWeFCLPQ.js +50 -0
- package/static/assets/index-WWn7U1pB.css +2 -0
- package/static/index.html +14 -0
package/server.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local-only Web configuration service for Git AI and its upload policy."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import py_compile
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from urllib.parse import unquote, urlsplit
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
HOST = "127.0.0.1"
|
|
21
|
+
PORT = 38742
|
|
22
|
+
APP_ROOT = Path(__file__).resolve().parent
|
|
23
|
+
STATIC_ROOT = APP_ROOT / "static"
|
|
24
|
+
GIT_AI_ROOT = Path.home() / ".git-ai"
|
|
25
|
+
NATIVE_CONFIG_PATH = GIT_AI_ROOT / "config.json"
|
|
26
|
+
POLICY_CONFIG_PATH = GIT_AI_ROOT / "filter_plugins.json"
|
|
27
|
+
CUSTOM_METRICS_PATH = GIT_AI_ROOT / "custom_metrics.json"
|
|
28
|
+
FILTER_SCRIPT_PATH = GIT_AI_ROOT / "filters" / "plugin_filter_runtime.py"
|
|
29
|
+
FILTER_HEALTH_URL = "http://127.0.0.1:38741/health"
|
|
30
|
+
FILTER_LAUNCH_AGENT = f"gui/{os.getuid()}/com.git-ai.skill-usage-filter"
|
|
31
|
+
|
|
32
|
+
NATIVE_FIELDS = {
|
|
33
|
+
"git_path",
|
|
34
|
+
"exclude_prompts_in_repositories",
|
|
35
|
+
"exclude_repositories",
|
|
36
|
+
"disable_version_checks",
|
|
37
|
+
"disable_auto_updates",
|
|
38
|
+
"telemetry_oss",
|
|
39
|
+
}
|
|
40
|
+
EVENT_KEYS = {
|
|
41
|
+
"token",
|
|
42
|
+
"skill",
|
|
43
|
+
"commit",
|
|
44
|
+
"checkpoint",
|
|
45
|
+
"agent",
|
|
46
|
+
"prompt_duration",
|
|
47
|
+
"prompt_report",
|
|
48
|
+
}
|
|
49
|
+
FIELD_KEYS = {"repository", "path", "branch"}
|
|
50
|
+
ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,47}$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ConfigError(ValueError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_json(path: Path, default):
|
|
58
|
+
try:
|
|
59
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
60
|
+
return json.load(handle)
|
|
61
|
+
except FileNotFoundError:
|
|
62
|
+
return default
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def atomic_write_json(path: Path, value) -> None:
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
current_mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
|
|
68
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
69
|
+
prefix=f".{path.name}.",
|
|
70
|
+
suffix=".tmp",
|
|
71
|
+
dir=path.parent,
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
75
|
+
json.dump(value, handle, ensure_ascii=False, indent=2)
|
|
76
|
+
handle.write("\n")
|
|
77
|
+
handle.flush()
|
|
78
|
+
os.fsync(handle.fileno())
|
|
79
|
+
os.chmod(temporary_name, current_mode)
|
|
80
|
+
os.replace(temporary_name, path)
|
|
81
|
+
except Exception:
|
|
82
|
+
try:
|
|
83
|
+
os.unlink(temporary_name)
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
pass
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def public_native_config() -> dict:
|
|
90
|
+
raw = read_json(NATIVE_CONFIG_PATH, {})
|
|
91
|
+
return {key: raw.get(key) for key in NATIVE_FIELDS if key in raw}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_string_list(value, label: str, *, maximum: int = 128) -> list[str]:
|
|
95
|
+
if not isinstance(value, list) or len(value) > maximum:
|
|
96
|
+
raise ConfigError(f"{label} 必须是最多 {maximum} 项的数组")
|
|
97
|
+
result = []
|
|
98
|
+
for item in value:
|
|
99
|
+
if not isinstance(item, str) or not item.strip() or len(item) > 500:
|
|
100
|
+
raise ConfigError(f"{label} 包含无效条目")
|
|
101
|
+
result.append(item.strip())
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_native_config(value) -> dict:
|
|
106
|
+
if not isinstance(value, dict):
|
|
107
|
+
raise ConfigError("Git AI 配置必须是对象")
|
|
108
|
+
|
|
109
|
+
result = {}
|
|
110
|
+
if "git_path" in value:
|
|
111
|
+
git_path = value["git_path"]
|
|
112
|
+
if not isinstance(git_path, str) or not git_path.startswith("/") or len(git_path) > 500:
|
|
113
|
+
raise ConfigError("Git 路径必须是绝对路径")
|
|
114
|
+
result["git_path"] = git_path
|
|
115
|
+
|
|
116
|
+
for key in ("exclude_prompts_in_repositories", "exclude_repositories"):
|
|
117
|
+
result[key] = validate_string_list(value.get(key, []), key)
|
|
118
|
+
|
|
119
|
+
for key in ("disable_version_checks", "disable_auto_updates"):
|
|
120
|
+
candidate = value.get(key, False)
|
|
121
|
+
if not isinstance(candidate, bool):
|
|
122
|
+
raise ConfigError(f"{key} 必须是布尔值")
|
|
123
|
+
result[key] = candidate
|
|
124
|
+
|
|
125
|
+
telemetry_oss = value.get("telemetry_oss", "on")
|
|
126
|
+
if telemetry_oss not in {"on", "off"}:
|
|
127
|
+
raise ConfigError("telemetry_oss 只能是 on 或 off")
|
|
128
|
+
result["telemetry_oss"] = telemetry_oss
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def validate_policy_config(value) -> dict:
|
|
133
|
+
if not isinstance(value, dict):
|
|
134
|
+
raise ConfigError("插件策略必须是对象")
|
|
135
|
+
if value.get("version") != 1:
|
|
136
|
+
raise ConfigError("仅支持插件策略版本 1")
|
|
137
|
+
|
|
138
|
+
skill_policy = value.get("skill_policy")
|
|
139
|
+
if not isinstance(skill_policy, dict):
|
|
140
|
+
raise ConfigError("缺少 skill_policy")
|
|
141
|
+
skill_enabled = skill_policy.get("enabled", True)
|
|
142
|
+
if not isinstance(skill_enabled, bool):
|
|
143
|
+
raise ConfigError("skill_policy.enabled 必须是布尔值")
|
|
144
|
+
skill_installed = skill_policy.get("installed", True)
|
|
145
|
+
if not isinstance(skill_installed, bool):
|
|
146
|
+
raise ConfigError("skill_policy.installed 必须是布尔值")
|
|
147
|
+
blocked_patterns = validate_string_list(
|
|
148
|
+
skill_policy.get("blocked_patterns", []),
|
|
149
|
+
"blocked_patterns",
|
|
150
|
+
maximum=256,
|
|
151
|
+
)
|
|
152
|
+
for pattern in blocked_patterns:
|
|
153
|
+
try:
|
|
154
|
+
re.compile(pattern)
|
|
155
|
+
except re.error as error:
|
|
156
|
+
raise ConfigError(f"无效的 Skill 正则:{pattern}({error})") from error
|
|
157
|
+
|
|
158
|
+
plugins = value.get("plugins")
|
|
159
|
+
if not isinstance(plugins, list) or len(plugins) > 1:
|
|
160
|
+
raise ConfigError("仓库插件只能配置一次")
|
|
161
|
+
|
|
162
|
+
seen_ids = set()
|
|
163
|
+
normalized_plugins = []
|
|
164
|
+
for index, plugin in enumerate(plugins):
|
|
165
|
+
if not isinstance(plugin, dict):
|
|
166
|
+
raise ConfigError(f"第 {index + 1} 个插件无效")
|
|
167
|
+
plugin_id = plugin.get("id")
|
|
168
|
+
if not isinstance(plugin_id, str) or not ID_PATTERN.fullmatch(plugin_id):
|
|
169
|
+
raise ConfigError(f"第 {index + 1} 个插件 ID 无效")
|
|
170
|
+
if plugin_id in seen_ids:
|
|
171
|
+
raise ConfigError(f"插件 ID 重复:{plugin_id}")
|
|
172
|
+
seen_ids.add(plugin_id)
|
|
173
|
+
|
|
174
|
+
name = plugin.get("name")
|
|
175
|
+
if not isinstance(name, str) or not name.strip() or len(name) > 80:
|
|
176
|
+
raise ConfigError(f"插件 {plugin_id} 名称无效")
|
|
177
|
+
enabled = plugin.get("enabled", True)
|
|
178
|
+
if not isinstance(enabled, bool):
|
|
179
|
+
raise ConfigError(f"插件 {plugin_id} enabled 无效")
|
|
180
|
+
|
|
181
|
+
match = plugin.get("match")
|
|
182
|
+
if not isinstance(match, dict):
|
|
183
|
+
raise ConfigError(f"插件 {plugin_id} match 无效")
|
|
184
|
+
hosts = validate_string_list(match.get("hosts", []), f"{plugin_id}.match.hosts", maximum=32)
|
|
185
|
+
fixed_directory = plugin.get("fixed_project_directory", "")
|
|
186
|
+
if not isinstance(fixed_directory, str) or len(fixed_directory) > 500:
|
|
187
|
+
raise ConfigError(f"插件 {plugin_id} 的固定项目目录无效")
|
|
188
|
+
if fixed_directory and not fixed_directory.startswith("/"):
|
|
189
|
+
raise ConfigError(f"插件 {plugin_id} 的固定项目目录必须是绝对路径")
|
|
190
|
+
|
|
191
|
+
allow = plugin.get("allow")
|
|
192
|
+
if not isinstance(allow, dict) or set(allow) != EVENT_KEYS:
|
|
193
|
+
raise ConfigError(f"插件 {plugin_id} 的数据类型选项不完整")
|
|
194
|
+
if not all(isinstance(allow[key], bool) for key in EVENT_KEYS):
|
|
195
|
+
raise ConfigError(f"插件 {plugin_id} 的数据类型选项必须是布尔值")
|
|
196
|
+
|
|
197
|
+
fields = plugin.get("fields")
|
|
198
|
+
if not isinstance(fields, dict) or set(fields) != FIELD_KEYS:
|
|
199
|
+
raise ConfigError(f"插件 {plugin_id} 的字段选项不完整")
|
|
200
|
+
if not all(isinstance(fields[key], bool) for key in FIELD_KEYS):
|
|
201
|
+
raise ConfigError(f"插件 {plugin_id} 的字段选项必须是布尔值")
|
|
202
|
+
|
|
203
|
+
normalized_plugins.append(
|
|
204
|
+
{
|
|
205
|
+
"id": plugin_id,
|
|
206
|
+
"name": name.strip(),
|
|
207
|
+
"enabled": enabled,
|
|
208
|
+
"match": {"hosts": hosts},
|
|
209
|
+
"fixed_project_directory": fixed_directory,
|
|
210
|
+
"allow": {key: allow[key] for key in sorted(EVENT_KEYS)},
|
|
211
|
+
"fields": {key: fields[key] for key in sorted(FIELD_KEYS)},
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
"version": 1,
|
|
217
|
+
"default_allow_unmatched": bool(value.get("default_allow_unmatched", True)),
|
|
218
|
+
"skill_policy": {
|
|
219
|
+
"installed": skill_installed,
|
|
220
|
+
"enabled": skill_enabled,
|
|
221
|
+
"blocked_patterns": blocked_patterns,
|
|
222
|
+
},
|
|
223
|
+
"plugins": normalized_plugins,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def merge_native_config(public_value: dict) -> dict:
|
|
228
|
+
current = read_json(NATIVE_CONFIG_PATH, {})
|
|
229
|
+
for key in NATIVE_FIELDS:
|
|
230
|
+
current.pop(key, None)
|
|
231
|
+
current.update(public_value)
|
|
232
|
+
return current
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def save_config_bundle(payload: dict) -> None:
|
|
236
|
+
if not isinstance(payload, dict):
|
|
237
|
+
raise ConfigError("请求内容必须是对象")
|
|
238
|
+
native = validate_native_config(payload.get("gitAi"))
|
|
239
|
+
policy = validate_policy_config(payload.get("policy"))
|
|
240
|
+
merged_native = merge_native_config(native)
|
|
241
|
+
|
|
242
|
+
previous_native = NATIVE_CONFIG_PATH.read_bytes() if NATIVE_CONFIG_PATH.exists() else None
|
|
243
|
+
previous_policy = POLICY_CONFIG_PATH.read_bytes() if POLICY_CONFIG_PATH.exists() else None
|
|
244
|
+
try:
|
|
245
|
+
atomic_write_json(POLICY_CONFIG_PATH, policy)
|
|
246
|
+
atomic_write_json(NATIVE_CONFIG_PATH, merged_native)
|
|
247
|
+
except Exception:
|
|
248
|
+
if previous_native is not None:
|
|
249
|
+
NATIVE_CONFIG_PATH.write_bytes(previous_native)
|
|
250
|
+
if previous_policy is not None:
|
|
251
|
+
POLICY_CONFIG_PATH.write_bytes(previous_policy)
|
|
252
|
+
raise
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def filter_health() -> dict:
|
|
256
|
+
try:
|
|
257
|
+
with urllib.request.urlopen(FILTER_HEALTH_URL, timeout=2) as response:
|
|
258
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
259
|
+
return {"ok": response.status == 200 and bool(data.get("ok")), "details": data}
|
|
260
|
+
except Exception as error:
|
|
261
|
+
return {"ok": False, "error": str(error)}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def launch_agent_status() -> dict:
|
|
265
|
+
result = subprocess.run(
|
|
266
|
+
["launchctl", "print", FILTER_LAUNCH_AGENT],
|
|
267
|
+
check=False,
|
|
268
|
+
capture_output=True,
|
|
269
|
+
text=True,
|
|
270
|
+
timeout=3,
|
|
271
|
+
)
|
|
272
|
+
state_match = re.search(r"^\s*state = (\w+)", result.stdout, re.MULTILINE)
|
|
273
|
+
return {
|
|
274
|
+
"ok": result.returncode == 0 and state_match and state_match.group(1) == "running",
|
|
275
|
+
"state": state_match.group(1) if state_match else "missing",
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def git_ai_version() -> str:
|
|
280
|
+
binary = GIT_AI_ROOT / "bin" / "git-ai"
|
|
281
|
+
try:
|
|
282
|
+
result = subprocess.run(
|
|
283
|
+
[str(binary), "--version"],
|
|
284
|
+
check=False,
|
|
285
|
+
capture_output=True,
|
|
286
|
+
text=True,
|
|
287
|
+
timeout=3,
|
|
288
|
+
)
|
|
289
|
+
return result.stdout.strip() or result.stderr.strip() or "unknown"
|
|
290
|
+
except Exception:
|
|
291
|
+
return "unknown"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@lru_cache(maxsize=1)
|
|
295
|
+
def custom_metrics_supported() -> bool:
|
|
296
|
+
binary = GIT_AI_ROOT / "bin" / "git-ai"
|
|
297
|
+
try:
|
|
298
|
+
return b"custom_metrics.json" in binary.read_bytes()
|
|
299
|
+
except Exception:
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def custom_metrics_routed() -> bool:
|
|
304
|
+
metrics = read_json(CUSTOM_METRICS_PATH, {})
|
|
305
|
+
endpoint_keys = {
|
|
306
|
+
"commit_endpoint",
|
|
307
|
+
"checkpoint_endpoint",
|
|
308
|
+
"token_usage_endpoint",
|
|
309
|
+
"prompt_duration_endpoint",
|
|
310
|
+
"commit_endpoint_v2",
|
|
311
|
+
"checkpoint_endpoint_v2",
|
|
312
|
+
"token_usage_endpoint_v2",
|
|
313
|
+
"skill_usage_endpoint_v2",
|
|
314
|
+
"agent_usage_endpoint_v2",
|
|
315
|
+
"prompt_report_endpoint_v2",
|
|
316
|
+
}
|
|
317
|
+
return all(
|
|
318
|
+
isinstance(metrics.get(key), str)
|
|
319
|
+
and metrics[key].startswith("http://127.0.0.1:38741/")
|
|
320
|
+
for key in endpoint_keys
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def runtime_status() -> dict:
|
|
325
|
+
health = filter_health()
|
|
326
|
+
agent = launch_agent_status()
|
|
327
|
+
supports_custom_metrics = custom_metrics_supported()
|
|
328
|
+
routes_are_local = custom_metrics_routed() if supports_custom_metrics else False
|
|
329
|
+
granular_filter_active = (
|
|
330
|
+
supports_custom_metrics
|
|
331
|
+
and routes_are_local
|
|
332
|
+
and bool(health.get("ok"))
|
|
333
|
+
and bool(agent.get("ok"))
|
|
334
|
+
)
|
|
335
|
+
return {
|
|
336
|
+
"ok": granular_filter_active if supports_custom_metrics else True,
|
|
337
|
+
"distribution": "custom-metrics" if supports_custom_metrics else "upstream-oss",
|
|
338
|
+
"granularFilterActive": granular_filter_active,
|
|
339
|
+
"capabilities": {
|
|
340
|
+
"nativeConfig": True,
|
|
341
|
+
"granularUploadFilter": supports_custom_metrics,
|
|
342
|
+
},
|
|
343
|
+
"filter": health,
|
|
344
|
+
"launchAgent": agent,
|
|
345
|
+
"gitAiVersion": git_ai_version(),
|
|
346
|
+
"paths": {
|
|
347
|
+
"nativeConfig": str(NATIVE_CONFIG_PATH),
|
|
348
|
+
"policyConfig": str(POLICY_CONFIG_PATH),
|
|
349
|
+
"customMetrics": str(CUSTOM_METRICS_PATH),
|
|
350
|
+
"filterScript": str(FILTER_SCRIPT_PATH),
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def run_self_test() -> dict:
|
|
356
|
+
checks = []
|
|
357
|
+
try:
|
|
358
|
+
py_compile.compile(str(FILTER_SCRIPT_PATH), doraise=True)
|
|
359
|
+
checks.append({"name": "过滤脚本语法", "ok": True})
|
|
360
|
+
except Exception as error:
|
|
361
|
+
checks.append({"name": "过滤脚本语法", "ok": False, "message": str(error)})
|
|
362
|
+
|
|
363
|
+
policy = read_json(POLICY_CONFIG_PATH, {})
|
|
364
|
+
try:
|
|
365
|
+
validate_policy_config(policy)
|
|
366
|
+
checks.append({"name": "插件策略格式", "ok": True})
|
|
367
|
+
except Exception as error:
|
|
368
|
+
checks.append({"name": "插件策略格式", "ok": False, "message": str(error)})
|
|
369
|
+
|
|
370
|
+
if custom_metrics_supported():
|
|
371
|
+
checks.append(
|
|
372
|
+
{
|
|
373
|
+
"name": "上报端点路由",
|
|
374
|
+
"ok": custom_metrics_routed(),
|
|
375
|
+
"message": "" if custom_metrics_routed() else "custom_metrics.json 未完整指向本机过滤服务",
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
health = filter_health()
|
|
379
|
+
checks.append(
|
|
380
|
+
{
|
|
381
|
+
"name": "过滤服务连接",
|
|
382
|
+
"ok": bool(health.get("ok")),
|
|
383
|
+
"message": health.get("error", ""),
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
checks.append(
|
|
388
|
+
{
|
|
389
|
+
"name": "细粒度过滤扩展点",
|
|
390
|
+
"ok": False,
|
|
391
|
+
"message": "官方上游版未提供 custom_metrics.json 扩展点;原生配置可用,插件上报规则不会生效",
|
|
392
|
+
}
|
|
393
|
+
)
|
|
394
|
+
return {"ok": all(item["ok"] for item in checks), "checks": checks}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class Handler(BaseHTTPRequestHandler):
|
|
398
|
+
server_version = "git-ai-control-panel/1.0"
|
|
399
|
+
|
|
400
|
+
def log_message(self, fmt, *args):
|
|
401
|
+
print("%s - %s" % (self.log_date_time_string(), fmt % args))
|
|
402
|
+
|
|
403
|
+
def origin_allowed(self) -> bool:
|
|
404
|
+
origin = self.headers.get("Origin")
|
|
405
|
+
if not origin:
|
|
406
|
+
return True
|
|
407
|
+
return origin in {
|
|
408
|
+
f"http://{HOST}:{PORT}",
|
|
409
|
+
f"http://localhost:{PORT}",
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
def do_GET(self):
|
|
413
|
+
path = urlsplit(self.path).path
|
|
414
|
+
if path == "/api/config":
|
|
415
|
+
self.send_json(
|
|
416
|
+
200,
|
|
417
|
+
{
|
|
418
|
+
"gitAi": public_native_config(),
|
|
419
|
+
"policy": read_json(POLICY_CONFIG_PATH, {}),
|
|
420
|
+
"runtime": runtime_status(),
|
|
421
|
+
},
|
|
422
|
+
)
|
|
423
|
+
return
|
|
424
|
+
if path == "/api/status":
|
|
425
|
+
self.send_json(200, runtime_status())
|
|
426
|
+
return
|
|
427
|
+
self.serve_static(path)
|
|
428
|
+
|
|
429
|
+
def do_PUT(self):
|
|
430
|
+
if not self.origin_allowed():
|
|
431
|
+
self.send_json(403, {"ok": False, "message": "请求来源不允许"})
|
|
432
|
+
return
|
|
433
|
+
if urlsplit(self.path).path != "/api/config":
|
|
434
|
+
self.send_json(404, {"ok": False, "message": "接口不存在"})
|
|
435
|
+
return
|
|
436
|
+
try:
|
|
437
|
+
payload = self.read_json_body()
|
|
438
|
+
save_config_bundle(payload)
|
|
439
|
+
self.send_json(
|
|
440
|
+
200,
|
|
441
|
+
{
|
|
442
|
+
"ok": True,
|
|
443
|
+
"message": "配置已保存",
|
|
444
|
+
"gitAi": public_native_config(),
|
|
445
|
+
"policy": read_json(POLICY_CONFIG_PATH, {}),
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
except ConfigError as error:
|
|
449
|
+
self.send_json(400, {"ok": False, "message": str(error)})
|
|
450
|
+
except Exception as error:
|
|
451
|
+
self.send_json(500, {"ok": False, "message": f"保存失败:{error}"})
|
|
452
|
+
|
|
453
|
+
def do_POST(self):
|
|
454
|
+
if not self.origin_allowed():
|
|
455
|
+
self.send_json(403, {"ok": False, "message": "请求来源不允许"})
|
|
456
|
+
return
|
|
457
|
+
path = urlsplit(self.path).path
|
|
458
|
+
if path == "/api/test":
|
|
459
|
+
self.send_json(200, run_self_test())
|
|
460
|
+
return
|
|
461
|
+
if path == "/api/restart-filter":
|
|
462
|
+
result = subprocess.run(
|
|
463
|
+
["launchctl", "kickstart", "-k", FILTER_LAUNCH_AGENT],
|
|
464
|
+
check=False,
|
|
465
|
+
capture_output=True,
|
|
466
|
+
text=True,
|
|
467
|
+
timeout=6,
|
|
468
|
+
)
|
|
469
|
+
self.send_json(
|
|
470
|
+
200 if result.returncode == 0 else 500,
|
|
471
|
+
{
|
|
472
|
+
"ok": result.returncode == 0,
|
|
473
|
+
"message": "过滤服务已重启" if result.returncode == 0 else result.stderr.strip(),
|
|
474
|
+
},
|
|
475
|
+
)
|
|
476
|
+
return
|
|
477
|
+
self.send_json(404, {"ok": False, "message": "接口不存在"})
|
|
478
|
+
|
|
479
|
+
def read_json_body(self):
|
|
480
|
+
length = int(self.headers.get("Content-Length") or "0")
|
|
481
|
+
if length <= 0 or length > 1_000_000:
|
|
482
|
+
raise ConfigError("请求大小无效")
|
|
483
|
+
try:
|
|
484
|
+
return json.loads(self.rfile.read(length).decode("utf-8"))
|
|
485
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
486
|
+
raise ConfigError("请求不是有效 JSON") from error
|
|
487
|
+
|
|
488
|
+
def serve_static(self, path: str):
|
|
489
|
+
relative = "index.html" if path in {"", "/"} else unquote(path).lstrip("/")
|
|
490
|
+
candidate = (STATIC_ROOT / relative).resolve()
|
|
491
|
+
try:
|
|
492
|
+
candidate.relative_to(STATIC_ROOT.resolve())
|
|
493
|
+
except ValueError:
|
|
494
|
+
self.send_error(404)
|
|
495
|
+
return
|
|
496
|
+
if not candidate.is_file():
|
|
497
|
+
self.send_error(404)
|
|
498
|
+
return
|
|
499
|
+
content_type = {
|
|
500
|
+
".html": "text/html; charset=utf-8",
|
|
501
|
+
".css": "text/css; charset=utf-8",
|
|
502
|
+
".js": "text/javascript; charset=utf-8",
|
|
503
|
+
".svg": "image/svg+xml",
|
|
504
|
+
}.get(candidate.suffix, "application/octet-stream")
|
|
505
|
+
data = candidate.read_bytes()
|
|
506
|
+
self.send_response(200)
|
|
507
|
+
self.send_header("Content-Type", content_type)
|
|
508
|
+
self.send_header("Content-Length", str(len(data)))
|
|
509
|
+
self.send_header("Cache-Control", "no-store")
|
|
510
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
511
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
512
|
+
self.send_header("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'")
|
|
513
|
+
self.end_headers()
|
|
514
|
+
self.wfile.write(data)
|
|
515
|
+
|
|
516
|
+
def send_json(self, status: int, payload):
|
|
517
|
+
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
518
|
+
self.send_response(status)
|
|
519
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
520
|
+
self.send_header("Content-Length", str(len(data)))
|
|
521
|
+
self.send_header("Cache-Control", "no-store")
|
|
522
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
523
|
+
self.end_headers()
|
|
524
|
+
self.wfile.write(data)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def main():
|
|
528
|
+
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
529
|
+
print(f"Git AI Control Panel: http://{HOST}:{PORT}", flush=True)
|
|
530
|
+
server.serve_forever()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
if __name__ == "__main__":
|
|
534
|
+
main()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|