2 Commits
52 changed files with 2883 additions and 287 deletions
+262 -1
View File
@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://agentdock.local/schemas/adapter.schema.json", "$id": "https://agentdock.local/schemas/adapter.schema.json",
"title": "AgentDock Adapter", "title": "AgentDock Adapter",
"description": "Agent CLI 适配器定义(v1)。Wave 0含占位字段;完整 schema 见架构 §3.1,随 Wave 1 落地。", "description": "Agent CLI 适配器定义(v1,对齐架构 §3.1)。首批 14 工具在 Wave 1保留 id/name/name_zh/vendor/status 五字段占位;其余字段(platforms/official/runtime_deps/install/detect/update/uninstall/authorization/configuration/diagnostics/documentation)为可选,供 Wave 2 起逐工具填充。所有 command 必须是 argv 数组,禁止 shell 元字符与字符串拼接。",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["id", "name", "name_zh", "vendor", "status"], "required": ["id", "name", "name_zh", "vendor", "status"],
@@ -28,6 +28,267 @@
"type": "string", "type": "string",
"enum": ["available", "watch"], "enum": ["available", "watch"],
"description": "目录状态:available=可安装列表;watch=观察中(第二批)" "description": "目录状态:available=可安装列表;watch=观察中(第二批)"
},
"adapter_version": {
"type": "string",
"description": "适配器自身版本(semver,如 1.2.0",
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
"license": {
"type": "string",
"description": "展示用许可;专有许可注明「仅官方渠道安装、不重打包」"
},
"platforms": {
"type": "object",
"additionalProperties": false,
"properties": {
"windows": {
"type": "object",
"additionalProperties": false,
"properties": {
"architectures": {
"type": "array",
"items": { "type": "string", "enum": ["x64", "arm64"] }
},
"notes": {
"type": "string",
"description": "如 Gemini 要求 Win11 24H2+"
}
}
},
"linux": {
"type": "object",
"additionalProperties": false,
"properties": {
"distributions": {
"type": "array",
"items": { "type": "string", "enum": ["ubuntu", "debian"] }
},
"architectures": {
"type": "array",
"items": { "type": "string", "enum": ["x64", "arm64"] }
},
"min_ubuntu": {
"type": "string",
"description": "最低 Ubuntu 版本,如 22.04"
}
}
}
}
},
"official": {
"type": "object",
"additionalProperties": false,
"properties": {
"homepage": { "type": "string", "format": "uri" },
"docs": { "type": "string", "format": "uri" },
"allowed_hosts": {
"type": "array",
"items": { "type": "string" },
"description": "网络白名单(诊断/下载仅可访问这些主机)"
}
}
},
"runtime_deps": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": { "type": "string", "enum": ["node", "python", "git", "powershell", "uv", "bash"] },
"semver_range": { "type": "string", "description": "如 >=20" },
"required_for": {
"type": "array",
"items": { "type": "string", "enum": ["install", "run"] }
}
}
}
},
"install": {
"type": "object",
"additionalProperties": false,
"properties": {
"preferred": { "type": "string", "description": "默认渠道 channel id" },
"channels": {
"type": "array",
"items": { "$ref": "#/definitions/channel" }
}
}
},
"detect": {
"type": "object",
"additionalProperties": false,
"required": ["executable"],
"properties": {
"executable": { "type": "string", "description": "PATH 上的命令名;Cursor 为 agent" },
"version_args": { "type": "array", "items": { "type": "string" } },
"version_regex": { "type": "string" },
"version_unconfirmed": { "type": "boolean", "description": "调研标注「文档未能确认」时 true" },
"path_hints": { "type": "array", "items": { "type": "string" }, "description": "非 PATH 常见位置" }
}
},
"update": {
"type": "object",
"additionalProperties": false,
"properties": {
"method": {
"type": "string",
"enum": ["npm_update", "self_update_cmd", "channel_reinstall", "winget_upgrade", "pypi_upgrade", "manual"]
},
"command": { "type": "array", "items": { "type": "string" } }
}
},
"uninstall": {
"type": "object",
"additionalProperties": false,
"properties": {
"method": { "type": "string", "enum": ["npm_uninstall", "package_manager", "manual_delete"] },
"command": { "type": "array", "items": { "type": "string" } },
"keep_config_default": { "type": "boolean", "default": true }
}
},
"authorization": {
"type": "object",
"additionalProperties": false,
"properties": {
"modes": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["mode"],
"properties": {
"mode": { "type": "string", "enum": ["browser_oauth", "device_code", "api_key", "local_tui"] },
"command": { "type": "array", "items": { "type": "string" } },
"env_keys": { "type": "array", "items": { "type": "string" } },
"status_command": { "type": "array", "items": { "type": "string" } },
"notes_zh": { "type": "string" }
}
}
}
}
},
"configuration": {
"type": "object",
"additionalProperties": false,
"properties": {
"files": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["path", "format"],
"properties": {
"path": { "type": "string", "description": "支持 ~ 与平台变量" },
"format": { "type": "string", "enum": ["toml", "json", "jsonc", "yaml", "env", "crushrc"] },
"scope": { "type": "string", "enum": ["user", "project", "system"] }
}
}
},
"environment": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key"],
"properties": {
"key": { "type": "string" },
"sensitive": { "type": "boolean", "default": false },
"maps_to_field": { "type": "string" }
}
}
},
"fields": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "label_zh", "type", "storage"],
"properties": {
"id": { "type": "string" },
"label_zh": { "type": "string" },
"help_zh": { "type": "string" },
"required": { "type": "boolean", "default": false },
"sensitive": { "type": "boolean", "default": false },
"type": { "type": "string", "enum": ["string", "url", "enum", "bool"] },
"storage": { "type": "string", "enum": ["file", "env", "keyring"], "description": "keyring 永不进普通备份" },
"platforms": { "type": "array", "items": { "type": "string", "enum": ["windows", "linux"] } },
"docs_url": { "type": "string", "format": "uri" }
}
}
}
}
},
"diagnostics": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["rule_id"],
"properties": {
"rule_id": { "type": "string", "description": "引用规则库或内联" }
}
}
},
"documentation": {
"type": "object",
"additionalProperties": false,
"properties": {
"quickstart_zh": { "type": "string" },
"commands": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"cmd": { "type": "string" },
"desc_zh": { "type": "string" }
}
}
},
"updated_at": { "type": "string", "format": "date" },
"risks_zh": { "type": "array", "items": { "type": "string" } }
}
}
},
"definitions": {
"channel": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": {
"type": "string",
"enum": ["npm", "official_script", "winget", "choco", "scoop", "brew", "apt", "pypi_uv", "github_release"]
},
"platforms": {
"type": "array",
"items": { "type": "string", "enum": ["windows", "linux"] }
},
"command": {
"type": "array",
"items": { "type": "string" },
"description": "命令必须是 argv 数组,禁止字符串拼接与 shell 元字符(| & ; $ \\ > < ( `"
},
"script": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": { "type": "string", "format": "uri" },
"kind": { "type": "string", "enum": ["powershell_irm", "bash_pipe", "ps1_file"] },
"integrity": {
"type": "object",
"additionalProperties": false,
"properties": { "sha256": { "type": "string", "pattern": "^[0-9a-fA-F]{64}$" } }
}
}
},
"package": { "type": "string", "description": "npm/pypi 包名" },
"elevate": { "type": "string", "enum": ["never", "if_needed", "required"] },
"elevate_reason_zh": { "type": "string" },
"post_checks": { "type": "array", "items": { "type": "string" } }
}
} }
} }
} }
+23 -1
View File
@@ -1,4 +1,26 @@
# aider 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: aider id: aider
name: Aider name: Aider
name_zh: Aider name_zh: Aider
+23 -1
View File
@@ -1,4 +1,26 @@
# claude-code 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: claude-code id: claude-code
name: Claude Code name: Claude Code
name_zh: Claude Code name_zh: Claude Code
+23 -1
View File
@@ -1,4 +1,26 @@
# cline 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: cline id: cline
name: Cline name: Cline
name_zh: Cline name_zh: Cline
+23 -1
View File
@@ -1,4 +1,26 @@
# codebuddy 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: codebuddy id: codebuddy
name: CodeBuddy Code name: CodeBuddy Code
name_zh: CodeBuddy Code name_zh: CodeBuddy Code
+23 -1
View File
@@ -1,4 +1,26 @@
# codex 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: codex id: codex
name: Codex CLI name: Codex CLI
name_zh: Codex CLI name_zh: Codex CLI
+23 -1
View File
@@ -1,4 +1,26 @@
# copilot 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: copilot id: copilot
name: Copilot CLI name: Copilot CLI
name_zh: Copilot CLI name_zh: Copilot CLI
+23 -1
View File
@@ -1,4 +1,26 @@
# crush 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: crush id: crush
name: Crush name: Crush
name_zh: Crush name_zh: Crush
+23 -1
View File
@@ -1,4 +1,26 @@
# cursor 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: cursor id: cursor
name: Cursor CLI name: Cursor CLI
name_zh: Cursor CLI name_zh: Cursor CLI
+23 -1
View File
@@ -1,4 +1,26 @@
# gemini 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: gemini id: gemini
name: Gemini CLI name: Gemini CLI
name_zh: Gemini CLI name_zh: Gemini CLI
+23 -1
View File
@@ -1,4 +1,26 @@
# goose 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: goose id: goose
name: Goose name: Goose
name_zh: Goose name_zh: Goose
+23 -1
View File
@@ -1,4 +1,26 @@
# kimi 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: kimi id: kimi
name: Kimi CLI name: Kimi CLI
name_zh: Kimi CLI name_zh: Kimi CLI
+23 -1
View File
@@ -1,4 +1,26 @@
# opencode 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: opencode id: opencode
name: OpenCode name: OpenCode
name_zh: OpenCode name_zh: OpenCode
+23 -1
View File
@@ -1,4 +1,26 @@
# qwen 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: qwen id: qwen
name: Qwen Code name: Qwen Code
name_zh: Qwen Code name_zh: Qwen Code
+23 -1
View File
@@ -1,4 +1,26 @@
# warp 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# ============================================================
id: warp id: warp
name: Warp Agent CLI name: Warp Agent CLI
name_zh: Warp Agent CLI name_zh: Warp Agent CLI
+3 -4
View File
@@ -33,13 +33,13 @@ export default function App() {
const content = useMemo(() => { const content = useMemo(() => {
switch (page) { switch (page) {
case "overview": case "overview":
return <OverviewPage />; return <OverviewPage onNavigate={setPage} />;
case "catalog": case "catalog":
return <CatalogPage />; return <CatalogPage />;
case "my-cli": case "my-cli":
return <MyCliPage />; return <MyCliPage onNavigate={setPage} />;
case "config": case "config":
return <ConfigCenterPage />; return <ConfigCenterPage onNavigate={setPage} />;
case "backup": case "backup":
return <BackupPage />; return <BackupPage />;
case "settings": case "settings":
@@ -49,7 +49,6 @@ export default function App() {
return ( return (
<div className="app-shell"> <div className="app-shell">
<div className="bg-grid grid-drift" aria-hidden="true" />
<Sidebar current={page} onNavigate={setPage} /> <Sidebar current={page} onNavigate={setPage} />
<div className="app-main"> <div className="app-main">
<Header title={PAGE_META[page].title} warningCount={warningCount} /> <Header title={PAGE_META[page].title} warningCount={warningCount} />
+35 -1
View File
@@ -1,12 +1,46 @@
/**
* CLI 双字母头像缩写注册表(视觉规范 v1.3 §7:全系统唯一来源)
* 规则:① 首两个字母(大写);② 冲突时保留先收录者,后收录方取「首字母 + 辨识辅音」;
* ③ 仍冲突升级「首字母 + 末字母」。新收录 CLI 必须先在此注册,不得现场发明。
*/
const CLI_MONOGRAMS: Record<string, string> = {
codex: "CO",
"claude-code": "CL",
gemini: "GE",
copilot: "CP",
kimi: "KI",
qwen: "QW",
codebuddy: "CB",
opencode: "OC",
crush: "CR",
goose: "GO",
aider: "AI",
cursor: "CU",
cline: "CN",
warp: "WA",
/* 第二批 4 个预注册(暂缓接入,登记防碰撞) */
grok: "GR",
amp: "AM",
"deepseek-dsh": "DS",
plandex: "PL",
};
/** 未注册时的兜底:取主名单词首两个字母(大写),仅用于未来新增且尚未登记的过渡态 */
function fallbackAbbr(name: string): string {
return name.replace(/\s+/g, "").slice(0, 2).toUpperCase();
}
/** CLI 单色字母头像(视觉规范 §7:双字母单色,--ad-bg-2 底 + 1px --ad-border */ /** CLI 单色字母头像(视觉规范 §7:双字母单色,--ad-bg-2 底 + 1px --ad-border */
export function CliMonogram({ export function CliMonogram({
id,
name, name,
size = 32, size = 32,
}: { }: {
id: string;
name: string; name: string;
size?: number; size?: number;
}) { }) {
const chars = name.replace(/\s+/g, "").slice(0, 2).toUpperCase(); const chars = CLI_MONOGRAMS[id] ?? fallbackAbbr(name);
return ( return (
<span <span
className="monogram" className="monogram"
+18 -17
View File
@@ -10,24 +10,25 @@ interface HeaderProps {
export function Header({ title, warningCount = 0 }: HeaderProps) { export function Header({ title, warningCount = 0 }: HeaderProps) {
return ( return (
<header className="header"> <header className="header">
<h1 className="header-title">{title}</h1> <div className="header-inner">
<div className="header-actions"> <h1 className="header-title">{title}</h1>
<div className="search-box"> <div className="header-actions">
<Search size={16} strokeWidth={1.5} className="search-icon" aria-hidden="true" /> <div className="search-box">
<input <Search size={16} strokeWidth={1.5} className="search-icon" aria-hidden="true" />
type="search" <input
placeholder="搜索 CLI…" type="search"
aria-label="搜索 CLI" placeholder="搜索 CLI"
// 搜索功能属于后续波次,Wave 0 仅为占位 aria-label="搜索 CLI"
/> />
</div>
{warningCount > 0 && (
<button type="button" className="env-chip">
<span className="status-dot warn breathe" aria-hidden="true" />
<span>{warningCount} </span>
<ChevronDown size={12} strokeWidth={1.5} aria-hidden="true" />
</button>
)}
</div> </div>
{warningCount > 0 && (
<button type="button" className="env-chip">
<span className="status-dot warn breathe" aria-hidden="true" />
<span>{warningCount} </span>
<ChevronDown size={12} strokeWidth={1.5} aria-hidden="true" />
</button>
)}
</div> </div>
</header> </header>
); );
+16 -4
View File
@@ -11,17 +11,19 @@ interface KpiCardProps {
label: string; label: string;
value: number; value: number;
tone: KpiTone; tone: KpiTone;
/** 副标题:对象点名句式(视觉规范 §3.1.1) */ /** 副标题:对象点名句式(视觉规范 §3.1.1)0 值用「确认句 + 行动」短句 */
subtitle: string; subtitle: string;
/** 0 值行动后缀(如「去目录看看」),仅当存在对应页面动作时出现 */
action?: { label: string; onClick: () => void };
} }
/** /**
* KPI 卡(视觉规范 §3.1.1): * KPI 卡(视觉规范 §3.1.1):
* - 径向渐变玻璃板 + 上缘高光 + --ad-shadow-panel * - 径向渐变玻璃板 + 上缘高光 + --ad-shadow-panel
* - 顶部色条贯通整宽;数值为 0 时色条降灰、数字回 --ad-text-1 * - 顶部色条贯通整宽;数值为 0 时色条降灰、数字回 --ad-text-1
* - 数字随状态色贯穿 * - 数字随状态色贯穿;0 值副文案 = 确认句 + 青字行动后缀
*/ */
export function KpiCard({ label, value, tone, subtitle }: KpiCardProps) { export function KpiCard({ label, value, tone, subtitle, action }: KpiCardProps) {
const color = value > 0 ? TONE_COLOR[tone] : "var(--ad-border)"; const color = value > 0 ? TONE_COLOR[tone] : "var(--ad-border)";
const valueColor = value > 0 ? TONE_COLOR[tone] : "var(--ad-text-1)"; const valueColor = value > 0 ? TONE_COLOR[tone] : "var(--ad-text-1)";
return ( return (
@@ -32,7 +34,17 @@ export function KpiCard({ label, value, tone, subtitle }: KpiCardProps) {
{value} {value}
</div> </div>
<div className="kpi-label">{label}</div> <div className="kpi-label">{label}</div>
<div className="kpi-subtitle">{subtitle}</div> <div className="kpi-subtitle">
{subtitle}
{action && value === 0 && (
<>
{" · "}
<button type="button" className="link-btn" onClick={action.onClick}>
{action.label}
</button>
</>
)}
</div>
</div> </div>
</div> </div>
); );
+2
View File
@@ -43,6 +43,8 @@ const mockPlatformEnv = (): PlatformEnv => ({
os: "windows", os: "windows",
os_version: "Windows 11 24H2 (Build 26100)", os_version: "Windows 11 24H2 (Build 26100)",
arch: "x86_64", arch: "x86_64",
distro: null,
distro_version: null,
shells: { powershell_version: "5.1.26100.1", pwsh_version: null, bash_available: true }, shells: { powershell_version: "5.1.26100.1", pwsh_version: null, bash_available: true },
runtimes: { runtimes: {
node: { status: "installed", version: "24.18.0", path: "C:\\Program Files\\nodejs\\node.exe" }, node: { status: "installed", version: "24.18.0", path: "C:\\Program Files\\nodejs\\node.exe" },
+3
View File
@@ -5,6 +5,8 @@ export interface PlatformEnv {
os: string; os: string;
os_version: string; os_version: string;
arch: string; arch: string;
distro: string | null;
distro_version: string | null;
shells: Shells; shells: Shells;
runtimes: Runtimes; runtimes: Runtimes;
path_entries: string[]; path_entries: string[];
@@ -31,6 +33,7 @@ export type RuntimeStatus =
| "installed" | "installed"
| "not_installed" | "not_installed"
| "not_in_path" | "not_in_path"
| "permission_denied"
| "exec_failed" | "exec_failed"
| "version_unparseable"; | "version_unparseable";
+9 -4
View File
@@ -1,17 +1,22 @@
import { Archive } from "lucide-react"; import { Archive } from "lucide-react";
/** 备份与迁移(PRD §7):创建备份、查看内容、恢复。Wave 0 占位。 */ /** 备份与迁移(PRD §7):创建备份、查看内容、恢复。空态按 v1.3 §3.7。 */
export function BackupPage() { export function BackupPage() {
return ( return (
<div className="placeholder-page"> <div className="placeholder-page">
<div className="placeholder-icon"> <div className="placeholder-icon">
<Archive size={40} strokeWidth={1.5} aria-hidden="true" /> <Archive size={40} strokeWidth={1.5} aria-hidden="true" />
</div> </div>
<h2 className="placeholder-title"></h2> <h2 className="placeholder-title"></h2>
<p className="placeholder-desc"> <p className="placeholder-desc">
Wave 4 CLI
</p> </p>
<button type="button" className="btn btn-secondary" disabled> <button
type="button"
className="btn btn-secondary"
disabled
title="安装 CLI 后才能创建备份"
>
</button> </button>
</div> </div>
+2 -4
View File
@@ -2,7 +2,6 @@ import { useMemo, useState } from "react";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { useCatalog } from "../hooks/useCatalog"; import { useCatalog } from "../hooks/useCatalog";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { MonoChip } from "../components/MonoChip";
import { CliMonogram } from "../components/CliMonogram"; import { CliMonogram } from "../components/CliMonogram";
type StatusFilter = "all" | "installed" | "uninstalled" | "update"; type StatusFilter = "all" | "installed" | "uninstalled" | "update";
@@ -90,17 +89,16 @@ export function CatalogPage() {
{filtered.map((entry) => ( {filtered.map((entry) => (
<article className="catalog-card" key={entry.id}> <article className="catalog-card" key={entry.id}>
<div className="catalog-card-top"> <div className="catalog-card-top">
<CliMonogram name={entry.name} size={40} /> <CliMonogram id={entry.id} name={entry.name} size={40} />
<div className="catalog-card-head"> <div className="catalog-card-head">
<div className="catalog-card-name">{entry.name_zh}</div> <div className="catalog-card-name">{entry.name_zh}</div>
<div className="catalog-card-vendor">{entry.vendor}</div> <div className="catalog-card-vendor">{entry.vendor}</div>
</div> </div>
</div> </div>
<p className="catalog-card-desc"> · Wave 1 </p> <p className="catalog-card-desc"> · </p>
<div className="catalog-card-bottom"> <div className="catalog-card-bottom">
<StatusBadge kind="uninstalled" label="未安装" /> <StatusBadge kind="uninstalled" label="未安装" />
<span className="catalog-platforms">Windows · Linux</span> <span className="catalog-platforms">Windows · Linux</span>
<MonoChip>v1</MonoChip>
</div> </div>
</article> </article>
))} ))}
+6 -5
View File
@@ -1,17 +1,18 @@
import { SlidersHorizontal } from "lucide-react"; import { SlidersHorizontal } from "lucide-react";
import type { PageKey } from "../components/Sidebar";
/** 配置中心(PRD §7):按 CLI 展示中文配置表单。Wave 0 占位。 */ /** 配置中心(PRD §7):按 CLI 展示中文配置表单。空态按 v1.3 §3.7。 */
export function ConfigCenterPage() { export function ConfigCenterPage({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
return ( return (
<div className="placeholder-page"> <div className="placeholder-page">
<div className="placeholder-icon"> <div className="placeholder-icon">
<SlidersHorizontal size={40} strokeWidth={1.5} aria-hidden="true" /> <SlidersHorizontal size={40} strokeWidth={1.5} aria-hidden="true" />
</div> </div>
<h2 className="placeholder-title"></h2> <h2 className="placeholder-title"> CLI</h2>
<p className="placeholder-desc"> <p className="placeholder-desc">
Wave 2 使 CLI
</p> </p>
<button type="button" className="btn btn-secondary" disabled> <button type="button" className="btn btn-secondary" onClick={() => onNavigate("catalog")}>
CLI CLI
</button> </button>
</div> </div>
+6 -5
View File
@@ -1,17 +1,18 @@
import { SquareTerminal } from "lucide-react"; import { SquareTerminal } from "lucide-react";
import type { PageKey } from "../components/Sidebar";
/** 我的 CLIPRD §7):版本 / 路径 / 授权状态。Wave 0 尚无已安装 CLI,展示空态。 */ /** 我的 CLIPRD §7):版本 / 路径 / 授权状态。空态按 v1.3 §3.7。 */
export function MyCliPage() { export function MyCliPage({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
return ( return (
<div className="placeholder-page"> <div className="placeholder-page">
<div className="placeholder-icon"> <div className="placeholder-icon">
<SquareTerminal size={40} strokeWidth={1.5} aria-hidden="true" /> <SquareTerminal size={40} strokeWidth={1.5} aria-hidden="true" />
</div> </div>
<h2 className="placeholder-title"> CLI</h2> <h2 className="placeholder-title"> CLI</h2>
<p className="placeholder-desc"> <p className="placeholder-desc">
Wave 1 CLI CLI
</p> </p>
<button type="button" className="btn btn-primary" disabled> <button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
CLI CLI
</button> </button>
</div> </div>
+236 -94
View File
@@ -1,12 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { Activity, Archive, ChevronRight, Lock, Plus } from "lucide-react"; import { Activity, Archive, Lock, Plus, ScanSearch } from "lucide-react";
import { useEnv } from "../hooks/useEnv"; import { useEnv } from "../hooks/useEnv";
import { useCatalog } from "../hooks/useCatalog"; import { useCatalog } from "../hooks/useCatalog";
import { KpiCard } from "../components/KpiCard"; import { KpiCard } from "../components/KpiCard";
import { MonoChip } from "../components/MonoChip"; import { MonoChip } from "../components/MonoChip";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { CliMonogram } from "../components/CliMonogram"; import { CliMonogram } from "../components/CliMonogram";
import type { PlatformEnv, RuntimeInfo } from "../ipc/types"; import type { PlatformEnv, RuntimeInfo, RuntimeStatus, CatalogEntry } from "../ipc/types";
import type { PageKey } from "../components/Sidebar";
/** 平台相关运行时集合(Windows 不看 aptLinux 不看 winget */ /** 平台相关运行时集合(Windows 不看 aptLinux 不看 winget */
const WINDOWS_RUNTIMES = ["node", "npm", "python", "uv", "git", "winget"] as const; const WINDOWS_RUNTIMES = ["node", "npm", "python", "uv", "git", "winget"] as const;
@@ -24,42 +25,61 @@ function runtimeProblems(env: PlatformEnv): { name: string; info: RuntimeInfo }[
return problems; return problems;
} }
function runtimeStatusText(status: string): string { /** 本机环境运行时项四元组(v1.3 §3.1.6):状态点 + 名称 + 版本位 + 行动位 */
function runtimeMeta(status: RuntimeStatus): {
dot: string;
missingText: string | null;
needsGuide: boolean;
} {
switch (status) { switch (status) {
case "not_in_path": case "installed":
return "不在 PATH"; return { dot: "status-dot ok", missingText: null, needsGuide: false };
case "not_installed": case "not_installed":
return "未安装"; return { dot: "status-dot unknown", missingText: "未检测到", needsGuide: true };
case "not_in_path":
return { dot: "status-dot warn breathe", missingText: "不在 PATH", needsGuide: false };
case "permission_denied":
return { dot: "status-dot warn breathe", missingText: "无访问权限", needsGuide: false };
case "exec_failed": case "exec_failed":
return "执行失败"; return { dot: "status-dot warn breathe", missingText: "执行失败", needsGuide: false };
case "version_unparseable": case "version_unparseable":
return "版本未知"; return { dot: "status-dot warn breathe", missingText: "版本未知", needsGuide: false };
default: default:
return "未知"; return { dot: "status-dot unknown", missingText: "未知", needsGuide: false };
} }
} }
function RuntimeItem({ name, info }: { name: string; info: RuntimeInfo }) { function RuntimeItem({
const dotClass = name,
info.status === "installed" info,
? "status-dot ok" onNavigate,
: info.status === "exec_failed" }: {
? "status-dot error" name: string;
: "status-dot warn"; info: RuntimeInfo;
onNavigate: (p: PageKey) => void;
}) {
const meta = runtimeMeta(info.status);
return ( return (
<div className="runtime-item"> <div className="runtime-item">
<span className={dotClass} aria-hidden="true" /> <span className={meta.dot} aria-hidden="true" />
<span className="runtime-name">{name}</span> <span className="runtime-name">{name}</span>
{info.version ? ( {info.version ? (
<MonoChip>{info.version}</MonoChip> <span className="runtime-version">
<MonoChip>{info.version}</MonoChip>
</span>
) : ( ) : (
<span className="runtime-missing">{runtimeStatusText(info.status)}</span> <span className="runtime-missing">{meta.missingText}</span>
)}
{meta.needsGuide && (
<button type="button" className="link-btn runtime-action" onClick={() => onNavigate("catalog")}>
</button>
)} )}
</div> </div>
); );
} }
function EnvPanel() { function EnvPanel({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
const { env, loading, error } = useEnv(); const { env, loading, error } = useEnv();
const [showAllPath, setShowAllPath] = useState(false); const [showAllPath, setShowAllPath] = useState(false);
@@ -122,7 +142,7 @@ function EnvPanel() {
(key) => { (key) => {
const info = env.runtimes[key]; const info = env.runtimes[key];
if (!info) return null; if (!info) return null;
return <RuntimeItem key={key} name={key} info={info} />; return <RuntimeItem key={key} name={key} info={info} onNavigate={onNavigate} />;
}, },
)} )}
</div> </div>
@@ -155,100 +175,222 @@ function EnvPanel() {
); );
} }
/** 总览页(视觉规范 §3.1 + PRD §7):KPI + 最近诊断/快速操作 + CLI 状态网格 + 本机环境 */ /** 首跑空态引导插画(v1.3 §7:等距线框终端 + 集装箱 Dock,1px 线,霓虹点缀) */
export function OverviewPage() { function OnboardingArt() {
return (
<svg width="200" height="150" viewBox="0 0 200 150" fill="none" aria-hidden="true">
<rect x="24" y="18" width="120" height="86" rx="8" stroke="currentColor" strokeWidth="1" />
<line x1="24" y1="40" x2="144" y2="40" stroke="currentColor" strokeWidth="1" />
<circle cx="36" cy="29" r="2" style={{ fill: "var(--ad-primary)" }} />
<circle cx="46" cy="29" r="2" fill="currentColor" opacity="0.55" />
<circle cx="56" cy="29" r="2" fill="currentColor" opacity="0.55" />
<rect x="36" y="52" width="58" height="6" rx="3" fill="currentColor" opacity="0.5" />
<rect x="36" y="66" width="84" height="6" rx="3" fill="currentColor" opacity="0.32" />
<rect x="36" y="80" width="46" height="6" rx="3" style={{ fill: "var(--ad-accent)" }} opacity="0.8" />
<rect x="24" y="116" width="36" height="26" rx="4" stroke="currentColor" strokeWidth="1" />
<rect x="66" y="116" width="36" height="26" rx="4" stroke="currentColor" strokeWidth="1" />
<rect x="108" y="116" width="36" height="26" rx="4" stroke="currentColor" strokeWidth="1" />
<line x1="24" y1="142" x2="144" y2="142" stroke="currentColor" strokeWidth="1" opacity="0.4" />
</svg>
);
}
/** 首跑引导区(v1.3 §3.1.5:插画 + 三句式文案 + 主行动按钮) */
function Onboarding({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
return (
<div className="panel onboarding">
<div className="onboarding-art">
<OnboardingArt />
</div>
<div className="onboarding-copy">
<h2 className="onboarding-title"></h2>
<p className="onboarding-desc">
Agent CLI
</p>
<div className="onboarding-actions">
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
CLI
</button>
<button type="button" className="btn btn-secondary">
</button>
</div>
</div>
</div>
);
}
/** 最近诊断卡(v1.3 §3.1.2:无记录时展示空态,不渲染占位行与「查看全部」) */
function DiagPanel() {
// Wave 0.5 尚无诊断引擎数据,恒为空态(语义按 §3.7,不虚构记录)
const records: { cli: string; text: string; time: string }[] = [];
return (
<div className="panel diag-panel">
<div className="panel-head">
<h2 className="panel-title"></h2>
</div>
{records.length === 0 ? (
<div className="diag-empty">
<span className="diag-empty-text"></span>
<button type="button" className="btn btn-secondary">
<ScanSearch size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
</div>
) : (
<ul className="diag-list">
{records.map((r) => (
<li className="diag-row" key={r.cli}>
<span className="status-dot ok" aria-hidden="true" />
<span className="diag-cli">{r.cli}</span>
<span className="diag-text">{r.text}</span>
<span className="diag-time">{r.time}</span>
</li>
))}
</ul>
)}
</div>
);
}
/** 快速操作卡(v1.3 §3.1.3:三按钮竖向操作组,创建备份依赖数据置灰) */
function QuickActions({
firstRun,
onNavigate,
}: {
firstRun: boolean;
onNavigate: (p: PageKey) => void;
}) {
return (
<div className="panel quick-card">
<h2 className="panel-title"></h2>
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
<Plus size={16} strokeWidth={1.5} aria-hidden="true" /> CLI
</button>
<button type="button" className="btn btn-secondary">
<Activity size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
<button
type="button"
className="btn btn-secondary"
disabled
title={firstRun ? "安装 CLI 后才能创建备份" : "暂无内容可备份"}
>
<Archive size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
<div className="recent-installs">
<div className="group-label"></div>
<p className="group-empty"></p>
</div>
<p className="safety-note">
<Lock size={12} strokeWidth={1.5} aria-hidden="true" />
</p>
</div>
);
}
/** CLI 状态网格块(v1.3 §3.1.4;首跑精简为两行 + 安装入口,§3.1.5) */
function CliBlock({
entry,
compact,
onNavigate,
}: {
entry: CatalogEntry;
compact: boolean;
onNavigate: (p: PageKey) => void;
}) {
const installed = entry.status === "installed";
return (
<div className="cli-block">
<div className="cli-block-top">
<CliMonogram id={entry.id} name={entry.name} size={32} />
<span className="cli-block-name">{entry.name_zh}</span>
<StatusBadge kind={installed ? "installed" : "uninstalled"} label={installed ? "已安装" : "未安装"} />
</div>
<div className="cli-block-version">
{installed ? null : <span className="version-missing"></span>}
</div>
{!compact && (
<div className="cli-block-auth">
{installed ? (
<>
<span className="status-dot ok" aria-hidden="true" />
<span></span>
</>
) : (
<>
<span className="status-dot unknown" aria-hidden="true" />
<span> · </span>
</>
)}
</div>
)}
{compact && (
<div className="cli-block-install">
<button type="button" className="link-btn" onClick={() => onNavigate("catalog")}>
</button>
</div>
)}
</div>
);
}
interface OverviewProps {
onNavigate: (p: PageKey) => void;
}
/** 总览页(视觉规范 §3.1 + PRD §7):首跑空态 / KPI + 诊断/快速操作 + CLI 状态网格 + 本机环境 */
export function OverviewPage({ onNavigate }: OverviewProps) {
const { env } = useEnv(); const { env } = useEnv();
const { entries } = useCatalog(); const { entries } = useCatalog();
const installedCount = entries.filter((e) => e.status === "installed").length;
const firstRun = installedCount === 0; // 无安装且无诊断记录(当前无诊断引擎)
const warningCount = env ? runtimeProblems(env).length : 0; const warningCount = env ? runtimeProblems(env).length : 0;
const warningNames = env ? runtimeProblems(env).map((p) => p.name) : []; const warningNames = env ? runtimeProblems(env).map((p) => p.name) : [];
return ( return (
<div className="overview"> <div className="overview">
{/* ① KPI 一排 4 张 */} {/* ① KPI 一排 4 张(首跑空态隐藏,v1.3 §3.1.5) */}
<section className="kpi-grid" aria-label="概览指标"> {!firstRun && (
<KpiCard label="已安装" value={0} tone="primary" subtitle="暂无已安装 CLI" /> <section className="kpi-grid" aria-label="概览指标">
<KpiCard label="待授权" value={0} tone="attention" subtitle="暂无待授权项" /> <KpiCard label="已安装" value={installedCount} tone="primary" subtitle="暂无安装" action={{ label: "去目录看看", onClick: () => onNavigate("catalog") }} />
<KpiCard label="可更新" value={0} tone="accent" subtitle="暂无可用更新" /> <KpiCard label="待授权" value={0} tone="attention" subtitle="暂无待授权" />
<KpiCard <KpiCard label="可更新" value={0} tone="accent" subtitle="全部已是最新" />
label="环境异常" <KpiCard
value={warningCount} label="环境异常"
tone="warning" value={warningCount}
subtitle={warningCount > 0 ? warningNames.join(" · ") : "环境正常"} tone="warning"
/> subtitle={warningCount > 0 ? warningNames.join(" · ") : "环境正常"}
</section> />
</section>
)}
{/* ② 中段:左 2/3 最近诊断 + 右 1/3 快速操作 */} {/* ② 中段:诊断/引导 + 快速操作(超宽三栏见 global.css) */}
<section className="overview-mid"> <section className="overview-mid">
<div className="panel diag-panel"> {firstRun ? <Onboarding onNavigate={onNavigate} /> : <DiagPanel />}
<div className="panel-head"> <QuickActions firstRun={firstRun} onNavigate={onNavigate} />
<h2 className="panel-title"></h2>
<button type="button" className="link-btn">
<ChevronRight size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
</div>
<ul className="diag-list">
<li className="diag-row">
<span className="status-dot ok" aria-hidden="true" />
<span className="diag-cli"></span>
<span className="diag-text"> Wave 1 </span>
<span className="diag-time"></span>
</li>
</ul>
</div>
<div className="panel quick-card">
<h2 className="panel-title"></h2>
<button type="button" className="btn btn-primary" disabled>
<Plus size={16} strokeWidth={1.5} aria-hidden="true" /> CLI
</button>
<button type="button" className="btn btn-secondary" disabled>
<Activity size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
<button type="button" className="btn btn-secondary" disabled>
<Archive size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
<div className="recent-installs">
<div className="group-label"></div>
<p className="group-empty"></p>
</div>
<p className="safety-note">
<Lock size={12} strokeWidth={1.5} aria-hidden="true" />
</p>
</div>
</section> </section>
{/* ③ 下段:CLI 状态网格通栏 */} {/* ③ 下段:CLI 状态网格通栏(首跑精简:可安装列表 + 安装入口) */}
<section className="panel cli-status-panel"> <section className="panel cli-status-panel">
<div className="panel-head"> <div className="panel-head">
<h2 className="panel-title">CLI </h2> <h2 className="panel-title">
<button type="button" className="link-btn"> {firstRun ? `可安装的 CLI${entries.length}` : "CLI 状态"}
{entries.length} </h2>
<ChevronRight size={14} strokeWidth={1.5} aria-hidden="true" /> {/* 「管理全部 N 个」入口:仅当未全量展示时才渲染(v1.3 §3.1.4),本页恒全量展示,故不渲染 */}
</button>
</div> </div>
<div className="cli-status-grid"> <div className="cli-status-grid">
{entries.map((entry) => ( {entries.map((entry) => (
<div className="cli-block" key={entry.id}> <CliBlock key={entry.id} entry={entry} compact={firstRun} onNavigate={onNavigate} />
<div className="cli-block-top">
<CliMonogram name={entry.name} size={32} />
<span className="cli-block-name">{entry.name_zh}</span>
<StatusBadge kind="uninstalled" label="未安装" />
</div>
<div className="cli-block-version">
<MonoChip></MonoChip>
</div>
<div className="cli-block-auth">
<span className="status-dot unknown" aria-hidden="true" />
<span> · </span>
</div>
</div>
))} ))}
</div> </div>
</section> </section>
{/* ④ 本机环境(真实检测结果,架构 §5) */} {/* ④ 本机环境(真实检测结果,架构 §5;超宽断点上提为第三栏 */}
<EnvPanel /> <EnvPanel onNavigate={onNavigate} />
</div> </div>
); );
} }
+3 -3
View File
@@ -9,14 +9,14 @@ export function SettingsPage() {
<div className="setting-row"> <div className="setting-row">
<div className="setting-info"> <div className="setting-info">
<div className="setting-label"></div> <div className="setting-label"></div>
<div className="setting-hint"> CLI Wave 1 </div> <div className="setting-hint"> CLI </div>
</div> </div>
<span className="setting-value"></span> <span className="setting-value"></span>
</div> </div>
<div className="setting-row"> <div className="setting-row">
<div className="setting-info"> <div className="setting-info">
<div className="setting-label"></div> <div className="setting-label"></div>
<div className="setting-hint"> AgentDock CLI Wave 4 </div> <div className="setting-hint"> AgentDock CLI </div>
</div> </div>
<span className="setting-value"></span> <span className="setting-value"></span>
</div> </div>
@@ -30,7 +30,7 @@ export function SettingsPage() {
<div className="setting-row"> <div className="setting-row">
<div className="setting-info"> <div className="setting-info">
<div className="setting-label"></div> <div className="setting-label"></div>
<div className="setting-hint">Wave 1 </div> <div className="setting-hint"></div>
</div> </div>
<span className="setting-value"></span> <span className="setting-value"></span>
</div> </div>
+215 -49
View File
@@ -1,6 +1,6 @@
/* ============================================================ /* ============================================================
* AgentDock 全局样式(Wave 0 * AgentDock 全局样式(Wave 0.5
* 全部颜色/辉光/动效时长来自 tokens/(视觉规范 v1.2), * 全部颜色/辉光/动效时长来自 tokens/(视觉规范 v1.3),
* 本文件不出现任何魔法色值;布局尺寸来自规范 §3.0/§6。 * 本文件不出现任何魔法色值;布局尺寸来自规范 §3.0/§6。
* ============================================================ */ * ============================================================ */
@@ -12,18 +12,22 @@
} }
html, html,
body, body {
#root {
height: 100%; height: 100%;
} }
#root {
min-height: 100%;
}
body { body {
font-family: var(--ad-font-ui); font-family: var(--ad-font-ui);
font-size: var(--ad-text-m-size); font-size: var(--ad-text-m-size);
line-height: var(--ad-text-m-height); line-height: var(--ad-text-m-height);
color: var(--ad-text-1); color: var(--ad-text-1);
background: var(--ad-bg-0); background: var(--ad-bg-gradient); /* v1.3 §3.0:静态深空渐变,网格已移除 */
overflow: hidden; overflow-y: auto; /* v1.3 §3.0:主滚动容器 = 窗口,滚动条贴右缘 */
overflow-x: hidden;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -31,25 +35,40 @@ button {
font-family: inherit; font-family: inherit;
} }
/* ---------- 背景网格(§3.0:5% + 边缘羽化,低档静态 ---------- */ /* ---------- 自定义滚动条(v1.3 §2.7:10px、灰蓝滑块、透明轨道、不发光 ---------- */
.bg-grid { ::-webkit-scrollbar {
position: fixed; width: 10px;
inset: 0; height: 10px;
z-index: 0; }
pointer-events: none;
background-image: ::-webkit-scrollbar-track {
linear-gradient(var(--ad-grid-color) 1px, transparent 1px), background: transparent;
linear-gradient(90deg, var(--ad-grid-color) 1px, transparent 1px); }
background-size: var(--ad-grid-size) var(--ad-grid-size);
-webkit-mask-image: var(--ad-grid-mask); ::-webkit-scrollbar-thumb {
mask-image: var(--ad-grid-mask); background: var(--ad-scrollbar-thumb);
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
min-height: 32px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ad-scrollbar-thumb-hover);
background-clip: padding-box;
}
/* 标准属性兜底(Firefox 等) */
* {
scrollbar-width: thin;
scrollbar-color: var(--ad-scrollbar-thumb) transparent;
} }
/* ---------- 应用外壳 ---------- */ /* ---------- 应用外壳 ---------- */
.app-shell { .app-shell {
position: relative; position: relative;
display: flex; display: flex;
height: 100%; min-height: 100vh;
z-index: 1; z-index: 1;
} }
@@ -60,17 +79,29 @@ button {
flex-direction: column; flex-direction: column;
} }
.app-content { /* 内容区与顶栏内区共用同一宽度体系(v1.3 §3.0:顶栏与内容区右缘对齐) */
flex: 1; .app-content,
overflow-y: auto; .header-inner {
width: 100%; width: 100%;
max-width: 1440px; /* §6 宽阔断点锁宽居中 */ max-width: var(--ad-frame-max);
margin: 0 auto; margin-left: auto;
padding: var(--ad-space-6) var(--ad-space-8); margin-right: auto;
padding-left: var(--ad-frame-pad-x);
padding-right: var(--ad-frame-pad-x);
} }
/* ---------- 侧边栏(§3.0 ---------- */ .app-content {
flex: 1;
padding-top: var(--ad-space-6);
padding-bottom: var(--ad-space-6);
}
/* ---------- 侧边栏(§3.0:232px 贴左,窗口滚动时保持固定) ---------- */
.sidebar { .sidebar {
position: sticky;
top: 0;
align-self: flex-start;
height: 100vh;
width: 232px; width: 232px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -141,17 +172,23 @@ button {
margin-left: auto; margin-left: auto;
} }
/* ---------- 顶栏(§3.0 ---------- */ /* ---------- 顶栏(§3.0:通栏 56px,内区与内容区同一宽度体系,滚动时吸顶 ---------- */
.header { .header {
position: sticky;
top: 0;
z-index: 10;
height: 56px; height: 56px;
flex-shrink: 0; flex-shrink: 0;
background: var(--ad-bg-0);
border-bottom: 1px solid var(--ad-border);
}
.header-inner {
height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--ad-space-4); gap: var(--ad-space-4);
padding: 0 var(--ad-space-8);
background: var(--ad-bg-0);
border-bottom: 1px solid var(--ad-border);
} }
.header-title { .header-title {
@@ -233,6 +270,8 @@ button {
justify-content: center; justify-content: center;
gap: var(--ad-space-2); gap: var(--ad-space-2);
height: 36px; height: 36px;
min-width: 96px; /* v1.3 §2.6.1 */
max-width: 360px; /* v1.3 §2.6.1:禁止通栏 */
padding: 0 var(--ad-space-4); padding: 0 var(--ad-space-4);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--ad-radius-s); border-radius: var(--ad-radius-s);
@@ -245,7 +284,7 @@ button {
} }
.btn:disabled { .btn:disabled {
opacity: 0.55; opacity: 0.4; /* v1.3 §3.7:无意义操作置灰 */
cursor: not-allowed; cursor: not-allowed;
} }
@@ -524,8 +563,12 @@ button {
gap: var(--ad-space-3); gap: var(--ad-space-3);
} }
/* v1.3 §2.6.1:快速操作组定宽(组宽 = min(内容宽, 360px)),左对齐,不通栏 */
.quick-card .btn { .quick-card .btn {
width: 100%; width: min(100%, 360px);
justify-content: flex-start;
padding-left: var(--ad-space-4);
padding-right: var(--ad-space-4);
} }
.recent-installs { .recent-installs {
@@ -592,10 +635,6 @@ button {
color: var(--ad-text-1); color: var(--ad-text-1);
} }
.cli-block-version .mono-chip {
color: var(--ad-text-3);
}
.cli-block-auth { .cli-block-auth {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -604,6 +643,80 @@ button {
font-size: var(--ad-text-xs-size); font-size: var(--ad-text-xs-size);
} }
/* 未安装卡版本位(v1.3 §3.1.4:废止「—」chip,改灰字「未安装」) */
.cli-block-version .version-missing {
color: var(--ad-text-3);
font-size: var(--ad-text-s-size);
}
/* 首跑精简块:安装入口(v1.3 §3.1.5) */
.cli-block-install {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: var(--ad-space-1);
}
/* ---------- 总览:首跑空态引导区(v1.3 §3.1.5 ---------- */
.onboarding {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--ad-space-6);
align-items: center;
padding: var(--ad-space-6);
background: var(--ad-bg-1);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-l);
}
.onboarding-art {
display: flex;
align-items: center;
justify-content: center;
color: var(--ad-border-bright);
}
.onboarding-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--ad-space-3);
}
.onboarding-title {
font-size: var(--ad-text-xl-size);
font-weight: var(--ad-text-xl-weight);
color: var(--ad-text-1);
}
.onboarding-desc {
max-width: 480px;
color: var(--ad-text-2);
font-size: var(--ad-text-m-size);
line-height: 1.6;
}
.onboarding-actions {
display: flex;
align-items: center;
gap: var(--ad-space-3);
margin-top: var(--ad-space-2);
}
/* 诊断空态(v1.3 §3.1.2 / §3.7:无记录不渲染占位行) */
.diag-empty {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--ad-space-3);
padding: var(--ad-space-5) var(--ad-space-2);
}
.diag-empty-text {
color: var(--ad-text-2);
font-size: var(--ad-text-s-size);
}
/* ---------- 总览:本机环境 ---------- */ /* ---------- 总览:本机环境 ---------- */
.env-panel { .env-panel {
margin-top: var(--ad-space-6); margin-top: var(--ad-space-6);
@@ -640,10 +753,11 @@ button {
word-break: break-all; word-break: break-all;
} }
/* 本机环境运行时项(v1.3 §3.1.6:四元组同构,点—名称—版本—行动,行高 28px) */
.runtime-grid { .runtime-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(4, 1fr);
gap: var(--ad-space-3); gap: var(--ad-space-4);
align-content: start; align-content: start;
} }
@@ -651,17 +765,32 @@ button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--ad-space-2); gap: var(--ad-space-2);
min-height: 28px;
min-width: 0;
} }
.runtime-name { .runtime-name {
flex: 1; color: var(--ad-text-1);
color: var(--ad-text-2);
font-size: var(--ad-text-s-size); font-size: var(--ad-text-s-size);
white-space: nowrap;
}
.runtime-version {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
} }
.runtime-missing { .runtime-missing {
color: var(--ad-warning); color: var(--ad-text-3);
font-size: var(--ad-text-xs-size); font-size: var(--ad-text-xs-size);
white-space: nowrap;
}
.runtime-action {
margin-left: auto;
white-space: nowrap;
} }
.path-block { .path-block {
@@ -903,8 +1032,8 @@ button {
} }
} }
/* ---------- 响应式(§6 断点) ---------- */ /* ---------- 响应式(v1.3 §6 断点) ---------- */
/* 紧凑 9601199px:导航收起 64pxKPI 2×2,中段上下堆叠,状态网格 2 列 */ /* 紧凑 9601199px:导航收起 64pxKPI 2×2,中段上下堆叠 */
@media (max-width: 1199px) { @media (max-width: 1199px) {
.sidebar { .sidebar {
width: 64px; width: 64px;
@@ -942,7 +1071,7 @@ button {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.cli-status-grid { .runtime-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@@ -983,11 +1112,48 @@ button {
} }
} }
/* 标准 12001599px:默认样式(完整导航、KPI 一排 4、诊断+快速 2:1、状态网格 3 列) */ /* 标准 12001919px:默认(完整导航、KPI 一排 4、诊断+快速 2:1、内容区 max 1440 居中)
/* 宽阔 ≥1600px:内容锁 1440 居中,状态网格 4 列 */ * 宽阔 19202559px:内容区 max 1760 居中(v1.3 §6
@media (min-width: 1600px) { * 超宽 ≥2560px:取消居中锁宽,内容区全宽、边距 48px(v1.3 §6 */
.cli-status-grid { @media (min-width: 1920px) and (max-width: 2559px) {
grid-template-columns: repeat(4, 1fr); :root {
--ad-frame-max: 1760px;
}
}
@media (min-width: 2560px) {
:root {
--ad-frame-max: none;
--ad-frame-pad-x: var(--ad-space-12);
}
}
/* 超宽 ≥2560px:中段三栏(诊断 1/2 + 快速操作 1/4 + 本机环境 1/4),本机环境上提 */
@media (min-width: 2560px) {
.overview {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
column-gap: var(--ad-space-4);
row-gap: var(--ad-space-6);
}
.overview .kpi-grid {
grid-column: 1 / -1;
margin-bottom: 0;
}
.overview .overview-mid {
grid-column: 1 / 3;
margin-bottom: 0;
}
.overview .cli-status-panel {
grid-column: 1 / -1;
}
.overview .env-panel {
grid-column: 3;
margin-top: 0;
} }
} }
+4
View File
@@ -12,6 +12,10 @@
--ad-border: #22304A; /* 常规 1px 分隔线 */ --ad-border: #22304A; /* 常规 1px 分隔线 */
--ad-border-bright: #35507A; /* 悬浮/选中的边框 */ --ad-border-bright: #35507A; /* 悬浮/选中的边框 */
/* ---- 自定义滚动条(v1.3 §2.7:灰蓝滑块、透明轨道、不发光)---- */
--ad-scrollbar-thumb: #1A2438; /* = --ad-bg-3 */
--ad-scrollbar-thumb-hover: #35507A; /* = --ad-border-bright */
/* ---- 霓虹强调色(全系统只有这两个霓虹色)---- */ /* ---- 霓虹强调色(全系统只有这两个霓虹色)---- */
--ad-primary: #00E5FF; /* 主操作、链接、聚焦、选中态(青) */ --ad-primary: #00E5FF; /* 主操作、链接、聚焦、选中态(青) */
--ad-primary-hover: #4DEFFF; /* 主操作悬浮 */ --ad-primary-hover: #4DEFFF; /* 主操作悬浮 */
+3 -4
View File
@@ -14,6 +14,7 @@
--ad-glow-accent: 0 0 12px rgba(255, 77, 219, 0.35); --ad-glow-accent: 0 0 12px rgba(255, 77, 219, 0.35);
--ad-glow-warning: 0 0 10px rgba(255, 176, 32, 0.28); /* 仅警告 chip / 警告状态灯 */ --ad-glow-warning: 0 0 10px rgba(255, 176, 32, 0.28); /* 仅警告 chip / 警告状态灯 */
--ad-glow-danger: 0 0 12px rgba(255, 77, 94, 0.35); --ad-glow-danger: 0 0 12px rgba(255, 77, 94, 0.35);
--ad-glow-balanced: 0 0 6px rgba(0, 229, 255, 0.3); /* 中档单层辉光(§5 半径减半),tiers.css 引用 */
/* ---- 主按钮(§2.6v1.2 hover 双层扩散辉光)---- */ /* ---- 主按钮(§2.6v1.2 hover 双层扩散辉光)---- */
--ad-btn-primary-bg: linear-gradient(180deg, #33D9EE 0%, #06BCD9 55%, #049EBB 100%); --ad-btn-primary-bg: linear-gradient(180deg, #33D9EE 0%, #06BCD9 55%, #049EBB 100%);
@@ -29,10 +30,8 @@
--ad-btn-accent-glow-hover: 0 0 14px rgba(255, 77, 219, 0.30), --ad-btn-accent-glow-hover: 0 0 14px rgba(255, 77, 219, 0.30),
0 0 36px rgba(255, 77, 219, 0.12); 0 0 36px rgba(255, 77, 219, 0.12);
/* ---- 背景网格(§3.0:要么可见要么不做---- */ /* ---- 背景氛围(v1.3 §3.0:网格移除,降级为静态深空渐变,三档通用---- */
--ad-grid-color: rgba(0, 229, 255, 0.05); /* 透明度固定 5%,施工员不许再调 */ --ad-bg-gradient: radial-gradient(120% 90% at 50% 0%, #0D1420 0%, #070B14 70%);
--ad-grid-size: 40px;
--ad-grid-mask: radial-gradient(120% 90% at 50% 40%, #000 30%, transparent 75%);
/* ---- 弹窗遮罩(§3.6---- */ /* ---- 弹窗遮罩(§3.6---- */
--ad-modal-mask: rgba(4, 7, 13, 0.7); --ad-modal-mask: rgba(4, 7, 13, 0.7);
+5
View File
@@ -13,6 +13,11 @@
--ad-space-6: 24px; --ad-space-6: 24px;
--ad-space-8: 32px; --ad-space-8: 32px;
--ad-space-10: 40px; --ad-space-10: 40px;
--ad-space-12: 48px; /* v1.3 §3.0 超宽断点内容区边距 */
/* ---- 内容区宽度体系(v1.3 §6:标准 1440 / 宽阔 1760 / 超宽全宽)---- */
--ad-frame-max: 1440px;
--ad-frame-pad-x: var(--ad-space-8);
/* ---- 圆角 ---- */ /* ---- 圆角 ---- */
--ad-radius-s: 4px; /* 标签、小按钮、chip */ --ad-radius-s: 4px; /* 标签、小按钮、chip */
+1 -13
View File
@@ -31,19 +31,7 @@
/* ---- 中档:只保留单层辉光(半径减半),循环动画停用 ---- */ /* ---- 中档:只保留单层辉光(半径减半),循环动画停用 ---- */
[data-fx-tier="balanced"] .fx-multilayer-glow { [data-fx-tier="balanced"] .fx-multilayer-glow {
box-shadow: 0 0 6px rgba(0, 229, 255, 0.3); box-shadow: var(--ad-glow-balanced);
}
/* 背景网格漂移(§4.8,60s 一周,仅高档/中档) */
[data-fx-tier="low"] .grid-drift {
animation: none;
}
.grid-drift {
animation: grid-drift 60s linear infinite;
}
@keyframes grid-drift {
from { background-position: 0 0; }
to { background-position: 40px 40px; }
} }
/* 状态点呼吸(§4.5:仅警告/错误/授权可能过期呼吸;低档停用) */ /* 状态点呼吸(§4.5:仅警告/错误/授权可能过期呼吸;低档停用) */
+89 -31
View File
@@ -1,7 +1,8 @@
//! 目录索引与占位条目加载(Wave 0 //! 目录索引与条目加载(Wave 1
//! //!
//! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml` //! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml`对每个工具
//! 返回目录条目。仅消费 `id / name / name_zh / vendor / status` 五个字段 //! 做完整 schema 校验(含危险命令拒载、版本号校验),最后返回 UI 侧的五字段
//! `CatalogEntry` 视图(`load_catalog`)或完整 `Adapter``load_adapters`)。
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
@@ -9,6 +10,7 @@ use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::AdapterError; use crate::error::AdapterError;
use crate::schema::{Adapter, parse_adapter};
/// 目录索引(adapters/catalog.yaml /// 目录索引(adapters/catalog.yaml
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@@ -24,7 +26,7 @@ pub struct CatalogRef {
pub file: String, pub file: String,
} }
/// 目录条目(占位 schema,Wave 0 仅五字段;完整字段见架构 §3.1 /// 目录条目(五字段视图,供 UI 展示
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct CatalogEntry { pub struct CatalogEntry {
pub id: String, pub id: String,
@@ -36,40 +38,58 @@ pub struct CatalogEntry {
pub status: String, pub status: String,
} }
/// 加载目录索引与全部工具占位 YAML impl From<Adapter> for CatalogEntry {
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> { fn from(a: Adapter) -> Self {
CatalogEntry {
id: a.id,
name: a.name,
name_zh: a.name_zh,
vendor: a.vendor,
status: a.status,
}
}
}
/// 读取目录索引。
fn read_index<P: AsRef<Path>>(adapters_dir: P) -> Result<Catalog, AdapterError> {
let dir = adapters_dir.as_ref(); let dir = adapters_dir.as_ref();
let catalog_text = fs::read_to_string(dir.join("catalog.yaml")) let catalog_text = fs::read_to_string(dir.join("catalog.yaml"))
.map_err(|e| AdapterError::Io(format!("读取 catalog.yaml 失败: {e}")))?; .map_err(|e| AdapterError::Io(format!("读取 catalog.yaml 失败: {e}")))?;
let catalog: Catalog = serde_yaml::from_str(&catalog_text) serde_yaml::from_str(&catalog_text)
.map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))?; .map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))
}
let mut entries = Vec::with_capacity(catalog.tools.len()); /// 加载并校验全部工具适配器(完整 schema)。
pub fn load_adapters<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<Adapter>, AdapterError> {
let dir = adapters_dir.as_ref();
let catalog = read_index(dir)?;
let mut adapters = Vec::with_capacity(catalog.tools.len());
for r in &catalog.tools { for r in &catalog.tools {
let text = fs::read_to_string(dir.join(&r.file)) let text = fs::read_to_string(dir.join(&r.file))
.map_err(|e| AdapterError::Io(format!("读取 {} 失败: {e}", r.file)))?; .map_err(|e| AdapterError::Io(format!("读取 {} 失败: {e}", r.file)))?;
let entry: CatalogEntry = serde_yaml::from_str(&text) let adapter = parse_adapter(&text)
.map_err(|e| AdapterError::Parse(format!("解析 {} 失败: {e}", r.file)))?; .map_err(|e| AdapterError::Parse(format!("{} 校验未通过: {e}", r.file)))?;
if entry.id != r.id { if adapter.id != r.id {
return Err(AdapterError::Parse(format!( return Err(AdapterError::Validation(format!(
"索引 id 与文件内 id 不一致: 索引={} 文件={}", "索引 id 与文件内 id 不一致: 索引={} 文件={}",
r.id, entry.id r.id, adapter.id
))); )));
} }
if entry.status != "available" && entry.status != "watch" { adapters.push(adapter);
return Err(AdapterError::Parse(format!(
"{} 的 status 非法: {}(应为 available | watch",
entry.id, entry.status
)));
}
entries.push(entry);
} }
Ok(entries) Ok(adapters)
}
/// 加载目录并返回 UI 五字段视图(内部已做完整 schema 校验)。
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> {
Ok(load_adapters(adapters_dir)?.into_iter().map(CatalogEntry::from).collect())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::error::AdapterError;
#[test] #[test]
fn parses_minimal_tool_yaml() { fn parses_minimal_tool_yaml() {
@@ -81,19 +101,11 @@ mod tests {
assert_eq!(entry.status, "available"); assert_eq!(entry.status, "available");
} }
#[test]
fn rejects_invalid_status() {
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: unknown\n";
let entry: Result<CatalogEntry, _> = serde_yaml::from_str(yaml);
// 解析本身成功,非法 status 由 load_catalog 校验;此处确认 schema 字段可读
assert!(entry.is_ok());
}
#[test] #[test]
fn loads_real_catalog_from_repo() { fn loads_real_catalog_from_repo() {
// 以真实 adapters/ 目录做集成测试(相对本 crate 位于 ../../adapters // 以真实 adapters/ 目录做集成测试(相对本 crate 位于 ../../adapters
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters"); let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let entries = load_catalog(&dir).expect("真实目录应可加载"); let entries = load_catalog(&dir).expect("真实目录应可加载并通过校验");
assert_eq!(entries.len(), 14, "第一批应为 14 个工具"); assert_eq!(entries.len(), 14, "第一批应为 14 个工具");
for e in &entries { for e in &entries {
assert!(!e.id.is_empty()); assert!(!e.id.is_empty());
@@ -107,4 +119,50 @@ mod tests {
assert!(ids.contains(&want), "目录应包含 {want}"); assert!(ids.contains(&want), "目录应包含 {want}");
} }
} }
/// 加载器拒载危险适配器(含 shell 元字符)并给中文错误。
#[test]
fn loader_rejects_dangerous_adapter() {
let dir = std::env::temp_dir().join(format!("agentdock-adapters-danger-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("tools")).unwrap();
fs::write(
dir.join("catalog.yaml"),
"catalog_version: 1\ntools:\n - id: evil\n file: tools/evil.yaml\n",
)
.unwrap();
fs::write(
dir.join("tools/evil.yaml"),
"id: evil\nname: Evil\nname_zh: Evil\nvendor: X\nstatus: available\ninstall:\n channels:\n - id: official_script\n command: [\"curl\", \"x | sh\"]\n",
)
.unwrap();
match load_adapters(&dir) {
Err(AdapterError::Parse(m)) => assert!(m.contains('|'), "错误应含元字符: {m}"),
other => panic!("应因危险命令拒载,实际 {other:?}"),
}
let _ = fs::remove_dir_all(&dir);
}
/// 加载器校验版本号:非法 adapter_version 拒载。
#[test]
fn loader_rejects_bad_version() {
let dir = std::env::temp_dir().join(format!("agentdock-adapters-ver-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("tools")).unwrap();
fs::write(
dir.join("catalog.yaml"),
"catalog_version: 1\ntools:\n - id: bad\n file: tools/bad.yaml\n",
)
.unwrap();
fs::write(
dir.join("tools/bad.yaml"),
"id: bad\nname: Bad\nname_zh: Bad\nvendor: X\nstatus: available\nadapter_version: not-semver\n",
)
.unwrap();
assert!(matches!(load_adapters(&dir), Err(AdapterError::Parse(_))));
let _ = fs::remove_dir_all(&dir);
}
} }
+56
View File
@@ -0,0 +1,56 @@
//! 危险命令检测(架构 §4.2)
//!
//! 默认禁止 shell 拼接:管道、重定向、`$()`、反引号、`&&` 链、`;`、`\`。
//! 适配器声明里的所有 `command` argv 都必须在加载期通过本检测,
//! 否则整条适配器拒载并给出中文错误。
/// shell 元字符集合(架构 §4.2`(` `)` 覆盖 `$()`,反引号覆盖命令替换)
const SHELL_METACHARS: &[char] = &['|', '&', ';', '$', '\\', '>', '<', '(', ')', '`'];
/// 返回第一个命中的 shell 元字符;无则返回 None。
pub fn first_shell_metachar(s: &str) -> Option<char> {
s.chars().find(|c| SHELL_METACHARS.contains(c))
}
/// 判断一个字符串是否含 shell 元字符。
pub fn contains_shell_metachar(s: &str) -> bool {
first_shell_metachar(s).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_common_metachars() {
for (sample, expected) in [
("curl | sh", Some('|')),
("a && b", Some('&')),
("a; rm -rf /", Some(';')),
("$(id)", Some('$')),
("echo `whoami`", Some('`')),
("ls > out", Some('>')),
("cat < in", Some('<')),
("a\\b", Some('\\')),
("echo (x)", Some('(')),
("echo )", Some(')')),
] {
assert_eq!(first_shell_metachar(sample), expected, "样本 {sample:?}");
}
}
#[test]
fn allows_plain_argv() {
for sample in [
"npm",
"install",
"-g",
"@openai/codex",
"codex",
"--version",
"https://example.com/install.ps1",
] {
assert!(!contains_shell_metachar(sample), "普通参数 {sample:?} 不应被判危险");
}
}
}
+9
View File
@@ -6,6 +6,12 @@ use std::fmt;
pub enum AdapterError { pub enum AdapterError {
Io(String), Io(String),
Parse(String), Parse(String),
/// schema / 语义校验失败(中文)
Validation(String),
/// 命令含 shell 元字符等危险输入(中文)
DangerousCommand(String),
/// 尚未实现的能力(Wave 2 起逐波落地)
NotImplemented(String),
} }
impl fmt::Display for AdapterError { impl fmt::Display for AdapterError {
@@ -13,6 +19,9 @@ impl fmt::Display for AdapterError {
match self { match self {
AdapterError::Io(m) => write!(f, "IO: {m}"), AdapterError::Io(m) => write!(f, "IO: {m}"),
AdapterError::Parse(m) => write!(f, "Parse: {m}"), AdapterError::Parse(m) => write!(f, "Parse: {m}"),
AdapterError::Validation(m) => write!(f, "校验失败: {m}"),
AdapterError::DangerousCommand(m) => write!(f, "危险命令: {m}"),
AdapterError::NotImplemented(m) => write!(f, "未实现: {m}"),
} }
} }
} }
+292
View File
@@ -0,0 +1,292 @@
//! 统一执行器接口与 dry-run(架构 §3.2
//!
//! `AdapterExecutor` 是各适配器的统一操作面;本波只落地骨架与
//! `preview_action`(返回 `DryRunPlan`),真实安装/检测/配置/授权/诊断
//! 命令的执行在 Wave 2 起由具体实现补全。所有 command 一律走 `agentdock-exec`
//! 的 argv 数组执行,禁止 shell 拼接。
use serde::{Deserialize, Serialize};
use crate::error::AdapterError;
use crate::schema::Adapter;
/// 动作类型(对应 IPC 契约 previewAction/runAction 的 action
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdapterAction {
Install,
Update,
Uninstall,
WriteConfig,
Authorize,
Repair,
}
impl AdapterAction {
/// 动作的中文名(用于 dry-run 说明与 UI
pub fn label_zh(&self) -> &'static str {
match self {
AdapterAction::Install => "安装",
AdapterAction::Update => "更新",
AdapterAction::Uninstall => "卸载",
AdapterAction::WriteConfig => "写配置",
AdapterAction::Authorize => "授权",
AdapterAction::Repair => "修复",
}
}
}
/// 干燥运行计划:命令 argv、权限、影响文件、回滚说明(架构 §3.2 dry_run
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DryRunPlan {
/// 将执行的命令(argv 数组,禁止 shell)
pub commands: Vec<Vec<String>>,
/// 是否需要权限提升
pub elevate: bool,
/// 权限提升说明(中文),需要提升时必填
pub elevate_reason_zh: Option<String>,
/// 将影响/写入的文件路径
pub affected_files: Vec<String>,
/// 回滚说明(中文)
pub rollback_zh: String,
}
/// 统一执行器接口(架构 §3.2)
///
/// 除 `dry_run` 外,其余方法为骨架:默认返回 `NotImplemented`,由 Wave 2 起
/// 各适配器实现补全。任何实现都不得绕过 `agentdock-exec` 拼接 shell。
pub trait AdapterExecutor {
/// 返回适配器定义
fn adapter(&self) -> &Adapter;
/// 干燥运行:不真正执行,返回将执行的命令与影响面
fn dry_run(&self, action: AdapterAction) -> Result<DryRunPlan, AdapterError> {
preview_action(self.adapter(), action)
}
fn detect(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("detect 自 Wave 2 起实现".into()))
}
fn install(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("install 自 Wave 2 起实现".into()))
}
fn update(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("update 自 Wave 2 起实现".into()))
}
fn uninstall(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("uninstall 自 Wave 2 起实现".into()))
}
fn read_config(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("read_config 自 Wave 2 起实现".into()))
}
fn write_config(&self, _patch: &str) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("write_config 自 Wave 2 起实现".into()))
}
fn authorization_status(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("authorization_status 自 Wave 2 起实现".into()))
}
fn authorize(&self, _mode: &str) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("authorize 自 Wave 2 起实现".into()))
}
fn diagnose(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("diagnose 自 Wave 2 起实现".into()))
}
}
/// 根据适配器声明 + 动作生成干燥运行计划(不执行)。
pub fn preview_action(adapter: &Adapter, action: AdapterAction) -> Result<DryRunPlan, AdapterError> {
let mut commands: Vec<Vec<String>> = Vec::new();
let mut elevate = false;
let mut elevate_reason_zh: Option<String> = None;
let mut affected_files: Vec<String> = Vec::new();
// 影响文件:配置文件路径(写配置/授权/修复都会触碰)
if let Some(cfg) = &adapter.configuration {
for f in &cfg.files {
affected_files.push(f.path.clone());
}
}
match action {
AdapterAction::Install => {
if let Some(install) = &adapter.install {
if let Some(channel) = pick_channel(install) {
if !channel.command.is_empty() {
commands.push(channel.command.clone());
}
elevate = channel_elevates(channel);
elevate_reason_zh = channel.elevate_reason_zh.clone();
if let Some(script) = &channel.script {
if let Some(url) = &script.url {
affected_files.push(format!("下载脚本: {url}"));
}
}
}
}
}
AdapterAction::Update => {
if let Some(update) = &adapter.update {
if !update.command.is_empty() {
commands.push(update.command.clone());
}
}
}
AdapterAction::Uninstall => {
if let Some(uninstall) = &adapter.uninstall {
if !uninstall.command.is_empty() {
commands.push(uninstall.command.clone());
}
}
}
AdapterAction::Authorize => {
if let Some(auth) = &adapter.authorization {
if let Some(mode) = auth.modes.first() {
if !mode.command.is_empty() {
commands.push(mode.command.clone());
}
}
}
}
AdapterAction::WriteConfig => {
// 写配置无外部命令,仅落盘配置文件
}
AdapterAction::Repair => {
// 修复由诊断规则驱动,本波仅占位
}
}
let rollback_zh = rollback_note(adapter, action);
Ok(DryRunPlan {
commands,
elevate,
elevate_reason_zh,
affected_files,
rollback_zh,
})
}
/// 取首选渠道,无 preferred 时回退到第一个声明了命令的渠道。
fn pick_channel(install: &crate::schema::Install) -> Option<&crate::schema::Channel> {
if let Some(preferred) = &install.preferred {
if let Some(ch) = install.channels.iter().find(|c| &c.id == preferred) {
return Some(ch);
}
}
install.channels.first()
}
/// 渠道是否需要权限提升(never → false,其余 true)。
fn channel_elevates(channel: &crate::schema::Channel) -> bool {
matches!(channel.elevate.as_deref(), Some("if_needed") | Some("required"))
}
/// 生成中文回滚说明。
fn rollback_note(adapter: &Adapter, action: AdapterAction) -> String {
match action {
AdapterAction::Install | AdapterAction::Update => {
format!("如需回退,可重新运行卸载({});已保留原配置文件不动。", adapter.id)
}
AdapterAction::Uninstall => {
format!("卸载默认保留配置文件({});如需彻底移除请手动删除配置文件。", adapter.id)
}
AdapterAction::WriteConfig => {
format!("写配置前会自动备份原文件为 .bak.<时间戳>,可随时恢复。")
}
AdapterAction::Authorize => {
format!("授权信息仅写入系统密钥库,不落盘;如需撤销可删除对应密钥条目。")
}
AdapterAction::Repair => {
format!("修复动作前会生成干燥运行计划,确认后才执行。")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::parse_adapter;
#[test]
fn preview_install_builds_argv_plan() {
let adapter = parse_adapter(
r#"
id: claude-code
name: Claude Code
name_zh: Claude Code
vendor: Anthropic
status: available
install:
preferred: npm
channels:
- id: npm
platforms: [windows]
command: [npm, install, -g, "@anthropic-ai/claude-code"]
elevate: never
configuration:
files:
- path: "~/.claude/settings.json"
format: json
"#,
)
.unwrap();
let plan = preview_action(&adapter, AdapterAction::Install).unwrap();
assert_eq!(plan.commands, vec![vec!["npm", "install", "-g", "@anthropic-ai/claude-code"]]);
assert!(!plan.elevate);
assert!(plan.affected_files.iter().any(|f| f.contains("settings.json")));
assert!(!plan.rollback_zh.is_empty());
}
#[test]
fn preview_elevates_when_required() {
let adapter = parse_adapter(
r#"
id: crush
name: Crush
name_zh: Crush
vendor: Charm
status: available
install:
preferred: apt
channels:
- id: apt
command: [apt, install, crush]
elevate: required
elevate_reason_zh: "需要管理员权限写入系统目录"
"#,
)
.unwrap();
let plan = preview_action(&adapter, AdapterAction::Install).unwrap();
assert!(plan.elevate);
assert_eq!(plan.elevate_reason_zh.as_deref(), Some("需要管理员权限写入系统目录"));
}
#[test]
fn action_label_zh_is_chinese() {
assert_eq!(AdapterAction::Install.label_zh(), "安装");
assert_eq!(AdapterAction::WriteConfig.label_zh(), "写配置");
}
#[test]
fn wave2_methods_return_not_implemented() {
let adapter = parse_adapter("id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\n").unwrap();
struct NoopExecutor(Adapter);
impl AdapterExecutor for NoopExecutor {
fn adapter(&self) -> &Adapter {
&self.0
}
}
let ex = NoopExecutor(adapter);
assert!(matches!(ex.detect(), Err(AdapterError::NotImplemented(_))));
assert!(matches!(ex.install(), Err(AdapterError::NotImplemented(_))));
}
}
+9 -3
View File
@@ -1,11 +1,17 @@
//! agentdock-adapter —— 适配器层 //! agentdock-adapter —— 适配器层
//! //!
//! 负责适配器 schema 加载、版本校验、dry-run 与执行器接口(架构 §3)。 //! 负责适配器 schema 加载、版本校验、dry-run 与执行器接口(架构 §3)。
//! Wave 0:仅落地目录索引与占位条目加载(`catalog` 模块); //! Wave 1:完整 schema 定义与校验(含危险命令拒载)、统一执行器骨架、
//! 完整 schema 校验、执行器接口随 Wave 1 实现 //! dry-run 计划(`preview_action`)。真实安装/检测/配置命令 Wave 2 起落地
pub mod catalog; pub mod catalog;
pub mod danger;
pub mod error; pub mod error;
pub mod executor;
pub mod schema;
pub use catalog::{Catalog, CatalogEntry, CatalogRef, load_catalog}; pub use catalog::{Catalog, CatalogEntry, CatalogRef, load_adapters, load_catalog};
pub use danger::{contains_shell_metachar, first_shell_metachar};
pub use error::AdapterError; pub use error::AdapterError;
pub use executor::{AdapterAction, AdapterExecutor, DryRunPlan, preview_action};
pub use schema::{Adapter, parse_adapter};
+557
View File
@@ -0,0 +1,557 @@
//! 适配器完整 schema(对齐架构 §3.1)
//!
//! 与 `adapters/schema/adapter.schema.json` 同源:JSON Schema 是声明式契约,
//! 本文件用 Rust 强类型结构体做运行时校验(deny_unknown_fields + 语义校验),
//! 两者字段一一对应。Wave 1 的 14 个占位 YAML 仅填五字段,其余字段均可选。
use serde::{Deserialize, Serialize};
use crate::danger::first_shell_metachar;
use crate::error::AdapterError;
/// 适配器定义(完整字段,§3.1)
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Adapter {
pub id: String,
pub name: String,
#[serde(rename = "name_zh")]
pub name_zh: String,
pub vendor: String,
/// available | watch
pub status: String,
/// semver,如 1.2.0
#[serde(rename = "adapter_version", default)]
pub adapter_version: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub platforms: Option<Platforms>,
#[serde(default)]
pub official: Option<Official>,
#[serde(rename = "runtime_deps", default)]
pub runtime_deps: Vec<RuntimeDep>,
#[serde(default)]
pub install: Option<Install>,
#[serde(default)]
pub detect: Option<Detect>,
#[serde(default)]
pub update: Option<Update>,
#[serde(default)]
pub uninstall: Option<Uninstall>,
#[serde(default)]
pub authorization: Option<Authorization>,
#[serde(default)]
pub configuration: Option<Configuration>,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
#[serde(default)]
pub documentation: Option<Documentation>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Platforms {
#[serde(default)]
pub windows: Option<PlatformWindows>,
#[serde(default)]
pub linux: Option<PlatformLinux>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PlatformWindows {
#[serde(default)]
pub architectures: Vec<String>,
#[serde(default)]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PlatformLinux {
#[serde(default)]
pub distributions: Vec<String>,
#[serde(default)]
pub architectures: Vec<String>,
#[serde(rename = "min_ubuntu", default)]
pub min_ubuntu: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Official {
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub docs: Option<String>,
#[serde(rename = "allowed_hosts", default)]
pub allowed_hosts: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RuntimeDep {
pub id: String,
#[serde(rename = "semver_range", default)]
pub semver_range: Option<String>,
#[serde(rename = "required_for", default)]
pub required_for: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Install {
#[serde(default)]
pub preferred: Option<String>,
#[serde(default)]
pub channels: Vec<Channel>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Channel {
pub id: String,
#[serde(default)]
pub platforms: Vec<String>,
#[serde(default)]
pub command: Vec<String>,
#[serde(default)]
pub script: Option<Script>,
#[serde(default)]
pub package: Option<String>,
#[serde(default)]
pub elevate: Option<String>,
#[serde(rename = "elevate_reason_zh", default)]
pub elevate_reason_zh: Option<String>,
#[serde(rename = "post_checks", default)]
pub post_checks: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Script {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub integrity: Option<Integrity>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Integrity {
#[serde(default)]
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Detect {
pub executable: String,
#[serde(rename = "version_args", default)]
pub version_args: Vec<String>,
#[serde(rename = "version_regex", default)]
pub version_regex: Option<String>,
#[serde(rename = "version_unconfirmed", default)]
pub version_unconfirmed: Option<bool>,
#[serde(rename = "path_hints", default)]
pub path_hints: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Update {
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub command: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Uninstall {
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub command: Vec<String>,
#[serde(rename = "keep_config_default", default = "default_true")]
pub keep_config_default: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Authorization {
#[serde(default)]
pub modes: Vec<AuthMode>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct AuthMode {
pub mode: String,
#[serde(default)]
pub command: Vec<String>,
#[serde(rename = "env_keys", default)]
pub env_keys: Vec<String>,
#[serde(rename = "status_command", default)]
pub status_command: Vec<String>,
#[serde(rename = "notes_zh", default)]
pub notes_zh: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Configuration {
#[serde(default)]
pub files: Vec<ConfigFile>,
#[serde(default)]
pub environment: Vec<EnvMapping>,
#[serde(default)]
pub fields: Vec<ConfigField>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
pub path: String,
pub format: String,
#[serde(default)]
pub scope: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct EnvMapping {
pub key: String,
#[serde(default)]
pub sensitive: bool,
#[serde(rename = "maps_to_field", default)]
pub maps_to_field: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigField {
pub id: String,
#[serde(rename = "label_zh")]
pub label_zh: String,
#[serde(rename = "help_zh", default)]
pub help_zh: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub sensitive: bool,
/// string | url | enum | bool
#[serde(rename = "type")]
pub field_type: String,
/// file | env | keyring
pub storage: String,
#[serde(default)]
pub platforms: Vec<String>,
#[serde(rename = "docs_url", default)]
pub docs_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Diagnostic {
#[serde(rename = "rule_id")]
pub rule_id: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Documentation {
#[serde(rename = "quickstart_zh", default)]
pub quickstart_zh: Option<String>,
#[serde(default)]
pub commands: Vec<DocCommand>,
#[serde(rename = "updated_at", default)]
pub updated_at: Option<String>,
#[serde(rename = "risks_zh", default)]
pub risks_zh: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct DocCommand {
pub cmd: String,
#[serde(rename = "desc_zh", default)]
pub desc_zh: Option<String>,
}
fn default_true() -> bool {
true
}
/// 校验 id 是否符合稳定 ID 约定:`^[a-z0-9][a-z0-9-]*$`
pub fn is_valid_id(id: &str) -> bool {
let mut chars = id.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
/// 校验 semver 形式(x.y.z,可选 -prerelease / +build),不引入 semver 依赖。
pub fn is_valid_semver(s: &str) -> bool {
let s = s.trim();
if s.is_empty() {
return false;
}
// 拆分 build 元数据(+...
let (no_build, build) = match s.split_once('+') {
Some((a, b)) => (a, Some(b)),
None => (s, None),
};
if let Some(b) = build {
if !is_semver_ident(b) {
return false;
}
}
// 拆分预发布(-...
let (core, pre) = match no_build.split_once('-') {
Some((a, b)) => (a, Some(b)),
None => (no_build, None),
};
let parts: Vec<&str> = core.split('.').collect();
if parts.len() != 3 {
return false;
}
for p in &parts {
if p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()) {
return false;
}
}
if let Some(pre) = pre {
if !is_semver_ident(pre) {
return false;
}
}
true
}
/// semver 标识段(预发布/构建元数据):由字母数字与 `-` 组成,点分隔各段非空。
fn is_semver_ident(s: &str) -> bool {
if s.is_empty() {
return false;
}
s.split('.').all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
}
/// 从 YAML 文本解析并校验适配器(解析失败 → Parse,语义/危险命令 → 对应错误)。
pub fn parse_adapter(yaml: &str) -> Result<Adapter, AdapterError> {
let adapter: Adapter = serde_yaml::from_str(yaml).map_err(|e| AdapterError::Parse(e.to_string()))?;
adapter.validate()?;
Ok(adapter)
}
impl Adapter {
/// 语义校验:id / status / 版本号 / 危险命令。
/// 校验通过返回 Ok(()),否则返回带中文说明的错误。
pub fn validate(&self) -> Result<(), AdapterError> {
if self.id.is_empty() {
return Err(AdapterError::Validation("id 不能为空".into()));
}
if !is_valid_id(&self.id) {
return Err(AdapterError::Validation(format!(
"id「{}」非法:只能由小写字母、数字、连字符组成,且以字母或数字开头",
self.id
)));
}
if self.status != "available" && self.status != "watch" {
return Err(AdapterError::Validation(format!(
"{}」的 status 非法: {}(应为 available | watch",
self.id, self.status
)));
}
if let Some(v) = &self.adapter_version {
if !is_valid_semver(v) {
return Err(AdapterError::Validation(format!(
"{}」的 adapter_version 非法: {}(应为 semver,如 1.2.0",
self.id, v
)));
}
}
// 危险命令检查:所有 command argv 必须不含 shell 元字符
if let Some(offender) = self.find_dangerous_command() {
return Err(AdapterError::DangerousCommand(format!(
"适配器「{}」声明了含 shell 元字符的命令参数 {:?},已拒绝加载(禁止管道/重定向/命令替换等)",
self.id, offender
)));
}
Ok(())
}
/// 遍历所有 command 数组,返回首个含 shell 元字符的参数。
pub fn find_dangerous_command(&self) -> Option<String> {
let mut commands: Vec<&Vec<String>> = Vec::new();
if let Some(install) = &self.install {
for ch in &install.channels {
commands.push(&ch.command);
}
}
if let Some(update) = &self.update {
commands.push(&update.command);
}
if let Some(uninstall) = &self.uninstall {
commands.push(&uninstall.command);
}
if let Some(auth) = &self.authorization {
for m in &auth.modes {
commands.push(&m.command);
commands.push(&m.status_command);
}
}
for argv in commands {
for arg in argv {
if let Some(c) = first_shell_metachar(arg) {
return Some(format!("{arg}(元字符 {c:?}"));
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(yaml: &str) -> Result<Adapter, AdapterError> {
let adapter: Adapter = serde_yaml::from_str(yaml).map_err(|e| AdapterError::Parse(e.to_string()))?;
adapter.validate()?;
Ok(adapter)
}
// ---- 合法 fixture(≥3 ----
#[test]
fn valid_minimal_five_fields() {
let yaml = "id: codex\nname: Codex CLI\nname_zh: Codex CLI\nvendor: OpenAI\nstatus: available\n";
assert!(parse(yaml).is_ok());
}
#[test]
fn valid_full_schema() {
let yaml = r#"
id: claude-code
name: Claude Code
name_zh: Claude Code
vendor: Anthropic
status: available
adapter_version: 1.2.0
license: 专有(仅官方渠道安装、不重打包)
platforms:
windows:
architectures: [x64]
linux:
distributions: [ubuntu]
architectures: [x64]
min_ubuntu: "22.04"
official:
homepage: https://claude.ai
docs: https://docs.anthropic.com
allowed_hosts: [claude.ai]
runtime_deps:
- id: node
semver_range: ">=20"
required_for: [install]
install:
preferred: npm
channels:
- id: npm
platforms: [windows, linux]
command: [npm, install, -g, "@anthropic-ai/claude-code"]
elevate: never
detect:
executable: claude
version_args: ["--version"]
version_regex: "^v?(\\d+\\.\\d+\\.\\d+)"
authorization:
modes:
- mode: browser_oauth
notes_zh: 浏览器登录
configuration:
files:
- path: "~/.claude/settings.json"
format: json
scope: user
"#;
let adapter = parse(yaml).expect("全字段 fixture 应合法");
assert_eq!(adapter.adapter_version.as_deref(), Some("1.2.0"));
assert_eq!(adapter.install.as_ref().unwrap().channels.len(), 1);
}
#[test]
fn valid_pre_release_semver() {
let yaml = "id: warp\nname: Warp Agent CLI\nname_zh: Warp Agent CLI\nvendor: Warp\nstatus: available\nadapter_version: 2.0.0-rc.1\n";
assert!(parse(yaml).is_ok());
}
// ---- 非法 fixture(≥3 ----
#[test]
fn invalid_unknown_field_rejected() {
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\nbogus_field: 1\n";
let adapter: Result<Adapter, _> = serde_yaml::from_str(yaml);
assert!(adapter.is_err(), "未知字段应被 deny_unknown_fields 拒绝");
}
#[test]
fn invalid_id_pattern_rejected() {
let yaml = "id: Bad_ID!\nname: X\nname_zh: X\nvendor: V\nstatus: available\n";
match parse(yaml) {
Err(AdapterError::Validation(m)) => assert!(m.contains("id"), "错误应指向 id: {m}"),
other => panic!("应返回 Validation 错误,实际 {other:?}"),
}
}
#[test]
fn invalid_status_rejected() {
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: unknown\n";
assert!(matches!(parse(yaml), Err(AdapterError::Validation(_))));
}
#[test]
fn invalid_adapter_version_rejected() {
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\nadapter_version: not-a-version\n";
assert!(matches!(parse(yaml), Err(AdapterError::Validation(_))));
}
#[test]
fn dangerous_command_rejected_with_chinese_error() {
let yaml = r#"
id: x
name: X
name_zh: X
vendor: V
status: available
install:
channels:
- id: official_script
command: ["curl", "https://x.sh", "|", "sh"]
"#;
match parse(yaml) {
Err(AdapterError::DangerousCommand(m)) => {
assert!(m.contains('|'), "错误信息应包含元字符: {m}");
assert!(m.contains("已拒绝"), "错误应为中文且明确拒载: {m}");
}
other => panic!("应返回 DangerousCommand,实际 {other:?}"),
}
}
#[test]
fn semver_helper() {
assert!(is_valid_semver("1.2.0"));
assert!(is_valid_semver("0.0.1"));
assert!(is_valid_semver("10.20.30-alpha.1+build5"));
assert!(!is_valid_semver("1.2"));
assert!(!is_valid_semver("1.2.x"));
assert!(!is_valid_semver(""));
assert!(!is_valid_semver("v1.2.3"));
}
}
+31
View File
@@ -0,0 +1,31 @@
//! 执行层错误类型(中文,不含密钥明文)
use std::fmt;
#[derive(Debug)]
pub enum ExecError {
/// 参数含 shell 元字符等危险输入
DangerousInput(String),
/// 参数槽位白名单校验失败
InvalidSlot(String),
/// 工作目录越界
CwdNotAllowed(String),
/// 进程启动失败
Spawn(String),
/// 非零退出码
NonZeroExit(String),
}
impl fmt::Display for ExecError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ExecError::DangerousInput(m) => write!(f, "危险输入: {m}"),
ExecError::InvalidSlot(m) => write!(f, "参数校验失败: {m}"),
ExecError::CwdNotAllowed(m) => write!(f, "工作目录越界: {m}"),
ExecError::Spawn(m) => write!(f, "进程启动失败: {m}"),
ExecError::NonZeroExit(m) => write!(f, "命令非零退出: {m}"),
}
}
}
impl std::error::Error for ExecError {}
+141
View File
@@ -0,0 +1,141 @@
//! 仅 argv 数组的安全进程执行(架构 §4.2)
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use crate::error::ExecError;
/// shell 注入元字符(argv 场景,架构 §4.2)。`\` `/` 属路径分隔符,允许出现在
/// 可执行文件路径或路径参数里(argv 不经 shell,无转义语义)。
const SHELL_INJECT_CHARS: &[char] = &['|', '&', ';', '$', '>', '<', '(', ')', '`'];
/// 返回首个 shell 注入元字符。
pub fn first_metachar(s: &str) -> Option<char> {
s.chars().find(|c| SHELL_INJECT_CHARS.contains(c))
}
/// 校验可执行文件名:非空、不含 shell 注入元字符、不以 `-` 开头。
fn validate_executable(prog: &str) -> Result<(), ExecError> {
if prog.is_empty() {
return Err(ExecError::InvalidSlot("可执行文件名不能为空".into()));
}
if let Some(c) = first_metachar(prog) {
return Err(ExecError::DangerousInput(format!("可执行文件名含 shell 元字符 {c:?}: {prog}")));
}
if prog.starts_with('-') {
return Err(ExecError::InvalidSlot(format!("可执行文件名不能以 - 开头: {prog}")));
}
Ok(())
}
/// 校验 argv 数组:可执行文件 + 每个参数均不含 shell 元字符。
/// 用户输入只能作为已校验参数槽位(枚举/路径/版本号)传入,禁止拼接。
pub fn validate_argv(prog: &str, args: &[String]) -> Result<(), ExecError> {
validate_executable(prog)?;
for arg in args {
if let Some(c) = first_metachar(arg) {
return Err(ExecError::DangerousInput(format!("参数含 shell 元字符 {c:?}: {arg}")));
}
}
Ok(())
}
/// 路径规范化:优先 canonicalize(消 .. 与符号链接),失败则转绝对路径。
fn normalize(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| {
if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_default()
.join(p)
}
})
}
/// 工作目录限制(架构 §4.2 第 3 条):cwd 必须落在某个允许的根目录之内。
pub fn ensure_cwd_within(cwd: &Path, allowed_roots: &[PathBuf]) -> Result<(), ExecError> {
if allowed_roots.is_empty() {
return Err(ExecError::CwdNotAllowed("未配置允许的工作目录根".into()));
}
let cwd_norm = normalize(cwd);
for root in allowed_roots {
let root_norm = normalize(root);
if cwd_norm.starts_with(&root_norm) {
return Ok(());
}
}
Err(ExecError::CwdNotAllowed(format!(
"工作目录「{}」超出允许范围",
cwd.display()
)))
}
/// 仅 argv 执行:`Command::new(prog).args(args)`,禁止 shell 拼接 / 管道 / 重定向。
/// cwd 为 None 时继承当前进程目录。
pub fn spawn(prog: &str, args: &[String], cwd: Option<&Path>) -> Result<Output, ExecError> {
validate_argv(prog, args)?;
let mut cmd = Command::new(prog);
cmd.args(args);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
cmd.output()
.map_err(|e| ExecError::Spawn(format!("无法执行 {prog}: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_shell_metachar_in_args() {
for (prog, args) in [
("npm", vec!["a|b".to_string()]),
("npm", vec!["a&&b".to_string()]),
("sh", vec!["$(id)".to_string()]),
("sh", vec!["`whoami`".to_string()]),
("sh", vec!["a;b".to_string()]),
] {
assert!(matches!(validate_argv(prog, &args), Err(ExecError::DangerousInput(_))),
"{prog} {args:?} 应被拒绝");
}
}
#[test]
fn rejects_metachar_in_executable() {
assert!(matches!(validate_argv("a|b", &[]), Err(ExecError::DangerousInput(_))));
assert!(matches!(validate_argv("", &[]), Err(ExecError::InvalidSlot(_))));
assert!(matches!(validate_argv("-rf", &[]), Err(ExecError::InvalidSlot(_))));
}
#[test]
fn accepts_plain_argv() {
assert!(validate_argv("npm", &["install".into(), "-g".into(), "@openai/codex".into()]).is_ok());
assert!(validate_argv("C:\\Program Files\\nodejs\\node.exe", &["--version".into()]).is_ok());
}
#[test]
fn cwd_within_allowed_root() {
let root = std::env::temp_dir();
let ok = root.join("agentdock-exec-ok");
let out = std::env::temp_dir().join("agentdock-exec-out");
let _ = std::fs::create_dir_all(&ok);
assert!(ensure_cwd_within(&ok, &[root.clone()]).is_ok());
assert!(ensure_cwd_within(&out, &[ok.clone()]).is_err());
}
#[cfg(windows)]
#[test]
fn spawn_runs_argv_without_shell() {
let out = spawn("where.exe", &["where".to_string()], None).expect("where.exe 应可执行");
assert!(out.status.success());
}
#[cfg(not(windows))]
#[test]
fn spawn_runs_argv_without_shell() {
let out = spawn("/bin/true", &[], None).expect("/bin/true 应可执行");
assert!(out.status.success());
}
}
+8 -13
View File
@@ -1,17 +1,12 @@
//! agentdock-exec —— 安全进程执行层(架构 §3.2 / §4.2) //! agentdock-exec —— 安全进程执行层(架构 §3.2 / §4.2)
//! //!
//! 职责:仅执行适配器声明的 argv,禁止 shell 拼接、管道、重定向; //! 职责:仅执行适配器声明的 argv 数组,禁止 shell 拼接、管道、重定向;
//! 参数白名单校验。Wave 0:空骨架,随 Wave 1 落地 //! 参数槽位白名单校验;工作目录限制
/// 执行层能力标记(占位) pub mod error;
pub const LAYER: &str = "agentdock-exec"; pub mod exec;
pub mod slots;
#[cfg(test)] pub use error::ExecError;
mod tests { pub use exec::{ensure_cwd_within, spawn, validate_argv};
use super::*; pub use slots::{Slot, validate_slot};
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-exec");
}
}
+89
View File
@@ -0,0 +1,89 @@
//! 参数槽位白名单校验(架构 §4.2:用户输入只作为已校验参数槽位)
use crate::error::ExecError;
use crate::exec::first_metachar;
/// 参数槽位类型。
#[derive(Debug, Clone, PartialEq)]
pub enum Slot {
/// 枚举:值必须在白名单内
Enum(Vec<&'static str>),
/// 版本号:纯数字 + 点 + 可选连字符(如 1.2.3 / 24.18.0
Version,
/// 路径:禁止 shell 元字符
Path,
/// 普通参数:禁止 shell 元字符、禁止为空
Plain,
}
/// 校验单个参数槽位。
pub fn validate_slot(slot: &Slot, value: &str) -> Result<(), ExecError> {
if value.is_empty() {
return Err(ExecError::InvalidSlot("参数不能为空".into()));
}
if let Some(c) = first_metachar(value) {
return Err(ExecError::DangerousInput(format!("参数含 shell 元字符 {c:?}: {value}")));
}
match slot {
Slot::Enum(allowed) => {
if !allowed.iter().any(|a| *a == value) {
return Err(ExecError::InvalidSlot(format!(
"值「{value}」不在白名单 {allowed:?}"
)));
}
}
Slot::Version => {
if !value.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') {
return Err(ExecError::InvalidSlot(format!("{value}」不是合法版本号")));
}
}
Slot::Path | Slot::Plain => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enum_whitelist_accepts_listed_value() {
let slot = Slot::Enum(vec!["npm", "winget", "apt"]);
assert!(validate_slot(&slot, "npm").is_ok());
assert!(validate_slot(&slot, "winget").is_ok());
}
#[test]
fn enum_whitelist_rejects_unknown_value() {
let slot = Slot::Enum(vec!["npm", "winget", "apt"]);
match validate_slot(&slot, "choco") {
Err(ExecError::InvalidSlot(m)) => assert!(m.contains("choco"), "错误应包含被拒值: {m}"),
other => panic!("应返回 InvalidSlot,实际 {other:?}"),
}
}
#[test]
fn version_slot_accepts_and_rejects() {
let slot = Slot::Version;
assert!(validate_slot(&slot, "24.18.0").is_ok());
assert!(validate_slot(&slot, "3.14.6").is_ok());
assert!(matches!(validate_slot(&slot, "abc"), Err(ExecError::InvalidSlot(_))));
}
#[test]
fn rejects_metachar_in_any_slot() {
for slot in [
Slot::Plain,
Slot::Path,
Slot::Version,
Slot::Enum(vec!["npm"]),
] {
assert!(matches!(validate_slot(&slot, "a|b"), Err(ExecError::DangerousInput(_))));
}
}
#[test]
fn rejects_empty() {
assert!(matches!(validate_slot(&Slot::Plain, ""), Err(ExecError::InvalidSlot(_))));
}
}
+61 -3
View File
@@ -15,10 +15,14 @@ pub fn detect_env() -> PlatformEnv {
#[cfg(not(windows))] #[cfg(not(windows))]
let (os, os_version) = (String::from("linux"), linux_os_version()); let (os, os_version) = (String::from("linux"), linux_os_version());
let (distro, distro_version) = distro_info();
PlatformEnv { PlatformEnv {
os, os,
os_version, os_version,
arch: std::env::consts::ARCH.to_string(), arch: std::env::consts::ARCH.to_string(),
distro,
distro_version,
shells: detect_shells(), shells: detect_shells(),
runtimes: detect_runtimes(), runtimes: detect_runtimes(),
path_entries: path_entries(), path_entries: path_entries(),
@@ -26,6 +30,31 @@ pub fn detect_env() -> PlatformEnv {
} }
} }
#[cfg(windows)]
fn distro_info() -> (Option<String>, Option<String>) {
(None, None)
}
#[cfg(not(windows))]
fn distro_info() -> (Option<String>, Option<String>) {
parse_os_release(&std::fs::read_to_string("/etc/os-release").unwrap_or_default())
}
/// 解析 os-release 内容,返回 (发行版名, 版本号)。
/// 独立成纯函数以便跨平台单元测试。
pub fn parse_os_release(content: &str) -> (Option<String>, Option<String>) {
let mut id = None;
let mut ver = None;
for line in content.lines() {
if let Some(v) = line.strip_prefix("ID=") {
id = Some(v.trim().trim_matches('"').to_string());
} else if let Some(v) = line.strip_prefix("VERSION_ID=") {
ver = Some(v.trim().trim_matches('"').to_string());
}
}
(id, ver)
}
/// 从命令输出中提取首个版本号(如 "v24.18.0" -> "24.18.0")。 /// 从命令输出中提取首个版本号(如 "v24.18.0" -> "24.18.0")。
/// 规则:取第一段以数字开头、由数字与点组成的子串,尾部点剔除。 /// 规则:取第一段以数字开头、由数字与点组成的子串,尾部点剔除。
pub fn extract_version(output: &str) -> Option<String> { pub fn extract_version(output: &str) -> Option<String> {
@@ -95,7 +124,7 @@ fn resolve_program(program: &str) -> Option<PathBuf> {
} }
/// 探测一个可执行文件:返回状态 / 版本 / 路径。 /// 探测一个可执行文件:返回状态 / 版本 / 路径。
/// 结果分类:installed / not_in_path / exec_failed / version_unparseable。 /// 结果分类:installed / not_in_path / permission_denied / exec_failed / version_unparseable。
fn probe(program: &str, args: &[&str]) -> Option<RuntimeInfo> { fn probe(program: &str, args: &[&str]) -> Option<RuntimeInfo> {
let path = resolve_program(program); let path = resolve_program(program);
let path_str = path.as_ref().map(|p| p.to_string_lossy().to_string()); let path_str = path.as_ref().map(|p| p.to_string_lossy().to_string());
@@ -119,9 +148,15 @@ fn probe(program: &str, args: &[&str]) -> Option<RuntimeInfo> {
let output = match cmd.output() { let output = match cmd.output() {
Ok(o) => o, Ok(o) => o,
Err(_) => { Err(e) => {
// 架构 §5:把「权限不足」与一般执行失败拆分开(总工 Wave 0 🟡)
let status = if e.kind() == std::io::ErrorKind::PermissionDenied {
"permission_denied"
} else {
"exec_failed"
};
return Some(RuntimeInfo { return Some(RuntimeInfo {
status: "exec_failed".into(), status: status.into(),
version: None, version: None,
path: path_str, path: path_str,
}); });
@@ -296,6 +331,29 @@ mod tests {
assert_eq!(extract_version("v1.2.3."), Some("1.2.3".into())); assert_eq!(extract_version("v1.2.3."), Some("1.2.3".into()));
} }
#[test]
fn parses_os_release_distro_and_version() {
let content = "NAME=\"Ubuntu\"\nVERSION=\"24.04.1 LTS (Noble Numbat)\"\nID=ubuntu\nID_LIKE=debian\nVERSION_ID=\"24.04\"\n";
let (distro, ver) = parse_os_release(content);
assert_eq!(distro.as_deref(), Some("ubuntu"));
assert_eq!(ver.as_deref(), Some("24.04"));
}
#[test]
fn parses_os_release_missing_fields() {
let (distro, ver) = parse_os_release("NAME=\"Other\"\n");
assert_eq!(distro, None);
assert_eq!(ver, None);
}
#[cfg(windows)]
#[test]
fn windows_distro_is_none() {
let env = detect_env();
assert_eq!(env.distro, None);
assert_eq!(env.distro_version, None);
}
#[test] #[test]
fn env_has_path_on_windows() { fn env_has_path_on_windows() {
// PATH 变量在任何真实 Windows/Linux 上都存在 // PATH 变量在任何真实 Windows/Linux 上都存在
+7 -1
View File
@@ -11,6 +11,12 @@ pub struct PlatformEnv {
pub os_version: String, pub os_version: String,
/// 架构(std::env::consts::ARCH,如 x86_64 /// 架构(std::env::consts::ARCH,如 x86_64
pub arch: String, pub arch: String,
/// Linux 发行版名称(如 "ubuntu");Windows 为 None。
/// 架构 §5 原 `distro?: ubuntu + version` 合并字段,本波拆分为
/// `distro` + `distro_version` 两个字段(总工 Wave 0 🟡)。
pub distro: Option<String>,
/// Linux 发行版版本(如 "22.04");Windows 为 None。
pub distro_version: Option<String>,
pub shells: Shells, pub shells: Shells,
pub runtimes: Runtimes, pub runtimes: Runtimes,
/// PATH 条目(Windows 用 ';' 分割,Linux 用 ':' /// PATH 条目(Windows 用 ';' 分割,Linux 用 ':'
@@ -41,7 +47,7 @@ pub struct Runtimes {
/// 单个运行时探测结果 /// 单个运行时探测结果
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct RuntimeInfo { pub struct RuntimeInfo {
/// installed | not_installed | not_in_path | exec_failed | version_unparseable /// installed | not_in_path | permission_denied | exec_failed | version_unparseable
pub status: String, pub status: String,
pub version: Option<String>, pub version: Option<String>,
/// 解析到的可执行文件绝对路径(PATH 搜索) /// 解析到的可执行文件绝对路径(PATH 搜索)
+10
View File
@@ -3,5 +3,15 @@ name = "agentdock-secrets"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "系统密钥库封装(keyring)+ 日志脱敏(架构 §4.1 / §4.3"
[dependencies] [dependencies]
regex = "1"
# keyring 为架构 §1/§4.1 指定的密钥库后端:Windows Credential Manager /
# Linux Secret Service。按平台仅编译对应后端,避免把 dbus 栈拖进 Windows 构建。
[target.'cfg(windows)'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", features = ["sync-secret-service"] }
+25
View File
@@ -0,0 +1,25 @@
//! 密钥库错误类型。
//!
//! 铁律:错误信息(Display / Debug)绝不携带密钥明文,也不携带后端原始错误
//! 文本(原始错误可能夹带敏感信息),只保留中文说明。
use std::fmt;
#[derive(Debug)]
pub enum SecretStoreError {
/// 无此条目
NotFound,
/// 后端不可用(中文说明,不含明文)
Backend(String),
}
impl fmt::Display for SecretStoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SecretStoreError::NotFound => write!(f, "未找到对应密钥条目"),
SecretStoreError::Backend(m) => write!(f, "密钥库错误: {m}"),
}
}
}
impl std::error::Error for SecretStoreError {}
+93
View File
@@ -0,0 +1,93 @@
//! 生产密钥库后端:keyring → Windows Credential Manager(优先)/ Linux Secret Service
//! (架构 §4.1
//!
//! 错误映射时只保留中文说明,绝不把后端原始错误或密钥明文带入错误信息。
use crate::error::SecretStoreError;
use crate::store::SecretStore;
/// keyring 后端封装。
#[derive(Default)]
pub struct KeyringSecretStore;
impl KeyringSecretStore {
pub fn new() -> Self {
Self
}
}
#[cfg(windows)]
mod platform {
use keyring::Entry;
use crate::error::SecretStoreError;
pub fn set(service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
let entry = Entry::new(service, account).map_err(map_err)?;
entry.set_password(secret).map_err(map_err)
}
pub fn get(service: &str, account: &str) -> Result<String, SecretStoreError> {
let entry = Entry::new(service, account).map_err(map_err)?;
entry.get_password().map_err(map_err)
}
pub fn has(service: &str, account: &str) -> bool {
Entry::new(service, account)
.and_then(|e| e.get_password())
.is_ok()
}
pub fn delete(service: &str, account: &str) -> Result<(), SecretStoreError> {
let entry = Entry::new(service, account).map_err(map_err)?;
entry.delete_credential().map_err(map_err)
}
fn map_err(e: keyring::Error) -> SecretStoreError {
// NoEntry 表示无此条目;其余错误统一映射为后端失败,绝不回显原始错误文本。
if matches!(e, keyring::Error::NoEntry) {
SecretStoreError::NotFound
} else {
SecretStoreError::Backend("系统密钥库操作失败(Windows Credential Manager".into())
}
}
}
#[cfg(not(windows))]
mod platform {
use crate::error::SecretStoreError;
pub fn set(_service: &str, _account: &str, _secret: &str) -> Result<(), SecretStoreError> {
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service".into()))
}
pub fn get(_service: &str, _account: &str) -> Result<String, SecretStoreError> {
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service".into()))
}
pub fn has(_service: &str, _account: &str) -> bool {
false
}
pub fn delete(_service: &str, _account: &str) -> Result<(), SecretStoreError> {
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service".into()))
}
}
impl SecretStore for KeyringSecretStore {
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
platform::set(service, account, secret)
}
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError> {
platform::get(service, account)
}
fn has(&self, service: &str, account: &str) -> bool {
platform::has(service, account)
}
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError> {
platform::delete(service, account)
}
}
+14 -13
View File
@@ -1,17 +1,18 @@
//! agentdock-secrets —— 系统密钥库封装与日志脱敏(架构 §4.1 / §4.3) //! agentdock-secrets —— 系统密钥库封装与日志脱敏(架构 §4.1 / §4.3)
//! //!
//! 职责:keyring 封装(Windows Credential Manager / Linux Secret Service //! - `store` / `mock` / `keyring`:密钥库封装(set/get/has/delete
//! 敏感值脱敏。Wave 0:空骨架,随 Wave 1 落地。 //! 生产后端走 keyringWindows Credential Manager / Linux Secret Service),
//! 测试用 mock 内存后端。
//! - `redact`:日志脱敏器(sk-/xai-/ghp_/gho_/JWT 与敏感键值)。
/// 密钥层能力标记(占位) pub mod error;
pub const LAYER: &str = "agentdock-secrets"; pub mod keyring;
pub mod mock;
pub mod redact;
pub mod store;
#[cfg(test)] pub use error::SecretStoreError;
mod tests { pub use keyring::KeyringSecretStore;
use super::*; pub use mock::MockSecretStore;
pub use redact::{REDACTED, redact, redact_value};
#[test] pub use store::SecretStore;
fn layer_identity() {
assert_eq!(LAYER, "agentdock-secrets");
}
}
+96
View File
@@ -0,0 +1,96 @@
//! 内存 mock 密钥库后端(测试与无系统钥匙串环境用)
use std::collections::HashMap;
use std::sync::Mutex;
use crate::error::SecretStoreError;
use crate::store::SecretStore;
/// 线程安全的内存后端。仅用于测试与开发联调,不落盘。
#[derive(Default)]
pub struct MockSecretStore {
inner: Mutex<HashMap<(String, String), String>>,
}
impl MockSecretStore {
pub fn new() -> Self {
Self::default()
}
/// 返回当前条目数量(测试断言用)。
pub fn len(&self) -> usize {
self.inner.lock().map(|m| m.len()).unwrap_or(0)
}
}
impl SecretStore for MockSecretStore {
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
let mut map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
map.insert((service.to_string(), account.to_string()), secret.to_string());
Ok(())
}
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError> {
let map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
map.get(&(service.to_string(), account.to_string()))
.cloned()
.ok_or(SecretStoreError::NotFound)
}
fn has(&self, service: &str, account: &str) -> bool {
self.get(service, account).is_ok()
}
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError> {
let mut map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
map.remove(&(service.to_string(), account.to_string()))
.map(|_| ())
.ok_or(SecretStoreError::NotFound)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_set_get() {
let store = MockSecretStore::new();
store.set("agentdock.codex", "api_key", "sk-verysecret").unwrap();
assert_eq!(store.get("agentdock.codex", "api_key").unwrap(), "sk-verysecret");
assert!(store.has("agentdock.codex", "api_key"));
}
#[test]
fn get_missing_returns_not_found_without_secret() {
let store = MockSecretStore::new();
let err = store.get("agentdock.codex", "nope").unwrap_err();
assert!(matches!(err, SecretStoreError::NotFound));
// 错误信息不得含任何密钥明文
let secret = "sk-do-not-leak";
assert!(!format!("{err}").contains(secret));
assert!(!format!("{err:?}").contains(secret));
}
#[test]
fn delete_removes_entry() {
let store = MockSecretStore::new();
store.set("agentdock.claude", "api_key", "xai-123").unwrap();
assert!(store.has("agentdock.claude", "api_key"));
store.delete("agentdock.claude", "api_key").unwrap();
assert!(!store.has("agentdock.claude", "api_key"));
assert!(matches!(store.delete("agentdock.claude", "api_key"), Err(SecretStoreError::NotFound)));
}
#[test]
fn error_never_contains_plaintext() {
let store = MockSecretStore::new();
let secret = "sk-super-secret-value";
store.set("agentdock.x", "k", secret).unwrap();
// 触发一个错误路径:删除后读取
store.delete("agentdock.x", "k").unwrap();
let err = store.get("agentdock.x", "k").unwrap_err();
let rendered = format!("{err} / {err:?}");
assert!(!rendered.contains(secret), "错误渲染不应含密钥明文: {rendered}");
}
}
+121
View File
@@ -0,0 +1,121 @@
//! 日志脱敏器(架构 §4.3
//!
//! 模式表:
//! - `sk-` / `xai-` / `ghp_`(及 GitHub 其它前缀 `gho_/ghs_/ghu_/ghr_`/ JWT 形态 → `***REDACTED***`
//! - `key=value` / `key: value` 形式的敏感键(api_key/token/secret/password/authorization/bearer)→ 值脱敏
//! - 任何标记 `sensitive: true` 的字段值,调用方用 `redact_value` 强制脱敏
use std::sync::OnceLock;
use regex::Regex;
/// 统一的脱敏占位符。
pub const REDACTED: &str = "***REDACTED***";
fn jwt_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap())
}
fn sk_xai_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)\b(?:sk|xai)-[A-Za-z0-9_-]+").unwrap())
}
fn github_token_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\bgh[pousr]_[A-Za-z0-9]+").unwrap())
}
fn sensitive_kv_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"(?i)\b(api[_-]?key|apikey|token|secret|password|authorization|bearer)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)"#,
)
.unwrap()
})
}
/// 对任意文本做脱敏:替换前缀密钥、JWT、敏感键值。
pub fn redact(input: &str) -> String {
let mut out = input.to_string();
out = jwt_re().replace_all(&out, REDACTED).to_string();
out = sk_xai_re().replace_all(&out, REDACTED).to_string();
out = github_token_re().replace_all(&out, REDACTED).to_string();
out = sensitive_kv_re()
.replace_all(&out, "$1=***REDACTED***")
.to_string();
out
}
/// 对单个已知敏感值强制脱敏(适配器 `sensitive: true` 字段落日志前调用)。
pub fn redact_value(_value: &str) -> String {
REDACTED.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_sk_prefix() {
let out = redact("密钥是 sk-abc123def");
assert!(out.contains(REDACTED));
assert!(!out.contains("sk-abc123def"));
}
#[test]
fn redacts_xai_prefix() {
let out = redact("xai-verysecret");
assert!(out.contains(REDACTED));
assert!(!out.contains("xai-verysecret"));
}
#[test]
fn redacts_github_tokens() {
for token in ["ghp_abcdefghijklmnop", "gho_1234", "ghs_xyz", "ghu_9", "ghr_ab"] {
let out = redact(token);
assert!(out.contains(REDACTED), "{token}");
assert!(!out.contains(token), "{token}");
}
}
#[test]
fn redacts_jwt() {
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
let out = redact(&format!("Authorization: Bearer {jwt}"));
assert!(!out.contains(jwt));
assert!(out.contains(REDACTED));
}
#[test]
fn redacts_sensitive_key_value() {
let out = redact("API_KEY=sk-abc123");
assert!(out.contains("API_KEY=***REDACTED***"), "{out}");
assert!(!out.contains("sk-abc123"));
let out2 = redact("token: abcdef123456");
assert!(out2.contains("***REDACTED***"), "{out2}");
assert!(!out2.contains("abcdef123456"));
}
#[test]
fn redacts_quoted_value() {
let out = redact("password=\"hunter2secret\"");
assert!(out.contains("***REDACTED***"), "{out}");
assert!(!out.contains("hunter2secret"));
}
#[test]
fn leaves_innocent_text_untouched() {
let s = "node --version 返回 24.18.0";
assert_eq!(redact(s), s);
}
#[test]
fn redact_value_always_masks() {
assert_eq!(redact_value("anything"), REDACTED);
assert_eq!(redact_value("sk-abc"), REDACTED);
}
}
+21
View File
@@ -0,0 +1,21 @@
//! 密钥库抽象接口(架构 §4.1)
use crate::error::SecretStoreError;
/// 系统密钥库封装接口。`service` 固定前缀 `agentdock.<cli_id>`
/// `account` 为字段 id(如 `api_key`)。
pub trait SecretStore: Send + Sync {
/// 写入密钥。
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError>;
/// 读取密钥。
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError>;
/// 是否存在该密钥条目。
fn has(&self, service: &str, account: &str) -> bool;
/// 删除密钥条目。
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError>;
}
/// 构造 service 名:`agentdock.<cli_id>`。
pub fn service_name(cli_id: &str) -> String {
format!("agentdock.{cli_id}")
}
+3 -1
View File
@@ -313,7 +313,8 @@ AdapterExecutor:
os: windows | linux os: windows | linux
os_version: ... os_version: ...
arch: x64 | other(reject) arch: x64 | other(reject)
distro?: ubuntu + version distro?: string # 发行版名,如 ubuntuWindows 为 None
distro_version?: string # 发行版版本,如 22.04Wave 1 拆分:原 distro?: ubuntu + version
shells: powershell_version?, bash_available? shells: powershell_version?, bash_available?
runtimes: { node?, npm?, python?, uv?, git?, winget?, apt? } runtimes: { node?, npm?, python?, uv?, git?, winget?, apt? }
path_entries: [...] path_entries: [...]
@@ -331,6 +332,7 @@ capabilities: { keyring: ok|missing, can_elevate: bool }
| 密钥库 | Credential Manager 可用性 | `secret-tool` / DBus Secret Service | | 密钥库 | Credential Manager 可用性 | `secret-tool` / DBus Secret Service |
检测失败分类(对齐 FR-02):`not_installed` | `not_in_path` | `permission_denied` | `exec_failed` | `version_unparseable` 检测失败分类(对齐 FR-02):`not_installed` | `not_in_path` | `permission_denied` | `exec_failed` | `version_unparseable`
Wave 1 已把 `permission_denied` 从一般执行失败中拆分为独立状态(`std::io::ErrorKind::PermissionDenied` 单独归类),`RuntimeInfo.status` 相应支持该值。
--- ---