Repository navigation
Relocate the front-door router into agentrelay.com (Phase 2) - #2
Conversation
Rename getOrigin to matchRoute and return both origin and matched prefix. Update request handling to use the returned origin and to strip the proxy prefix from url.pathname (falling back to "/"), while preserving the default origin from env when no prefix matches. This enables forwarding to proxied origins with proper path rewriting.
The observer dashboard now uses basePath: /observer, so the worker should pass the path through as-is. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stop stripping /observer prefix in router
Add a new Footer component and include it on the home and examples pages. Simplify Header by removing the showGitHubStars prop, always fetching GitHub stars, and adding a Star icon alongside the GitHub link. Add a new proxy route "/openclaw" in the router. Update favicon color to #00d9ff. Remove the AgentRelayRouter entry from sst-env.d.ts typings.
Add a new Footer component and include it on the home and examples pages. Simplify Header by removing the showGitHubStars prop, always fetching GitHub stars, and adding a Star icon alongside the GitHub link. Add a new proxy route "/openclaw" in the router. Update favicon color to #00d9ff. Remove the AgentRelayRouter entry from sst-env.d.ts typings.
Orchestrator: move workflow routes to packages/web, clean up artifacts
The Cloudflare router rewrites the hostname to the CloudFront origin before proxying, so the Next.js app sees the CloudFront URL instead of agentrelay.dev. This causes the OAuth redirect_uri to not match what's configured in Google Cloud Console. Fix: forward X-Forwarded-Host from the router and use it in the auth routes to construct the correct public origin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…main Redirect responses from the CloudFront origin contain Location headers pointing to the CloudFront hostname. Without rewriting, the browser would follow redirects to the internal CloudFront URL instead of agentrelay.dev. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Create a URL object for the origin and use its hostname when adjusting the proxied URL. Capture the incoming request Host once (requestHost) and set X-Forwarded-Host to that value. Remove the duplicate requestHost declaration and use originUrl.hostname when comparing redirect Location hostnames, replacing them with the original request host when rewriting Location. This ensures the original Host is preserved in headers and redirect rewrites are consistent.
Add X-Original-Host to proxied requests in the router and update Google auth routes to prefer it (falling back to X-Forwarded-Host, then url.origin). This ensures redirect_uri/origin is computed from the original host reported by a proxy/load-balancer, avoiding mismatches during OAuth flows. Also removes debug logging from the auth start route.
Move the /cloud path check before the apex-host split so any request whose pathname matches /cloud* returns NEXT_PUBLIC_APP_URL regardless of hostname. Update tests to assert cloud routes resolve to the cloud app on any host and add coverage that non-apex hosts still use the fallback proxy for non-cloud paths.
Apply Agent Relay brand system across web app
Refactor environment/domain handling for the cloud web and router. infra/web.ts now computes routerDomain, originDomain and appUrl (with local dev fallback), configures the Next.js site to use the origin domain via Cloudflare DNS, and exposes NEXT_PUBLIC_APP_URL and S3_BUCKET to the app. The Cloudflare Worker router is only created in non-dev stages and uses routerDomain; it now receives CLOUD_APP_ORIGIN as its environment variable. packages/router/index.ts updated to read CLOUD_APP_ORIGIN instead of NEXT_PUBLIC_APP_URL. sst.config.ts returns appUrl directly (removed previous web/url and DB helper metadata). Tests were updated to reflect the new env var name and canonical domain (removed www prefix) and to assert the router routes to the cloud origin.
Fix cloud domain
Add support for an asset origin and improve cloud base-path handling across the router and web app. - infra/web: expose NEXT_PUBLIC_ASSET_ORIGIN to Next.js and add assetOrigin constant. - packages/web/lib/base-path: add getConfiguredAssetOrigin() and withAssetOrigin() to build absolute asset URLs when NEXT_PUBLIC_ASSET_ORIGIN is set; keep withBasePath for routed pages. - packages/web/next.config.ts: set assetPrefix when asset origin is configured and add a rewrite to expose /cloud/:path* as /:path* for the app. - packages/router: introduce strip/add path prefix helpers, getUpstreamPath() and rewriteLocation() to properly strip /cloud for upstream requests and rewrite Location headers back to the mounted public path; include X-Forwarded-Prefix header. - UI updates: use withBasePath for internal links and withAssetOrigin for the favicon in layout. - Tests: extend base-path and router tests to cover asset origin behavior, upstream path stripping, and redirect rewriting. These changes allow proxied deployments where the app is mounted under /cloud while assets may be served from a separate origin, ensuring redirects and links are rewritten correctly.
Previously only /cloud/* was routed to the Next.js cloud app. API routes at /api/v1/* fell through to the legacy proxy (agentrelay.net), causing 404s for workspace, CLI auth, and webhook endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Will's review: API routes should go through /cloud/api/*, not /api/*. The /cloud prefix is the established routing convention for the cloud app. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: route /api/* through cloud app in edge router
Update the primary domain from agentrelay.dev to agentrelay.com across the codebase. Changes include email sender/DMARC, router domain and routing logic, Next.js app config (app URL and PostHog host), CLI default API URL, default OpenAPI server, footer contact/status links, README diagrams, and corresponding test expectations. This aligns production config and tests with the new .com apex.
Cloudflare Workers with `nodejs_compat` can hoist a bare `fetch(url, init)` identifier off `globalThis`, throwing `TypeError: Illegal invocation: function called with incorrect 'this' reference`. Convert all bare-fetch call sites in Worker-running packages to `globalThis.fetch(...)` and ensure error paths consume the response body before throwing. Sites changed: - packages/core/src/clone/github-clone-production.ts: fetchGithubTarballDirect - packages/relaycron/src/engine/executor.ts: webhook delivery - packages/router/index.ts: edge proxy subRequest - packages/specialist-worker/src/specialist/agent-card.ts: fetchSpecialistCard (also added body cancel on the !response.ok throw path) Per sage `.claude/rules/workers-fetch.md`. Co-authored-by: GitHub Clone Durable Queue Workflow <agent@agent-relay.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mant) (#629)
* chore(migration): select Workers + add wrangler devDep
Two things this PR does:
1. Lands docs/spikes/cf-spike-report.md with **RECOMMENDATION: WORKERS**
as an operator decision. The cf-spike workflow (cloud#614, closed)
surfaced one real defect (missing wrangler devDep) but its other
probes cascade-failed, producing a conservative FARGATE auto-rec.
Operator has accepted the Workers path; the remaining unknowns
(SSE behavior, bundle size, node:* surface) become Phase 3 work
items gated by the cutover spec's Layer 4.
2. Adds wrangler as devDep in packages/web/ — required peer of
@opennextjs/cloudflare. Unblocks Phase 3 builds.
Downstream phase workflows grep docs/spikes/cf-spike-report.md for the
RECOMMENDATION: WORKERS line; this PR satisfies that gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: add migration-phase1-hyperdrive workflow (pre-run snapshot)
Self-contained version (no applyCloudRepoSetup) — single-line bash to
avoid the helper's JSON.stringify multi-line quoting bug. Stacked on
chore/migration-cf-decision (#615) so the spike-report gate passes
without waiting for that PR to merge.
* chore: document why Phase 1 setup-branch does NOT base on origin/main
Per PR #616 review feedback, setup-branch in the emergency-throttle
workflow now bases on origin/main. Phase 1 is stacked on
chore/migration-cf-decision (#615), so applying the same change here
would drop the spike report from the working tree and fail preflight.
Adding a comment explaining the divergence so future readers don't
'fix' it.
No functional change.
* feat(migration): Phase 1 — Hyperdrive in front of RDS (additive)
Stands up sst.cf.Hyperdrive pointed at the existing RDS instance and
adds a Workers-compatible DB client behind a selectDbClient() factory.
Lambda continues to use the existing TCP client unchanged.
Changes:
- infra/hyperdrive.ts (new): sst.cf.Hyperdrive bound to existing appDatabase
- packages/web/lib/db/client-hyperdrive.ts (new): postgres-js + Drizzle over Hyperdrive
- packages/web/lib/db/index.ts: selectDbClient(env) factory; HYPERDRIVE binding → Workers client, else TCP
- packages/web/lib/boot/resource-check.ts: REQUIRED_RESOURCES gains CloudDb binding entry
Stacked on chore/migration-cf-decision (#615). Spike report gate passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(phase1): address PR #620 review — use sst.cloudflare.Hyperdrive namespace
Devin review flagged that `sst.cf.Hyperdrive` is not a valid namespace —
the Cloudflare provider is registered as `cloudflare` in `sst.config.ts`,
so SST generates `sst.cloudflare.*` (matches every other Cloudflare
resource in `infra/`: Worker, D1, Kv, Queue, Bucket, Cron). `sst.cf`
would fail at deploy time when Pulumi tries to instantiate the resource.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(phase1): address PR #620 review — defer CloudDb boot check until link lands
Devin review flagged that adding `{ name: "CloudDb", kind: "binding" }`
to REQUIRED_RESOURCES before the Hyperdrive resource is wired into
`sst.config.ts` and linked into `infra/web.ts` would cause
`[boot] resource binding check FAILED` on every Lambda cold start. The
false alarm would pollute CloudWatch and mask real missing-resource
failures.
Per `.claude/rules/sst-resource-registration.md` step 7, the boot-check
entry should only land in the same commit that also wires `link: [...,
hyperdrive]`. Removing the entry now and leaving a NOTE pointing at the
follow-up so the next phase doesn't forget to re-add it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(phase1): address PR #620 review — cache Hyperdrive client in selectDbClient
CodeRabbit review flagged that `selectDbClient(env)` would call
`createHyperdriveDb` on every invocation, leaking postgres connection
pools when Worker handlers start using it in Phase 2+.
Cache the Drizzle client at module scope keyed by the binding identity.
In production `env.HYPERDRIVE` is a stable Worker binding so the
identity check is a fast-path; in tests that swap envs the identity
guard rebuilds correctly. Adds `resetSelectDbClientForTests` for tests.
The fallback path (`getCoreDb()`) is unchanged — `@cloud/core` already
caches its core client internally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: add migration-phase2-webhook-worker workflow (pre-run snapshot)
Self-contained (no applyCloudRepoSetup) — single-line bash, plain string
concat for bash variable interpolation, .onError('fail-fast'), uses
sst.cloudflare.* (not sst.cf.*) per #620 review.
Stacked on feat/migration-phase1-hyperdrive so the webhook Worker has
the Hyperdrive resource to bind to.
* feat(migration): Phase 2 — webhook Worker + CF Queue (KV-flagged, dormant)
Moves /api/v1/webhooks/* off the OpenNext Lambda onto a dedicated
Cloudflare Worker. Router rule is KV-flagged (WEBHOOK_ORIGIN). Default
is lambda — ships dormant. Operator flips via:
wrangler kv:key put --binding=ROUTER_CONFIG WEBHOOK_ORIGIN worker --remote
Deliverables:
- packages/webhook-worker/ — CF Worker entry + per-provider handlers
(nango, github, composio, hookdeck) with HMAC verify ported verbatim
from the Lambda routes. miniflare tests (13/13 passing).
- infra/webhook-queue.ts — sst.cloudflare.Queue resource
- infra/webhook-worker.ts — sst.cloudflare.Worker linked to hyperdrive + queue + provider secrets
- infra/web.ts — router worker now binds ROUTER_CONFIG KV + WEBHOOK_WORKER_ORIGIN env
- packages/router/index.ts — KV-flagged forward to the webhook Worker (default: lambda)
Stacked on feat/migration-phase1-hyperdrive (#620). Uses sst.cloudflare.*
(not sst.cf.*) per #620 review correction.
Eliminates webhook contention with the user-facing tier. A Nango/GitHub
webhook fan-in storm can no longer 429 dashboard requests once flipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(phase2): address PR #629 review — exact-path match for webhook worker
Devin flagged that the router's webhook prefix match would capture the
Composio OAuth callback (/api/v1/webhooks/composio/connect/callback) when
WEBHOOK_ORIGIN=worker, returning 404 instead of falling through to the
Lambda. Replace the prefix check in shouldUseWebhookWorker() with a
membership check against the exact set of paths the worker knows how to
handle (composio, github, hookdeck, nango). Any other sub-path under
/api/v1/webhooks/* keeps routing to the Lambda — preserving the OAuth
callback and any future Next.js routes that share the prefix.
Adds two router unit tests covering the regression: the four exact paths
match, and known sub-paths (notably composio/connect/callback) do not.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: add migration-phase0-recorder workflow (pre-run snapshot) Self-contained version — inlines setup-branch + install-deps to avoid applyCloudRepoSetup's multi-line bash quoting bug. Committing pre-run so ricky auto-fix mutations are recoverable via git checkout. * feat(migration): Phase 0 — production traffic recorder + R2 Adds NDJSON request/response capture to the existing CF router worker. Redaction, deny-list for secret-bearing routes, 14-day R2 lifecycle. KV-tunable sample rate (default 100%). Additive — no routing behavior change. Feeds Phase 3 Layer 3 replay corpus. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t + foundation + rate limit + ACU bump + writeback backfill + docs (#647)
* feat(migration): Phase 0 foundation — acceptance suite + replay harness + handler tests (salvage)
Salvages the Phase 0 foundation work that the
`fargate-migration-phase0-foundation` workflow generated but failed to commit
when its verify gates died on a bash quoting bug (unrelated to the agent
output). The deliverables themselves are sound and gate Phase 3 cutover, so
they're landing here in a clean PR from origin/main.
Brings in:
- `packages/acceptance/` — Layer 1 contract suite scaffold + 42 acceptance
`.test.ts` files across auth / workspaces / integrations / runtime / api,
plus helpers (auth, fixtures, env, runtime, server) and fixture seeds.
Intended to run against deployed environments via `ACCEPTANCE_BASE_URL`
(not exercised in this PR — see test plan).
- `packages/replay-harness/` — Layer 3 replay oracle: CLI, corpus reader,
S3-backed replay engine, equivalence comparator, report writer, plus 2
unit tests covering equivalence + replay-smoke. Used by Phase 3 to prove
Worker responses match Lambda for a captured corpus.
- `packages/web/test/handlers/` — Layer 2 integration tests: 45
handler-coverage `.test.ts` files (many are thin re-exports of existing
top-level tests with `// @handler` route headers so coverage tooling can
enforce one integration test per route).
- `packages/web/test/helpers/{pglite-db,localstack,boot-app}.ts` — shared
helpers for in-memory Postgres + LocalStack-backed tests.
- `scripts/check-route-coverage.mjs` + `scripts/check-handler-coverage.ts`
+ `scripts/route-coverage-allowlist.json` — coverage gates that enforce
every `/api/v1/...` route has a corresponding handler test (or an
allowlist entry).
Supporting changes:
- Add `undici` to root devDependencies (used by acceptance helpers).
- Add `allowImportingTsExtensions: true` to `packages/web/tsconfig.json`
to match root tsconfig — the handler-coverage re-exports drag in top-level
`tests/*.ts` files that use explicit `.ts` import extensions.
- Update `tests/github-webhook-route.test.ts` `loadRoute` signature to
`NextRequest`/`NextResponse` so the route module typechecks cleanly when
pulled in via the new handler-coverage re-export.
Validation in this PR (run in the new worktree):
- `packages/replay-harness`: `tsc --noEmit` clean; vitest 2 files / 5 tests
PASS.
- `packages/acceptance`: `tsc --noEmit` clean. Acceptance suite NOT run —
requires `ACCEPTANCE_BASE_URL` + a test workspace; landing as code only.
- `packages/web/test/handlers`: `tsc --noEmit` clean (under web tsconfig);
vitest 279/280 pass. One flaky test
(`workflow-run-response-pushed-to.test.ts > merges disjoint pushedTo keys
when two updates fire concurrently`) times out under parallel load but
passes in isolation — to investigate as a follow-up.
- Root `tsc --noEmit`: 0 new errors introduced by salvaged code (13
remaining errors pre-exist on origin/main and are unrelated).
What's NOT in this PR / known follow-ups:
- Acceptance suite has not been executed against a real environment; only
typechecked. Wiring `ACCEPTANCE_BASE_URL` into CI is a follow-up.
- The flaky concurrent-update handler test needs a higher timeout or a
serial fixture.
- Pre-existing root typecheck errors (`packages/web/lib/integrations/
nango-sync-queue.ts`, `relayauth/packages/server/src/__tests__/
test-helpers.ts`) are not addressed here — they exist on main.
Spec: `docs/fargate-migration-spec.md`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(migration): Hyperdrive network-path spec (Phase 3 prereq)
Phase 3 of the cloud-web migration moves web onto Cloudflare Workers, which
can only reach Postgres via Hyperdrive. Hyperdrive runs at CF's edge and
connects out to a Postgres endpoint over the public internet; our Aurora
cluster lives on private VPC subnets and is not reachable today. This spec
lays out three options for bridging that gap (public Aurora endpoint with
allowlist; PrivateLink + Cloudflare Tunnel; public read replica with
private writer), a decision matrix, recommended starting point (Option A,
subject to security signoff), risks, and rollout plan.
Status: Proposed. Decision belongs to a human reviewer with security sign-off.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: add migration-hyperdrive-option-b workflow (pre-run snapshot)
Self-contained workflow for Hyperdrive Option B infrastructure:
CF Tunnel + Access app + service token + cloudflared Fargate daemon +
real Hyperdrive resource replacing the null placeholder. All declarative
via @pulumi/cloudflare.
Patterns: inlined setup-branch + install-deps, single-line bash, plain
string concat (no template-literal traps), .onError('fail-fast').
* chore: add migration-phase3-web-worker-build workflow (pre-run snapshot)
Self-contained workflow for the Phase 3 cloud-web Worker build:
@opennextjs/cloudflare build of packages/web, wrangler.toml bindings,
sst.cloudflare.Worker resource, KV-flagged router rule. Ships dormant
(KV default: lambda).
* fix(workflow): relax preflight; install @opennextjs/cloudflare in-workflow
Original preflight required @opennextjs/cloudflare to be preinstalled
as a precondition, but that dep should be installed BY the workflow,
not before it. Removed the preinstall check + the strict ALLOWED_DIRTY
gate; added an explicit install-opennext-cf step. The standalone-output
check moves into the fix-build agent loop where the agent can patch
next.config.ts if needed.
* fix(workflow): remove false-positive verify rules; rely on claude reviewer
The 'no process.env' shell regex was a false positive — process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID
is the canonical pattern in this repo (infra/sage.ts, infra/traffic-recorder.ts,
infra/cloudflare-worker-migrations.ts all use it). The 'publicly_accessible'
regex also fired spuriously due to set -e + || subshell interaction.
The semantic checks (no secrets via process.env, Aurora stays private) are
already enumerated in the final-review step's task prompt for the claude
reviewer agent — keep them there where judgement can apply, drop the
brittle regex.
* fix(acceptance): retry 503 + correct stale auth-error contract
Resolves five categories of failures from running the suite against
current prod:
- helpers/server.ts now retries 503 as well as 429 with exponential
backoff. Prod is in throttle recovery (Lambda URL OAC was just
hardened in #631) and the suite shouldn't fail PRs while the edge
works through the backlog.
- integrations/{github,linear,notion}-query.test.ts and
integrations/proxy-slack.test.ts now assert 401 (the unified auth
envelope) instead of the historical 403. The bearer-auth helper was
unified months ago; the tests were encoding pre-unification behavior.
- workspaces/workspaces-join.test.ts now correctly asserts that
anonymous workspaces ignore a forged bearer (the existing route
contract — anon join is open) instead of asserting an impossible 401.
- runtime/{agents,deploy}.test.ts no longer assert exact substring
"Internal Server Error" in non-JSON error bodies — CloudFront /
Lambda edge pages vary; we now just assert a non-empty body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(coverage): real handler-coverage gate + issue-tracked allowlist
The Phase 0 coverage gates were paper: check-handler-coverage.ts only
grep'd for `// @handler` comment markers, and check-route-coverage.mjs
already had a `reason` field but no `issue` requirement so deferrals
weren't actionable. Three 2-line handler-test files were also
re-exporting node:test suites that the vitest runner can't see.
This commit:
- Rewrites scripts/check-handler-coverage.ts to validate every claimed
`@handler` route by walking the file's import graph (static, dynamic,
and the bare-string-literal-to-route.ts pattern node:test handler
shims use) and confirming (a) at least one reachable file imports
vitest, and (b) at least one reachable file lives under the route's
directory in packages/web/app/api/. Files that satisfy only the
`@handler` marker but never load any of the route's source fail.
- Adds destructured-export detection (`export const { POST } = ...`) to
the stateful-route walker so factory-pattern routes like
/api/v1/agents/deploy are no longer silently dropped.
- Adds `issue` as a required allowlist field in both gates. Deferred
coverage now requires a tracking reference, not just a freeform
reason. (route gate previously only required `reason`.)
- Deletes 3 handler test files that re-exported node:test suites:
github-clone-route, proactive-deploy-routes, workspace-secrets-routes.
Their underlying tests still run via npm run github:validate /
orchestrator:test; the deletion just removes the false 0-test-claim.
- Adds allowlist entries for the 4 corresponding routes referencing
tracking issue #638 (vitest-port follow-up).
- Adds allowlist entries for 10 routes added post-Phase-0 that have no
acceptance coverage yet (digest-functions, gitlab, relayfile batch /
audit) referencing tracking issue #639.
- Makes check-route-coverage.mjs fall back to walking
packages/web/app/api/ when the generated .next/types/routes.d.ts is
not present (local dev / pre-build CI invocation).
- Bumps the timeout on tests/workflow-run-response-pushed-to.test.ts's
concurrent-merge test from the default 5s to 30s — the PR author
had already flagged this as flaky under parallel load; the fix is
trivial.
After this commit:
$ node scripts/check-route-coverage.mjs
Route coverage OK (181 route-method pairs checked).
$ npx tsx scripts/check-handler-coverage.ts
Handler coverage OK (41 stateful routes, 47 real-covered, 11 allowlisted).
$ vitest run --dir packages/web/test/handlers
280 passed (280), 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci+docs: wire phase0-tests job and document the suite
CI:
- Adds a `phase0-tests` job to .github/workflows/ci.yml that runs on
every PR:
1. node scripts/check-route-coverage.mjs — acceptance gate
2. npx tsx scripts/check-handler-coverage.ts — handler gate
3. vitest run --dir packages/web/test/handlers — Layer 2 (offline)
4. vitest run (replay-harness) — Layer 3 (offline)
5. vitest run (acceptance) against prod — Layer 1
Authenticated acceptance tests are it.skip'd when no credentials are
provided, so the suite is safe to run on any PR without secrets.
Docs:
- Rewrites packages/acceptance/README.md from a layout stub into a
living playbook: how-to-run, how-to-add-a-test-for-a-new-route,
what-gets-enforced, when-tests-fail-in-CI, what-is-NOT-tested, and
the two follow-up issues (#638, #639).
- Replaces packages/acceptance/AGENTS.md, which was full of stale
workflow guidance, with a real agent guide: contract conventions,
the // @route marker contract, allowlist rules, and a do-not list.
- Adds packages/web/test/handlers/README.md (didn't exist) with the
two test-authoring patterns (in-file vitest vs. re-export), the
coverage-gate contract, common failure modes, and open follow-ups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(migration): Hyperdrive Option B — Tunnel + Access + private Aurora
Phase 3 prerequisite. Wires up the full Hyperdrive-via-Cloudflare-Tunnel
infrastructure so Workers can reach Aurora WITHOUT exposing Aurora to
the public internet.
All declarative — no operator dashboard clicks (CF API token already
has Tunnel/Access/Hyperdrive scopes per operator setup).
Deliverables:
- infra/cloudflared-tunnel.ts Tunnel + Access app + service token
- infra/aurora-tunnel-daemon.ts cloudflared Fargate task in VPC
- infra/hyperdrive.ts real HyperdriveConfig (replaces null)
- infra/webhook-worker.ts restore hyperdrive link
Spec: docs/migration/hyperdrive-network-path.md (PR #634)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(architecture): defense-in-depth doc for preventing single-client outages
Direct response to 2026-05-14 incident (issue #640): a single internal
client's fsnotify-driven retry loop saturated production for ~6.5h
because five independent rate-limit / backoff defense layers were
simultaneously missing.
This doc maps the seven defense layers, what each does, what's in
place, what's missing, and where each gap is tracked. Living
document — updates as defense layers land.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(edge): per-key rate limit at Cloudflare Worker gateways
Closes the server-side rate-limit gap exposed by the 2026-05-14
relayfile-mount runaway loop. A single workspace hitting
/v1/workspaces/<id>/sync/status at ~30 req/sec exhausted the
upstream Lambda's 10-slot concurrency cap and 429'd unrelated
users; neither the cloud router nor the relayfile-api worker
had any per-key throttle in place.
Adds a shared KV-backed rate limiter to both edge Workers:
60 req/min per workspace (or user, or IP), 1000 req/min global,
tunable at runtime through the existing ROUTER_CONFIG KV
namespace. A RATE_LIMIT_DISABLED kill switch is available for
emergency rollback without a redeploy.
The limiter runs before any other routing in both Workers, so a
runaway key gets a 429 at the edge and never reaches CloudFront
or Lambda. Counters are bucketed by minute in a dedicated
RATE_LIMIT_COUNTERS KV namespace bound to both gateways, so a
client that hits both endpoints does not get to consume two
separate budgets.
Known limitation: KV is eventually consistent, so the effective
ceiling at high traffic is ~2-3x configured (180 req/min worst-
case for the 60 default). That's accepted for now — still ~100x
lower than the incident-day pattern. Upgrade path to Durable
Object atomicity is documented in docs/security/rate-limiting.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(acceptance): validate redactions per secret match
* fix(acceptance): address review nits for fixtures and helpers
* feat(migration): Phase 3 — cloud-web Worker build (KV-flagged, dormant)
OpenNext-CF build of packages/web, wrangler.toml bindings, SST Worker
resource, KV-flagged router rule. Ships dormant — router defaults to
lambda; cutover is Phase 4.
Deliverables:
- packages/web/.open-next/worker.js (build artifact)
- packages/web/wrangler.toml (staging deploy + wrangler dev config)
- infra/web-worker.ts (sst.cloudflare.Worker for staging)
- packages/router/index.ts (cloudOrigin KV flag, defaults to lambda)
Spec: docs/fargate-migration-spec.md (Phase 3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(phase3): address PR #641 review — wire daemon, harden secret bindings, scope egress
Devin findings (both 🔴):
- BUG-0001: aurora-tunnel-daemon.ts imported `{ cluster, vpc }` but there was no
ECS cluster declared anywhere. Added `sst.aws.Cluster("CloudCluster")` export
to infra/vpc.ts so the tunnel daemon Fargate service has a cluster to land in.
- BUG-0002: aurora-tunnel-daemon.ts was never imported from sst.config.ts, so
the daemon, SG ingress rule, and CW alarm would silently not deploy and the
Hyperdrive → Aurora path would have no tunnel connector. sst.config.ts now
imports both cloudflared-tunnel and aurora-tunnel-daemon explicitly.
CodeRabbit findings:
- Aurora SG resolution: removed the fragile `vpc.securityGroups[0]` fallback
that would have attached the 5432 ingress rule to whatever SG happened to be
first (NOT Aurora's). Now throws explicitly if
appDatabase.nodes.cluster.vpcSecurityGroupIds is empty.
- Scoped cloudflared daemon SG egress from "all ports, all destinations" to
443/tcp only — cloudflared only needs outbound HTTPS to CF's edge.
Other self-review fixes:
- infra/hyperdrive.ts: account ID now reads CLOUDFLARE_DEFAULT_ACCOUNT_ID
directly (canonical pattern used by sage.ts, traffic-recorder.ts,
cloudflare-worker-migrations.ts) instead of the over-clever
`pulumi.all([tunnel.accountId, serviceToken.accountId])` chain that bought
nothing but inconsistency.
- infra/webhook-worker.ts: GITHUB_WEBHOOK_SECRET and COMPOSIO_WEBHOOK_SECRET
bindings are now conditional via spread — omit the binding entirely when the
env var is unset, rather than binding empty strings. An HMAC compared against
"" would pass any signature; the prior behavior was a security hole the
moment the KV flag flipped. Cutover preflight must verify both are populated.
- infra/cloudflared-tunnel.ts: added comment block before accessApplication
documenting the `<tunnelId>.cfargotunnel.com` domain assumption and the
CNAME-from-owned-zone fallback if CF rejects it at deploy time.
Workflow side-effects also committed:
- infra/relayfile.ts, infra/relaycron.ts, infra/sage.ts,
infra/cataloging-agent-{github,linear}.ts, infra/credential-proxy.ts,
infra/relayauth.ts: hoisted scriptName constants to module top instead of
reading `<worker>.nodes.worker.scriptName` after construction. The latter
pattern can trip a Pulumi cycle for queue/cron consumers that reference the
producing worker's scriptName as part of their own binding.
- infra/sst-globals.d.ts + tsconfig change: typecheck shim so `infra/` builds
before `sst install` materializes .sst/platform/config.d.ts. SST's own deploy
regenerates config.d.ts at run time so this only affects local typecheck.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(edge): make rate-limit bypass list runtime-tunable via KV
Adds RATE_LIMIT_BYPASS_PREFIXES_EXTRA to ROUTER_CONFIG so operators
can whitelist additional paths from rate limiting without a deploy.
Static BYPASS_PREFIXES stays as the fail-safe (KV unreachable still
exempts health/observer/static), and the dynamic check fails open on
KV errors so a transient KV blip cannot start rate-limiting healthcheck
traffic. Per-isolate cache with 60s TTL keeps the hot path cheap;
propagation across edges is <60s plus KV's consistency window.
Now ALL rate-limit config (per-key limit, global limit, kill switch,
bypass list) is runtime-tunable via the same KV namespace.
Tests: 13 new — parseBypassExtras defensive behavior (null/empty/
whitespace/non-path/dedup), isBypassPathDynamic static+KV path,
cache TTL boundary, KV error fail-open, end-to-end via maybeRateLimit
showing a KV-whitelisted path bypassing a tight per-key limit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(phase3): nodejs_compat on deployed Worker; consolidate wrangler config
Critical:
- infra/web-worker.ts: add `transform: { worker(args) { args.compatibilityFlags
= ["nodejs_compat"] } }`. OpenNext's Next.js → Worker output requires the
flag at runtime (it imports node:buffer / node:process / etc). Without it,
the deployed Worker boots fine but crashes on the first request touching a
Node API. Setting it via SST's transform is the canonical pattern in this
repo (see infra/webhook-worker.ts:63). The `compatibility_flags` line in
packages/web/wrangler.toml only applies to local `wrangler dev`, NOT to the
SST-deployed Worker — that file is consulted by Wrangler, not by SST.
Cleanup:
- Deleted packages/web/wrangler.jsonc. The PR shipped both a `.jsonc` and a
`.toml` with conflicting `name` ("web" vs "cloud-web-staging") and
conflicting `compatibility_date` (2026-04-15 vs 2026-05-14). Keep
`wrangler.toml` as the single source of truth — it has the full set of
bindings (Hyperdrive, KV, R2, Queues, vars) that the .jsonc was missing.
- packages/web/wrangler.toml: expanded the header comment to (a) call out that
the `<set by ...>` placeholders are documentation, not valid IDs, and
`wrangler dev` will refuse to start until they're replaced, and (b) make
explicit that this file does NOT govern the SST-deployed Worker — the
`transform` block in `infra/web-worker.ts` does.
Hyperdrive ternary in `link:` is intentionally left as-is — `hyperdrive` is
still the `null` placeholder from #632 on this branch until #641 merges and
this branch rebases. Removing the ternary now would break standalone builds
of this branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(infra): bump prod Aurora max ACU 4→8 ahead of Workers Phase 3
Production cluster (cloud-production-appdatabasecluster) is currently
configured to scale 1-4 ACU and peaked at the ceiling (4 ACU) multiple
times in the 24h before this change. The Cloudflare Workers Phase 3
cutover will land more concurrent isolates against the same cluster
than the current Lambda path does, so the compute/IO ceiling needs to
move before that traffic shape arrives.
Doubling (4→8) is the minimum viable hedge — it doesn't change the min,
doesn't change preview/dev (which stay at 0-4 with auto-pause), and
Aurora Serverless v2 pricing is on-demand so the new ceiling is free
until it's actually reached.
Connection count is not the bottleneck (peak observed: 18 connections;
8 ACU = ~450 max_connections), and RDS Proxy continues to front the
cluster in prod with its existing pool config.
Followups (tracked separately, not in this PR):
- Hyperdrive resource + binding lands with the Phase 3 web Worker PR
- CloudWatch alarms on DatabaseConnections (>150) and ACU (>=7)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(acceptance): address remaining helper review defects
* chore(bundle): cherry-pick #623 — RelayFile writeback CF backfill
Brings in the 4 net-new files + the push.ts signature change without the
stale-base PR's 15k lines of accidental main reverts. See #623 for the
original review history.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(phase3): address Devin findings on #647 — daemon egress to Aurora + acceptance retry body cancel
Devin BUG-0001 🔴 — tunnel daemon SG blocks egress to Aurora:5432.
The previous CodeRabbit-driven scoping of the daemon SG to 443/tcp egress
only covered the Cloudflare control-plane tunnel; it never permitted the
*data-plane* hop from cloudflared to Aurora. AWS SGs are stateful on the
inbound side, but the initial outbound TCP SYN still needs an explicit
egress rule. Tunnel would have established to CF cleanly and every
Hyperdrive query would have timed out at the daemon-→-Aurora hop.
Added `auroraTunnelDaemonEgressToAppDatabaseRule` — 5432/tcp egress
scoped to Aurora's SG specifically (NOT 0.0.0.0/0). Updated the SG def
comment to reference both hops.
Devin BUG-0002 🟡 — acceptance retry helper canceled the response body
before returning on the last attempt, so callers got a response with a
disturbed stream and `.json()` / `.text()` threw "stream already
canceled". Swapped the order: check `attempt === MAX_ATTEMPTS - 1`
*first* and return the response untouched, only cancel the body when
we're going to retry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(phase3): address all 5 Codex findings on bundle PR #647
P1.1 🔴 — `infra/web-worker.ts` was declared but never imported in
`sst.config.ts`, so SST never materialized the CloudWebWorker resource.
Same root cause as Devin BUG-0002 on #641's aurora-tunnel-daemon. Added
`await import("./infra/web-worker")` to the import chain.
P1.1b 🔴 — Router's `env.CLOUD_WEB_WORKER` service binding was never
declared on the AgentRelayRouter in `infra/web.ts`. `cloudOrigin=workers`
would have been a silent no-op falling back to Lambda. Added the
service binding via `appendWorkerBindings`. Also hoisted
`cloudWebWorkerScriptName` const in web-worker.ts (matches the pattern
sage / relayauth / relayfile-api use) so the binding can reference a
known name at construction time instead of reading it post-creation.
P1.2 🔴 — Hyperdrive private-DB flow now matches CF docs:
- Real public hostname: `aurora-db.<stage>.agentrelay.cloud` (internal
zone, matches the catalog-* pattern already in use).
- `cloudflare.DnsRecord` CNAMEs it to `<tunnelId>.cfargotunnel.com`
with `proxied: true`.
- Tunnel ingress now declares the hostname explicitly (with a
`http_status:404` catch-all per CF requirement).
- `ZeroTrustAccessApplication.domain` uses the real hostname,
`dependsOn: [auroraDbDnsRecord]` so DNS is in place first.
- `HyperdriveConfig.origin.host` uses the same hostname,
`dependsOn: [accessApplication]` so the Access app exists when
Hyperdrive validates the service-token credentials at create time.
The earlier `<tunnelId>.cfargotunnel.com` pattern is for IP-routed
private networks, not the hostname-routed Access flow Hyperdrive needs.
P1.3 🔴 — Added `npx @opennextjs/cloudflare@1.19.10 build` step to
`_deploy-cloud-stage.yml` so `packages/web/.open-next/worker.js`
actually exists at `sst deploy` time. The existing `@opennextjs/aws`
build produces a different `.open-next/` layout that SST's
CloudWebWorker handler path cannot find.
P1.4 🟠 — Added `/api/v1/webhooks` to `BYPASS_PREFIXES` in
`packages/router/src/rate-limit.ts`. Webhook ingress has no auth
token and no workspace path, so it would bucket by IP and trip the
60/min cap with even modest provider burst traffic. Webhook worker
already has queue-backed backpressure.
P1.5 🟠 — Gated the POST `/api/waitlist` happy-path test in
`packages/acceptance/src/api/public-routes.test.ts` behind
`ACCEPTANCE_WRITES_OK=1`. Default OFF on PR CI so the test stops
writing real rows to the prod waitlist on every PR run. The 400-case
and GET tests still run unconditionally.
All findings credited to Codex review on PR #647. Local gates pass:
infra typecheck clean, 43 router rate-limit tests, 276 handler tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(rate-limit): key cookie-auth /cloud* traffic by session cookie, not IP
Cookie-authenticated requests to /cloud* had no workspace ID and no
Authorization header, so extractLimitKey() was falling through to the
IP bucket — collapsing all users behind a shared NAT into one rate
limit bucket.
Add session-cookie parsing (cookie name: agent_relay_session) as
precedence level 3, between auth-header and IP. The cookie value is
hashed with FNV-1a 32 and keyed as `session:<hex>` so the raw token
never appears in the counter store.
Precedence order is now:
1. ws:<id> — workspace path segment
2. user:<hash> — Authorization header
3. session:<hash> — agent_relay_session cookie (new)
4. ip:<addr> — cf-connecting-ip / x-forwarded-for
5. anon — no signal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(phase3): Track D — make replay/backfill tooling safe-by-default
- backfill script now defaults to dry-run; requires explicit --apply to
mutate (old --dry-run flag kept as an alias so existing invocations
don't break); prints clear warnings in dry-run mode
- runbook fixes the non-existent npm script reference to the correct
`npx tsx scripts/...` invocation via sst shell, and documents the
--apply requirement with a quick-reference options table
- replay harness strips the /cloud mount prefix by default before
building target URLs (configurable via stripMountPrefix option)
- replay harness skips POST/PUT/PATCH/DELETE unless --allow-mutations
is passed; MutationBlockedError exported for programmatic use
- replay harness adds per-request timeout (default 30 s) and overall
replay timeout (default 5 min) with AbortController + deadline guard
- 13/13 replay-harness tests pass (5 new tests cover the new behaviours)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(phase3): Track B — DB client factory, CF env propagation, S3 storage on Workers
B1: Add `packages/core/src/db/factory.ts` with `selectDbClient(env?)` that
routes to postgres-js/Hyperdrive when `env.HYPERDRIVE.connectionString` is set,
else falls back to the existing Lambda pg-pool path. Includes connection caching
and `setDbForTesting` override support. Add `factory.test.ts` (3 vitest cases).
Add `postgres` to `@cloud/core` deps.
B2: Update `packages/web/lib/db/index.ts` to make `getDb()` context-aware: reads
the Cloudflare context via `Symbol.for("__cloudflare-context__")`, calls
`selectDbClient({ HYPERDRIVE })` on Workers, and falls through to Lambda client
otherwise. No call-site changes (51 handlers unmodified).
B3: Add `packages/web/lib/storage.ts` with `createWorkflowStorageS3Client()` and
`readWorkflowStorageBucket()` that read credentials from Worker env bindings first,
then SST Resource, then process.env. Wire all S3 usage sites (artifact-store,
evidence-builder, bundle-store, digest-functions/store, github-push-back, 3 route
handlers) to use the centralized storage helpers. Delete `client-hyperdrive.ts`
(now redundant). Add `lib/cloudflare-context.ts` internal helper and
`scripts/patch-opennext-native-shims.mjs` for Worker build compat.
Verification: factory.test.ts 3/3 pass; handler tests 276/276 pass; infra tsc clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(phase3): Track A — alias ssh2/cpu-features to empty shim in CF Worker bundle
The Worker never invokes SSH code paths, but @agent-relay/sdk → @agent-relay/cloud
→ ssh2 → cpu-features pulls in a native .node binary that esbuild cannot bundle for
a Cloudflare Worker.
Three-layer fix:
1. next.config.ts outputFileTracingExcludes: prevents @vercel/nft from copying ssh2
and cpu-features into the standalone output.
2. next.config.ts turbopack.resolveAlias + webpack: aliases ssh2 and cpu-features to
OpenNext's empty shim during next build so the file tracer never sees the packages.
3. package.json postinstall runs scripts/patch-opennext-native-shims.mjs: patches
bundle-server.js to add the same aliases in the OpenNext esbuild step (which does
not read next.config.ts aliases).
Verified: npx @opennextjs/cloudflare@1.19.10 build exits 0 and produces worker.js.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(phase3): hard-error when --apply and --dry-run are passed together
Silently last-flag-wins was confusing and potentially dangerous for prod ops.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(phase3): address Codex round-3 findings on bundle PR #647
P1.1 🔴 + P1.2 🔴 — CloudWebWorker missing bindings + environment.
The Worker resource only set scriptName + nodejs_compat. `link:` materializes
SST resources but does NOT generate the env.* bindings on the CF Worker.
So `env.HYPERDRIVE` was undefined, `getDb()` fell through to the Lambda
pg+Resource.AppDatabase path (which the Worker can't run), and process.env
reads (URLs, cookie domain, S3 bucket, webhook config) all returned blank.
Added explicit `appendWorkerBindings` for HYPERDRIVE, ROUTER_CONFIG,
RATE_LIMIT_COUNTERS. Mirrored infra/web.ts:148 environment block so the
Worker sees the same NEXT_PUBLIC_APP_URL / RELAYAUTH_URL / RELAYFILE_URL /
RELAY_LLM_PROXY / RELAYCRON_URL / CREDENTIAL_PROXY_URL / cookie domain /
SAGE_WEBHOOK_URL / S3_BUCKET / WEBHOOK_CONSUMERS_JSON / WEBHOOK_MSD_BACKEND_*
that Lambda gets. OpenNext-CF's init.js copies these into process.env on
first request, so the existing handler code reads them unchanged.
P1.4 🔴 — Acceptance CI still mutated prod beyond the waitlist gate.
`workspaces-crud.test.ts:82` POSTed `/api/v1/workspaces/create` and
`workspaces-join.test.ts:31` did the same in beforeAll, both unauthenticated.
The destroyWorkspace helper at `_helpers.ts:78` explicitly skips unauth
workspaces, so every PR CI run was leaking a workspaces row in prod.
Both now gated on `ACCEPTANCE_WRITES_OK=1` — same env var pattern as the
waitlist gate. crud uses `it.skipIf(...)`, join uses `describe.skipIf(...)`
since the whole suite hangs off the beforeAll write.
P1.5 🔴 — `npx tsx scripts/backfill-relayfile-writeback-cf.ts --help` was
broken at the entrypoint. The static named-import form failed at Node's
ESM parse time because packages/web has no `"type": "module"`, so tsx
loaded the .ts via CJS shape and exposed exports under `default`. Node
ESM rejected the named binding before main() ever ran.
Switched to dynamic `await import(...)` inside main() with a guard that
accepts both module shapes. Runbook command from
`docs/operations/relayfile-writeback-cf-backfill.md` now works end-to-end.
P2.6 🟠 — Recorder bypassed the cloud-web Worker path.
`packages/router/index.ts:256` returned `env.CLOUD_WEB_WORKER.fetch()`
early, BEFORE the `recordingRequest = request.clone()` at line 288. So
once cloudOrigin flipped to "workers", the replay harness would have no
/cloud Worker corpus — exactly the wrong moment to lose recording, since
that's when we need replay evidence to prove cutover equivalence.
Moved the recorder clone to the top of fetch(), kept the existing Lambda
path's `recordingRequest` symbol pointing at the same clone so the
Lambda-origin code path is unchanged. Each early-return branch now
ctx.waitUntil(maybeRecord(...)) the cloned request + response. Both the
cloud-web Worker AND webhook Worker paths now capture.
P1.3 (STS-on-Worker) acknowledged out of scope — 6 routes still call
Resource.WorkflowStorage.stsRoleArn and can't trivially run on a Worker
without a separate credential broker. Documented as known gap in the
previous bundle review comment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(phase3): partial Codex round-4 — WORKER_SELF_REFERENCE + acceptance auth-session gate
P1.1 partial 🟠 — Added a `service` binding for WORKER_SELF_REFERENCE
pointing at the Worker's own script name. OpenNext-CF emits
revalidation / on-demand-ISR paths that use this binding to call back
into the Worker without going through the CF edge. Matches the binding
documented in packages/web/wrangler.toml.
The sibling ASSETS binding is intentionally NOT added in this PR — it
requires the `cloudflare.WorkersAssetsUpload` resource flow (hash each
file in .open-next/assets/, upload via CF API, obtain completion JWT,
pass to WorkersScript.assets). SST doesn't expose this directly; it's a
substantial workstream tracked as a follow-up to scope the cutover.
Without ASSETS, the Worker still boots and serves regular requests —
just can't serve `/cloud/_next/static/*` images through the Worker
path. The bundle ships dormant so this doesn't affect current traffic.
P2 🟢 — Auth-session test now requires explicit ACCEPTANCE_ALT_WORKSPACE_ID
when running session-authed. Previously fell back to provisionTestWorkspace()
which silently leaked a workspaces row because teardownTestWorkspace() is
a no-op (no delete endpoint for acceptance-created workspaces yet). PR CI
never hit this today (no session auth wired), but it was a latent leak
for any future authenticated run.
This PR is now considered cutover-DORMANT-ready, not cutover-LIVE-ready.
Remaining cutover gaps (P1.2 S3 creds, P1.3 STS, P1.4 /api/v1/deploy,
plus ASSETS) require structural decisions and ship as follow-up PRs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Phase 3 Web Worker Build Bot <agent@agent-relay.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er-origin fix(router): prefer corrected relay web origin
…der (#946) PR #944 fixed webhook-worker's classification of Nango forward envelopes (`ingress: "nango-forward"` instead of `nango-sync`), and the queue consumer now correctly POSTs the raw envelope to `origin.agentrelay.cloud/cloud/api/v1/webhooks/nango` so cloud-web's `routeForwardEvent` → `handleGitHubForward` can run. Problem: the router worker catches every `/api/v1/webhooks/nango` request when `WEBHOOK_ORIGIN=worker` (per ws-option-b flip, 2026-05-20) and forwards it to webhook-worker — including webhook-worker's own outbound forwards. The queue consumer re-enqueues the bounced request, queue redelivers, ad infinitum until `maxRetries: 5` exhausts. Cloud-web's `handleGitHubForward` is never invoked, `dispatchIntegrationWatchEvent` never fires, and proactive agents triggered on github events stay inert. Evidence post-#944 deploy: - 0 "Nango webhook received" logs on cloud-web-worker - 0 `integration-watch-dispatch` lines (PR #939 diag) - All 196+ POSTs to /api/v1/webhooks/nango landed on webhook-worker - inbound count to webhook-worker rose ~2x post-deploy (loop traffic) Fix: honour the `x-cloud-webhook-worker-forwarded: webhook-worker` header that `buildForwardHeaders` already sets in webhook-worker's queue consumer. When the router sees that header, it skips the webhook-worker redirect and lets the request fall through to cloud-web — the actual destination. Tests: - New `packages/router/test/webhook-routing.test.ts` (4 cases): - routes to webhook-worker when WEBHOOK_ORIGIN=worker (existing) - bypasses webhook-worker when loop-break header present (new) - ignores unrelated header values (don't widen bypass) - regression: flag unset still routes to cloud-web - `npm run typecheck`: clean - Existing router tests (rate-limit, recorder, redact): 60 tests pass Co-authored-by: Hubspot Phase B Bot <agent@agent-relay.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores 2794 files accidentally deleted from main; tree returns to known-good b6b9c954 (all tonight's E2E fixes #1205-#1231 intact). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ng-actions (#2158)
* feat(transcripts): wire recorder transcript ingest → workspace VFS → meeting-actions
Closes the last-mile core gap: the desktop recorder's bot-free path transcribes
on the Modal worker but had nowhere to deliver the note (TRANSCRIPTS_INGEST_URL
had no receiver), so customer transcripts never reached meeting-actions.
- New delivery-only route POST /api/v1/webhooks/transcripts: receives the
transcription-worker's already-transcribed granola note, writes it to the
customer workspace VFS at /recall/recordings/<id>.json, and dispatches the
meeting-actions watch event. Transcription stays on the Modal worker.
- Refactored the recall-hookdeck-webhook delivery half into a shared
`deliverRecallTranscriptNote()` + exported `resolveRecallWorkspaceId()`; the
existing hookdeck path is behavior-unchanged (still transcribes inline).
- Auth: reuses the existing RecorderTranscribeToken (Bearer) — worker's
ingestTranscript now sends it; route verifies it. Moved the secret decl to
infra/secrets.ts so both the worker and web runtime resolve the same value.
- Workspace routing: single-tenant via RECALL_WORKSPACE_ID (wired to the web
worker + deploy var), else first recall-connected workspace.
FOLLOW-UP (durable multi-tenant, tracked): the recorder now knows its signed-in
workspace (cli:auth login, watchdog-recorder#19) — tag the SDK upload with the
customer rw_ id, propagate it into the worker note, and route the ingest on it,
removing the single-tenant RECALL_WORKSPACE_ID pin.
Human-set values after merge+deploy (Khaliq):
gh variable set TRANSCRIPTS_INGEST_URL --repo AgentWorkforce/cloud \
--env production --body https://agentrelay.com/cloud/api/v1/webhooks/transcripts
gh variable set RECALL_WORKSPACE_ID --repo AgentWorkforce/cloud \
--env production --body <Ålesund customer rw_ id>
Verified: route unit test 7/7 + existing recall-hookdeck svix tests 11/11 green;
route-coverage OK; transcription-worker tsc clean; packages/web tsc 0 errors;
build:core + build:platform green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(transcripts): redact ingest body in router recorder + seed secret + timing-safe bearer
Addresses LoginReview on #2158:
P1 (privacy): the edge traffic recorder clones/records /cloud* request bodies to
R2; the transcripts ingest carries full meeting transcript_text/summary_text and
was not in the router's full-body deny list, so sampled traffic could persist raw
transcripts. Added /(?:cloud/)?api/v1/webhooks/transcripts to
FULL_BODY_REDACTION_PATHS + a redaction test (both /api and /cloud/api forms).
P2 (CI / validate-secret-wiring): RecorderTranscribeToken moved to infra/secrets.ts
and linked into web + transcription workers, but wasn't seeded for long-lived
stages. Added `set_secret RecorderTranscribeToken RECORDER_TRANSCRIBE_TOKEN` to
scripts/set-secrets.sh. validate-secret-wiring.sh now passes.
Non-blocking (LoginReview suggestion): bearer check is now constant-time
(node:crypto timingSafeEqual) instead of string equality.
Verified: router redaction + route + recall-hookdeck svix tests 23/23 green;
validate-secret-wiring OK; route-coverage OK; web + router tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…igin (#2438) Repoint the apex (non-/cloud, non-/observer) fallback from the legacy AWS origin origin.agentrelay.net to origin-web.agentrelay.com — the marketing/docs site now running on Cloudflare Workers from the standalone AgentWorkforce/ agentrelay.com repo. Zero-downtime: the new origin is already live and verified; revert this one line to roll back to AWS. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the Cloudflare router that fronts agentrelay.com out of cloud's SST app into this repo (full git history preserved via git-filter-repo), deployed via plain Wrangler as a sibling to web/. This co-locates the front door with the web product and frees router changes from cloud's gated full-SST deploy. - router/ : index.ts + src + tests (61 tests pass, typecheck clean) - router/wrangler.jsonc : binds the cloud-owned resources cross-repo by name/id (CLOUD_WEB_WORKER service, ROUTER_CONFIG + RATE_LIMIT_COUNTERS KV, TRAFFIC_RECORDER R2). Starts on router-next.agentrelay.com for parity verification; flips to agentrelay.com at the apex cutover. - .github/workflows/deploy-router.yml : wrangler deploy on router/ changes - router added as a workspace member Three cloud-owned values are placeholdered in wrangler.jsonc pending lookup: the two prod KV namespace ids and the webhook worker *.workers.dev url. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
More reviews will be available in 26 minutes and 40 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate. For paid Pro and Pro+ PR reviews, CodeRabbit uses rolling per-developer review limits. Reviews become available again as older review attempts age out of the rolling limit window. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThis PR adds a new Cloudflare-based router workspace, implements request routing with webhook handling, rate limiting, and sampled traffic recording, wires build and deployment configuration, adds tests for the new behavior, and documents route precedence and deployment paths. ChangesRouter worker rollout
Sequence Diagram(s)sequenceDiagram
participant Client
participant Router
participant RateLimit
participant WorkerBinding
participant Upstream
participant Recorder
Client->>Router: HTTP request
Router->>RateLimit: maybeRateLimit(request)
alt /cloud or webhook worker route
Router->>WorkerBinding: fetch(transformed request)
WorkerBinding-->>Router: response
else other routed path
Router->>Upstream: fetch(resolved origin/path)
Upstream-->>Router: response
end
Router->>Recorder: maybeRecord(request, response)
Router-->>Client: final response
Estimated code review effort🎯 5 (Critical) | ⏱️ ~100 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces a new Cloudflare Worker router package to manage route precedence, edge-based per-key rate limiting via Cloudflare KV, and a traffic recorder with sensitive data redaction. The feedback highlights two important issues in router/index.ts: first, a potential TypeError caused by stream disturbance when pre-emptively constructing a Request with request.body before verifying environment bindings; second, a missing content-type: application/json header on the 500 error response.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if (await shouldUseWebhookWorker(url.pathname, request, env)) { | ||
| const webhookWorkerRequest = buildWebhookWorkerRequest(request, url); | ||
| if (env.WEBHOOK_WORKER) { | ||
| const workerResponse = await env.WEBHOOK_WORKER.fetch(webhookWorkerRequest); | ||
| if (recorderRequestClone && recorderEnv) { | ||
| ctx.waitUntil( | ||
| maybeRecord(recorderRequestClone, workerResponse.clone(), recorderEnv, ctx), | ||
| ); | ||
| } | ||
| return workerResponse; | ||
| } | ||
|
|
||
| const workerOrigin = env.WEBHOOK_WORKER_ORIGIN?.trim(); | ||
| if (workerOrigin) { | ||
| const originResponse = await globalThis.fetch( | ||
| buildWebhookWorkerRequest(request, url, workerOrigin), | ||
| ); | ||
| if (recorderRequestClone && recorderEnv) { | ||
| ctx.waitUntil( | ||
| maybeRecord(recorderRequestClone, originResponse.clone(), recorderEnv, ctx), | ||
| ); | ||
| } | ||
| return originResponse; | ||
| } | ||
| } |
There was a problem hiding this comment.
Creating webhookWorkerRequest using request.body before checking if env.WEBHOOK_WORKER is defined can cause a TypeError due to stream disturbance if the code falls back to using env.WEBHOOK_WORKER_ORIGIN. In Cloudflare Workers, a ReadableStream (like request.body) can only be associated with a single Request or Response constructor. If env.WEBHOOK_WORKER is undefined, the stream is already disturbed/locked by the first Request constructor, causing the subsequent buildWebhookWorkerRequest call for workerOrigin to throw an error.
We should defer the creation of the Request objects to their respective conditional branches.
if (await shouldUseWebhookWorker(url.pathname, request, env)) {
if (env.WEBHOOK_WORKER) {
const webhookWorkerRequest = buildWebhookWorkerRequest(request, url);
const workerResponse = await env.WEBHOOK_WORKER.fetch(webhookWorkerRequest);
if (recorderRequestClone && recorderEnv) {
ctx.waitUntil(
maybeRecord(recorderRequestClone, workerResponse.clone(), recorderEnv, ctx),
);
}
return workerResponse;
}
const workerOrigin = env.WEBHOOK_WORKER_ORIGIN?.trim();
if (workerOrigin) {
const originResponse = await globalThis.fetch(
buildWebhookWorkerRequest(request, url, workerOrigin),
);
if (recorderRequestClone && recorderEnv) {
ctx.waitUntil(
maybeRecord(recorderRequestClone, originResponse.clone(), recorderEnv, ctx),
);
}
return originResponse;
}
}| } catch (error) { | ||
| return new Response(JSON.stringify({ error: (error as Error).message }), { | ||
| status: 500, | ||
| }); | ||
| } |
There was a problem hiding this comment.
The 500 error response returned when globalThis.fetch fails does not specify a content-type header, even though the body is a JSON string. Adding "content-type": "application/json" ensures clients can parse the error response correctly.
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { "content-type": "application/json" },
});
}|
Preview deployed!
This is a Cloudflare Workers preview version of this PR's build. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3d7ac214a3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const SENSITIVE_HEADER_NAME = /(token|secret|password|signature)/i; | ||
|
|
||
| const FULL_BODY_REDACTION_PATHS = [ | ||
| /^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/, |
There was a problem hiding this comment.
Add /cloud to full-body redaction rules
Because the recorder passes the original public pathname into redactBody, a secrets request routed through the new front door arrives as /cloud/api/v1/workspaces/.../secrets, which this regex (and the following full-body redaction entries) do not match. Those requests then fall through to field-level JSON redaction and can persist secret/auth/connect-session payload values under non-denylisted keys in TRAFFIC_RECORDER; add the optional /cloud prefix as was done for transcripts.
Useful? React with 👍 / 👎.
| output[key] = JSON_SECRET_KEYS.has(key.toLowerCase()) | ||
| ? redactValue(typeof nested === "string" ? nested : JSON.stringify(nested) ?? "") | ||
| : redactJsonValue(nested); |
There was a problem hiding this comment.
Normalize JSON secret keys before matching
When a recorded JSON body uses conventional snake_case secret fields such as access_token, refresh_token, or client_secret, lowercasing alone leaves the underscores, so none of those names match the deny-list entries like accesstoken and clientsecret. On any route that is not fully redacted, those credentials are written verbatim to the traffic corpus; normalize separators or include the snake_case variants before enabling the recorder.
Useful? React with 👍 / 👎.
| // Hookdeck). The webhook worker already has queue-backed | ||
| // backpressure, so rate-limiting at the edge would only cause us | ||
| // to drop legitimate events. See Codex P1.4 on bundle PR #647. | ||
| "/api/v1/webhooks", |
There was a problem hiding this comment.
Exempt /cloud webhooks from rate limiting
maybeRateLimit runs before webhook routing strips the /cloud mount prefix, and the new public router receives webhook URLs as /cloud/api/v1/webhooks/.... This bypass only covers the unmounted path, so provider bursts through agentrelay.com are bucketed by IP and start returning 429 after the default 60/min, dropping legitimate webhook events; include the /cloud-mounted prefix too.
Useful? React with 👍 / 👎.
| { "binding": "ROUTER_CONFIG", "id": "REPLACE_ME__router_config_kv_id" }, | ||
| { "binding": "RATE_LIMIT_COUNTERS", "id": "REPLACE_ME__rate_limit_counters_kv_id" } |
There was a problem hiding this comment.
Replace placeholder KV IDs before auto-deploy
The new workflow deploys this worker on pushes to main that touch router/**, but the config still binds ROUTER_CONFIG and RATE_LIMIT_COUNTERS to literal placeholder IDs. Merging in this state makes the first wrangler deploy target non-existent KV namespaces, so router-next.agentrelay.com will not publish until the real production IDs are committed or injected at deploy time.
Useful? React with 👍 / 👎.
Use a WEBHOOK_WORKER service binding (script name `webhook-worker`) instead of the WEBHOOK_WORKER_ORIGIN *.workers.dev URL var. The router code already prefers the binding, and binding by stable script name avoids pinning an account-specific URL — one fewer cross-repo value that could drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (3)
router/README.md (1)
17-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSpecify language identifier for the fenced code block.
Add a language identifier to the code fence to satisfy the markdown linter and improve editor syntax highlighting.
Suggested fix
-``` +```text ┌────────────────────────────────────┐ │ `@agentworkforce/cloud` │🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@router/README.md` at line 17, The fenced code block in router/README.md is missing a language identifier on its opening fence, which violates the markdown linter rules and prevents syntax highlighting. Add a language identifier (such as `text`) immediately after the opening triple backticks (```) at the start of the code block that contains the ASCII diagram showing the `@agentworkforce/cloud` architecture.Source: Linters/SAST tools
router/test/redact.test.ts (1)
22-40: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd regression tests for
/clouddeny-list parity and malformed-JSON fail-closed behavior.Please add cases for
/cloud/api/...on the other full-body redaction routes (not just transcripts), plus a JSONcontent-typepayload that is malformed and should still be redacted.Also applies to: 42-78
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@router/test/redact.test.ts` around lines 22 - 40, Add additional regression test cases to cover `/cloud` deny-list parity and malformed JSON behavior. First, extend or create test cases similar to the transcript redaction test to verify that other full-body redaction routes (such as the `/api/v1/workspaces/ws_123/secrets` path tested in the "fully redacts bodies for deny-listed paths" test) also properly redact when accessed with the `/cloud` prefix (e.g., `/cloud/api/v1/workspaces/ws_123/secrets`). Second, add a new test case that verifies the redactBody function handles malformed JSON payloads with `application/json` content-type by still redacting them completely, ensuring fail-closed behavior for invalid JSON input..github/workflows/deploy-router.yml (1)
25-29: 🔒 Security & Privacy | 🔵 TrivialConsider pinning GitHub Actions to commit SHAs for stricter supply-chain control.
While using major version tags (
@v4) is GitHub's recommended approach for official actions and ensures you receive security patches, pinning to specific commit SHAs provides explicit control and prevents unexpected behavior changes. This is optional but aligns with defense-in-depth practices if your organization requires stricter CI/CD hardening.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/deploy-router.yml around lines 25 - 29, The GitHub Actions used in the workflow file are pinned to major version tags (`@v4`) rather than specific commit SHAs. Replace the version tags in the `actions/checkout@v4` and `actions/setup-node@v4` actions with their corresponding commit SHAs (for example, `actions/checkout@<commit-sha>` and `actions/setup-node@<commit-sha>`) to provide stricter supply-chain control and prevent unexpected behavior changes from action updates.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/deploy-router.yml:
- Around line 33-41: The workflow is missing a validation step to prevent
deploying with unresolved configuration values. Add a new step between "Install
dependencies" and "Deploy router to Cloudflare" that checks the
router/wrangler.jsonc file for any remaining REPLACE_ME__ sentinel values using
a grep or similar validation command, and fails the job if any are found. This
guard should execute before the wrangler deploy command runs to catch
configuration issues early.
- Around line 24-25: The actions/checkout@v4 step in the deploy-router workflow
is missing the persist-credentials configuration which leaves a writable
authentication token in the workspace after checkout. Add the
persist-credentials input parameter set to false on the checkout action to
prevent this security risk, as the workflow performs no authenticated Git
operations after the initial checkout step.
In `@router/index.ts`:
- Around line 290-295: The code constructs a new URL object from the
workerOrigin config value without validation, which will throw an uncaught
exception if the environment variable is malformed. Wrap the URL construction
logic in a try-catch block to catch any exceptions thrown by new
URL(workerOrigin), and return a controlled error response instead of allowing
the exception to escape. Apply this validation fix to both code blocks where
this pattern occurs (the first at the location shown and the second at lines
366-370 as noted in the comment).
In `@router/src/redact.ts`:
- Around line 11-23: The FULL_BODY_REDACTION_PATHS array in redact.ts currently
only has one pattern that handles the optional /cloud prefix for sensitive
routes. Update all remaining regex patterns in this array to also match their
/cloud-prefixed equivalents by adding the optional (?:cloud\/)? prefix pattern
at the beginning of each route, similar to how it's already done for the
/api/v1/webhooks/transcripts pattern. This applies to all patterns that start
with /api/ such as those for workspaces/secrets, /api/auth, /api/v1/auth,
/api/v1/cli/auth, /api/v1/cli/login, /api/v1/integrations/nango/connect-link,
and workspaces integrations connect-session.
- Around line 123-127: The catch block in the JSON redaction logic currently
returns the original unredacted body when JSON parsing fails, which can leak
secrets into recorder storage. Instead of returning the raw body in the catch
block following the JSON.parse and redactJsonValue calls, return a safe default
value such as an empty string or a JSON object that indicates redaction failure,
ensuring that unparseable content does not expose sensitive information.
In `@router/wrangler.jsonc`:
- Around line 36-39: The WEBHOOK_WORKER_ORIGIN configuration value in
wrangler.jsonc contains a sentinel placeholder
"REPLACE_ME__webhook_worker_workers_dev_url" instead of the actual webhook
worker URL. Replace this placeholder value with the actual generated
*.workers.dev URL of your webhook worker by looking it up via the `wrangler
deployments list --name <webhook worker script>` command or checking the
Cloudflare dashboard, then update the WEBHOOK_WORKER_ORIGIN value with the
correct URL before merging.
---
Nitpick comments:
In @.github/workflows/deploy-router.yml:
- Around line 25-29: The GitHub Actions used in the workflow file are pinned to
major version tags (`@v4`) rather than specific commit SHAs. Replace the version
tags in the `actions/checkout@v4` and `actions/setup-node@v4` actions with their
corresponding commit SHAs (for example, `actions/checkout@<commit-sha>` and
`actions/setup-node@<commit-sha>`) to provide stricter supply-chain control and
prevent unexpected behavior changes from action updates.
In `@router/README.md`:
- Line 17: The fenced code block in router/README.md is missing a language
identifier on its opening fence, which violates the markdown linter rules and
prevents syntax highlighting. Add a language identifier (such as `text`)
immediately after the opening triple backticks (```) at the start of the code
block that contains the ASCII diagram showing the `@agentworkforce/cloud`
architecture.
In `@router/test/redact.test.ts`:
- Around line 22-40: Add additional regression test cases to cover `/cloud`
deny-list parity and malformed JSON behavior. First, extend or create test cases
similar to the transcript redaction test to verify that other full-body
redaction routes (such as the `/api/v1/workspaces/ws_123/secrets` path tested in
the "fully redacts bodies for deny-listed paths" test) also properly redact when
accessed with the `/cloud` prefix (e.g.,
`/cloud/api/v1/workspaces/ws_123/secrets`). Second, add a new test case that
verifies the redactBody function handles malformed JSON payloads with
`application/json` content-type by still redacting them completely, ensuring
fail-closed behavior for invalid JSON input.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 94d73fe8-3f84-4767-a964-f69f7b386e82
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (14)
.github/workflows/deploy-router.ymlpackage.jsonrouter/README.mdrouter/index.tsrouter/package.jsonrouter/src/rate-limit.tsrouter/src/recorder.tsrouter/src/redact.tsrouter/test/rate-limit.test.tsrouter/test/recorder.test.tsrouter/test/redact.test.tsrouter/test/webhook-routing.test.tsrouter/tsconfig.jsonrouter/wrangler.jsonc
| - name: Checkout code | ||
| uses: actions/checkout@v4 |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
git ls-files | grep -i "deploy-router"Repository: AgentWorkforce/agentrelay.com
Length of output: 203
🏁 Script executed:
cat -n .github/workflows/deploy-router.yml | head -40Repository: AgentWorkforce/agentrelay.com
Length of output: 1281
🌐 Web query:
actions/checkout v4 persist-credentials security best practice GitHub Actions
💡 Result:
Setting persist-credentials: false in the actions/checkout action is considered a security best practice for most GitHub Actions workflows [1][2][3]. By default (persist-credentials: true), the action configures the Git local configuration with the provided token or SSH key [4][5][6]. This allows subsequent steps in the same job to perform authenticated Git operations automatically, but it also creates a security risk: any process or action executed later in the job can access these credentials from the.git/config file [7][2]. If a workflow step is compromised, or if the working directory is inadvertently uploaded as an artifact, the credentials may be exposed to unauthorized parties [2][3]. Key security considerations: 1. Recommended Practice: Set persist-credentials: false unless your workflow explicitly requires authenticated Git operations (e.g., pushing commits back to the repository) after the checkout step [1][2][3]. 2. Risk Reduction: Disabling credential persistence ensures that credentials are not stored on disk in the runner's working directory, significantly reducing the potential blast radius if a workflow is compromised [1][2]. 3. Manual Credential Management: If you disable persistence but still need to perform authenticated Git operations, you must manually configure the required authentication (e.g., by using an environment variable or explicit configuration) in the steps that require it [2]. 4. Context Matters: While GITHUB_TOKEN has default permissions and is handled by the runner environment, persist-credentials specifically controls whether that token is written to the Git configuration on disk [4][7]. For high-value workflows, such as those performing releases or deployments, it is especially important to follow the principle of least privilege, limit the scope of tokens, and consider using more secure alternatives like OIDC (OpenID Connect) for authentication with cloud providers rather than relying on long-lived or persisted secrets [8][3].
Citations:
- 1: https://cheatsheetseries.owasp.org/cheatsheets/GitHub_Actions_Security_Cheat_Sheet.html
- 2: https://phpunit.expert/articles/hardening-github-actions-workflows.html
- 3: https://www.wiz.io/blog/github-actions-security-guide
- 4: https://github.com/actions/checkout
- 5: https://github.com/actions/checkout/blob/v4/README.md
- 6: https://github.com/actions/checkout/blob/v4/action.yml
- 7: Remove
persist-credentialsor change the default tofalseactions/checkout#485 - 8: https://github.com/github/docs/blob/962a1c8dccb8c0f66548b324e5b921b5e4fbc3d6/content/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions.md
Disable checkout credential persistence.
Set persist-credentials: false on the checkout action to avoid leaving a writable token in the workspace for subsequent steps. This workflow performs no authenticated Git operations after checkout, so credential persistence is unnecessary and increases the attack surface.
Suggested change
- name: Checkout code
uses: actions/checkout@v4
+ with:
+ persist-credentials: false📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| persist-credentials: false |
🧰 Tools
🪛 zizmor (1.26.1)
[warning] 24-25: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
[error] 25-25: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/deploy-router.yml around lines 24 - 25, The
actions/checkout@v4 step in the deploy-router workflow is missing the
persist-credentials configuration which leaves a writable authentication token
in the workspace after checkout. Add the persist-credentials input parameter set
to false on the checkout action to prevent this security risk, as the workflow
performs no authenticated Git operations after the initial checkout step.
Source: Linters/SAST tools
| - name: Install dependencies | ||
| run: npm ci | ||
|
|
||
| - name: Deploy router to Cloudflare | ||
| working-directory: router | ||
| env: | ||
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
| CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} | ||
| run: ../node_modules/.bin/wrangler deploy |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Add a pre-deploy guard for unresolved config sentinels.
A fast validation step should fail the job if REPLACE_ME__ values remain in router/wrangler.jsonc, preventing bad production deploys.
Suggested change
- name: Install dependencies
run: npm ci
+ - name: Validate router wrangler config
+ run: |
+ if grep -q "REPLACE_ME__" router/wrangler.jsonc; then
+ echo "Unresolved placeholder found in router/wrangler.jsonc"
+ exit 1
+ fi
+
- name: Deploy router to Cloudflare
working-directory: router
env:🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/deploy-router.yml around lines 33 - 41, The workflow is
missing a validation step to prevent deploying with unresolved configuration
values. Add a new step between "Install dependencies" and "Deploy router to
Cloudflare" that checks the router/wrangler.jsonc file for any remaining
REPLACE_ME__ sentinel values using a grep or similar validation command, and
fails the job if any are found. This guard should execute before the wrangler
deploy command runs to catch configuration issues early.
| if (workerOrigin) { | ||
| const originUrl = new URL(workerOrigin); | ||
| targetUrl.protocol = originUrl.protocol; | ||
| targetUrl.hostname = originUrl.hostname; | ||
| targetUrl.port = originUrl.port; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Validate WEBHOOK_WORKER_ORIGIN before request construction.
Line 291 constructs new URL(workerOrigin) from config without validation. If this env value is malformed, the throw escapes this branch and webhook requests fail with an uncaught runtime error instead of a controlled response.
Suggested fix
const workerOrigin = env.WEBHOOK_WORKER_ORIGIN?.trim();
if (workerOrigin) {
- const originResponse = await globalThis.fetch(
- buildWebhookWorkerRequest(request, url, workerOrigin),
- );
+ let forwardedRequest: Request;
+ try {
+ forwardedRequest = buildWebhookWorkerRequest(request, url, workerOrigin);
+ } catch {
+ return new Response(
+ JSON.stringify({ error: "invalid webhook worker origin configuration" }),
+ {
+ status: 503,
+ headers: { "content-type": "application/json" },
+ },
+ );
+ }
+ const originResponse = await globalThis.fetch(forwardedRequest);
if (recorderRequestClone && recorderEnv) {
ctx.waitUntil(
maybeRecord(recorderRequestClone, originResponse.clone(), recorderEnv, ctx),
);
}Also applies to: 366-370
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@router/index.ts` around lines 290 - 295, The code constructs a new URL object
from the workerOrigin config value without validation, which will throw an
uncaught exception if the environment variable is malformed. Wrap the URL
construction logic in a try-catch block to catch any exceptions thrown by new
URL(workerOrigin), and return a controlled error response instead of allowing
the exception to escape. Apply this validation fix to both code blocks where
this pattern occurs (the first at the location shown and the second at lines
366-370 as noted in the comment).
| const FULL_BODY_REDACTION_PATHS = [ | ||
| /^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/, | ||
| /^\/api\/auth(?:\/.*)?$/, | ||
| /^\/api\/v1\/auth(?:\/.*)?$/, | ||
| /^\/api\/v1\/cli\/auth(?:\/.*)?$/, | ||
| /^\/api\/v1\/cli\/login(?:\/.*)?$/, | ||
| /^\/api\/v1\/integrations\/nango\/connect-link(?:\/.*)?$/, | ||
| /^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/integrations\/connect-session(?:\/.*)?$/, | ||
| // Recorder transcript ingest carries full meeting transcript_text/summary_text; | ||
| // never persist its body to the traffic corpus. Match with or without the | ||
| // /cloud app prefix. | ||
| /^\/(?:cloud\/)?api\/v1\/webhooks\/transcripts(?:\/.*)?$/, | ||
| ]; |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Extend full-body redaction rules to /cloud-prefixed sensitive routes.
Most deny-listed patterns only match /api/.... Router-ingressed equivalents under /cloud/api/... can bypass full-body redaction and persist secrets.
Suggested change
const FULL_BODY_REDACTION_PATHS = [
- /^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/,
- /^\/api\/auth(?:\/.*)?$/,
- /^\/api\/v1\/auth(?:\/.*)?$/,
- /^\/api\/v1\/cli\/auth(?:\/.*)?$/,
- /^\/api\/v1\/cli\/login(?:\/.*)?$/,
- /^\/api\/v1\/integrations\/nango\/connect-link(?:\/.*)?$/,
- /^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/integrations\/connect-session(?:\/.*)?$/,
+ /^\/(?:cloud\/)?api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/,
+ /^\/(?:cloud\/)?api\/auth(?:\/.*)?$/,
+ /^\/(?:cloud\/)?api\/v1\/auth(?:\/.*)?$/,
+ /^\/(?:cloud\/)?api\/v1\/cli\/auth(?:\/.*)?$/,
+ /^\/(?:cloud\/)?api\/v1\/cli\/login(?:\/.*)?$/,
+ /^\/(?:cloud\/)?api\/v1\/integrations\/nango\/connect-link(?:\/.*)?$/,
+ /^\/(?:cloud\/)?api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/integrations\/connect-session(?:\/.*)?$/,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const FULL_BODY_REDACTION_PATHS = [ | |
| /^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/, | |
| /^\/api\/auth(?:\/.*)?$/, | |
| /^\/api\/v1\/auth(?:\/.*)?$/, | |
| /^\/api\/v1\/cli\/auth(?:\/.*)?$/, | |
| /^\/api\/v1\/cli\/login(?:\/.*)?$/, | |
| /^\/api\/v1\/integrations\/nango\/connect-link(?:\/.*)?$/, | |
| /^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/integrations\/connect-session(?:\/.*)?$/, | |
| // Recorder transcript ingest carries full meeting transcript_text/summary_text; | |
| // never persist its body to the traffic corpus. Match with or without the | |
| // /cloud app prefix. | |
| /^\/(?:cloud\/)?api\/v1\/webhooks\/transcripts(?:\/.*)?$/, | |
| ]; | |
| const FULL_BODY_REDACTION_PATHS = [ | |
| /^\/(?:cloud\/)?api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/, | |
| /^\/(?:cloud\/)?api\/auth(?:\/.*)?$/, | |
| /^\/(?:cloud\/)?api\/v1\/auth(?:\/.*)?$/, | |
| /^\/(?:cloud\/)?api\/v1\/cli\/auth(?:\/.*)?$/, | |
| /^\/(?:cloud\/)?api\/v1\/cli\/login(?:\/.*)?$/, | |
| /^\/(?:cloud\/)?api\/v1\/integrations\/nango\/connect-link(?:\/.*)?$/, | |
| /^\/(?:cloud\/)?api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/integrations\/connect-session(?:\/.*)?$/, | |
| // Recorder transcript ingest carries full meeting transcript_text/summary_text; | |
| // never persist its body to the traffic corpus. Match with or without the | |
| // /cloud app prefix. | |
| /^\/(?:cloud\/)?api\/v1\/webhooks\/transcripts(?:\/.*)?$/, | |
| ]; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@router/src/redact.ts` around lines 11 - 23, The FULL_BODY_REDACTION_PATHS
array in redact.ts currently only has one pattern that handles the optional
/cloud prefix for sensitive routes. Update all remaining regex patterns in this
array to also match their /cloud-prefixed equivalents by adding the optional
(?:cloud\/)? prefix pattern at the beginning of each route, similar to how it's
already done for the /api/v1/webhooks/transcripts pattern. This applies to all
patterns that start with /api/ such as those for workspaces/secrets, /api/auth,
/api/v1/auth, /api/v1/cli/auth, /api/v1/cli/login,
/api/v1/integrations/nango/connect-link, and workspaces integrations
connect-session.
| try { | ||
| return JSON.stringify(redactJsonValue(JSON.parse(body))); | ||
| } catch { | ||
| return body; | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Fail closed on JSON parse errors in redaction.
For JSON content types, parse failures currently return the original body unchanged. That can leak secrets into recorder storage.
Suggested change
try {
return JSON.stringify(redactJsonValue(JSON.parse(body)));
} catch {
- return body;
+ return redactValue(body);
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@router/src/redact.ts` around lines 123 - 127, The catch block in the JSON
redaction logic currently returns the original unredacted body when JSON parsing
fails, which can leak secrets into recorder storage. Instead of returning the
raw body in the catch block following the JSON.parse and redactJsonValue calls,
return a safe default value such as an empty string or a JSON object that
indicates redaction failure, ensuring that unparseable content does not expose
sensitive information.
Address review feedback: - Build the forwarded webhook Request inside each branch so request.body (a single-use ReadableStream) isn't disturbed before the WEBHOOK_WORKER vs WEBHOOK_WORKER_ORIGIN choice — the fallback path could otherwise throw. - Set content-type: application/json on the 500 error response (JSON body). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the reviews (Gemini / Codex / CodeRabbit). Triaged below. ✅ Fixed in this PR
✅ Already resolved (reviews ran on the pre-fix commit
|
The router and web app are no longer in cloud/relay — relabel the diagram boxes: @agentworkforce/cloud /packages/router -> @agentworkforce/agentrelay.com /router, and the marketing web source -> @agentworkforce/agentrelay.com /web. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Moves the Cloudflare router that fronts
agentrelay.comout of cloud's SST appinto this repo (full git history preserved), deployed via plain Wrangler as a
sibling to
web/. Co-locates the front door with the web product and freesrouter changes from cloud's gated full-SST production deploy.
Scope: prod only. Cloud keeps deploying the staging/dev routers; those retire
in Phase 3 (domain consolidation).
What's here
router/—index.ts+src/+ tests, lifted fromcloud/packages/router.61 tests pass, typecheck clean,
wrangler deploy --dry-runbundles it.router/wrangler.jsonc— binds the cloud-owned resources cross-repo byname/id:
CLOUD_WEB_WORKERservice (cloud-web-worker),ROUTER_CONFIG+RATE_LIMIT_COUNTERSKV,TRAFFIC_RECORDERR2, plus theCLOUD_APP_ORIGIN/FILE_OBSERVER_ORIGIN/WEBHOOK_WORKER_ORIGINvars..github/workflows/deploy-router.yml—wrangler deployonrouter/changes.routeradded as a workspace member.router/wrangler.jsoncAll in the same Cloudflare account; look them up once:
ROUTER_CONFIGKV id andRATE_LIMIT_COUNTERSKV id →wrangler kv namespace list(titlesrouter-config,rate-limit-counters)WEBHOOK_WORKER_ORIGIN→ the cloud webhook worker's*.workers.devURL (dashboard /wrangler deployments)Rollout (after the ids are filled)
deploy-router.ymldeploys torouter-next.agentrelay.com./,/cloud,/observer*, rate-limit, recorder, webhook flag).(cloud PR) → flip
router/wrangler.jsoncroute toagentrelay.com→wrangler deploy.🤖 Generated with Claude Code
Summary by cubic
Relocates the Cloudflare front-door router for agentrelay.com into this repo under
router/, deployed withwranglerfor faster, independent releases. Also fixes webhook forward handling to avoid disturbing request streams and ensures 500 errors return JSON.New Features
router/Worker with rate limiting, traffic recorder, redirect rewrites; tests pass and typecheck is clean.router/wrangler.jsoncwith bindings:CLOUD_WEB_WORKER,WEBHOOK_WORKER,ROUTER_CONFIG,RATE_LIMIT_COUNTERS,TRAFFIC_RECORDER; varsCLOUD_APP_ORIGIN,FILE_OBSERVER_ORIGIN; prod KV IDs are pinned..github/workflows/deploy-router.ymltowrangler deployonrouter/**changes; root scripts includedeploy:router.https://origin-web.agentrelay.com;/observerrouting remains unchanged; webhook requests build forwarded bodies per-branch to avoid stream errors and break the/webhooks/nangoself-forward loop; 500s returnapplication/json.router/README.mddiagram for the relocation and clarifies the agent name.Migration
router-next.agentrelay.com/,/cloud,/observer*, rate-limit, recorder, webhook flag)agentrelay.cominrouter/wrangler.jsonc, thenwrangler deployWritten for commit 4b06f49. Summary will update on new commits.