ctrlrelay 0.26.0__tar.gz → 0.27.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/CHANGELOG.md +12 -1
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/PKG-INFO +1 -1
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/configuration.md +12 -8
- ctrlrelay-0.27.0/docs/control-plane-brain.md +350 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/registered-mode.md +120 -63
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/pyproject.toml +1 -1
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/cli.py +1 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/config.py +61 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/poller.py +24 -8
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/waiting.py +1 -0
- ctrlrelay-0.27.0/tests/test_allowed_accounts.py +159 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_waiting.py +15 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.gitignore +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/LICENSE +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/README.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/SECURITY.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/cli.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/development.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/index.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/llms.txt +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/operations.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/blockers.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/budget.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/intake_hold.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/issue_questions.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/orphans.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/proof.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/reopens.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/self_update.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/slots.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/stuck.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/weekly_summary.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/personalization/merge.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/drift.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/numbers.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/static/index.html +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/status.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_budget.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_plan_step.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_step_switches.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_stuck.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_work_steps.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_events.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_intake_hold.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_intake_hold_poller.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_issue_blockers.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_issue_questions.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_max_parallel.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_no_change_run.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_orphans.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_personalization_merge.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_poller_priority_order.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_poller_recheck.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_proof.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_question_survives_restart.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_relay_git_263.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_reopens.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_review_copy.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_review_gate.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_run_resume.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_sandbox_visibility.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_secops_operator_prs.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_self_update.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_stuck.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_timeout_save.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_drift.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_numbers.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_peer_links.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_server.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_weekly_summary.py +0 -0
- {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.27.0] - 2026-11-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- An allowlist of accounts that can hand the relay work (#374).
|
|
15
|
+
`intake.allowed_accounts` lists GitHub logins, besides the relay's own
|
|
16
|
+
account, whose assignment to the relay or whose trigger label starts a
|
|
17
|
+
run. A repo can replace the list with `automation.allowed_accounts`.
|
|
18
|
+
Empty by default, so nothing changes until you set it.
|
|
19
|
+
|
|
10
20
|
## [0.26.0] - 2026-11-06
|
|
11
21
|
|
|
12
22
|
### Added
|
|
@@ -2640,7 +2650,8 @@ pipeline).
|
|
|
2640
2650
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2641
2651
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2642
2652
|
|
|
2643
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
2653
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.27.0...HEAD
|
|
2654
|
+
[0.27.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.26.0...v0.27.0
|
|
2644
2655
|
[0.26.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.25.0...v0.26.0
|
|
2645
2656
|
[0.25.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.24.0...v0.25.0
|
|
2646
2657
|
[0.24.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.23.0...v0.24.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.27.0
|
|
4
4
|
Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
|
|
5
5
|
Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
|
|
6
6
|
Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
|
|
@@ -593,17 +593,19 @@ questions agents asked. **Never expose it to the internet.**
|
|
|
593
593
|
|
|
594
594
|
## intake
|
|
595
595
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
that batch.
|
|
596
|
+
Who can hand the relay work (#374), and the order each poll's new issues run
|
|
597
|
+
in (#356). A poll gathers one batch of new issues and works through it before
|
|
598
|
+
the next poll; `priority_labels` sets the order of that batch.
|
|
599
599
|
|
|
600
600
|
```yaml
|
|
601
601
|
intake:
|
|
602
|
+
allowed_accounts: ["teammate-1", "teammate-2"]
|
|
602
603
|
priority_labels: ["P0", "P1", "P2", "P3"]
|
|
603
604
|
```
|
|
604
605
|
|
|
605
606
|
| Key | Type | Default | Description |
|
|
606
607
|
|---|---|---|---|
|
|
608
|
+
| `allowed_accounts` | list of GitHub logins | `[]` | Accounts, besides the relay's own (the account its `gh` token belongs to), that can hand it work: an issue assigned to the relay's account **by** one of them is taken, and a trigger label from `include_labels` **added by** one of them starts a run. Logins match without case; each must be a real login (no `@`) and listed once, or the config refuses to load. `[]` keeps today's rule: only the relay's own account. A repo's `automation.allowed_accounts` replaces this list for that repo. `accept_foreign_assignments: true` still takes an assignment from anyone. |
|
|
607
609
|
| `priority_labels` | list of strings | `["P0", "P1", "P2", "P3"]` | Issues carrying an earlier label in this list run first; labels are compared without case, and an issue carrying several runs at its earliest. Issues carrying none of them run last. Within the same label, the oldest issue runs first (by when it was opened, then by number). An issue whose creation time cannot be read runs after the others with its label. `[]` keeps the oldest-first order only. Each label must be a non-empty string and listed once. |
|
|
608
610
|
|
|
609
611
|
Each poll that found new issues logs the order it will run them in, as one
|
|
@@ -783,7 +785,8 @@ and ask the operator), or `never` (skip).
|
|
|
783
785
|
| `secret_alerts` | `never` | Secret-scan alerts. |
|
|
784
786
|
| `deploy_after_merge` | `auto` | Whether to deploy after a merged PR. |
|
|
785
787
|
| `operator_prs` | `ask` | `ask` or `ignore`. What the secops sweep does with your own open PRs that touch more than `.github/dependabot.yml`. See [operator_prs](#reposautomationoperator_prs) below. |
|
|
786
|
-
| `accept_foreign_assignments` | `false` | When `true`, the poller also picks up issues assigned to you by
|
|
788
|
+
| `accept_foreign_assignments` | `false` | When `true`, the poller also picks up issues assigned to you by anyone else. Default (`false`) runs the dev pipeline only on issues you self-assigned, or that an account in `allowed_accounts` assigned to you. |
|
|
789
|
+
| `allowed_accounts` | `null` | Accounts whose assignment or trigger label hands this repo's issues to the relay, besides the relay's own account. `null` uses [`intake.allowed_accounts`](#intake); a list, even `[]`, replaces it for this repo. |
|
|
787
790
|
| `exclude_labels` | `["manual", "operator", "instruction"]` | Issue labels that tell the poller "this isn't for the agent". See [exclude_labels](#reposautomationexclude_labels) below. |
|
|
788
791
|
| `include_labels` | `[]` | Issue labels that opt an issue **into** the dev pipeline regardless of who is (or isn't) assigned. See [include_labels](#reposautomationinclude_labels) below. |
|
|
789
792
|
| `require_labels` | `[]` | Issue labels that gate the plain-assignment trigger: when set, assignment alone is no longer enough — the issue must also carry one of these labels. See [require_labels](#reposautomationrequire_labels) below. |
|
|
@@ -879,10 +882,11 @@ repos:
|
|
|
879
882
|
- An issue is accepted when **either** (a) it's assigned to the configured
|
|
880
883
|
user (subject to the self-assignment filter from #79 and
|
|
881
884
|
`accept_foreign_assignments`) **or** (b) it carries any label in
|
|
882
|
-
`include_labels` **that the configured user
|
|
883
|
-
`labeled` event for that label
|
|
884
|
-
does not widen this). A
|
|
885
|
-
|
|
885
|
+
`include_labels` **that the configured user, or an account in
|
|
886
|
+
`allowed_accounts`, added** (the latest `labeled` event for that label
|
|
887
|
+
names one of them; `accept_foreign_assignments` does not widen this). A
|
|
888
|
+
label anyone else added does not start a run; the issue is still taken
|
|
889
|
+
if the user also assigned it to themselves.
|
|
886
890
|
- Dedup: an issue that is **both** labeled and assigned is picked up exactly
|
|
887
891
|
once per poll cycle — no duplicate entries in `seen_issues` and no double
|
|
888
892
|
pipeline spawn.
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Control plane as the brain (design)
|
|
3
|
+
layout: default
|
|
4
|
+
nav_order: 13
|
|
5
|
+
description: "Design: how the control plane holds knowledge, memory, roles and credentials for every node, so a node can start clean, and how that is kept safe."
|
|
6
|
+
permalink: /control-plane-brain/
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# The control plane as the brain: design
|
|
10
|
+
|
|
11
|
+
**Status: agreed 2026-11-06, not built.** This is issue #372. It builds on
|
|
12
|
+
[Registered mode]({{ '/registered-mode/' | relative_url }}) (#251), which
|
|
13
|
+
fixes the protocol between a node and the control plane. That protocol was
|
|
14
|
+
first written on the assumption that the control plane never puts text in
|
|
15
|
+
front of an agent and never holds a credential. This page drops that
|
|
16
|
+
assumption, on purpose, and says what replaces it. The owner's decisions are at the end, under "Decisions".
|
|
17
|
+
|
|
18
|
+
## The idea in one paragraph
|
|
19
|
+
|
|
20
|
+
The control plane holds what the factory **knows** (context, learnings,
|
|
21
|
+
decisions, history), what each kind of work **is** (roles, with their
|
|
22
|
+
instructions, skills and agent settings) and, where a role needs it, the
|
|
23
|
+
**credentials** for that work. A node holds the **runtime**: ctrlrelay, the
|
|
24
|
+
agent CLI and its login, the sandbox, its own GitHub token and its key. A
|
|
25
|
+
node with an empty cache registers, takes an assignment, fetches what that
|
|
26
|
+
assignment names, and runs. Losing a node loses no knowledge.
|
|
27
|
+
|
|
28
|
+
**The safety rule that makes this possible: anything that changes what an
|
|
29
|
+
agent is told, or what it can reach, is signed by the operator, with a key
|
|
30
|
+
the control plane server does not hold.** The control plane stores and hands
|
|
31
|
+
out that content; it cannot author it. Everything else it sends is data, and
|
|
32
|
+
a node shows data to an agent only as data.
|
|
33
|
+
|
|
34
|
+
## What exists today, and what it becomes
|
|
35
|
+
|
|
36
|
+
| Today, on each node | Becomes |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `<paths.contexts>/<owner-repo>/CLAUDE.md`, linked into each worktree | **Repo context**, a signed knowledge entry per repo |
|
|
39
|
+
| Skills under `paths.skills`, synced by [personalization]({{ '/personalization/' | relative_url }}) | Skills inside a signed **role** bundle |
|
|
40
|
+
| `agent:` block in each node's config (type, model, sandbox paths) | Agent settings inside a role, within the node's own limits |
|
|
41
|
+
| Per-repo learning (#225), planned in the repo on GitHub (decision 5 of #251) | **Learnings**, proposed by nodes, approved and signed by the operator |
|
|
42
|
+
| Plans saved under the contexts directory (#220) | **History**: plans, run summaries and outcomes, stored as data |
|
|
43
|
+
| Third-party secrets in the node's environment | **Role credentials**, sealed to one node for one assignment |
|
|
44
|
+
|
|
45
|
+
Personalization sync and the contexts directory keep working in standalone
|
|
46
|
+
mode. A registered node with brain content enabled ignores them for runs
|
|
47
|
+
that carry a role.
|
|
48
|
+
|
|
49
|
+
## Three kinds of content
|
|
50
|
+
|
|
51
|
+
Everything the control plane holds falls in one of three kinds. The kind
|
|
52
|
+
decides how it is protected and how an agent sees it.
|
|
53
|
+
|
|
54
|
+
| Kind | Examples | Signed by the operator | How the agent sees it |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| **Instructions** | roles, skills, repo context, approved learnings, decisions, config | yes | as instructions, in the prompt or as skill files |
|
|
57
|
+
| **Data** | run history, plans, summaries of earlier runs, proposed learnings, numbers | no | inside a fenced block marked as data, never as instructions, like issue text today |
|
|
58
|
+
| **Secrets** | role credentials | no (sealed instead, see "Credentials") | never: only the step that needs one gets it, as an environment variable |
|
|
59
|
+
|
|
60
|
+
The protocol content from #251 (assignments, commands, leases) is data too:
|
|
61
|
+
it says **what** to work on, never **how**.
|
|
62
|
+
|
|
63
|
+
### Signing
|
|
64
|
+
|
|
65
|
+
- The operator holds an **operator key** (Ed25519) off the control plane
|
|
66
|
+
server: on a hardware key or the operator's own machine.
|
|
67
|
+
- The node pins the operator's public keys in its **local** `control_plane`
|
|
68
|
+
block, which the control plane can never change (decision 6 of #251).
|
|
69
|
+
- Every instruction item is a **bundle**: a JSON manifest plus files, named
|
|
70
|
+
by the SHA-256 of its content, with `name`, `version`, `not_after` and the
|
|
71
|
+
operator's signature over all of it.
|
|
72
|
+
- A node refuses a bundle that is unsigned, signed by a key it does not pin,
|
|
73
|
+
past `not_after`, revoked, or below the floor (next point). A refused
|
|
74
|
+
bundle fails the assignment with `bundle_refused`, which the node reports
|
|
75
|
+
and posts in chat. It never runs without it.
|
|
76
|
+
- **The policy list** stops replays. It is one small signed document: a
|
|
77
|
+
sequence number, a `not_after` (default 7 days), the **minimum version**
|
|
78
|
+
of each bundle name and of the config, and the revoked digests. The node
|
|
79
|
+
fetches it with the heartbeat and keeps the highest sequence number it has
|
|
80
|
+
seen; it refuses a list with a lower number or past its `not_after`.
|
|
81
|
+
Because the floor is signed, a wiped node that remembers nothing still
|
|
82
|
+
refuses an old role or config. The operator re-signs the list at least
|
|
83
|
+
every `not_after` (one `ctrlrelay` command on the operator's machine),
|
|
84
|
+
and whenever a bundle is revoked or a floor is raised.
|
|
85
|
+
- No valid policy list (unreachable, or expired) is "could not check", not
|
|
86
|
+
"nothing revoked": the node finishes runs already going, and takes no new
|
|
87
|
+
assignment with a role, and applies no new pushed config, until it has a
|
|
88
|
+
valid list again. It keeps the last good config meanwhile.
|
|
89
|
+
- **Known limit:** a node wiped of its state remembers no sequence number, so
|
|
90
|
+
until its first valid list it can be handed an older one that has not yet
|
|
91
|
+
expired. The window is the list's `not_after`, at most 7 days by default;
|
|
92
|
+
a shorter `not_after` narrows it at the cost of re-signing more often.
|
|
93
|
+
|
|
94
|
+
Signing is the approval step. The operator approves a role change, a context
|
|
95
|
+
edit or a learning in the control plane's UI, the UI asks the operator's key
|
|
96
|
+
to sign it, and only then does it become something a node will use.
|
|
97
|
+
|
|
98
|
+
## Knowledge and memory
|
|
99
|
+
|
|
100
|
+
**What is stored, per repo:**
|
|
101
|
+
|
|
102
|
+
- **Context:** what `CLAUDE.md` in the contexts directory holds today: how
|
|
103
|
+
the repo is built and tested, its rules, its layout. Instructions.
|
|
104
|
+
- **Learnings:** facts from real runs (#225): build and test commands that
|
|
105
|
+
worked, flaky tests, common failure causes. Each one carries its evidence:
|
|
106
|
+
the runs, by assignment id, and the result that showed it. Instructions,
|
|
107
|
+
once approved.
|
|
108
|
+
- **Decisions:** design decisions an agent should respect, with a link to
|
|
109
|
+
where they were made (an issue, a doc). Instructions.
|
|
110
|
+
- **History:** per run, the assignment, outcome, PR number, phase reached and
|
|
111
|
+
a short scrubbed summary written by the node. Data.
|
|
112
|
+
|
|
113
|
+
**Who writes it:**
|
|
114
|
+
|
|
115
|
+
- The **operator** writes context and decisions directly.
|
|
116
|
+
- **Nodes** propose learnings, from measured results only, never because an
|
|
117
|
+
agent said so, as #225 asks. A proposal goes to the control plane as data.
|
|
118
|
+
- **Agents** may suggest a learning in their checkpoint. The node forwards it
|
|
119
|
+
as a proposal marked "agent suggestion", with the run it came from.
|
|
120
|
+
- Nothing proposed reaches a later run until the operator approves it (see
|
|
121
|
+
"Decisions", decision 4).
|
|
122
|
+
|
|
123
|
+
**How it reaches a run.** The assignment names a **knowledge snapshot** for
|
|
124
|
+
the repo: one signed bundle holding the context, approved learnings and
|
|
125
|
+
decisions, at a version. The node assembles the prompt as it does today, and
|
|
126
|
+
puts the snapshot in its own labelled section. Recent history for the issue
|
|
127
|
+
and repo (for example the last three runs on this issue) goes in a separate
|
|
128
|
+
fenced section marked as data. Both have a size cap, and the node, not the
|
|
129
|
+
control plane, decides where they sit in the prompt.
|
|
130
|
+
|
|
131
|
+
**How stale entries go:**
|
|
132
|
+
|
|
133
|
+
- A learning names what would contradict it (for example, "the test command
|
|
134
|
+
is X": a later run where X fails for a reason other than the code). A node
|
|
135
|
+
that measures a contradiction proposes dropping it, with its evidence.
|
|
136
|
+
- Learnings not confirmed by a run in 90 days are flagged for the operator,
|
|
137
|
+
not dropped silently.
|
|
138
|
+
- Dropping or editing an approved entry is a new signed snapshot, so every
|
|
139
|
+
run can say which version it used. The run's report carries the snapshot
|
|
140
|
+
digest.
|
|
141
|
+
|
|
142
|
+
## Roles
|
|
143
|
+
|
|
144
|
+
A **role** is a signed bundle that says what a node is asked to be for one
|
|
145
|
+
piece of work:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"name": "builder",
|
|
150
|
+
"version": 7,
|
|
151
|
+
"pipelines": ["dev"],
|
|
152
|
+
"instructions": "role.md",
|
|
153
|
+
"skills": ["skills/tdd/SKILL.md", "skills/review/SKILL.md"],
|
|
154
|
+
"agent": {"type": "claude", "model": "default", "max_turns": 200},
|
|
155
|
+
"sandbox": {"extra_readonly": [], "extra_writable": [], "network": "default"},
|
|
156
|
+
"credentials": ["sentry-read"],
|
|
157
|
+
"repos": ["owner/app", "owner/lib"],
|
|
158
|
+
"not_after": 1800000000
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
First roles, matching what the relay already does: **builder** (the dev
|
|
163
|
+
pipeline), **reviewer** (the review step), **triager** (#227), **planner**
|
|
164
|
+
(the plan step, #220). More are added by the operator, not by code.
|
|
165
|
+
|
|
166
|
+
**How a node takes one on.** The assignment from #251 gains two fields:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
"role": {"name": "builder", "digest": "sha256:4be1..."},
|
|
170
|
+
"knowledge": {"repo": "owner/app", "digest": "sha256:91c0..."}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The node fetches both by digest (`GET /v1/bundles/<digest>`), unless cached,
|
|
174
|
+
verifies them, and checks both against the assignment: the pipeline and
|
|
175
|
+
repo must be in the role's lists, and the knowledge snapshot must be for the
|
|
176
|
+
assignment's repo. It writes the skills into a fresh
|
|
177
|
+
directory for this run only, outside `~/.claude/skills`, and points the agent
|
|
178
|
+
at it. The directory is removed when the run ends. Nothing a role brings
|
|
179
|
+
outlives its run.
|
|
180
|
+
|
|
181
|
+
**What a node still refuses.** Each node keeps a local `role_limits` block,
|
|
182
|
+
which the control plane cannot change:
|
|
183
|
+
|
|
184
|
+
```yaml
|
|
185
|
+
control_plane:
|
|
186
|
+
operator_keys: ["ed25519:...", "ed25519:..."]
|
|
187
|
+
role_limits:
|
|
188
|
+
agents: [claude] # agent types a role may ask for
|
|
189
|
+
max_extra_writable: [] # paths a role may add as writable; empty = none
|
|
190
|
+
network: [default] # sandbox network modes a role may ask for
|
|
191
|
+
credentials: true # whether this node accepts role credentials
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A role that asks for more than these limits allow is refused
|
|
195
|
+
(`role_exceeds_limits`), even when signed. The signature says the operator
|
|
196
|
+
wrote it; the limits say what this machine allows. The sandbox from #219
|
|
197
|
+
still applies to every run.
|
|
198
|
+
|
|
199
|
+
## Starting clean
|
|
200
|
+
|
|
201
|
+
**What a node keeps locally, and why:**
|
|
202
|
+
|
|
203
|
+
| Kept on the node | Why |
|
|
204
|
+
|---|---|
|
|
205
|
+
| ctrlrelay, the agent CLI, bwrap | the runtime; the control plane sends no code |
|
|
206
|
+
| the agent login | it is an account, not a role credential (decision 3) |
|
|
207
|
+
| its GitHub token | the node reads issues and opens PRs with its own access (#251, rule 3) |
|
|
208
|
+
| node key, `node_id`, the `control_plane` block with `operator_keys` and `role_limits` | without them the node cannot reach the control plane, prove who it is, or check what it is sent |
|
|
209
|
+
| the state database, worktrees, the report queue | work in progress; claims on GitHub say who holds an issue |
|
|
210
|
+
| a bundle cache, by digest | speed only; safe to delete at any time |
|
|
211
|
+
|
|
212
|
+
**What it gets:** with its first heartbeat after registering, the signed
|
|
213
|
+
config (decision 6 of #251) and the policy list; at each assignment, the role and knowledge snapshot it
|
|
214
|
+
names, and any role credentials.
|
|
215
|
+
|
|
216
|
+
**What losing a node costs:** its runs in progress (their claims go stale and
|
|
217
|
+
another node takes over, as #251 describes) and any reports still queued on
|
|
218
|
+
disk. To keep that small, a node sends a learning proposal as soon as it has
|
|
219
|
+
one, not at the end of the run.
|
|
220
|
+
|
|
221
|
+
**The test for "starts clean":** take a node that has run an issue, delete
|
|
222
|
+
everything but the runtime, the agent login, the GitHub token and the local
|
|
223
|
+
`control_plane` block, register it again, and run the same issue. Its prompt
|
|
224
|
+
holds the same knowledge section, byte for byte, for the same snapshot
|
|
225
|
+
digest.
|
|
226
|
+
|
|
227
|
+
## Credentials
|
|
228
|
+
|
|
229
|
+
**Two classes, kept apart:**
|
|
230
|
+
|
|
231
|
+
- **Node credentials:** the node key, the GitHub token, the agent login.
|
|
232
|
+
They never leave the node, as #251 rule 3 says today.
|
|
233
|
+
- **Role credentials:** secrets a role needs for its work, for example a
|
|
234
|
+
read token for an error tracker, a package registry token, a scanner's API
|
|
235
|
+
key. These live on the control plane.
|
|
236
|
+
|
|
237
|
+
**How one moves:**
|
|
238
|
+
|
|
239
|
+
1. The role names it (`"credentials": ["sentry-read"]`). A node takes only
|
|
240
|
+
credentials named by the signed role it is running, and only if its
|
|
241
|
+
`role_limits.credentials` is true.
|
|
242
|
+
2. At enrolment (`ctrlrelay node enroll`) the node also makes a second key
|
|
243
|
+
pair, X25519, for receiving secrets, and sends its public half at
|
|
244
|
+
register. The control plane seals each credential to that one node's key,
|
|
245
|
+
for that one assignment (`assignment_id` and an expiry inside the sealed
|
|
246
|
+
box). Nothing in transit or in the control plane's logs is readable.
|
|
247
|
+
3. The node opens it, gives it only to the step that needs it, as an
|
|
248
|
+
environment variable, and adds the exact value to the run's redaction
|
|
249
|
+
list so it is scrubbed from every log, report and chat line.
|
|
250
|
+
4. It is never written into the prompt, the worktree or a file on disk, and
|
|
251
|
+
it is dropped when the run ends, on release, and on revocation.
|
|
252
|
+
|
|
253
|
+
**Scope and life.** Prefer a token the control plane mints per assignment
|
|
254
|
+
(scoped to one repo, living as long as the lease) over a long-lived secret.
|
|
255
|
+
A long-lived secret is allowed, with its scope written in the role, and is
|
|
256
|
+
rotated at its source when a node holding it is revoked.
|
|
257
|
+
|
|
258
|
+
**What this does not cover:** the GitHub token and the agent login. They
|
|
259
|
+
stay on the node for now (decision 3). That may change: some tokens may be
|
|
260
|
+
better held centrally, and one shape for that is the control plane acting as
|
|
261
|
+
a hub, where the agent asks it to perform an action and it acts with a token
|
|
262
|
+
the node never sees. That needs its own design before it is built.
|
|
263
|
+
|
|
264
|
+
## Threat model
|
|
265
|
+
|
|
266
|
+
Registered mode's threat model assumed a compromised control plane could
|
|
267
|
+
learn little and steer nothing. With the brain, the control plane holds the
|
|
268
|
+
factory's instructions and some of its secrets, so protecting it becomes the
|
|
269
|
+
main security job. Three attackers:
|
|
270
|
+
|
|
271
|
+
**1. Someone controls the control plane server, but not the operator key.**
|
|
272
|
+
|
|
273
|
+
| They can | They cannot, because |
|
|
274
|
+
|---|---|
|
|
275
|
+
| read all knowledge, history and roles | (nothing stops this; it is not secret from the server) |
|
|
276
|
+
| read role credentials it holds | (nothing stops this; keep them scoped and short-lived) |
|
|
277
|
+
| withhold work, route it badly, pause nodes | (as in #251) |
|
|
278
|
+
| insert false history | history is data, fenced, like issue text |
|
|
279
|
+
| | change a role, a skill, context or an approved learning: no operator signature |
|
|
280
|
+
| | hand an agent a new instruction: only signed content is shown as instructions |
|
|
281
|
+
| | roll a node back to an older, weaker role or config: the signed policy list sets a floor, and a node refuses a list older than one it has seen |
|
|
282
|
+
| | widen what a role may do on a node: `role_limits` is local |
|
|
283
|
+
| | plant a learning: proposals need the operator's approval and signature |
|
|
284
|
+
| | give a node a credential its role does not name |
|
|
285
|
+
|
|
286
|
+
Config is signed too (decision 2), so this attacker cannot change a node's
|
|
287
|
+
repos, sandbox paths or schedules either.
|
|
288
|
+
|
|
289
|
+
**2. Someone holds the operator key.** They control what every agent is told.
|
|
290
|
+
So the key lives off the server, ideally on a hardware key; there can be two
|
|
291
|
+
pinned keys so one can be rotated or revoked without touching every node by
|
|
292
|
+
hand; and every signature is logged with what it signed.
|
|
293
|
+
|
|
294
|
+
**3. Someone controls one node.** They can read the bundles and credentials
|
|
295
|
+
of the assignments that node gets, and all knowledge for the repos in its
|
|
296
|
+
allowlist. They can propose false learnings, which the operator then sees
|
|
297
|
+
with the node's name on them. Revoke the node's key, rotate the role
|
|
298
|
+
credentials it held, and the rest carry on.
|
|
299
|
+
|
|
300
|
+
**Protecting the control plane itself:** operator access behind SSO with a
|
|
301
|
+
second factor; an append-only audit log of every approval, signature, config
|
|
302
|
+
push and credential handed out; role credentials encrypted at rest; the
|
|
303
|
+
signing step on the operator's side, never a server job.
|
|
304
|
+
|
|
305
|
+
## Changes to registered mode
|
|
306
|
+
|
|
307
|
+
- **Rule 3** becomes: "Node credentials (the node key, the GitHub token, the
|
|
308
|
+
agent login) never leave the node. Role credentials may come from the
|
|
309
|
+
control plane, sealed to one node for one assignment."
|
|
310
|
+
- **Assignments** gain `role` and `knowledge` digests (above). Without them
|
|
311
|
+
a run behaves exactly as today.
|
|
312
|
+
- **Threat model:** the row "put its own instructions in front of the agent"
|
|
313
|
+
changes to "unless signed with an operator key"; the row "get a credential"
|
|
314
|
+
narrows to node credentials.
|
|
315
|
+
- **Config from the control plane** (decision 6 of #251) must be signed with
|
|
316
|
+
an operator key (decision 2 here). An unsigned config is refused whole.
|
|
317
|
+
- **Non-goal** "the control plane holding or brokering GitHub or agent
|
|
318
|
+
credentials" stays for now (decision 3).
|
|
319
|
+
|
|
320
|
+
## Build order
|
|
321
|
+
|
|
322
|
+
1. **The protocol from #251, as agreed.** Reserve the `role` and
|
|
323
|
+
`knowledge` fields in assignments and the bundle fetch path, so nothing
|
|
324
|
+
here needs it reworked.
|
|
325
|
+
2. **Bundles and signing:** the manifest format, `ctrlrelay` commands for the
|
|
326
|
+
operator to sign, node-side checks, the cache, the revocation list.
|
|
327
|
+
3. **Knowledge:** repo context and approved learnings as snapshots, the
|
|
328
|
+
history section in the prompt, learning proposals from nodes (this is
|
|
329
|
+
#225 moved to the control plane).
|
|
330
|
+
4. **Roles:** builder, reviewer, triager, planner as bundles, `role_limits`,
|
|
331
|
+
per-run skill directories.
|
|
332
|
+
5. **Role credentials:** the second key, sealed delivery, redaction.
|
|
333
|
+
|
|
334
|
+
## Decisions
|
|
335
|
+
|
|
336
|
+
Made by the owner on 2026-11-06 (see #372).
|
|
337
|
+
|
|
338
|
+
1. **Signing:** everything that steers an agent (roles, skills, repo
|
|
339
|
+
context, approved learnings, decisions) is signed by an operator key held
|
|
340
|
+
off the control plane server.
|
|
341
|
+
2. **Config is signed too.** This adds a signature to decision 6 of #251: the
|
|
342
|
+
control plane may still set the full config, but a node applies only a
|
|
343
|
+
config signed with an operator key.
|
|
344
|
+
3. **GitHub token and agent login stay on the node, for now.** A node starts
|
|
345
|
+
clean of knowledge, not of its own accounts. This may change: some tokens
|
|
346
|
+
could be set centrally, possibly with the control plane as a hub that
|
|
347
|
+
performs an action for the agent instead of handing over the token.
|
|
348
|
+
Revisit with its own design.
|
|
349
|
+
4. **Every learning needs the operator's approval**, measured or suggested,
|
|
350
|
+
before a later run sees it.
|