Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ The monorepo ships **one shared train version** across all eleven packages, so u

### Added

- Manual Cursor Cloud Agent reviews can use the same Greptile-like `caeliq-reviewer` harness as the Cursor provider: isolated PR checks, structured preview approval, and optional `--token-env` publication.
- Manual Cursor Cloud Agent reviews can use the same Greptile-like `caeliq-reviewer` harness as the Cursor provider: isolated PR checks, structured preview approval, and optional `--token-env` on `post`, `threads`, and `complete-resolutions.py`.

### Fixed

- `post`, `threads`, and `complete-resolutions.py` send one GitHub credential on every `gh` call: the `--token-env` personal access token when that option is set, otherwise the App installation token. The named token wins over an ambient `GH_TOKEN` or `GITHUB_TOKEN`.
- Cloud Agent setup copies `bun` and `bunx` onto `/usr/bin`, so non-interactive shells and the isolated-check sandbox can run them without `~/.bashrc`.
- The isolated-check sandbox names the program it could not start.
- `ocp-dev` DSH local wiring generates Host Typert declarations before host `tsc`, so package `./remote` exports resolve and leftover empty package dirs no longer block a full reshim.
Expand Down
6 changes: 3 additions & 3 deletions docs/pr-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Contributor pull requests use `scripts/isolated-check.sh` below. Interactive sto

## Manual review

GitHub authentication for posting is a GitHub App installation token. `post` reads `REVIEW_APP_ID` and `REVIEW_APP_PRIVATE_KEY`, or `REVIEW_APP_PRIVATE_KEY_FILE` instead of the inline key. It signs a short-lived JWT and exchanges it for that app's installation token. Set `REVIEW_APP_INSTALLATION_ID` to the installation, or leave it unset to use the installation on the pull request's repository. The review author is the app's bot account. A client secret and the Cloud Agent `gh` login are not used for the review request. Pass `--token-env NAME` to `post` or `complete-resolutions.py` to use a GitHub personal access token (`ghp_` or `github_pat_`) from that environment variable instead of the App; the token is not a command-line argument. The same token is also used for the pull and review reads when it is the only GitHub credential. Without that option the App installation token remains the only publication credential. The private key stays out of the check boundary below.
GitHub authentication for posting is a GitHub App installation token. `post` reads `REVIEW_APP_ID` and `REVIEW_APP_PRIVATE_KEY`, or `REVIEW_APP_PRIVATE_KEY_FILE` instead of the inline key. It signs a short-lived JWT and exchanges it for that app's installation token. Set `REVIEW_APP_INSTALLATION_ID` to the installation, or leave it unset to use the installation on the pull request's repository. The review author is the app's bot account. A client secret and the Cloud Agent `gh` login are not used for the review request. Pass `--token-env NAME` to `post`, `threads`, or `complete-resolutions.py` to use a GitHub personal access token (`ghp_` or `github_pat_`) from that environment variable instead of the App; the token is not a command-line argument. That credential is sent on every `gh` call the command makes, including thread listing and the reads before publication, and it wins over an ambient `GH_TOKEN` or `GITHUB_TOKEN`. Without that option the App installation token is the credential for those calls. The private key stays out of the check boundary below.

Launch a Cursor Cloud Agent on this repository with this instruction, substituting the pull request:

Expand Down Expand Up @@ -176,7 +176,7 @@ URL is not accepted as a review receipt; the send remains unconfirmed and is
not automatically repeated. Every publication still requires the approved
preview. Readback checks do not authorize additional GitHub mutations.

`approve` records the preview digest, base, head, and runtime locally, and only when the file and the frozen preview are still those bytes. `approve` and `post` take the rendered payload, the same file `stamp` and `show` froze. `post` takes the publication lock and then reads the approval and the payload again. It reads them again after each read-only GitHub lookup. Each read must be the same pull request, base, head, runtime, identity, preview digest, and approval generation as the approval that was current when the lock was acquired. A denial or a different approval is not sent, including a replacement that would be valid on its own. After the reviews lookup, and immediately before it sends, `post` reads the pull request again and then reads the pinned approval and payload again. It mints the GitHub App installation token and reads that approval once more. A different base or head stops the send and requires a refreshed review and a new approval. A push that lands after that read can still be published on the approved commit. A request that was already sent is not cancelled. Two posts of the same payload share one lock. When a send does not confirm delivery, a later `post` checks whether the review is visible and does not send a second request. A response that shows GitHub rejected the request can be retried. A returned review URL has to name the approved pull request; otherwise the send stays unconfirmed and is not repeated. A review id that was already confirmed is not posted again. A later `post` returns that review and its comment URLs, or stops when the id is no longer listed, even if the summary no longer contains the marker. The checkpoint keeps the review id and comment ids. The review is a `COMMENT` on the approved commit. Comment URLs come from the pull request's review-comments list. `deny` records a refusal and `post` then makes no GitHub call:
`approve` records the preview digest, base, head, and runtime locally, and only when the file and the frozen preview are still those bytes. `approve` and `post` take the rendered payload, the same file `stamp` and `show` froze. `post` takes the publication lock and then reads the approval and the payload again. It reads them again after each read-only GitHub lookup. Each read must be the same pull request, base, head, runtime, identity, preview digest, and approval generation as the approval that was current when the lock was acquired. A denial or a different approval is not sent, including a replacement that would be valid on its own. Before the first GitHub lookup, `post` selects one credential, the `--token-env` token or one GitHub App installation token, and sends that credential on every GitHub call. After the reviews lookup, and immediately before it sends, `post` reads the pull request again and then reads the pinned approval and payload again. A different base or head stops the send and requires a refreshed review and a new approval. A push that lands after that read can still be published on the approved commit. A request that was already sent is not cancelled. Two posts of the same payload share one lock. When a send does not confirm delivery, a later `post` checks whether the review is visible and does not send a second request. A response that shows GitHub rejected the request can be retried. A returned review URL has to name the approved pull request; otherwise the send stays unconfirmed and is not repeated. A review id that was already confirmed is not posted again. A later `post` returns that review and its comment URLs, or stops when the id is no longer listed, even if the summary no longer contains the marker. The checkpoint keeps the review id and comment ids. The review is a `COMMENT` on the approved commit. Comment URLs come from the pull request's review-comments list. `deny` records a refusal and `post` then makes no GitHub call:

```bash
python3 scripts/review-publish.py deny "$review_dir/approval.json"
Expand Down Expand Up @@ -309,7 +309,7 @@ Keep the existing `REVIEW_APP_ID`, `REVIEW_APP_PRIVATE_KEY` (or owner-only
configuration. Store the inline PEM as a Cloud Agent Runtime Secret. Install
the App on this repository with **Pull requests: Read and write** and disable
webhooks for this manually initiated workflow. A client secret or ambient
user token is not a publishing credential unless `post` or
user token is not a publishing credential unless `post`, `threads`, or
`complete-resolutions.py` is given `--token-env NAME` for a personal access
token in that environment variable.

Expand Down
16 changes: 8 additions & 8 deletions scripts/complete-resolutions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
python3 scripts/complete-resolutions.py <payload.json> <approval.json>
python3 scripts/complete-resolutions.py --token-env NAME <payload.json> <approval.json>

Without --token-env, resolution uses the GitHub App installation token.
Without --token-env, every gh call uses one GitHub App installation token.
--token-env NAME reads a GitHub personal access token (ghp_ or github_pat_)
from that environment variable and uses it for resolveReviewThread. The token
value is never taken from the command line.
from that environment variable and sends it as GH_TOKEN on every gh call,
including the review lookup and resolveReviewThread. The token value is never
taken from the command line. An ambient GH_TOKEN does not replace it.
"""

from __future__ import annotations
Expand Down Expand Up @@ -52,9 +53,9 @@ def main() -> None:
metavar="NAME",
help=(
"read a GitHub personal access token (ghp_ or github_pat_) from environment "
"variable NAME and use it for resolveReviewThread. NAME is the variable's name; "
"do not pass the token on the command line. Omit this option to mint a GitHub App "
"installation token instead."
"variable NAME and send it as GH_TOKEN on every gh call, including the review "
"lookup and resolveReviewThread. NAME is the variable's name; do not pass the token "
"on the command line. Omit this option to mint a GitHub App installation token instead."
),
)
arguments = parser.parse_args()
Expand All @@ -78,8 +79,7 @@ def main() -> None:
lock = lock_path.open("a+")
try:
fcntl.flock(lock, fcntl.LOCK_EX)
if arguments.token_env is not None:
publisher.personal_access_token(arguments.token_env)
publisher.begin_publication(str(owner), str(repo), arguments.token_env)
marker = f"<!-- cursor-review:{identity} -->"
existing = publisher.matching_review(publisher.reviews_of(owner, repo, pull), marker)
journal = publisher.read_json_object(checkpoint_path)
Expand Down
76 changes: 43 additions & 33 deletions scripts/review-publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
different approval is not sent, even when the replacement is valid by
itself. After the reviews lookup, and immediately before it sends, post
reads the pull request again and then re-reads that pinned operation.
Without --token-env, it mints a GitHub App installation token and reads
the approval once more. The review author is that app's bot. A client
secret and the ambient gh login are not used.
Before the first GitHub lookup, it selects one GitHub App installation
token unless --token-env is set. The review author is that app's bot
when using the App. A client secret and the ambient gh login are not used.
--token-env NAME instead reads a GitHub personal access token (ghp_ or
github_pat_) from the environment variable NAME and uses that token for
the review POST and resolveReviewThread. The token value is never taken
from the command line. A moved base or head is not sent; refresh the
every gh call, including the review POST and resolveReviewThread. The
token value is never taken from the command line. A moved base or head is not sent; refresh the
review and approve it again.
A push after that read can still land on the approved commit. A request
that was already sent is not cancelled. A send that does not confirm
Expand Down Expand Up @@ -643,10 +643,14 @@ def redact_secret(text: str) -> str:


# Names passed to --token-env. The named variable is removed from gh's
# environment and its value is redacted from printed errors. A read that has
# no other GitHub credential receives the value back as GH_TOKEN. The set is
# process-local; post is a short-lived command.
# environment and its value is redacted from printed errors. The publication
# credential, once chosen, is what gh receives. The set is process-local;
# post, threads, and complete-resolutions are short-lived commands.
_hidden_env: set[str] = set()
# One credential for every gh call in this process: the --token-env PAT, or
# the App installation token. None until begin_publication selects it.
_publication_token: str | None = None
_publication_required = False
ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
PERSONAL_ACCESS_TOKEN = re.compile(r"^(?:ghp_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+)$")

Expand Down Expand Up @@ -791,15 +795,18 @@ def publisher_env(token: str | None) -> dict[str, str]:
env.pop("GH_DEBUG", None)
env["GH_HOST"] = "github.com"
env["GH_PROMPT_DISABLED"] = "1"
# Reads call gh without an explicit token and rely on GH_TOKEN / GITHUB_TOKEN.
# Hiding the selected PAT removes that credential when it is the only one,
# including when the named variable is GH_TOKEN or GITHUB_TOKEN itself.
if token is None and not any(env.get(name, "").strip() for name in ("GH_TOKEN", "GITHUB_TOKEN")):
# The publication credential and a --token-env PAT both win over ambient
# GH_TOKEN / GITHUB_TOKEN. An empty result is not an anonymous gh login.
if token is None:
token = _publication_token
if token is None:
for name in _hidden_env:
value = os.environ.get(name, "").strip()
if value:
token = value
break
if token is None and _publication_required:
refuse_publication("No GitHub credential is available. GitHub was not called.")
if token is not None:
env["GH_TOKEN"] = token
env["GITHUB_TOKEN"] = token
Expand Down Expand Up @@ -1070,6 +1077,14 @@ def publication_token(owner: str, repo: str, token_env: str | None) -> str:
return personal_access_token(token_env)


def begin_publication(owner: str, repo: str, token_env: str | None) -> str:
"""Select the one credential for every later gh call in this process."""
global _publication_token, _publication_required
_publication_required = True
_publication_token = publication_token(owner, repo, token_env)
return _publication_token


def resolve_threads(
payload_path: Path,
approval_path: Path,
Expand All @@ -1083,7 +1098,9 @@ def resolve_threads(
return
require_same_operation(payload_path, approval_path, pinned)
owner, repo, pull = payload["owner"], payload["repo"], payload["pull"]
token = publication_token(str(owner), str(repo), token_env)
token = _publication_token
if token is None:
token = publication_token(str(owner), str(repo), token_env)
require_same_operation(payload_path, approval_path, pinned)
for action in actions:
thread = checked_thread(payload, action, token)
Expand Down Expand Up @@ -1705,8 +1722,7 @@ def post(payload_path: Path, approval_path: Path, token_env: str | None = None)
print("The approval record was replaced. GitHub was not called.", file=sys.stderr)
raise SystemExit(5)
pinned = operation_key(payload, approval, digest)
if token_env is not None:
personal_access_token(token_env)
begin_publication(str(owner), str(repo), token_env)
if isinstance(payload.get("result"), dict):
inventory = thread_inventory(owner, repo, pull)
require_same_operation(payload_path, approval_path, pinned)
Expand Down Expand Up @@ -1747,10 +1763,9 @@ def post_locked(
token_env: str | None = None,
) -> None:
owner, repo, pull = payload["owner"], payload["repo"], payload["pull"]
if token_env is None:
app_id, pem = load_app_material()
else:
personal_access_token(token_env)
token = _publication_token
if not token:
refuse_publication("No GitHub credential is available. GitHub was not called.")
approved_head = payload["head"]
approved_base = payload["base"]
marker = f"<!-- cursor-review:{approval['identity']} -->"
Expand Down Expand Up @@ -1782,18 +1797,12 @@ def post_locked(
)
try:
# The reviews lookup is the last preparatory read. Fetch the remote
# revision, then take the publication credential and re-read the pinned
# approval. Without --token-env that credential is a new installation
# token. A push after this fetch is not covered. A changed revision or
# approval is not sent.
# revision again and re-read the pinned approval. The credential was
# selected once, before the first gh call. A push after this fetch is
# not covered. A changed revision or approval is not sent.
fetched_head, fetched_base, readable = read_pull(owner, repo, pull)
reject_moved_revision(fetched_head, fetched_base, readable, approved_head, approved_base)
require_same_operation(payload_path, approval_path, pinned)
if token_env is None:
token = installation_token(str(owner), str(repo), app_id, pem)
else:
token = personal_access_token(token_env)
require_same_operation(payload_path, approval_path, pinned)
except SystemExit:
# The request was not sent. Drop the checkpoint so it is not an unsettled delivery.
checkpoint_path.unlink(missing_ok=True)
Expand Down Expand Up @@ -1945,17 +1954,17 @@ def main() -> None:
"--token-env",
metavar="NAME",
help=(
"post only: read a GitHub personal access token (ghp_ or github_pat_) from "
"environment variable NAME and use it for the review POST and resolveReviewThread. "
"NAME is the variable's name; do not pass the token on the command line. "
"post and threads: read a GitHub personal access token (ghp_ or github_pat_) from "
"environment variable NAME and send it as GH_TOKEN on every gh call, including "
"thread reads. NAME is the variable's name; do not pass the token on the command line. "
"Omit this option to mint a GitHub App installation token instead."
),
)
args = parser.parse_args()
if args.discard_instruction is not None and args.command != "cleanup":
parser.error("--discard-instruction is only valid with cleanup")
if args.token_env is not None and args.command != "post":
parser.error("--token-env is only valid with post")
if args.token_env is not None and args.command not in {"post", "threads"}:
parser.error("--token-env is only valid with post and threads")
if args.command == "auth-info":
auth_info()
elif args.command == "cleanup":
Expand Down Expand Up @@ -1992,6 +2001,7 @@ def main() -> None:
if args.command == "start":
start_review(str(args.payload), str(args.approval), pull)
elif args.command == "threads":
begin_publication(str(args.payload), str(args.approval), args.token_env)
json.dump(thread_inventory(str(args.payload), str(args.approval), pull), sys.stdout, indent=2)
sys.stdout.write("\n")
else:
Expand Down
Loading
Loading