For Divvent’s own team. It is generated from the operations document in our repository, which is the one to edit. If you use Divvent, the help centre is for you.
6b. Agent access, and the one thing not to do about egress
divvent agent login --env staging mints a GitHub App installation token beside the agent and exchanges it on the agent surface — https://agent.divvent.dev, or https://pr-<n>-agent.divvent.dev for a preview, a hostname on the console Worker that no Access application covers — for a one-hour platform token (docs/proposals/22-agent-access.md). It used to exchange at the console's own hostname, which is behind Access, and got Access's login page. The App's private key lives in the agent's environment as DIVVENT_AGENT_PRIVATE_KEY; the control plane never holds one and never signs.
What an agent can do with the token is read (POST /agent/fn/<name>: agentFleet, agentFleetHealth, agentFaults, agentFaultCounts, agentDeploys, agentAudit, agentCacheWarmer, agentSelf) and ask (requestAction with { function, input, reason }, for one of approveChange, declineChange, openTrial, closeTrial, decideOnCandidate). It is not an operator and runs nothing as itself; every call it makes is an audit row spelled agent:github:<login> or agent:github-app:<slug>. The surface is rate-limited per address and per session (429), and answers 404 to everything else. **Both the functions and the exchange read Authorization: Bearer <token> exactly** — that case, one space (bearerFrom, since hardening step 4). Any other spelling is no token at all: a function call answers 401, recorded once a window as agent.refused "no agent token"; an exchange with no GitHub token in its body either is a 403, "no GitHub token was presented".
Approving what an agent asked for — Ant, in the console
- Open Requests in the console (
/requestsonconsole.divvent.dev;/agent-requestsredirects there — the agent's requests are one queue with the customers' since decision 57), behind Access as every other page; staging and previews only). - Each pending request shows the function, who asked, the change it targets as it is now (for
approveChangeanddeclineChange), for a decline the note exactly as the customer will read it, labelled as written by the agent, the input exactly as it will run and its sha256 — and last, in a box of its own, the agent's reason, labelled agent-authored and not verified, capped at 500 characters, as plain text (nothing in it is a link or a button). Read the reason as a claim, not as the input: the input block is what runs. - Approve and run as me runs that stored input as you, once; the function's own audit row is yours, and a second row reads
<function> requested by agent:…, approved by operator:you. Decline ends it. Either is final: a request is decided once, a replay or a second click is refused, and a request expires unapproved after an hour. If the input was changed after you loaded the page, the approval is refused rather than run — and so is anapproveChangewhose change was replaced after the agent asked about it.
Who may approve. On staging and previews, you may approve your own agent's request — there is nobody else yet. In production the approver must be a second person (decided by Ant, 2026-09-24): the operator an agent's identity is linked to cannot approve its requests, and an identity linked to nobody cannot have a request approved at all. The App's identity is linked to nobody. Each operator links their own agent: linkAgentOwner({ identity: '<github login>' }) links it to your Access email — there is no way to name anybody else's — an operator function on the console's /fn/ behind Access (there is no screen for it yet), with agentOwners and unlinkAgentOwner beside it; none of the three can be called or requested by an agent. Link before the agent files anything: production reads the link as it stood when a request was filed, and refuses an approval by whoever set it. Nothing reads the link until production agent mode exists — below — but it belongs set before the day it does.
The edge, reconciled: rate limits and the sign-up widget
Nothing here is set by hand any more (Ant, 2026-09-27). edgeRules and turnstile in infra/control-plane.json declare the edge, and the deployer reconciles it (packages/deployer/src/edge.ts) on the runs staging.yml and production.yml already make:
- A rate-limiting binding on every door, always. Each
edgeRulesentry is a Cloudflareratelimitbinding on the Worker it names —EDGE_SIGN_UP,EDGE_ERRORSandEDGE_TELEMETRYon the portal,EDGE_AGENTandEDGE_ERRORSon the console — put on every upload by theworkersstage. The door asks it before any other work (before Turnstile's siteverify, before a D1 row, before the body is read), by the connecting address, or by the path for telemetry (decision 27: never by address there). Over it the door answers as its own limits do: 429 with no body at/_divvent/errorsand/_divvent/telemetry, the agent surface's 429, and sign-up's?outcome=limitedredirect. Counted per Cloudflare location, across every isolate there; the in-code limits behind it stay. - A WAF rate-limiting rule on the zone, where the plan can hold one. The
edgestage (aftercrons, in the same invocation) writes one rule per entry into the zone'shttp_ratelimitentrypoint, matching the path on this environment's hostnames for the Worker,refdivvent:<environment>:<id>. It creates, updates and removes only rules with that ref; a rule a person made is a slot it cannot use, named in the log, and never touched. A plan holds 1 (Free), 2 (Pro), 5 (Business) or 100 (Enterprise) rules, so they claim slots inpriorityorder — sign-up, the agent surface, the portal's and the console's browser-fault doors, telemetry — and every rule that did not fit is a line ending covered by the Worker binding only. A Free zone cannot name a hostname in a rule, so there every door has its binding alone. Telemetry's rule counts by path and throttles, which only Advanced Rate Limiting (Enterprise) allows; elsewhere it is skipped with that sentence, and never replaced by a count by address. A second deploy changes nothing. No rule isrequired, so a rule that cannot be applied fails nothing. - The Turnstile widget sign-up draws. On every run that uploads the Workers,
divvent-<environment>-signupis made to match: created where there is none, its domains set to this environment's portal hostnames, mode managed. Its site key becomes the Workers'TURNSTILE_SITE_KEY(the manifest may no longer name it); its secret goes straight onto the portal asTURNSTILE_SECRET_KEY— from the create, or rotated where the portal holds none (a widget somebody made by hand, a run that died between the two), the old secret staying valid for two hours — and is never in a log, a report, GitHub or an artifact. There is noTURNSTILE_SECRET_KEYin any workflow, and the CLI reads none: delete the GitHub secrets of that name in thestagingandproductionenvironments if they were ever set. A preview has no widget, and its sign-up says it is closed.
The one change by hand: two rows on the deploy token, in each account's token (staging's in "Divvent — staging", production's in "Divvent — production"), in Cloudflare's token editor. Names are Cloudflare's API permissions reference's (read 2026-09-27, not yet off the editor):
- Zone → Zone WAF → Edit (API:
Zone WAF Write), on the zone the environment serves:divvent.devon staging's token,divvent.co.ukon production's. It covers reading the entrypoint too. - Account → Turnstile → Edit (API:
Turnstile Sites Write), on the account.
Nothing wider: not Account Rulesets, not Zone Settings. Then check it from a terminal holding the token:
divvent control-plane token --env staging
which asks the Turnstile API once and each zone's rate-limiting entrypoint once per zone the token can see, and prints allowed or refused per row.
Until the rows are added, nothing fails. The Worker bindings are on every door either way — they need no permission beyond Workers Scripts. The edge stage prints a refused line for every rule, naming Zone → Zone WAF: Edit for divvent.dev and ending covered by the Worker binding only. The Turnstile step prints refused turnstile divvent-staging-signup, naming Account → Turnstile: Edit; the portal is uploaded with no site key, and sign-up stays closed and says so. The next deploy after the rows are added creates the widget and the rules.
Rules set by hand before this (the agent hostname's, the browser-fault doors', telemetry's) are not the deployer's: they have no divvent: ref, so they are left alone and take the plan's slots. The log names each by its description. Delete them in Security → WAF → Rate limiting rules once the deployer's are in, or the deployer's lower-priority rules will not fit.
What an agent can never ask for, whoever approves — credentials, break-glass, agent sessions, the warmer owner, change mode and new accounts, and the approval functions themselves — is AGENT_NEVER in packages/api/src/functions/agent.ts, with the reason beside each.
Tenant request rates: per tenant, per route class, by plan
A tenant application's own doors are counted too (docs/proposals/47-backup-scale.md §2), with nothing to set by hand. Every compiled application asks rateLimited before anything else; every deploy through --via carries the account's plan (ratePlanFor), and the deployer gives the Worker five Cloudflare rate-limiting bindings — DIVVENT_RATE_READ, _WRITE, _AUTH, _PUBLIC and _TENANT, namespaces 5101–5105 — at that plan's numbers, and DIVVENT_REQUEST_RATES with the scope every key begins with (<company>/<application>/<target>). So:
- A flood at one tenant is that tenant's. Two tenants in one account, or a trial and its production, never share a counter.
- A plan change reaches the Worker on its next deploy, as a change of standing does.
tenant redeployputs it on sooner. - No plan is still limited (the growth numbers), and a Worker with no bindings —
divvent dev, a Worker from before this — counts in the isolate. - Uncounted:
/_divvent/health,/_divvent/smokeand the command channel, so a flood never hides that an application is up or fails a deploy's smoke.
A customer who says a real person was refused: the answer is a 429 with retry-after: 60, and their plan's row in the table is what they met. A higher plan, or — for a class of traffic the table does not fit — a decision recorded in the proposal, not a hand-edited binding.
Production agent mode — off, and why it cannot be turned on yet
agentMode.prod in infra/control-plane.json is off, and the deploy refuses any other value, naming why. The production mode that was asked for — agents file requests only, each approval carrying the approving operator's fresh second factor — needs an operator step-up the console does not have: an operator there is an Access session with a Workspace identity on a WARP device, and nothing verifies a second factor inside an approval. Break-glass no longer takes the requester's word for one (§6f): it reads when Access last admitted them, off the verified assertion, and needs that within ten minutes. That is a proven, recent Access sign-in under an MFA policy — a real step-up signal, and one an approval could read the same way — but it is a session that is fresh, not a factor presented inside the approve request, which is the bar below; whether it is enough for agent approvals is Ant's decision, and until it is made nothing reads it there. So production has no agent hostname, its console ignores DIVVENT_AGENT_HOST, no agent function is mounted there, and the approval page is a 404.
Enabling it is two things, in order: build a verified step-up for operators on the approval (a WebAuthn assertion or a TOTP check made inside the approve request itself, not a session flag), then change agentMode.prod in a commit a reviewer sees. The deployer's refusal is the second half's guard until the first exists.
**Never set NODE_USE_ENV_PROXY=1 to get around an egress refusal.** A sandboxed agent's outbound HTTPS is an allowlist, and a host that is not on it answers 403 with x-deny-reason: host_not_allowed. The tempting fix is to route Node's fetch through the session's CONNECT proxy, and it breaks minting outright:
$ NODE_USE_ENV_PROXY=1 divvent agent login --env staging
error agent login
GitHub answered 403 minting an installation token for `162581217`.
api.github.com through that proxy gets the session's own GitHub credentials substituted, so the App's JWT is rejected — and the failure reads as a problem with the App, the installation or the key, none of which is true. Node's default fetch goes direct and mints correctly.
The right fix for a blocked host is to add it to the environment's network egress settings. divvent.dev and *.divvent.dev are what a session needs to reach the portal, the console and every preview; the apex and the wildcard are both required, because a wildcard does not match the bare apex. api.cloudflare.com is deliberately not on that list: the control plane holds no Cloudflare credential (DESIGN.md §10), and a session that could reach the Cloudflare API would put back the reach that design removes.
agent login distinguishes the two cases by the x-deny-reason header rather than by the status, because an egress refusal and a 403 from the exchange have nothing in common and the same sentence for both sent somebody reading Cloudflare Access settings for a problem that was a line in a settings page.