codebee 0.1.99 → 0.1.101

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 CHANGED
@@ -4,6 +4,20 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
+ ## v0.1.101(2026-10-10)
8
+
9
+ - **重写未达标章带上旧账(连载)**:上一轮评审的 major 意见(章节点名+全书级)随重写提示词下发(`__REDO_ISSUES__` 渲染「上一轮评审未达标原因」块),被 major 点名的过线章一并回炉——总判定卡 major 时只重写未过线章消不掉账的空转实案修复,作者第一遍就知道往哪改
10
+ - **评审口径事前下发作者面(连载)**:单章评审清单、全局关注点、签约专项维度与评审面同源提取(单一真源常量装配,两边永不脱节),经 `__REVIEW_RUBRIC__` 注入起草/修订/打磨提示词——作者开写前就知道按什么标准被审;评分机制本体(分值/阈值)刻意不下发
11
+ - **插件安装失败回滚修复**:回滚标记提前绑定+残缺目标自愈重装;此前标记未绑定把人话错误炸成 500 且跳过残留清理(Mac 装 Auto Retry 撞 429 永久撞车实案)
12
+ - **全局兜底错误行带崩溃现场**:任务异常时 traceback 尾帧(文件:行号:函数)追加进错误字段,UI 错误行直接说清死在哪、不必翻 run 目录日志;同因止损签名按崩溃位置区分,代码升级行号漂移自动允许再试一轮
13
+ - **代码块长行自动换行缺省改为开**:运行详情对话里长 JSON/代码行自动折行,不再撑横向滚动条;设置页「长行自动换行」仍可显式关闭
14
+
15
+ ## v0.1.100(2026-10-10)
16
+
17
+ - **ACP 智能体接入(Agent Client Protocol v1)**:设置页新增「ACP Backend Profiles」,可把遵循 ACP v1 的本地/SSH stdio Agent 登记为执行后端(名称/启动命令/参数/工作区),登记后进入智能体注册表、可被编排路由选用;密钥只保存在本机、读取视图始终脱敏
18
+ - **命令执行治理(命令授权)**:新增 legacy / restricted 两档策略,默认 legacy(自动执行,行为不变);切到 restricted 后命令逐条等待批准(5 分钟等待窗口),批准队列在设置页操作,执行留脱敏审计
19
+ - **git 传输抖动统一 HTTP/1.1 兜底**:插件市场 clone 与工作台 git 全量调用统一挂 HTTP/1.1 传输层兜底(trae Mac 实案同类点扫捕),市场源下载抖动原地重试一次,clone 更稳
20
+
7
21
  ## v0.1.99(2026-10-09)
8
22
 
9
23
  - **直连对话「本轮失败 NameError」修复**:内置智能体(CodeBee 直连)每轮起跑数秒即失败,根因是沙箱加固提交把 `sandbox=task.get("sandbox")` 误引入无 task 上下文的内置步骤函数——实参求值即 NameError,Mac/Windows 全平台直连对话不可用。现改为显式传参,任务沙箱策略照常透传生效(回归测试锁定形参与全链)
package/README.md CHANGED
@@ -25,12 +25,13 @@ Trae Agent、OpenClaw……),提供
25
25
  ---
26
26
 
27
27
  <!-- relnotes:start -->
28
- ### 最新版更新内容(v0.1.99)
28
+ ### 最新版更新内容(v0.1.101)
29
29
 
30
- - 修复直连对话每轮起跑即「本轮失败 NameError」:Mac/Windows 全平台直连对话恢复可用
31
- - 内置智能体命令执行沙箱闸策略感知:默认策略照常直跑,仅收窄策略仍拒绝
32
- - 大屏 KPI 长数值不再裁字:禁换行 + 按内容长度三档降字号
33
- - 经验库设置页注入范围文案与全类型实现口径对齐
30
+ - 连载重写带旧账:上一轮评审 major 意见随重写提示词下发,被点名章一并回炉
31
+ - 评审口径事前下发作者面:写作前就知道按什么标准被审(与评审面同源,永不脱节)
32
+ - 插件安装失败回滚修复(人话报错+残缺自愈,不再 500/永久撞车)
33
+ - 错误行带崩溃现场(traceback 尾帧直达 UI,同因止损按位置区分)
34
+ - 代码块长行缺省自动换行,运行详情不再撑横向滚动条
34
35
  <!-- relnotes:end -->
35
36
 
36
37
 
@@ -0,0 +1,426 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Minimal ACP v1 stdio client for locally configured agent processes."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import queue
8
+ import subprocess
9
+ import threading
10
+ import time
11
+ import uuid
12
+
13
+ from . import runner
14
+ from .env_scrub import scrub_env
15
+
16
+ _MAX_LINE = 2 * 1024 * 1024
17
+ _MAX_TEXT = 2 * 1024 * 1024
18
+
19
+
20
+ class ACPError(Exception):
21
+ pass
22
+
23
+
24
+ class _Connection:
25
+ def __init__(self, process):
26
+ self.process = process
27
+ self.incoming = queue.Queue()
28
+ self.write_lock = threading.Lock()
29
+ self.next_id = 1
30
+ self.closed = False
31
+ self.reader = threading.Thread(target=self._read_lines,
32
+ name="acp-stdio-reader", daemon=True)
33
+ self.reader.start()
34
+
35
+ def _read_lines(self):
36
+ try:
37
+ while True:
38
+ line = self.process.stdout.readline()
39
+ if not line:
40
+ break
41
+ if len(line) > _MAX_LINE:
42
+ self.incoming.put({"_transport_error": "ACP 响应超过 2 MiB"})
43
+ break
44
+ try:
45
+ message = json.loads(line.decode("utf-8") if isinstance(line, bytes)
46
+ else line)
47
+ if isinstance(message, dict):
48
+ self.incoming.put(message)
49
+ except (ValueError, UnicodeDecodeError):
50
+ self.incoming.put({"_transport_error": "ACP 返回了无效 JSON"})
51
+ except Exception as exc:
52
+ self.incoming.put({"_transport_error": "ACP 读取失败:%s" % exc})
53
+ finally:
54
+ self.incoming.put(None)
55
+
56
+ def send(self, message):
57
+ raw = (json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
58
+ with self.write_lock:
59
+ if self.closed or self.process.poll() is not None:
60
+ raise ACPError("ACP 进程已退出")
61
+ try:
62
+ self.process.stdin.write(raw.encode("utf-8"))
63
+ except TypeError:
64
+ self.process.stdin.write(raw)
65
+ self.process.stdin.flush()
66
+
67
+ def request(self, method, params, deadline, cancel_event, on_message):
68
+ request_id = self.next_id
69
+ self.next_id += 1
70
+ self.send({"jsonrpc": "2.0", "id": request_id,
71
+ "method": method, "params": params})
72
+ while True:
73
+ if cancel_event is not None and cancel_event.is_set():
74
+ self.send({"jsonrpc": "2.0", "method": "session/cancel",
75
+ "params": {"sessionId": params.get("sessionId", "")}})
76
+ raise InterruptedError("ACP 任务已取消")
77
+ if deadline is not None and time.monotonic() >= deadline:
78
+ self.send({"jsonrpc": "2.0", "method": "session/cancel",
79
+ "params": {"sessionId": params.get("sessionId", "")}})
80
+ raise TimeoutError("ACP 任务总时限已到")
81
+ wait = 0.2
82
+ if deadline is not None:
83
+ wait = min(wait, max(0.01, deadline - time.monotonic()))
84
+ try:
85
+ message = self.incoming.get(timeout=wait)
86
+ except queue.Empty:
87
+ if self.process.poll() is not None and self.incoming.empty():
88
+ raise ACPError("ACP 进程退出码 %s" % self.process.poll())
89
+ continue
90
+ if message is None:
91
+ raise ACPError("ACP 连接已关闭")
92
+ if message.get("_transport_error"):
93
+ raise ACPError(message["_transport_error"])
94
+ if message.get("id") == request_id and ("result" in message or "error" in message):
95
+ if "error" in message:
96
+ error = message.get("error") or {}
97
+ raise ACPError("ACP %s 失败:%s" %
98
+ (method, str(error.get("message") or error)[:500]))
99
+ return message.get("result") or {}
100
+ if message.get("method"):
101
+ response = on_message(message)
102
+ if response is not None:
103
+ self.send(response)
104
+
105
+ def close(self):
106
+ self.closed = True
107
+ try:
108
+ if self.process.poll() is None:
109
+ self.process.stdin.close()
110
+ except Exception:
111
+ pass
112
+ try:
113
+ self.process.wait(timeout=0.5)
114
+ except Exception:
115
+ try:
116
+ runner._kill_tree(self.process.pid)
117
+ except Exception:
118
+ try:
119
+ self.process.kill()
120
+ except Exception:
121
+ pass
122
+ for stream_name in ("stdout", "stderr"):
123
+ try:
124
+ getattr(self.process, stream_name).close()
125
+ except Exception:
126
+ pass
127
+
128
+
129
+ def _profile_runtime(agent):
130
+ if not agent.get("profile_managed"):
131
+ return dict(agent)
132
+ from . import backend_profiles
133
+ profile = backend_profiles.get_runtime(agent.get("profile_id"))
134
+ if not profile:
135
+ raise ACPError("ACP Backend Profile 不存在或已停用")
136
+ merged = dict(agent)
137
+ merged.update({"command": profile["command"], "args": profile["args"],
138
+ "env": profile["env"],
139
+ "workspace_path": profile.get("workspace_path") or ""})
140
+ return merged
141
+
142
+
143
+ def _path(workdir, configured_root, requested):
144
+ from pathlib import Path
145
+ root = Path(workdir or ".").expanduser().resolve(strict=True)
146
+ raw = str(requested or "")
147
+ if not raw or "\x00" in raw:
148
+ raise ValueError("ACP 文件路径无效")
149
+ configured = str(configured_root or "").rstrip("/\\")
150
+ if configured and (raw == configured or raw.startswith(configured + "/") or
151
+ raw.startswith(configured + "\\")):
152
+ rel = raw[len(configured):].lstrip("/\\")
153
+ target = root.joinpath(*rel.replace("\\", "/").split("/"))
154
+ else:
155
+ target = Path(raw).expanduser()
156
+ if not target.is_absolute():
157
+ raise ValueError("ACP 文件路径必须映射到当前工作区")
158
+ resolved = target.resolve(strict=False)
159
+ if resolved != root and root not in resolved.parents:
160
+ raise ValueError("ACP 文件路径超出当前工作区")
161
+ return root, resolved
162
+
163
+
164
+ def _fs_request(method, params, workdir, configured_root, readonly, checkpoint_run_id):
165
+ from . import checkpoints
166
+ root, target = _path(workdir, configured_root, params.get("path"))
167
+ if method == "fs/read_text_file":
168
+ try:
169
+ content = target.read_text(encoding="utf-8")
170
+ except (OSError, UnicodeError) as exc:
171
+ raise ValueError("文件不可读取:%s" % exc)
172
+ lines = content.splitlines(keepends=True)
173
+ line = max(1, int(params.get("line") or 1))
174
+ limit = max(1, min(10000, int(params.get("limit") or 10000)))
175
+ if line > 1 or len(lines) > limit:
176
+ content = "".join(lines[line - 1:line - 1 + limit])
177
+ return {"content": content}
178
+ if method == "fs/write_text_file":
179
+ if readonly:
180
+ raise ValueError("此步骤为只读模式")
181
+ content = params.get("content")
182
+ if not isinstance(content, str) or len(content.encode("utf-8")) > 16 * 1024 * 1024:
183
+ raise ValueError("写入内容无效或超过 16 MiB")
184
+ if checkpoint_run_id:
185
+ saved = checkpoints.capture_file(checkpoint_run_id, str(root), target)
186
+ if not saved.get("ok"):
187
+ raise ValueError("检查点无法保存原始文件,拒绝写入")
188
+ target.parent.mkdir(parents=True, exist_ok=True)
189
+ # Keep Python 3.8 compatibility: pathlib.Path.write_text did not yet
190
+ # accept ``newline`` on all supported runtimes.
191
+ with target.open("w", encoding="utf-8", newline="") as handle:
192
+ handle.write(content)
193
+ if checkpoint_run_id and not checkpoints.mark_file_after(
194
+ checkpoint_run_id, str(root), target):
195
+ raise ValueError("检查点无法记录写入后的状态")
196
+ return {}
197
+ raise ValueError("不支持的 ACP 文件系统方法")
198
+
199
+
200
+ def _agent_request(message, *, connection, session_id, workdir, configured_root,
201
+ readonly, checkpoint_run_id, cancel_event, deadline, sandbox,
202
+ command_context, terminals):
203
+ method = message.get("method")
204
+ params = message.get("params") or {}
205
+ request_id = message.get("id")
206
+ try:
207
+ if method in ("fs/read_text_file", "fs/write_text_file"):
208
+ blocked = set(sandbox.get("disabled_tools") or [])
209
+ required = "read_file" if method == "fs/read_text_file" else "write_file"
210
+ if required in blocked:
211
+ raise ValueError("沙箱策略已禁用 %s" % required)
212
+ result = _fs_request(method, params, workdir, configured_root,
213
+ readonly, checkpoint_run_id)
214
+ elif method == "session/request_permission":
215
+ result = {"outcome": {"outcome": "cancelled"}}
216
+ elif method == "terminal/create":
217
+ if readonly:
218
+ raise ValueError("此步骤禁止执行终端命令")
219
+ if not command_context.get("allow_terminal", True):
220
+ raise ValueError("ACP 终端能力已禁用")
221
+ command = str(params.get("command") or "").strip()
222
+ args = params.get("args") or []
223
+ if not command or not isinstance(args, list) or len(args) > 100:
224
+ raise ValueError("ACP 命令参数无效")
225
+ if any(not isinstance(arg, str) or "\x00" in arg or len(arg) > 8192
226
+ for arg in args):
227
+ raise ValueError("ACP 命令参数无效")
228
+ cwd = str(params.get("cwd") or workdir or "")
229
+ from . import policy
230
+ if not policy.path_allowed(cwd, sandbox):
231
+ raise ValueError("沙箱拒绝:终端工作目录超出允许范围")
232
+ if os.path.realpath(cwd) != os.path.realpath(workdir or "."):
233
+ raise ValueError("ACP 终端目前仅允许在任务工作目录执行")
234
+ from .builtin_agent import _exec_tool
235
+ import shlex
236
+ terminal_id = "term-" + uuid.uuid4().hex[:16]
237
+ terminal = {"output": "", "done": threading.Event(),
238
+ "cancel": threading.Event(), "released": False}
239
+ terminals[terminal_id] = terminal
240
+
241
+ def _run_terminal():
242
+ rendered = shlex.join([command] + args)
243
+ terminal["output"] = _exec_tool(
244
+ workdir, "run_command", {"command": rendered},
245
+ cancel_event=terminal["cancel"], deadline=deadline,
246
+ disabled_tools=sandbox.get("disabled_tools"), sandbox=sandbox,
247
+ checkpoint_run_id=command_context.get("run_id", ""))
248
+ terminal["done"].set()
249
+
250
+ threading.Thread(target=_run_terminal, name="acp-terminal", daemon=True).start()
251
+ result = {"terminalId": terminal_id}
252
+ elif method == "terminal/output":
253
+ terminal = terminals.get(str(params.get("terminalId") or ""))
254
+ if not terminal:
255
+ raise ValueError("terminalId 不存在或已释放")
256
+ output = terminal["output"]
257
+ marker = "退出码: "
258
+ code = None
259
+ if marker in output:
260
+ try:
261
+ code = int(output.split(marker, 1)[1].splitlines()[0].strip())
262
+ except (ValueError, IndexError):
263
+ pass
264
+ limit = max(1, min(4 * 1024 * 1024,
265
+ int(params.get("outputByteLimit") or 1024 * 1024)))
266
+ encoded = output.encode("utf-8")
267
+ truncated = len(encoded) > limit
268
+ if truncated:
269
+ output = encoded[-limit:].decode("utf-8", errors="ignore")
270
+ result = {"output": output, "truncated": truncated,
271
+ "exitStatus": {"exitCode": code, "signal": None}}
272
+ elif method == "terminal/wait_for_exit":
273
+ terminal = terminals.get(str(params.get("terminalId") or ""))
274
+ if not terminal:
275
+ raise ValueError("terminalId 不存在或已释放")
276
+ while not terminal["done"].wait(0.1):
277
+ if cancel_event is not None and cancel_event.is_set():
278
+ terminal["cancel"].set()
279
+ if deadline is not None and time.monotonic() >= deadline:
280
+ terminal["cancel"].set()
281
+ raise TimeoutError("ACP 终端等待超过任务时限")
282
+ code = None
283
+ if "退出码: " in terminal["output"]:
284
+ try:
285
+ code = int(terminal["output"].split("退出码: ", 1)[1].splitlines()[0])
286
+ except (ValueError, IndexError):
287
+ pass
288
+ result = {"exitCode": code, "signal": None}
289
+ elif method == "terminal/kill":
290
+ terminal = terminals.get(str(params.get("terminalId") or ""))
291
+ if not terminal:
292
+ raise ValueError("terminalId 不存在或已释放")
293
+ terminal["cancel"].set()
294
+ result = {}
295
+ elif method == "terminal/release":
296
+ terminal = terminals.pop(str(params.get("terminalId") or ""), None)
297
+ if not terminal:
298
+ raise ValueError("terminalId 不存在或已释放")
299
+ if not terminal["done"].is_set():
300
+ terminal["cancel"].set()
301
+ result = {}
302
+ else:
303
+ raise ValueError("不支持的 ACP client 方法:%s" % str(method or "")[:80])
304
+ return {"jsonrpc": "2.0", "id": request_id, "result": result}
305
+ except Exception as exc:
306
+ return {"jsonrpc": "2.0", "id": request_id,
307
+ "error": {"code": -32000, "message": str(exc)[:500]}}
308
+
309
+
310
+ def run_agent(agent, prompt, workdir=None, readonly=True, timeout=1200,
311
+ cancel_event=None, deadline=None, command_context=None,
312
+ checkpoint_run_id="", resume=None):
313
+ """Run an ACP v1 agent over stdio and return the common runner result shape."""
314
+ started = time.monotonic()
315
+ process = None
316
+ connection = None
317
+ command_context = dict(command_context or {})
318
+ try:
319
+ profile = _profile_runtime(agent)
320
+ cwd = os.path.realpath(workdir or ".")
321
+ if not os.path.isdir(cwd):
322
+ raise ACPError("ACP 工作目录不可用")
323
+ command = str(profile.get("command") or "").strip()
324
+ args = profile.get("args") or []
325
+ if not command or not isinstance(args, list) or any(not isinstance(x, str) for x in args):
326
+ raise ACPError("ACP 启动命令配置无效")
327
+ env = scrub_env(os.environ.copy())
328
+ env.update({str(k): str(v) for k, v in (profile.get("env") or {}).items()})
329
+ argv = runner.resolve_command(command) + args
330
+ process = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
331
+ stderr=subprocess.PIPE, cwd=cwd, env=env,
332
+ bufsize=0, start_new_session=(os.name != "nt"),
333
+ creationflags=runner.CREATE_NO_WINDOW)
334
+ connection = _Connection(process)
335
+ absolute_deadline = deadline
336
+ if absolute_deadline is None:
337
+ absolute_deadline = started + max(1.0, float(timeout or 1200))
338
+ text_parts = []
339
+ terminals = {}
340
+ session_id = str(resume or "")
341
+
342
+ def on_message(message):
343
+ nonlocal session_id
344
+ if message.get("method") == "session/update":
345
+ params = message.get("params") or {}
346
+ update = params.get("update") or {}
347
+ if params.get("sessionId") == session_id and \
348
+ update.get("sessionUpdate") == "agent_message_chunk":
349
+ content = update.get("content") or {}
350
+ if content.get("type") == "text":
351
+ piece = str(content.get("text") or "")
352
+ if sum(map(len, text_parts)) < _MAX_TEXT:
353
+ text_parts.append(piece[:_MAX_TEXT - sum(map(len, text_parts))])
354
+ return None
355
+ return _agent_request(
356
+ message, connection=connection, session_id=session_id,
357
+ workdir=cwd, configured_root=profile.get("workspace_path"),
358
+ readonly=readonly, checkpoint_run_id=checkpoint_run_id,
359
+ cancel_event=cancel_event, deadline=absolute_deadline,
360
+ sandbox=command_context.get("sandbox") or {},
361
+ command_context=command_context, terminals=terminals)
362
+
363
+ client_capabilities = {"fs": {"readTextFile": True,
364
+ "writeTextFile": not readonly},
365
+ "terminal": not readonly and
366
+ command_context.get("allow_terminal", True)}
367
+ initialized = connection.request("initialize", {
368
+ "protocolVersion": 1, "clientCapabilities": client_capabilities,
369
+ "clientInfo": {"name": "CodeBee", "title": "CodeBee", "version": "1"}},
370
+ absolute_deadline, cancel_event, on_message)
371
+ if int(initialized.get("protocolVersion") or 0) != 1:
372
+ raise ACPError("ACP 版本不兼容:服务端未协商 protocolVersion 1")
373
+ mcp_servers = []
374
+ if session_id:
375
+ capabilities = initialized.get("agentCapabilities") or {}
376
+ if not capabilities.get("loadSession"):
377
+ raise ACPError("此 ACP Agent 不支持恢复会话")
378
+ connection.request("session/load", {
379
+ "sessionId": session_id,
380
+ "cwd": profile.get("workspace_path") or cwd,
381
+ "mcpServers": mcp_servers}, absolute_deadline, cancel_event, on_message)
382
+ else:
383
+ session = connection.request("session/new", {
384
+ "cwd": profile.get("workspace_path") or cwd,
385
+ "mcpServers": mcp_servers}, absolute_deadline, cancel_event, on_message)
386
+ session_id = str(session.get("sessionId") or "")
387
+ if not session_id:
388
+ raise ACPError("ACP 未返回 sessionId")
389
+ connection.request("session/prompt", {
390
+ "sessionId": session_id,
391
+ "prompt": [{"type": "text", "text": str(prompt or "")}]},
392
+ absolute_deadline, cancel_event, on_message)
393
+ if not text_parts:
394
+ raise ACPError("ACP 完成但未返回文本消息")
395
+ return {"ok": True, "text": "".join(text_parts)[:_MAX_TEXT], "json": None,
396
+ "cost_usd": 0.0, "tokens": 0, "usage": None, "error": "",
397
+ "error_code": "", "raw": {"exit_code": 0,
398
+ "duration": time.monotonic() - started},
399
+ "kind": "acp", "model": None, "sid": session_id,
400
+ "attempts": []}
401
+ except InterruptedError as exc:
402
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
403
+ "tokens": 0, "usage": None, "error": str(exc),
404
+ "raw": {"exit_code": None, "cancelled": True,
405
+ "duration": time.monotonic() - started},
406
+ "kind": "acp", "model": None, "attempts": []}
407
+ except TimeoutError as exc:
408
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
409
+ "tokens": 0, "usage": None, "error": str(exc),
410
+ "raw": {"exit_code": None, "timed_out": True,
411
+ "deadline_exceeded": deadline is not None,
412
+ "duration": time.monotonic() - started},
413
+ "kind": "acp", "model": None, "attempts": []}
414
+ except Exception as exc:
415
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
416
+ "tokens": 0, "usage": None, "error": str(exc)[:1000],
417
+ "raw": {"exit_code": None, "duration": time.monotonic() - started},
418
+ "kind": "acp", "model": None, "attempts": []}
419
+ finally:
420
+ if connection is not None:
421
+ connection.close()
422
+ elif process is not None:
423
+ try:
424
+ runner._kill_tree(process.pid)
425
+ except Exception:
426
+ pass
@@ -0,0 +1,188 @@
1
+ # -*- coding: utf-8 -*-
2
+ """User-managed ACP agent launch profiles with secret-redacted views."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import threading
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ from .redact import scrub_text
13
+
14
+ _LOCK = threading.RLock()
15
+ _FILE = None
16
+ _SECRET_NAME = re.compile(r"(?i)(token|secret|password|passwd|api[_-]?key|credential)")
17
+ _PROFILE_ID = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
18
+ _ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
19
+
20
+
21
+ def init(data_dir=None):
22
+ global _FILE
23
+ with _LOCK:
24
+ if data_dir is None:
25
+ from . import paths
26
+ base = paths.DATA_DIR
27
+ else:
28
+ base = Path(data_dir)
29
+ base.mkdir(parents=True, exist_ok=True)
30
+ _FILE = base / "backend-profiles.json"
31
+
32
+
33
+ def _file():
34
+ global _FILE
35
+ if _FILE is None:
36
+ init()
37
+ return _FILE
38
+
39
+
40
+ def _load():
41
+ path = _file()
42
+ try:
43
+ data = json.loads(path.read_text(encoding="utf-8"))
44
+ except FileNotFoundError:
45
+ return []
46
+ except (OSError, ValueError) as exc:
47
+ raise ValueError("ACP 后端配置不可读取,已阻止覆盖") from exc
48
+ profiles = data.get("profiles") if isinstance(data, dict) else None
49
+ if not isinstance(profiles, list):
50
+ raise ValueError("ACP 后端配置格式无效")
51
+ return [row for row in profiles if isinstance(row, dict)][:50]
52
+
53
+
54
+ def _persist(profiles):
55
+ path = _file()
56
+ tmp = path.with_name(path.name + ".tmp")
57
+ tmp.write_text(json.dumps({"profiles": profiles}, ensure_ascii=False, indent=2),
58
+ encoding="utf-8")
59
+ try:
60
+ os.chmod(str(tmp), 0o600)
61
+ except OSError:
62
+ pass
63
+ os.replace(str(tmp), str(path))
64
+
65
+
66
+ def _valid_workspace_path(value):
67
+ value = str(value or "").strip()
68
+ if not value:
69
+ return ""
70
+ if "\x00" in value or len(value) > 1024:
71
+ raise ValueError("workspace_path 无效")
72
+ if not (value.startswith("/") or value.startswith("\\") or
73
+ re.match(r"^[A-Za-z]:[\\/]", value)):
74
+ raise ValueError("workspace_path 必须是绝对路径")
75
+ return value
76
+
77
+
78
+ def _normalize(payload, existing=None):
79
+ if not isinstance(payload, dict):
80
+ raise ValueError("profile 必须是 JSON 对象")
81
+ old = existing or {}
82
+ profile_id = str(payload.get("id") or old.get("id") or
83
+ ("backend-" + uuid.uuid4().hex[:16])).strip()
84
+ if not _PROFILE_ID.fullmatch(profile_id):
85
+ raise ValueError("profile id 格式无效")
86
+ label = str(payload.get("label") or "").strip()
87
+ command = str(payload.get("command") or "").strip()
88
+ if not label or len(label) > 80:
89
+ raise ValueError("label 必填且不能超过 80 个字符")
90
+ if not command or len(command) > 500 or "\x00" in command:
91
+ raise ValueError("command 必填且格式无效")
92
+ raw_args = payload.get("args", old.get("args", []))
93
+ if not isinstance(raw_args, list) or len(raw_args) > 80:
94
+ raise ValueError("args 必须是最多 80 项的列表")
95
+ args = []
96
+ for value in raw_args:
97
+ if not isinstance(value, str) or len(value) > 2048 or "\x00" in value:
98
+ raise ValueError("args 只能包含不超过 2048 字符的文本")
99
+ args.append(value)
100
+ raw_env = payload.get("env", old.get("env", {}))
101
+ if not isinstance(raw_env, dict) or len(raw_env) > 100:
102
+ raise ValueError("env 必须是最多 100 项的对象")
103
+ env = {}
104
+ old_env = old.get("env") or {}
105
+ for name, value in raw_env.items():
106
+ name = str(name)
107
+ if not _ENV_NAME.fullmatch(name) or not isinstance(value, str) \
108
+ or len(value) > 8192 or "\x00" in value:
109
+ raise ValueError("env 变量名或值无效")
110
+ if value == "__REDACTED__" and name in old_env:
111
+ value = old_env[name]
112
+ env[name] = value
113
+ enabled = payload.get("enabled", old.get("enabled", True))
114
+ if not isinstance(enabled, bool):
115
+ raise ValueError("enabled 必须是布尔值")
116
+ return {"id": profile_id, "agent_id": "acp-" + profile_id,
117
+ "label": label, "command": command, "args": args,
118
+ "env": env, "workspace_path": _valid_workspace_path(
119
+ payload.get("workspace_path", old.get("workspace_path", ""))),
120
+ "enabled": enabled}
121
+
122
+
123
+ def _view(profile):
124
+ row = dict(profile)
125
+ row["command"] = scrub_text(profile.get("command") or "", 500)
126
+ row["args"] = [scrub_text(value, 2048) for value in profile.get("args") or []]
127
+ env = {}
128
+ for name, value in (profile.get("env") or {}).items():
129
+ env[name] = "__REDACTED__" if _SECRET_NAME.search(name) and value else value
130
+ row["env"] = env
131
+ return row
132
+
133
+
134
+ def list_profiles():
135
+ with _LOCK:
136
+ return [_view(row) for row in _load()]
137
+
138
+
139
+ def runtime_profiles():
140
+ with _LOCK:
141
+ return [dict(row, env=dict(row.get("env") or {})) for row in _load()
142
+ if row.get("enabled", True)]
143
+
144
+
145
+ def get_runtime(profile_id):
146
+ with _LOCK:
147
+ return next((dict(row, env=dict(row.get("env") or {}))
148
+ for row in _load() if row.get("id") == str(profile_id or "")
149
+ and row.get("enabled", True)), None)
150
+
151
+
152
+ def runtime_agents():
153
+ return [{"id": row["agent_id"], "profile_id": row["id"],
154
+ "label": row["label"], "kind": "acp", "mode": "real",
155
+ "command": row["command"], "args": list(row["args"]),
156
+ "env": {}, "workspace_path": row["workspace_path"],
157
+ "profile_managed": True}
158
+ for row in runtime_profiles()]
159
+
160
+
161
+ def save(payload):
162
+ with _LOCK:
163
+ profiles = _load()
164
+ requested_id = str((payload or {}).get("id") or "")
165
+ old = next((row for row in profiles if row.get("id") == requested_id), None)
166
+ if requested_id and old is None:
167
+ raise KeyError("ACP 后端配置不存在")
168
+ profile = _normalize(payload, old)
169
+ if any(row.get("id") == profile["id"] and row is not old for row in profiles):
170
+ raise ValueError("profile id 已存在")
171
+ if old is None and len(profiles) >= 50:
172
+ raise ValueError("ACP 后端最多配置 50 个")
173
+ if old is None:
174
+ profiles.append(profile)
175
+ else:
176
+ profiles[profiles.index(old)] = profile
177
+ _persist(profiles)
178
+ return _view(profile)
179
+
180
+
181
+ def remove(profile_id):
182
+ with _LOCK:
183
+ profiles = _load()
184
+ remaining = [row for row in profiles if row.get("id") != str(profile_id or "")]
185
+ if len(remaining) == len(profiles):
186
+ raise KeyError("ACP 后端配置不存在")
187
+ _persist(remaining)
188
+ return True
@@ -958,6 +958,18 @@ def _exec_tool(workdir, name, args, cancel_event=None, deadline=None, task_creat
958
958
  except Exception:
959
959
  return "(检查点拒绝:文件原始版本保存失败,未执行文件操作)"
960
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
961
973
  result = fn(workdir, args or {}, cancel_event=cancel_event,
962
974
  deadline=deadline, sandbox=sandbox)
963
975
  else: