The box-side dispatch smoke test failed at `git clone` (exit 128, "could not
read Username"): GitHub's git smart-HTTP transport authenticates an installation
token via BASIC auth (username x-access-token), not Bearer. Bearer is the REST
API form (minting + workflow_dispatch + run-list all use it correctly) but git
rejects it.
app_branch_pusher now sets the http.extraHeader to
`Authorization: Basic <base64("x-access-token:" + token)>`. Verified on the box:
Bearer -> exit 128, Basic -> clone OK. _scrub now also redacts the base64
credential blob (it decodes to the token). Test updated to assert the Basic form
and that the raw token never appears literally in the header.
End-to-end validation surfaced that auto-dispatch parked every task at
"empty declared_scope": dispatch_node required plan["scope"], but the planner
emits only summary+phases (never a scope) and config.allowed_scope defaults to
None, so no node ever populated it. (The earlier dispatch test was
operator-initiated with an explicit scope; the auto planner->build->dispatch
path was never exercised until box-side App dispatch went live.)
Fix: when no planner-/operator-declared scope is present, dispatch_node derives
declared_scope from the candidate diff's own touched paths (ci_gate.diff_touched_paths).
This supplies the missing scope without relaxing any CI trust control — the
apply/verify workflow still INDEPENDENTLY re-checks the materialized diff against
the denylist + '..'-escape + this scope + the diff-hash binding, and the
agent-apply environment's required reviewer remains the human gate. A non-empty
diff that parses to zero touched paths still parks (fail closed).
Tests: the three old park-on-missing-scope cases now assert scope-from-diff
dispatch; added a park case for a diff with no parseable paths. 1527 pass.
The App private key is placed under ~/.ssh (already mode 700) rather than
~/.sea-haven (which holds the synced engineering-handbook). Update the env path,
the secure-copy block, and the rotation/incident-response rm to ~/.ssh.
App agent-team-apply: app_id 4119505, installation 141992144 (not secrets — only
the .pem is). A 2026-06-24 test mint confirmed contents:write + actions:write +
repository_selection:selected. The box gets its own freshly-generated private key
(the CI-side AGENT_APPLY_APP_PRIVATE_KEY Actions secret is write-only and cannot
be re-exported); secure-copy it to the box and delete the Mac copy after.
- app_run_locator: default a missing status_code to None (fail closed -> raise)
rather than 200, so a malformed response object can never be treated as a
successful run list.
- app_run_locator: drop the unused `_now` parameter (dead/misleading — the floor
is derived solely from since_iso) and simplify the redundant two-step `_sleep`
indirection to a single resolution.
- github_app._parse_expires_at: normalise a naive parsed datetime to aware UTC so
an offset-less expires_at cannot raise a bare TypeError in TokenProvider.token
(bypassing the fail-closed GitHubAppError contract).
- coordinator._app_dispatch_seams: use the module-level Path import instead of a
redundant inline one.
Follow-ups (left to respect the plan's "leave the gh _default_* seams untouched,
additive only"): the floor/skew/poll scaffold is duplicated between
app_run_locator and _default_run_locator, and the GitHub REST header dict is
rebuilt in several places — both worth a later shared helper.
Full suite 1526 passing; ruff clean.
Two defense-in-depth fixes surfaced by /sh-security-review (both were
unverified — no exploit — but cheaply strengthen the credential contract):
- dispatcher: wrap the requests.post/get in app_workflow_dispatcher and
app_run_locator in try/except that re-raises DispatcherError with the
exception TYPE only (`from None`). The no-token-in-a-propagating-exception
guarantee is now enforced by code, not by requests' incidental behavior.
- github_app: parse expires_at BEFORE caching the token and raise GitHubAppError
(scrubbed) on a malformed value, so a parse failure fails closed without
leaving a half-written cache (token set, expiry None) behind a bare ValueError.
Tests: +3 (transport-error scrub for both HTTP seams; malformed-expiry fail-closed
with no half-written cache). Full suite 1526 passing; ruff clean.
The P3 dispatcher's default seams shell out to gh/git, but the R720 box has
no gh and a read-only PAT with no Actions scope — so dispatch_apply_verify
returned no run_id and every task parked at verify ("dispatch unresolved").
Add a GitHub-App auth path: the box mints short-lived (~1h) installation
access tokens from the App private key and uses them for the three dispatch
seams, removing the gh dependency.
- agent_team/github_app.py (new): mint_installation_token (RS256 App JWT,
iss=app_id, iat backdated 60s, exp 9 min; POST /access_tokens) + a lazy
TokenProvider that caches and re-mints near expiry. Secret-safe: the JWT
and token are never logged, never in an exception message, never persisted.
- dispatcher.py: app_branch_pusher / app_workflow_dispatcher / app_run_locator
(additive; gh/git _default_* left untouched). Push auth rides a host-scoped
http.extraHeader via GIT_CONFIG_* env (token never in argv/ps); the REST
run locator maps id->databaseId / created_at->createdAt into select_run_id
and surfaces 4xx promptly instead of silently exhausting the poll window.
- coordinator.py: default_dispatch_node_factory binds the App seams when
AGENT_TEAM_GH_APP_ID / _INSTALLATION_ID / _PRIVATE_KEY are all set; partial
or unreadable config logs one warning and falls back to gh-default (never
raises at serve-start).
- requirements.txt: pin PyJWT, cryptography, requests (App seams + CI fetcher).
- DEPLOY-R720.md / README.md: App dispatch config, permission/scope audit,
env-precedence check, key rotation/revocation + incident response.
Tests: +18 (test_github_app.py new; dispatcher/coordinator additions) covering
JWT claims, cache/re-mint, token-scrub-on-error, REST field mapping + run-name
correlation, and the partial-env inert fallback. Full suite 1523 passing.