You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
F2d: push the task head to its branch with a real BranchPusher (#98) - #101
GitBranchPusher (runner/branch-push.ts) is the real BranchPusher that PullRequestPublisher calls before it opens or refreshes a PR. It pushes the task head from the runner-owned repository to https://<GH_HOST or github.com>/<owner>/<name>.git. It never reads or writes the user's checkout.
Authentication (decision 1):-c credential.helper= and then !gh auth git-credential. Git gets its hardened environment plus the gh allowlist, and nothing else. No token is in any argument or on disk.
What it may overwrite (decision 2): it reads the remote branch first.
At the head already: nothing to do. A push whose outcome was lost settles this way.
Absent, or at a commit the task's ledger records as owned: it pushes with --force-with-lease pinned to the value it read (empty means the branch must not exist).
Anything else: BranchPushRefused, and nothing is pushed.
After the push, it reads the branch back and requires the head.
It only pushes branches under codeboost/, and only <head>:refs/heads/<branch>: no tags, no hooks, no submodules.
An abort keeps the caller's reason (for example, shutdown), whether it lands during a Git call or between two. The call settles only after Git has exited.
Errors are one line. Any token is removed from them. A push that GitHub refuses for lack of the workflow scope says so.
test/runner-branch-push.test.ts (20 tests) uses real Git against a local bare remote:
Covered cases: first push, the no-op, a non-fast-forward refresh over an owned commit, refusal over a foreign commit, and lease races both ways (the branch moves, and the branch is created, between the read and the push). The test causes each race from inside the ledger read, which is a real step between the two.
The exact-ref read, input refusals, Git config from the environment that would redirect the push, abort timing and reason, and token redaction.
A Git wrapper on PATH proves two things: every remote call carries the gh helper with no token in arguments or extra variables, and a push that exits 0 without moving the branch is caught.
I mutation-checked the tests. Each of these changes fails at least one test: plain --force, no ledger check, no read-back, no credential helper, and dropping the abort-reason check.
Local validation (validated head 530018b): tsc --noEmit is clean, and the new test file passes. I could not finish the full suite locally: the Docker daemon on this machine is not responding (docker info hangs), so the Docker-backed agent-* tests time out. CI is the source of truth for those.
The workflow scope hint matched any output that contains "workflow"
Fixed: it now matches only GitHub's refusal text, and only for push. Tested.
2
An abort during a Git call replaced the caller's reason (ShuttingDownError)
Fixed: signal.throwIfAborted() after every call. Tested at each of the 3 calls.
3
An abort between calls, or a timed-out cat-file, was reported as "no commit" or as a generic error
Fixed: only a cat-file that ran to completion means a missing commit, and an abort keeps its reason.
4
The owned set may not include a head that codeboost pushed but the ledger marks as foreign (a future rebase)
Deferred: the caller supplies the set. Decide it when wiring (#91) and with rebasing (#22).
5
The credential path was not exercised by a real Git call
Fixed: Git-wrapper test.
6
An injected url skips validation
Deferred: production builds the URL from pushUrl. #91 must not pass url.
7
head is not tied to the task
One-off: a wrong head can only create an absent branch or overwrite a commit this task owns.
8
A server-side lock race shows [remote rejected], not BranchPushRefused
One-off: nothing is pushed, and the next publish refuses correctly.
9
A killed call dropped its cause
Fixed: the cause is kept when status is null. Tested.
10–12
No overall deadline, GHES host with a port, localized messages
One-off: the timeout is per call, as in runner-repository.ts. The porcelain verdicts are not translated.
Review findings (Copilot round 1, fixed in dd67abd)
#
Finding
Outcome
C1
Redaction ran after the 400-character cut and missed enterprise tokens of any shape
Fixed: redact before the cut, including the exact configured GH_TOKEN, GITHUB_TOKEN and enterprise token values. The test for a token across the cut also found that the shape pattern's \b boundaries missed a token glued to other text, so they were removed. Tested, and mutation-checked.
C2
cat-file -e exits 128 for a broken repository too, which was reported as "no commit"
Fixed: cat-file --batch-check reports missing explicitly, and any other failure keeps Git's error. Tested.
Review findings (Copilot round 2, fixed in 530018b)
#
Finding
Outcome
C3
The C1 fix put a killed call's cause after Git's output, so 400+ characters of output dropped it again (a regression of finding 9)
Fixed: the cause comes first. Long-output regression test, mutation-checked.
None of these needs a new AGENTS.md rule. C3 is covered by "Each self-review pass rereads every changed function in full against the base" and by "Treat every behavioural claim … as something to verify": the claim held only for short output, and the test did not exercise long output. C1 is covered by "Treat every behavioural claim … as something to verify" and C2 by "Preserve the original … reason through every layer". For the rest:: 2 and 3 are covered by "Preserve the original timeout, cancellation, and shutdown reason", 5 and 9 by "Treat every behavioural claim … as something to verify", and 1 is a one-off.
GitBranchPusher pushes from the runner-owned repository to
https://<GH_HOST or github.com>/<owner>/<name>.git, with gh as Git's only
credential helper and only the Git and gh allowlisted environment.
It reads the remote branch first: at the head it does nothing; absent or
at a commit the task's ledger records as owned, it pushes with
--force-with-lease pinned to the value read; anything else is refused
with BranchPushRefused and nothing is pushed. After the push it reads the
branch back and requires the head. An abort keeps the caller's reason.
Wiring into web/cli.ts stays in #91.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…urally (Copilot round 1)
- Git's error text is redacted before it is cut to 400 characters, and the
exact GH_TOKEN, GITHUB_TOKEN and enterprise token values are removed as
well as known token shapes. The shape pattern no longer needs word
boundaries, so a token glued to a path or URL is still removed.
- The head check uses cat-file --batch-check: only Git's "missing" answer
means the commit is absent; a broken repository keeps Git's own error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Avoid passing credentials to local cat-file subprocesses
runner/branch-push.ts:141
This supplies the GitHub tokens and keyring/configuration environment to the local cat-file call as well as to authenticated remote calls. That subprocess does not need credentials, contrary to the least-privilege requirement in AGENTS.md:111; select the hardened Git-only environment when remote is false, and update the environment test to require tokens only for ls-remote/push.
Remove racy assertion on push after callback abort
test/runner-branch-push.test.ts:162
This assertion is racy for the at === 3 iteration: the push process has already spawned when the callback aborts, so it can update the local remote before the parent sends SIGTERM even though the promise ultimately rejects with the caller's reason. Remove this assertion or use a controlled Git wrapper that blocks before performing the push.
Round 1 put the cause after Git's output before the 400-character cut, so
400 characters of output dropped it again. The cause now comes first, with
a long-output regression.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #98.
What
GitBranchPusher(runner/branch-push.ts) is the realBranchPusherthatPullRequestPublishercalls before it opens or refreshes a PR. It pushes the task head from the runner-owned repository tohttps://<GH_HOST or github.com>/<owner>/<name>.git. It never reads or writes the user's checkout.-c credential.helper=and then!gh auth git-credential. Git gets its hardened environment plus theghallowlist, and nothing else. No token is in any argument or on disk.--force-with-leasepinned to the value it read (empty means the branch must not exist).BranchPushRefused, and nothing is pushed.codeboost/, and only<head>:refs/heads/<branch>: no tags, no hooks, no submodules.workflowscope says so.Wiring the pusher into
web/cli.tsstays in #91.Tests
test/runner-branch-push.test.ts(20 tests) uses real Git against a local bare remote:ghhelper with no token in arguments or extra variables, and a push that exits 0 without moving the branch is caught.I mutation-checked the tests. Each of these changes fails at least one test: plain
--force, no ledger check, no read-back, no credential helper, and dropping the abort-reason check.Local validation (validated head 530018b):
tsc --noEmitis clean, and the new test file passes. I could not finish the full suite locally: the Docker daemon on this machine is not responding (docker infohangs), so the Docker-backedagent-*tests time out. CI is the source of truth for those.Review findings (independent fresh-context review)
workflowscope hint matched any output that contains "workflow"push. Tested.ShuttingDownError)signal.throwIfAborted()after every call. Tested at each of the 3 calls.cat-file, was reported as "no commit" or as a generic errorcat-filethat ran to completion means a missing commit, and an abort keeps its reason.urlskips validationpushUrl. #91 must not passurl.headis not tied to the task[remote rejected], notBranchPushRefusedrunner-repository.ts. The porcelain verdicts are not translated.Review findings (Copilot round 1, fixed in dd67abd)
GH_TOKEN,GITHUB_TOKENand enterprise token values. The test for a token across the cut also found that the shape pattern's\bboundaries missed a token glued to other text, so they were removed. Tested, and mutation-checked.cat-file -eexits 128 for a broken repository too, which was reported as "no commit"cat-file --batch-checkreportsmissingexplicitly, and any other failure keeps Git's error. Tested.Review findings (Copilot round 2, fixed in 530018b)
None of these needs a new AGENTS.md rule. C3 is covered by "Each self-review pass rereads every changed function in full against the base" and by "Treat every behavioural claim … as something to verify": the claim held only for short output, and the test did not exercise long output. C1 is covered by "Treat every behavioural claim … as something to verify" and C2 by "Preserve the original … reason through every layer". For the rest:: 2 and 3 are covered by "Preserve the original timeout, cancellation, and shutdown reason", 5 and 9 by "Treat every behavioural claim … as something to verify", and 1 is a one-off.
🤖 Generated with Claude Code