- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
216 lines
8.1 KiB
Python
216 lines
8.1 KiB
Python
"""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))
|