214 lines
9.3 KiB
Python
214 lines
9.3 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
from typing import Any
|
|
|
|
from .errors import ConfigurationError
|
|
from .securefs import read_secure_bytes, read_secure_json, verify_secure_file
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Config:
|
|
host: str
|
|
port: int
|
|
project_key: str
|
|
database_path: Path
|
|
event_inbox_dir: Path
|
|
decision_inbox_dir: Path
|
|
dispatch_outbox_dir: Path
|
|
openclaw_sync_dir: Path
|
|
command_queue_dir: Path
|
|
inbound_token_file: Path
|
|
callback_token_file: Path
|
|
contract_commit: str
|
|
workspace_trigger_id: str | None
|
|
workspace_token_file: Path | None
|
|
responses_api_key_file: Path | None
|
|
responses_model: str | None
|
|
poll_interval_seconds: int
|
|
summary_interval_seconds: int
|
|
callback_timeout_seconds: int
|
|
lease_seconds: int
|
|
retry_base_seconds: int
|
|
max_retries: int
|
|
max_same_blocker_retries: int
|
|
max_hops_per_gate: int
|
|
openclaw_sync_endpoint: str | None
|
|
openclaw_sync_token_file: Path | None
|
|
git_poll_enabled: bool
|
|
git_poll_bare_repo: Path | None
|
|
git_poll_ref_prefixes: tuple[str, ...]
|
|
git_poll_interval_seconds: int
|
|
|
|
@classmethod
|
|
def from_file(cls, path: Path) -> "Config":
|
|
owner_uid = int(os.environ.get("AIOS_ORCHESTRATOR_CONFIG_OWNER_UID", "0"))
|
|
raw = read_secure_json(path, expected_uid=owner_uid)
|
|
expected = {
|
|
"host", "port", "project_key", "database_path", "event_inbox_dir",
|
|
"decision_inbox_dir", "dispatch_outbox_dir", "openclaw_sync_dir",
|
|
"command_queue_dir", "inbound_token_file", "callback_token_file",
|
|
"contract_commit", "workspace_agent", "responses_fallback",
|
|
"polling", "loop_controls", "openclaw_sync", "git_poll",
|
|
}
|
|
if set(raw) != expected:
|
|
raise ConfigurationError("invalid_config_fields")
|
|
|
|
def path_value(key: str) -> Path:
|
|
value = raw.get(key)
|
|
if not isinstance(value, str):
|
|
raise ConfigurationError(f"invalid_{key}")
|
|
result = Path(value)
|
|
if not result.is_absolute():
|
|
raise ConfigurationError(f"path_not_absolute:{key}")
|
|
return result
|
|
|
|
def integer(container: dict[str, Any], key: str, lo: int, hi: int) -> int:
|
|
value = container.get(key)
|
|
if not isinstance(value, int) or not lo <= value <= hi:
|
|
raise ConfigurationError(f"invalid_{key}")
|
|
return value
|
|
|
|
host = raw.get("host")
|
|
port = raw.get("port")
|
|
project_key = raw.get("project_key")
|
|
contract = raw.get("contract_commit")
|
|
if not isinstance(host, str) or not host:
|
|
raise ConfigurationError("invalid_host")
|
|
if not isinstance(project_key, str) or not project_key:
|
|
raise ConfigurationError("invalid_project_key")
|
|
if not isinstance(port, int) or not 1 <= port <= 65535:
|
|
raise ConfigurationError("invalid_port")
|
|
if not isinstance(contract, str) or len(contract) != 40:
|
|
raise ConfigurationError("invalid_contract_commit")
|
|
|
|
workspace = raw.get("workspace_agent")
|
|
responses = raw.get("responses_fallback")
|
|
polling = raw.get("polling")
|
|
loop = raw.get("loop_controls")
|
|
sync = raw.get("openclaw_sync")
|
|
git_poll = raw.get("git_poll")
|
|
if not all(isinstance(x, dict) for x in (workspace, responses, polling, loop, sync, git_poll)):
|
|
raise ConfigurationError("invalid_nested_config")
|
|
|
|
trigger_id = workspace.get("trigger_id")
|
|
workspace_token = workspace.get("access_token_file")
|
|
if trigger_id is not None:
|
|
if not isinstance(trigger_id, str) or not trigger_id.startswith("agtch_"):
|
|
raise ConfigurationError("invalid_workspace_trigger_id")
|
|
if not isinstance(workspace_token, str) or not Path(workspace_token).is_absolute():
|
|
raise ConfigurationError("invalid_workspace_token_file")
|
|
workspace_token_file = Path(workspace_token)
|
|
else:
|
|
workspace_token_file = None
|
|
|
|
responses_key = responses.get("api_key_file")
|
|
responses_model = responses.get("model")
|
|
if responses_key is not None:
|
|
if not isinstance(responses_key, str) or not Path(responses_key).is_absolute():
|
|
raise ConfigurationError("invalid_responses_key_file")
|
|
if not isinstance(responses_model, str) or not responses_model:
|
|
raise ConfigurationError("invalid_responses_model")
|
|
responses_key_file = Path(responses_key)
|
|
else:
|
|
responses_key_file = None
|
|
responses_model = None
|
|
|
|
if trigger_id is None and responses_key_file is None:
|
|
raise ConfigurationError("no_central_model_channel")
|
|
|
|
endpoint = sync.get("endpoint")
|
|
token_file = sync.get("token_file")
|
|
if endpoint is not None and (not isinstance(endpoint, str) or not endpoint.startswith(("http://", "https://"))):
|
|
raise ConfigurationError("invalid_openclaw_sync_endpoint")
|
|
if endpoint is not None:
|
|
if not isinstance(token_file, str) or not Path(token_file).is_absolute():
|
|
raise ConfigurationError("invalid_openclaw_sync_token_file")
|
|
sync_token_file = Path(token_file)
|
|
else:
|
|
sync_token_file = None
|
|
|
|
git_enabled = git_poll.get("enabled")
|
|
if not isinstance(git_enabled, bool):
|
|
raise ConfigurationError("invalid_git_poll_enabled")
|
|
git_bare_repo_raw = git_poll.get("bare_repo")
|
|
git_prefixes_raw = git_poll.get("ref_prefixes")
|
|
if git_enabled:
|
|
if not isinstance(git_bare_repo_raw, str) or not Path(git_bare_repo_raw).is_absolute():
|
|
raise ConfigurationError("invalid_git_poll_bare_repo")
|
|
if (
|
|
not isinstance(git_prefixes_raw, list)
|
|
or not git_prefixes_raw
|
|
or any(not isinstance(item, str) or not item.startswith("refs/") for item in git_prefixes_raw)
|
|
):
|
|
raise ConfigurationError("invalid_git_poll_ref_prefixes")
|
|
git_bare_repo = Path(git_bare_repo_raw)
|
|
git_prefixes = tuple(git_prefixes_raw)
|
|
else:
|
|
git_bare_repo = None
|
|
git_prefixes = ()
|
|
|
|
return cls(
|
|
host=host,
|
|
port=port,
|
|
project_key=project_key,
|
|
database_path=path_value("database_path"),
|
|
event_inbox_dir=path_value("event_inbox_dir"),
|
|
decision_inbox_dir=path_value("decision_inbox_dir"),
|
|
dispatch_outbox_dir=path_value("dispatch_outbox_dir"),
|
|
openclaw_sync_dir=path_value("openclaw_sync_dir"),
|
|
command_queue_dir=path_value("command_queue_dir"),
|
|
inbound_token_file=path_value("inbound_token_file"),
|
|
callback_token_file=path_value("callback_token_file"),
|
|
contract_commit=contract,
|
|
workspace_trigger_id=trigger_id,
|
|
workspace_token_file=workspace_token_file,
|
|
responses_api_key_file=responses_key_file,
|
|
responses_model=responses_model,
|
|
poll_interval_seconds=integer(polling, "interval_seconds", 1, 300),
|
|
summary_interval_seconds=integer(polling, "summary_interval_seconds", 10, 86400),
|
|
callback_timeout_seconds=integer(loop, "callback_timeout_seconds", 30, 86400),
|
|
lease_seconds=integer(loop, "lease_seconds", 10, 3600),
|
|
retry_base_seconds=integer(loop, "retry_base_seconds", 1, 3600),
|
|
max_retries=integer(loop, "max_retries", 0, 20),
|
|
max_same_blocker_retries=integer(loop, "max_same_blocker_retries", 0, 20),
|
|
max_hops_per_gate=integer(loop, "max_hops_per_gate", 1, 100),
|
|
openclaw_sync_endpoint=endpoint,
|
|
openclaw_sync_token_file=sync_token_file,
|
|
git_poll_enabled=git_enabled,
|
|
git_poll_bare_repo=git_bare_repo,
|
|
git_poll_ref_prefixes=git_prefixes,
|
|
git_poll_interval_seconds=integer(git_poll, "interval_seconds", 5, 3600),
|
|
)
|
|
|
|
def _secret(self, path: Path) -> str:
|
|
owner_uid = int(os.environ.get("AIOS_ORCHESTRATOR_SECRET_OWNER_UID", "0"))
|
|
info = verify_secure_file(path, expected_uid=owner_uid)
|
|
if stat.S_IMODE(info.st_mode) & ~0o640:
|
|
raise ConfigurationError(f"permissive_secret_mode:{path}")
|
|
data = read_secure_bytes(path, expected_uid=owner_uid, max_bytes=64_000)
|
|
if not data or b"\n" in data or b"\r" in data:
|
|
raise ConfigurationError(f"invalid_secret_format:{path}")
|
|
try:
|
|
return data.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise ConfigurationError(f"secret_not_utf8:{path}") from exc
|
|
|
|
def inbound_token(self) -> str:
|
|
return self._secret(self.inbound_token_file)
|
|
|
|
def callback_token(self) -> str:
|
|
return self._secret(self.callback_token_file)
|
|
|
|
def workspace_token(self) -> str | None:
|
|
return self._secret(self.workspace_token_file) if self.workspace_token_file else None
|
|
|
|
def responses_api_key(self) -> str | None:
|
|
return self._secret(self.responses_api_key_file) if self.responses_api_key_file else None
|
|
|
|
def openclaw_sync_token(self) -> str | None:
|
|
return self._secret(self.openclaw_sync_token_file) if self.openclaw_sync_token_file else None
|