codebee 0.1.98 → 0.1.100
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 +12 -1
- package/README.md +4 -4
- package/app/core/acp_client.py +426 -0
- package/app/core/backend_profiles.py +188 -0
- package/app/core/builtin_agent.py +48 -37
- package/app/core/command_auth.py +302 -0
- package/app/core/gitmod.py +6 -2
- package/app/core/jobs.py +3 -2
- package/app/core/manager.py +59 -4
- package/app/core/market_remote.py +20 -7
- package/app/core/modelhub.py +21 -5
- package/app/core/openapi.py +32 -0
- package/app/core/pipeline.py +34 -9
- package/app/core/project_memory.py +73 -6
- package/app/core/publish/auto.py +91 -20
- package/app/core/publish/fanqie.py +30 -0
- package/app/core/publish/flows-fanqie-calibrated.json +81 -0
- package/app/core/publish/flows-qimao-calibrated.json +58 -0
- package/app/core/publish/ledger.py +24 -1
- package/app/core/publish/manager.py +36 -9
- package/app/core/publish/qimao.py +17 -0
- package/app/core/quality_gate.py +7 -0
- package/app/core/registry.py +13 -0
- package/app/core/runner.py +13 -1
- package/app/main.py +75 -2
- package/app/ui/app.js +167 -15
- package/app/ui/board.css +5 -1
- package/app/ui/board.js +16 -0
- package/app/ui/i18n.js +26 -1
- package/app/ui/index.html +20 -1
- package/package.json +1 -1
|
@@ -98,15 +98,15 @@ def resolve(provider_id="", model="", difficulty="default"):
|
|
|
98
98
|
if selected not in enabled_names:
|
|
99
99
|
return None
|
|
100
100
|
if not selected:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
p.get("model") or "").strip()
|
|
104
|
-
if not selected:
|
|
101
|
+
# 默认/难度模型键位与 models[] 启停开关不联动:键位指向已停用
|
|
102
|
+
# 模型时对账启用名单回落,不冒充可用(停用的模型不准用)
|
|
105
103
|
try:
|
|
106
|
-
|
|
104
|
+
selected = modelhub._usable_default_model(
|
|
105
|
+
p, (p.get("model_hard") if difficulty == "hard" else
|
|
106
|
+
p.get("model_easy") if difficulty == "easy" else
|
|
107
|
+
p.get("model")) or "")
|
|
107
108
|
except Exception:
|
|
108
|
-
|
|
109
|
-
selected = names[0]["name"] if names else ""
|
|
109
|
+
selected = (p.get("model") or "").strip()
|
|
110
110
|
if selected:
|
|
111
111
|
return {"prov": p, "model": selected,
|
|
112
112
|
"provider_id": p.get("id") or "",
|
|
@@ -125,15 +125,13 @@ def resolve(provider_id="", model="", difficulty="default"):
|
|
|
125
125
|
for p in modelhub.providers():
|
|
126
126
|
if not p.get("enabled", True) or not p.get("api_key"):
|
|
127
127
|
continue
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
names = []
|
|
136
|
-
m = names[0]["name"] if names else ""
|
|
128
|
+
try:
|
|
129
|
+
m = modelhub._usable_default_model(
|
|
130
|
+
p, (p.get("model_hard") if difficulty == "hard" else
|
|
131
|
+
p.get("model_easy") if difficulty == "easy" else
|
|
132
|
+
p.get("model")) or "")
|
|
133
|
+
except Exception:
|
|
134
|
+
m = (p.get("model") or "").strip()
|
|
137
135
|
if m:
|
|
138
136
|
return {"prov": p, "model": m, "provider_id": p.get("id") or "",
|
|
139
137
|
"provider_name": p.get("name") or p.get("id") or ""}
|
|
@@ -422,7 +420,11 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
|
|
|
422
420
|
→ cmd /c 或 /bin/sh -c),仓库唯一的 shell 落点,不另起炉灶。全信任档
|
|
423
421
|
(用户拍板 2026-09-22):无白名单无确认闸,与 codex/claude 全权沙箱同档。
|
|
424
422
|
提权靠模型自己包 Start-Process -Verb RunAs——UAC 必须人点,绕不过也不该绕。
|
|
425
|
-
输出按字符头尾截断(解码已在 run_process 完成,无残缺多字节问题)。
|
|
423
|
+
输出按字符头尾截断(解码已在 run_process 完成,无残缺多字节问题)。
|
|
424
|
+
沙箱闸策略感知(与外部 CLI 闸 b267107 同口径):Linux 有 bwrap 全策略
|
|
425
|
+
强制;Win/Mac 无 OS 后端,进程内无法兑现的收窄仍拒(禁网、env 白名单
|
|
426
|
+
——run_process 的 env 是注入语义滤不掉),默认宽策略保持全信任档直跑,
|
|
427
|
+
目录闸由 path_allowed 兑现。"""
|
|
426
428
|
cmdline = str(args.get("command") or "").strip()
|
|
427
429
|
if not cmdline:
|
|
428
430
|
return "(命令为空,未执行)"
|
|
@@ -432,15 +434,20 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
|
|
|
432
434
|
cwd = os.path.abspath(workdir or ".")
|
|
433
435
|
if not policy.path_allowed(cwd, sandbox):
|
|
434
436
|
return "(沙箱拒绝:工作目录不在允许范围内,命令未执行)"
|
|
435
|
-
#
|
|
436
|
-
#
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
#
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
437
|
+
# Enforce the declared boundary at the process boundary where an OS
|
|
438
|
+
# backend exists (bubblewrap on Linux). Windows/macOS have no equivalent:
|
|
439
|
+
# stay policy-aware like the external-CLI gate (b267107) — the narrowings
|
|
440
|
+
# this in-process runner cannot honor fail closed, while a default wide
|
|
441
|
+
# policy keeps the user-approved full-trust tier (2026-09-22) instead of
|
|
442
|
+
# refusing every command.
|
|
443
|
+
has_bwrap = os.name == "posix" and bool(shutil.which("bwrap"))
|
|
444
|
+
if sandbox.get("network") is False and not has_bwrap:
|
|
445
|
+
return "(沙箱拒绝:当前系统没有可用的网络隔离后端,命令未执行)"
|
|
446
|
+
if sandbox.get("env_allowlist"):
|
|
447
|
+
# run_process 的 env 是注入语义(合并进完整父环境,供凭据注入),
|
|
448
|
+
# 不是白名单替换——环境收窄在子进程侧无法兑现,宁拒不假装
|
|
449
|
+
return "(沙箱拒绝:该任务带环境变量白名单,命令执行无法兑现该策略,未执行)"
|
|
450
|
+
if has_bwrap:
|
|
444
451
|
argv = ["bwrap", "--die-with-parent", "--new-session", "--tmpfs", "/"]
|
|
445
452
|
# Mount only the runtime needed by ordinary commands. Do not expose the
|
|
446
453
|
# entire host root read-only: read access can still leak user secrets.
|
|
@@ -461,18 +468,10 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
|
|
|
461
468
|
argv.extend(["--dir", root_path])
|
|
462
469
|
argv.extend(["--bind", root_path, root_path])
|
|
463
470
|
argv.extend(["--chdir", cwd, "--", "/bin/sh", "-lc", cmdline])
|
|
464
|
-
elif os.name == "posix" and sys.platform == "darwin":
|
|
465
|
-
return "(沙箱拒绝:当前 macOS 执行器尚未接入文件系统隔离后端,命令未执行)"
|
|
466
|
-
elif os.name != "posix":
|
|
467
|
-
return "(沙箱拒绝:当前 Windows 执行器尚未接入文件系统隔离后端,命令未执行)"
|
|
468
471
|
else:
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
list(sandbox.get("env_allowlist") or [])
|
|
473
|
-
+ ["PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "COMSPEC", "HOME", "USERPROFILE"]))
|
|
474
|
-
r = runner.run_process(argv=argv, cwd=cwd,
|
|
475
|
-
env=policy.filter_env(os.environ, env_policy),
|
|
472
|
+
argv = None # 全信任档:runner 按平台自包 cmd /c 或 /bin/sh -c
|
|
473
|
+
r = runner.run_process(argv=argv, shell_cmd=cmdline if argv is None else None,
|
|
474
|
+
cwd=cwd, env=None,
|
|
476
475
|
timeout=min(timeout, CMD_MAX_TIMEOUT),
|
|
477
476
|
deadline=deadline, cancel_event=cancel_event)
|
|
478
477
|
out = runner.clean_cli_text(
|
|
@@ -959,6 +958,18 @@ def _exec_tool(workdir, name, args, cancel_event=None, deadline=None, task_creat
|
|
|
959
958
|
except Exception:
|
|
960
959
|
return "(检查点拒绝:文件原始版本保存失败,未执行文件操作)"
|
|
961
960
|
if name == "run_command":
|
|
961
|
+
try:
|
|
962
|
+
from . import command_auth
|
|
963
|
+
approval = command_auth.authorize(
|
|
964
|
+
str((args or {}).get("command") or ""),
|
|
965
|
+
run_id=checkpoint_run_id, workdir=workdir,
|
|
966
|
+
deadline=deadline, cancel_event=cancel_event,
|
|
967
|
+
source="run_command")
|
|
968
|
+
except Exception:
|
|
969
|
+
approval = {"allowed": False, "reason": "authorization_unavailable"}
|
|
970
|
+
if not approval.get("allowed"):
|
|
971
|
+
reason = approval.get("reason") or "denied"
|
|
972
|
+
return "(命令授权%s,未执行;拒绝后不会自动重放,请模型检查并重新请求)" % reason
|
|
962
973
|
result = fn(workdir, args or {}, cancel_event=cancel_event,
|
|
963
974
|
deadline=deadline, sandbox=sandbox)
|
|
964
975
|
else:
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Command execution policy with live approval requests and redacted audit."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .redact import scrub_text
|
|
15
|
+
|
|
16
|
+
_LOCK = threading.RLock()
|
|
17
|
+
_FILE = None
|
|
18
|
+
_PENDING = {}
|
|
19
|
+
_WAIT_SECONDS = 300
|
|
20
|
+
_MAX_AUDIT = 500
|
|
21
|
+
_MODES = {"legacy", "restricted"}
|
|
22
|
+
_SCOPES = {"once", "run", "project", "global"}
|
|
23
|
+
_MATCHES = {"exact", "prefix", "any"}
|
|
24
|
+
_SHELL_META = re.compile(r"[;&|<>`$\r\n]")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init(data_dir=None):
|
|
28
|
+
global _FILE, _PENDING
|
|
29
|
+
with _LOCK:
|
|
30
|
+
if data_dir is None:
|
|
31
|
+
from . import paths
|
|
32
|
+
base = paths.DATA_DIR
|
|
33
|
+
else:
|
|
34
|
+
base = Path(data_dir)
|
|
35
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
_FILE = base / "command-permissions.json"
|
|
37
|
+
_PENDING = {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _file():
|
|
41
|
+
global _FILE
|
|
42
|
+
if _FILE is None:
|
|
43
|
+
init()
|
|
44
|
+
return _FILE
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _default_state():
|
|
48
|
+
return {"mode": "legacy", "rules": [], "audit": []}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load():
|
|
52
|
+
path = _file()
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
return _default_state()
|
|
57
|
+
except (OSError, ValueError):
|
|
58
|
+
return {"mode": "restricted", "rules": [], "audit": []}
|
|
59
|
+
if not isinstance(data, dict) or data.get("mode") not in _MODES:
|
|
60
|
+
return {"mode": "restricted", "rules": [], "audit": []}
|
|
61
|
+
return {"mode": data["mode"],
|
|
62
|
+
"rules": data.get("rules") if isinstance(data.get("rules"), list) else [],
|
|
63
|
+
"audit": data.get("audit") if isinstance(data.get("audit"), list) else []}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _persist(state):
|
|
67
|
+
path = _file()
|
|
68
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
69
|
+
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
70
|
+
try:
|
|
71
|
+
os.chmod(str(tmp), 0o600)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
os.replace(str(tmp), str(path))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _hash(value):
|
|
78
|
+
return hashlib.sha256(str(value or "").encode("utf-8")).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _path_key(workdir):
|
|
82
|
+
if not workdir:
|
|
83
|
+
return ""
|
|
84
|
+
return _hash(os.path.normcase(os.path.realpath(os.path.expanduser(str(workdir)))))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _scope_key(scope, run_id, workdir):
|
|
88
|
+
if scope == "global":
|
|
89
|
+
return "*"
|
|
90
|
+
if scope == "run":
|
|
91
|
+
return str(run_id or "")
|
|
92
|
+
if scope == "project":
|
|
93
|
+
return _path_key(workdir)
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _prefix_tokens(value):
|
|
98
|
+
return tuple(str(value or "").strip().split())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _rule_matches(rule, command, run_id, workdir):
|
|
102
|
+
scope = rule.get("scope")
|
|
103
|
+
if scope not in ("run", "project", "global"):
|
|
104
|
+
return False
|
|
105
|
+
if _scope_key(scope, run_id, workdir) != rule.get("scope_key"):
|
|
106
|
+
return False
|
|
107
|
+
match = rule.get("match")
|
|
108
|
+
if match == "any":
|
|
109
|
+
return True
|
|
110
|
+
if match == "exact":
|
|
111
|
+
return _hash(command) == rule.get("command_hash")
|
|
112
|
+
if match == "prefix":
|
|
113
|
+
if _SHELL_META.search(command):
|
|
114
|
+
return False
|
|
115
|
+
tokens = _prefix_tokens(command)
|
|
116
|
+
prefix_hashes = rule.get("prefix_hashes") or []
|
|
117
|
+
return (bool(prefix_hashes) and len(tokens) >= len(prefix_hashes) and
|
|
118
|
+
[_hash(x) for x in tokens[:len(prefix_hashes)]] == prefix_hashes)
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _audit(state, action, command, request_id="", scope="", match=""):
|
|
123
|
+
entry = {"id": request_id or uuid.uuid4().hex[:16], "action": action,
|
|
124
|
+
"command": scrub_text(command, limit=240), "command_sha256": _hash(command),
|
|
125
|
+
"scope": scope or "", "match": match or "", "at": time.time()}
|
|
126
|
+
state["audit"] = (state.get("audit") or [])[-(_MAX_AUDIT - 1):] + [entry]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _bump_state():
|
|
130
|
+
try:
|
|
131
|
+
from . import store
|
|
132
|
+
store.bump_state()
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def mode():
|
|
138
|
+
with _LOCK:
|
|
139
|
+
return _load()["mode"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def set_mode(value):
|
|
143
|
+
if value not in _MODES:
|
|
144
|
+
raise ValueError("mode 必须是 legacy/restricted")
|
|
145
|
+
with _LOCK:
|
|
146
|
+
state = _load()
|
|
147
|
+
changed = state["mode"] != value
|
|
148
|
+
state["mode"] = value
|
|
149
|
+
_persist(state)
|
|
150
|
+
if changed:
|
|
151
|
+
for request in _PENDING.values():
|
|
152
|
+
request["decision"] = {"allowed": False, "reason": "policy_changed"}
|
|
153
|
+
request["event"].set()
|
|
154
|
+
if changed:
|
|
155
|
+
_bump_state()
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _make_rule(command, scope, match, run_id, workdir, prefix=None):
|
|
160
|
+
if scope not in ("run", "project", "global"):
|
|
161
|
+
raise ValueError("持久规则 scope 必须是 run/project/global")
|
|
162
|
+
if match not in _MATCHES:
|
|
163
|
+
raise ValueError("match 必须是 exact/prefix/any")
|
|
164
|
+
if scope == "run" and not run_id:
|
|
165
|
+
raise ValueError("当前运行授权缺少 run_id")
|
|
166
|
+
if scope == "project" and not workdir:
|
|
167
|
+
raise ValueError("项目授权缺少工作目录")
|
|
168
|
+
rule = {"id": uuid.uuid4().hex[:16], "scope": scope,
|
|
169
|
+
"scope_key": _scope_key(scope, run_id, workdir), "match": match,
|
|
170
|
+
"command_preview": scrub_text(command if match != "any" else "*", 180),
|
|
171
|
+
"created_at": time.time()}
|
|
172
|
+
if match == "exact":
|
|
173
|
+
rule["command_hash"] = _hash(command)
|
|
174
|
+
elif match == "prefix":
|
|
175
|
+
raw_prefix = str(prefix or command or "").strip()
|
|
176
|
+
if not raw_prefix or _SHELL_META.search(raw_prefix) or _SHELL_META.search(command):
|
|
177
|
+
raise ValueError("前缀授权只支持不含 shell 控制符的单条命令")
|
|
178
|
+
tokens = _prefix_tokens(raw_prefix)
|
|
179
|
+
if not tokens or len(tokens) > len(_prefix_tokens(command)):
|
|
180
|
+
raise ValueError("授权前缀必须是本次请求命令的前缀")
|
|
181
|
+
if _prefix_tokens(command)[:len(tokens)] != tokens:
|
|
182
|
+
raise ValueError("授权前缀必须与本次请求命令开头一致")
|
|
183
|
+
rule["prefix_hashes"] = [_hash(token) for token in tokens]
|
|
184
|
+
rule["command_preview"] = scrub_text(raw_prefix, 180)
|
|
185
|
+
return rule
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def authorize(command, *, run_id="", workdir="", timeout_s=None,
|
|
189
|
+
deadline=None, cancel_event=None, source="run_command"):
|
|
190
|
+
command = str(command or "").strip()
|
|
191
|
+
if not command:
|
|
192
|
+
return {"allowed": False, "reason": "empty_command"}
|
|
193
|
+
with _LOCK:
|
|
194
|
+
state = _load()
|
|
195
|
+
if state["mode"] == "legacy":
|
|
196
|
+
return {"allowed": True, "reason": "legacy"}
|
|
197
|
+
matched = next((rule for rule in reversed(state["rules"])
|
|
198
|
+
if _rule_matches(rule, command, run_id, workdir)), None)
|
|
199
|
+
if matched:
|
|
200
|
+
_audit(state, "allowed_by_rule", command,
|
|
201
|
+
scope=matched.get("scope"), match=matched.get("match"))
|
|
202
|
+
_persist(state)
|
|
203
|
+
return {"allowed": True, "reason": "rule", "rule_id": matched.get("id")}
|
|
204
|
+
wait_s = _WAIT_SECONDS if timeout_s is None else max(0.0, float(timeout_s))
|
|
205
|
+
if deadline is not None:
|
|
206
|
+
wait_s = min(wait_s, max(0.0, float(deadline) - time.monotonic()))
|
|
207
|
+
if wait_s <= 0 or (cancel_event is not None and cancel_event.is_set()):
|
|
208
|
+
reason = "cancelled" if cancel_event is not None and cancel_event.is_set() else "timeout"
|
|
209
|
+
_audit(state, reason, command)
|
|
210
|
+
_persist(state)
|
|
211
|
+
return {"allowed": False, "reason": reason}
|
|
212
|
+
request_id = uuid.uuid4().hex[:16]
|
|
213
|
+
request = {"id": request_id, "command": command, "run_id": str(run_id or ""),
|
|
214
|
+
"workdir": str(workdir or ""), "source": str(source or "")[:40],
|
|
215
|
+
"created_at": time.time(), "event": threading.Event(), "decision": None}
|
|
216
|
+
_PENDING[request_id] = request
|
|
217
|
+
_audit(state, "requested", command, request_id=request_id)
|
|
218
|
+
_persist(state)
|
|
219
|
+
_bump_state()
|
|
220
|
+
expires = time.monotonic() + wait_s
|
|
221
|
+
decision = None
|
|
222
|
+
while time.monotonic() < expires:
|
|
223
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
224
|
+
decision = {"allowed": False, "reason": "cancelled"}
|
|
225
|
+
break
|
|
226
|
+
if request["event"].wait(min(0.2, max(0.0, expires - time.monotonic()))):
|
|
227
|
+
decision = request.get("decision")
|
|
228
|
+
break
|
|
229
|
+
if decision is None:
|
|
230
|
+
decision = {"allowed": False, "reason": "timeout"}
|
|
231
|
+
with _LOCK:
|
|
232
|
+
_PENDING.pop(request_id, None)
|
|
233
|
+
state = _load()
|
|
234
|
+
_audit(state, decision.get("reason") or "denied", command,
|
|
235
|
+
request_id=request_id, scope=decision.get("scope", ""),
|
|
236
|
+
match=decision.get("match", ""))
|
|
237
|
+
_persist(state)
|
|
238
|
+
_bump_state()
|
|
239
|
+
return decision
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def pending_requests():
|
|
243
|
+
with _LOCK:
|
|
244
|
+
rows = []
|
|
245
|
+
for request in _PENDING.values():
|
|
246
|
+
rows.append({"id": request["id"], "command": scrub_text(request["command"], 500),
|
|
247
|
+
"run_id": request["run_id"], "source": request["source"],
|
|
248
|
+
"created_at": request["created_at"]})
|
|
249
|
+
return sorted(rows, key=lambda row: row["created_at"])
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def decide(request_id, *, approve, scope="once", match="exact", prefix=None):
|
|
253
|
+
if not isinstance(approve, bool):
|
|
254
|
+
raise ValueError("approve 必須是布尔值")
|
|
255
|
+
with _LOCK:
|
|
256
|
+
request = _PENDING.get(str(request_id or ""))
|
|
257
|
+
if request is None:
|
|
258
|
+
raise KeyError("命令授权请求不存在或已结束")
|
|
259
|
+
state = _load()
|
|
260
|
+
if approve:
|
|
261
|
+
if scope not in _SCOPES:
|
|
262
|
+
raise ValueError("scope 必须是 once/run/project/global")
|
|
263
|
+
if scope == "once":
|
|
264
|
+
rule = None
|
|
265
|
+
else:
|
|
266
|
+
rule = _make_rule(request["command"], scope, match,
|
|
267
|
+
request["run_id"], request["workdir"], prefix=prefix)
|
|
268
|
+
state["rules"].append(rule)
|
|
269
|
+
request["decision"] = {"allowed": True, "reason": "approved",
|
|
270
|
+
"rule_id": rule.get("id") if rule else "",
|
|
271
|
+
"scope": scope, "match": match}
|
|
272
|
+
_audit(state, "approved", request["command"], request_id=request["id"],
|
|
273
|
+
scope=scope, match=match)
|
|
274
|
+
else:
|
|
275
|
+
request["decision"] = {"allowed": False, "reason": "denied"}
|
|
276
|
+
_audit(state, "denied_by_user", request["command"],
|
|
277
|
+
request_id=request["id"])
|
|
278
|
+
_persist(state)
|
|
279
|
+
request["event"].set()
|
|
280
|
+
_bump_state()
|
|
281
|
+
return {"ok": True}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def revoke(rule_id):
|
|
285
|
+
with _LOCK:
|
|
286
|
+
state = _load()
|
|
287
|
+
remaining = [rule for rule in state["rules"] if rule.get("id") != str(rule_id or "")]
|
|
288
|
+
if len(remaining) == len(state["rules"]):
|
|
289
|
+
raise KeyError("授权规则不存在")
|
|
290
|
+
state["rules"] = remaining
|
|
291
|
+
_audit(state, "revoked", "", request_id=str(rule_id or ""))
|
|
292
|
+
_persist(state)
|
|
293
|
+
_bump_state()
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def view():
|
|
298
|
+
with _LOCK:
|
|
299
|
+
state = _load()
|
|
300
|
+
return {"mode": state["mode"], "rules": [dict(rule) for rule in state["rules"]],
|
|
301
|
+
"pending": pending_requests(),
|
|
302
|
+
"audit": [dict(row) for row in state["audit"][-100:]]}
|
package/app/core/gitmod.py
CHANGED
|
@@ -29,8 +29,12 @@ def valid_rev(rev):
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def _git(workdir, *args, timeout=20):
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
# fetch/pull/push 走 HTTPS 远端与安装链同根因:GitHub 的 HTTP/2 常被中间
|
|
33
|
+
# 设备掐断("Error in the HTTP2 framing layer"),统一强制 HTTP/1.1;
|
|
34
|
+
# 本地命令不受该配置影响。延迟 import 防 L2 层内环。
|
|
35
|
+
from . import manager
|
|
36
|
+
return runner.run_process(argv=["git", *args], cwd=str(workdir), timeout=timeout,
|
|
37
|
+
env=manager.git_http11_env())
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
def _gh(workdir, *args, timeout=60):
|
package/app/core/jobs.py
CHANGED
|
@@ -56,6 +56,8 @@ AI_REPAIR_PROMPT = """你是环境工程师。在 __OS__ 上执行下面的安
|
|
|
56
56
|
硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 __ALLOW__ 之一,且不要带 sudo
|
|
57
57
|
(命令非交互执行,sudo 会挂死;brew/npm 本身也拒绝 sudo)。
|
|
58
58
|
给不出符合约束的安全命令时,safe 设为 false 且 command 留空。
|
|
59
|
+
优先沿用失败命令的包管理器与安装形态(如原命令是 uv tool install 就继续用 uv,
|
|
60
|
+
不要另起 pip 换安装面);git 拉取类的网络抖动直接给原命令即可,传输层兜底由执行方处理。
|
|
59
61
|
|
|
60
62
|
## 失败的命令
|
|
61
63
|
__CMD__
|
|
@@ -1180,8 +1182,7 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
|
1180
1182
|
summary="AI 建议命令未过白名单,需人工执行:%s(诊断:%s)" % (cmd, diagnosis))
|
|
1181
1183
|
return False
|
|
1182
1184
|
from . import paths
|
|
1183
|
-
fix =
|
|
1184
|
-
cancel_event=ev, log_path=str(log_abs))
|
|
1185
|
+
fix = manager.run_install_cmd(cmd, cancel_event=ev, log_path=str(log_abs))
|
|
1185
1186
|
manager.detect_all(force=True)
|
|
1186
1187
|
with manager._LOCK:
|
|
1187
1188
|
manager._STATE["versions"].pop(entry["id"], None)
|
package/app/core/manager.py
CHANGED
|
@@ -2019,6 +2019,64 @@ def _cmd_head_is_uv(cmd):
|
|
|
2019
2019
|
return os.path.basename(stripped.split()[0]).lower() in ("uv", "uv.exe")
|
|
2020
2020
|
|
|
2021
2021
|
|
|
2022
|
+
# pip/uv 的 git+ 直装要从 GitHub 拉源码,国内链路对 GitHub 的 HTTP/2 常被中间设备
|
|
2023
|
+
# 掐断(git 报 "Error in the HTTP2 framing layer",exit 128)——强制 HTTP/1.1 是
|
|
2024
|
+
# 标准绕法。用 GIT_CONFIG_* 环境变量注入,不动用户全局 git 配置;老 git 不识别
|
|
2025
|
+
# 这组变量时静默忽略,无害。
|
|
2026
|
+
_GIT_FLAKE_MARKERS = ("HTTP2 framing layer", "unable to access", "RPC failed",
|
|
2027
|
+
"Connection reset", "connection was reset", "Recv failure")
|
|
2028
|
+
|
|
2029
|
+
|
|
2030
|
+
def git_http11_env():
|
|
2031
|
+
"""git HTTPS 传输层 HTTP/1.1 兜底 env(返回新字典,调用方可自由合并)。
|
|
2032
|
+
|
|
2033
|
+
本模块是安装/工作台/git 插件拉取各执行面的共享源;L0 的 tlsctx 够不着
|
|
2034
|
+
(L2 不准反向 import L0),runner 又在并行在制品里,故落在 manager。"""
|
|
2035
|
+
return {"GIT_CONFIG_COUNT": "1",
|
|
2036
|
+
"GIT_CONFIG_KEY_0": "http.version",
|
|
2037
|
+
"GIT_CONFIG_VALUE_0": "HTTP/1.1"}
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
def _git_safe_env(cmd):
|
|
2041
|
+
"""命令涉及 git+ 远端拉取时的 git 传输层兜底 env(只加键,不覆盖既有)。"""
|
|
2042
|
+
if "git+" not in str(cmd or ""):
|
|
2043
|
+
return {}
|
|
2044
|
+
return git_http11_env()
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def _git_transport_flake(res):
|
|
2048
|
+
"""安装失败输出是否为 git 传输层抖动类错误(值得原地重试一次的那种)。
|
|
2049
|
+
|
|
2050
|
+
接受 run_process 结果字典,也接受纯文本 blob(gitmod/market_remote 的
|
|
2051
|
+
subprocess 输出直接传字符串)。"""
|
|
2052
|
+
if not isinstance(res, str):
|
|
2053
|
+
res = "%s%s" % ((res or {}).get("stderr") or "", (res or {}).get("stdout") or "")
|
|
2054
|
+
return any(m in res for m in _GIT_FLAKE_MARKERS)
|
|
2055
|
+
|
|
2056
|
+
|
|
2057
|
+
def run_install_cmd(cmd, cancel_event=None, log_path=None):
|
|
2058
|
+
"""安装类命令(目录页 install/upgrade 与 AI 修复命令)的统一执行面。
|
|
2059
|
+
|
|
2060
|
+
- uv 系挂镜像兜底(_uv_mirror_env,只补缺不覆盖)
|
|
2061
|
+
- 带 git+ 拉取的命令强制 git 走 HTTP/1.1
|
|
2062
|
+
- git 传输层抖动失败自动同命令重试一次——链路瞬断不值得烧一轮 AI 诊断
|
|
2063
|
+
"""
|
|
2064
|
+
env = {}
|
|
2065
|
+
if _cmd_head_is_uv(cmd):
|
|
2066
|
+
env.update(_uv_mirror_env())
|
|
2067
|
+
env.update(_git_safe_env(cmd))
|
|
2068
|
+
res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT), timeout=1800,
|
|
2069
|
+
cancel_event=cancel_event, log_path=log_path,
|
|
2070
|
+
env=env or None)
|
|
2071
|
+
if not res["ok"] and not res.get("cancelled") and _git_transport_flake(res):
|
|
2072
|
+
res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT), timeout=1800,
|
|
2073
|
+
cancel_event=cancel_event, log_path=log_path,
|
|
2074
|
+
env=env or None,
|
|
2075
|
+
audit_notes=["首跑 git 传输层抖动,同命令自动重试一次"])
|
|
2076
|
+
res["retried"] = True
|
|
2077
|
+
return res
|
|
2078
|
+
|
|
2079
|
+
|
|
2022
2080
|
def _bootstrap_uv(cancel_event=None, log_path=None):
|
|
2023
2081
|
"""uv 不在本机时自动补装一个——安装按钮「点了就该装上」的契约兜底。
|
|
2024
2082
|
|
|
@@ -2096,10 +2154,7 @@ def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
|
|
|
2096
2154
|
"error": "自动安装 uv 未成功(%s)。请手动安装:%s;"
|
|
2097
2155
|
"装完重启 CodeBee,再点一次安装。"
|
|
2098
2156
|
% (boot["detail"], _uv_install_hint())}
|
|
2099
|
-
|
|
2100
|
-
res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT),
|
|
2101
|
-
timeout=1800, cancel_event=cancel_event, log_path=log_path,
|
|
2102
|
-
env=_uv_mirror_env() if _cmd_head_is_uv(cmd) else None)
|
|
2157
|
+
res = run_install_cmd(cmd, cancel_event=cancel_event, log_path=log_path)
|
|
2103
2158
|
winget_noop = _winget_already_ok(cmd, op, res)
|
|
2104
2159
|
detect_all(force=True)
|
|
2105
2160
|
with _LOCK:
|
|
@@ -843,13 +843,26 @@ def _download_git(entry, tmp):
|
|
|
843
843
|
if inst.get("ref"):
|
|
844
844
|
cmd += ["--branch", inst["ref"]]
|
|
845
845
|
cmd += [inst["url"], str(dst)]
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
846
|
+
# git 走 HTTPS 拉远端与安装链同根因:国内链路对 GitHub 的 HTTP/2 常被中间
|
|
847
|
+
# 设备掐断("Error in the HTTP2 framing layer"),manager.git_http11_env()
|
|
848
|
+
# 强制 HTTP/1.1;clone 已是最后一个下载通道,传输层抖动原地重试一次。
|
|
849
|
+
# 延迟 import:模块级挂 L2 重依赖会拖累 market 链加载。
|
|
850
|
+
from . import manager
|
|
851
|
+
env = os.environ.copy()
|
|
852
|
+
env.update(manager.git_http11_env())
|
|
853
|
+
for attempt in (1, 2):
|
|
854
|
+
try:
|
|
855
|
+
proc = subprocess.run(cmd, capture_output=True, timeout=300,
|
|
856
|
+
text=True, encoding="utf-8", errors="replace",
|
|
857
|
+
env=env)
|
|
858
|
+
except subprocess.TimeoutExpired:
|
|
859
|
+
raise ValueError("git 克隆超时: %s" % inst["url"])
|
|
860
|
+
if proc.returncode == 0:
|
|
861
|
+
break
|
|
862
|
+
err = (proc.stderr or "").strip()[-200:]
|
|
863
|
+
if attempt == 1 and manager._git_transport_flake(err):
|
|
864
|
+
continue
|
|
865
|
+
raise ValueError("git 克隆失败: %s" % err)
|
|
853
866
|
root = dst / sub if sub else dst
|
|
854
867
|
# 双保险:解析后必须仍在克隆目录内(防符号链接等绕过)
|
|
855
868
|
if dst.resolve() != root.resolve() and dst.resolve() not in root.resolve().parents:
|
package/app/core/modelhub.py
CHANGED
|
@@ -2177,6 +2177,20 @@ def _model_bindable(prov, model):
|
|
|
2177
2177
|
return True
|
|
2178
2178
|
|
|
2179
2179
|
|
|
2180
|
+
def _usable_default_model(prov, model=""):
|
|
2181
|
+
"""默认模型键位(model / model_easy / model_hard / 编排者配置)对账启用名单。
|
|
2182
|
+
|
|
2183
|
+
默认/难度模型是自由文本,与 models[] 的启停开关互不联动——键位指向已
|
|
2184
|
+
停用/已删除的模型时不能冒充可用:回落该厂商启用名单首个;全停用返回
|
|
2185
|
+
空串(调用方按「无默认模型」处理)。空键位本身合法(用网关默认),
|
|
2186
|
+
也返回空。"""
|
|
2187
|
+
model = (model or "").strip()
|
|
2188
|
+
if model and _model_bindable(prov, model):
|
|
2189
|
+
return model
|
|
2190
|
+
names = [m.get("name") for m in _enabled_models(prov) if m.get("name")]
|
|
2191
|
+
return names[0] if names else ""
|
|
2192
|
+
|
|
2193
|
+
|
|
2180
2194
|
def _model_image_in(prov, model):
|
|
2181
2195
|
"""模型是否声明支持图片输入:models[] 条目 image_in;缺省/查不到=False(纯文本)。"""
|
|
2182
2196
|
for m in ((prov or {}).get("models") or []):
|
|
@@ -2591,7 +2605,7 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2591
2605
|
if len(entries) >= MAX_CHAIN_ATTEMPTS:
|
|
2592
2606
|
break
|
|
2593
2607
|
entries.append(_chain_entry_env(
|
|
2594
|
-
prov, model or prov.get("model")
|
|
2608
|
+
prov, model or _usable_default_model(prov, prov.get("model")),
|
|
2595
2609
|
target=agent_kind_or_id, endpoint=ep, key=kk["key"],
|
|
2596
2610
|
key_id=kk.get("id") or "", provider_id=pid))
|
|
2597
2611
|
if len(entries) >= MAX_CHAIN_ATTEMPTS:
|
|
@@ -2662,6 +2676,8 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2662
2676
|
model = prov.get("model_" + tier) or "" if (routing and tier) else ""
|
|
2663
2677
|
if not model:
|
|
2664
2678
|
model = prov.get("model") or ""
|
|
2679
|
+
if model and not _model_bindable(prov, model):
|
|
2680
|
+
model = "" # 默认/难度模型键位指向停用或已删模型:不冒充可用,回落启用名单
|
|
2665
2681
|
if not model and names:
|
|
2666
2682
|
model = names[0] if (not routing or difficulty != "easy") else names[-1]
|
|
2667
2683
|
if not _evaluation_is_usable(pid, model):
|
|
@@ -2724,7 +2740,9 @@ def recommend_binding(agent_kind_or_id, difficulty="default", task_type="", role
|
|
|
2724
2740
|
names = [m.get("name") for m in _enabled_models(prov)
|
|
2725
2741
|
if isinstance(m, dict) and m.get("name")]
|
|
2726
2742
|
if not names and prov.get("model"):
|
|
2727
|
-
|
|
2743
|
+
dflt = str(prov.get("model"))
|
|
2744
|
+
if _model_bindable(prov, dflt): # 默认模型被停用时不再充作唯一候选
|
|
2745
|
+
names = [dflt]
|
|
2728
2746
|
# 候选阶段不能先截前三个:便宜档往往排在供应商列表后面,easy 任务
|
|
2729
2747
|
# 需要看到完整启用列表后再按成本与档位评分。最终调用链仍受上限约束。
|
|
2730
2748
|
for model in names:
|
|
@@ -3618,9 +3636,7 @@ def resolve_orchestrator():
|
|
|
3618
3636
|
if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
|
|
3619
3637
|
return None
|
|
3620
3638
|
model = (cfg.get("model") or "").strip() or prov.get("model") or ""
|
|
3621
|
-
|
|
3622
|
-
names = _enabled_models(prov)
|
|
3623
|
-
model = names[0]["name"] if names else ""
|
|
3639
|
+
model = _usable_default_model(prov, model) # 配置的模型被停用时回落启用名单,不冒充可用
|
|
3624
3640
|
if not model:
|
|
3625
3641
|
return None
|
|
3626
3642
|
if not _evaluation_is_usable(pid, model):
|