codebee 0.1.99 → 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 +6 -0
- package/README.md +4 -5
- package/app/core/acp_client.py +426 -0
- package/app/core/backend_profiles.py +188 -0
- package/app/core/builtin_agent.py +12 -0
- 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/openapi.py +32 -0
- package/app/core/pipeline.py +28 -5
- package/app/core/project_memory.py +73 -6
- package/app/core/registry.py +13 -0
- package/app/core/runner.py +13 -1
- package/app/main.py +70 -0
- package/app/ui/app.js +81 -1
- package/app/ui/index.html +19 -0
- package/package.json +1 -1
|
@@ -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/openapi.py
CHANGED
|
@@ -51,6 +51,15 @@ def document():
|
|
|
51
51
|
"items": {"type": "string", "maxLength": 80}},
|
|
52
52
|
"task_id": {"type": "string", "maxLength": 100}, "status": {"type": "string", "maxLength": 30},
|
|
53
53
|
"metadata": {"type": "object"}}},
|
|
54
|
+
"BackendProfile": {"type": "object", "required": ["label", "command"], "properties": {
|
|
55
|
+
"id": {"type": "string"}, "agent_id": {"type": "string"},
|
|
56
|
+
"label": {"type": "string", "maxLength": 80}, "command": {"type": "string", "maxLength": 500},
|
|
57
|
+
"args": {"type": "array", "maxItems": 80, "items": {"type": "string"}},
|
|
58
|
+
"env": {"type": "object", "additionalProperties": {"type": "string"}},
|
|
59
|
+
"workspace_path": {"type": "string"}, "enabled": {"type": "boolean"}}},
|
|
60
|
+
"CommandAuth": {"type": "object", "properties": {
|
|
61
|
+
"mode": {"type": "string", "enum": ["legacy", "restricted"]},
|
|
62
|
+
"pending": {"type": "array"}, "rules": {"type": "array"}, "audit": {"type": "array"}}},
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
def body(schema):
|
|
@@ -106,6 +115,29 @@ def document():
|
|
|
106
115
|
"post": {"summary": "Agent 心跳", "security": security, "requestBody": body(ref("Presence")),
|
|
107
116
|
"responses": {"200": response("心跳已记录", {"type": "object"}), "400": errors["400"],
|
|
108
117
|
"401": errors["401"], "423": errors["423"]}}},
|
|
118
|
+
"/api/backend-profiles": {
|
|
119
|
+
"get": {"summary": "读取 ACP Backend Profiles(密钥脱敏)", "security": security,
|
|
120
|
+
"responses": {"200": response("Profiles", {"type": "object"}), "401": errors["401"]}},
|
|
121
|
+
"post": {"summary": "保存 ACP Backend Profile", "security": security,
|
|
122
|
+
"requestBody": body(ref("BackendProfile")),
|
|
123
|
+
"responses": {"200": response("Profile", {"type": "object"}), "400": errors["400"], "401": errors["401"]}}},
|
|
124
|
+
"/api/backend-profiles/{profile_id}/delete": {
|
|
125
|
+
"post": {"summary": "删除 ACP Backend Profile", "security": security,
|
|
126
|
+
"responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"]}}},
|
|
127
|
+
"/api/command-auth": {
|
|
128
|
+
"get": {"summary": "读取命令授权策略与待处理请求", "security": security,
|
|
129
|
+
"responses": {"200": response("Authorization state", ref("CommandAuth")), "401": errors["401"]}},
|
|
130
|
+
"post": {"summary": "切换命令授权策略", "security": security,
|
|
131
|
+
"requestBody": body({"type": "object", "properties": {"mode": {"type": "string", "enum": ["legacy", "restricted"]}}}),
|
|
132
|
+
"responses": {"200": response("Authorization state", ref("CommandAuth")), "400": errors["400"], "401": errors["401"]}}},
|
|
133
|
+
"/api/command-auth/requests/{request_id}": {
|
|
134
|
+
"post": {"summary": "批准或拒绝等待中的命令", "security": security,
|
|
135
|
+
"requestBody": body({"type": "object", "required": ["approve"], "properties": {
|
|
136
|
+
"approve": {"type": "boolean"}, "scope": {"type": "string"}, "match": {"type": "string"}, "prefix": {"type": "string"}}}),
|
|
137
|
+
"responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"]}}},
|
|
138
|
+
"/api/command-auth/rules/{rule_id}/revoke": {
|
|
139
|
+
"post": {"summary": "撤销命令授权规则", "security": security,
|
|
140
|
+
"responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"]}}},
|
|
109
141
|
"/api/retrieval/search": {"get": {"summary": "本地检索", "security": security,
|
|
110
142
|
"parameters": [{"name": "q", "in": "query", "required": True,
|
|
111
143
|
"schema": {"type": "string", "maxLength": 500}},
|
package/app/core/pipeline.py
CHANGED
|
@@ -596,6 +596,16 @@ def _external_sandbox_block_reason(agent, workdir, sandbox):
|
|
|
596
596
|
if normalized.get("env_allowlist"):
|
|
597
597
|
return "沙箱拒绝:该 CLI 后端未接入环境变量白名单,拒绝运行受限任务"
|
|
598
598
|
return ""
|
|
599
|
+
if (agent or {}).get("kind") == "acp":
|
|
600
|
+
if normalized.get("network") is False:
|
|
601
|
+
return "沙箱拒绝:ACP 后端进程未接入网络隔离,拒绝运行网络受限任务"
|
|
602
|
+
if normalized.get("env_allowlist"):
|
|
603
|
+
return "沙箱拒绝:ACP 后端进程未接入环境变量白名单,拒绝运行受限任务"
|
|
604
|
+
roots = [os.path.realpath(x) for x in normalized.get("allowed_roots") or []]
|
|
605
|
+
if roots and not any(root == allowed or root.startswith(allowed + os.sep)
|
|
606
|
+
for allowed in roots):
|
|
607
|
+
return "沙箱拒绝:任务目录不在 ACP 允许目录内"
|
|
608
|
+
return ""
|
|
599
609
|
# 非 codex 外部 CLI:默认策略放行,收窄策略拒绝
|
|
600
610
|
if normalized.get("network") is False:
|
|
601
611
|
return "沙箱拒绝:该任务要求禁网,外部 CLI 无法执行网络受限任务"
|
|
@@ -818,7 +828,13 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
|
818
828
|
llm_caller = _make_llm_caller(agent, workdir, deadline=deadline)
|
|
819
829
|
call_kwargs = dict(workdir=workdir, readonly=readonly,
|
|
820
830
|
timeout=timeout, cancel_event=ev, log_path=str(log_abs),
|
|
821
|
-
images=images, require_tools=require_tools, deadline=deadline
|
|
831
|
+
images=images, require_tools=require_tools, deadline=deadline,
|
|
832
|
+
command_context={"run_id": session_run_id,
|
|
833
|
+
"sandbox": (store.get_task(
|
|
834
|
+
(store.get_run(session_run_id) or {}).get("task_id")
|
|
835
|
+
) or {}).get("sandbox") or {},
|
|
836
|
+
"allow_terminal": not readonly},
|
|
837
|
+
checkpoint_run_id=session_run_id)
|
|
822
838
|
|
|
823
839
|
def _call(p, **kw):
|
|
824
840
|
nonlocal usage_recorded
|
|
@@ -846,7 +862,13 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
|
846
862
|
res = runner.run_agent(agent, prompt, workdir=workdir, readonly=readonly,
|
|
847
863
|
timeout=timeout, cancel_event=ev, log_path=str(log_abs),
|
|
848
864
|
resume=resume, images=images, require_tools=require_tools,
|
|
849
|
-
deadline=deadline
|
|
865
|
+
deadline=deadline,
|
|
866
|
+
command_context={"run_id": session_run_id,
|
|
867
|
+
"sandbox": (store.get_task(
|
|
868
|
+
(store.get_run(session_run_id) or {}).get("task_id")
|
|
869
|
+
) or {}).get("sandbox") or {},
|
|
870
|
+
"allow_terminal": not readonly},
|
|
871
|
+
checkpoint_run_id=session_run_id)
|
|
850
872
|
# 默认关闭压缩和 resume 都走直通分支,也必须把真实 usage 送进预算表;否则
|
|
851
873
|
# 下一步永远看到 used=0,max_tokens_per_run 只是一个无效设置。
|
|
852
874
|
if not usage_recorded:
|
|
@@ -1561,7 +1583,8 @@ def _run_code(run, task, agents, ev, stats, mode):
|
|
|
1561
1583
|
|
|
1562
1584
|
# 项目记忆既给规划器,也给快速路径的实现者;不能因省掉独立规划而漏掉。
|
|
1563
1585
|
user_context_len = len(task.get("context") or "")
|
|
1564
|
-
project_memory = _read_project_memory(
|
|
1586
|
+
project_memory = _read_project_memory(
|
|
1587
|
+
workdir, query=task.get("goal") or task.get("title") or "")
|
|
1565
1588
|
if project_memory:
|
|
1566
1589
|
task = dict(task, context=(task.get("context") or "") + "\n\n" + project_memory)
|
|
1567
1590
|
|
|
@@ -5382,11 +5405,11 @@ def _write_project_memory(task, workdir, lines, source_run=""):
|
|
|
5382
5405
|
return ""
|
|
5383
5406
|
|
|
5384
5407
|
|
|
5385
|
-
def _read_project_memory(workdir, cap=4000):
|
|
5408
|
+
def _read_project_memory(workdir, cap=4000, query=""):
|
|
5386
5409
|
"""Only inject approved, unexpired and fingerprint-verified memory."""
|
|
5387
5410
|
try:
|
|
5388
5411
|
from . import project_memory
|
|
5389
|
-
return project_memory.active_text(workdir, cap=cap)
|
|
5412
|
+
return project_memory.active_text(workdir, cap=cap, query=query)
|
|
5390
5413
|
except (OSError, ValueError, TypeError):
|
|
5391
5414
|
return ""
|
|
5392
5415
|
|