mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 06:50:48 +08:00
Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw "sign-in failed / add API key" on DSH Desktop because the remote.mux opens anonymously at boot and every early call was rejected terminally. - gateway: anonymous workspace/follow reaches dsh-acl's empty baseline; anonymous streams are parked until the carrier aborts instead of failing; anonymous workspace/initializeDefault answers "nothing created" - request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket or bridge (sync + async variants used by all callers) - dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup instead of 403, so the Desktop welcome read no longer fails - desktop-bootstrap: key/account projection is best-effort per reference - client: emit connection/reset after login so boot-time caches (settings describe mirror -> Settings > Models) re-read under the real principal - sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min); bridge expiry slides with its session; MemoryStore persisted to sessions.json (bearer hash only, userData AES-256-GCM sealed) - README: session lifetime/persistence and DSH compatibility notes Also includes previously uncommitted uds-auth 0.3.x work in this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
8.5 KiB
Python
237 lines
8.5 KiB
Python
# -*- 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)
|
|
UDS_TASK_CAPABILITY — short-lived Host-minted capability (preferred for cron/skill)
|
|
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:
|
|
"""Prefer loopback Host. Env override cannot point at non-loopback without explicit allow."""
|
|
managed = os.environ.get("DSH_UDS_AUTH_BASE")
|
|
explicit = (managed if managed is not None else os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/")
|
|
web = (os.environ.get("DSH_WEB_URL") or "").strip().rstrip("/")
|
|
base = explicit or (web + "/uds-auth" if web else "")
|
|
if managed is not None and not explicit:
|
|
raise UdsAuthError("Host did not authorize this session", code="no_session")
|
|
if not base:
|
|
raise UdsAuthError("Missing Host endpoint; load uds-auth shellEnv adapter", code="no_base")
|
|
parsed = urlparse(base)
|
|
if parsed.scheme not in ("http", "https") or parsed.hostname not in ("127.0.0.1", "localhost", "::1") or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
raise UdsAuthError("UDS auth endpoint must be an owned loopback HTTP(S) URL", code="unsafe_base")
|
|
return base
|
|
|
|
|
|
def _session_id() -> str:
|
|
return (os.environ.get("DSH_SESSION_ID") or "").strip()
|
|
|
|
|
|
def _task_capability() -> str:
|
|
managed = os.environ.get("DSH_UDS_TASK_CAPABILITY")
|
|
return (managed if managed is not None else os.environ.get("UDS_TASK_CAPABILITY") or os.environ.get("UDS_AUTH_TASK_CAPABILITY") or "").strip()
|
|
|
|
|
|
def _auth_headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
hdrs = dict(extra or {})
|
|
sid = _session_id()
|
|
if sid:
|
|
hdrs.setdefault("X-DSH-Session-Id", sid)
|
|
cap = _task_capability()
|
|
if cap:
|
|
hdrs["X-UDS-Task-Capability"] = cap
|
|
hdrs.setdefault("Accept", "application/json")
|
|
return hdrs
|
|
|
|
|
|
def _is_loopback_url(url: str) -> bool:
|
|
try:
|
|
host = (urlparse(url).hostname or "").lower()
|
|
except Exception:
|
|
return False
|
|
return host in ("127.0.0.1", "localhost", "::1")
|
|
|
|
|
|
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())
|
|
# SEC-18: always use standard TLS verification — never unverified context.
|
|
ctx = ssl.create_default_context()
|
|
try:
|
|
if _is_loopback_url(url):
|
|
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 = False) -> Dict[str, str]:
|
|
"""
|
|
Fetch {empNo, token} for the current DSH session from Host.
|
|
Prefer UDS_TASK_CAPABILITY (Host-minted) over a browser session cookie.
|
|
apply_env_aliases defaults False (SEC-18) — subprocesses inherit secrets if True.
|
|
Prefer request()/outbound instead of exposing raw tokens when possible.
|
|
"""
|
|
sid = _session_id()
|
|
cap = _task_capability()
|
|
if not sid and not cap:
|
|
raise UdsAuthError(
|
|
"缺少 DSH_SESSION_ID 或 UDS_TASK_CAPABILITY",
|
|
code="no_session",
|
|
)
|
|
|
|
url = _base_url() + "/agent-credentials"
|
|
body: Dict[str, Any] = {}
|
|
if sid:
|
|
body["sessionId"] = sid
|
|
if cap:
|
|
body["taskCapability"] = cap
|
|
status, parsed, raw = _http_json(
|
|
"POST",
|
|
url,
|
|
headers=_auth_headers(),
|
|
body=body,
|
|
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 == 403 and isinstance(parsed, dict) and parsed.get("error") == "raw_token_disabled":
|
|
raise UdsAuthError(
|
|
"原始凭证接口已关闭,请使用 outbound()",
|
|
code="raw_token_disabled",
|
|
status=403,
|
|
)
|
|
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()
|
|
cap = _task_capability()
|
|
if not sid and not cap:
|
|
raise UdsAuthError(
|
|
"缺少 DSH_SESSION_ID 或 UDS_TASK_CAPABILITY",
|
|
code="no_session",
|
|
)
|
|
|
|
host = urlparse(url).hostname or ""
|
|
payload: Dict[str, Any] = {
|
|
"method": method.upper(),
|
|
"url": url,
|
|
"headers": headers or {},
|
|
"body": body,
|
|
"timeoutMs": int(timeout * 1000),
|
|
}
|
|
if sid:
|
|
payload["sessionId"] = sid
|
|
if cap:
|
|
payload["taskCapability"] = cap
|
|
status, parsed, raw = _http_json(
|
|
"POST",
|
|
_base_url() + "/outbound",
|
|
headers=_auth_headers(),
|
|
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
|