feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
215
coworker/secrets.py
Normal file
215
coworker/secrets.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Secret store — one canonical, file-backed store for connector/MCP credentials.
|
||||
|
||||
Design (from OpenClaw): secrets **never enter the model's context, prompts, or traces**.
|
||||
The store holds profiles keyed by `connector[:account]`; values may be literals OR
|
||||
`${ENV_VAR}` references resolved at read time from the process env / `~/.config/coworker/.env`.
|
||||
|
||||
v1 is a `0600` JSON file behind this interface; the interface is what callers depend on, so
|
||||
a Keychain / age-encrypted backend can swap in later without touching them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
|
||||
def state_dir() -> Path:
|
||||
"""Where coworker keeps its state — the one cross-platform source of truth.
|
||||
|
||||
Resolution order:
|
||||
1. `$COWORKER_STATE_DIR` — explicit override on any OS (used by tests/sidecars).
|
||||
2. Windows: `%APPDATA%\\coworker` (e.g. `C:\\Users\\You\\AppData\\Roaming\\coworker`),
|
||||
the native per-user app-data location.
|
||||
3. macOS / Linux: `~/.config/coworker` (XDG-style, unchanged from prior behavior).
|
||||
"""
|
||||
base = os.environ.get("COWORKER_STATE_DIR")
|
||||
if base:
|
||||
return Path(base).expanduser()
|
||||
if sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if appdata:
|
||||
return Path(appdata) / "coworker"
|
||||
return Path.home() / ".config" / "coworker"
|
||||
|
||||
|
||||
def _load_dotenv(path: Path) -> dict[str, str]:
|
||||
env: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return env
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
env[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return env
|
||||
|
||||
|
||||
def _restrict_to_user(path: Path, *, is_dir: bool) -> None:
|
||||
"""Restrict a path so only the current user can access it.
|
||||
|
||||
POSIX expresses this with mode bits (0700 dir / 0600 file). Windows has no such bits —
|
||||
`os.chmod` there only toggles the read-only flag, so a 0600 chmod is a silent no-op and
|
||||
the file inherits broad ACLs (SYSTEM, Administrators, …). Use an ACL instead: strip
|
||||
inherited entries and grant the current user alone. Best-effort on Windows so a transient
|
||||
icacls failure never blocks saving a key."""
|
||||
if _IS_WINDOWS:
|
||||
user = os.environ.get("USERNAME")
|
||||
if not user:
|
||||
return
|
||||
domain = os.environ.get("USERDOMAIN")
|
||||
account = f"{domain}\\{user}" if domain else user
|
||||
# A directory grant MUST be inheritable — (OI) object-inherit for files, (CI)
|
||||
# container-inherit for subdirs — so everything created inside (the SQLite stores,
|
||||
# conversations, …) inherits the user's access. Without these flags, /inheritance:r
|
||||
# leaves the directory with a non-inheritable ACE and any child file ends up with an
|
||||
# empty DACL → sqlite3 "unable to open database file", crashing the server on launch.
|
||||
grant = f"{account}:(OI)(CI)F" if is_dir else f"{account}:F"
|
||||
try:
|
||||
subprocess.run(
|
||||
["icacls", str(path), "/inheritance:r", "/grant:r", grant],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
os.chmod(path, 0o700 if is_dir else 0o600)
|
||||
|
||||
|
||||
def _atomic_private_write(target: Path, content: str) -> Path:
|
||||
"""Write `content` to `target` atomically, never exposing it through a readable temp.
|
||||
|
||||
The temp file used to be created by `Path.write_text` and only chmod-ed afterwards, so
|
||||
the plaintext sat on disk at the umask default (0644 on a normal box) for the length of
|
||||
the write — readable by every local process and by anything backing the directory up.
|
||||
That is issue #143; the same pattern was in both writers here.
|
||||
|
||||
`tempfile.mkstemp` creates with 0600 and O_EXCL before a byte is written, which also
|
||||
removes the fixed `<name>.tmp` filename. That name was predictable, so a local attacker
|
||||
could pre-create it as a symlink and have the write land wherever the link pointed.
|
||||
|
||||
Windows gets no mode bits from mkstemp, so the ACL is applied to the still-empty file
|
||||
before the content goes in.
|
||||
"""
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
_restrict_to_user(target.parent, is_dir=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(target.parent), prefix=f".{target.name}.", suffix=".tmp"
|
||||
)
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
_restrict_to_user(tmp, is_dir=False)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
os.replace(tmp, target)
|
||||
except BaseException:
|
||||
try:
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return target
|
||||
|
||||
|
||||
def write_private_text(path: str | Path, content: str) -> Path:
|
||||
"""Atomically write a user-only text file using the SecretStore's OS protections."""
|
||||
return _atomic_private_write(Path(path).expanduser(), content)
|
||||
|
||||
|
||||
class SecretStore:
|
||||
"""File-backed secret store. Reads resolve `${VAR}` refs; status never leaks values."""
|
||||
|
||||
def __init__(self, path: Optional[str | Path] = None) -> None:
|
||||
self.path = Path(path).expanduser() if path else state_dir() / "secrets.json"
|
||||
self._dotenv_path = self.path.parent / ".env"
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# -- reads ------------------------------------------------------------------
|
||||
def get(self, profile: str) -> Optional[dict[str, Any]]:
|
||||
"""Return a profile with `${VAR}` refs resolved, or None if absent."""
|
||||
data = self._read().get(profile)
|
||||
if data is None:
|
||||
return None
|
||||
return self.resolve(data)
|
||||
|
||||
def resolve(self, value: Any) -> Any:
|
||||
"""Resolve `${VAR}` refs in a value (recursively) from env + the local `.env`."""
|
||||
env = _load_dotenv(self._dotenv_path)
|
||||
|
||||
def _walk(v: Any) -> Any:
|
||||
if isinstance(v, str):
|
||||
return _REF.sub(
|
||||
lambda m: os.environ.get(m.group(1))
|
||||
or env.get(m.group(1))
|
||||
or m.group(0),
|
||||
v,
|
||||
)
|
||||
if isinstance(v, dict):
|
||||
return {k: _walk(x) for k, x in v.items()}
|
||||
if isinstance(v, list):
|
||||
return [_walk(x) for x in v]
|
||||
return v
|
||||
|
||||
return _walk(value)
|
||||
|
||||
def status(self) -> list[dict[str, Any]]:
|
||||
"""Profile metadata only — **never** the secret values themselves."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for profile, data in self._read().items():
|
||||
data = data if isinstance(data, dict) else {}
|
||||
expires = data.get("expires")
|
||||
expired = isinstance(expires, (int, float)) and expires < time.time()
|
||||
out.append(
|
||||
{
|
||||
"profile": profile,
|
||||
"type": data.get("type"),
|
||||
"account": data.get("account_id"),
|
||||
"expired": bool(expired),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
# -- writes -----------------------------------------------------------------
|
||||
def put(self, profile: str, data: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
store = self._read()
|
||||
store[profile] = data
|
||||
self._write(store)
|
||||
|
||||
def delete(self, profile: str) -> bool:
|
||||
with self._lock:
|
||||
store = self._read()
|
||||
if profile not in store:
|
||||
return False
|
||||
del store[profile]
|
||||
self._write(store)
|
||||
return True
|
||||
|
||||
# -- internals --------------------------------------------------------------
|
||||
def _read(self) -> dict[str, Any]:
|
||||
if not self.path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def _write(self, store: dict[str, Any]) -> None:
|
||||
_atomic_private_write(self.path, json.dumps(store, indent=2))
|
||||
Reference in New Issue
Block a user