tools/agents provides the backend implementation for fx agents, an extensible CLI tool managing AI coding agent configurations, permission profiles, command expansion regexes, and environment lifecycle for Fuchsia developers.
tools/agents/ ├── BUILD.gn # GN build definitions with python_library("agents_lib") ├── OWNERS # Tool ownership ├── __init__.py # Top-level agents package marker ├── main.py # CLI dispatcher & subcommand routing entrypoint ├── commands/ │ ├── __init__.py │ └── setup.py # CLI argument parsing and setup orchestration ├── lib/ │ ├── __init__.py │ ├── config.py # Atomic JSON I/O, .tmp swap, and backup creation │ ├── permissions.py # Command regex generator, profile manifests loader │ ├── services.py # Multi-repo daemon discovery and systemctl restart │ └── state.py # State journal, rolling backups, and 3-way reconciliation └── tests/ ├── __init__.py ├── main_test.py # CLI dispatcher unit tests ├── setup_test.py # Setup command integration unit tests ├── config_test.py # Config I/O & atomic write unit tests ├── permissions_test.py # Regex expansion & profile unit tests ├── services_test.py # Daemon discovery & restart unit tests └── state_test.py # State journal, rollback & reconciliation unit tests
main.py (CLI Dispatcher)argparse configuration for fx agents.commands/.args.func(args)).commands/setup.py (Setup Orchestrator)--profile, --status, --rollback, --reset, --state-dir, --allow, --deny, --ask, --allow-list, --deny-list, --ask-list, --config, and --dry-run.lib/config.py (Atomic JSON Configuration Management)load_config(path): Safely loads JSON dictionaries, returning {} if missing or malformed.save_config_atomic(path, data): Writes to a hidden temporary file .{name}.tmp in the target directory, formats JSON with 2-space indentation and trailing newline, and atomically swaps it using Path.replace.apply_grants(...): Orchestrates three-way reconciliation, backup creation, atomic config persistence, and state journal logging.lib/permissions.py (Command Expansion & Profile Engine)read-only, local-changes, external-changes, full-access).command(regex:...) with support for:VAR=value ...).-C <dir>, --no-pager, etc.) and subcommand flag placement.-i, --in-place)./usr/bin/ <-> /bin/ aliases.lib/services.py (Multi-Repo Daemon Management)services.txt).systemctl --user try-restart <service>.lib/state.py (State Journal & Reconciliation Engine)StateJournal schema in ~/.local/share/Fuchsia/agents/setup/state.json (adhering to $XDG_STATE_HOME / $XDG_DATA_HOME).~/.local/share/Fuchsia/agents/setup/backups/ capped at MAX_BACKUPS = 10.rollback), configuration reset (reset), and status reporting (format_status).To prevent configuration drift, lingering rule conflicts, or loss of developer-authored custom rules, lib/state.py implements a deterministic 3-way set reconciliation model:
Let $\text{cat} \in {\text{allow}, \text{deny}, \text{ask}}$ represent the grant categories.
Let $E[\text{cat}]$ be the list of existing grants in config.json, and $M_{\text{prev}}[\text{cat}]$ be the grants recorded as managed by the previous setup transaction in state.json. The developer's custom rules $U[\text{cat}]$ are computed as: $$U[\text{cat}] = E[\text{cat}] \setminus M_{\text{prev}}[\text{cat}]$$
When transitioning across profiles (e.g., from read-only where local_changes.txt is denied, to local-changes where it is allowed), rules moving into $M_{\text{target}}[\text{cat}]$ must not be blocked by lingering entries in opposing categories: $$\forall \text{cat} \in {\text{allow}, \text{deny}, \text{ask}}, \forall r \in M_{\text{target}}[\text{cat}], \forall \text{other} \neq \text{cat}: \quad U[\text{other}] \leftarrow U[\text{other}] \setminus {r}$$
Rules previously managed that are no longer part of $M_{\text{target}}[\text{cat}]$ (i.e., $r \in M_{\text{prev}}[\text{cat}] \setminus M_{\text{target}}[\text{cat}]$) are automatically retired and excluded from the final grants.
The final grant list $\text{final}[\text{cat}]$ combines user custom rules and target managed rules, preserving deterministic ordering: $$\text{final}[\text{cat}] = U[\text{cat}] \cup M_{\text{target}}[\text{cat}]$$
Grant entries in config.json must reliably match how AI agents and developers invoke commands from shells or subagents. lib/permissions.py expands human-readable lines into robust grant variants:
Commands frequently execute with leading environment variables (e.g. GIT_PAGER=cat git status). Regexes prepend ENV_VARS_PREFIX_PATTERN:
ENV_VARS_PREFIX_PATTERN = rf"([A-Za-z_][A-Za-z0-9_]*={_ARG_VALUE_PATTERN}\s+)*"
Git commands allow global flags before the subcommand (e.g. git -C //src status) and flags anywhere in the argument list (e.g. git push origin main --force vs git push -f origin HEAD):
GIT_GLOBAL_FLAGS_PATTERN = ( rf"(\s+(-C\s+{_ARG_VALUE_PATTERN}" rf"|--no-pager|--no-color|--literal-pathspecs|--no-optional-locks|-c\s+{_ARG_VALUE_PATTERN}))*" )
Fuchsia wrapper tools emit direct regexes matching any binary path (fx, scripts/fx, /abs/path/fx) and supported global flags:
fx: Handles -t <target>, --dir <out_dir>, --enable=..., --disable=..., -x, -xx, -i, --.ffx: Handles standalone ffx as well as chained fx [flags] ffx [flags], including --machine json, -t <target>, -c <config>, -v, and --isolate-dir.jiri: Handles -j <N>, -root <dir>, -color <mode>, -time, -v, -vv, and --show-progress.Sed in-place invocations (-i, -i.bak, --in-place) can execute with combined flags (e.g., sed -Ei '...'). The generator creates specialized regexes matching any -i flag variant:
pattern = ( f"command(regex:{ENV_VARS_PREFIX_PATTERN}(\\S+/)?sed\\b" f"(?:\\s+{_ARG_VALUE_PATTERN})*\\s+(-[a-zA-Z]*i\\S*|--in-place(\\S*)?)(?:\\s+.*)?)" )
For general binaries (e.g. grep, jq):
shutil.which./usr/bin/ $\leftrightarrow$ /bin/ aliases if both paths exist.$PATH are allowed.Fuchsia uses a multi-repository structure managed by jiri. fx agents implements transparent overlay discovery across public and vendor trees:
def find_config_dirs(fuchsia_dir: pathlib.Path) -> list[pathlib.Path]: candidates = [fuchsia_dir / ".agents" / "config"] vendor_dir = fuchsia_dir / "vendor" if vendor_dir.is_dir(): for vendor_child in sorted(vendor_dir.iterdir()): if vendor_child.is_dir(): cfg_dir = vendor_child / ".agents" / "config" if cfg_dir.is_dir(): candidates.append(cfg_dir) return candidates
find_permission_dirs aggregates all permissions/ directories. Manifests with matching filenames across public and vendor overlays are concatenated.services.txt files across all overlays are parsed and aggregated in order.tools/agents/BUILD.gn)The package is structured as a host Python library and individual host unit tests:
import("//build/python/python_host_test.gni") import("//build/python/python_library.gni") _agents_sources = [ "__init__.py", "commands/__init__.py", "commands/setup.py", "lib/__init__.py", "lib/config.py", "lib/permissions.py", "lib/services.py", "lib/state.py", "main.py", ] if (is_host) { python_library("agents_lib") { library_name = "agents" source_root = "." sources = _agents_sources } python_host_test("main_test") { main_source = "tests/main_test.py" libraries = [ ":agents_lib" ] } # ... }
fx test main_test setup_test config_test permissions_test services_test state_test
To add a new subcommand fx agents <subcommand>:
tools/agents/commands/<subcommand>.py.import argparse def register_subcommand( subparsers: argparse._SubParsersAction[argparse.ArgumentParser], ) -> argparse.ArgumentParser: parser = subparsers.add_parser("<subcommand>", help="...") parser.set_defaults(func=run) return parser def run(args: argparse.Namespace) -> int: # Execution logic return 0
main.py:from agents.commands import <subcommand>, setup def create_parser() -> argparse.ArgumentParser: # ... setup.register_subcommand(subparsers) <subcommand>.register_subcommand(subparsers) return parser
BUILD.gn: Add "commands/<subcommand>.py" to _agents_sources in tools/agents/BUILD.gn.tools/agents/tests/<subcommand>_test.py and declare python_host_test("<subcommand>_test") in BUILD.gn.