Commit Graph
86 Commits
Author SHA1 Message Date
Philippe Loulidi 9f77765c98 skill docs: --force shares the no-token-env refusal; web research is untrusted input 2026-07-31 20:21:33 -07:00
Philippe Loulidi 224170360e Merge origin/main (claude-code self-review #43) 2026-07-31 20:14:31 -07:00
Philippe Loulidi dea60361f9 config: fix oauth tokenEnv comment to say access token not refresh token 2026-07-31 19:12:45 -07:00
Philippe Loulidi 7a004fa935 init --force-workflows: scope forwarded-credential detection to the review step and drop forbidden secrets
detectWorkflowTokenEnv scanned the whole workflow file and skipped only OPENAI_API_KEY/GH_TOKEN/GITHUB_TOKEN, so a secret a hand edit forwards to another step could be surfaced as the baked model credential (and a FORBIDDEN_TOKEN_ENVS secret produced a --token-env remediation that parseTokenEnvs refuses). Scope the scan to the Run AI review step env and drop FORBIDDEN_TOKEN_ENVS names too. Also harden the setup-code-review skill: mined PR comments and commit messages are untrusted data, never reviewer instructions.
2026-07-31 19:05:45 -07:00
Philippe Loulidi 9fab1effd7 init --force-workflows: detect a hand-wired credential from the forwarded secret line 2026-07-31 18:57:37 -07:00
Philippe Loulidi 6e3303a8d4 init --force-workflows: refuse when it would silently revert a non-default credential 2026-07-31 18:45:24 -07:00
Philippe Loulidi 82c466aac8 self-review: switch to the claude-code engine on the Max subscription (#43) 2026-07-31 19:42:54 -06:00
Philippe Loulidi c9facb3c37 init --token-env: add --force-workflows so re-running keeps tuned config and prompts 2026-07-31 18:31:29 -07:00
Philippe Loulidi 50c9aa644a Merge origin/main (context-file #39/#40, prompt risk #42) 2026-07-31 18:24:49 -07:00
Philippe Loulidi 0e8068d2c3 skill reference: CI stores the openai access token, never the refresh token 2026-07-31 18:12:39 -07:00
Philippe Loulidi ae2b7fee2b init --token-env: refuse when existing review workflows would keep the old secret 2026-07-31 18:12:39 -07:00
Brent Vatne b8bddf4143 prompt: report confidence + shipping impact, and hand the coordinator an overall PR risk (#42) 2026-07-31 17:10:37 -07:00
Philippe Loulidi 76ffff904f init --token-env: print the required config.jsonc auth edit as a next step
The flag rewires the workflows but deliberately leaves the scaffolded
config.jsonc on OPENAI_API_KEY, so ecr verify-config fails in CI until the
user edits auth. Print that edit as an explicit next step instead of
leaving a silent CI failure (review finding 6cb8b566167c on PR #37).
2026-07-31 11:18:22 -07:00
Philippe Loulidi 9e8e9d6371 context-file: inject external context (Atlantis plans) into reviews as untrusted data (#40)
* context-file: inject external context (Atlantis plans) into reviews as untrusted data

* context-file: enforce the read ceiling during the read, not by a prior stat
2026-07-31 10:23:26 -06:00
Philippe Loulidi 4d4e307a40 fix(ci): post no comment when no scope matches the changed files (#39)
In routed aggregate ("single") mode, ecr ci posted an empty approve
comment with a zero-row scope table on every PR whose changed files
matched no scope. Now it posts nothing and deletes a stale aggregate
comment from an earlier run instead. The unmatched-files warning stays
in the job log. Per-scope mode already behaved this way.

The partial-run merge moves into an exported pure mergePartialAggregate
so the empty/carry-over boundary is unit-tested.
2026-07-31 03:51:00 -06:00
Philippe Loulidi 14e04a13db llp: document --token-env in LLP 0007/0009, annotate the new init code
LLP 0009 said init is a pure copy with no logic; --token-env made that
false. Records the CI gap it closes (secret set but never mapped in the
YAML), why the mapping stays static, and the fail-loud template markers.
2026-07-31 00:49:40 -07:00
Philippe Loulidi a99d5fc8c6 Merge origin/main (LLP adoption #38) 2026-07-31 00:47:34 -07:00
Philippe Loulidi 0110ca4e34 init: add --token-env so non-OpenAI credentials reach the scaffolded CI workflows
The scaffolded workflows only forwarded OPENAI_API_KEY, so a Claude or Codex
setup passed the auth lock but ran with an empty credential in CI (found by the
AI review on this PR). ecr init --token-env <name[,name]> now bakes the chosen
tokenEnv into both review workflows: the ECR_EXPECTED_TOKEN_ENV fallback and
one forwarded secret line per name. The mapping stays static in the committed
YAML on purpose; the skill now passes the flag from the Phase 3 auth decision.
2026-07-31 00:34:29 -07:00
Philippe Loulidi 6bbc1fbd7a Adopt LLP: design docs in llp/, @ref annotations, ref-check in CI (#38)
Retrofit of https://github.com/ccheever/llp. Ten LLP docs capture the
design rationale (trust model, engine pipeline, runtimes, config,
verification, sources/reporters, CLI, templates); 161 @ref comments link
code to the sections that explain it. ref-check (copied from upstream,
dependency-free) validates refs and metadata on every push and PR.
Every rationale claim is tagged [observed] with its evidence.
2026-07-31 01:20:51 -06:00
Philippe Loulidi 14b6d7957b Add setup-code-review skill: install and configure ecr in any repo 2026-07-30 23:36:29 -07:00
Philippe Loulidi 93aaf305fd Claude Code CLI engine: run reviews on a Claude Max/Team subscription via claude -p (#34)
* Claude Code CLI engine: run reviews on a Claude Max/Team subscription via `claude -p`

OpenCode has no Anthropic subscription OAuth support, so reviews on a
Claude Max/Team plan were impossible. New auth mode "claude-code" runs
each pass as a single `claude -p --output-format json` subprocess:

- Engine seam reused: promptAndParse/promptAgent/assertModelsResolvable
  dispatch on handle.engine; consumers unchanged.
- Subscription forced: ANTHROPIC_API_KEY/AUTH_TOKEN stripped from the
  child env; CLAUDE_CODE_OAUTH_TOKEN injected from tokenEnv when set.
- Trust-isolated, read-only passes: --safe-mode, --strict-mcp-config,
  dontAsk permission mode, Read path-scoped to the review tree, no
  Bash/Edit/Write/Web tools.
- Rate/usage-limit detection feeds the existing RateLimitWatch/backoff;
  subscription-aware concurrency cap (3) applies.
- ecr setup-auth walks through `claude setup-token`; ecr doctor checks
  the CLI and subscription login.

* Cleanup pass: crash-safe spawn/stream error paths, discriminated engine dispatch, env-allowlist and process-group-kill test coverage, doc drift fixes

* Strip out-of-tree symlinks when materializing the PR-head read root

* Harden untrusted-tree trust boundary: address AI review + security audit

Fixes the review's critical finding plus five further issues surfaced by a
full-repo security audit, all in the same threat class — the reviewed PR tree
is attacker-controlled and its content must never resolve a binary, reach a raw
fs read, forge reviewer state, or leak host secrets.

Review findings:
- claudeSubscriptionActive no longer spawns a bare `claude` with inherited
  cwd/env; it takes the resolved, pathInside-checked CLI path + allowlisted
  env and probes from tmpdir. All three call sites routed through it.
- doctor folds routed scope configs into the engine set (resolveEngines), so
  an Anthropic-only scope no longer passes with the Claude checks skipped.
- review scopes buildEngineMap to the selected agents, so a run whose passes
  never use Claude no longer preflights (and fails on) a missing Claude CLI.

Audit fixes (platform-independent — live on POSIX):
- verify: confine evidencePresence's read to the review tree (pathInside),
  blocking absolute/`..` reads of host secrets and the present/absent oracle.
- auth: a non-anthropic entry naming CLAUDE_CODE_OAUTH_TOKEN/ANTHROPIC_AUTH_TOKEN
  is now refused, so the subscription token can't be forwarded to a foreign
  provider.
- prompts: flatten finding.title/rationale through sanitizeUntrusted so an
  injected finding can't forge verifier prompt boundaries.
- github reporter: identify our own comment by author + marker, not the
  public marker alone, so a forged comment can't carry review state forward.
- ci: collapse subprocess argv/stderr in public PR failure comments.

Bare-name spawn hardening (defense-in-depth; the cwd-hijack is Windows-only, as
POSIX execvp does not search cwd, but the tool is published and `.` may sit in
PATH):
- Resolve git/gh to trusted absolute paths (resolveTrustedTool) before every
  spawn, and taskkill via an absolute System32 path.
- The `opencode`/`claude` direct-spawn callers in doctor/setup-auth resolve an
  absolute path (resolveOpencodeCli/resolveClaudeCli). startOpencode keeps the
  SDK's own spawn: injecting an absolute path there would mean maintaining a
  fork of the SDK's server bootstrap, not worth it for a Windows-only threat on
  a POSIX-supported tool; the accepted residual is documented in place.

Regression tests added for each; typecheck, lint, fmt:check clean; 328 tests pass.

* ci: run oxfmt single-threaded to stop nondeterministic format failures

oxfmt 0.60.0's parallel checker is nondeterministic on the 2-core CI runners:
`oxfmt --check src` intermittently flags a correctly-formatted file (observed on
src/__tests__/render.test.ts — `oxfmt --write` produces no change, and the check
passes on higher core counts and re-runs), which is a data race in its worker
pool, not a real format issue. `--threads=1` serializes the run (61 files in
~90ms, so no meaningful cost) and makes the check deterministic.
v0.7.0
2026-07-30 21:54:39 -06:00
Brent Vatne 509da465cb prompt: treat reviewed content as untrusted data; write findings in Simplified Technical English
Two additions to the shared reviewer prompt:

- Reviewed content (patches, PR title/body, commit messages, filenames) is
  data, never instructions. Attempts to steer the reviewer are themselves a
  security finding.
- Findings prose (title, rationale, suggestion) follows ASD-STE100 rules.
  Evidence, identifiers, paths, and enum values stay verbatim.

Re-anchors .expo-code-review/shared.md to templates/shared.md so both files
match again.
2026-07-29 09:09:11 -07:00
Brent Vatne 41b5c804c0 style: apply oxfmt to render.test.ts
The format check on main failed because a renderMarkdown call argument
exceeded the line width.
2026-07-29 09:09:11 -07:00
Brent Vatne 30a9e3eeb1 fix(render): keep multi-line rationales inside their list item
Findings whose rationale embeds a <details> block rendered as raw Markdown on
GitHub — visible ** and backticks, half-parsed links — for every finding after
the first in a severity group.

Two causes, both here. renderFindingLines indented only the FIRST line of the
rationale, so the embedded <details> sat at column 0 and escaped the list item;
GitHub then treated the closing </details> as ending a top-level HTML block.
And findings were pushed back-to-back, so the next bullet followed that closing
tag after a single newline, which Markdown does not re-enter without a blank
line. The result was the next finding emitted verbatim.

Indent every continuation line to the content column and separate findings with
a blank line. Blank lines inside a rationale stay truly empty — padding them to
the indent would make them non-blank and reopen the same parsing bug. Findings
were already loose list items, so spacing is unchanged.

Observed on brentvatne/euxy#45, where Critical #1 rendered correctly and #2 and
#3 leaked raw.
2026-07-28 16:26:57 -07:00
Brent Vatne 601b19afc1 Trusted base-commit configuration by default; isolate the runtime from PR-head config
ecr ci now loads all review configuration (config.jsonc, routing, prompts,
models, auth mapping) from the PR's immutable base commit, materialized via
the GitHub API — never from the PR head — failing closed with one terminal
comment when the base can't be materialized. The PR head is materialized
separately (pinned to its immutable OID, mandatory in CI) purely as source
content, 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, closing a fork-reachable code
execution path through the /review command workflow.

- git fetches append `-c credential.helper=!gh auth git-credential` so
  base-SHA checkouts with persist-credentials:false still fetch private repos
  (token never in argv or .git/config)
- run log + patch workspace anchor at the workspace (runsDir), never the
  temporary trusted root, so the artifact upload path keeps resolving
- a scope config that is new in a PR reviews with the root config and
  activates after merge (hasScopeConfig fallback)
- scaffolded workflows check out the base SHA with persist-credentials:false;
  the repo's own self-review workflow runs the engine from base source
- escape hatch: ecr ci --unsafe-config-from-head (loud warning, never
  scaffolded, removal scheduled)
v0.6.0
2026-07-26 22:29:18 -07:00
Brent Vatne 4990b2f5a0 docs: plan trusted review configuration 2026-07-26 22:07:52 -07:00
Brent Vatne 188f8521bf Detect provider rate limits and wait them out; subscription-aware concurrency (#32)
* Detect provider rate limits and wait them out; subscription-aware concurrency

A shared subscription credential can serve several PRs' reviews at once, so
throttling is a normal condition to ride out, not a failure — and it must be
distinguishable from a wedged request.

- watch the OpenCode server's own log (isolated per run) for 429/rate-limit
  ERROR lines — explicit throttle evidence, the only kind that exists outside
  the provider; a silently parked request emits nothing anywhere
- a stall WITH recent evidence waits in 90s beats (never consuming the one
  wedged retry, bounded by the pass deadline) instead of re-sending the pass's
  whole context into a limited account; without evidence, the wedged-retry
  behavior is unchanged
- explicit 429 errors retry on a patient 15s/45s/90s schedule instead of 2s/8s
- chunk.concurrency unset now resolves by auth mode: 3 on a subscription
  (oauth) credential, 6 on API keys — one account handles six parallel streams
  poorly (the parked-request stall signature on eas-cli#4084 hit both agents'
  chunk 2 simultaneously); an explicit value always wins
- rate-limit events surface at run end and in the run log (rateLimitEvents),
  so throttle frequency is measurable across PRs

* Support ChatGPT ACCESS tokens (rotation-safe); honest verdict when nothing ran

The refresh-token-as-static-secret strategy died in production (euxy#8: every
pass "Token refresh failed: 401" — refresh tokens are SINGLE-USE; the shared
copy was spent by its first CI use and the sign-in with it).

- oauthAuthJsonEntry detects the token kind: a JWT (ACCESS token) is used
  as-is with its exp claim as the expiry — no rotation involvement, safe as a
  shared/static secret; an opaque value keeps the refresh-token shape for
  sole-consumer setups
- an expired access token fails fast naming the re-mint; near-expiry warns
  (doctor + preflight) — the credential can't silently lapse mid-run
- setup-auth hands out the ACCESS token (default env CODEX_OAUTH_ACCESS_TOKEN)
  and never surfaces the refresh token, which stays in OpenCode's own store
  as its sole legitimate consumer
- an all-passes-failed run no longer renders as "Approve with comments":
  couldNotComplete overrides the decision label with "No review — every pass
  failed" (single and per-scope comments)
- README: retract the false "refresh-token reuse verified, static CI secret
  works" claim; document the access-token strategy
- ROADMAP: token-rotator cron job added as the most important next item
- this repo's config/workflow: tokenEnv renamed to CODEX_OAUTH_ACCESS_TOKEN
v0.5.2
2026-07-26 19:44:03 -07:00
Brent Vatne caf3fdce10 Add ecr setup-auth: guided local credential setup; make bare npx work (#31)
- setup-auth reads the repo's auth config and walks through each credential:
  a ChatGPT/Codex sign-in (runs the bundled `opencode auth login`, reuses an
  existing sign-in, extracts the refresh token) and/or API keys (prints where
  to create them and the exact restricted permissions), then prints the
  `export` lines to add to your shell config
- doctor recommends it when a credential is missing, and offers to run it
  right there on an interactive terminal
- add a `code-review-cli` bin alias so bare `npx @expo/code-review-cli <cmd>`
  resolves (npx matches the bin named after the package; with only ecr/
  expo-code-review it errored) — README's documented invocation now works
- README: first-time setup collapses to init → setup-auth → doctor
v0.5.1
2026-07-26 18:38:07 -07:00
Brent Vatne 43b31a4879 Mixed Codex-subscription + API-key auth; GPT by default; per-pass model reporting (#30)
* Mixed Codex-subscription + API-key auth; GPT by default; report each pass's model

- auth accepts a per-provider map (auth.providers) alongside the legacy single
  object; upstream aliases synthesize OpenCode provider blocks so one upstream
  is reachable with two credentials at once (subscription for default models,
  metered key for the pro tier the subscription excludes)
- openai oauth stores the REFRESH token (expires 0; OpenCode's codex plugin
  mints access tokens; reuse across runs verified)
- tokenEnv guard (verify-config) + runtime lock use exact SET semantics via
  comma-separated ECR_EXPECTED_TOKEN_ENV
- default model is openai/gpt-5.5; anthropic subscription OAuth removed from
  docs (prohibited upstream and unsupported by OpenCode; API key still works)
- an empty REVIEWER_MODEL no longer wipes configured model ids
- every pass reports the provider/model that ACTUALLY answered (job log, step
  summary table, run log agentModels) and warns loudly on substitution —
  reviews silently ran on opencode's free gateway model for weeks behind the
  two failures above
- this repo's own review runs the mixed setup (security on openai-api/gpt-5.5-pro)

* Close the cross-provider key redirect; default the expected set to both names

Review findings on this PR (the pro-tier security pass, fittingly):
- a well-known provider key env may only feed the provider that owns it —
  the tokenEnv guard locks NAMES, so without this an auth entry could keep a
  locked name but point provider/upstream at a different provider
- the self-review workflow's ECR_EXPECTED_TOKEN_ENV fallback now matches the
  committed two-credential config instead of relying on the repo variable
v0.5.0
2026-07-26 18:19:26 -07:00
Brent Vatne 14b32a015b Fail fast on unusable credentials, model ids, and a port/PATH collision (#29)
* Fail fast on unusable credentials, model ids, and a port/PATH collision

Debugging a local run surfaced four setup problems that all failed in the same
misleading way — silently, late, or as something unrelated:

- A malformed OAuth token makes OpenCode drop the provider from its provider
  list entirely, so every configured model reports "model not found" and nothing
  mentions credentials. Check the token's shape up front. The bar for refusing a
  run is "this cannot be a valid credential" (whitespace, absurdly short, an API
  key in oauth mode, or an OAuth token whose leading "sk-" was dropped); an
  unrecognized shape only warns, since the format is an observed convention
  rather than a guarantee and a false rejection blocks a working setup.
- A model id the server can't resolve failed every pass identically, N times,
  after spending the run's budget. Preflight the configured ids against the
  server's real provider/model list and throw once, with close matches. When the
  provider we supplied a credential for is the missing one, say the credential
  was refused rather than blaming the model id.
- The SDK defaults to a FIXED port 4096, so any already-running opencode (a
  developer's own session) killed every local run with an opaque ServeError.
  Use port 0.
- The SDK spawns a bare `opencode`, i.e. whatever is first on PATH, so a stale
  global install silently replaced the version we depend on. Prepend our own
  node_modules/.bin, pin the CLI and SDK exactly as the matched pair they are,
  and have `ecr doctor` report the version actually in use.

* Name rate limiting as a cause when a credential is refused or a pass goes silent

A throttled subscription credential (HTTP 429) is refused exactly like an invalid
one, and OpenCode surfaces neither: it drops the provider, so every model looks
nonexistent, and it retries a 429 mid-request internally, so a throttled pass
reaches us as pure silence rather than an error (opencode#17583).

Both messages previously blamed the only cause they knew about — the token and a
wedged connection — which sends people to re-issue a perfectly good credential or
hunt for a bug here. Offer rate limiting first, with the one-line curl that tells
429 (wait) from 401 (the credential really is bad).
2026-07-26 18:02:52 -07:00
Brent Vatne 3aeb82ba0d Detect stalled model requests; let the cross-file pass use the whole budget (#27)
A wedged provider request used to burn a pass's entire wall-clock cap in
silence, and the cross-file pass had no way to spend more than 25 minutes even
when the diff warranted it. Add a no-progress watchdog with one clean-slate
retry, make the finalize/corrective salvage physically tool-free, and give the
cross-file pass the remaining passes window instead of a fixed cap.

Also stop subdividing the cross-file pass by file halves: a left-half/
right-half interaction is invisible to both halves, so splitting deleted the
coverage the pass exists for while reporting success. It now falls back to a
whole-diff no-tools pass.
2026-07-26 18:00:51 -07:00
Philippe Loulidi 57d879789b Publish to npm from CI on version tags (tag is the version source of truth) (#26) v0.4.0 2026-07-24 14:31:22 -06:00
Philippe Loulidi 6ee5cfcee4 Move the cross-cutting lens list into the task message; document tokens, cost and prompt caching (#25) 2026-07-24 14:30:53 -06:00
Philippe Loulidi 6d3322ded8 Mirror the posted comment and a per-pass usage table into the Actions step summary (#24) 2026-07-24 14:30:40 -06:00
Philippe Loulidi 8b971c1a47 Persist per-agent tokens and the reasoning trail in the run log (#23) 2026-07-24 14:30:27 -06:00
Philippe Loulidi 08026ea2d8 Bound the routed passes budget: budget.totalPassesMinutes / minScopeMinutes (#15) 2026-07-24 14:30:14 -06:00
Philippe Loulidi 720b3f1392 Compose --config-dir/ECR_CONFIG_DIR with the routing manifest (#14) 2026-07-24 14:30:01 -06:00
Philippe Loulidi 0273426afa Ship command/dismiss workflow templates, run-log upload, SHA pins, Opus for coordinator+security (#13) 2026-07-24 14:29:48 -06:00
Philippe Loulidi 31f289b793 Ship the canonical config guard as ecr verify-config; replace the awk sweep (#12) 2026-07-24 14:29:34 -06:00
Philippe Loulidi 69b841c5e3 Let explicit /review bypass the trigger-policy gate (--force / issue_comment) (#11) 2026-07-24 14:29:21 -06:00
Philippe Loulidi cc234d720f CI routed fan-out: runLegacyCi/runRoutedCi, aggregate or per-scope comments, docs (#21) 2026-07-24 14:29:08 -06:00
Philippe Loulidi c8edaa9477 Wire review --scope/--config-dir and doctor scope diagnostics (#20) 2026-07-24 14:28:38 -06:00
Philippe Loulidi fdbd822d22 Sweep every config for tokenEnv/commentTag in the scaffolded CI guard (#19)
* Add routing manifest schemas + scope config loading (inert)

* Add scoped fingerprints, aggregate rendering, and reporter/review fan-out plumbing (inert)

* Add ecr init --monorepo/--scope scaffolding and scope templates

* Sweep every config for tokenEnv/commentTag in the scaffolded CI guard
2026-07-24 14:27:25 -06:00
Philippe Loulidi c7e4405cb5 Add ecr init --monorepo/--scope scaffolding and scope templates (#18)
* Add routing manifest schemas + scope config loading (inert)

* Add scoped fingerprints, aggregate rendering, and reporter/review fan-out plumbing (inert)

* Add ecr init --monorepo/--scope scaffolding and scope templates
2026-07-24 14:27:15 -06:00
Philippe Loulidi 4cfe919a8c Add scoped fingerprints, aggregate rendering, and reporter/review plumbing (inert) (#17)
* Add routing manifest schemas + scope config loading (inert)

* Add scoped fingerprints, aggregate rendering, and reporter/review fan-out plumbing (inert)
2026-07-24 14:27:06 -06:00
Philippe Loulidi bc353312b1 Add routing manifest schemas + scope config loading (inert) (#9) 2026-07-24 14:25:42 -06:00
Brent Vatne f10133e668 Release v0.3.0 2026-07-24 12:44:22 -07:00
expo-tuft[bot]andbrentvatne c215c4f0f0 docs: reflect the #2–#7 hardening batch (ROADMAP + README) (#8)
Co-authored-by: brentvatne <[email protected]>
2026-07-23 11:09:42 -07:00
expo-tuft[bot]andbrentvatne 97473bab52 Add oxlint + oxfmt and wire into CI (#10)
Co-authored-by: brentvatne <[email protected]>
2026-07-23 11:08:33 -07:00
expo-tuft[bot]andbrentvatne c924dfec48 Surface token/cache totals to the run log and CI (#6)
Token/cache/cost totals were accumulated and written to .runs/reviews.jsonl,
but that file is ephemeral in CI, so prompt-cache effectiveness was invisible
after a CI run. Emit a one-line usage summary (input/output/reasoning + cache
read/write, and cost when non-zero) via progress right before finalizing, so
it lands in the CI job log and the local terminal. Adds unit tests for the
formatter.

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: brentvatne <[email protected]>
2026-07-23 10:52:26 -07:00