mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
194 lines
7.3 KiB
Python
194 lines
7.3 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 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 write_private_text(path: str | Path, content: str) -> Path:
|
|
"""Atomically write a user-only text file using the SecretStore's OS protections."""
|
|
target = Path(path).expanduser()
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
_restrict_to_user(target.parent, is_dir=True)
|
|
except OSError:
|
|
pass
|
|
tmp = target.with_name(target.name + ".tmp")
|
|
tmp.write_text(content, encoding="utf-8")
|
|
_restrict_to_user(tmp, is_dir=False)
|
|
os.replace(tmp, target)
|
|
return target
|
|
|
|
|
|
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:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
_restrict_to_user(self.path.parent, is_dir=True)
|
|
except OSError:
|
|
pass
|
|
tmp = self.path.with_name(self.path.name + ".tmp")
|
|
tmp.write_text(json.dumps(store, indent=2), encoding="utf-8")
|
|
_restrict_to_user(tmp, is_dir=False)
|
|
os.replace(tmp, self.path)
|