ctrlrelay 0.14.0__tar.gz → 0.15.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.14.0 → ctrlrelay-0.15.0}/CHANGELOG.md +45 -1
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/PKG-INFO +1 -1
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/architecture.md +8 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/configuration.md +79 -3
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/feedback-loop.md +6 -3
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/operations.md +25 -4
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +20 -2
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/pyproject.toml +1 -1
- ctrlrelay-0.15.0/src/ctrlrelay/core/agent_env.py +228 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/config.py +12 -1
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/dispatcher.py +112 -2
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/github.py +57 -5
- ctrlrelay-0.15.0/src/ctrlrelay/core/publish.py +189 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/state.py +45 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/worktree.py +289 -3
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/dev.py +378 -47
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/secops.py +9 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/task.py +123 -26
- ctrlrelay-0.15.0/src/ctrlrelay/web/health.py +167 -0
- ctrlrelay-0.15.0/src/ctrlrelay/web/links.py +35 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/server.py +8 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/static/index.html +218 -11
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/status.py +143 -34
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/transcript.py +49 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/waiting.py +118 -14
- ctrlrelay-0.15.0/tests/_publish_fakes.py +25 -0
- ctrlrelay-0.15.0/tests/test_agent_no_keys.py +1229 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_claim_reconciliation.py +26 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dev_integration.py +23 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dev_pipeline.py +62 -70
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dispatch_outcome_hook.py +28 -14
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dispatcher.py +38 -19
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_event_emission.py +18 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_relay_alert_off_lock.py +2 -1
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_resume_prompt_contract.py +14 -4
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_task_pipeline.py +10 -0
- ctrlrelay-0.15.0/tests/test_web_health.py +228 -0
- ctrlrelay-0.15.0/tests/test_web_links.py +60 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_web_server.py +226 -2
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_web_status.py +369 -9
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_web_transcript.py +96 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_web_waiting.py +266 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_worktree.py +0 -23
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.gitignore +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/LICENSE +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/README.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/SECURITY.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/cli.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/development.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/index.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_events.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_unreviewed_pr_announcement.py +0 -0
|
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.15.0] - 2026-10-31
|
|
11
|
+
|
|
12
|
+
The dev and task agents no longer inherit the usual GitHub credentials;
|
|
13
|
+
ctrlrelay does the pushing, PR opening and commenting itself
|
|
14
|
+
after the agent exits. One new
|
|
15
|
+
database table is added automatically and the new config values have
|
|
16
|
+
defaults, so **no config edit is required** to upgrade.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Dev and task agents run with inherited `GH_*` and `GITHUB_*` variables
|
|
21
|
+
and gh's login removed from their environment, git credential helpers and
|
|
22
|
+
git's SSH disabled, and the D-Bus and runtime-dir variables pointed away
|
|
23
|
+
from the desktop keyring. After the agent exits the
|
|
24
|
+
relay pushes the run branch from a relay-private git dir to an explicit
|
|
25
|
+
GitHub URL, opens the PR from the DONE checkpoint (or reuses an open PR on the run
|
|
26
|
+
branch), pushes fix rounds,
|
|
27
|
+
fetches failed CI logs and posts task reports. It refuses to push the
|
|
28
|
+
default branch and only overwrites a remote branch whose head it recorded
|
|
29
|
+
pushing. secops keeps GitHub access (#235, closes #219)
|
|
30
|
+
- Status page: the phase bar shows the furthest phase seen in the recent
|
|
31
|
+
transcript and pipeline events, CI waits are
|
|
32
|
+
shown as waiting rather than reading, and the PR is linked (#239, closes
|
|
33
|
+
#238)
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- Optional `agent.sandbox: bwrap` with `agent.sandbox_hide_paths` to run the
|
|
38
|
+
dev and task agents under bubblewrap with credential paths hidden. Off by
|
|
39
|
+
default (#235)
|
|
40
|
+
- A `relay_pushes` table recording the heads ctrlrelay pushed (#235)
|
|
41
|
+
- Status page: a live counter and dot, server health, GitHub links, and the
|
|
42
|
+
list of skipped issues (#233, closes #232)
|
|
43
|
+
|
|
44
|
+
### Known limits
|
|
45
|
+
|
|
46
|
+
- The agent still runs as the same OS user. Without the bwrap sandbox it
|
|
47
|
+
can read credential files on disk; with it, only the paths in
|
|
48
|
+
`agent.sandbox_hide_paths` are hidden. It can still run ssh directly, reach
|
|
49
|
+
the Linux keyring by resetting the D-Bus address, or read a token kept
|
|
50
|
+
under some other variable name, and the macOS keychain is not covered. Running the agent as a separate user
|
|
51
|
+
is tracked in #236
|
|
52
|
+
|
|
10
53
|
## [0.14.0] - 2026-10-31
|
|
11
54
|
|
|
12
55
|
A read-only status page. `state.db` switches to WAL mode and gains an
|
|
@@ -1796,7 +1839,8 @@ pipeline).
|
|
|
1796
1839
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1797
1840
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1798
1841
|
|
|
1799
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
1842
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.15.0...HEAD
|
|
1843
|
+
[0.15.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.14.0...v0.15.0
|
|
1800
1844
|
[0.14.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.13.0...v0.14.0
|
|
1801
1845
|
[0.13.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.4...v0.13.0
|
|
1802
1846
|
[0.12.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...v0.12.4
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.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/
|
|
@@ -70,6 +70,14 @@ is the only place ctrlrelay calls out to Claude Code.
|
|
|
70
70
|
- **Environment additions:** `CTRLRELAY_SESSION_ID` and `CTRLRELAY_STATE_FILE`
|
|
71
71
|
are the only contract between dispatcher and child. Anything else the agent
|
|
72
72
|
needs (issue body, PR rules, etc.) goes into the prompt.
|
|
73
|
+
- **Credentials (#219):** every spawn says whether it gets GitHub access
|
|
74
|
+
(`spawn_session(..., github_access=...)`, no default). dev and task spawn
|
|
75
|
+
without it: GitHub tokens, `gh`'s login, git credential helpers, SSH and
|
|
76
|
+
the desktop keyring are cut off (`core/agent_env.py`), and ctrlrelay
|
|
77
|
+
pushes the branch, opens the PR or posts the report itself afterwards
|
|
78
|
+
(`core/publish.py`, `WorktreeManager.push_run_branch`). secops still
|
|
79
|
+
spawns with access. See
|
|
80
|
+
[What the agent can reach](configuration.md#what-the-agent-can-reach-219).
|
|
73
81
|
- **Result:** `SessionResult(session_id, exit_code, state, stdout, stderr,
|
|
74
82
|
agent_session_id)`. `session_id` is our composite orchestrator id;
|
|
75
83
|
`agent_session_id` is Claude's own UUID, parsed from the JSON stdout and
|
|
@@ -88,7 +88,78 @@ claude:
|
|
|
88
88
|
|
|
89
89
|
ctrlrelay always invokes claude with `--dangerously-skip-permissions` so the
|
|
90
90
|
agent does not pause on tool-permission prompts in headless runs. This is
|
|
91
|
-
intentional — the orchestrator runs unattended.
|
|
91
|
+
intentional — the orchestrator runs unattended. The CLI's other unattended
|
|
92
|
+
modes either need every shell command allow-listed or filter by command text,
|
|
93
|
+
which a wrapper script walks past, so they are not used as a guard. The guard
|
|
94
|
+
is the next section: the agent holds no GitHub credentials.
|
|
95
|
+
|
|
96
|
+
### What the agent can reach (#219)
|
|
97
|
+
|
|
98
|
+
The dev and task agents run **without GitHub access**. ctrlrelay starts them
|
|
99
|
+
with:
|
|
100
|
+
|
|
101
|
+
- every `GH_*` and `GITHUB_*` variable removed (`GH_TOKEN`, `GITHUB_TOKEN`,
|
|
102
|
+
`GH_ENTERPRISE_TOKEN`, ...), and every inherited `CTRLRELAY_*` variable
|
|
103
|
+
(the chat bot tokens) except the two the checkpoint needs;
|
|
104
|
+
- `GH_CONFIG_DIR` pointed at a fresh empty directory, so `gh` finds no login;
|
|
105
|
+
- `DBUS_SESSION_BUS_ADDRESS` pointed at nothing, so `gh` and other libsecret
|
|
106
|
+
clients cannot fall back to the desktop keyring;
|
|
107
|
+
- `credential.helper` reset to empty (through `GIT_CONFIG_COUNT`),
|
|
108
|
+
`GIT_SSH_COMMAND=false` and `SSH_AUTH_SOCK` removed, so git cannot
|
|
109
|
+
authenticate over HTTPS or SSH, including through an `insteadOf` rewrite;
|
|
110
|
+
- `GIT_TERMINAL_PROMPT=0`, so nothing waits on a prompt.
|
|
111
|
+
|
|
112
|
+
Everything else is inherited unchanged, so the model CLI and the project's
|
|
113
|
+
toolchain behave as before. ctrlrelay then does the GitHub side itself, with
|
|
114
|
+
its own credentials, after the agent exits:
|
|
115
|
+
|
|
116
|
+
- **dev:** the agent commits on the run's branch and writes a PR title and
|
|
117
|
+
description in its DONE checkpoint. ctrlrelay pushes the branch and opens
|
|
118
|
+
the pull request. After a CI or conflict fix round it pushes again, and it
|
|
119
|
+
fetches the failed-job logs the agent used to read with `gh run view`.
|
|
120
|
+
- **task:** the agent writes its report in its DONE checkpoint and ctrlrelay
|
|
121
|
+
posts it as the issue comment.
|
|
122
|
+
- **secops** still runs **with** the operator's GitHub access. Its agent
|
|
123
|
+
reads Dependabot alerts and pull requests and merges what the policy
|
|
124
|
+
allows during the run. Moving it would mean ctrlrelay doing those reads
|
|
125
|
+
up front and the merges afterwards from a decision the agent returns.
|
|
126
|
+
|
|
127
|
+
Pushes are refused, and logged as `publish.push.refused`, for `main`,
|
|
128
|
+
`master`, the repository's default branch (read from the GitHub API, not
|
|
129
|
+
from the local clone) and any branch that is not the run's own. The push
|
|
130
|
+
goes to `https://github.com/<owner>/<repo>.git` (never `origin`) from a
|
|
131
|
+
private git dir with hooks off and only ctrlrelay's own git config, pushes the
|
|
132
|
+
commit it checked rather than the ref name, and uses `--force-with-lease`
|
|
133
|
+
against the head this session last pushed (recorded in the state DB). An
|
|
134
|
+
open PR found for the branch is reused only if its head repo is this repo and
|
|
135
|
+
its base is the default branch. Text the agent wrote is posted with `@`-mentions and
|
|
136
|
+
issue-closing keywords disabled and its length capped (20,000 characters);
|
|
137
|
+
the PR's own `Closes #N` is added by ctrlrelay.
|
|
138
|
+
|
|
139
|
+
**What this guarantees.** No GitHub credential is handed to the agent through
|
|
140
|
+
its environment. ctrlrelay chooses the push URL, the branch and the PR base.
|
|
141
|
+
`main`, `master`, the default branch and any branch other than the run's own
|
|
142
|
+
are refused, and a branch that already exists on GitHub is only pushed over if
|
|
143
|
+
its head is one ctrlrelay recorded pushing. Agent-written text is sanitized.
|
|
144
|
+
An agent that only uses its normal tooling (git, gh, ssh as configured) cannot
|
|
145
|
+
push. The push itself runs from a fresh relay-owned git dir under
|
|
146
|
+
`~/.ctrlrelay/push/`, reads the clone's branch heads from its ref files, and
|
|
147
|
+
never reads the clone's config, which every worktree shares.
|
|
148
|
+
|
|
149
|
+
**What it does not guarantee.** The agent still runs as your user. A
|
|
150
|
+
deliberately hostile agent can read `~/.config/gh/hosts.yml`, `~/.ssh` and
|
|
151
|
+
anything else in your home directory and use them by hand; it can write
|
|
152
|
+
anywhere your user can, including your dotfiles and ctrlrelay's install; and
|
|
153
|
+
it can write to `~/.ctrlrelay/push/`, the bare clone's object store and any
|
|
154
|
+
`objects/info/alternates` chain it adds there, racing a push. On macOS the
|
|
155
|
+
login keychain is reachable whatever the environment says. Closing that needs
|
|
156
|
+
a separate user or a read-only home (#236). The next setting hides the
|
|
157
|
+
credential paths; it does not stop the writes.
|
|
158
|
+
|
|
159
|
+
| Key | Type | Default | Description |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| `sandbox` | `"none"` or `"bwrap"` | `"none"` | `"bwrap"` runs the dev and task agents under bubblewrap with `sandbox_hide_paths` replaced by empty mounts. Linux only; needs `bwrap` installed and unprivileged user namespaces allowed (on Ubuntu 24.04+ an AppArmor profile for `bwrap`). ctrlrelay refuses to start if it is set and `bwrap` is missing. |
|
|
162
|
+
| `sandbox_hide_paths` | list of strings | `~/.ssh`, `~/.config/gh`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.netrc`, `~/.local/share/keyrings`, `$XDG_RUNTIME_DIR/bus` | Paths hidden under `sandbox: bwrap`. A directory becomes an empty tmpfs, a file or socket becomes `/dev/null`, a missing path is skipped. |
|
|
92
163
|
|
|
93
164
|
## transport
|
|
94
165
|
|
|
@@ -285,7 +356,7 @@ and ask the operator), or `never` (skip).
|
|
|
285
356
|
| `exclude_labels` | `["manual", "operator", "instruction"]` | Issue labels that tell the poller "this isn't for the agent". See [exclude_labels](#reposautomationexclude_labels) below. |
|
|
286
357
|
| `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. |
|
|
287
358
|
| `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. |
|
|
288
|
-
| `ci_wait_timeout_seconds` | `600` |
|
|
359
|
+
| `ci_wait_timeout_seconds` | `600` | Unused since #219 (the agent no longer waits on CI; ctrlrelay does). Still accepted so existing configs load. See [ci_wait_timeout_seconds](#reposautomationci_wait_timeout_seconds) below. |
|
|
289
360
|
|
|
290
361
|
The current secops and dev pipelines read these settings to bias their prompts
|
|
291
362
|
to Claude — they're not enforced by hard-coded checks.
|
|
@@ -432,7 +503,12 @@ repos:
|
|
|
432
503
|
|
|
433
504
|
### repos[].automation.ci_wait_timeout_seconds
|
|
434
505
|
|
|
435
|
-
|
|
506
|
+
**Not used by the dev pipeline since #219.** The agent no longer has GitHub
|
|
507
|
+
access, so it no longer waits on CI: ctrlrelay pushes the branch after the
|
|
508
|
+
agent exits and its own PR verifier waits on CI. The key is still accepted so
|
|
509
|
+
existing configs load. What follows describes the earlier behaviour.
|
|
510
|
+
|
|
511
|
+
The dev pipeline prompt told Claude to run `ctrlrelay ci wait --pr <PR>
|
|
436
512
|
--repo <repo> --timeout <N>` before signaling DONE — a single blocking call
|
|
437
513
|
that polls GitHub every 15s until CI finishes, fails, or the timeout hits.
|
|
438
514
|
No tokens get generated while it's blocked; on a repo with a slow CI suite,
|
|
@@ -151,9 +151,12 @@ Step-by-step:
|
|
|
151
151
|
15–17. **Loop until terminal.** If the resumed session blocks again, steps
|
|
152
152
|
8–14 repeat (capped at `DEFAULT_MAX_BLOCKED_ROUNDS`, currently 5). On
|
|
153
153
|
`DONE`/`FAILED`, the loop exits.
|
|
154
|
-
18. **
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
18. **Publish and verify.** On `DONE` the agent has only committed; it has
|
|
155
|
+
no GitHub access (#219). ctrlrelay pushes the run's branch, opens the PR
|
|
156
|
+
from the title and description in the checkpoint, then verifies the PR
|
|
157
|
+
is mergeable and CI is green. If not, it resumes the agent with the
|
|
158
|
+
failed-job logs, pushes the fix itself and re-verifies (up to
|
|
159
|
+
`DEFAULT_MAX_FIX_ATTEMPTS`, currently 3).
|
|
157
160
|
19. **Operator notified.** The poller pushes a final `✅ PR ready: <url>` (or
|
|
158
161
|
`❌ Failed: …`) message via the bridge.
|
|
159
162
|
|
|
@@ -238,10 +238,18 @@ journalctl --user -u ctrlrelay-poller -f
|
|
|
238
238
|
## Status page
|
|
239
239
|
|
|
240
240
|
`ctrlrelay web start` serves a read-only page and JSON API showing what each
|
|
241
|
-
agent is doing (phase, last command), what is waiting and
|
|
242
|
-
that need an answer, and a feed of what ctrlrelay did and
|
|
243
|
-
process next to the bridge and the poller, so a slow or
|
|
244
|
-
stall them.
|
|
241
|
+
agent is doing (phase, current activity, last command), what is waiting and
|
|
242
|
+
why, the questions that need an answer, and a feed of what ctrlrelay did and
|
|
243
|
+
said. It is a third process next to the bridge and the poller, so a slow or
|
|
244
|
+
broken page can never stall them.
|
|
245
|
+
|
|
246
|
+
On a running card, **phase** (the bar) is the furthest step the session has
|
|
247
|
+
reached, from the agent's transcript plus ctrlrelay's own CI, review and PR
|
|
248
|
+
events, so it never moves back. **Current activity** is the most recent step:
|
|
249
|
+
an open CI wait or review if it started after the agent's latest step, else
|
|
250
|
+
that step. It can be earlier than the bar, for example a read after the PR
|
|
251
|
+
or a fix round after review. The card links the PR once ctrlrelay has
|
|
252
|
+
recorded its number.
|
|
245
253
|
|
|
246
254
|
It is **read-only**: it opens `state.db` read-only, reads the tail of each
|
|
247
255
|
agent's transcript, and asks GitHub (through the `gh` login ctrlrelay already
|
|
@@ -257,6 +265,19 @@ loopback or on the home network.
|
|
|
257
265
|
| `/api/status` | Everything on the page, as JSON (the contract is in `src/ctrlrelay/web/status.py`). A section that could not be read is `null` and `errors` says why; `[]` means "read, and empty". The feed holds at most the newest `feed_limit` (200) events: `feed_total` is how many the history window holds (`null` if unreadable) and `feed_truncated` is `true` when some were left out, in which case the page says "showing newest N of M". |
|
|
258
266
|
| `/api/events?since=<epoch seconds>` | The feed, newest first, at most `limit` (200) events. Without `since`, the last `web.history_days` days. Carries the same `feed_total` and `feed_truncated`, plus the `limit` used. |
|
|
259
267
|
|
|
268
|
+
The page header shows how long ago it last heard from the server ("updated
|
|
269
|
+
12s ago", ticking every second) and a dot that is green while the last good
|
|
270
|
+
answer is at most two refresh intervals old and red otherwise. Elapsed times
|
|
271
|
+
on cards tick between polls too.
|
|
272
|
+
|
|
273
|
+
Fields added in #232 (all in `/api/status`):
|
|
274
|
+
|
|
275
|
+
| Field | What |
|
|
276
|
+
|---|---|
|
|
277
|
+
| `url` | On every `running`, `needs_you`, `waiting.items`, `skipped` and `feed` item (and on `/api/events` events): a link to the GitHub issue, or for a feed event that names a PR by number, the PR. Built only as `https://github.com/<owner>/<repo>/issues/<n>` or `/pull/<n>`, from a repo matching `owner/name` and an integer; `null` otherwise (a secops run with no issue, a restart). A `pr_url` an agent reported is never used. |
|
|
278
|
+
| `skipped` | Every open issue assigned to you that the poller will **not** pick up and is not running, from the same search as the waiting line (no extra GitHub calls): `repo`, `issue_number`, `title`, `url`, `updated`/`updated_ts` and `reason`, one of `repo not in config`, `excluded label <label>`, `already handled once (won't retry)`, `missing required label <label>` (several joined with "or": any one admits it), `assigned by someone else`. `null` until GitHub has answered once; `waiting.status` and `waiting.last_good_ts` say how current it is. The page shows it collapsed, with a count. |
|
|
279
|
+
| `server` | This machine's health, read on each request: `load_1m`, `load_5m`, `load_15m`, `cpu_count`, `memory` (`total_bytes`, `available_bytes`, `available_pct`, `low`), `disks` (one each for `home` and `worktrees`: `name`, `path`, `total_bytes`, `free_bytes`, `free_pct`, `low`), `uptime_s`, and `unavailable` (why any number is `null`; memory and uptime are `null` off Linux). `low` is true below 10% free and the page marks it. The whole section is `null`, with a line in `errors`, only if reading it failed outright. |
|
|
280
|
+
|
|
260
281
|
Try it on loopback first (the default `web.host` is `127.0.0.1`, port `8787`):
|
|
261
282
|
|
|
262
283
|
```bash
|
{ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/superpowers/specs/2026-10-30-status-page-design.md
RENAMED
|
@@ -117,15 +117,33 @@ pattern) and a timestamp. Phase is derived from the most recent tool uses:
|
|
|
117
117
|
- `Read`, `Grep`, `Glob`, read-only shell (`cat`, `sed -n`, `grep`, `ls`, `git log/diff/show`) → **reading**
|
|
118
118
|
- `Edit`, `Write`, `NotebookEdit` → **coding**
|
|
119
119
|
- shell running a test runner (`pytest`, `npm test`, `npx playwright`, `go test`, `cargo test`, `uv run pytest`, ...) → **testing**
|
|
120
|
+
- `ctrlrelay ci wait`, `gh pr checks`, `gh run watch`, `gh run view`, `gh pr view --json statusCheckRollup` → **CI**
|
|
120
121
|
- `git push`, `gh pr create` → **PR**
|
|
121
122
|
|
|
123
|
+
The bar shows the furthest phase reached (transcript tail plus pipeline events), so it
|
|
124
|
+
never moves back; the card also names the current activity, which can be earlier (a
|
|
125
|
+
read after the PR, a fix round after review) (#238).
|
|
126
|
+
|
|
122
127
|
If no transcript is found, phase is **unknown**. The agents need no change for this.
|
|
123
128
|
|
|
124
129
|
### CI wait and review: ctrlrelay says when it starts and stops them
|
|
125
130
|
|
|
126
131
|
After the agent exits, the dev pipeline waits for CI and runs the review itself. It
|
|
127
|
-
writes an event when each starts and ends. While one is open for a session
|
|
128
|
-
the session's
|
|
132
|
+
writes an event when each starts and ends. While one is open for a session the card
|
|
133
|
+
is not "looks stuck", and it is the session's current activity if it started after the
|
|
134
|
+
agent's latest transcript step.
|
|
135
|
+
|
|
136
|
+
### Phase and current activity (#238)
|
|
137
|
+
|
|
138
|
+
- **phase** (the bar) is the furthest step reached, in the order reading < coding <
|
|
139
|
+
testing < CI < review < PR, over the transcript tail plus the session's pipeline
|
|
140
|
+
events (CI wait, review, PR opened). It never moves back, and it can come from
|
|
141
|
+
history: a CI wait that has finished still counts as reached.
|
|
142
|
+
- **current_activity** is the most recent step by time: an open CI wait or review
|
|
143
|
+
when it started after the agent's latest transcript step, else that transcript
|
|
144
|
+
step, else unknown. It can be earlier than the bar (a read after the PR, a fix
|
|
145
|
+
round after review). `phase_source` says where it came from: `pipeline`,
|
|
146
|
+
`transcript`, or `none`.
|
|
129
147
|
|
|
130
148
|
### Waiting line: the web process asks GitHub (Oscar's choice)
|
|
131
149
|
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""What the coding agent's process is allowed to carry (#219).
|
|
2
|
+
|
|
3
|
+
The relay runs the agent as the operator's own user, under the
|
|
4
|
+
operator's own environment. Before #219 that environment reached the
|
|
5
|
+
agent whole: a ``GH_TOKEN`` if one was set, ``SSH_AUTH_SOCK``, the
|
|
6
|
+
chat bot tokens, and through them ``gh``'s stored login, git's
|
|
7
|
+
credential helpers and the operator's SSH key. Text the agent reads
|
|
8
|
+
(an issue body, a file in the repository) could therefore push to any
|
|
9
|
+
branch or merge a pull request with the operator's login.
|
|
10
|
+
|
|
11
|
+
For the dev and task lanes the relay now does every GitHub write
|
|
12
|
+
itself, after the agent exits, so the agent needs none of that. This
|
|
13
|
+
module builds the environment that says so:
|
|
14
|
+
|
|
15
|
+
- GitHub token variables are removed, and so is everything else in
|
|
16
|
+
the ``GH_*`` and ``GITHUB_*`` families, because ``gh`` reads its
|
|
17
|
+
settings from the first and CI-style tooling reads tokens from the
|
|
18
|
+
second.
|
|
19
|
+
- ``GH_CONFIG_DIR`` points at a fresh empty directory, so ``gh`` finds
|
|
20
|
+
no stored login.
|
|
21
|
+
- git gets ``credential.helper`` reset to empty through
|
|
22
|
+
``GIT_CONFIG_COUNT``, ``GIT_SSH_COMMAND=false`` so any SSH remote
|
|
23
|
+
(including one reached through an ``insteadOf`` rewrite, which is
|
|
24
|
+
how alkaturbo pushes) fails before a key is offered, and
|
|
25
|
+
``GIT_TERMINAL_PROMPT=0`` so nothing waits on a prompt.
|
|
26
|
+
- ``SSH_AUTH_SOCK`` and friends are removed.
|
|
27
|
+
- The relay's own chat tokens are removed: the agent has no reason to
|
|
28
|
+
hold the credentials of the channel the operator is reached on.
|
|
29
|
+
|
|
30
|
+
What it keeps is everything else, on purpose. The agent still has to
|
|
31
|
+
run the model CLI (which authenticates from its own files under
|
|
32
|
+
``~/.claude`` or from ``ANTHROPIC_*`` variables) and build and test
|
|
33
|
+
the project with whatever toolchain the operator installed.
|
|
34
|
+
|
|
35
|
+
What #219 guarantees: no GitHub credential is handed to the agent
|
|
36
|
+
through its environment; ctrlrelay chooses the push URL, the branch and
|
|
37
|
+
the base; main, master, the default branch and any other branch are
|
|
38
|
+
refused; a remote branch ctrlrelay did not push is not overwritten;
|
|
39
|
+
agent-written PR and comment text is sanitized; and an agent that only
|
|
40
|
+
uses its normal tooling (git, gh, ssh as configured) cannot push.
|
|
41
|
+
|
|
42
|
+
What it does NOT guarantee: the agent runs as the same OS user, so a
|
|
43
|
+
deliberately hostile agent can still read credential files
|
|
44
|
+
(``~/.config/gh/hosts.yml``, ``~/.ssh``), use them by hand, and write
|
|
45
|
+
to the relay's push directory, the bare clone's object store and any
|
|
46
|
+
``objects/info/alternates`` chain it adds there, racing a push. Closing
|
|
47
|
+
that needs a separate user or a read-only home (#236). ``agent.sandbox:
|
|
48
|
+
bwrap`` hides the credential paths; it does not stop the writes.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import os
|
|
54
|
+
import re
|
|
55
|
+
import shutil
|
|
56
|
+
from collections.abc import Mapping, Sequence
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
# Named explicitly as well as caught by the prefix rule below, so the
|
|
60
|
+
# list a reader greps for is in the file and a test can pin it.
|
|
61
|
+
GITHUB_TOKEN_VARS: tuple[str, ...] = (
|
|
62
|
+
"GH_TOKEN",
|
|
63
|
+
"GITHUB_TOKEN",
|
|
64
|
+
"GH_ENTERPRISE_TOKEN",
|
|
65
|
+
"GITHUB_ENTERPRISE_TOKEN",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Whole families, by prefix. ``gh`` reads GH_* (GH_HOST, GH_REPO,
|
|
69
|
+
# GH_CONFIG_DIR, ...); GITHUB_* is where Actions-style tooling looks
|
|
70
|
+
# for tokens. Nothing a build needs lives in either.
|
|
71
|
+
_STRIPPED_PREFIXES: tuple[str, ...] = ("GH_", "GITHUB_")
|
|
72
|
+
|
|
73
|
+
# Ways git or ssh could reach a credential without a token variable.
|
|
74
|
+
# GIT_CONFIG_* is cleared so an inherited GIT_CONFIG_COUNT cannot
|
|
75
|
+
# re-add a helper after ours (entries are numbered, and a second
|
|
76
|
+
# count would replace ours wholesale). GIT_CONFIG_PARAMETERS is the
|
|
77
|
+
# carrier for `git -c`, which outranks GIT_CONFIG_COUNT.
|
|
78
|
+
_STRIPPED_EXACT: frozenset[str] = frozenset({
|
|
79
|
+
"SSH_AUTH_SOCK",
|
|
80
|
+
"SSH_AGENT_PID",
|
|
81
|
+
"SSH_ASKPASS",
|
|
82
|
+
"GIT_ASKPASS",
|
|
83
|
+
"GIT_SSH",
|
|
84
|
+
"GIT_SSH_COMMAND",
|
|
85
|
+
"GIT_CONFIG_PARAMETERS",
|
|
86
|
+
"GIT_CONFIG_COUNT",
|
|
87
|
+
})
|
|
88
|
+
_GIT_CONFIG_INDEXED = re.compile(r"^GIT_CONFIG_(KEY|VALUE)_\d+$")
|
|
89
|
+
|
|
90
|
+
# The relay's own secrets. Every CTRLRELAY_* variable is dropped and
|
|
91
|
+
# the two the checkpoint protocol needs are set again by the
|
|
92
|
+
# dispatcher, so a chat token added later under a new name is covered
|
|
93
|
+
# without anybody remembering to list it.
|
|
94
|
+
_RELAY_PREFIX = "CTRLRELAY_"
|
|
95
|
+
|
|
96
|
+
# Deliberately unconnectable. See build_agent_env.
|
|
97
|
+
_NO_SESSION_BUS = "unix:path=/nonexistent/ctrlrelay-agent-has-no-session-bus"
|
|
98
|
+
|
|
99
|
+
# Applied through GIT_CONFIG_COUNT. An empty `credential.helper`
|
|
100
|
+
# resets the helper list, so helpers from ~/.gitconfig and from
|
|
101
|
+
# /etc/gitconfig are never consulted.
|
|
102
|
+
_GIT_CONFIG_OVERRIDES: tuple[tuple[str, str], ...] = (
|
|
103
|
+
("credential.helper", ""),
|
|
104
|
+
("core.askPass", ""),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_stripped(name: str) -> bool:
|
|
109
|
+
if name in GITHUB_TOKEN_VARS or name in _STRIPPED_EXACT:
|
|
110
|
+
return True
|
|
111
|
+
if name.startswith(_STRIPPED_PREFIXES) or name.startswith(_RELAY_PREFIX):
|
|
112
|
+
return True
|
|
113
|
+
return bool(_GIT_CONFIG_INDEXED.match(name))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_agent_env(
|
|
117
|
+
base: Mapping[str, str],
|
|
118
|
+
*,
|
|
119
|
+
gh_config_dir: Path,
|
|
120
|
+
runtime_dir: Path,
|
|
121
|
+
) -> tuple[dict[str, str], list[str]]:
|
|
122
|
+
"""Return ``(env, stripped_names)`` for an agent with no GitHub access.
|
|
123
|
+
|
|
124
|
+
``gh_config_dir`` and ``runtime_dir`` must be empty directories the
|
|
125
|
+
agent did not choose; the caller creates and removes them.
|
|
126
|
+
``runtime_dir`` becomes ``XDG_RUNTIME_DIR``, so a client that looks
|
|
127
|
+
for the session bus under it finds nothing there. ``stripped_names`` is
|
|
128
|
+
the sorted list of variable NAMES removed, for the log. Values
|
|
129
|
+
are never returned or logged.
|
|
130
|
+
"""
|
|
131
|
+
env: dict[str, str] = {}
|
|
132
|
+
stripped: list[str] = []
|
|
133
|
+
for name, value in base.items():
|
|
134
|
+
if _is_stripped(name):
|
|
135
|
+
stripped.append(name)
|
|
136
|
+
continue
|
|
137
|
+
env[name] = value
|
|
138
|
+
|
|
139
|
+
env["GH_CONFIG_DIR"] = str(gh_config_dir)
|
|
140
|
+
# gh falls back to the desktop keyring (the Secret Service, over the
|
|
141
|
+
# D-Bus session bus) when its config dir has no token, and so does
|
|
142
|
+
# anything built on libsecret. Measured on a laptop host on
|
|
143
|
+
# 2026-10-31: with GH_CONFIG_DIR empty, `gh auth token` still printed
|
|
144
|
+
# the operator's token, with or without DBUS_SESSION_BUS_ADDRESS set,
|
|
145
|
+
# because the D-Bus client then dials /run/user/<uid>/bus by itself.
|
|
146
|
+
# An address that cannot connect is what stops it. macOS keeps the
|
|
147
|
+
# login keychain outside anything an environment variable reaches;
|
|
148
|
+
# see the configuration docs.
|
|
149
|
+
env["DBUS_SESSION_BUS_ADDRESS"] = _NO_SESSION_BUS
|
|
150
|
+
# Also replaced, so a client that derives the bus from it finds an
|
|
151
|
+
# empty private directory. Measured: gh's D-Bus client falls back to
|
|
152
|
+
# /run/user/<uid>/bus by uid when no address is set, ignoring this
|
|
153
|
+
# variable, so it is the address above that blocks that fallback;
|
|
154
|
+
# this closes the XDG-based one.
|
|
155
|
+
env["XDG_RUNTIME_DIR"] = str(runtime_dir)
|
|
156
|
+
env["GH_PROMPT_DISABLED"] = "1"
|
|
157
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
158
|
+
env["GCM_INTERACTIVE"] = "never"
|
|
159
|
+
# `false` exits 1 without reading anything, so every SSH remote
|
|
160
|
+
# fails before a key or an agent is touched. Takes precedence over
|
|
161
|
+
# core.sshCommand in any config file.
|
|
162
|
+
env["GIT_SSH_COMMAND"] = "false"
|
|
163
|
+
env["GIT_CONFIG_COUNT"] = str(len(_GIT_CONFIG_OVERRIDES))
|
|
164
|
+
for i, (key, value) in enumerate(_GIT_CONFIG_OVERRIDES):
|
|
165
|
+
env[f"GIT_CONFIG_KEY_{i}"] = key
|
|
166
|
+
env[f"GIT_CONFIG_VALUE_{i}"] = value
|
|
167
|
+
return env, sorted(stripped)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
DEFAULT_SANDBOX_HIDE_PATHS: tuple[str, ...] = (
|
|
171
|
+
"~/.ssh",
|
|
172
|
+
"~/.config/gh",
|
|
173
|
+
"~/.git-credentials",
|
|
174
|
+
"~/.config/git/credentials",
|
|
175
|
+
"~/.netrc",
|
|
176
|
+
# The desktop keyring's files and the session bus it answers on,
|
|
177
|
+
# so resetting DBUS_SESSION_BUS_ADDRESS inside the sandbox does not
|
|
178
|
+
# reach it either.
|
|
179
|
+
"~/.local/share/keyrings",
|
|
180
|
+
"$XDG_RUNTIME_DIR/bus",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class SandboxUnavailableError(RuntimeError):
|
|
185
|
+
"""``agent.sandbox: bwrap`` was configured and cannot be honoured."""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def resolve_bwrap() -> str:
|
|
189
|
+
"""Absolute path of ``bwrap``, or raise naming what was searched."""
|
|
190
|
+
found = shutil.which("bwrap")
|
|
191
|
+
if found:
|
|
192
|
+
return found
|
|
193
|
+
for candidate in ("/usr/bin/bwrap", "/usr/local/bin/bwrap"):
|
|
194
|
+
if os.access(candidate, os.X_OK):
|
|
195
|
+
return candidate
|
|
196
|
+
raise SandboxUnavailableError(
|
|
197
|
+
"agent.sandbox is 'bwrap' but no bwrap binary was found on PATH "
|
|
198
|
+
"or in /usr/bin, /usr/local/bin. Install bubblewrap or set "
|
|
199
|
+
"agent.sandbox: none."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def bwrap_prefix(bwrap: str, hide_paths: Sequence[str]) -> list[str]:
|
|
204
|
+
"""Argv prefix that runs a command with ``hide_paths`` made empty.
|
|
205
|
+
|
|
206
|
+
The rest of the filesystem is bound through unchanged and writable,
|
|
207
|
+
so the agent's toolchain, caches and the model CLI's own state
|
|
208
|
+
behave exactly as without the sandbox. Only the named paths change:
|
|
209
|
+
a directory becomes an empty tmpfs and a file becomes /dev/null.
|
|
210
|
+
A path that does not exist is skipped (bwrap would otherwise have
|
|
211
|
+
to create a mount point for it).
|
|
212
|
+
"""
|
|
213
|
+
argv = [bwrap, "--dev-bind", "/", "/", "--die-with-parent"]
|
|
214
|
+
for raw in hide_paths:
|
|
215
|
+
expanded = os.path.expandvars(os.path.expanduser(raw))
|
|
216
|
+
if "$" in expanded:
|
|
217
|
+
# An unset variable: there is nothing at a path we cannot
|
|
218
|
+
# name, and a literal "$XDG_RUNTIME_DIR" is not a path.
|
|
219
|
+
continue
|
|
220
|
+
p = Path(expanded)
|
|
221
|
+
if p.is_symlink():
|
|
222
|
+
p = p.resolve()
|
|
223
|
+
if p.is_dir():
|
|
224
|
+
argv += ["--tmpfs", str(p)]
|
|
225
|
+
elif p.exists():
|
|
226
|
+
argv += ["--ro-bind", "/dev/null", str(p)]
|
|
227
|
+
argv.append("--")
|
|
228
|
+
return argv
|
|
@@ -7,11 +7,13 @@ import re
|
|
|
7
7
|
import socket
|
|
8
8
|
from enum import Enum
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import Any
|
|
10
|
+
from typing import Any, Literal
|
|
11
11
|
|
|
12
12
|
import yaml
|
|
13
13
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
14
14
|
|
|
15
|
+
from ctrlrelay.core.agent_env import DEFAULT_SANDBOX_HIDE_PATHS
|
|
16
|
+
|
|
15
17
|
CONFIG_FILENAME = "orchestrator.yaml"
|
|
16
18
|
CONFIG_SUBDIR = "config"
|
|
17
19
|
CONFIG_ENV_VAR = "CTRLRELAY_CONFIG"
|
|
@@ -83,6 +85,15 @@ class AgentConfig(BaseModel):
|
|
|
83
85
|
binary: str = "claude"
|
|
84
86
|
default_timeout_seconds: int = 1800
|
|
85
87
|
output_format: str = "json"
|
|
88
|
+
# #219. "bwrap" runs the dev and task agents under bubblewrap with
|
|
89
|
+
# the paths below replaced by empty mounts, so the agent cannot
|
|
90
|
+
# read the SSH key or gh's stored login. Off by default because it
|
|
91
|
+
# needs bwrap installed and unprivileged user namespaces allowed.
|
|
92
|
+
# The credential stripping in core.agent_env applies either way.
|
|
93
|
+
sandbox: Literal["none", "bwrap"] = "none"
|
|
94
|
+
sandbox_hide_paths: list[str] = Field(
|
|
95
|
+
default_factory=lambda: list(DEFAULT_SANDBOX_HIDE_PATHS)
|
|
96
|
+
)
|
|
86
97
|
|
|
87
98
|
|
|
88
99
|
# Backwards-compat alias. Older code and docs reference ``ClaudeConfig``;
|