Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ def get_local_permission_defaults(system: str | None = None) -> dict:
"read_only": True,
"report_via_conversation": True,
"workspace_root": "",
"coding_agents": [],
},
"work_session": {"max_age_seconds": 3600},
"plugin_routes": [],
Expand Down Expand Up @@ -4865,6 +4866,16 @@ def get_local_permission_defaults(system: str | None = None) -> dict:
"hint": "每个委派任务在此目录下拥有独立文件夹。留空时使用数据目录下的 btw/workspaces。",
"condition": {"btw.work_loop.enabled": True},
},
"btw.work_loop.coding_agents": {
"description": "第三方编码代理",
"type": "list",
"hint": "可委派写入任务的本地 CLI 代理(如 Claude Code、Codex)。每项包含 id、type、command、权限模式与 provider 预设;provider 会以该 CLI 原生配置层的形式生效,不改动用户全局配置。权限模式与沙箱是该 CLI 自己执行的策略,不是操作系统级隔离。委派会启动本地进程并写入文件,因此还要求工作循环的 Computer Use 运行时为 local(sandbox 下委派等于绕过沙箱),并通过 tool.local_exec 与 tool.file_write 的授权。Claude Code 以非交互方式(-p)运行,没有终端可以回答权限询问:acceptEdits 只自动放行编辑,需要跑 shell 命令的任务会一直等到超时,这类任务要显式选择 bypassPermissions。",
"_special": "select_coding_agents",
# The editor is a list of cards holding a nested preset list; half
# of a row is not enough to lay one out.
"full_width": True,
"condition": {"btw.work_loop.enabled": True},
},
"btw.work_session.max_age_seconds": {
"description": "终态工作会话保留秒数",
"type": "int",
Expand Down
137 changes: 118 additions & 19 deletions astrbot/dashboard/services/config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,98 @@ def _is_sensitive_config_key(key: str | None) -> bool:
return normalized.endswith(SENSITIVE_CONFIG_SUFFIXES)


def _item_identity(item: Any) -> str | None:
"""Return the ``id`` that names a list item, when it carries one."""
if not isinstance(item, dict):
return None
identity = item.get("id")
return identity if isinstance(identity, str) and identity else None


def _items_by_identity(items: list) -> dict[str, int]:
"""Map each item's ``id`` to its position, or ``{}`` when that does not fit.

A list of objects is read by identity only when every item carries a unique,
non-empty ``id``. Anything else -- a list of scalars, a repeated or missing
id -- leaves position as the only reading the two lists share.
"""
by_id: dict[str, int] = {}
for index, item in enumerate(items):
identity = _item_identity(item)
if identity is None or identity in by_id:
return {}
by_id[identity] = index
return by_id


def _stored_twins(posted: list, current: list) -> list[Any]:
"""Return the stored item each posted item is the same entry as, if any.

An entry that carries an ``id`` is matched by it, so that moving, removing
or copying one cannot hand it a neighbour's stored value; that is the
failure this exists to prevent, and it is also what makes the match survive
a reordering. An entry the stored list does not name -- a new one, or one
the client renamed, which it has no way to say -- falls back to its own
position, but only while that stored item is not already claimed by a name,
so a fallback can never hand the same stored value out twice.
"""
current_by_id = _items_by_identity(current)
if not current_by_id:
return [
current[index] if index < len(current) else None
for index in range(len(posted))
]

twins: list[Any] = [None] * len(posted)
matched: set[int] = set()
claimed: set[int] = set()
for index, item in enumerate(posted):
position = current_by_id.get(_item_identity(item) or "")
if position is None:
continue
twins[index] = current[position]
matched.add(index)
claimed.add(position)
for index in range(len(posted)):
if index in matched or index >= len(current) or index in claimed:
continue
twins[index] = current[index]
return twins


def _blank_placeholders(value: Any, *, key_name: str | None) -> Any:
"""Replace every secret marker that has no stored value to stand for.

A response writes ``REDACTED_SECRET_PLACEHOLDER`` where a secret is stored,
so the marker is never itself a secret. A request that carries it for
something the profile does not have -- a copied entry, a hand-built one --
has nothing to resolve it back to, and keeping it would write the marker
into the configuration as if it were a credential.
"""
if isinstance(value, dict):
blanked = {
key: _blank_placeholders(item, key_name=key) for key, item in value.items()
}
return blanked if blanked != value else value

if isinstance(value, list):
if key_name and _is_sensitive_config_key(key_name):
blanked = [
"" if item == REDACTED_SECRET_PLACEHOLDER else item for item in value
]
return blanked if blanked != value else value
return [_blank_placeholders(item, key_name=key_name) for item in value]

if (
key_name
and _is_sensitive_config_key(key_name)
and value == REDACTED_SECRET_PLACEHOLDER
):
return ""

return value


def _redact_sensitive_config(value: Any, *, key_name: str | None = None) -> Any:
if isinstance(value, dict):
return {
Expand Down Expand Up @@ -172,6 +264,7 @@ def _restore_redacted_sensitive_config(
if isinstance(posted_value, dict) and isinstance(current_value, dict):
for key, item in posted_value.items():
if key not in current_value:
posted_value[key] = _blank_placeholders(item, key_name=key)
continue
posted_value[key] = _restore_redacted_sensitive_config(
item,
Expand All @@ -184,22 +277,22 @@ def _restore_redacted_sensitive_config(
if key_name and _is_sensitive_config_key(key_name):
restored_items = []
for idx, item in enumerate(posted_value):
if (
item == REDACTED_SECRET_PLACEHOLDER
and idx < len(current_value)
and isinstance(current_value[idx], str)
):
restored_items.append(current_value[idx])
else:
if item != REDACTED_SECRET_PLACEHOLDER:
restored_items.append(item)
continue
stored = current_value[idx] if idx < len(current_value) else None
restored_items.append(stored if isinstance(stored, str) else "")
return restored_items

twins = _stored_twins(posted_value, current_value)
for idx, item in enumerate(posted_value):
if idx >= len(current_value):
break
counterpart = twins[idx]
if counterpart is None:
posted_value[idx] = _blank_placeholders(item, key_name=key_name)
continue
posted_value[idx] = _restore_redacted_sensitive_config(
item,
current_value[idx],
counterpart,
key_name=key_name,
)
return posted_value
Expand All @@ -209,7 +302,7 @@ def _restore_redacted_sensitive_config(
and _is_sensitive_config_key(key_name)
and posted_value == REDACTED_SECRET_PLACEHOLDER
):
return current_value
return current_value if current_value != REDACTED_SECRET_PLACEHOLDER else ""

return posted_value

Expand Down Expand Up @@ -269,24 +362,30 @@ def changed(
return False
if isinstance(posted, list):
current_list = current if isinstance(current, list) else []
twins = _stored_twins(posted, current_list)
if any(
changed(
current_list[index] if index < len(current_list) else None,
twins[index],
value,
key_name=key_name,
path=path,
)
for index, value in enumerate(posted)
):
return True
return bool(
missing_is_change
and key_name
and _is_sensitive_config_key(key_name)
and any(
item not in (None, "", [], {})
for item in current_list[len(posted) :]
if not (
missing_is_change and key_name and _is_sensitive_config_key(key_name)
):
return False
if _items_by_identity(current_list):
posted_identities = {_item_identity(value) for value in posted}
return any(
_item_identity(item) not in posted_identities
for item in current_list
if item not in (None, "", [], {})
)
return any(
item not in (None, "", [], {}) for item in current_list[len(posted) :]
)
return False

Expand Down
4 changes: 3 additions & 1 deletion dashboard/src/components/config/AiConfigPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,9 @@ const localTabGroups = computed(() => {
'websearch',
'agent_computer_use',
'proactive_capability',
'btw',
// `btw` lives on its own page now (More Features > BTW Dual Loop); the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI move is not reflected in docs. On this branch docs/zh/dev/astrbot-config.md / docs/en/dev/astrbot-config.md still say operators configure BTW via Config → AI → Capabilities → BTW dual loops (plugin/MCP/Skill assignment sections). This PR does not touch docs/.

AGENTS.md requires bilingual docs plus an old-to-new row in docs/zh/use/webui.md and docs/en/use/webui.md when a WebUI entry point moves. Please add something like:

配置文件 → AI → 能力 → BTW 双循环更多功能 → BTW 双循环 (/btw)

and point the ConfigDocsLink target (dev/astrbot-config.html) at the new location in those BTW sections.

// work loop is a separate execution path with its own boundary, and it
// was easy to lose among the model and runner options here.
]
.filter((key) => props.metadata?.[key])
.map((key) => ({
Expand Down
Loading
Loading