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/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()