Files
JWeBot/packages/jwebot-config/src/jwebot_config/profile.py
T

1266 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Validated, atomic JWeBot configuration profiles."""
from __future__ import annotations
import ast
import hashlib
import json
import os
import re
import shutil
import tarfile
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any, Mapping
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python 3.9/3.10
import tomli as tomllib
CONFIG_FORMAT_VERSION = 1
PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,47}$")
ENVIRONMENT_KEY = re.compile(r"^(?:JWEBOT|WECHAT)_[A-Z0-9_]+$")
SAFE_ENVIRONMENT_VALUE = re.compile(r"^[A-Za-z0-9_./:@%+,-]*$")
MAX_CONFIG_FILE_BYTES = 1_000_000
MAX_CONTEXT_PEOPLE = 500
MAX_CONTEXT_VALUES = 64
SENSITIVE_ENVIRONMENT_KEYS = frozenset(
{
"JWEBOT_EDGE_DEEPSEEK_API_KEY",
"JWEBOT_EDGE_STEAM_API_KEY",
"JWEBOT_HEALTH_NOTIFICATION_TOKEN",
}
)
REQUIRED_PROFILE_FILES = (
"manifest.toml",
"components/bot.toml",
"components/providers.toml",
"components/runtime.toml",
"components/debug.toml",
"components/notification.toml",
"components/deployment.toml",
"context/people.json",
"secrets/runtime.env",
"overrides/runtime.env",
"overrides/root.env",
)
DEFAULT_PROFILE_FILES: Mapping[str, str] = {
"manifest.toml": """format_version = 1
id = "default"
description = "Local JWeBot configuration"
""",
"components/bot.toml": """[bot]
mention = "JWeBot"
identity_note = "You are JWeBot, a concise assistant for one configured group."
cloud_agent = "wechat-responder"
memory_remote_path = "/home/node/.openclaw/workspace/MEMORY.md"
""",
"components/providers.toml": """[deepseek]
base_url = "https://api.deepseek.com"
[cloud]
host = ""
[steam]
owner_id64 = ""
proxy_url = "http://127.0.0.1:7890"
""",
"components/runtime.toml": """[features]
edge_enabled = true
local_extensions_enabled = true
telemetry_enabled = true
ocr_prewarm = true
delivery_enabled = true
[extensions]
mode = "in-process"
transport = "unix"
publisher = ""
host_timeout_seconds = 8
fallback_enabled = true
[gui]
sender_timeout_seconds = 55
""",
"components/debug.toml": """[debug]
enabled = false
capture_payloads = false
incident_retention_days = 30
session_retention_days = 14
""",
"components/notification.toml": """[notification]
enabled = false
command = []
timeout_seconds = 30
""",
"components/deployment.toml": """[deployment]
runtime_user = "jwebot"
runtime_home = "/home/jwebot"
runtime_python = "/opt/jwebot/venv/bin/python"
backup_root = "/var/backups/jwebot"
""",
"context/people.json": """{
"people": [],
"clock_scan_targets": []
}
""",
"secrets/runtime.env": """# Secrets only. Keep this file mode 0600.
JWEBOT_EDGE_DEEPSEEK_API_KEY=
JWEBOT_EDGE_STEAM_API_KEY=
""",
"overrides/runtime.env": """# Optional non-secret runtime overrides.
""",
"overrides/root.env": """# Optional root-only deployment overrides.
""",
}
class ConfigError(ValueError):
"""Configuration input is invalid or cannot be activated safely."""
@dataclass(frozen=True)
class ConfigLayout:
config_root: Path
state_root: Path
@classmethod
def from_environment(
cls,
*,
home: Path | None = None,
config_root: Path | None = None,
state_root: Path | None = None,
) -> "ConfigLayout":
base = (home or Path.home()).expanduser()
configured = config_root or Path(
os.environ.get(
"JWEBOT_CONFIG_ROOT",
str(base / ".config" / "jwebot"),
)
)
state = state_root or Path(
os.environ.get(
"JWEBOT_CONFIG_STATE_ROOT",
str(base / ".local" / "state" / "jwebot"),
)
)
return cls(configured.expanduser(), state.expanduser())
@property
def profiles_root(self) -> Path:
return self.config_root / "profiles"
@property
def current_link(self) -> Path:
return self.config_root / "current"
def profile(self, profile_id: str) -> Path:
validate_profile_id(profile_id)
return self.profiles_root / profile_id
def profile_state(self, profile_id: str) -> Path:
validate_profile_id(profile_id)
return self.state_root / "profiles" / profile_id
def active_profile(self) -> Path | None:
if not self.current_link.is_symlink():
return None
target = self.current_link.resolve(strict=False)
try:
target.relative_to(self.profiles_root.resolve(strict=False))
except ValueError as error:
raise ConfigError("active profile points outside the profile root") from error
return target if target.is_dir() else None
def ensure_private_directory(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True, mode=0o700)
os.chmod(path, 0o700)
def validate_profile_id(value: object) -> str:
profile_id = str(value or "")
if not PROFILE_ID.fullmatch(profile_id):
raise ConfigError("profile id must match [a-z][a-z0-9-]{0,47}")
return profile_id
def read_text(path: Path) -> str:
try:
size = path.stat().st_size
except OSError as error:
raise ConfigError(f"missing configuration file: {path.name}") from error
if size > MAX_CONFIG_FILE_BYTES:
raise ConfigError(f"configuration file is too large: {path.name}")
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise ConfigError(f"cannot read configuration file: {path.name}") from error
def load_toml(path: Path) -> dict[str, Any]:
try:
value = tomllib.loads(read_text(path))
except tomllib.TOMLDecodeError as error:
raise ConfigError(f"invalid TOML: {path.name}") from error
if not isinstance(value, dict):
raise ConfigError(f"TOML root must be a table: {path.name}")
return value
def load_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(read_text(path))
except json.JSONDecodeError as error:
raise ConfigError(f"invalid JSON: {path.name}") from error
if not isinstance(value, dict):
raise ConfigError(f"JSON root must be an object: {path.name}")
return value
def table(value: Mapping[str, Any], name: str) -> dict[str, Any]:
result = value.get(name, {})
if not isinstance(result, dict):
raise ConfigError(f"{name} must be a table")
return result
def require_string(
value: Mapping[str, Any],
key: str,
*,
limit: int,
allow_empty: bool = True,
) -> str:
result = value.get(key, "")
if not isinstance(result, str):
raise ConfigError(f"{key} must be a string")
result = result.strip()
if not allow_empty and not result:
raise ConfigError(f"{key} must not be empty")
if len(result) > limit:
raise ConfigError(f"{key} exceeds {limit} characters")
if "\n" in result or "\r" in result or "\x00" in result:
raise ConfigError(f"{key} contains an invalid control character")
return result
def require_bool(value: Mapping[str, Any], key: str, default: bool) -> bool:
result = value.get(key, default)
if not isinstance(result, bool):
raise ConfigError(f"{key} must be a boolean")
return result
def require_int(
value: Mapping[str, Any],
key: str,
default: int,
*,
minimum: int,
maximum: int,
) -> int:
result = value.get(key, default)
if not isinstance(result, int) or isinstance(result, bool):
raise ConfigError(f"{key} must be an integer")
if not minimum <= result <= maximum:
raise ConfigError(f"{key} must be between {minimum} and {maximum}")
return result
def parse_environment(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
for line_number, raw in enumerate(read_text(path).splitlines(), start=1):
line = raw.strip()
if not line or line.startswith("#"):
continue
key, separator, value = line.partition("=")
key = key.strip()
if not separator or not ENVIRONMENT_KEY.fullmatch(key):
raise ConfigError(
f"invalid environment entry in {path.name}:{line_number}"
)
if key in values:
raise ConfigError(f"duplicate environment key: {key}")
if "\x00" in value or "\n" in value or "\r" in value:
raise ConfigError(f"invalid environment value: {key}")
values[key] = value.strip()
return values
def validate_private_context(value: Mapping[str, Any]) -> None:
people = value.get("people", [])
targets = value.get("clock_scan_targets", [])
if not isinstance(people, list) or len(people) > MAX_CONTEXT_PEOPLE:
raise ConfigError("people must be a bounded array")
if not isinstance(targets, list) or len(targets) > MAX_CONTEXT_PEOPLE:
raise ConfigError("clock_scan_targets must be a bounded array")
steam_ids: set[str] = set()
for person in people:
if not isinstance(person, dict):
raise ConfigError("each people entry must be an object")
require_string(person, "canonical_name", limit=120, allow_empty=False)
require_string(person, "identity_fact", limit=240)
require_string(person, "display_id", limit=120)
steam_id64 = require_string(person, "steam_id64", limit=17)
if steam_id64 and not re.fullmatch(r"\d{17}", steam_id64):
raise ConfigError("people steam_id64 must be a 17-digit SteamID")
if steam_id64 in steam_ids:
raise ConfigError("people steam_id64 values must be unique")
if steam_id64:
steam_ids.add(steam_id64)
for key in ("aliases", "prefixes", "steam_aliases"):
entries = person.get(key, [])
if not isinstance(entries, list) or len(entries) > MAX_CONTEXT_VALUES:
raise ConfigError(f"{key} must be a bounded array")
if any(
not isinstance(entry, str)
or not entry.strip()
or len(entry.strip()) > 120
for entry in entries
):
raise ConfigError(f"{key} contains an invalid value")
for target in targets:
if not isinstance(target, dict):
raise ConfigError("each clock target must be an object")
require_string(target, "canonical_name", limit=120, allow_empty=False)
require_string(target, "display_id", limit=120, allow_empty=False)
def validate_profile(path: Path) -> str:
root = path.expanduser().resolve()
if not root.is_dir():
raise ConfigError("profile directory is missing")
for child in root.rglob("*"):
if child.is_symlink():
raise ConfigError("profile files must not be symlinks")
for relative in REQUIRED_PROFILE_FILES:
candidate = root / relative
if not candidate.is_file():
raise ConfigError(f"profile is missing {relative}")
manifest = load_toml(root / "manifest.toml")
if manifest.get("format_version") != CONFIG_FORMAT_VERSION:
raise ConfigError("unsupported configuration format version")
profile_id = validate_profile_id(manifest.get("id"))
require_string(manifest, "description", limit=240)
bot = table(load_toml(root / "components" / "bot.toml"), "bot")
require_string(bot, "mention", limit=40, allow_empty=False)
require_string(bot, "identity_note", limit=500)
require_string(bot, "cloud_agent", limit=80, allow_empty=False)
require_string(bot, "memory_remote_path", limit=240, allow_empty=False)
providers = load_toml(root / "components" / "providers.toml")
deepseek = table(providers, "deepseek")
cloud = table(providers, "cloud")
steam = table(providers, "steam")
require_string(deepseek, "base_url", limit=240, allow_empty=False)
require_string(cloud, "host", limit=240)
owner_id = require_string(steam, "owner_id64", limit=17)
if owner_id and not re.fullmatch(r"\d{17}", owner_id):
raise ConfigError("steam.owner_id64 must be a 17-digit SteamID")
proxy = require_string(steam, "proxy_url", limit=120, allow_empty=False)
if not re.fullmatch(
r"http://(?:127\.0\.0\.1|localhost):(?:[1-9]\d{0,4})",
proxy,
):
raise ConfigError("steam.proxy_url must use a loopback HTTP proxy")
runtime = load_toml(root / "components" / "runtime.toml")
features = table(runtime, "features")
extensions = table(runtime, "extensions")
gui = table(runtime, "gui")
for key in (
"edge_enabled",
"local_extensions_enabled",
"telemetry_enabled",
"ocr_prewarm",
"delivery_enabled",
):
require_bool(features, key, True)
mode = require_string(extensions, "mode", limit=24, allow_empty=False)
if mode not in {"in-process", "prefer-host", "host-only"}:
raise ConfigError("extensions.mode is invalid")
transport = require_string(
extensions,
"transport",
limit=16,
allow_empty=False,
)
if transport not in {"unix", "process"}:
raise ConfigError("extensions.transport is invalid")
require_string(extensions, "publisher", limit=120)
require_int(
extensions,
"host_timeout_seconds",
8,
minimum=1,
maximum=30,
)
require_bool(extensions, "fallback_enabled", True)
require_int(
gui,
"sender_timeout_seconds",
55,
minimum=1,
maximum=300,
)
debug = table(load_toml(root / "components" / "debug.toml"), "debug")
require_bool(debug, "enabled", False)
require_bool(debug, "capture_payloads", False)
require_int(
debug,
"incident_retention_days",
30,
minimum=1,
maximum=3650,
)
require_int(
debug,
"session_retention_days",
14,
minimum=1,
maximum=3650,
)
notification = table(
load_toml(root / "components" / "notification.toml"),
"notification",
)
notification_enabled = require_bool(notification, "enabled", False)
command = notification.get("command", [])
if (
not isinstance(command, list)
or len(command) > 32
or any(
not isinstance(argument, str)
or not argument
or len(argument) > 500
or "\x00" in argument
for argument in command
)
):
raise ConfigError("notification.command must be a bounded string array")
if notification_enabled and (
not command or not str(command[0]).startswith("/")
):
raise ConfigError("enabled notification.command must start with an absolute path")
require_int(
notification,
"timeout_seconds",
30,
minimum=1,
maximum=120,
)
deployment = table(
load_toml(root / "components" / "deployment.toml"),
"deployment",
)
require_string(deployment, "runtime_user", limit=64, allow_empty=False)
for key in ("runtime_home", "runtime_python", "backup_root"):
value = require_string(deployment, key, limit=240, allow_empty=False)
if not value.startswith("/"):
raise ConfigError(f"deployment.{key} must be an absolute path")
validate_private_context(load_json(root / "context" / "people.json"))
secrets = parse_environment(root / "secrets" / "runtime.env")
if any(key not in SENSITIVE_ENVIRONMENT_KEYS for key in secrets):
raise ConfigError("secrets/runtime.env contains a non-secret key")
runtime_overrides = parse_environment(root / "overrides" / "runtime.env")
if any(key in SENSITIVE_ENVIRONMENT_KEYS for key in runtime_overrides):
raise ConfigError("secret keys belong in secrets/runtime.env")
parse_environment(root / "overrides" / "root.env")
return profile_id
def bool_value(value: bool) -> str:
return "1" if value else "0"
def encode_environment(values: Mapping[str, object]) -> str:
lines = []
for key, raw in values.items():
value = str(raw)
if not ENVIRONMENT_KEY.fullmatch(key):
raise ConfigError(f"invalid generated environment key: {key}")
if not SAFE_ENVIRONMENT_VALUE.fullmatch(value):
raise ConfigError(f"generated value requires unsupported quoting: {key}")
lines.append(f"{key}={value}")
return "\n".join(lines) + "\n"
def compile_profile(path: Path, layout: ConfigLayout) -> Path:
profile_id = validate_profile(path)
root = path.expanduser().resolve()
ensure_private_directory(layout.config_root)
ensure_private_directory(layout.profiles_root)
try:
root.relative_to(layout.profiles_root.resolve())
except ValueError:
pass
else:
os.chmod(root, 0o700)
bot = table(load_toml(root / "components" / "bot.toml"), "bot")
providers = load_toml(root / "components" / "providers.toml")
deepseek = table(providers, "deepseek")
cloud = table(providers, "cloud")
steam = table(providers, "steam")
runtime = load_toml(root / "components" / "runtime.toml")
features = table(runtime, "features")
extensions = table(runtime, "extensions")
gui = table(runtime, "gui")
debug = table(load_toml(root / "components" / "debug.toml"), "debug")
notification = table(
load_toml(root / "components" / "notification.toml"),
"notification",
)
deployment = table(
load_toml(root / "components" / "deployment.toml"),
"deployment",
)
private_context = load_json(root / "context" / "people.json")
private_context["bot"] = {
"mention": require_string(bot, "mention", limit=40, allow_empty=False),
"identity_note": require_string(bot, "identity_note", limit=500),
}
profile_state = layout.profile_state(profile_id)
runtime_values: dict[str, object] = {
"JWEBOT_CONFIG_PROFILE": profile_id,
"JWEBOT_BOT_MENTION": require_string(
bot,
"mention",
limit=40,
allow_empty=False,
),
"JWEBOT_EDGE_ENABLED": bool_value(
require_bool(features, "edge_enabled", True)
),
"JWEBOT_LOCAL_EXTENSIONS_ENABLED": bool_value(
require_bool(features, "local_extensions_enabled", True)
),
"JWEBOT_TELEMETRY_ENABLED": bool_value(
require_bool(features, "telemetry_enabled", True)
),
"WECHAT_OCR_PREWARM": bool_value(
require_bool(features, "ocr_prewarm", True)
),
"JWEBOT_EDGE_DELIVERY_ENABLED": bool_value(
require_bool(features, "delivery_enabled", True)
),
"JWEBOT_EDGE_DEEPSEEK_BASE_URL": require_string(
deepseek,
"base_url",
limit=240,
allow_empty=False,
),
"WECHAT_HEALTH_CLOUD_HOST": require_string(cloud, "host", limit=240),
"JWEBOT_EDGE_CLOUD_AGENT": require_string(
bot,
"cloud_agent",
limit=80,
allow_empty=False,
),
"JWEBOT_EDGE_MEMORY_REMOTE_PATH": require_string(
bot,
"memory_remote_path",
limit=240,
allow_empty=False,
),
"JWEBOT_EDGE_STEAM_ID64": require_string(
steam,
"owner_id64",
limit=17,
),
"JWEBOT_EDGE_STEAM_PROXY_URL": require_string(
steam,
"proxy_url",
limit=120,
allow_empty=False,
),
"JWEBOT_EXTENSION_MODE": require_string(
extensions,
"mode",
limit=24,
allow_empty=False,
),
"JWEBOT_EXTENSION_TRANSPORT": require_string(
extensions,
"transport",
limit=16,
allow_empty=False,
),
"JWEBOT_EXTENSION_PUBLISHER": require_string(
extensions,
"publisher",
limit=120,
),
"JWEBOT_EXTENSION_HOST_TIMEOUT_SECONDS": require_int(
extensions,
"host_timeout_seconds",
8,
minimum=1,
maximum=30,
),
"JWEBOT_EXTENSION_FALLBACK_ENABLED": bool_value(
require_bool(extensions, "fallback_enabled", True)
),
"JWEBOT_GUI_SENDER_TIMEOUT_SECONDS": require_int(
gui,
"sender_timeout_seconds",
55,
minimum=1,
maximum=300,
),
"JWEBOT_DEBUG_ENABLED": bool_value(
require_bool(debug, "enabled", False)
),
"JWEBOT_DEBUG_CAPTURE_PAYLOADS": bool_value(
require_bool(debug, "capture_payloads", False)
),
"JWEBOT_DEBUG_INCIDENT_RETENTION_DAYS": require_int(
debug,
"incident_retention_days",
30,
minimum=1,
maximum=3650,
),
"JWEBOT_DEBUG_SESSION_RETENTION_DAYS": require_int(
debug,
"session_retention_days",
14,
minimum=1,
maximum=3650,
),
"JWEBOT_DEBUG_INCIDENT_ROOT": str(profile_state / "incidents"),
"JWEBOT_DEBUG_SESSION_ROOT": str(profile_state / "debug" / "sessions"),
"JWEBOT_HEALTH_NOTIFICATION_CONFIG": str(
root / "generated" / "notification.json"
),
"JWEBOT_RUNTIME_USER": require_string(
deployment,
"runtime_user",
limit=64,
allow_empty=False,
),
"JWEBOT_RUNTIME_HOME": require_string(
deployment,
"runtime_home",
limit=240,
allow_empty=False,
),
"JWEBOT_RUNTIME_PYTHON": require_string(
deployment,
"runtime_python",
limit=240,
allow_empty=False,
),
"JWEBOT_PRIVATE_CONTEXT_FILE": str(
root / "generated" / "private-context.json"
),
}
runtime_values.update(
parse_environment(root / "overrides" / "runtime.env")
)
runtime_values.update(parse_environment(root / "secrets" / "runtime.env"))
root_values: dict[str, object] = {
"JWEBOT_RUNTIME_USER": require_string(
deployment,
"runtime_user",
limit=64,
allow_empty=False,
),
"JWEBOT_RUNTIME_HOME": require_string(
deployment,
"runtime_home",
limit=240,
allow_empty=False,
),
"JWEBOT_RUNTIME_PYTHON": require_string(
deployment,
"runtime_python",
limit=240,
allow_empty=False,
),
"JWEBOT_PRODUCTION_BACKUP_ROOT": require_string(
deployment,
"backup_root",
limit=240,
allow_empty=False,
),
}
root_values.update(parse_environment(root / "overrides" / "root.env"))
generated = root / "generated"
temporary = root / f".generated-{os.getpid()}-{time.time_ns()}"
temporary.mkdir(mode=0o700)
try:
runtime_path = temporary / "runtime.env"
root_path = temporary / "root.env"
context_path = temporary / "private-context.json"
notification_path = temporary / "notification.json"
runtime_path.write_text(
encode_environment(runtime_values),
encoding="utf-8",
)
root_path.write_text(encode_environment(root_values), encoding="utf-8")
context_path.write_text(
json.dumps(
private_context,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
notification_path.write_text(
json.dumps(
{
"enabled": require_bool(notification, "enabled", False),
"command": notification.get("command", []),
"timeout_seconds": require_int(
notification,
"timeout_seconds",
30,
minimum=1,
maximum=120,
),
},
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
generated_files = (
runtime_path,
root_path,
context_path,
notification_path,
)
for candidate in generated_files:
os.chmod(candidate, 0o600)
digest_manifest = {
"format_version": CONFIG_FORMAT_VERSION,
"profile": profile_id,
"generated_at": int(time.time()),
"files": {
candidate.name: hashlib.sha256(candidate.read_bytes()).hexdigest()
for candidate in generated_files
},
}
(temporary / "manifest.json").write_text(
json.dumps(digest_manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
os.chmod(temporary / "manifest.json", 0o600)
previous = root / ".generated-previous"
if previous.exists():
shutil.rmtree(previous)
if generated.exists():
generated.rename(previous)
temporary.rename(generated)
if previous.exists():
shutil.rmtree(previous)
except Exception:
shutil.rmtree(temporary, ignore_errors=True)
raise
for directory in (
layout.state_root,
layout.state_root / "profiles",
profile_state,
profile_state / "debug" / "sessions",
profile_state / "incidents",
profile_state / "telemetry",
profile_state / "archives",
profile_state / "root",
):
ensure_private_directory(directory)
return generated
def activate_profile(profile_id: str, layout: ConfigLayout) -> Path:
profile = layout.profile(profile_id)
generated = compile_profile(profile, layout)
ensure_private_directory(layout.config_root)
temporary_link = layout.config_root / f".current-{os.getpid()}"
temporary_link.unlink(missing_ok=True)
temporary_link.symlink_to(Path("profiles") / profile_id)
temporary_link.replace(layout.current_link)
return generated
def write_default_profile(
profile_id: str,
layout: ConfigLayout,
*,
force: bool = False,
) -> Path:
profile_id = validate_profile_id(profile_id)
destination = layout.profile(profile_id)
if destination.exists():
if not force:
raise ConfigError("profile already exists")
shutil.rmtree(destination)
temporary = layout.profiles_root / f".{profile_id}-{os.getpid()}"
shutil.rmtree(temporary, ignore_errors=True)
temporary.mkdir(parents=True, mode=0o700)
try:
for relative, content in DEFAULT_PROFILE_FILES.items():
candidate = temporary / relative
candidate.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if relative == "manifest.toml":
content = content.replace('id = "default"', f'id = "{profile_id}"')
candidate.write_text(content, encoding="utf-8")
os.chmod(candidate, 0o600)
validate_profile(temporary)
ensure_private_directory(layout.config_root)
ensure_private_directory(destination.parent)
temporary.rename(destination)
except Exception:
shutil.rmtree(temporary, ignore_errors=True)
raise
return destination
def copy_profile_tree(source: Path, destination: Path) -> None:
for candidate in source.rglob("*"):
relative = candidate.relative_to(source)
target = destination / relative
if candidate.is_symlink():
raise ConfigError("imported profiles must not contain symlinks")
if candidate.is_dir():
target.mkdir(parents=True, exist_ok=True, mode=0o700)
elif candidate.is_file():
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
shutil.copyfile(candidate, target)
os.chmod(target, 0o600)
def safe_extract_profile(archive: Path, destination: Path) -> Path:
with tarfile.open(archive, "r:*") as bundle:
members = bundle.getmembers()
for member in members:
relative = PurePosixPath(member.name)
if (
relative.is_absolute()
or ".." in relative.parts
or member.issym()
or member.islnk()
or member.isdev()
):
raise ConfigError("configuration archive contains an unsafe path")
bundle.extractall(destination, members=members)
manifests = list(destination.rglob("manifest.toml"))
if len(manifests) != 1:
raise ConfigError("configuration archive must contain one profile")
return manifests[0].parent
def import_profile(
source: Path,
layout: ConfigLayout,
*,
replace: bool = False,
) -> str:
source = source.expanduser().resolve()
with tempfile.TemporaryDirectory(prefix="jwebot-config-import-") as directory:
staging = Path(directory)
profile_source = (
source
if source.is_dir()
else safe_extract_profile(source, staging)
)
profile_id = validate_profile(profile_source)
destination = layout.profile(profile_id)
if destination.exists() and not replace:
raise ConfigError("profile already exists")
temporary = layout.profiles_root / f".{profile_id}-import-{os.getpid()}"
shutil.rmtree(temporary, ignore_errors=True)
temporary.mkdir(parents=True, mode=0o700)
try:
copy_profile_tree(profile_source, temporary)
validate_profile(temporary)
if destination.exists():
shutil.rmtree(destination)
ensure_private_directory(layout.config_root)
ensure_private_directory(destination.parent)
temporary.rename(destination)
except Exception:
shutil.rmtree(temporary, ignore_errors=True)
raise
return profile_id
def redact_environment_file(path: Path) -> None:
values = parse_environment(path)
safe = {
key: "" if is_sensitive_key(key) else value
for key, value in values.items()
}
path.write_text(encode_environment(safe), encoding="utf-8")
os.chmod(path, 0o600)
def is_sensitive_key(key: str) -> bool:
return (
key in SENSITIVE_ENVIRONMENT_KEYS
or key.endswith(("_KEY", "_TOKEN", "_PASSWORD", "_SECRET"))
)
def export_profile(
profile_id: str,
output: Path,
layout: ConfigLayout,
*,
include_private: bool = False,
) -> Path:
source = layout.profile(profile_id)
validate_profile(source)
output = output.expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="jwebot-config-export-") as directory:
root = Path(directory) / profile_id
copy_profile_tree(source, root)
shutil.rmtree(root / "generated", ignore_errors=True)
if not include_private:
redact_environment_file(root / "secrets" / "runtime.env")
redact_environment_file(root / "overrides" / "runtime.env")
redact_environment_file(root / "overrides" / "root.env")
context = load_json(root / "context" / "people.json")
context["people"] = []
context["clock_scan_targets"] = []
(root / "context" / "people.json").write_text(
json.dumps(context, ensure_ascii=False, indent=2, sort_keys=True)
+ "\n",
encoding="utf-8",
)
with tarfile.open(output, "w:gz") as bundle:
bundle.add(root, arcname=profile_id, recursive=True)
os.chmod(output, 0o600)
return output
def archive_legacy_material(
source_root: Path,
profile_id: str,
layout: ConfigLayout,
) -> Path:
source_root = source_root.expanduser().resolve()
destination = (
layout.profile_state(profile_id)
/ "archives"
/ time.strftime("legacy-%Y%m%d-%H%M%S")
)
ensure_private_directory(layout.state_root)
ensure_private_directory(layout.state_root / "profiles")
ensure_private_directory(layout.profile_state(profile_id))
ensure_private_directory(layout.profile_state(profile_id) / "archives")
ensure_private_directory(destination)
candidates = [
source_root / ".dbg",
source_root / "dbg",
source_root / "docs" / "debug",
source_root / "docs" / "reports",
source_root / "ops",
]
candidates.extend(source_root.glob("debug-*.md"))
candidates.extend(source_root.glob("homeserver-apikey-*.md"))
for candidate in candidates:
if not candidate.exists() or candidate.is_symlink():
continue
relative = candidate.relative_to(source_root)
target = destination / relative
if candidate.is_dir():
shutil.copytree(candidate, target)
else:
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
shutil.copyfile(candidate, target)
for copied in [target, *target.rglob("*")] if target.is_dir() else [target]:
if copied.is_dir():
os.chmod(copied, 0o700)
elif copied.is_file():
os.chmod(copied, 0o600)
return destination
def migrate_legacy_environment(
source: Path,
profile_id: str,
layout: ConfigLayout,
) -> bool:
source = source.expanduser().resolve()
if not source.is_file():
return False
profile = layout.profile(profile_id)
validate_profile(profile)
values = parse_environment(source)
secrets = parse_environment(profile / "secrets" / "runtime.env")
overrides = parse_environment(profile / "overrides" / "runtime.env")
for key, value in values.items():
if is_sensitive_key(key):
secrets[key] = value
else:
overrides[key] = value
(profile / "secrets" / "runtime.env").write_text(
encode_environment(secrets),
encoding="utf-8",
)
(profile / "overrides" / "runtime.env").write_text(
encode_environment(overrides),
encoding="utf-8",
)
os.chmod(profile / "secrets" / "runtime.env", 0o600)
os.chmod(profile / "overrides" / "runtime.env", 0o600)
archive = layout.profile_state(profile_id) / "archives" / "legacy-environment"
ensure_private_directory(archive)
destination = archive / "environment"
shutil.copyfile(source, destination)
os.chmod(destination, 0o600)
return True
def migrate_legacy_context(
source_root: Path,
profile_id: str,
layout: ConfigLayout,
*,
environment: Path | None = None,
) -> bool:
source = (
source_root.expanduser().resolve()
/ "packages"
/ "jwebot-kernel"
/ "src"
/ "jwebot_kernel"
/ "edge_runtime.py"
)
if not source.is_file():
return False
try:
syntax = ast.parse(read_text(source))
except SyntaxError as error:
raise ConfigError("legacy context source is not valid Python") from error
assignments: dict[str, object] = {}
for node in syntax.body:
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
continue
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
value_node = node.value
for target in targets:
if not isinstance(target, ast.Name):
continue
name = target.id
if (
name in {
"PERSON_ALIAS_TEMPLATES",
"PERSON_DISPLAY_IDS",
"CLOCK_SCAN_TARGETS",
}
or name.endswith("_IDENTITY_NOTE")
):
try:
assignments[name] = ast.literal_eval(value_node)
except (ValueError, TypeError):
continue
templates = assignments.get("PERSON_ALIAS_TEMPLATES", ())
display_ids = assignments.get("PERSON_DISPLAY_IDS", {})
clock_targets = assignments.get("CLOCK_SCAN_TARGETS", ())
if not isinstance(templates, (list, tuple)) or not isinstance(display_ids, dict):
return False
steam_assignments: dict[str, object] = {}
steam_source = (
source_root.expanduser().resolve()
/ "packages"
/ "jwebot-official-extensions"
/ "src"
/ "jwebot_official_extensions"
/ "steam.py"
)
if steam_source.is_file():
try:
steam_syntax = ast.parse(read_text(steam_source))
except SyntaxError as error:
raise ConfigError("legacy Steam source is not valid Python") from error
for node in steam_syntax.body:
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
continue
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
for target in targets:
if (
not isinstance(target, ast.Name)
or target.id
not in {"STEAM_GROUP_ROSTER", "STEAM_ALIAS_LABELS"}
):
continue
try:
steam_assignments[target.id] = ast.literal_eval(node.value)
except (ValueError, TypeError):
continue
roster_by_label: dict[str, str] = {}
legacy_roster = steam_assignments.get("STEAM_GROUP_ROSTER", ())
if isinstance(legacy_roster, (list, tuple)):
for raw in legacy_roster:
if not isinstance(raw, (list, tuple)) or len(raw) != 2:
continue
label = str(raw[0]).strip()
steam_id64 = str(raw[1]).strip()
if label and re.fullmatch(r"\d{17}", steam_id64):
roster_by_label[label] = steam_id64
owner_id64 = ""
if environment is not None and environment.expanduser().is_file():
owner_id64 = parse_environment(
environment.expanduser().resolve()
).get("JWEBOT_EDGE_STEAM_ID64", "").strip()
display_values = {
str(value).strip()
for value in display_ids.values()
if str(value).strip()
}
missing_labels = display_values - set(roster_by_label)
if re.fullmatch(r"\d{17}", owner_id64) and len(missing_labels) == 1:
roster_by_label[missing_labels.pop()] = owner_id64
aliases_by_label: dict[str, list[str]] = {}
legacy_aliases = steam_assignments.get("STEAM_ALIAS_LABELS", {})
if isinstance(legacy_aliases, dict):
for raw_alias, raw_label in legacy_aliases.items():
alias = str(raw_alias).strip()
label = str(raw_label).strip()
if alias and label in roster_by_label:
aliases_by_label.setdefault(label, []).append(alias)
people = []
for raw in templates:
if not isinstance(raw, dict):
continue
canonical_name = str(raw.get("canonical_name", "")).strip()
if not canonical_name:
continue
display_id = str(display_ids.get(canonical_name, "")).strip()
person = {
"canonical_name": canonical_name,
"aliases": [
str(value)
for value in raw.get("aliases", ())
if str(value).strip()
],
"prefixes": [
str(value)
for value in raw.get("prefixes", ())
if str(value).strip()
],
"identity_fact": str(raw.get("identity_fact", "")).strip(),
"display_id": display_id,
}
steam_id64 = roster_by_label.get(display_id, "")
if steam_id64:
person["steam_id64"] = steam_id64
person["steam_aliases"] = aliases_by_label.get(display_id, [])
people.append(person)
targets = []
if isinstance(clock_targets, (list, tuple)):
for raw in clock_targets:
if (
isinstance(raw, (list, tuple))
and len(raw) == 2
and all(str(value).strip() for value in raw)
):
targets.append(
{
"canonical_name": str(raw[0]).strip(),
"display_id": str(raw[1]).strip(),
}
)
profile = layout.profile(profile_id)
validate_profile(profile)
context = {
"people": people,
"clock_scan_targets": targets,
}
validate_private_context(context)
(profile / "context" / "people.json").write_text(
json.dumps(context, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
os.chmod(profile / "context" / "people.json", 0o600)
identity_note = next(
(
str(value).strip()
for key, value in assignments.items()
if key.endswith("_IDENTITY_NOTE") and str(value).strip()
),
"",
)
if identity_note:
mention = identity_note.split(maxsplit=1)[0].strip("::,,。.")
bot = table(load_toml(profile / "components" / "bot.toml"), "bot")
bot["mention"] = mention or str(bot.get("mention", "JWeBot"))
bot["identity_note"] = identity_note
lines = ["[bot]"]
for key in (
"mention",
"identity_note",
"cloud_agent",
"memory_remote_path",
):
lines.append(
f"{key} = "
+ json.dumps(str(bot.get(key, "")), ensure_ascii=False)
)
(profile / "components" / "bot.toml").write_text(
"\n".join(lines) + "\n",
encoding="utf-8",
)
os.chmod(profile / "components" / "bot.toml", 0o600)
return bool(people or targets or identity_note)
def load_active_environment(
layout: ConfigLayout | None = None,
) -> dict[str, str]:
active_layout = layout or ConfigLayout.from_environment()
profile = active_layout.active_profile()
if profile is None:
return {}
generated = profile / "generated" / "runtime.env"
return parse_environment(generated) if generated.is_file() else {}
def load_private_context(
layout: ConfigLayout | None = None,
) -> dict[str, Any]:
configured = os.environ.get("JWEBOT_PRIVATE_CONTEXT_FILE", "").strip()
if configured:
path = Path(configured).expanduser()
else:
active_layout = layout or ConfigLayout.from_environment()
profile = active_layout.active_profile()
if profile is None:
return {"bot": {"mention": "JWeBot", "identity_note": ""}, "people": []}
path = profile / "generated" / "private-context.json"
if not path.is_file():
return {"bot": {"mention": "JWeBot", "identity_note": ""}, "people": []}
value = load_json(path)
validate_private_context(value)
return value