diff --git a/.flocks/plugins/skills/web2cli/SKILL.md b/.flocks/plugins/skills/web2cli/SKILL.md index 27fe5685f..0ba30d6ef 100644 --- a/.flocks/plugins/skills/web2cli/SKILL.md +++ b/.flocks/plugins/skills/web2cli/SKILL.md @@ -51,16 +51,15 @@ mkdir -p "$CAPTURE_ROOT/captures" - 浏览器内存中的原始捕获数据:`window.__capturedRequests` - 导出的接口抓包 JSON:`$CAPTURE_ROOT/captures/${CAPTURE_NAME}_api.json` - 浏览器认证状态:`$CAPTURE_ROOT/auth-state.json` -- 操作适配规格:`$CAPTURE_ROOT/web2cli-spec.json` - 站点自适应 Hook(仅当 base 失败时创建):`$CAPTURE_ROOT/hook.js` -- 生成的 CLI 工具:`$CAPTURE_ROOT/_cli.py`,`generate-cli.py` 会把 `-` 等非 Python 模块名字符替换为 `_` +- 生成的 CLI 工具:`$CAPTURE_ROOT/_cli.py`,文件名中的 `-` 等非 Python 模块名字符需替换为 `_` - 生成的验证材料:`$CAPTURE_ROOT/${CAPTURE_NAME}_verify.json` -- 生成的接口文档:`$CAPTURE_ROOT/${CAPTURE_NAME}_api.md` +- 生成的接口文档:`$CAPTURE_ROOT/cli-reference.md` - 生成的 Postman 集合:`$CAPTURE_ROOT/${CAPTURE_NAME}_postman.json` ## 标准流程 -> 按照以下 1-12 的操作流程完成任务 +> 按照以下 1-11 的操作流程完成任务 ### 1. 打开浏览器或创建 Tab @@ -133,8 +132,9 @@ print(js("window.__apiCapture.config.captureMode")) ### 4. 明确需要捕获的功能/操作 -- 要求用户手动操作要捕获的页面动作,例如查询、翻页、筛选、提交表单、点击按钮、导出数据。 -- 或者请求用户描述需要 hook 的操作或功能,便于你直接去页面代替用户执行 +- 方式 1:要求用户手动操作要捕获的页面动作,例如查询、翻页、筛选、提交表单、点击按钮、导出数据。 +- 方式 2:请求用户描述需要 hook 的操作或功能,你直接去页面代替用户执行 +- 方式 3:用户之前已经描述了需要的 CLI功能,你直接去页面代替用户执行 需要确认捕获是否开始时: @@ -255,66 +255,23 @@ jq -r '.[].method' "$CAPTURE_ROOT/captures/${CAPTURE_NAME}_api.json" | sort | un jq '.[] | select(.method == "POST") | {url: .url, body: .requestBody}' "$CAPTURE_ROOT/captures/${CAPTURE_NAME}_api.json" ``` -### 8. 生成 web2cli-spec 规格 +### 8. CLI 工具生成 -先基于 `"$CAPTURE_ROOT/captures/${CAPTURE_NAME}_api.json"` 生成中间契约层 `web2cli-spec.json`。 +根据 `references/cli-requirements.md` 的要求,基于抓包结果、认证状态和用户目标,生成 CLI、验证材料和接口文档。 -```bash -uv run python .flocks/plugins/skills/web2cli/scripts/generate-spec.py \ - "$CAPTURE_ROOT/captures/${CAPTURE_NAME}_api.json" \ - --base-url "https://example.com" \ - --output "$CAPTURE_ROOT/web2cli-spec.json" -``` - -`web2cli-spec.json` 是抓包结果到最终 CLI 之间的可编辑契约,包含: +生成前必须读取并遵循: -- 目标站点与命令名 -- 鉴权策略(如 `PUBLIC` / `COOKIE` / `HEADER`) -- 主请求的 method、endpoint、query/body/payload 模板 -- CLI 参数定义 -- 固定输出列定义 -- 验证材料初稿 +- `$WEB2CLI_SKILL/references/cli-requirements.md` -生成后必须检查并按需修正: +最终产物必须包含: -- `strategy` 是否正确 -- `args` 是否符合实际操作意图 -- `columns` 与字段路径是否对应目标数据 -- `verify` 的最少行数、必填列是否合理 +- `$CAPTURE_ROOT/_cli.py` +- `$CAPTURE_ROOT/${CAPTURE_NAME}_verify.json` +- `$CAPTURE_ROOT/cli-reference.md` -### 9. 基于 spec 生成 CLI 工具 +如果 `CAPTURE_NAME` 包含 `-` 等不能作为 Python 模块名的字符,生成 CLI 文件名时必须规范化为 `_`,例如 `test-domain_cli.py` 应写为 `test_domain_cli.py`。 -从 `"$CAPTURE_ROOT/web2cli-spec.json"` 生成最终 CLI。 - -```bash -uv run python .flocks/plugins/skills/web2cli/scripts/generate-cli.py \ - --spec "$CAPTURE_ROOT/web2cli-spec.json" \ - --format python \ - --output "$CAPTURE_ROOT/${CAPTURE_NAME}_cli.py" -``` - -如果 `CAPTURE_NAME` 包含 `-` 等不能作为 Python 模块名的字符,生成器会自动规范化输出文件名,例如 `test-domain_cli.py` 会写为 `test_domain_cli.py`,并在命令输出中打印实际路径。 - -生成验证文件: - -```bash -uv run python .flocks/plugins/skills/web2cli/scripts/generate-cli.py \ - --spec "$CAPTURE_ROOT/web2cli-spec.json" \ - --format verify \ - --output "$CAPTURE_ROOT/${CAPTURE_NAME}_verify.json" -``` - -生成接口文档: - -```bash -uv run python .flocks/plugins/skills/web2cli/scripts/generate-cli.py \ - --spec "$CAPTURE_ROOT/web2cli-spec.json" \ - --format markdown \ - --title "${CAPTURE_NAME} API Documentation" \ - --output "$CAPTURE_ROOT/${CAPTURE_NAME}_api.md" -``` - -### 10. CLI工具验证与修改 +### 9. CLI工具验证与修改 根据生成的 CLI ,任意选择一个接口调用测试可用性 - CLI 工具可用性 @@ -324,11 +281,27 @@ uv run python .flocks/plugins/skills/web2cli/scripts/generate-cli.py \ 推荐先查看 `"$CAPTURE_ROOT/${CAPTURE_NAME}_verify.json"`,再用生成的 CLI 以默认参数执行一次,确认固定输出列与认证状态都正确。 -### 11. CLI 工具集成到skill +### 10. 将 WebCLI 能力沉淀为最终产物 + +分为基础层和可选增强层: + +#### 基础要求:必须集成到 skill + +- 所有 `web2cli` 结果都必须先按 `references/cli-in-skill.md` 集成为可长期维护的 skill / CLI 资产 +- 这是必选步骤,因为认证失效、浏览器恢复、重新抓包、CLI 参数说明和日常排障都依赖 skill 文档入口 +- `references/browser-workflow.md` 必须记录浏览器连接检查、登录步骤、state 保存位置和认证恢复流程 + +#### CLI 放置原则 + +- 主 CLI 的落点是二选一:要么放在 skill 的 `scripts/`,要么放在 `tools/device//` 下 +- 通用网站、查询等场景:优先放在 skill 的 `scripts/` +- 安全设备接入:按 `references/cli-in-device.md` 将能力整理成 `tools/device//` 下的 `_provider.yaml`、工具 YAML 和 handler +- 不要同时维护两份独立演进的主 CLI,避免能力漂移和认证逻辑分叉 +- 无论主 CLI 放在哪,skill 集成都始终是必选 -将 CLI 按 `references/cli-in-skill.md` 集成为 skill; +不要只停留在一次性 CLI 或临时抓包结果;最终都要沉淀成可长期维护的资产。 -### 12. summary并关闭浏览器 tab +### 11. summary并关闭浏览器 tab 1. 总结当前生成的 CLI 工具有哪些接口/能力 2. 确保 CLI 可用后关闭浏览器或 Tab @@ -383,4 +356,5 @@ else: - 登录状态失效:重新登录后再次执行保存状态命令。 ## Reference +- references/cli-in-device.md 在 skill 集成完成后,将 WebCLI 能力进一步封装为 device 插件 - references/cli-in-skill.md 将生成的 CLI 集成到 skill 中使用 diff --git a/.flocks/plugins/skills/web2cli/references/cli-in-device.md b/.flocks/plugins/skills/web2cli/references/cli-in-device.md new file mode 100644 index 000000000..56a12af07 --- /dev/null +++ b/.flocks/plugins/skills/web2cli/references/cli-in-device.md @@ -0,0 +1,280 @@ +# 生成后的 WebCLI 如何接入 Device 插件 + +> 本文说明:`web2cli` 已经抓到页面请求、并整理出可复用调用逻辑后,怎样把它沉淀成可在设备页识别、配置和调用的 device 插件。 + +## 结论 + +`cli-in-device.md` 不是 `cli-in-skill.md` 的替代物,而是安全设备场景下的进一步封装: + +- 所有 `web2cli` 结果都必须先完成 skill 集成 +- 如果目标是安全设备接入,再继续按本文档额外生成 device 插件 +- 最终交付关系是:`skill` 必选,`device 插件` 为安全设备场景下的额外交付 + +## 何时使用 + +在以下场景调用本文档: + +- 当前任务明确来自“设备接入”页面,目标是把某个安全设备或安全产品接入到设备管理体系 +- 最终产物需要出现在设备页,并允许用户填写实例配置、刷新模板、按 `device_id` 调用 +- 当前 WebCLI 抓到的能力属于安全设备能力,而不是单纯给 skill 复用的站点操作脚本 + +不优先使用本文档的场景: + +- 只是想保留一个可复用 CLI 供 agent 在 skill 中调用 +- 目标不是设备接入,而是某个通用网站的操作自动化、查询脚本或内部工具 +- 暂时只需要沉淀浏览器经验、CLI 参数和认证恢复流程,不需要设备页识别 + +如果当前任务来自“设备接入”页面,并且目标是安全设备接入,WebCLI 在完成 skill 集成后,还应当额外生成标准 device 插件: + +```text +$HOME/.flocks/plugins/tools/device// +├── _provider.yaml +├── .yaml +├── .handler.py +├── _cli.py # 可选,仅用于调试/回归 +└── _test.yaml # 可选,最小验证样例 +``` + +其中: + +- `_provider.yaml`:决定设备页是否能识别该模板,以及用户创建实例时需要填写哪些字段 +- `.yaml`:定义可调用工具、参数和 action +- `.handler.py`:设备运行时入口,负责读取配置、认证、发请求、清洗结果 +- `_cli.py`:只作为调试入口保留,不作为设备运行时主路径 + +认证默认规则: + +- 自定义 CLI / WebCLI 默认认证方式为 `cookie/auth-state`:优先复用浏览器保存的 `auth-state.json`,从中按请求域名/path/secure 规则选择 Cookie,并在需要时读取 localStorage +- 默认认证状态文件:`~/.flocks/browser//auth-state.json` +- 优先使用 `auth_state_path` 指向 `~/.flocks/browser//auth-state.json` +- 可以额外暴露可选 `username` / `password`,但它们只用于 cookie 失效后的认证恢复,不替代默认的 `auth_state_path` +- 不要生成或使用 `auth_state_json` / `Legacy Auth State JSON` 这类内联 JSON 字段;设备配置只保存 state 文件路径,不粘贴 state 文件内容 +- 只有在目标站点确实还依赖额外字段时,才补充 `cookie`、`csrf_token`、`access_token` 或特定认证头;这些字段是 `auth_state_path` 之外的补充,不替代默认的 cookie/auth-state +- 不要把 `cookie` 或 `token` 设计成和 `auth-state` 并列的多个默认入口;如果用户提供的是 state 文件路径,必须写入 `auth_state_path` + +## 命名约定 + +- 插件目录:`$HOME/.flocks/plugins/tools/device//` +- `plugin_id`:推荐使用稳定产品名加版本,例如 `_v1_0_0` +- `service_id`:推荐使用稳定能力标识,例如 `_device` +- handler 文件:`.handler.py` +- 可选 CLI 文件:`_cli.py` + +约定说明: + +- `` 用产品或系统的稳定标识,不用一次性任务名 +- 目录名可以带版本;`service_id` 要尽量稳定,避免和临时抓包任务绑定 +- Python 文件名统一用 `_` + +## 最小 `_provider.yaml` + +至少包含以下字段: + +```yaml +name: Acme Portal +vendor: acme_security +service_id: acme_portal_device +version: "1.0.0" +integration_type: device +description: > + Acme Portal WebCLI-backed device integration for alert listing and asset + detail queries. Configure Base URL and the required login state fields + separately in the credentials form. +description_cn: > + Acme Portal 的 WebCLI 设备接入模板,支持告警列表和资产详情查询。 + 请在设备配置中分别填写 Base URL 与所需登录态字段。 +credential_fields: + - key: base_url + label: Base URL + storage: config + config_key: base_url + input_type: url + required: true + - key: auth_state_path + label: Auth State Path + storage: config + config_key: auth_state_path + input_type: text + default: "~/.flocks/browser/acme-portal/auth-state.json" + - key: username + label: Username + storage: config + config_key: username + input_type: text + required: false + description: 仅在 cookie 失效后需要 Agent 辅助登录刷新 state 时填写 + - key: password + label: Password + storage: secret + config_key: password + secret_id: acme_portal_password + input_type: password + required: false + description: 仅在 cookie 失效后需要 Agent 辅助登录刷新 state 时填写 + - key: cookie + label: Cookie + storage: secret + config_key: cookie + secret_id: acme_portal_cookie + input_type: password + - key: csrf_token + label: CSRF Token + storage: secret + config_key: csrf_token + secret_id: acme_portal_csrf_token + input_type: password +defaults: + timeout: 30 + category: custom +notes: | + WebCLI 设备建议优先复用稳定隐藏接口,不建议把浏览器自动化作为默认运行时。 + 若返回 401/403、跳转登录页或 CSRF 失效,应先按认证失效处理。 +``` + +注意: + +- 必须包含 `integration_type: device` +- `description` 用英文,`description_cn` 用中文 +- 只把运行时真正需要用户填写的字段放进 `credential_fields` +- 不要把真实 cookie、token、密码、auth state JSON 写进插件文件 +- 默认先放 `auth_state_path`,并指向 `~/.flocks/browser//auth-state.json`;不要添加 `auth_state_json` / `Legacy Auth State JSON` +- 可以补充可选 `username` / `password`,但必须标注它们仅用于认证恢复或浏览器辅助登录,不得作为默认运行时认证入口 +- `cookie`、`csrf_token`、`access_token` 或特定认证头只有在实际站点需要时再补,并在 handler 中明确说明来源与刷新方式 + +## 最小工具 YAML + +MVP 阶段推荐一个分组工具 + 多个 action: + +```yaml +name: acme_portal_ops +description: > + Acme Portal grouped device tool. Use the action parameter to query alerts, + assets, and other WebCLI-backed operations. +description_cn: > + Acme Portal 分组设备工具。通过 action 参数调用告警、资产和其他 WebCLI 能力。 +category: custom +enabled: true +requires_confirmation: false +provider: acme_portal_device +inputSchema: + type: object + properties: + action: + type: string + enum: [list_alerts, get_asset_detail] + description: 统一业务动作名,不要暴露内部实现来源。 + alert_id: + type: string + description: 查询资产详情时可选使用的关联标识。 + required: [action] +handler: + type: script + script_file: acme_portal.handler.py + function: handle +``` + +规则: + +- `provider` 必须与 `_provider.yaml.service_id` 一致 +- 高风险写操作必须设置 `requires_confirmation: true` +- 对外 action 用统一业务语义,不要命名成 `webcli_get_alerts`、`api_get_alerts` + +## 最小 handler 结构 + +MVP 阶段优先单文件 handler,不强制拆 client 模块: + +```python +from __future__ import annotations + +from typing import Any + +from flocks.config.config_writer import ConfigWriter +from flocks.tool.registry import ToolContext, ToolResult + +SERVICE_ID = "acme_portal_device" + + +def _service_config() -> dict[str, Any]: + raw = ConfigWriter.get_api_service_raw(SERVICE_ID) + return raw if isinstance(raw, dict) else {} + + +async def handle(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + cfg = _service_config() + if action == "list_alerts": + return ToolResult(success=True, output={"items": [], "source": "webcli_api"}) + if action == "get_asset_detail": + return ToolResult(success=True, output={"item": None, "source": "webcli_api"}) + return ToolResult(success=False, error=f"Unsupported action: {action}") +``` + +要求: + +- 通过 `ConfigWriter.get_api_service_raw(SERVICE_ID)` 读取当前设备实例配置 +- handler 内部负责认证头构造、分页、超时、重试和响应归一化 +- handler 默认只读取 `auth_state_path` 指向的 `auth-state.json`;如果文件缺失、不是合法 JSON,或没有匹配当前 Base URL 的 Cookie,应返回明确错误并提示重新登录/保存 state +- handler 不要 fallback 到内联 `auth_state_json`;这会把路径字符串、占位文本或过期内容误当 JSON 解析,导致设备测试报错不清晰 +- 如果模板提供了 `username` / `password`,handler 也不要在普通 tool 调用里静默自动登录;这些字段只用于后续由 Rex 进入浏览器认证恢复流程时辅助填表 +- CLI 可选保留,但不要让设备运行时通过 subprocess 调 CLI + +## 组合 API / WebCLI / 处理逻辑 + +同一设备可以混合多种能力来源,但对外仍然是统一 action: + +- `api`:正式 API,可直接调用 +- `webcli_api`:WebCLI 抓到的隐藏接口 +- `process`:本地字段归一化、过滤、聚合、补全 +- `composed`:先调一种来源,再补另一种来源,最后统一输出 + +推荐选择顺序: + +1. 正式 API 稳定可用时,优先正式 API +2. 正式 API 缺能力但 WebCLI 接口稳定时,用 `webcli_api` +3. 需要字段清洗、补全、排序、聚合时,在 handler 内增加 `process` +4. 需要多个来源补齐同一业务结果时,用 `composed` +5. 必须验证码、强动态页面或人工交互时,只记录为 browser fallback,不放进默认设备运行时 +6. 如果某个隐藏接口依赖 `Authorization`、`Tdp-Authentication`、CSRF 等临时头,只有在 handler 已实现可靠的恢复/刷新逻辑时才暴露为默认 action;否则保留在 CLI 或文档中,不放进设备默认动作 + +示例 action 映射: + +```yaml +list_alerts: webcli_api +get_asset_detail: composed +list_users: api +normalize_alert: process +``` + +这里的映射可以写进 handler 常量、注释、`notes` 或单独的设计文档,但不要把“来源类型”直接暴露给最终用户。 + +## 认证失败处理 + +出现以下情况时,优先按认证失效处理: + +- 返回 `401` 或 `403` +- 返回内容出现 `Unauthorized`、`login`、未登录、无权限 +- Cookie / CSRF / access token 明显过期 +- `auth_state_path` 已存在,但接口仍跳转登录页 + +处理原则: + +1. 不要无限重试 +2. 优先返回明确话术,提示 Rex 使用 `flocks browser` 和对应 skill 的认证失败处理去恢复登录态 +3. 如果设备已配置可选 `username` / `password`,Rex 可以在浏览器恢复流程中读取它们辅助登录;如遇验证码、MFA、短信码或人工确认,立即停下并让用户接管 +4. 登录成功后执行 `flocks browser state save ` 更新 cookie/state 文件 +5. 如仍失败,再提示用户重新登录或更新设备配置中的认证字段 +6. 如果保留了 CLI,可用 CLI 做一次最小验证 +7. 验证通过后,再让用户回到设备页点击“刷新设备模板” + +## `_test.yaml` 建议 + +如果该 WebCLI 设备已经有最小可验证动作,建议补一个 `_test.yaml`,至少覆盖: + +- 一个低风险读操作 +- 最小必填参数 +- 成功时的关键字段断言 + +这样后续更新 handler 或认证逻辑时更容易回归验证。 + +## 一句话原则 + +`web2cli` 生成的 CLI 是中间产物;只有在“安全设备接入”场景下,才把它整理成标准 device 插件,让设备页能识别、配置并调用。 diff --git a/.flocks/plugins/skills/web2cli/references/cli-requirements.md b/.flocks/plugins/skills/web2cli/references/cli-requirements.md new file mode 100644 index 000000000..16ef30d38 --- /dev/null +++ b/.flocks/plugins/skills/web2cli/references/cli-requirements.md @@ -0,0 +1,101 @@ +# Web2CLI CLI 生成要求 + +> 本文是生成 WebCLI 工具时必须遵循的参考要求。应根据抓包结果、认证状态和用户目标直接生成 CLI、验证材料和接口文档。 + +## 输入材料 + +- 抓包 JSON:`$CAPTURE_ROOT/captures/${CAPTURE_NAME}_api.json` +- 浏览器认证状态:`$CAPTURE_ROOT/auth-state.json` +- 页面入口 URL:来自用户目标或当前浏览器页面 +- 用户要复现的操作:来自用户目标和最近页面操作 +- 目标 base URL:从抓包 URL 中归纳,必要时结合用户指定值 + +## 生成目标 + +生成以下文件: + +- CLI 主脚本:`$CAPTURE_ROOT/_cli.py` +- 验证材料:`$CAPTURE_ROOT/${CAPTURE_NAME}_verify.json` +- 接口文档:`$CAPTURE_ROOT/cli-reference.md` + +命名要求: + +- `` 必须是合法 Python 文件名片段 +- 将 `-`、空格等不适合作为 Python 模块名的字符替换为 `_` +- 不要把一次性路径、cookie、token 或用户私密信息硬编码到脚本中 + +## CLI 行为要求 + +CLI 必须说明并实现: + +- 命令名:`` +- 目标能力:`` +- 默认认证策略:`auth-state` / `cookie` / `header` / `public` +- 默认认证输入:优先使用 `--auth-state "$CAPTURE_ROOT/auth-state.json"` 或对应环境变量 +- 必填参数:`` +- 可选参数与默认值:`` +- 输出格式:默认 `table` 或 `json`,必要时同时支持 `--json` +- 退出码:成功为 `0`,认证失败、参数错误、请求失败和验证失败使用非零退出码 + +## 请求链路要求 + +从抓包 JSON 中选出与目标操作直接相关的请求,并写清楚: + +- 请求顺序和依赖关系 +- method、endpoint、query、body/payload 模板 +- 必要 headers,如 `content-type`、`csrf`、`x-requested-with` +- 认证信息从哪里读取,如何注入到请求中 +- 分页、排序、过滤、时间范围等参数如何映射到 CLI 参数 +- 响应字段路径,以及嵌套列表、空值、错误结构如何处理 + +不要把无关埋点、静态资源、日志上报、健康检查或页面渲染请求纳入主链路。 + +## 输出要求 + +固定输出列必须写清楚: + +| column | source_path | required | description | +| --- | --- | --- | --- | +| `` | `` | `` | `` | + +要求: + +- 表格输出列顺序稳定 +- JSON 输出保留原始字段或清洗后的结构 +- 必填列为空时应在验证材料中标记为失败 +- 时间、数量、状态等字段需要说明格式化规则 + +## 验证要求 + +`verify.json` 至少包含: + +- CLI 调用样例 +- 认证输入说明 +- 最少返回行数 +- 必填列列表 +- 预期 HTTP 状态码或业务成功字段 +- 常见失败场景与判定方式 + +示例结构: + +```json +{ + "command": "uv run python _cli.py --auth-state auth-state.json", + "min_rows": 1, + "required_columns": [""], + "success_status": [200], + "failure_hints": ["authentication expired", "missing required argument"] +} +``` + +## 接口文档要求 + +`cli-reference.md` 至少包含: + +- 能力说明和适用场景 +- 认证方式与刷新登录态的方法 +- CLI 参数表 +- 请求链路摘要 +- 输出字段说明 +- 验证方式和常见问题 + diff --git a/flocks/server/routes/device.py b/flocks/server/routes/device.py index f5785bffa..2b61c0864 100644 --- a/flocks/server/routes/device.py +++ b/flocks/server/routes/device.py @@ -21,6 +21,7 @@ DeviceGroup, DeviceGroupCreate, DeviceGroupUpdate, + DeviceCredentialResponse, DeviceIntegration, DeviceIntegrationCreate, DeviceIntegrationUpdate, @@ -128,6 +129,15 @@ async def route_get_device(device_id: str): return row_to_device(row) +@router.get("/{device_id}/credentials", response_model=DeviceCredentialResponse) +async def route_get_device_credentials(device_id: str): + row = await fetch_device(device_id) + if row is None: + raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail="Device not found") + db_fields: dict = json.loads(row["fields"] or "{}") + return DeviceCredentialResponse(fields=resolve_for_runtime(db_fields)) + + @router.post("", response_model=DeviceIntegration, status_code=http_status.HTTP_201_CREATED) async def route_create_device(body: DeviceIntegrationCreate): name = body.name.strip() diff --git a/flocks/tool/device/__init__.py b/flocks/tool/device/__init__.py index e243dd44e..f4219eb1c 100644 --- a/flocks/tool/device/__init__.py +++ b/flocks/tool/device/__init__.py @@ -11,6 +11,7 @@ DeviceGroup, DeviceGroupCreate, DeviceGroupUpdate, + DeviceCredentialResponse, DeviceIntegration, DeviceIntegrationCreate, DeviceIntegrationUpdate, @@ -28,6 +29,7 @@ "DeviceGroup", "DeviceGroupCreate", "DeviceGroupUpdate", + "DeviceCredentialResponse", "DeviceIntegration", "DeviceIntegrationCreate", "DeviceIntegrationUpdate", diff --git a/flocks/tool/device/models.py b/flocks/tool/device/models.py index 71def077b..0bf37735f 100644 --- a/flocks/tool/device/models.py +++ b/flocks/tool/device/models.py @@ -131,6 +131,10 @@ class DeviceIntegrationUpdate(BaseModel): fields: Optional[Dict[str, str]] = None +class DeviceCredentialResponse(BaseModel): + fields: Dict[str, str] = Field(default_factory=dict) + + class DeviceTestResult(BaseModel): success: bool message: str diff --git a/flocks/tool/device/secrets.py b/flocks/tool/device/secrets.py index d48a165fe..4ea51fe9f 100644 --- a/flocks/tool/device/secrets.py +++ b/flocks/tool/device/secrets.py @@ -135,8 +135,8 @@ def mask_for_display(db_fields: Dict[str, str]) -> Tuple[Dict[str, str], Dict[st def resolve_for_runtime(db_fields: Dict[str, str]) -> Dict[str, str]: """Resolve ``{secret:…}`` placeholders to plaintext. - Call ONLY at the moment of making an outbound API request. - Never store or return the result through a public interface. + Call ONLY at the moment of making an outbound API request or serving an + explicit authenticated reveal action. Never store or log the result. """ from flocks.security import get_secret_manager diff --git a/tests/server/routes/test_device_routes.py b/tests/server/routes/test_device_routes.py index 8a4cd5148..cf94284b0 100644 --- a/tests/server/routes/test_device_routes.py +++ b/tests/server/routes/test_device_routes.py @@ -196,3 +196,54 @@ async def fake_fetch_device(device_id: str): resp = await client.post("/api/devices/missing-id/test", json={}) assert resp.status_code == 404 + + +class TestDeviceCredentialEndpoint: + @pytest.mark.asyncio + async def test_returns_resolved_fields_for_explicit_reveal( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + captured: dict = {} + + async def fake_fetch_device(device_id: str): + captured["device_id"] = device_id + return _fake_row( + fields={ + "api_key": "{secret:device_dev-test_api_key}", + "base_url": "https://console.onesec.net", + } + ) + + monkeypatch.setattr(device_routes, "fetch_device", fake_fetch_device) + monkeypatch.setattr( + device_routes, + "resolve_for_runtime", + lambda db_fields: { + **db_fields, + "api_key": "long-real-onesec-api-key-Cd4Y", + }, + ) + + resp = await client.get("/api/devices/dev-test/credentials") + + assert resp.status_code == 200, resp.text + assert captured["device_id"] == "dev-test" + assert resp.json() == { + "fields": { + "api_key": "long-real-onesec-api-key-Cd4Y", + "base_url": "https://console.onesec.net", + } + } + + @pytest.mark.asyncio + async def test_returns_404_for_unknown_device( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + async def fake_fetch_device(device_id: str): + return None + + monkeypatch.setattr(device_routes, "fetch_device", fake_fetch_device) + + resp = await client.get("/api/devices/missing-id/credentials") + + assert resp.status_code == 404 diff --git a/webui/src/api/device.ts b/webui/src/api/device.ts index 357114a98..8cc810e18 100644 --- a/webui/src/api/device.ts +++ b/webui/src/api/device.ts @@ -53,6 +53,10 @@ export interface DeviceIntegration { updated_at: number; } +export interface DeviceCredentialResponse { + fields: Record; +} + export interface DeviceIntegrationCreate { name: string; storage_key: string; @@ -110,6 +114,9 @@ export const deviceAPI = { get: (id: string) => client.get(`/api/devices/${id}`), + getCredentials: (id: string) => + client.get(`/api/devices/${id}/credentials`), + create: (data: DeviceIntegrationCreate) => client.post('/api/devices', data), diff --git a/webui/src/pages/DeviceIntegration/CustomDeviceAccessPanel.tsx b/webui/src/pages/DeviceIntegration/CustomDeviceAccessPanel.tsx new file mode 100644 index 000000000..1b053844a --- /dev/null +++ b/webui/src/pages/DeviceIntegration/CustomDeviceAccessPanel.tsx @@ -0,0 +1,422 @@ +import { useEffect, useMemo, useState, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'; +import { ChevronLeft, Loader2, MessageSquare, Route, Workflow, X } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { useToast } from '@/components/common/Toast'; +import SessionChat from '@/components/common/SessionChat'; +import { useSessionChat } from '@/hooks/useSessionChat'; +import type { + CustomDeviceAccessMode, + CustomDeviceApiDraft, + CustomDeviceWebCliDraft, +} from '@/types'; +import { + buildCustomDevicePrompt, + buildCustomDeviceSessionContext, + buildCustomDeviceWelcomeMessage, +} from './customDevice'; + +type PanelView = 'details' | 'rex' | 'guide'; + +const EMPTY_API_DRAFT: CustomDeviceApiDraft = { + accessMode: 'api', + deviceName: '', + vendorName: '', + version: '', + baseUrl: '', + docsUrl: '', + capabilities: '', +}; + +const EMPTY_WEBCLI_DRAFT: CustomDeviceWebCliDraft = { + accessMode: 'webcli', + deviceName: '', + vendorName: '', + version: '', + productUrl: '', + targetInterfaces: '', + authHint: '', +}; + +function FieldLabel({ label, required = false }: { label: string; required?: boolean }) { + return ( + + ); +} + +function TextInput(props: InputHTMLAttributes) { + return ( + + ); +} + +function TextArea(props: TextareaHTMLAttributes) { + return ( +