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.
Files changed (167) hide show
  1. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/CHANGELOG.md +45 -1
  2. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/architecture.md +8 -0
  4. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/configuration.md +79 -3
  5. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/feedback-loop.md +6 -3
  6. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/operations.md +25 -4
  7. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +20 -2
  8. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/pyproject.toml +1 -1
  9. ctrlrelay-0.15.0/src/ctrlrelay/core/agent_env.py +228 -0
  10. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/config.py +12 -1
  11. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/dispatcher.py +112 -2
  12. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/github.py +57 -5
  13. ctrlrelay-0.15.0/src/ctrlrelay/core/publish.py +189 -0
  14. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/state.py +45 -0
  15. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/worktree.py +289 -3
  16. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/dev.py +378 -47
  17. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/secops.py +9 -0
  18. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/task.py +123 -26
  19. ctrlrelay-0.15.0/src/ctrlrelay/web/health.py +167 -0
  20. ctrlrelay-0.15.0/src/ctrlrelay/web/links.py +35 -0
  21. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/server.py +8 -0
  22. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/static/index.html +218 -11
  23. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/status.py +143 -34
  24. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/transcript.py +49 -0
  25. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/waiting.py +118 -14
  26. ctrlrelay-0.15.0/tests/_publish_fakes.py +25 -0
  27. ctrlrelay-0.15.0/tests/test_agent_no_keys.py +1229 -0
  28. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_claim_reconciliation.py +26 -0
  29. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dev_integration.py +23 -0
  30. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dev_pipeline.py +62 -70
  31. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dispatch_outcome_hook.py +28 -14
  32. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dispatcher.py +38 -19
  33. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_event_emission.py +18 -0
  34. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_relay_alert_off_lock.py +2 -1
  35. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_resume_prompt_contract.py +14 -4
  36. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_task_pipeline.py +10 -0
  37. ctrlrelay-0.15.0/tests/test_web_health.py +228 -0
  38. ctrlrelay-0.15.0/tests/test_web_links.py +60 -0
  39. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_web_server.py +226 -2
  40. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_web_status.py +369 -9
  41. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_web_transcript.py +96 -0
  42. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_web_waiting.py +266 -0
  43. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_worktree.py +0 -23
  44. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  45. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  46. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  47. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/dependabot.yml +0 -0
  48. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/workflows/build.yml +0 -0
  49. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/workflows/cla.yml +0 -0
  50. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/workflows/pages.yml +0 -0
  51. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/workflows/publish.yml +0 -0
  52. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.github/workflows/test.yml +0 -0
  53. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/.gitignore +0 -0
  54. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/CODE_OF_CONDUCT.md +0 -0
  55. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/CONTRIBUTING.md +0 -0
  56. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/LICENSE +0 -0
  57. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/README.md +0 -0
  58. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/SECURITY.md +0 -0
  59. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/config/orchestrator.yaml.example +0 -0
  60. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/Gemfile +0 -0
  61. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/_config.yml +0 -0
  62. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/bridge.md +0 -0
  63. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/cli.md +0 -0
  64. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/development.md +0 -0
  65. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/getting-started.md +0 -0
  66. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/index.md +0 -0
  67. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/personalization.md +0 -0
  68. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  69. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/__init__.py +0 -0
  70. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  71. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  72. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/factory.py +0 -0
  73. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/handler.py +0 -0
  74. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  75. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  76. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/server.py +0 -0
  77. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  78. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/cli.py +0 -0
  79. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/__init__.py +0 -0
  80. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/archived.py +0 -0
  81. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/audit.py +0 -0
  82. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/binaries.py +0 -0
  83. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  84. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/code_review.py +0 -0
  85. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  86. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/events.py +0 -0
  87. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/network.py +0 -0
  88. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/obs.py +0 -0
  89. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/poller.py +0 -0
  90. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  91. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  92. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  93. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/redact.py +0 -0
  94. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/scheduler.py +0 -0
  95. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  96. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/dashboard/client.py +0 -0
  97. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/gh_protocol.py +0 -0
  98. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/install.py +0 -0
  99. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  100. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/personalization/manager.py +0 -0
  101. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/personalization/paths.py +0 -0
  102. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  103. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/base.py +0 -0
  104. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  105. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/setup.py +0 -0
  106. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/__init__.py +0 -0
  107. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  108. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  109. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  110. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  111. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  112. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  113. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  114. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  115. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/__init__.py +0 -0
  116. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/base.py +0 -0
  117. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  118. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  119. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/src/ctrlrelay/web/__init__.py +0 -0
  120. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/__init__.py +0 -0
  121. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/conftest.py +0 -0
  122. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/fixtures/gh_search_issues.json +0 -0
  123. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  124. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_archived.py +0 -0
  125. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_audit.py +0 -0
  126. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_binary_resolution.py +0 -0
  127. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_bridge_protocol.py +0 -0
  128. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_bridge_server.py +0 -0
  129. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_checkpoint.py +0 -0
  130. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_ci_wait.py +0 -0
  131. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_dev.py +0 -0
  132. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_repos.py +0 -0
  133. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_secops.py +0 -0
  134. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_start.py +0 -0
  135. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_cli_version.py +0 -0
  136. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_code_review.py +0 -0
  137. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_config.py +0 -0
  138. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dashboard_client.py +0 -0
  139. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_dispatch_health.py +0 -0
  140. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_docs_site.py +0 -0
  141. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_events.py +0 -0
  142. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_gh_protocol.py +0 -0
  143. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_github.py +0 -0
  144. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_install.py +0 -0
  145. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_issue_outcome_announcement.py +0 -0
  146. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_mattermost_handler.py +0 -0
  147. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_network.py +0 -0
  148. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_notify_transport.py +0 -0
  149. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_obs.py +0 -0
  150. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_personalization.py +0 -0
  151. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_pipeline_base.py +0 -0
  152. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_poller.py +0 -0
  153. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_post_merge.py +0 -0
  154. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_pr_verifier.py +0 -0
  155. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_pr_watcher.py +0 -0
  156. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_question_backoff.py +0 -0
  157. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_question_expiry.py +0 -0
  158. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_redact.py +0 -0
  159. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_scheduler.py +0 -0
  160. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_secops_integration.py +0 -0
  161. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_secops_pipeline.py +0 -0
  162. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_setup.py +0 -0
  163. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_state.py +0 -0
  164. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_telegram_handler.py +0 -0
  165. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_transport.py +0 -0
  166. {ctrlrelay-0.14.0 → ctrlrelay-0.15.0}/tests/test_transport_resolution.py +0 -0
  167. {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.14.0...HEAD
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.14.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` | Hard cap, in seconds, on the single `ctrlrelay ci wait` call the dev pipeline prompt tells Claude to run before signaling DONE. See [ci_wait_timeout_seconds](#reposautomationci_wait_timeout_seconds) below. |
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
- The dev pipeline prompt tells Claude to run `ctrlrelay ci wait --pr <PR>
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. **PR verification.** If the dev pipeline got `DONE` with a `pr_url`, it
155
- verifies the PR is mergeable and CI is green. If not, it asks Claude to
156
- fix and re-verifies (up to `DEFAULT_MAX_FIX_ATTEMPTS`, currently 3).
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 why, the questions
242
- that need an answer, and a feed of what ctrlrelay did and said. It is a third
243
- process next to the bridge and the poller, so a slow or broken page can never
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
@@ -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, that is
128
- the session's phase, and the card is not "looks stuck".
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.14.0"
3
+ version = "0.15.0"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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``;