diff --git a/skills/uds-skill-auth/SKILL.md b/skills/uds-skill-auth/SKILL.md new file mode 100644 index 00000000..47c5624c --- /dev/null +++ b/skills/uds-skill-auth/SKILL.md @@ -0,0 +1,60 @@ +--- +name: uds-skill-auth +description: UDS 技能认证公共库。其它依赖 UDS 登录的 skill 必须安装本 skill,通过其 scripts/uds_skill_auth.py 取凭证或出站调用。需现场已安装 uds-auth 插件且用户已扫码登录。 +--- + +# uds-skill-auth(认证公共 Skill) + +本 skill **不是业务能力**,而是给其它 skill 用的公共库。 + +## 现场依赖 + +```text +1. 安装并启用 uds-auth 插件 +2. 安装本 skill(uds-skill-auth)到 skills 目录 +3. 用户扫码登录 +4. 再安装/使用业务 skill(如通讯录、发信等) +``` + +## 给其它 Skill 用 + +业务 skill 的 Python 脚本里,把本 skill 的 `scripts/` 加入 `sys.path` 后: + +```python +from uds_skill_auth import resolve, request, UdsAuthError + +# Mode Creds:拿到 empNo + token(可写进程内 EMP_NO/AUTH_VALUE) +creds = resolve() + +# Mode Outbound(推荐):Host 代贴鉴权头 +out = request("POST", "https://icenterapi.zte.com.cn/...", headers={...}, body={...}) +``` + +定位本 skill 目录的推荐写法(与业务 skill 同级安装时): + +```python +from pathlib import Path +import sys + +def load_uds_skill_auth(): + here = Path(__file__).resolve().parent + # .../skills/<业务skill>/scripts → .../skills/uds-skill-auth/scripts + for skills_root in [here.parents[1], *here.parents]: + candidate = skills_root / "uds-skill-auth" / "scripts" + if (candidate / "uds_skill_auth.py").is_file(): + sys.path.insert(0, str(candidate)) + import uds_skill_auth + return uds_skill_auth + raise RuntimeError("未找到 uds-skill-auth,请先安装该 skill") +``` + +也可设置环境变量 `UDS_AUTH_HELPERS` 指向本 skill 的 `scripts` 绝对路径。 + +## 环境变量(Agent 已注入,勿教模型打印密钥) + +- `DSH_SESSION_ID` — 当前会话 +- `DSH_WEB_URL` — Harness Web 地址(用于拼 `/uds-auth`) + +## 标准文档 + +完整契约见插件仓库:`uds-auth/docs/skill-auth-standard.zh.md`。 diff --git a/skills/uds-skill-auth/scripts/uds_skill_auth.py b/skills/uds-skill-auth/scripts/uds_skill_auth.py new file mode 100644 index 00000000..c096992b --- /dev/null +++ b/skills/uds-skill-auth/scripts/uds_skill_auth.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +""" +uds-skill-auth — Python helpers for UDS-backed skills. + +Resolve SSO credentials via loopback Host APIs, or call intranet APIs through +the outbound proxy. + +Environment: + DSH_SESSION_ID — agent session id (injected by DSH shell-env) + DSH_WEB_URL — optional base URL of the Harness web server + UDS_AUTH_BASE — optional override, e.g. http://127.0.0.1:PORT/uds-auth +""" +from __future__ import annotations + +import json +import os +import ssl +import urllib.error +import urllib.request +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlparse + + +class UdsAuthError(RuntimeError): + def __init__(self, message: str, *, code: str = "uds_auth_error", status: int = 0): + super().__init__(message) + self.code = code + self.status = status + + +def _base_url() -> str: + explicit = (os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/") + if explicit: + return explicit + web = (os.environ.get("DSH_WEB_URL") or "").strip().rstrip("/") + if web: + return web + "/uds-auth" + return "http://127.0.0.1:8787/uds-auth" + + +def _session_id() -> str: + return (os.environ.get("DSH_SESSION_ID") or "").strip() + + +def _opener_no_proxy(): + return urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def _http_json( + method: str, + url: str, + *, + headers: Optional[Dict[str, str]] = None, + body: Any = None, + timeout: float = 30.0, +) -> Tuple[int, Any, str]: + data = None + hdrs = dict(headers or {}) + if body is not None: + if isinstance(body, (dict, list)): + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + hdrs.setdefault("Content-Type", "application/json;charset=UTF-8") + elif isinstance(body, str): + data = body.encode("utf-8") + elif isinstance(body, bytes): + data = body + else: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + hdrs.setdefault("Content-Type", "application/json;charset=UTF-8") + req = urllib.request.Request(url, data=data, headers=hdrs, method=method.upper()) + ctx = ssl._create_unverified_context() + try: + if "127.0.0.1" in url or "localhost" in url.lower(): + opener = _opener_no_proxy() + with opener.open(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8", errors="replace") + status = getattr(resp, "status", 200) or 200 + else: + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + raw = resp.read().decode("utf-8", errors="replace") + status = getattr(resp, "status", 200) or 200 + except urllib.error.HTTPError as e: + raw = (e.fp.read().decode("utf-8", errors="replace") if e.fp else "") + status = e.code + except urllib.error.URLError as e: + raise UdsAuthError(f"无法连接 uds-auth: {e.reason}", code="uds_unreachable") from e + + parsed: Any = None + if raw.strip(): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = None + return status, parsed, raw + + +def resolve(*, apply_env_aliases: bool = True) -> Dict[str, str]: + """ + Fetch {empNo, token} for the current DSH session from Host. + Optionally set process-local EMP_NO / AUTH_VALUE (and coclaw_* aliases). + """ + sid = _session_id() + if not sid: + raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session") + + url = _base_url() + "/agent-credentials" + status, parsed, raw = _http_json( + "POST", + url, + headers={"X-DSH-Session-Id": sid, "Accept": "application/json"}, + body={"sessionId": sid}, + timeout=15.0, + ) + if status == 401 or (isinstance(parsed, dict) and parsed.get("error") == "no_skill_credentials"): + msg = (parsed or {}).get("message") if isinstance(parsed, dict) else None + raise UdsAuthError(msg or "请先完成 UDS 扫码登录", code="no_credentials", status=status) + if status != 200 or not isinstance(parsed, dict): + raise UdsAuthError( + f"获取凭证失败 (HTTP {status})", + code="credentials_http", + status=status, + ) + emp_no = str(parsed.get("empNo") or "").strip() + token = str(parsed.get("token") or "").strip() + if not emp_no or not token: + raise UdsAuthError("凭证响应不完整", code="bad_credentials") + + if apply_env_aliases: + os.environ["EMP_NO"] = emp_no + os.environ["AUTH_VALUE"] = token + os.environ["coclaw_empno"] = emp_no + os.environ["coclaw_token"] = token + + return {"empNo": emp_no, "token": token, "updatedAt": str(parsed.get("updatedAt") or "")} + + +def request( + method: str, + url: str, + *, + headers: Optional[Dict[str, str]] = None, + body: Any = None, + timeout: float = 30.0, +) -> Dict[str, Any]: + """ + Call an intranet URL via Host outbound proxy (injects X-Emp-No / X-Auth-Value). + Returns {statusCode, headers, body, json}. + """ + sid = _session_id() + if not sid: + raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session") + + host = urlparse(url).hostname or "" + payload = { + "sessionId": sid, + "method": method.upper(), + "url": url, + "headers": headers or {}, + "body": body, + "timeoutMs": int(timeout * 1000), + } + status, parsed, raw = _http_json( + "POST", + _base_url() + "/outbound", + headers={"X-DSH-Session-Id": sid, "Accept": "application/json"}, + body=payload, + timeout=timeout + 5.0, + ) + if status == 401 or (isinstance(parsed, dict) and parsed.get("error") == "no_skill_credentials"): + msg = (parsed or {}).get("message") if isinstance(parsed, dict) else None + raise UdsAuthError(msg or "请先完成 UDS 扫码登录", code="no_credentials", status=status) + if status == 403 and isinstance(parsed, dict) and parsed.get("error") == "host_not_allowed": + raise UdsAuthError(f"主机不在 outbound 白名单: {host}", code="host_not_allowed", status=403) + if not isinstance(parsed, dict) or "statusCode" not in parsed: + raise UdsAuthError( + f"outbound 失败 (HTTP {status}): {raw[:200]}", + code="outbound_http", + status=status, + ) + return parsed diff --git a/uds-auth/README.zh.md b/uds-auth/README.zh.md index bd2fb90f..958114a8 100644 --- a/uds-auth/README.zh.md +++ b/uds-auth/README.zh.md @@ -45,7 +45,9 @@ originSystemCode: '' ## Skill 认证 -见 [docs/skill-auth-standard.zh.md](docs/skill-auth-standard.zh.md)。官方样板 skill:`../skills/uds-icenter`。 +见 [docs/skill-auth-standard.zh.md](docs/skill-auth-standard.zh.md)。 + +**必须安装 skill**:[`skills/uds-skill-auth`](../skills/uds-skill-auth)(认证公共库)。业务 skill 依赖它,而不是插件源码里的 helpers。 可配置:`retainSkillCredentialsOnLogout`(默认 true)、`skillCredentialTtlSeconds`、`outboundAllowedHosts`。 diff --git a/uds-auth/docs/skill-auth-standard.zh.md b/uds-auth/docs/skill-auth-standard.zh.md index b5f020a2..374ecbaa 100644 --- a/uds-auth/docs/skill-auth-standard.zh.md +++ b/uds-auth/docs/skill-auth-standard.zh.md @@ -1,15 +1,21 @@ # Skill 认证标准(uds-auth) -面向 DeepSeekHarness:现场安装 **uds-auth** 后,第三方 / 自研 skill 按本标准取凭证或出站调用。官方样板:[skills/uds-icenter](../../skills/uds-icenter)。 +面向 DeepSeekHarness:现场安装 **uds-auth 插件** + **uds-skill-auth skill** 后,第三方 / 自研 skill 按本标准取凭证或出站调用。 ## 现场形态 ```text -安装 uds-auth 插件 → 用户扫码登录 → 加载 skill → 可用 +安装 uds-auth 插件 + → 安装 skill「uds-skill-auth」(认证公共库,必须) + → 用户扫码登录 + → 安装/加载业务 skill + → 可用 ``` 无需再配全局 `coclaw_token` / `AUTH_VALUE`。 +**认证公共库以 skill 分发**:仓库路径 `skills/uds-skill-auth/`。业务 skill 不要假设能访问插件源码目录。 + ## 双轨模型 | 轨道 | 用途 | 行为 | @@ -49,7 +55,7 @@ X-DSH-Session-Id: - 剥离客户端自带鉴权头 - host 必须在白名单内 -Python: +Python(先把 `uds-skill-auth/scripts` 加入 `sys.path`): ```python from uds_skill_auth import request @@ -71,36 +77,44 @@ from uds_skill_auth import resolve creds = resolve() # empNo + token;默认写入 EMP_NO/AUTH_VALUE ``` -Helpers 路径:`uds-auth/skill-helpers/python/`(或环境变量 `UDS_AUTH_HELPERS`)。 +Helpers 来源:已安装的 skill `uds-skill-auth` 的 `scripts/`(或环境变量 `UDS_AUTH_HELPERS`)。 -## 自研 Skill 清单 +## 改造现有 Skill 清单 -1. 建目录:`SKILL.md` + `scripts/` -2. 引用 helpers(见样板 `lib_uds.py`) -3. 默认走 `request()` outbound;发信等桌面端口可用 `resolve()` 贴头 -4. SKILL 只写「需已 UDS 登录」,不写 token 变量名 +1. 现场确保已安装 `uds-skill-auth` +2. 业务脚本定位 sibling:`skills/uds-skill-auth/scripts` → `import uds_skill_auth` +3. 替换原来的 `coclaw_*` / `EMP_NO`/`AUTH_VALUE` 手工读环境:改用 `resolve()` 或 `request()` +4. SKILL.md 只写「需已 UDS 登录,并已安装 uds-skill-auth」,不写 token 变量名 5. JSON stdout;日志脱敏 +定位示例: + +```python +from pathlib import Path +import sys + +def load_uds_skill_auth(): + here = Path(__file__).resolve().parent + candidate = here.parents[1] / "uds-skill-auth" / "scripts" + if not (candidate / "uds_skill_auth.py").is_file(): + raise RuntimeError("请先安装 uds-skill-auth skill") + sys.path.insert(0, str(candidate)) + import uds_skill_auth + return uds_skill_auth +``` + ## 禁止项 - 全局共享 token / 把 token 注入 `shellEnv` - SKILL 教打印环境变量中的密钥 - 无白名单的开放代理 - 用 UI `sessionStore` 的 30min TTL 冒充 skill 长凭证 +- 业务 skill 硬编码插件源码路径(应用 `uds-skill-auth` skill) ## 验收 - UI 登录/退出与改前一致 - 默认配置下 UI 退出后 skill/cron 仍可用 - `retainSkillCredentialsOnLogout=false` 时退出后取证失败 +- 未安装 `uds-skill-auth` 时业务 skill 报错清晰 - 两会话不串号;未登录错误可理解且无 token 泄露 - -## 样板走读 - -| 文件 | 作用 | -|------|------| -| `skills/uds-icenter/SKILL.md` | 给模型的用法 | -| `scripts/lib_uds.py` | 定位 helpers | -| `scripts/contacts.py` | 搜人/搜群/群成员(outbound) | -| `scripts/messaging.py` | 本机发信 + resolve 贴头 | -| `scripts/cli.py` | 统一入口 | diff --git a/uds-auth/skill-helpers/README.md b/uds-auth/skill-helpers/README.md index 89c084c4..7b409400 100644 --- a/uds-auth/skill-helpers/README.md +++ b/uds-auth/skill-helpers/README.md @@ -1,4 +1,7 @@ -# uds-auth skill helpers -# -# Python: add this directory to PYTHONPATH or sys.path, then: -# from uds_skill_auth import resolve, request, UdsAuthError +# Deprecated path + +认证公共库已改为 **skill 分发**:请安装并使用仓库内的 + +`skills/uds-skill-auth/` + +本目录仅作兼容备份;新 skill 不要依赖此路径。 diff --git a/uds-auth/skill-helpers/python/uds_skill_auth.py b/uds-auth/skill-helpers/python/uds_skill_auth.py index d65b81f8..c096992b 100644 --- a/uds-auth/skill-helpers/python/uds_skill_auth.py +++ b/uds-auth/skill-helpers/python/uds_skill_auth.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """ -Official uds-auth skill helpers (Python). +uds-skill-auth — Python helpers for UDS-backed skills. -Resolve current-user SSO credentials via loopback Host APIs, or call intranet -APIs through the outbound proxy (token never needed in skill code for outbound). +Resolve SSO credentials via loopback Host APIs, or call intranet APIs through +the outbound proxy. Environment: DSH_SESSION_ID — agent session id (injected by DSH shell-env) @@ -35,7 +35,6 @@ def _base_url() -> str: web = (os.environ.get("DSH_WEB_URL") or "").strip().rstrip("/") if web: return web + "/uds-auth" - # Common local defaults — try DSH_WEB_URL first in production. return "http://127.0.0.1:8787/uds-auth" @@ -71,7 +70,6 @@ def _http_json( req = urllib.request.Request(url, data=data, headers=hdrs, method=method.upper()) ctx = ssl._create_unverified_context() try: - # Prefer no-proxy opener for loopback Host if "127.0.0.1" in url or "localhost" in url.lower(): opener = _opener_no_proxy() with opener.open(req, timeout=timeout) as resp: