Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7208586850 |
@@ -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" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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/linux:architectures、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
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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.6,v1.2 hover 双层扩散辉光)---- */
|
/* ---- 主按钮(§2.6,v1.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%);
|
||||||
|
|||||||
@@ -31,7 +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 一周,仅高档/中档) */
|
/* 背景网格漂移(§4.8,60s 一周,仅高档/中档) */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:?} 不应被判危险");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 上都存在
|
||||||
|
|||||||
@@ -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 搜索)
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 落地。
|
//! 生产后端走 keyring(Windows 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}")
|
||||||
|
}
|
||||||
@@ -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 # 发行版名,如 ubuntu(Windows 为 None)
|
||||||
|
distro_version?: string # 发行版版本,如 22.04(Wave 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` 相应支持该值。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user