Ship uds-skill-auth as a distributable skill for field UDS credential helpers.

Business skills depend on this sibling skill instead of the plugin source tree; update the auth standard docs accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-08 23:35:28 +08:00
parent 64761c8300
commit 3595f49c86
6 changed files with 286 additions and 29 deletions

View file

@ -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`。

View file

@ -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

View file

@ -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`。

View file

@ -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: <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` | 统一入口 |

View file

@ -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 不要依赖此路径。

View file

@ -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: