mirror of
https://github.com/expo/code-review-cli.git
synced 2026-11-15 03:38:09 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
601b19afc1 | ||
|
|
4990b2f5a0 | ||
|
|
188f8521bf |
No files matched your search
@@ -39,7 +39,7 @@
|
||||
// ECR_EXPECTED_TOKEN_ENV (repo variable) must equal this comma-separated set.
|
||||
"auth": {
|
||||
"providers": {
|
||||
"openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
|
||||
"openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" },
|
||||
"openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,46 +33,56 @@ jobs:
|
||||
# A reviewer failure must never fail the PR's checks.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
# SECURITY: check out the PR's immutable BASE commit, never the PR head or
|
||||
# merge ref. This repo self-reviews from source, so the base checkout is what
|
||||
# keeps a PR from rewriting the ENGINE that reviews it — engine (and config)
|
||||
# changes activate after merge, exactly like the trust model `ecr ci` enforces
|
||||
# for config internally. persist-credentials off — the CLI's own git fetches
|
||||
# authenticate through `gh` from GH_TOKEN, so the token never lands in
|
||||
# .git/config.
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
# Shallow is enough — the reviewer gets the diff from the API (`gh`).
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# SECURITY: this workflow checks out the PR's code, including
|
||||
# .expo-code-review/config.jsonc, whose auth.tokenEnv names the env var the CLI
|
||||
# forwards as the model credential. `ecr verify-config` is the canonical guard
|
||||
# (ships with the CLI): it parses every config with the real JSONC parser and
|
||||
# refuses unless tokenEnv appears exactly once, in a ROOT-owned file, equal to
|
||||
# the expected value (repo var ECR_EXPECTED_TOKEN_ENV) so a PR can't repoint it
|
||||
# at another runner secret. This repo self-reviews from its OWN source (not the
|
||||
# published package), so the guard runs from source too — same engine as `ci`.
|
||||
# SECURITY: the TRUSTED BASE checkout above includes .expo-code-review/
|
||||
# config.jsonc, whose auth.tokenEnv names the env var the CLI forwards as the
|
||||
# model credential. `ecr verify-config` is the canonical guard (ships with the
|
||||
# CLI): it parses every config with the real JSONC parser and refuses unless
|
||||
# tokenEnv appears exactly once, in a ROOT-owned file, equal to the expected
|
||||
# value (repo var ECR_EXPECTED_TOKEN_ENV). This repo self-reviews from BASE
|
||||
# source, so the guard runs from that same base source — same engine as `ci`.
|
||||
- name: Guard config tokenEnv
|
||||
env:
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY' }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN,OPENAI_API_KEY' }}
|
||||
run: bun run src/cli.ts verify-config
|
||||
|
||||
- name: Run AI review
|
||||
# True self-review: this repo IS the reviewer, so run the CLI from the PR's
|
||||
# own source instead of the published npm package. `bun run` puts
|
||||
# node_modules/.bin (which has the `opencode` binary) on PATH.
|
||||
# True self-review: this repo IS the reviewer, so run the CLI from source —
|
||||
# the trusted BASE commit's source (see the checkout above), never the PR's.
|
||||
# `bun run` puts node_modules/.bin (which has the `opencode` binary) on PATH.
|
||||
# `ecr ci` materializes the PR head itself (OID-pinned, scrubbed of ambient
|
||||
# runtime config) for the reviewers' source reads.
|
||||
run: bun run src/cli.ts ci
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Layer-1 auth lock: `ecr ci` re-checks the honored tokenEnv at runtime,
|
||||
# catching what the guard step above can't. Keep in sync with the guard.
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY' }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN,OPENAI_API_KEY' }}
|
||||
# Mixed credentials (see config.jsonc auth.providers): the ChatGPT/Codex
|
||||
# subscription refresh token serves the default models; the usage-based
|
||||
# subscription ACCESS token serves the default models; the usage-based
|
||||
# API key (project-scoped, inference-only, budget-capped) serves the
|
||||
# pro-tier security pass. ECR_EXPECTED_TOKEN_ENV (repo variable) locks
|
||||
# the comma-separated set of BOTH names.
|
||||
CODEX_OAUTH_REFRESH_TOKEN: ${{ secrets.CODEX_OAUTH_REFRESH_TOKEN }}
|
||||
CODEX_OAUTH_ACCESS_TOKEN: ${{ secrets.CODEX_OAUTH_ACCESS_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# Optional: override the model for every agent (uses your OpenCode login).
|
||||
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
|
||||
@@ -0,0 +1,328 @@
|
||||
# Plan: trusted configuration by default
|
||||
|
||||
## Status
|
||||
|
||||
Proposed security hardening for the next release of `@expo/code-review-cli`.
|
||||
Reviewed 2026-07-26; amendments folded in below. The two most important changes
|
||||
from review:
|
||||
|
||||
- **Runtime ambient-config isolation (§5) leads the rollout, not trails it.**
|
||||
`runReview` chdirs into a PR-head worktree and then spawns the OpenCode server
|
||||
with no `cwd`, and the pinned OpenCode binary reads project-directory config
|
||||
(`opencode.json{,c}`, `.opencode/plugin`, `AGENTS.md`, `CLAUDE.md`, `.env`).
|
||||
Because `issue_comment` (`/review`) is not fork-restricted, this is reachable
|
||||
by **fork** PRs with full secrets in the process — a wider threat model than
|
||||
the same-repo config swap the rest of this plan addresses.
|
||||
- **Base-SHA checkout without mandatory head materialization is a silent
|
||||
correctness regression.** `prepareReadRootAsync` fails soft to the current
|
||||
checkout; once the checkout is base, that fallback makes agents and the
|
||||
verifier read pre-PR file contents and silently drop real findings. Head
|
||||
materialization must fail closed in CI in the same release as the template
|
||||
flip.
|
||||
|
||||
The immediate workflow mitigation is straightforward: check out the pull
|
||||
request's base SHA before invoking `ecr ci`. Version 0.5.2 already loads review
|
||||
configuration before materializing the PR head for source reads, so a correctly
|
||||
written workflow can keep configuration trusted while still reviewing the PR's
|
||||
actual files.
|
||||
|
||||
That is not a sufficient product default. A security property this important
|
||||
must be enforced by the CLI rather than depending on every adopter writing the
|
||||
same workflow correctly.
|
||||
|
||||
## Problem
|
||||
|
||||
In a `pull_request` workflow, the default checkout is the PR merge ref. ECR then
|
||||
loads the following security-sensitive inputs from that checkout:
|
||||
|
||||
- root and routed `config.jsonc` files;
|
||||
- provider, auth mode, `tokenEnv`, model, budget, and trigger policy;
|
||||
- reviewer, shared, and coordinator prompts;
|
||||
- routing, enforced agents, break-glass policy, and comment tags.
|
||||
|
||||
A same-repository PR can therefore change the reviewer that evaluates that PR
|
||||
while the process has a model credential and a comment-capable GitHub token.
|
||||
`ECR_EXPECTED_TOKEN_ENV` and `ecr verify-config` constrain credential names, but
|
||||
they do not make the rest of a PR-controlled configuration trustworthy.
|
||||
|
||||
## Security invariant
|
||||
|
||||
For a GitHub PR review:
|
||||
|
||||
> Review policy and executable reviewer configuration come from a trusted base
|
||||
> commit or an explicit operator-owned directory. The PR head is untrusted data
|
||||
> used only for its diff and source contents.
|
||||
|
||||
This includes every root and scope config, all prompt markdown, routing, model
|
||||
selection, auth/provider mapping, break-glass behavior, and comment identity.
|
||||
A config change in a PR becomes active only after it merges.
|
||||
|
||||
Failure to resolve or materialize the trusted config must fail closed. CI must
|
||||
not silently fall back to the current checkout.
|
||||
|
||||
## Desired default behavior
|
||||
|
||||
1. `ecr ci` resolves the PR's immutable base and head commit OIDs through the
|
||||
GitHub API.
|
||||
2. It materializes the base commit into a temporary trusted-config root.
|
||||
3. It loads and validates all ECR configuration and prompts from that root.
|
||||
4. It fetches the diff through GitHub and materializes the PR head separately
|
||||
for source reads and finding verification.
|
||||
5. It starts the model runtime without loading ambient configuration from the
|
||||
untrusted PR-head directory.
|
||||
6. It removes both temporary trees on every success and failure path.
|
||||
|
||||
An explicit operator-owned `--config-dir` / `ECR_CONFIG_DIR` remains supported
|
||||
and takes precedence. In CI, a relative override is resolved beneath the trusted
|
||||
base root; an absolute override must be explicitly allowed and documented as an
|
||||
operator trust decision.
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Immediate workflow containment
|
||||
|
||||
Update all scaffolded auto-review workflows to:
|
||||
|
||||
```yaml
|
||||
- uses: actions/checkout@<full-sha> # pinned release
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
```
|
||||
|
||||
Keep the published ECR package pinned to an exact version. Run
|
||||
`ecr verify-config` against this trusted checkout before `ecr ci`.
|
||||
|
||||
The command workflow already uses the base ref; align comments and tests so the
|
||||
auto and command workflows state the same trust model. `dismiss.yml` also has a
|
||||
bare checkout — give it `persist-credentials: false` too.
|
||||
|
||||
**Amendment — `persist-credentials: false` breaks the CLI's own fetches on
|
||||
private repos.** `prepareReadRootAsync` runs `git fetch <https-url>` and relies
|
||||
today on the `extraheader` credential `actions/checkout` persists. Ship this
|
||||
only together with the CLI appending `-c credential.helper=!gh auth
|
||||
git-credential` to its fetches (token read from `GH_TOKEN` env; never argv,
|
||||
never `.git/config`). Existing helpers stay first, so local behavior is
|
||||
unchanged.
|
||||
|
||||
**Amendment — this template flip must not precede mandatory head
|
||||
materialization** (see §5a): with a base checkout, the soft fallback in
|
||||
`prepareReadRootAsync` silently reviews/verifies pre-PR file contents.
|
||||
|
||||
### 2. Resolve immutable PR refs
|
||||
|
||||
Extend `GitHubPRSource.getMetadata()` to request immutable base/head OIDs in
|
||||
addition to names. Treat missing OIDs, a repository mismatch, or an unexpected
|
||||
base repository as a setup error.
|
||||
|
||||
Add a small ref type rather than passing branch names through security-sensitive
|
||||
code:
|
||||
|
||||
```ts
|
||||
interface PullRequestRefs {
|
||||
base: { repo: string; oid: string };
|
||||
head: { repo: string; oid: string };
|
||||
}
|
||||
```
|
||||
|
||||
Use argument arrays for every `git`/`gh` call and validate OIDs as full
|
||||
hexadecimal commit hashes before passing them to Git.
|
||||
|
||||
**Amendment:** `runRoutedCi` already fetches `baseRefOid` for link context via a
|
||||
second `gh pr view` — fold OID resolution into one metadata call rather than
|
||||
adding another. The head worktree should also check out the resolved `headRefOid`
|
||||
(detached), not `FETCH_HEAD`, so a force-push between diff fetch and
|
||||
materialization can't swap the reviewed tree.
|
||||
|
||||
### 3. Materialize a trusted config root
|
||||
|
||||
Add a `prepareTrustedConfigRootAsync()` path to the GitHub PR source:
|
||||
|
||||
- fetch the base OID from the base repository's HTTPS URL without credentials
|
||||
embedded in Git configuration;
|
||||
- create a detached temporary worktree or archive;
|
||||
- return its directory plus an idempotent cleanup callback;
|
||||
- never fall back to `process.cwd()` in CI.
|
||||
|
||||
Local review modes may retain their current checkout behavior because the user
|
||||
is the trust principal. `ecr ci`, and `review --pr` when posting with a token,
|
||||
should default to base-ref trust.
|
||||
|
||||
### 4. Separate config root from source root
|
||||
|
||||
Change config loading APIs to receive an explicit `configRoot` instead of
|
||||
implicitly reading from `cwd`. This must cover:
|
||||
|
||||
- `loadReviewConfig`;
|
||||
- `loadRoutingManifest`;
|
||||
- `loadScopeConfig`;
|
||||
- agent discovery and frontmatter;
|
||||
- `shared.md` and coordinator prompt loading;
|
||||
- `verify-config`;
|
||||
- run-log and comment-tag initialization.
|
||||
|
||||
Continue passing a distinct PR-head `sourceRoot` to the read/grep and finding
|
||||
verification paths. Do not use `process.chdir()` as the mechanism that decides
|
||||
which tree is trusted.
|
||||
|
||||
**Amendment — `runtimeRoot` is a first-class third root, not a test detail.**
|
||||
The run log and patch workspace are derived from `config.configDir`
|
||||
(`review.ts`: `path.join(config.configDir, ".runs")`), and both workflow
|
||||
templates upload `.expo-code-review/.runs/reviews.jsonl` from the workspace.
|
||||
Once `configDir` points into a temporary trusted root that is removed on every
|
||||
exit path, the log dies with it. Anchor `.runs/` at the workspace checkout
|
||||
explicitly, independent of where config was loaded from.
|
||||
|
||||
**Amendment — scope configs absent from base need a defined miss behavior.**
|
||||
A PR that introduces a new package together with its scope config would
|
||||
otherwise fail closed on exactly that PR (the scope's config dir doesn't exist
|
||||
at the base commit). Fall back to the root config for such scopes, warn in the
|
||||
job log, and let the scope config activate after merge.
|
||||
|
||||
### 5. Isolate the model runtime from PR-owned ambient configuration
|
||||
|
||||
**Amendment — this is the top-severity item and ships first** (see Status).
|
||||
Verified during review: the OpenCode SDK spawns `opencode serve` with no `cwd`
|
||||
(inheriting the PR-head worktree ECR chdir'd into), `OPENCODE_CONFIG_CONTENT`
|
||||
merges with — does not replace — project config, and the pinned binary contains
|
||||
project-discovery references to `opencode.json{,c}`, `.opencode/plugin`,
|
||||
`AGENTS.md`, `CLAUDE.md`, and `.env*`. A PR-head `.env` alone could redirect a
|
||||
provider base URL. Reachable by fork PRs via the `/review` command workflow.
|
||||
|
||||
**Amendment — a cheap first cut exists: scrub the worktree.** The head worktree
|
||||
is a throwaway copy, so delete ambient runtime config from it (at every depth)
|
||||
before the server starts: `opencode.json{,c}`, `.opencode/`, `AGENTS.md`,
|
||||
`CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, `.env*`. Source reads keep
|
||||
working; no OpenCode feature is required. Known tradeoffs: the reviewer no
|
||||
longer sees the PR's file version of those files (their diffs are still inlined
|
||||
in the prompt), and a finding citing a scrubbed file will fail verification.
|
||||
The full empty-runtime-dir isolation below remains the end state.
|
||||
|
||||
### 5a. Make head materialization mandatory in CI
|
||||
|
||||
`prepareReadRootAsync` fails soft (returns null → review the current checkout).
|
||||
Acceptable while the checkout is the merge ref; a silent correctness and trust
|
||||
regression once the checkout is base. In CI mode a failed head materialization
|
||||
must fail the run closed (one terminal comment), and the fetch must use the
|
||||
immutable head OID. Local `ecr review` keeps the soft fallback — the user is
|
||||
the trust principal there.
|
||||
|
||||
The target behavior is:
|
||||
|
||||
- start the model runtime from an empty ECR-owned runtime directory;
|
||||
- provide explicit read-only access to the PR-head source root;
|
||||
- pass ECR's trusted prompts and generated OpenCode configuration explicitly;
|
||||
- disable project plugin, hook, MCP, and auto-discovery paths that are not part
|
||||
of the trusted ECR configuration;
|
||||
- permit writes only to ECR-owned run directories.
|
||||
|
||||
If OpenCode cannot provide those guarantees, implement ECR-owned read/grep tools
|
||||
over `sourceRoot` and do not expose the PR tree as the runtime working directory.
|
||||
|
||||
### 6. Make verification trust-aware
|
||||
|
||||
`ecr verify-config` should accept the same resolved trusted config root as
|
||||
`ecr ci`. In GitHub CI mode it should report both refs:
|
||||
|
||||
```text
|
||||
config: <base-repo>@<base-oid>
|
||||
source: <head-repo>@<head-oid>
|
||||
```
|
||||
|
||||
The existing repo-wide sweep and `ECR_EXPECTED_TOKEN_ENV` lock remain useful
|
||||
defense in depth, but they apply to the trusted tree. PR-head ECR config files
|
||||
may be reported as pending configuration changes; they must not affect the
|
||||
current run.
|
||||
|
||||
### 7. CLI and compatibility surface
|
||||
|
||||
Prefer a safe default with a conspicuous escape hatch:
|
||||
|
||||
- `ecr ci`: trusted base config, always;
|
||||
- `ecr review --pr`: trusted base config by default;
|
||||
- local diff/staged review: current checkout config;
|
||||
- `--config-dir`: explicit operator-owned override;
|
||||
- optional `--unsafe-config-from-head`: temporary compatibility escape hatch,
|
||||
rejected unless explicitly supplied and never scaffolded.
|
||||
|
||||
Do not offer an environment variable that silently changes CI back to PR-head
|
||||
trust. If compatibility requires one release of opt-out behavior, print a
|
||||
security warning and remove it on a scheduled major/minor boundary.
|
||||
|
||||
**Amendments:**
|
||||
|
||||
- `ECR_CONFIG_DIR` is read deep inside `resolveConfigDir` and honored
|
||||
independently by `hasConfig`, `doctor`, and `verify-config`. It is not
|
||||
PR-controllable (the base repo's workflow defines the env), but the "no env
|
||||
var flips CI back to head trust" rule must explicitly cover it: in CI a
|
||||
relative value resolves beneath the trusted base root; an absolute value is
|
||||
an operator trust decision (root config + manifest only — scope subtrees
|
||||
still resolve beneath the trusted base root).
|
||||
- "Fail closed" means **skip the review and post the one terminal comment** —
|
||||
it does not mean failing the PR's checks. The non-blocking property
|
||||
(`continue-on-error`, reviewer failures never block merges) is established
|
||||
behavior and stays.
|
||||
- `ecr review --pr` needs no trusted-root machinery: config is loaded from the
|
||||
user's own checkout before the head worktree chdir, so the config principal
|
||||
is already the local user, and §5's scrub covers the runtime surface. Document
|
||||
this rather than building a second materialization path.
|
||||
|
||||
## Tests
|
||||
|
||||
Add end-to-end fixtures where a same-repository PR changes each of the following:
|
||||
|
||||
- `auth.provider`, `auth.mode`, and `auth.tokenEnv`;
|
||||
- root and scope models;
|
||||
- agent, shared, and coordinator prompts;
|
||||
- routing and enforced-agent policy;
|
||||
- trigger, break-glass, noise, budget, and comment tag;
|
||||
- an unreferenced nested `.expo-code-review/config.jsonc`;
|
||||
- `opencode.json`, `AGENTS.md`, `.claude/settings.json`, plugins, hooks, and MCP
|
||||
definitions in the PR head.
|
||||
|
||||
Assert that:
|
||||
|
||||
- the run uses every relevant value from the base commit;
|
||||
- the PR-head source contents and line numbers are still reviewed;
|
||||
- no PR-owned hook, plugin, MCP server, or command executes;
|
||||
- a config-only PR is reviewed with the previous config and activates only
|
||||
after merge;
|
||||
- missing/unfetchable base configuration fails closed with one actionable
|
||||
terminal comment;
|
||||
- fork PRs, renamed branches, force-pushes, and deleted heads use immutable OIDs;
|
||||
- fetch URLs and `.git/config` never contain `GH_TOKEN`;
|
||||
- temporary config/source/runtime directories are removed on all failure paths;
|
||||
- a failed head materialization in CI never silently reviews base contents;
|
||||
- the run-log artifact path (`.expo-code-review/.runs/reviews.jsonl` in the
|
||||
workspace) still resolves when config comes from the trusted root.
|
||||
|
||||
Unit tests should make `configRoot`, `sourceRoot`, and `runtimeRoot` visibly
|
||||
different directories so accidental `cwd` coupling fails immediately.
|
||||
|
||||
## Rollout (reordered on review)
|
||||
|
||||
The original order shipped containment that doesn't close the worst hole before
|
||||
the isolation that does:
|
||||
|
||||
1. Runtime ambient-config scrub (§5) + mandatory head materialization on
|
||||
immutable OIDs (§5a). CLI-only, no workflow change; closes the fork-reachable
|
||||
runtime injection for every adopter on `latest`.
|
||||
2. Base-SHA workflow templates (§1) + credential-helper fetches, in the same
|
||||
release as (1).
|
||||
3. Trusted base config root in `ecr ci` (§2–§4): explicit
|
||||
`configRoot`/`sourceRoot`/`runtimeRoot` separation, scope-miss fallback,
|
||||
red tests.
|
||||
4. Release as a new minor; update `ecr init --with-workflow`, README security
|
||||
documentation, `doctor`, and migration notes.
|
||||
5. Run the release against a PR that maliciously changes every configuration
|
||||
surface above before recommending adoption.
|
||||
6. Follow-up (not blocking): full empty-runtime-dir isolation from §5;
|
||||
trust-aware `config:`/`source:` ref reporting in `verify-config` (§6).
|
||||
|
||||
## Completion criteria
|
||||
|
||||
This work is complete when an adopter can use the scaffolded workflow—or a
|
||||
minimal custom workflow calling pinned `ecr ci`—and a same-repository PR cannot
|
||||
change the model credential destination, reviewer policy, prompts, tools, or
|
||||
runtime extensions used to review itself.
|
||||
@@ -55,7 +55,7 @@ subscription** sign-in (it runs OpenCode's browser login and extracts the token
|
||||
for you). `doctor` offers to run it whenever a credential is missing.
|
||||
|
||||
In CI, store the same values as repo secrets (`OPENAI_API_KEY`; plus
|
||||
`CODEX_OAUTH_REFRESH_TOKEN` for the mixed setup) — the scaffolded workflow
|
||||
`CODEX_OAUTH_ACCESS_TOKEN` for the mixed setup) — the scaffolded workflow
|
||||
forwards them.
|
||||
|
||||
**Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
|
||||
@@ -218,16 +218,31 @@ your-monorepo/
|
||||
(e.g. `security`) are injected into every scope with `alwaysRun`, taken from the
|
||||
root roster — a scope defining a same-id agent gets the root one, so a team can't
|
||||
shadow the enforced reviewer with a weaker version on its own subtree.
|
||||
- **Config comes from the checked-out ref (documented tradeoff).** The scaffolded
|
||||
auto workflow checks out the PR **merge ref**, so a PR *can* edit rosters, prompts,
|
||||
and routing globs for its own advisory review — that only steers what the
|
||||
comment-only reviewer says about that PR. What a PR can **never** do is touch
|
||||
credentials: `auth` is locked by the scope-schema rejection, the CLI's runtime
|
||||
`ECR_EXPECTED_TOKEN_ENV` check, and the guard step above, all three of which run
|
||||
against whatever ref is checked out. A `/review`-command workflow that checks out
|
||||
only the trusted base ref (see eas-cli's) closes the prompt-tampering vector too;
|
||||
resolving config from the base ref on the auto path as well is on the
|
||||
[roadmap](./ROADMAP.md).
|
||||
- **Configuration comes from the PR's trusted base commit.** In `ecr ci`, review
|
||||
policy and reviewer configuration — `config.jsonc`, `routing.jsonc`, prompts,
|
||||
models, and the auth mapping — load from the PR's immutable **base** commit,
|
||||
materialized via the GitHub API. The PR head is untrusted data: it is
|
||||
materialized separately (pinned to its immutable OID) purely as source content
|
||||
to read and verify against. A PR editing rosters, prompts, or routing is
|
||||
reviewed under the **previous** config; its changes activate after merge. If
|
||||
the base commit can't be materialized, the run fails closed (one terminal
|
||||
comment) — it never falls back to the checkout. A scope config that is new in
|
||||
a PR is reviewed with the root config until it merges.
|
||||
- **The model runtime never sees PR-owned ambient config.** The head worktree the
|
||||
agents read from is scrubbed of runtime configuration before the OpenCode
|
||||
server starts: `opencode.json{,c}`, `.opencode/` (plugins), `AGENTS.md`,
|
||||
`CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, and `.env*` at every depth.
|
||||
A PR can't install a plugin, MCP server, instruction file, or `.env` into the
|
||||
process that holds the model credential and the comment token. (Changes to
|
||||
those files are still reviewed — their diffs are inlined in the prompt — but a
|
||||
finding citing one can't be re-read during verification; that's the tradeoff.)
|
||||
- **The scaffolded workflows check out only the base commit** with
|
||||
`persist-credentials: false`; the CLI's own git fetches authenticate through
|
||||
`gh` from `GH_TOKEN`, so the token never lands in `.git/config` or argv. The
|
||||
CLI enforces the trust model itself, so a custom workflow that checks out the
|
||||
PR head still gets base-commit configuration. The temporary escape hatch
|
||||
`ecr ci --unsafe-config-from-head` restores the old behavior with a loud
|
||||
security warning and will be removed on a minor boundary.
|
||||
|
||||
Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
|
||||
(the single authoritative router) and `/server/www/.expo-code-review/ @your-www-team`
|
||||
@@ -344,7 +359,7 @@ coordinator, and per-repo `noise.additionalIgnores`.
|
||||
{
|
||||
"model": "openai/gpt-5.5", // default model for the specialists
|
||||
"policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
|
||||
"chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 6 },
|
||||
"chunk": { "maxChangedLines": 1000, "maxFiles": 20 }, // concurrency defaults: 6 (API key) / 3 (subscription)
|
||||
"noise": { "additionalIgnores": ["packages/*/build/**"] },
|
||||
"review": { "trigger": "all", // which PRs `ecr ci` reviews: "all"
|
||||
"label": "ai-review", // (default, except ai-review:skip) or
|
||||
@@ -406,6 +421,15 @@ change which model reviewed your code. Use an explicit override instead.
|
||||
session, inside the same budget — instead of spending the whole cap on a dead
|
||||
request. Progress lines say how long a reply has been silent, so this is legible in
|
||||
the CI log.
|
||||
- **Rate limits are detected and waited out, not fought.** The reviewer watches the
|
||||
OpenCode server's own log for provider 429s (hard evidence, per run). A stall
|
||||
*with* recent 429 evidence is throttling, not a wedge — the pass waits in 90s
|
||||
beats (without consuming its one retry) instead of re-sending its whole context
|
||||
into a limited account; explicit 429 errors retry on a slow 15s/45s/90s schedule.
|
||||
Subscription (oauth) runs also default to `concurrency` 3 instead of 6, since one
|
||||
account may be serving several PRs' reviews at once. Rate-limit events are
|
||||
reported in the job log and the run log (`rateLimitEvents`), so throttling is a
|
||||
visible fact about a run, never a mystery slowdown.
|
||||
- **Soft landing on timeout** — at either cap, the run is interrupted and the agent
|
||||
is asked to return the findings it already has, rather than discarding its work.
|
||||
Tools are disabled for that request, so the salvage step can't resume investigating
|
||||
@@ -490,7 +514,7 @@ set in `config.auth` (credentials come from OpenCode):
|
||||
|
||||
```jsonc
|
||||
"auth": { "providers": {
|
||||
"openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
|
||||
"openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" },
|
||||
"openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
||||
} }
|
||||
```
|
||||
@@ -499,11 +523,14 @@ set in `config.auth` (credentials come from OpenCode):
|
||||
(`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
|
||||
in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
|
||||
|
||||
- **The oauth `tokenEnv` holds the refresh token** from an `opencode auth login`
|
||||
ChatGPT sign-in (copy `.openai.refresh` out of OpenCode's `auth.json`) —
|
||||
access tokens are short-lived, so the refresh token is the durable secret and
|
||||
OpenCode mints access tokens on demand. Refresh-token reuse across runs is
|
||||
verified, so a static CI secret works.
|
||||
- **The oauth `tokenEnv` holds the ACCESS token** from an `opencode auth login`
|
||||
ChatGPT sign-in (`ecr setup-auth` extracts it) — a plain bearer, valid for
|
||||
days, with no rotation involvement. Do **not** use the refresh token as a
|
||||
shared secret: refresh tokens are single-use (rotation), so a static copy is
|
||||
spent by its first use and the sign-in dies with it. Access tokens expire
|
||||
(~10 days observed), so CI secrets need periodic re-minting — see the
|
||||
token-rotator item in the [roadmap](./ROADMAP.md); `doctor` and the run
|
||||
preflight warn before expiry.
|
||||
- **The API key needs exactly two permissions** — a *Restricted* key with
|
||||
*Model capabilities*: **Responses → Request** and **Chat completions →
|
||||
Request**; everything else (including *List models*) stays None. Create it
|
||||
@@ -511,7 +538,7 @@ set in `config.auth` (credentials come from OpenCode):
|
||||
instructions too.)
|
||||
- **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
|
||||
comma-separated set of both env names
|
||||
(`CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
|
||||
(`CODEX_OAUTH_ACCESS_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
|
||||
workflow.
|
||||
- **Auditability**: every pass logs which provider/model answered it (job log,
|
||||
step summary, run log), so the subscription/API split is visible per run.
|
||||
|
||||
+35
-12
@@ -12,6 +12,29 @@ that is meant to merge and then iterate in follow-up PRs. What's **in** phase 1
|
||||
under "Recently shipped" below. The highest-value work **deferred to follow-up
|
||||
PRs** (do these first, in this order):
|
||||
|
||||
0. **Codex access-token rotator (cron) — MOST IMPORTANT NEXT.** CI subscription
|
||||
auth now uses ACCESS tokens (~10-day observed lifetime) because refresh tokens
|
||||
are single-use: sharing one as a static secret spent it on first use and killed
|
||||
the whole sign-in ("Your refresh token has already been used…", euxy#8,
|
||||
2026-07-27). Until the rotator exists, the access-token secrets need a manual
|
||||
re-mint (~weekly): `ecr setup-auth` → `gh secret set CODEX_OAUTH_ACCESS_TOKEN`
|
||||
per repo. Design (constraints are load-bearing):
|
||||
- a scheduled workflow (~twice weekly + workflow_dispatch) in ONE home repo is
|
||||
the refresh token's SOLE consumer — nothing else may ever hold or use it;
|
||||
- serialized via a concurrency group (a race forks the rotation family);
|
||||
- each run: one form-urlencoded refresh call (client_id
|
||||
`app_EMoamEEZ73f0CkXaXp7hrann`, endpoint `auth.openai.com/oauth/token`),
|
||||
then persist the NEW refresh token to its own secret FIRST, then fan the new
|
||||
access token out to `CODEX_OAUTH_ACCESS_TOKEN` on every consumer repo
|
||||
(code-review-cli, eas-cli, euxy, …);
|
||||
- needs a fine-grained PAT with Actions-secrets write on the consumer repos
|
||||
(GITHUB_TOKEN cannot write secrets);
|
||||
- failure mode: crashing between spending the old token and saving the new one
|
||||
loses the family (recover: `opencode auth login` + reseed) — the rotation
|
||||
grace window makes an immediate retry likely to succeed, so retry once
|
||||
before alerting;
|
||||
- `doctor`/preflight already warn when the access token is near expiry, which
|
||||
doubles as the rotator's health check.
|
||||
1. **Incremental / delta review** — review only what changed since the last review
|
||||
(persistent per-file-version state). Biggest speed + cost + reliability win on
|
||||
re-pushes; the last remaining lever for the monster-PR case. See §5.
|
||||
@@ -362,18 +385,18 @@ Untrusted input reaches the reviewer from three places; defenses by layer:
|
||||
- **Prior comments / dismissal state** — NOT fed back to the review agents (runs are
|
||||
stateless), so there is no injection path there. The embedded comment state is
|
||||
only ever read from the bot's own comment (GitHub write perms are the boundary).
|
||||
- **The reviewer's OWN config, prompts, and code** — the sharpest vector, and the
|
||||
one not fully closed. The `pull_request` auto-workflow checks out the PR *merge
|
||||
ref*, so a same-repo PR can rewrite `.expo-code-review/` (agent prompts,
|
||||
`config.jsonc`, `tokenEnv`) **or the CLI source itself** and it runs with secrets.
|
||||
In-PR mitigations shipped: the `auth.ts` denylist refuses to forward well-known
|
||||
non-provider secrets even if config names them, and the security agent is told to
|
||||
treat PR-supplied config as attacker-controlled. **Structural fix (post-merge
|
||||
follow-up):** have the auto-workflow build + load config from the **trusted base
|
||||
ref** (the diff still comes from `gh pr diff`), exactly like the `/review` command
|
||||
workflow already does — or switch to the published package via `npx` (item 9).
|
||||
This can't land in *this* PR because the reviewer package isn't on `main` yet, so
|
||||
the base ref has nothing to build; it becomes available the moment this merges.
|
||||
- **The reviewer's OWN config, prompts, and code** — **structurally closed** (see
|
||||
`PLAN-trusted-base-config.md`). `ecr ci` loads all review configuration (config,
|
||||
routing, prompts, models, auth mapping) from the PR's immutable **base commit**,
|
||||
materialized via the GitHub API, failing closed when it can't; the PR head is
|
||||
materialized separately (OID-pinned) as source data only and is **scrubbed of
|
||||
ambient runtime config** (`opencode.json{,c}`, `.opencode/` plugins, `AGENTS.md`,
|
||||
`CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, `.env*`) before the OpenCode
|
||||
server starts, so a PR can't install a plugin/MCP/instruction file/`.env` into
|
||||
the credentialed process. The scaffolded workflows check out only the base SHA
|
||||
with `persist-credentials: false`, and the engine runs as the published npm
|
||||
package. Earlier defense-in-depth stays: the `auth.ts` denylist, the
|
||||
scope-schema auth rejection, `ECR_EXPECTED_TOKEN_ENV`, and `verify-config`.
|
||||
|
||||
## 4. Caching (LLM cost / quota / latency)
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
import { prepareAuth, checkProviderAuth, checkOauthTokenShape } from "../core/auth.js";
|
||||
import {
|
||||
prepareAuth,
|
||||
checkProviderAuth,
|
||||
checkOauthTokenShape,
|
||||
oauthAuthJsonEntry,
|
||||
jwtExpiryMs,
|
||||
} from "../core/auth.js";
|
||||
import type { LoadedConfig } from "../config/schema.js";
|
||||
|
||||
// Internal auth is a LIST of entries; most tests exercise one credential, so the
|
||||
@@ -329,3 +335,46 @@ test("a well-known provider key env may only feed that provider", () => {
|
||||
).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ---- access-token support (rotation-safe subscription credentials) ----
|
||||
|
||||
test("a JWT access token is used as-is with its own expiry — never refreshed", () => {
|
||||
// Refresh tokens are SINGLE-USE: sharing one as a static secret killed the
|
||||
// whole sign-in (euxy#8, "Your refresh token has already been used"). An
|
||||
// access token is a plain bearer with no rotation involvement.
|
||||
const exp = Math.floor(Date.now() / 1000) + 5 * 24 * 3600;
|
||||
const jwt = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ exp })).toString("base64url")}.sig`;
|
||||
const entry = oauthAuthJsonEntry("openai", jwt);
|
||||
expect(entry).toEqual({ type: "oauth", access: jwt, refresh: "", expires: exp * 1000 });
|
||||
// An opaque token is still treated as a refresh token (sole-consumer setups).
|
||||
const opaque = oauthAuthJsonEntry("openai", "r".repeat(196));
|
||||
expect(opaque).toEqual({ type: "oauth", access: "", refresh: "r".repeat(196), expires: 0 });
|
||||
});
|
||||
|
||||
test("an EXPIRED access token fails fast with the re-mint instruction", () => {
|
||||
const exp = Math.floor(Date.now() / 1000) - 2 * 24 * 3600;
|
||||
const jwt = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ exp })).toString("base64url")}.sig`;
|
||||
const r = checkProviderAuth(cfg({ mode: "oauth", provider: "openai", tokenEnv: "TOK" }), {
|
||||
TOK: jwt,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.detail).toContain("EXPIRED");
|
||||
expect(r.detail).toContain("setup-auth");
|
||||
});
|
||||
|
||||
test("a nearly-expired access token warns but never blocks", () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 12 * 3600; // 12h left
|
||||
const jwt = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ exp })).toString("base64url")}.sig`;
|
||||
const r = checkProviderAuth(cfg({ mode: "oauth", provider: "openai", tokenEnv: "TOK" }), {
|
||||
TOK: jwt,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.warning).toContain("expires in");
|
||||
});
|
||||
|
||||
test("jwtExpiryMs decodes exp and tolerates garbage", () => {
|
||||
expect(jwtExpiryMs("not-a-jwt")).toBeNull();
|
||||
expect(jwtExpiryMs("eyJ.%%%.sig")).toBeNull();
|
||||
const jwt = `eyJ.${Buffer.from(JSON.stringify({ exp: 1000 })).toString("base64url")}.s`;
|
||||
expect(jwtExpiryMs(jwt)).toBe(1000_000);
|
||||
});
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
isAuthError,
|
||||
formatUsageSummary,
|
||||
renderUsageMarkdown,
|
||||
effectiveConcurrency,
|
||||
} from "../core/review.js";
|
||||
import type { LoadedConfig } from "../config/schema.js";
|
||||
import type { PatchWorkspaceFile } from "../core/noise.js";
|
||||
import type { CoordinatorOutput, Finding } from "../core/schema.js";
|
||||
|
||||
@@ -198,3 +200,27 @@ test("renderUsageMarkdown omits the hit rate when no prompt tokens were used", (
|
||||
const s = renderUsageMarkdown({}, {}, {}, 0);
|
||||
expect(s).not.toContain("hit rate");
|
||||
});
|
||||
|
||||
// ---- auth-mode-aware concurrency ----
|
||||
|
||||
test("effectiveConcurrency: explicit config wins; subscription defaults lower", () => {
|
||||
const cfg = (chunk: object, auth: object[]): LoadedConfig =>
|
||||
({ chunk, auth }) as unknown as LoadedConfig;
|
||||
// An explicit value always wins, whatever the auth mode.
|
||||
expect(
|
||||
effectiveConcurrency(cfg({ concurrency: 8 }, [{ mode: "oauth", provider: "openai" }])),
|
||||
).toBe(8);
|
||||
// A subscription (oauth) credential defaults lower: one account handles six
|
||||
// parallel streams poorly, and several PRs may share the credential.
|
||||
expect(effectiveConcurrency(cfg({}, [{ mode: "oauth", provider: "openai" }]))).toBe(3);
|
||||
expect(
|
||||
effectiveConcurrency(
|
||||
cfg({}, [
|
||||
{ mode: "oauth", provider: "openai" },
|
||||
{ mode: "api-key", provider: "openai-api", upstream: "openai" },
|
||||
]),
|
||||
),
|
||||
).toBe(3);
|
||||
// Pure API-key runs keep the full default.
|
||||
expect(effectiveConcurrency(cfg({}, [{ mode: "api-key", provider: "openai" }]))).toBe(6);
|
||||
});
|
||||
@@ -4,10 +4,10 @@ import { planFromAuth, exportLine, opencodeAuthJsonPath } from "../commands/setu
|
||||
|
||||
test("plan: the mixed setup needs one ChatGPT login and one manual key", () => {
|
||||
const plan = planFromAuth([
|
||||
{ provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_REFRESH_TOKEN" },
|
||||
{ provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_ACCESS_TOKEN" },
|
||||
{ provider: "openai-api", mode: "api-key", tokenEnv: "OPENAI_API_KEY", upstream: "openai" },
|
||||
]);
|
||||
expect(plan.chatgptLogin).toEqual({ tokenEnv: "CODEX_OAUTH_REFRESH_TOKEN" });
|
||||
expect(plan.chatgptLogin).toEqual({ tokenEnv: "CODEX_OAUTH_ACCESS_TOKEN" });
|
||||
expect(plan.manualKeys).toEqual([
|
||||
{ provider: "openai-api", tokenEnv: "OPENAI_API_KEY", upstream: "openai" },
|
||||
]);
|
||||
@@ -27,8 +27,8 @@ test("plan: anthropic oauth is unsupported (dead path), api-key without tokenEnv
|
||||
});
|
||||
|
||||
test("export line is single-quoted so shells never expand the value", () => {
|
||||
expect(exportLine("CODEX_OAUTH_REFRESH_TOKEN", "abc$HOME!x")).toBe(
|
||||
"export CODEX_OAUTH_REFRESH_TOKEN='abc$HOME!x'",
|
||||
expect(exportLine("CODEX_OAUTH_ACCESS_TOKEN", "abc$HOME!x")).toBe(
|
||||
"export CODEX_OAUTH_ACCESS_TOKEN='abc$HOME!x'",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtemp, writeFile, appendFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { isRateLimitError, stallAction } from "../core/opencode.js";
|
||||
import { countRateLimitLines, opencodeLogFile, RateLimitWatch } from "../core/throttle.js";
|
||||
|
||||
const MIN = 60_000;
|
||||
|
||||
test("only ERROR lines with a throttle signature count as evidence", () => {
|
||||
const chunk = [
|
||||
// Real evidence, in OpenCode's logfmt shape.
|
||||
'timestamp=x level=ERROR message="stream error" agent=security error.error="AI_APICallError: 429 Too Many Requests"',
|
||||
'timestamp=x level=ERROR message="stream error" error.error="Rate limit reached for gpt-5.5"',
|
||||
// NOT evidence: an INFO line that merely mentions retrying…
|
||||
"timestamp=x level=INFO message=retry attempt=2",
|
||||
// …an ERROR with no throttle signature…
|
||||
'timestamp=x level=ERROR message="stream error" error.error="AI_APICallError: An error occurred"',
|
||||
// …and a 429-looking number on an INFO line (timestamps contain .429Z!).
|
||||
"timestamp=2026-07-23T05:20:31.429Z level=INFO message=process",
|
||||
].join("\n");
|
||||
expect(countRateLimitLines(chunk)).toBe(2);
|
||||
});
|
||||
|
||||
test("the watch reads incrementally and reports recency", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "ecr-throttle-"));
|
||||
const file = path.join(dir, "opencode.log");
|
||||
await writeFile(file, "timestamp=x level=INFO message=init\n");
|
||||
|
||||
const watch = new RateLimitWatch(file);
|
||||
expect(await watch.check()).toBe(0);
|
||||
expect(watch.recentlyLimited()).toBe(false);
|
||||
|
||||
await appendFile(file, 'timestamp=x level=ERROR message="stream error" error.error="429"\n');
|
||||
expect(await watch.check()).toBe(1);
|
||||
expect(watch.recentlyLimited()).toBe(true);
|
||||
// Evidence goes stale outside the recency window.
|
||||
expect(watch.recentlyLimited(Date.now() + 10 * MIN)).toBe(false);
|
||||
|
||||
// Already-read lines are never recounted.
|
||||
expect(await watch.check()).toBe(1);
|
||||
});
|
||||
|
||||
test("a missing log file is no evidence, not an error", async () => {
|
||||
const watch = new RateLimitWatch("/nonexistent/opencode.log");
|
||||
expect(await watch.check()).toBe(0);
|
||||
expect(watch.recentlyLimited()).toBe(false);
|
||||
});
|
||||
|
||||
test("log path honors XDG_DATA_HOME (the isolated run dir)", () => {
|
||||
expect(opencodeLogFile({ XDG_DATA_HOME: "/isolated" })).toBe(
|
||||
"/isolated/opencode/log/opencode.log",
|
||||
);
|
||||
});
|
||||
|
||||
// ---- patient stall behavior ----
|
||||
|
||||
test("a stall WITH rate-limit evidence waits instead of retrying — and keeps waiting", () => {
|
||||
// Waiting must not consume the single wedged retry: patience is free while
|
||||
// budget remains, whatever the retry history.
|
||||
expect(stallAction(0, 20 * MIN, true)).toBe("wait");
|
||||
expect(stallAction(1, 20 * MIN, true)).toBe("wait");
|
||||
// Out of room: salvage what exists rather than waiting past the deadline.
|
||||
expect(stallAction(0, 2 * MIN, true)).toBe("soft-land");
|
||||
});
|
||||
|
||||
test("a stall WITHOUT evidence keeps the wedged-retry behavior", () => {
|
||||
expect(stallAction(0, 20 * MIN, false)).toBe("retry");
|
||||
expect(stallAction(1, 20 * MIN, false)).toBe("soft-land");
|
||||
});
|
||||
|
||||
test("rate-limit errors are recognized for the slower backoff schedule", () => {
|
||||
expect(isRateLimitError(new Error("HTTP 429 Too Many Requests"))).toBe(true);
|
||||
expect(isRateLimitError(new Error("Rate limit reached for requests"))).toBe(true);
|
||||
expect(isRateLimitError(new Error("server error 503"))).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtemp, mkdir, writeFile, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { isAmbientRuntimeConfig, scrubAmbientRuntimeConfig } from "../core/scrub.js";
|
||||
import { isCommitOid } from "../sources/github-pr.js";
|
||||
import { hasScopeConfig } from "../config/load.js";
|
||||
import { resolveReadRoot } from "../core/review.js";
|
||||
import type { PreparedReadRoot, ReviewSource } from "../sources/source.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scrub: ambient runtime config never reaches the model runtime's project root
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const exists = async (p: string): Promise<boolean> =>
|
||||
stat(p).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
async function makeTree(): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ecr-scrub-test-"));
|
||||
const put = async (rel: string, dir = false): Promise<void> => {
|
||||
const full = path.join(root, rel);
|
||||
if (dir) {
|
||||
await mkdir(full, { recursive: true });
|
||||
} else {
|
||||
await mkdir(path.dirname(full), { recursive: true });
|
||||
await writeFile(full, "x", "utf8");
|
||||
}
|
||||
};
|
||||
// Ambient runtime config at several depths (all attacker-writable in a PR).
|
||||
await put("opencode.json");
|
||||
await put("AGENTS.md");
|
||||
await put("CLAUDE.md");
|
||||
await put(".env");
|
||||
await put(".env.local");
|
||||
await put(".mcp.json");
|
||||
await put(".cursorrules");
|
||||
await put(".opencode/plugin/evil.js");
|
||||
await put(".claude/settings.json");
|
||||
await put(".cursor/rules/x.md");
|
||||
await put("packages/app/opencode.jsonc");
|
||||
await put("packages/app/AGENTS.md");
|
||||
await put("packages/app/.env.production");
|
||||
// Legitimate source content that must survive.
|
||||
await put("src/index.ts");
|
||||
await put("packages/app/src/main.ts");
|
||||
await put("README.md");
|
||||
await put("environment.ts"); // not a .env file
|
||||
// Never descended into / never removed as a unit.
|
||||
await put(".git/config");
|
||||
await put("node_modules/dep/AGENTS.md");
|
||||
return root;
|
||||
}
|
||||
|
||||
test("scrubAmbientRuntimeConfig removes runtime config at every depth and keeps source", async () => {
|
||||
const root = await makeTree();
|
||||
const removed = await scrubAmbientRuntimeConfig(root);
|
||||
|
||||
for (const gone of [
|
||||
"opencode.json",
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
".env",
|
||||
".env.local",
|
||||
".mcp.json",
|
||||
".cursorrules",
|
||||
".opencode",
|
||||
".claude",
|
||||
".cursor",
|
||||
"packages/app/opencode.jsonc",
|
||||
"packages/app/AGENTS.md",
|
||||
"packages/app/.env.production",
|
||||
]) {
|
||||
expect(await exists(path.join(root, gone))).toBe(false);
|
||||
}
|
||||
for (const kept of ["src/index.ts", "packages/app/src/main.ts", "README.md", "environment.ts"]) {
|
||||
expect(await exists(path.join(root, kept))).toBe(true);
|
||||
}
|
||||
// .git is the worktree link back to the real repo; node_modules is skipped.
|
||||
expect(await exists(path.join(root, ".git/config"))).toBe(true);
|
||||
expect(await exists(path.join(root, "node_modules/dep/AGENTS.md"))).toBe(true);
|
||||
// Removed paths are reported (repo-relative) so runs can log them.
|
||||
expect(removed).toContain("opencode.json");
|
||||
expect(removed).toContain(path.join("packages", "app", "AGENTS.md"));
|
||||
expect(removed).not.toContain("README.md");
|
||||
});
|
||||
|
||||
test("isAmbientRuntimeConfig: exact names, .env prefix family, nothing else", () => {
|
||||
for (const name of [
|
||||
"opencode.json",
|
||||
".opencode",
|
||||
"AGENTS.md",
|
||||
".env",
|
||||
".env.local",
|
||||
".mcp.json",
|
||||
]) {
|
||||
expect(isAmbientRuntimeConfig(name)).toBe(true);
|
||||
}
|
||||
for (const name of [
|
||||
"environment.ts",
|
||||
"env.ts",
|
||||
"opencode.json.bak",
|
||||
"agents.md.txt",
|
||||
"README.md",
|
||||
]) {
|
||||
expect(isAmbientRuntimeConfig(name)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OIDs: only full commit hashes reach security-sensitive git calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("isCommitOid accepts only full 40-hex commit hashes", () => {
|
||||
expect(isCommitOid("a".repeat(40))).toBe(true);
|
||||
expect(isCommitOid("0123456789abcdef0123456789abcdef01234567")).toBe(true);
|
||||
expect(isCommitOid("0123456789ABCDEF0123456789ABCDEF01234567")).toBe(true);
|
||||
expect(isCommitOid("a".repeat(39))).toBe(false); // short
|
||||
expect(isCommitOid("a".repeat(41))).toBe(false); // long
|
||||
expect(isCommitOid("main")).toBe(false); // branch name
|
||||
expect(isCommitOid("refs/pull/1/head")).toBe(false); // ref
|
||||
expect(isCommitOid("--upload-pack=evil")).toBe(false); // argument injection
|
||||
expect(isCommitOid(undefined)).toBe(false);
|
||||
expect(isCommitOid("")).toBe(false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveReadRoot: CI fails closed, local degrades softly, null is never an error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sourceWith = (
|
||||
prepare: (() => Promise<PreparedReadRoot | null>) | undefined,
|
||||
): ReviewSource => ({
|
||||
getMetadata: async () => ({ title: "", body: "", baseRef: "", headRef: "" }),
|
||||
getChangedFiles: async () => [],
|
||||
...(prepare ? { prepareReadRootAsync: prepare } : {}),
|
||||
});
|
||||
|
||||
test("resolveReadRoot: materialization failure is fatal in ci mode", async () => {
|
||||
const source = sourceWith(async () => {
|
||||
throw new Error("fetch failed");
|
||||
});
|
||||
await expect(resolveReadRoot(source, "ci", () => {})).rejects.toThrow(
|
||||
/trusted base|fetch failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveReadRoot: materialization failure falls back softly in local mode", async () => {
|
||||
const messages: string[] = [];
|
||||
const source = sourceWith(async () => {
|
||||
throw new Error("fetch failed");
|
||||
});
|
||||
const root = await resolveReadRoot(source, "local", (m) => messages.push(m));
|
||||
expect(root).toBeNull();
|
||||
expect(messages.join("\n")).toContain("fetch failed");
|
||||
});
|
||||
|
||||
test("resolveReadRoot: null (nothing to materialize) is fine in every mode", async () => {
|
||||
const source = sourceWith(async () => null);
|
||||
expect(await resolveReadRoot(source, "ci", () => {})).toBeNull();
|
||||
expect(await resolveReadRoot(source, "local", () => {})).toBeNull();
|
||||
expect(await resolveReadRoot(sourceWith(undefined), "ci", () => {})).toBeNull();
|
||||
});
|
||||
|
||||
test("resolveReadRoot: a materialized root passes through untouched", async () => {
|
||||
const handle: PreparedReadRoot = { dir: "/tmp/x", cleanup: async () => {} };
|
||||
const source = sourceWith(async () => handle);
|
||||
expect(await resolveReadRoot(source, "ci", () => {})).toBe(handle);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hasScopeConfig: scope-miss detection against the trusted base root
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("hasScopeConfig: default scope always exists; nested scope needs config.json(c) at the root", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ecr-scope-test-"));
|
||||
const scope = { name: "app", config: "packages/app", include: ["packages/app/**"] };
|
||||
|
||||
expect(hasScopeConfig(root, { ...scope, config: "." })).toBe(true);
|
||||
expect(hasScopeConfig(root, scope)).toBe(false); // absent from base → miss
|
||||
|
||||
await mkdir(path.join(root, "packages/app/.expo-code-review"), { recursive: true });
|
||||
expect(hasScopeConfig(root, scope)).toBe(false); // dir alone isn't a config
|
||||
await writeFile(path.join(root, "packages/app/.expo-code-review/config.jsonc"), "{}", "utf8");
|
||||
expect(hasScopeConfig(root, scope)).toBe(true);
|
||||
});
|
||||
+189
-57
@@ -1,6 +1,10 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
CONFIG_DIRNAME,
|
||||
hasScopeConfig,
|
||||
loadAuthFromRoot,
|
||||
loadReviewConfig,
|
||||
loadScopeConfig,
|
||||
@@ -22,6 +26,7 @@ import type { CoordinatorOutput } from "../core/schema.js";
|
||||
import { runReview } from "../core/review.js";
|
||||
import { GitHubPRSource } from "../sources/github-pr.js";
|
||||
import { memoizeSource } from "../sources/source.js";
|
||||
import type { PreparedReadRoot, ReviewSource } from "../sources/source.js";
|
||||
import { GitHubReporter } from "../reporters/github.js";
|
||||
|
||||
/** Resolve the PR number from the Actions event payload or GITHUB_REF. */
|
||||
@@ -53,6 +58,15 @@ For GitHub Actions: reads the PR number + repo from the event/env, gets the diff
|
||||
via \`gh pr diff\`, runs the reviewer, and upserts a single PR comment. Comment-only
|
||||
and non-blocking (a reviewer failure never fails the PR's checks).
|
||||
|
||||
Trust model: review policy and reviewer configuration (config.jsonc, routing,
|
||||
prompts, models, auth mapping) load from the PR's immutable BASE commit,
|
||||
materialized via the GitHub API — never from the PR head — so a PR cannot change
|
||||
the reviewer that evaluates it; config changes activate after merge. The PR head
|
||||
is materialized separately (pinned to its immutable OID, scrubbed of ambient
|
||||
runtime config) purely as source content to read and verify against. If the
|
||||
trusted base cannot be materialized, the run fails closed with one terminal
|
||||
comment; it never falls back to the checkout.
|
||||
|
||||
Monorepos: when .expo-code-review/routing.jsonc exists, ci fans out INTERNALLY —
|
||||
it assigns each changed file to exactly one scope (last-match-wins) and reviews
|
||||
each active scope over only its files, then renders one aggregated comment (or one
|
||||
@@ -63,8 +77,17 @@ Options:
|
||||
--route Let the router pick relevant agents from the diff
|
||||
--scopes <a,b> Limit the fan-out to these named scopes (routing only)
|
||||
--config-dir <dir> Load the ROOT config.jsonc + routing.jsonc from <dir>
|
||||
instead of .expo-code-review/ (also ECR_CONFIG_DIR). Scope
|
||||
subtrees stay repo-root-relative.
|
||||
instead of .expo-code-review/ (also ECR_CONFIG_DIR). A
|
||||
RELATIVE dir resolves beneath the trusted base commit; an
|
||||
ABSOLUTE dir is an explicit operator trust decision. Scope
|
||||
subtrees always resolve beneath the trusted base commit.
|
||||
--unsafe-config-from-head
|
||||
COMPATIBILITY ESCAPE HATCH: load configuration from the
|
||||
current checkout instead of the PR's trusted base commit.
|
||||
This lets a same-repo PR change the reviewer (policy,
|
||||
prompts, model, auth mapping) that evaluates itself.
|
||||
Never scaffolded; prints a security warning; will be
|
||||
removed on a scheduled minor boundary.
|
||||
--comment <mode> Override manifest comment mode: single | per-scope
|
||||
--force Manual override: review even if the trigger policy (label
|
||||
trigger / ai-review:skip) would skip. Break-glass and the
|
||||
@@ -99,6 +122,7 @@ export async function ciCommand(argv: string[] = []): Promise<void> {
|
||||
// A maintainer's explicit `/review` (comment command or --force) is a manual
|
||||
// escape hatch that bypasses the trigger-policy gate only (see passesTriggerGate).
|
||||
const bypassTriggerGate = shouldBypassTriggerGate(argv);
|
||||
const unsafeConfigFromHead = argv.includes("--unsafe-config-from-head");
|
||||
const root = await repoRoot();
|
||||
if (root && root !== process.cwd()) {
|
||||
process.chdir(root);
|
||||
@@ -115,37 +139,138 @@ export async function ciCommand(argv: string[] = []): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// A malformed manifest is a loud, non-blocking error (never a silent fallback).
|
||||
let manifest: RoutingManifest | null;
|
||||
try {
|
||||
manifest = await loadRoutingManifest(cwd, { configDir });
|
||||
} catch (error) {
|
||||
process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
|
||||
return;
|
||||
}
|
||||
// ONE source for the whole run: metadata (incl. immutable OIDs), the diff, and
|
||||
// the PR-head read root are each fetched once and shared across scopes.
|
||||
const ghSource = new GitHubPRSource({ prNumber, repo, cwd });
|
||||
const source = memoizeSource(ghSource);
|
||||
|
||||
if (manifest == null) {
|
||||
await runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runRoutedCi(
|
||||
manifest,
|
||||
repo,
|
||||
prNumber,
|
||||
cwd,
|
||||
agents,
|
||||
route,
|
||||
scopesFilter,
|
||||
commentOverride,
|
||||
bypassTriggerGate,
|
||||
configDir,
|
||||
// Trusted configuration root: review policy and reviewer config load from the
|
||||
// PR's immutable BASE commit, so the PR head is data, never policy. Fail CLOSED:
|
||||
// when the base can't be materialized, post the one terminal comment and stop —
|
||||
// silently reading the checkout would let a head checkout smuggle config in.
|
||||
let trustedRoot: PreparedReadRoot | null = null;
|
||||
let configRoot = cwd;
|
||||
if (unsafeConfigFromHead) {
|
||||
process.stderr.write(
|
||||
"CI reviewer: ⚠ SECURITY — --unsafe-config-from-head is set: reviewer configuration " +
|
||||
"(policy, prompts, models, auth mapping) is being loaded from the current checkout, so " +
|
||||
"a same-repository PR can change the reviewer that evaluates it. This escape hatch will " +
|
||||
"be removed in a future minor release.\n",
|
||||
);
|
||||
} catch (error) {
|
||||
// Fan-out failures stay non-blocking (single-writer property is the point).
|
||||
process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
|
||||
} else {
|
||||
try {
|
||||
trustedRoot = await ghSource.prepareTrustedConfigRootAsync();
|
||||
configRoot = trustedRoot.dir;
|
||||
} catch (error) {
|
||||
const reason = errorMessage(error);
|
||||
process.stderr.write(
|
||||
`CI reviewer: could not materialize the PR's base commit for trusted configuration ` +
|
||||
`(failing closed, not reviewing): ${reason}\n`,
|
||||
);
|
||||
await postTerminalFailureNote(
|
||||
repo,
|
||||
prNumber,
|
||||
cwd,
|
||||
`it could not load trusted configuration from the PR's base commit (${reason}). ` +
|
||||
`This usually means the runner has no git checkout or no usable GH_TOKEN; re-run once fixed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// A malformed manifest is a loud, non-blocking error (never a silent fallback).
|
||||
let manifest: RoutingManifest | null;
|
||||
try {
|
||||
manifest = await loadRoutingManifest(configRoot, { configDir });
|
||||
} catch (error) {
|
||||
process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (manifest == null) {
|
||||
await runLegacyCi(source, repo, prNumber, cwd, configRoot, {
|
||||
agents,
|
||||
route,
|
||||
bypassTriggerGate,
|
||||
configDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, {
|
||||
agents,
|
||||
route,
|
||||
scopesFilter,
|
||||
commentOverride,
|
||||
bypassTriggerGate,
|
||||
configDir,
|
||||
});
|
||||
} catch (error) {
|
||||
// Fan-out failures stay non-blocking (single-writer property is the point).
|
||||
process.stderr.write(
|
||||
`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await source.dispose();
|
||||
await trustedRoot?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one terminal "this PR was NOT reviewed" comment for failures that happen
|
||||
* before any configuration is loaded (trusted-root materialization). Uses the
|
||||
* DEFAULT comment tag/break-glass marker because the config that could customize
|
||||
* them is exactly what failed to load; a repo with a custom tag gets a fresh
|
||||
* comment rather than an upsert, which is the acceptable degraded case.
|
||||
*/
|
||||
async function postTerminalFailureNote(
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
cwd: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const reporter = new GitHubReporter({
|
||||
prNumber,
|
||||
repo,
|
||||
commentTag: "expo-ai-code-reviewer",
|
||||
breakGlassMarker: "/skip-review",
|
||||
cwd,
|
||||
});
|
||||
await reporter.report({
|
||||
decision: "approve_with_comments",
|
||||
findings: [],
|
||||
summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed: ${reason}.`,
|
||||
incomplete: [],
|
||||
couldNotComplete: true,
|
||||
});
|
||||
} catch (postError) {
|
||||
process.stderr.write(
|
||||
`CI reviewer: also failed to post the failure notice: ${errorMessage(postError)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Options shared by the legacy and routed CI paths (parsed from argv once). */
|
||||
interface CiRunOptions {
|
||||
agents: string[] | undefined;
|
||||
route: boolean;
|
||||
scopesFilter?: string[] | undefined;
|
||||
commentOverride?: "single" | "per-scope" | undefined;
|
||||
bypassTriggerGate: boolean;
|
||||
configDir: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run-log + patch-workspace anchor: ALWAYS the workspace checkout, never the
|
||||
* (temporary, removed-on-exit) trusted config root — the workflow uploads
|
||||
* `.expo-code-review/.runs/reviews.jsonl` from the workspace as an artifact.
|
||||
*/
|
||||
function workspaceRunsDir(cwd: string): string {
|
||||
return path.join(cwd, CONFIG_DIRNAME, ".runs");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,17 +278,17 @@ export async function ciCommand(argv: string[] = []): Promise<void> {
|
||||
* routing.jsonc the CLI behaves exactly as before (backcompat invariant).
|
||||
*/
|
||||
async function runLegacyCi(
|
||||
source: ReviewSource,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
cwd: string,
|
||||
agents: string[] | undefined,
|
||||
route: boolean,
|
||||
bypassTriggerGate: boolean,
|
||||
configDir: string | undefined,
|
||||
configRoot: string,
|
||||
options: CiRunOptions,
|
||||
): Promise<void> {
|
||||
const { agents, route, bypassTriggerGate, configDir } = options;
|
||||
let config;
|
||||
try {
|
||||
config = await loadReviewConfig(cwd, { configDir });
|
||||
config = await loadReviewConfig(configRoot, { configDir });
|
||||
} catch (error) {
|
||||
process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
|
||||
return;
|
||||
@@ -211,11 +336,12 @@ async function runLegacyCi(
|
||||
}
|
||||
|
||||
try {
|
||||
const review = await runReview(new GitHubPRSource({ prNumber, repo, cwd }), {
|
||||
const review = await runReview(source, {
|
||||
config,
|
||||
mode: "ci",
|
||||
agents,
|
||||
route,
|
||||
runsDir: workspaceRunsDir(cwd),
|
||||
onProgress: (message) => process.stderr.write(`${message}\n`),
|
||||
});
|
||||
await reporter.report(review);
|
||||
@@ -254,20 +380,19 @@ function failureReview(scopeName: string, reason: string): CoordinatorOutput {
|
||||
|
||||
/** The routing fan-out: one process, N scopes reviewed sequentially, one render. */
|
||||
async function runRoutedCi(
|
||||
source: ReviewSource & { dispose(): Promise<void> },
|
||||
manifest: RoutingManifest,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
cwd: string,
|
||||
agents: string[] | undefined,
|
||||
route: boolean,
|
||||
scopesFilter: string[] | undefined,
|
||||
commentOverride: "single" | "per-scope" | undefined,
|
||||
bypassTriggerGate: boolean,
|
||||
configDir: string | undefined,
|
||||
configRoot: string,
|
||||
options: CiRunOptions,
|
||||
): Promise<void> {
|
||||
const { agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir } = options;
|
||||
// The root config + manifest follow the override; scope configs stay
|
||||
// repo-root-relative (loadScopeConfig reads <root>/<scope.config>/.expo-code-review).
|
||||
const rootConfig = await loadReviewConfig(cwd, { configDir });
|
||||
// relative to the TRUSTED root (loadScopeConfig reads
|
||||
// <configRoot>/<scope.config>/.expo-code-review).
|
||||
const rootConfig = await loadReviewConfig(configRoot, { configDir });
|
||||
// The root/aggregate marker is the ACTUAL root-owned comment tag so the
|
||||
// pre-routing comment and its dismissal state upsert in place, not stranded
|
||||
// under a new marker (risk 8/9). manifest.defaults.commentTag is the
|
||||
@@ -322,7 +447,6 @@ async function runRoutedCi(
|
||||
return;
|
||||
}
|
||||
|
||||
const source = memoizeSource(new GitHubPRSource({ prNumber, repo, cwd }));
|
||||
const changed = await source.getChangedFiles();
|
||||
const resolution = resolveScopes(
|
||||
manifest,
|
||||
@@ -369,7 +493,6 @@ async function runRoutedCi(
|
||||
if (await bgReporter.checkBreakGlass()) {
|
||||
process.stderr.write(`CI reviewer: ${rootConfig.breakGlassMarker} detected; skipping.\n`);
|
||||
await bgReporter.postSkipNote();
|
||||
await source.dispose();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -379,21 +502,17 @@ async function runRoutedCi(
|
||||
}
|
||||
|
||||
// Build ONE link context for all scopes (rate-limit hygiene): diff lines from the
|
||||
// already-fetched changed files, base SHA via a single `gh pr view`.
|
||||
// already-fetched changed files, base OID from the memoized PR metadata (the same
|
||||
// immutable OID the trusted config root was materialized from).
|
||||
const link: LinkContext = {
|
||||
repo,
|
||||
prNumber,
|
||||
diffLines: buildDiffLineIndex(changed.map((file) => ({ path: file.path, patch: file.patch }))),
|
||||
};
|
||||
try {
|
||||
const { stdout } = await run(
|
||||
"gh",
|
||||
["pr", "view", String(prNumber), "--repo", repo, "--json", "baseRefOid"],
|
||||
{ cwd },
|
||||
);
|
||||
const oid = (JSON.parse(stdout) as { baseRefOid?: string }).baseRefOid;
|
||||
if (oid) {
|
||||
link.baseSha = oid;
|
||||
const { baseOid } = await source.getMetadata();
|
||||
if (baseOid) {
|
||||
link.baseSha = baseOid;
|
||||
}
|
||||
} catch {
|
||||
// leave baseSha unset → out-of-diff findings degrade to plain text
|
||||
@@ -419,7 +538,20 @@ async function runRoutedCi(
|
||||
const isDefault = scope.configDir === ".";
|
||||
let review: CoordinatorOutput;
|
||||
try {
|
||||
const config = await loadScopeConfig(cwd, scopeDef, manifest, rootConfig);
|
||||
// A scope whose config dir doesn't exist at the TRUSTED base commit is a
|
||||
// scope this PR introduces: review it with the root config rather than
|
||||
// failing the run on exactly that PR. The scope's own config (PR-owned,
|
||||
// untrusted for this run) activates once it merges.
|
||||
let effectiveScopeDef = scopeDef;
|
||||
if (!hasScopeConfig(configRoot, scopeDef)) {
|
||||
process.stderr.write(
|
||||
`CI reviewer: [${scope.name}] no config at "${scopeDef.config}" in the PR's base ` +
|
||||
`commit (new in this PR?); reviewing with the root config — the scope's config ` +
|
||||
`takes effect after merge.\n`,
|
||||
);
|
||||
effectiveScopeDef = { ...scopeDef, config: "." };
|
||||
}
|
||||
const config = await loadScopeConfig(configRoot, effectiveScopeDef, manifest, rootConfig);
|
||||
review = await runReview(source, {
|
||||
config,
|
||||
mode: "ci",
|
||||
@@ -427,6 +559,7 @@ async function runRoutedCi(
|
||||
route,
|
||||
includePaths: scope.files,
|
||||
passesBudgetMs: budget,
|
||||
runsDir: workspaceRunsDir(cwd),
|
||||
onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -436,7 +569,6 @@ async function runRoutedCi(
|
||||
}
|
||||
results.push({ scope: scope.name, isDefault, review });
|
||||
}
|
||||
await source.dispose();
|
||||
|
||||
const reporterFor = (tag: string, withLink = false): GitHubReporter =>
|
||||
new GitHubReporter({
|
||||
|
||||
@@ -40,9 +40,11 @@ Options:
|
||||
--no-fail always exit 0, even on request-changes
|
||||
-h, --help show this help
|
||||
|
||||
Note: agents read the local working tree for surrounding context, so --pr uses the
|
||||
PR's diff (authoritative) but your checked-out files for context. For full fidelity
|
||||
on a PR, \`gh pr checkout <n>\` first, then run a plain \`ecr review\`.
|
||||
Note: with --repo (or in CI), --pr materializes the PR-head tree (pinned to its
|
||||
immutable commit, scrubbed of ambient runtime config) so reads match the PR; if
|
||||
that isn't possible, it falls back to your checked-out files with a warning.
|
||||
Config always loads from YOUR checkout in local runs — you are the trust
|
||||
principal here. In \`ecr ci\`, config loads from the PR's trusted base commit.
|
||||
|
||||
Exit codes: 0 approve / approve-with-comments, 1 request-changes, 2 error.
|
||||
`;
|
||||
|
||||
+39
-18
@@ -6,6 +6,7 @@ import readline from "node:readline/promises";
|
||||
|
||||
import { hasConfig, loadReviewConfig } from "../config/load.js";
|
||||
import type { AuthConfigEntry } from "../config/schema.js";
|
||||
import { jwtExpiryMs } from "../core/auth.js";
|
||||
import { opencodeBinSource } from "../core/opencode.js";
|
||||
import { errorMessage } from "../core/util.js";
|
||||
|
||||
@@ -62,16 +63,28 @@ export function opencodeAuthJsonPath(env: NodeJS.ProcessEnv = process.env): stri
|
||||
return path.join(dataHome, "opencode", "auth.json");
|
||||
}
|
||||
|
||||
/** The stored ChatGPT sign-in's refresh token, if OpenCode has one. */
|
||||
async function readStoredRefreshToken(): Promise<string | null> {
|
||||
/**
|
||||
* The stored ChatGPT sign-in's ACCESS token, if OpenCode has a live one. The
|
||||
* refresh token deliberately never leaves OpenCode's store: refresh tokens are
|
||||
* SINGLE-USE (rotation) and OpenCode is their sole legitimate consumer — a copy
|
||||
* in a shell config or CI secret dies on the next rotation and can take the
|
||||
* whole sign-in with it. The access token is a plain bearer that stays valid for
|
||||
* days and never touches rotation.
|
||||
*/
|
||||
async function readStoredAccessToken(): Promise<{ token: string; expiresMs: number } | null> {
|
||||
try {
|
||||
const raw = await readFile(opencodeAuthJsonPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<
|
||||
string,
|
||||
{ type?: string; refresh?: string } | undefined
|
||||
{ type?: string; access?: string } | undefined
|
||||
>;
|
||||
const openai = parsed.openai;
|
||||
return openai?.type === "oauth" && openai.refresh ? openai.refresh : null;
|
||||
if (openai?.type !== "oauth" || !openai.access) {
|
||||
return null;
|
||||
}
|
||||
const expiresMs = jwtExpiryMs(openai.access) ?? 0;
|
||||
// An expired stored token means the sign-in needs redoing anyway.
|
||||
return expiresMs > Date.now() ? { token: openai.access, expiresMs } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -114,7 +127,7 @@ export async function setupAuthCommand(argv: string[] = []): Promise<void> {
|
||||
} else {
|
||||
err("No .expo-code-review config here — setting up the default ChatGPT/Codex flow.");
|
||||
plan = planFromAuth([
|
||||
{ provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_REFRESH_TOKEN" },
|
||||
{ provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_ACCESS_TOKEN" },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -132,14 +145,17 @@ export async function setupAuthCommand(argv: string[] = []): Promise<void> {
|
||||
if (process.env[tokenEnv]) {
|
||||
err(`✓ ${tokenEnv} is already set in this shell — skipping the ChatGPT sign-in.`);
|
||||
} else {
|
||||
let refresh = await readStoredRefreshToken();
|
||||
if (refresh) {
|
||||
err("Found an existing ChatGPT sign-in in OpenCode.");
|
||||
if (!(await confirm(`Reuse it for ${tokenEnv}?`, yes))) {
|
||||
refresh = null;
|
||||
let stored = await readStoredAccessToken();
|
||||
if (stored) {
|
||||
err(
|
||||
`Found a live ChatGPT sign-in in OpenCode (access token valid ` +
|
||||
`${Math.max(1, Math.round((stored.expiresMs - Date.now()) / 86_400_000))} more day(s)).`,
|
||||
);
|
||||
if (!(await confirm(`Use it for ${tokenEnv}?`, yes))) {
|
||||
stored = null;
|
||||
}
|
||||
}
|
||||
if (!refresh) {
|
||||
if (!stored) {
|
||||
err("This will run the bundled `opencode auth login` (interactive).");
|
||||
err("When it prompts:");
|
||||
err(" 1. select the provider: OpenAI");
|
||||
@@ -156,19 +172,24 @@ export async function setupAuthCommand(argv: string[] = []): Promise<void> {
|
||||
`\`opencode auth login\` exited with ${result.status ?? "a signal"}; nothing was changed.`,
|
||||
);
|
||||
}
|
||||
refresh = await readStoredRefreshToken();
|
||||
if (!refresh) {
|
||||
stored = await readStoredAccessToken();
|
||||
if (!stored) {
|
||||
throw new Error(
|
||||
"The login finished but no ChatGPT sign-in was stored — did you select " +
|
||||
"The login finished but no live ChatGPT sign-in was stored — did you select " +
|
||||
'OpenAI → "Sign in with ChatGPT"? Re-run `ecr setup-auth` to try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (refresh) {
|
||||
// The REFRESH token is the durable secret: access tokens are short-lived,
|
||||
// and OpenCode mints them from this on demand.
|
||||
exports.push(exportLine(tokenEnv, refresh));
|
||||
if (stored) {
|
||||
// The ACCESS token: a plain bearer, valid for days, no rotation involved.
|
||||
// (The refresh token stays in OpenCode's store — it is single-use, and
|
||||
// copying it anywhere kills it on the next rotation.)
|
||||
exports.push(exportLine(tokenEnv, stored.token));
|
||||
err(
|
||||
`Note: this access token expires in ~${Math.max(1, Math.round((stored.expiresMs - Date.now()) / 86_400_000))} day(s); ` +
|
||||
`re-run \`ecr setup-auth\` then to refresh it (your OpenCode sign-in stays valid).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,22 @@ export interface LoadedScopeConfig extends LoadedConfig {
|
||||
scopeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scope's own config dir exists under `root` — the same path
|
||||
* `loadScopeConfig` reads (deliberately NOT via resolveConfigDir: ECR_CONFIG_DIR
|
||||
* must never redirect scope subtrees). `ecr ci` uses this against the TRUSTED
|
||||
* BASE root to give scopes that are new in a PR a defined miss behavior (review
|
||||
* with the root config; the scope config activates after merge) instead of
|
||||
* failing the run on exactly the PR that introduces the scope.
|
||||
*/
|
||||
export function hasScopeConfig(root: string, scope: RoutingScope): boolean {
|
||||
if (scope.config === ".") {
|
||||
return true;
|
||||
}
|
||||
const dir = path.join(root, scope.config, CONFIG_DIRNAME);
|
||||
return existsSync(path.join(dir, "config.jsonc")) || existsSync(path.join(dir, "config.json"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one scope's fully-resolved config. The default scope (config '.') reuses
|
||||
* the root config unchanged except auth; a nested scope reads its own
|
||||
|
||||
+10
-4
@@ -40,10 +40,15 @@ export const ReviewConfigSchema = z.object({
|
||||
maxChangedLines: z.number().int().positive().default(1000),
|
||||
// Secondary guard so a chunk isn't an absurd number of tiny-diff files.
|
||||
maxFiles: z.number().int().positive().default(20),
|
||||
// Max concurrent reviewer calls across all agents/chunks.
|
||||
concurrency: z.number().int().positive().default(6),
|
||||
// Max concurrent reviewer calls across all agents/chunks. Unset ⇒ resolved
|
||||
// from the auth mode: 6 for API-key runs, 3 when a subscription (oauth)
|
||||
// credential is configured — one ChatGPT account handles six parallel
|
||||
// streams poorly (requests get parked = the stall signature), and several
|
||||
// PRs may be reviewing on the same credential at once. An explicit value
|
||||
// here always wins. See effectiveConcurrency in core/review.ts.
|
||||
concurrency: z.number().int().positive().optional(),
|
||||
})
|
||||
.default({ maxChangedLines: 1000, maxFiles: 20, concurrency: 6 }),
|
||||
.default({ maxChangedLines: 1000, maxFiles: 20 }),
|
||||
noise: z
|
||||
.object({
|
||||
additionalIgnores: z.array(z.string()).default([]),
|
||||
@@ -284,7 +289,8 @@ export interface LoadedConfig {
|
||||
chunk: {
|
||||
maxChangedLines: number;
|
||||
maxFiles: number;
|
||||
concurrency: number;
|
||||
/** Unset ⇒ resolved by effectiveConcurrency (auth-mode-aware default). */
|
||||
concurrency?: number;
|
||||
};
|
||||
noise: {
|
||||
additionalIgnores: string[];
|
||||
|
||||
+62
-6
@@ -103,6 +103,34 @@ export function checkOauthTokenShape(
|
||||
detail: `${tokenEnv} holds only ${token.length} characters, too short to be a real ${provider} token — it looks truncated. ${fix}`,
|
||||
};
|
||||
}
|
||||
// A ChatGPT access token carries its own expiry — check it up front, so a
|
||||
// lapsed credential is one clear message instead of N failed passes, and a
|
||||
// nearly-lapsed one warns before it bites mid-run.
|
||||
if (provider === "openai" && isJwtAccessToken(token)) {
|
||||
const expires = jwtExpiryMs(token);
|
||||
if (expires !== null) {
|
||||
const remainingMs = expires - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
detail:
|
||||
`${tokenEnv} holds a ChatGPT access token that EXPIRED ${Math.ceil(-remainingMs / 86_400_000)} day(s) ago. ` +
|
||||
`Mint a fresh one (\`ecr setup-auth\`, or your token-rotator job) and update ${tokenEnv}.`,
|
||||
};
|
||||
}
|
||||
if (remainingMs < 3 * 86_400_000) {
|
||||
return {
|
||||
...ok,
|
||||
detail: `oauth for ${provider}; token env ${tokenEnv} is set`,
|
||||
warning:
|
||||
`${tokenEnv}'s ChatGPT access token expires in ${Math.max(1, Math.round(remainingMs / 3_600_000))}h — ` +
|
||||
`re-mint it soon (\`ecr setup-auth\`, or your token-rotator job).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ...ok, detail: `oauth for ${provider}; token env ${tokenEnv} is set` };
|
||||
}
|
||||
|
||||
// Only anthropic's formats are known well enough to say anything about.
|
||||
if (provider !== "anthropic") {
|
||||
return ok;
|
||||
@@ -291,11 +319,38 @@ export function checkProviderAuth(
|
||||
};
|
||||
}
|
||||
|
||||
/** A ChatGPT access token is a JWT (three base64url segments); refresh tokens are opaque. */
|
||||
export function isJwtAccessToken(token: string): boolean {
|
||||
return token.startsWith("eyJ") && token.split(".").length === 3;
|
||||
}
|
||||
|
||||
/** A JWT's `exp` claim as epoch ms, decoded (not verified) — null when unreadable. */
|
||||
export function jwtExpiryMs(token: string): number | null {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
|
||||
exp?: unknown;
|
||||
};
|
||||
return typeof claims.exp === "number" ? claims.exp * 1000 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The auth.json entry for one oauth credential. Provider-shaped:
|
||||
* - openai: the durable secret is the REFRESH token (a ChatGPT/Codex sign-in's
|
||||
* access tokens live ~1h, shorter than a worst-case run), so store it with
|
||||
* `expires: 0` and let OpenCode's codex plugin mint access tokens on demand.
|
||||
* The auth.json entry for one oauth credential. Shaped by what the token IS:
|
||||
* - a JWT (an ACCESS token, e.g. from `ecr setup-auth` or a rotator job): use it
|
||||
* as-is and never refresh — refresh tokens are SINGLE-USE (rotation), so a
|
||||
* static/shared secret must not participate in rotation at all. Expiry comes
|
||||
* from the JWT's own `exp` claim so OpenCode trusts it exactly as long as it
|
||||
* is valid.
|
||||
* - an opaque openai token (a REFRESH token): store it with `expires: 0` and let
|
||||
* OpenCode's codex plugin mint the access token. Only safe when this run is
|
||||
* the token's SOLE consumer — a value shared across runs/repos dies on first
|
||||
* rotation (learned the hard way).
|
||||
* - everything else: the token IS the access credential (e.g. long-lived
|
||||
* setup-token style bearers), far-future expiry so OpenCode never tries to
|
||||
* refresh a credential that has no refresh half.
|
||||
@@ -304,10 +359,11 @@ export function oauthAuthJsonEntry(
|
||||
provider: string,
|
||||
token: string,
|
||||
): Record<string, string | number> {
|
||||
if (provider === "openai") {
|
||||
if (provider === "openai" && !isJwtAccessToken(token)) {
|
||||
return { type: "oauth", access: "", refresh: token, expires: 0 };
|
||||
}
|
||||
return { type: "oauth", access: token, refresh: "", expires: Date.now() + YEAR_MS };
|
||||
const expires = isJwtAccessToken(token) ? jwtExpiryMs(token) : null;
|
||||
return { type: "oauth", access: token, refresh: "", expires: expires ?? Date.now() + YEAR_MS };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface RunLogRecord {
|
||||
agentTokens?: Record<string, TokenUsage>;
|
||||
/** provider/model that ACTUALLY answered each pass (not what was configured). */
|
||||
agentModels?: Record<string, string>;
|
||||
/** Provider rate-limit (429) events observed in the OpenCode server log this run. */
|
||||
rateLimitEvents?: number;
|
||||
// The reasoning trail behind the posted result: raw per-agent findings before
|
||||
// coordination, the coverage gaps reported to the coordinator, and the findings
|
||||
// the verifier rejected. Together these explain WHY the final finding set looks
|
||||
|
||||
+62
-9
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { createOpencode } from "@opencode-ai/sdk";
|
||||
|
||||
import type { LoadedConfig } from "../config/schema.js";
|
||||
import { RateLimitWatch } from "./throttle.js";
|
||||
import { toolMap } from "./tools.js";
|
||||
import { errorMessage, sleep } from "./util.js";
|
||||
|
||||
@@ -11,6 +12,8 @@ export interface OpencodeHandle {
|
||||
client: any;
|
||||
url: string;
|
||||
close: () => void;
|
||||
/** Rate-limit evidence from this server's own log (see core/throttle.ts). */
|
||||
rateLimit: RateLimitWatch;
|
||||
}
|
||||
|
||||
/** Token usage as reported on an OpenCode assistant message's `info.tokens`. */
|
||||
@@ -225,7 +228,9 @@ export async function startOpencode(config: unknown): Promise<OpencodeHandle> {
|
||||
port: 0,
|
||||
config: config as any,
|
||||
});
|
||||
return { client, url: server.url, close: () => server.close() };
|
||||
// Watch THIS server's log for provider throttle evidence (prepareAuth has already
|
||||
// pointed XDG_DATA_HOME at the run's isolated dir when auth is injected).
|
||||
return { client, url: server.url, close: () => server.close(), rateLimit: new RateLimitWatch() };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -459,18 +464,31 @@ const FINALIZE_STALL_MS = 60 * 1000;
|
||||
// Breathing room before the retry: if the silence came from provider-side throttling
|
||||
// or backoff, reconnecting instantly is the worst move.
|
||||
const STALL_RETRY_BACKOFF_MS = 20 * 1000;
|
||||
// When the account is provably rate-limited, wait in longer beats: re-sending the
|
||||
// pass's whole context into a throttled account only deepens the limit. Several
|
||||
// waits fit inside a pass budget, and each is long enough for a limit window to move.
|
||||
const RATE_LIMIT_WAIT_MS = 90 * 1000;
|
||||
// Only retry when enough of the pass's budget remains for the fresh attempt to
|
||||
// plausibly finish; otherwise go straight to the soft landing.
|
||||
const STALL_RETRY_MIN_REMAINING_MS = STALL_MS + 60 * 1000;
|
||||
|
||||
/**
|
||||
* What to do about a stalled attempt: start over from a clean session, or stop and
|
||||
* try to salvage findings. Exactly ONE retry, and only with enough budget left for it
|
||||
* to land — a second wedged attempt would just spend the rest of the pass's window,
|
||||
* What to do about a stalled attempt: WAIT (the account is provably rate-limited —
|
||||
* see core/throttle.ts — so patience beats re-sending the context; waits don't
|
||||
* consume the one retry), start over from a clean session, or stop and salvage
|
||||
* findings. Exactly ONE wedged retry, and only with enough budget left for it to
|
||||
* land — a second wedged attempt would just spend the rest of the pass's window,
|
||||
* which is the failure this whole mechanism exists to end. Exported for tests.
|
||||
*/
|
||||
export function stallAction(attempt: number, remainingMs: number): "retry" | "soft-land" {
|
||||
return attempt === 0 && remainingMs > STALL_RETRY_MIN_REMAINING_MS ? "retry" : "soft-land";
|
||||
export function stallAction(
|
||||
wedgedRetries: number,
|
||||
remainingMs: number,
|
||||
rateLimited = false,
|
||||
): "wait" | "retry" | "soft-land" {
|
||||
if (rateLimited && remainingMs > STALL_RETRY_MIN_REMAINING_MS) {
|
||||
return "wait";
|
||||
}
|
||||
return wedgedRetries === 0 && remainingMs > STALL_RETRY_MIN_REMAINING_MS ? "retry" : "soft-land";
|
||||
}
|
||||
|
||||
const FINALIZE_PROMPT =
|
||||
@@ -707,6 +725,7 @@ export async function promptAgent(
|
||||
}
|
||||
};
|
||||
|
||||
let wedgedRetries = 0;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const session = unwrap<{ id: string }>(
|
||||
await handle.client.session.create({
|
||||
@@ -738,11 +757,29 @@ export async function promptAgent(
|
||||
// wedged request to answer. Exactly one retry, and only when enough budget
|
||||
// remains for it to land; after that the finalize is still worth a try as the
|
||||
// only remaining salvage (in eas-cli#4084 the session did respond once aborted).
|
||||
//
|
||||
// EXCEPT when the server log shows the account is rate-limited: then the
|
||||
// silence is throttling, not a wedge, and the patient move is to wait —
|
||||
// re-sending the pass's whole context would deepen the limit. Waits repeat
|
||||
// (never consuming the one wedged retry) until the evidence goes stale or
|
||||
// the pass runs out of room, both bounded by the pass deadline.
|
||||
if (error instanceof NoProgress) {
|
||||
await abortQuietly(handle, session.id);
|
||||
absorb(error);
|
||||
await handle.rateLimit.check();
|
||||
const remaining = deadline - Date.now();
|
||||
if (stallAction(attempt, remaining) === "retry") {
|
||||
const action = stallAction(wedgedRetries, remaining, handle.rateLimit.recentlyLimited());
|
||||
if (action === "wait") {
|
||||
args.onActivity?.(
|
||||
`provider is rate-limiting this account (429 in the server log; ` +
|
||||
`${handle.rateLimit.events} so far) — waiting ${Math.round(RATE_LIMIT_WAIT_MS / 1000)}s ` +
|
||||
`instead of retrying (${Math.round(remaining / 60000)}m of budget left)`,
|
||||
);
|
||||
await sleep(RATE_LIMIT_WAIT_MS);
|
||||
continue;
|
||||
}
|
||||
if (action === "retry") {
|
||||
wedgedRetries++;
|
||||
args.onActivity?.(
|
||||
`stalled — no output for ${Math.round(error.idleMs / 1000)}s; ` +
|
||||
`retrying once from a clean session (${Math.round(remaining / 60000)}m of budget left)`,
|
||||
@@ -772,6 +809,20 @@ const CORRECTIVE_WAIT_MS = 2 * 60 * 1000;
|
||||
|
||||
/** Backoff (ms) before the 2nd and 3rd attempt of a transient-failing model call. */
|
||||
const TRANSIENT_BACKOFF_MS = [2_000, 8_000];
|
||||
/**
|
||||
* Rate limits need patience, not persistence: a limited account stays limited for
|
||||
* tens of seconds to minutes, so the 2s/8s schedule just burns the retries. Shared
|
||||
* subscription credentials (several PRs reviewing at once) make this the common
|
||||
* transient, hence the dedicated, slower schedule.
|
||||
*/
|
||||
const RATE_LIMIT_BACKOFF_MS = [15_000, 45_000, 90_000];
|
||||
|
||||
const RATE_LIMIT_ERROR = /\b429\b|rate.?limit|too many requests/i;
|
||||
|
||||
/** A transient error that is specifically a provider rate limit. */
|
||||
export function isRateLimitError(error: unknown): boolean {
|
||||
return !(error instanceof AgentTimeoutError) && RATE_LIMIT_ERROR.test(errorMessage(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* A transient, retryable API failure — a one-off rate-limit (429), server error
|
||||
@@ -821,13 +872,15 @@ async function withTransientRetry<T>(
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
const waitMs = TRANSIENT_BACKOFF_MS[attempt];
|
||||
// Rate limits get the slower, longer schedule — see RATE_LIMIT_BACKOFF_MS.
|
||||
const schedule = isRateLimitError(error) ? RATE_LIMIT_BACKOFF_MS : TRANSIENT_BACKOFF_MS;
|
||||
const waitMs = schedule[attempt];
|
||||
if (waitMs === undefined || !isTransientApiError(error)) {
|
||||
throw error;
|
||||
}
|
||||
onActivity?.(
|
||||
`${label}: transient API error (${errorMessage(error)}); retry ${attempt + 1}/${
|
||||
TRANSIENT_BACKOFF_MS.length
|
||||
schedule.length
|
||||
} in ${Math.round(waitMs / 1000)}s`,
|
||||
);
|
||||
await sleep(waitMs);
|
||||
|
||||
+9
-2
@@ -154,7 +154,12 @@ export function renderMarkdown(
|
||||
const dropped = withFp.filter(({ fp }) => dismissedByFp.has(fp));
|
||||
|
||||
const lines: string[] = [commentMarker(tag), "## 🤖 AI code review", ""];
|
||||
lines.push(`**Decision:** ${decisionLabel(review.decision)}`, "", review.summary, "");
|
||||
lines.push(
|
||||
`**Decision:** ${review.couldNotComplete ? "No review — every pass failed" : decisionLabel(review.decision)}`,
|
||||
"",
|
||||
review.summary,
|
||||
"",
|
||||
);
|
||||
|
||||
if (review.incomplete.length > 0) {
|
||||
lines.push(
|
||||
@@ -334,7 +339,9 @@ export function renderAggregateMarkdown(
|
||||
"| --- | --- | --- |",
|
||||
];
|
||||
for (const { result, kept } of perScope) {
|
||||
lines.push(`| ${result.scope} | ${decisionLabel(result.review.decision)} | ${kept.length} |`);
|
||||
lines.push(
|
||||
`| ${result.scope} | ${result.review.couldNotComplete ? "No review — every pass failed" : decisionLabel(result.review.decision)} | ${kept.length} |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
|
||||
+105
-16
@@ -1,7 +1,7 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { LoadedAgent, LoadedConfig } from "../config/schema.js";
|
||||
import type { ReviewSource } from "../sources/source.js";
|
||||
import type { PreparedReadRoot, ReviewSource } from "../sources/source.js";
|
||||
import { prepareAuth } from "./auth.js";
|
||||
import { coordinate } from "./coordinator.js";
|
||||
import { writeRunLog } from "./log.js";
|
||||
@@ -46,6 +46,14 @@ export interface ReviewRunOptions {
|
||||
includePaths?: string[];
|
||||
/** Wall-clock ceiling for all review passes. Default: today's PASSES_BUDGET_MS. */
|
||||
passesBudgetMs?: number;
|
||||
/**
|
||||
* Directory for the run log + patch workspace (`.runs/`). Defaults to
|
||||
* `<configDir>/.runs` — correct when config lives in the checkout, WRONG when
|
||||
* config was materialized into a temporary trusted root (removed on exit, and
|
||||
* CI uploads the log from the workspace path). `ecr ci` pins this to the
|
||||
* workspace checkout explicitly.
|
||||
*/
|
||||
runsDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +81,21 @@ function makeRunId(): string {
|
||||
* → coordinate → apply policy. Returns a CoordinatorOutput; the CLI commands are
|
||||
* thin wrappers that supply a Source and render the result.
|
||||
*/
|
||||
/**
|
||||
* Max concurrent reviewer calls: an explicit config value wins; otherwise 3 when a
|
||||
* subscription (oauth) credential is configured, else 6. One ChatGPT account
|
||||
* handles six parallel streams poorly — requests get parked server-side (the
|
||||
* stall signature seen on eas-cli#4084), and several PRs may be reviewing on the
|
||||
* same credential at once — so subscription runs trade a little wall-clock for a
|
||||
* lot of reliability. Exported for tests.
|
||||
*/
|
||||
export function effectiveConcurrency(config: LoadedConfig): number {
|
||||
if (config.chunk.concurrency) {
|
||||
return config.chunk.concurrency;
|
||||
}
|
||||
return config.auth.some((entry) => entry.mode === "oauth") ? 3 : 6;
|
||||
}
|
||||
|
||||
export async function runReview(
|
||||
source: ReviewSource,
|
||||
options: ReviewRunOptions,
|
||||
@@ -81,7 +104,7 @@ export async function runReview(
|
||||
const started = Date.now();
|
||||
const runId = makeRunId();
|
||||
const progress = options.onProgress ?? (() => {});
|
||||
const runsRoot = path.join(config.configDir, ".runs");
|
||||
const runsRoot = options.runsDir ?? path.join(config.configDir, ".runs");
|
||||
const runDir = path.join(runsRoot, runId);
|
||||
const logPath = path.join(runsRoot, "reviews.jsonl");
|
||||
|
||||
@@ -136,19 +159,28 @@ export async function runReview(
|
||||
return output;
|
||||
}
|
||||
|
||||
// Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
|
||||
// and doing it first means nothing that can throw sits between the chdir and the
|
||||
// guarded blocks — so a prepareAuth failure can't leak the worktree or leave cwd
|
||||
// pointing at it.
|
||||
const auth = await prepareAuth(config);
|
||||
|
||||
// Read the PR-head tree (not the current checkout) when the source can materialize
|
||||
// it, so the agents' surrounding-source reads and the verifier's re-reads see the
|
||||
// versions that match the diff. Config is already fully loaded in memory, so the
|
||||
// chdir doesn't affect it; run-log/patch paths are absolute; gh/git calls already
|
||||
// ran above. Fails soft to the current directory.
|
||||
// Materialize the PR-head tree (not the current checkout) when the source can, so
|
||||
// the agents' surrounding-source reads and the verifier's re-reads see the versions
|
||||
// that match the diff. Config is already fully loaded in memory, so the chdir below
|
||||
// doesn't affect it; run-log/patch paths are absolute; gh/git calls already ran
|
||||
// above. Failure policy is MODE-DEPENDENT (see resolveReadRoot): CI fails closed —
|
||||
// with a base-SHA checkout the fallback tree is pre-PR content, and silently
|
||||
// reviewing/verifying that drops real findings — while a local run falls back to
|
||||
// the user's own checkout with a warning.
|
||||
const originalCwd = process.cwd();
|
||||
const readRoot = (await source.prepareReadRootAsync?.()) ?? null;
|
||||
const readRoot = await resolveReadRoot(source, options.mode, progress);
|
||||
|
||||
// Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
|
||||
// and doing it after readRoot but before chdir means a prepareAuth failure can't
|
||||
// leave cwd pointing at the worktree — it only has the worktree itself to release.
|
||||
let auth: Awaited<ReturnType<typeof prepareAuth>>;
|
||||
try {
|
||||
auth = await prepareAuth(config);
|
||||
} catch (error) {
|
||||
await readRoot?.cleanup();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const restoreCwd = async (): Promise<void> => {
|
||||
if (readRoot) {
|
||||
process.chdir(originalCwd);
|
||||
@@ -247,10 +279,11 @@ export async function runReview(
|
||||
);
|
||||
// Only chunk (and add a cross-cutting pass) when the diff exceeds one chunk.
|
||||
const chunked = chunks.length > 1;
|
||||
const concurrency = effectiveConcurrency(config);
|
||||
progress(
|
||||
`Running ${selectedAgents.length} reviewer(s) [${selectedAgents.map((a) => a.id).join(", ")}] over ${chunks.length} chunk(s)` +
|
||||
`${chunked ? " + cross-cutting pass" : ""} ` +
|
||||
`(${kept.length} files, concurrency ${config.chunk.concurrency})…`,
|
||||
`(${kept.length} files, concurrency ${concurrency})…`,
|
||||
);
|
||||
|
||||
for (const agent of selectedAgents) {
|
||||
@@ -437,7 +470,7 @@ export async function runReview(
|
||||
// TIMEOUT, instead of dropping the work we break it into units that converge:
|
||||
// subdivide the chunk, then a fast no-tools pass, and only report a coverage gap
|
||||
// when even that can't finish inside the budget — so dropped work is never silent.
|
||||
await runGrowableQueue(tasks, config.chunk.concurrency, async (task, enqueue) => {
|
||||
await runGrowableQueue(tasks, concurrency, async (task, enqueue) => {
|
||||
const minutes = Math.round(task.maxWaitMs / 60000);
|
||||
try {
|
||||
const { value, cost, truncated, tokens, model } = await promptAndParse(
|
||||
@@ -598,6 +631,9 @@ export async function runReview(
|
||||
"⚠️ The AI review could not complete: every review pass failed or timed out, " +
|
||||
'so these changes were effectively NOT reviewed. Treat this as "no review", not "looks good".',
|
||||
incomplete: coverageNotes,
|
||||
// Presentation override: without it the comment header reads "Decision:
|
||||
// Approve with comments" over a review that reviewed nothing (euxy#8).
|
||||
couldNotComplete: true,
|
||||
};
|
||||
} else {
|
||||
progress("Coordinating findings…");
|
||||
@@ -691,6 +727,17 @@ export async function runReview(
|
||||
output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
|
||||
}
|
||||
|
||||
// Surface provider throttling as a fact about the run: passes already waited or
|
||||
// backed off, but the operator should still SEE that it happened (a run that
|
||||
// was rate-limited is slower and may carry partial passes — that's the cause).
|
||||
await handle!.rateLimit.check();
|
||||
if (handle!.rateLimit.events > 0) {
|
||||
progress(
|
||||
` ⚠ provider rate-limited this run ${handle!.rateLimit.events} time(s) ` +
|
||||
`(429s in the OpenCode server log) — passes waited it out rather than failing`,
|
||||
);
|
||||
}
|
||||
|
||||
// Every pass says which model actually answered it — in the job log, the step
|
||||
// summary table, and the run log — so a wrong or substituted model is always
|
||||
// visible, not just when the substitution warning fires.
|
||||
@@ -716,6 +763,7 @@ export async function runReview(
|
||||
agentFindings,
|
||||
coverageNotes,
|
||||
verifierDropped,
|
||||
...(handle!.rateLimit.events > 0 ? { rateLimitEvents: handle!.rateLimit.events } : {}),
|
||||
durationMs: Date.now() - started,
|
||||
decision: output.decision,
|
||||
findingCount: output.findings.length,
|
||||
@@ -842,6 +890,47 @@ export function reconcileSummary(summary: string, remaining: number): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the tree the review reads from, applying the mode's trust policy:
|
||||
*
|
||||
* - `null` from the source means "nothing to materialize" — reviewing the current
|
||||
* checkout is intended (local diffs, or `--pr` without a repo). Never an error.
|
||||
* - A materialization FAILURE (throw) is fatal in CI: the checkout there is the
|
||||
* trusted BASE tree, and falling back to it would silently review and verify
|
||||
* pre-PR file contents (dropping real findings with no trace in the output).
|
||||
* The throw propagates to `ecr ci`'s catch, which posts the one terminal
|
||||
* "not reviewed" comment.
|
||||
* - The same failure in local mode degrades softly to the user's own checkout —
|
||||
* the user is the trust principal there and sees the warning directly.
|
||||
*
|
||||
* Exported for tests.
|
||||
*/
|
||||
export async function resolveReadRoot(
|
||||
source: ReviewSource,
|
||||
mode: ReviewRunOptions["mode"],
|
||||
progress: (message: string) => void,
|
||||
): Promise<PreparedReadRoot | null> {
|
||||
if (!source.prepareReadRootAsync) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await source.prepareReadRootAsync();
|
||||
} catch (error) {
|
||||
if (mode === "ci") {
|
||||
throw new Error(
|
||||
`Could not materialize the PR-head tree to review (and the CI checkout is the ` +
|
||||
`trusted base, so reviewing it instead would silently review the wrong ` +
|
||||
`contents): ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
progress(
|
||||
`Could not materialize the PR-head tree (${errorMessage(error)}); ` +
|
||||
`reading the current checkout instead — file contents may not match the PR.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Capitalize the first letter (coverage notes read as sentences). */
|
||||
function capitalize(text: string): string {
|
||||
return text.length > 0 ? text[0]!.toUpperCase() + text.slice(1) : text;
|
||||
|
||||
@@ -38,6 +38,13 @@ export interface ReviewMetadata {
|
||||
body: string;
|
||||
baseRef: string;
|
||||
headRef: string;
|
||||
/**
|
||||
* Immutable commit OIDs for the PR's base and head, when the source can resolve
|
||||
* them (GitHub PRs). Materialization pins to these — never to branch names —
|
||||
* so a rename/force-push between API calls can't swap the reviewed tree.
|
||||
*/
|
||||
baseOid?: string;
|
||||
headOid?: string;
|
||||
}
|
||||
|
||||
export const FindingSchema = z.object({
|
||||
@@ -86,6 +93,14 @@ export const CoordinatorOutputSchema = z.object({
|
||||
* cut-short review is never presented as complete.
|
||||
*/
|
||||
incomplete: z.array(z.string()).default([]),
|
||||
/**
|
||||
* True when EVERY pass failed — nothing was actually reviewed. Set by the
|
||||
* engine, never the model. Reporters must not render an approving decision
|
||||
* label for such a run (the decision enum has no "no review" member, and
|
||||
* widening it would ripple through dismiss state and exit codes — this flag
|
||||
* overrides the presentation instead).
|
||||
*/
|
||||
couldNotComplete: z.boolean().optional(),
|
||||
});
|
||||
export type CoordinatorOutput = z.infer<typeof CoordinatorOutputSchema>;
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readdir, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Ambient runtime configuration the OpenCode server (and the Claude-compatible
|
||||
* loaders inside it) discovers from its project directory. The review core chdirs
|
||||
* into a materialized PR-HEAD worktree before starting the server, so every one
|
||||
* of these is attacker-writable in a PR: a plugin or MCP definition is arbitrary
|
||||
* code execution in a process holding the model credential and a comment-capable
|
||||
* GH_TOKEN; a `.env` can repoint a provider base URL; instruction files inject
|
||||
* system-level prompts. OPENCODE_CONFIG_CONTENT (how ECR passes its own config)
|
||||
* MERGES with project config rather than replacing it, so deleting these from the
|
||||
* throwaway worktree is the only isolation that doesn't depend on OpenCode
|
||||
* semantics.
|
||||
*
|
||||
* Exact-name entries match files or directories at any depth; `.env` is matched
|
||||
* as a prefix (`.env`, `.env.local`, …). The PR's CHANGES to these files are
|
||||
* still reviewed — their diffs are inlined in the task prompt — but the reviewer
|
||||
* can no longer open their full head contents, and a finding citing one will
|
||||
* fail verification (a documented tradeoff of the scrub approach).
|
||||
*/
|
||||
export const AMBIENT_RUNTIME_CONFIG_NAMES = new Set([
|
||||
"opencode.json",
|
||||
"opencode.jsonc",
|
||||
".opencode",
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
".claude",
|
||||
".mcp.json",
|
||||
".cursor",
|
||||
".cursorrules",
|
||||
]);
|
||||
|
||||
/** Names never descended into (and never scrubbed as a unit — `.git` is the worktree link). */
|
||||
const SKIP_DIRS = new Set([".git", "node_modules"]);
|
||||
|
||||
/** Whether a directory entry is ambient runtime config that must not reach the model runtime. */
|
||||
export function isAmbientRuntimeConfig(name: string): boolean {
|
||||
return AMBIENT_RUNTIME_CONFIG_NAMES.has(name) || name === ".env" || name.startsWith(".env.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove ambient runtime config from a THROWAWAY materialized tree, at every
|
||||
* depth. Must only ever run on a tree ECR created and will delete (a worktree or
|
||||
* extracted archive) — never on the user's checkout. Returns the repo-relative
|
||||
* paths removed so callers can log them.
|
||||
*/
|
||||
export async function scrubAmbientRuntimeConfig(root: string): Promise<string[]> {
|
||||
const removed: string[] = [];
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (isAmbientRuntimeConfig(entry.name)) {
|
||||
await rm(full, { recursive: true, force: true });
|
||||
removed.push(path.relative(root, full));
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
||||
await walk(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(root);
|
||||
return removed.sort();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { open } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Rate-limit evidence, read from the OpenCode server's own log.
|
||||
*
|
||||
* Why this exists: provider throttling reaches us in two shapes. An EXPLICIT
|
||||
* failure (HTTP 429 / "rate limit" stream error) surfaces in OpenCode's log as a
|
||||
* structured ERROR line — that is proof. A SILENT one (the request is accepted
|
||||
* and parked server-side) produces no error anywhere and is indistinguishable
|
||||
* from a wedged request from the outside. So: the log watcher turns the explicit
|
||||
* case into a hard signal, and the stall path treats "stall + recent explicit
|
||||
* evidence" as throttling — the one situation where the right move is to WAIT
|
||||
* (re-sending the whole context into a limited account only makes it worse).
|
||||
*
|
||||
* During oauth runs prepareAuth points XDG_DATA_HOME at an isolated temp dir, so
|
||||
* the log we read belongs to exactly this run's server — no cross-talk with a
|
||||
* developer's own OpenCode sessions.
|
||||
*/
|
||||
|
||||
/** The OpenCode server's log file under the active data dir. */
|
||||
export function opencodeLogFile(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
||||
return path.join(dataHome, "opencode", "log", "opencode.log");
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider throttle signatures in OpenCode log lines. Matched only against ERROR
|
||||
* lines (a chatty INFO line mentioning "retry" must not count as evidence).
|
||||
*/
|
||||
const RATE_LIMIT_PATTERN = /\b429\b|rate.?limit|too many requests|quota exceeded/i;
|
||||
const ERROR_LINE = /\blevel=ERROR\b/;
|
||||
|
||||
/** Count rate-limit ERROR lines in a chunk of log text. Pure, for tests. */
|
||||
export function countRateLimitLines(chunk: string): number {
|
||||
let count = 0;
|
||||
for (const line of chunk.split("\n")) {
|
||||
if (ERROR_LINE.test(line) && RATE_LIMIT_PATTERN.test(line)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** How recent explicit evidence must be for a stall to be read as throttling. */
|
||||
const EVIDENCE_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Incremental watcher over the OpenCode server log. `check()` reads only what was
|
||||
* appended since the last call (cheap enough for poll loops); `recentlyLimited()`
|
||||
* is the signal the stall path consults. Fails soft everywhere: a missing or
|
||||
* unreadable log yields "no evidence", never an error.
|
||||
*/
|
||||
export class RateLimitWatch {
|
||||
/** Total rate-limit ERROR lines seen this run. */
|
||||
events = 0;
|
||||
/** Wall-clock time evidence was last SEEN (observation time, not log time). */
|
||||
lastSeenAt = 0;
|
||||
private offset = 0;
|
||||
|
||||
constructor(private readonly file: string = opencodeLogFile()) {}
|
||||
|
||||
/** Scan newly-appended log lines for rate-limit evidence. */
|
||||
async check(): Promise<number> {
|
||||
try {
|
||||
const handle = await open(this.file, "r");
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
if (size < this.offset) {
|
||||
this.offset = 0; // rotated/truncated — rescan from the top
|
||||
}
|
||||
if (size === this.offset) {
|
||||
return this.events;
|
||||
}
|
||||
const length = size - this.offset;
|
||||
const buffer = Buffer.alloc(length);
|
||||
await handle.read(buffer, 0, length, this.offset);
|
||||
this.offset = size;
|
||||
const found = countRateLimitLines(buffer.toString("utf8"));
|
||||
if (found > 0) {
|
||||
this.events += found;
|
||||
this.lastSeenAt = Date.now();
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
// No log yet (server just started) or unreadable — no evidence, no error.
|
||||
}
|
||||
return this.events;
|
||||
}
|
||||
|
||||
/** True when explicit throttle evidence appeared within the recency window. */
|
||||
recentlyLimited(now: number = Date.now()): boolean {
|
||||
return this.lastSeenAt > 0 && now - this.lastSeenAt < EVIDENCE_WINDOW_MS;
|
||||
}
|
||||
}
|
||||
+114
-19
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
|
||||
import { run } from "../core/exec.js";
|
||||
import { parseUnifiedDiff } from "../core/diff.js";
|
||||
import { scrubAmbientRuntimeConfig } from "../core/scrub.js";
|
||||
import type { DiffEntry, ReviewMetadata } from "../core/schema.js";
|
||||
import type { PreparedReadRoot, ReviewSource } from "./source.js";
|
||||
|
||||
@@ -14,6 +15,20 @@ export interface GitHubPRSourceOptions {
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/** A full 40-hex-char commit OID — the only ref form passed to security-sensitive git calls. */
|
||||
export function isCommitOid(value: string | undefined): value is string {
|
||||
return typeof value === "string" && /^[0-9a-f]{40}$/i.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append gh as a git credential helper for a single command. The token comes from
|
||||
* GH_TOKEN via the credential-helper protocol — never argv, never `.git/config` —
|
||||
* so a base-SHA checkout with `persist-credentials: false` (no extraheader) can
|
||||
* still fetch a private repo. Appending (not replacing) keeps a local user's own
|
||||
* helpers first, so developer machines behave exactly as before.
|
||||
*/
|
||||
const GH_CREDENTIAL_HELPER_ARGS = ["-c", "credential.helper=!gh auth git-credential"];
|
||||
|
||||
/**
|
||||
* Pulls PR diff + metadata through the `gh` CLI, which is preinstalled and
|
||||
* authenticated on GitHub Actions runners via GH_TOKEN.
|
||||
@@ -21,11 +36,22 @@ export interface GitHubPRSourceOptions {
|
||||
export class GitHubPRSource implements ReviewSource {
|
||||
constructor(private readonly options: GitHubPRSourceOptions) {}
|
||||
|
||||
private metadataPromise: Promise<ReviewMetadata> | undefined;
|
||||
|
||||
private repoArgs(): string[] {
|
||||
return this.options.repo ? ["--repo", this.options.repo] : [];
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<ReviewMetadata> {
|
||||
/**
|
||||
* Memoized internally (not just by memoizeSource) because the materialization
|
||||
* paths below need the immutable OIDs and must not depend on the caller having
|
||||
* called getMetadata() first.
|
||||
*/
|
||||
getMetadata(): Promise<ReviewMetadata> {
|
||||
return (this.metadataPromise ??= this.fetchMetadata());
|
||||
}
|
||||
|
||||
private async fetchMetadata(): Promise<ReviewMetadata> {
|
||||
const { stdout } = await run(
|
||||
"gh",
|
||||
[
|
||||
@@ -34,7 +60,10 @@ export class GitHubPRSource implements ReviewSource {
|
||||
String(this.options.prNumber),
|
||||
...this.repoArgs(),
|
||||
"--json",
|
||||
"title,body,baseRefName,headRefName",
|
||||
// baseRefOid/headRefOid are the immutable commit OIDs backing this PR at
|
||||
// this moment; every materialization below pins to them so a rename,
|
||||
// force-push, or deleted head between API calls can't swap a tree.
|
||||
"title,body,baseRefName,headRefName,baseRefOid,headRefOid",
|
||||
],
|
||||
{ cwd: this.options.cwd },
|
||||
);
|
||||
@@ -43,12 +72,16 @@ export class GitHubPRSource implements ReviewSource {
|
||||
body?: string;
|
||||
baseRefName?: string;
|
||||
headRefName?: string;
|
||||
baseRefOid?: string;
|
||||
headRefOid?: string;
|
||||
};
|
||||
return {
|
||||
title: parsed.title ?? "",
|
||||
body: parsed.body ?? "",
|
||||
baseRef: parsed.baseRefName ?? "",
|
||||
headRef: parsed.headRefName ?? "",
|
||||
baseOid: isCommitOid(parsed.baseRefOid) ? parsed.baseRefOid.toLowerCase() : undefined,
|
||||
headOid: isCommitOid(parsed.headRefOid) ? parsed.headRefOid.toLowerCase() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,27 +95,32 @@ export class GitHubPRSource implements ReviewSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the PR HEAD out into a throwaway git worktree so the agents and verifier
|
||||
* read the PR's versions of files (not whatever branch happens to be checked out).
|
||||
* Fetches the head from the repo's own URL — `refs/pull/<n>/head`, which the base
|
||||
* repo hosts even for fork PRs — so it's always the correct PR, independent of the
|
||||
* local `origin`. Fails SOFT: any problem (not a git repo, fetch/worktree error)
|
||||
* returns null, and the review falls back to reading the current checkout.
|
||||
* Fetch `ref` from the repo's own HTTPS URL and materialize `oid` as a detached
|
||||
* throwaway worktree under a fresh temp dir. Never falls back to a branch name:
|
||||
* `oid` is validated as a full commit hash before reaching git.
|
||||
*/
|
||||
async prepareReadRootAsync(): Promise<PreparedReadRoot | null> {
|
||||
const cwd = this.options.cwd;
|
||||
private async materializeWorktreeAsync(ref: string, oid: string): Promise<PreparedReadRoot> {
|
||||
if (!this.options.repo) {
|
||||
// Without an explicit owner/repo we can't build the fetch URL safely.
|
||||
return null;
|
||||
throw new Error("cannot materialize a PR tree without an explicit owner/repo");
|
||||
}
|
||||
if (!isCommitOid(oid)) {
|
||||
throw new Error(`refusing to materialize a non-OID ref: "${oid}"`);
|
||||
}
|
||||
const cwd = this.options.cwd;
|
||||
const url = `https://github.com/${this.options.repo}.git`;
|
||||
const ref = `refs/pull/${this.options.prNumber}/head`;
|
||||
let parent: string | undefined;
|
||||
try {
|
||||
await run("git", ["fetch", "--no-tags", "--depth=1", url, ref], { cwd });
|
||||
parent = await mkdtemp(path.join(tmpdir(), "ecr-prhead-"));
|
||||
const dir = path.join(parent, "head"); // must not pre-exist for `worktree add`
|
||||
await run("git", ["worktree", "add", "--detach", dir, "FETCH_HEAD"], { cwd });
|
||||
await run(
|
||||
"git",
|
||||
[...GH_CREDENTIAL_HELPER_ARGS, "fetch", "--no-tags", "--depth=1", url, ref],
|
||||
{ cwd },
|
||||
);
|
||||
parent = await mkdtemp(path.join(tmpdir(), "ecr-tree-"));
|
||||
const dir = path.join(parent, "tree"); // must not pre-exist for `worktree add`
|
||||
// Check out the OID (not FETCH_HEAD): if the ref moved between the API call
|
||||
// and this fetch, the OID is absent and this fails instead of silently
|
||||
// materializing a different tree than the one the diff was fetched for.
|
||||
await run("git", ["worktree", "add", "--detach", dir, oid], { cwd });
|
||||
const removeParent = parent;
|
||||
return {
|
||||
dir,
|
||||
@@ -95,11 +133,68 @@ export class GitHubPRSource implements ReviewSource {
|
||||
await rm(removeParent, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (parent) {
|
||||
await rm(parent, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the PR HEAD out into a throwaway worktree so the agents and verifier
|
||||
* read the PR's versions of files (not whatever branch happens to be checked
|
||||
* out), pinned to the immutable head OID. The fetch uses `refs/pull/<n>/head`,
|
||||
* which the base repo hosts even for fork PRs.
|
||||
*
|
||||
* The worktree is SCRUBBED of ambient runtime config (opencode.json, .opencode
|
||||
* plugins, AGENTS.md/CLAUDE.md, .mcp.json, .env*, …) before it's returned: the
|
||||
* OpenCode server is started with this directory as its project root, and
|
||||
* anything it discovers there is attacker-controlled PR content executing or
|
||||
* injecting inside a process that holds the model credential and GH_TOKEN.
|
||||
*
|
||||
* Returns null only when no owner/repo is configured (a local `--pr` run
|
||||
* without --repo, where the current checkout is an acceptable read root).
|
||||
* Materialization FAILURES throw — the caller decides per mode whether that is
|
||||
* fatal (CI: fail closed) or a soft fallback (local: the user's own checkout).
|
||||
*/
|
||||
async prepareReadRootAsync(): Promise<PreparedReadRoot | null> {
|
||||
if (!this.options.repo) {
|
||||
// Without an explicit owner/repo we can't build the fetch URL safely.
|
||||
return null;
|
||||
}
|
||||
const metadata = await this.getMetadata();
|
||||
if (!isCommitOid(metadata.headOid)) {
|
||||
throw new Error("GitHub did not report an immutable head OID for this PR");
|
||||
}
|
||||
const root = await this.materializeWorktreeAsync(
|
||||
`refs/pull/${this.options.prNumber}/head`,
|
||||
metadata.headOid,
|
||||
);
|
||||
try {
|
||||
await scrubAmbientRuntimeConfig(root.dir);
|
||||
} catch (error) {
|
||||
// A half-scrubbed tree must never become the runtime's project root.
|
||||
await root.cleanup().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the PR's BASE commit as the trusted configuration root: review
|
||||
* policy, prompts, routing, and auth mapping load from here, so a PR cannot
|
||||
* change the reviewer that evaluates it (config changes activate on merge).
|
||||
* Failures throw — `ecr ci` must fail closed, never fall back to the checkout.
|
||||
*/
|
||||
async prepareTrustedConfigRootAsync(): Promise<PreparedReadRoot> {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!isCommitOid(metadata.baseOid)) {
|
||||
throw new Error("GitHub did not report an immutable base OID for this PR");
|
||||
}
|
||||
// The base OID is fetchable by SHA (it's the tip of the base branch as of the
|
||||
// API call; GitHub serves reachable SHAs — the same mechanism actions/checkout
|
||||
// uses for `ref:` pins).
|
||||
return this.materializeWorktreeAsync(metadata.baseOid, metadata.baseOid);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,11 @@ export interface ReviewSource {
|
||||
* agents also read surrounding source and the verifier re-reads cited files — and
|
||||
* those must be the PR-HEAD versions, not whatever happens to be checked out.
|
||||
* Return `null` (or omit the method) to just review the current working directory.
|
||||
*
|
||||
* Contract: `null` means "no tree to materialize" (a deliberate, safe
|
||||
* fall-through to the current checkout). A materialization FAILURE throws —
|
||||
* the review core fails closed on it in CI mode and falls back softly only in
|
||||
* local mode, where the user's own checkout is an acceptable read root.
|
||||
*/
|
||||
prepareReadRootAsync?(): Promise<PreparedReadRoot | null>;
|
||||
}
|
||||
|
||||
@@ -85,12 +85,18 @@ jobs:
|
||||
# config, and never `gh pr checkout` the PR head. The reviewer engine itself
|
||||
# is the PUBLISHED @expo/code-review-cli (fetched by npx), not built from any
|
||||
# checkout, so attacker-controlled PR code never runs here. The diff + PR
|
||||
# metadata come from the API (`gh pr diff`/`gh pr view`).
|
||||
# metadata come from the API (`gh pr diff`/`gh pr view`); `ecr ci` loads
|
||||
# configuration from the PR's immutable base commit and reads source from a
|
||||
# head worktree scrubbed of ambient runtime config (opencode.json, plugins,
|
||||
# AGENTS.md, .env, …) — the same trust model as the pull_request workflow.
|
||||
- name: Checkout (base ref only — never the PR head)
|
||||
if: steps.cmd.outputs.run == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# The CLI's own git fetches authenticate through `gh` from GH_TOKEN, so
|
||||
# the token never lands in .git/config.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
if: steps.cmd.outputs.run == 'true'
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
// Large diffs are split into focused chunks by changed-line count, plus a
|
||||
// cross-cutting pass for multi-file issues. Diffs under maxChangedLines are one
|
||||
// full-context pass. Defaults shown; raise/lower per your model + PR sizes.
|
||||
// Concurrency defaults by auth mode: 6 with an API key, 3 on a subscription
|
||||
// (oauth) credential — one account handles many parallel streams poorly, and
|
||||
// several PRs may review on the same credential at once. Set it to override.
|
||||
// "chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 4 },
|
||||
|
||||
// Which PRs `ecr ci` reviews. This is the source of truth for trigger policy;
|
||||
@@ -56,11 +59,12 @@
|
||||
// of the agents that need the pro tier, and set ECR_EXPECTED_TOKEN_ENV in the
|
||||
// workflow to the comma-separated set of both env names.
|
||||
// "auth": { "providers": {
|
||||
// "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
|
||||
// "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" },
|
||||
// "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
||||
// } }
|
||||
// (openai oauth: tokenEnv holds the REFRESH token from an `opencode auth login`
|
||||
// ChatGPT sign-in — copy `.openai.refresh` from OpenCode's auth.json.)
|
||||
// (openai oauth: tokenEnv holds the ACCESS token from an `opencode auth login`
|
||||
// ChatGPT sign-in — `ecr setup-auth` extracts it. NEVER share the refresh
|
||||
// token: it is single-use and dies on first rotation.)
|
||||
"auth": {
|
||||
"mode": "api-key",
|
||||
"provider": "openai",
|
||||
|
||||
@@ -83,6 +83,9 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# Dismiss never fetches; gh authenticates from GH_TOKEN. Keep the token
|
||||
# out of .git/config.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
if: steps.cmd.outputs.run == 'true'
|
||||
|
||||
+17
-5
@@ -39,10 +39,21 @@ jobs:
|
||||
# A reviewer failure must never fail the PR's checks.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
# SECURITY: check out the PR's immutable BASE commit, never the PR head or
|
||||
# merge ref. Everything security-sensitive on this runner (`ecr verify-config`'s
|
||||
# sweep, and any ambient files) therefore comes from a commit that already
|
||||
# merged. `ecr ci` additionally enforces this itself: it materializes the base
|
||||
# commit via the GitHub API for configuration and the head commit (scrubbed of
|
||||
# runtime config) for source reads, so this checkout is defense in depth, not
|
||||
# the only line. persist-credentials off — the CLI's own git fetches
|
||||
# authenticate through `gh` from GH_TOKEN, so the token never lands in
|
||||
# .git/config.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
# Shallow is enough — the reviewer gets the diff from the API (`gh`).
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -52,16 +63,17 @@ jobs:
|
||||
# to save an empty cache and error).
|
||||
package-manager-cache: false
|
||||
|
||||
# SECURITY: this workflow checks out the PR's code, including every
|
||||
# SECURITY: the TRUSTED BASE checkout above includes every
|
||||
# .expo-code-review/config.jsonc + routing.jsonc, whose auth.tokenEnv names the
|
||||
# env var the CLI forwards as the model credential. The canonical guard ships
|
||||
# with the CLI: `ecr verify-config` sweeps every config (root + routing + all
|
||||
# scopes, referenced or not) with the engine's real JSONC parser and refuses
|
||||
# unless tokenEnv appears exactly once, in a ROOT-owned file, equal to the
|
||||
# expected value (repo var ECR_EXPECTED_TOKEN_ENV) — so a PR can't repoint it at
|
||||
# another runner secret, sneak in a JSON-escaped key, or stage an unreferenced
|
||||
# scope config with its own auth. This is layer 2; layer 1 is the runtime
|
||||
# ECR_EXPECTED_TOKEN_ENV lock in `ecr ci` itself, so guard/loader drift fails safe.
|
||||
# expected value (repo var ECR_EXPECTED_TOKEN_ENV) — so a config change can't
|
||||
# repoint it at another runner secret, sneak in a JSON-escaped key, or stage an
|
||||
# unreferenced scope config with its own auth. This is layer 2; layer 1 is the
|
||||
# runtime ECR_EXPECTED_TOKEN_ENV lock in `ecr ci` itself, so guard/loader drift
|
||||
# fails safe.
|
||||
#
|
||||
# This step MUST run BEFORE `ecr ci` (before any PR code is built or loaded).
|
||||
# Only setup-node (runtime install) precedes it; running the PUBLISHED package
|
||||
|
||||
Reference in new issue
Block a user