mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 22:20:54 +08:00
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:
parent
64761c8300
commit
3595f49c86
6 changed files with 286 additions and 29 deletions
60
skills/uds-skill-auth/SKILL.md
Normal file
60
skills/uds-skill-auth/SKILL.md
Normal 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`。
|
||||
180
skills/uds-skill-auth/scripts/uds_skill_auth.py
Normal file
180
skills/uds-skill-auth/scripts/uds_skill_auth.py
Normal 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
|
||||
|
|
@ -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`。
|
||||
|
||||
|
|
|
|||
|
|
@ -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` | 统一入口 |
|
||||
|
|
|
|||
|
|
@ -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 不要依赖此路径。
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue