Repository navigation
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a9485fb556
ℹ️ 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".
| default: | ||
| // The next roster carries the same devices. | ||
| s.log.Warn("roster queue full; an agent change waits for the next roster") |
There was a problem hiding this comment.
Do not discard the only copy of a roster change
When more than 1,024 roster events are awaiting processing, this branch silently drops the newest event and assumes another roster will follow. However, internal/uplink/uplink.go invokes OnRoster only when SetRoster accepts a newly received roster, so an unchanged roster is not periodically replayed. During a reconnect or update burst, dropping the event that removes an agent can therefore leave that agent's tokens and retained key valid indefinitely, until the box happens to publish another roster revision.
Useful? React with 👍 / 👎.
| tokenError(w, http.StatusBadRequest, "invalid_grant", "the refresh token is not valid") | ||
| return | ||
| } | ||
| fresh, err := s.st.rotate(tok) |
There was a problem hiding this comment.
Validate refresh scopes before rotating the token
For a refresh request containing a scope not present in the original grant, the token is marked used here before the scope validation at lines 106–115 rejects the request. The client receives no replacement refresh token, and retrying with a valid scope is then treated as token reuse and revokes the whole family, forcing the owner through authorization again. Validate the request first and rotate only as part of issuing the replacement.
Useful? React with 👍 / 👎.
| if s.st.countClients() >= maxClients { | ||
| registerError(w, http.StatusServiceUnavailable, "invalid_client_metadata", "registration is full") | ||
| return | ||
| } |
There was a problem hiding this comment.
Prevent registrations from permanently exhausting capacity
The unauthenticated registration endpoint rejects every new client once this persistent count reaches 50,000, but the added store has no expiry or deletion path for client rows. A caller can consume the capacity at the allowed sustained rate in roughly three days, after which dynamic registration returns 503 permanently across restarts and requires manual database repair. Prune stale or unused registrations rather than imposing a lifetime-only count.
Useful? React with 👍 / 👎.
fc61829 to
cd878ad
Compare
Review of #19 (N3, N4): - Authenticate grants the intersection of the token's own scopes and the agent's in the newest roster, so a narrowed refresh stays narrow and an owner's widening needs a new consent. - A bearer token must have this server's shape (prefix, home, dot, 43 characters of base64url) before the store is asked. - The token, revocation and registration endpoints give a client 10 s to send its body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… home The authorization server for MCP clients, after the MCP authorization spec, revision 2026-07-28, with the issuer at https://cloud.ftw.energy: - OAuth 2.1, authorization code with PKCE S256 only; every authorization response, errors too, carries iss (RFC 9207), and the metadata says so (RFC 8414). - Protected resource metadata for https://cloud.ftw.energy/mcp (RFC 9728), at the path-inserted and the root well-known URIs. - resource (RFC 8707): only the MCP resource, at authorize and token; each token records its audience and /mcp checks it. - Client ID metadata documents, fetched behind a fence: https on 443 to a DNS name, every resolved address public (checked again as the socket connects), no proxy, no redirects, 16 KiB, 5 s, eight at once, cached by Cache-Control within a minute and a day, failures for a minute. No shared secret may sit in a public document. - Dynamic registration kept for ChatGPT and older clients: public, or client_secret_post/basic with the secret stored as a hash. - Access tokens for 10 minutes; refresh tokens rotate within a family, and a reused one revokes the family; a reused code revokes what it got. Tokens, codes and request ids are kept as SHA-256 only. - 401 and 403 with WWW-Authenticate: resource_metadata, scope, and insufficient_scope for step-up. Consent has no account and no password. /oauth/authorize asks ftwkeysd for an agent key, then shows the client's name (checked for a metadata document, marked unchecked for a registration), the host the browser goes back to (with a warning for loopback), the rights in plain words, a short code, a QR and https://app.ftw.energy/a/<request>. The app reads /oauth/requests/<id>, the box's page /oauth/requests?code=. The page waits on a server-sent event. When the box's signed roster lists the agent's key as a device that may open a session, the request is approved, the key is kept, and only the browser that started it gets a fresh code. Denied and expired requests return access_denied and the key is forgotten. A token maps to (shard, home, agent id). The subject has a kind, so a staff principal fits later without changing what an agent's token is. A roster that drops an agent, or the end the box set for it, deletes its tokens and its key and tells the MCP side. ftwcloudd mounts it with -keys-socket; state in oauth.db beside the registry. No route reads a home (TestOAuthRoutesReadNoHome). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cloud-scale's shard seam (#17) names shards by string, so the subject carries the shard's name, not a number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A box sends its roster when it changes, so an approval whose key the keys part could not keep at once is tried again every 5 s for two minutes, rather than waiting for a roster that may not come. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srcfl/ftw#1642 (pinned at eeb3a6aa until it merges) defines agents. This follows it: - Scopes are the registry's constants. An agent row is approved only as the roster lists it: role agent, a tunnel, its `agent` grant for the client the offer named, and an end still ahead. Support and release rows are not this door's. - The grant is the roster's: scopes, ops, caps and end, refreshed with every roster, so an owner who narrows, widens or renews a grant changes the token at once. The service stops admitting an agent at its end by its own clock. - The consent page builds the grant offer (key, role, client, clientId, scopes, the ops those scopes open, proposed caps, 30 days, the code; no home, since the owner picks it), has the keys part sign it, and puts it in the link's fragment and the QR. /oauth/requests?code= returns it once. /.well-known/ftw-offer-key serves the key the app pins. - verified means a client FTW Cloud knows, by its document's host; any other document client is shown by its host. - Tokens name their home, ftwat_<home>.<secret>, for shard routing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s without tun srcfl/ftw#1642 at b7d1cc80 (pinned until it merges): - The offer is protocol.GrantOffer with clientHost (the metadata document's host, or a registered client's redirect host), verified and exp (the request's end, at most ten minutes), sent as protocol.SignedOffer {o, kid, sig}; protocol.OpenOffer opens it with the key pinned by keys.OfferKid. Only loadpoint.hold is a hold op now. - Agent rows carry tun false: approval needs the agent grant, the role, the client and an end ahead, not a tunnel. - The code lookup answers any origin, as the box's page fetches it from the owner's browser; the offer key's endpoint names its kid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rKid lands srcfl/ftw#1642 now derives an offer key's kid the same way keys.OfferKid does, and Core pins offer keys as a list of kid:pub. A test holds the two derivations equal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #19 (N3, N4): - Authenticate grants the intersection of the token's own scopes and the agent's in the newest roster, so a narrowed refresh stays narrow and an owner's widening needs a new consent. - A bearer token must have this server's shape (prefix, home, dot, 43 characters of base64url) before the store is asked. - The token, revocation and registration endpoints give a client 10 s to send its body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…direct, exact verified clients 1. Only the newest roster per home waits to be applied, so nothing drops and nothing old replays. A retry after the keys part was away runs the approval pass alone, on the newest roster. 2. A purge reaches the authorization server (uplink.OnPurge -> DropHome): the home's agents, their tokens and keys, and its consents go. A roster from before the purge is not applied after it. 3. A client FTW Cloud does not know gets an error page with a link back to its host, never a 302 before the owner acts (RFC 9700, 4.11.2). 4. Verified is an exact list of client_id URLs. Claude's is on it, read at its URL on 15 Oct 2026; ChatGPT's and Grok's documents were not found. 5. A key listed at a second home is revoked everywhere and forgotten. Codes are eight characters, drawn evenly. Lookups have a bucket per address. The request id moves into the link's fragment. 6. Per-address buckets beside the global ones, taken before the client's document is fetched. CIMD: a document is kept at least a minute, failures have a small cache of their own, and fetches are capped per host. Only the cookie holder keeps an event stream, two at most, under a global cap. Registrations with no agent go after a day. 7. The store admits an agent's, support's or the release check's row neither as a phone nor by a token. And: a key to forget stays queued, across restarts, until the keys part has forgotten it; Keep and Forget get 5 s. redirect_uri is required at the token endpoint when authorize named one. A code used again after the sweep still revokes. A refresh retried within 5 s works once more. Tokens carry their home sealed with a fresh nonce, so no client sees an id that links its tokens. Agents are unique by their full key. Support's client id is not a metadata client. HSTS on the pages. ::/96 is blocked. Claude's real document lists the JWT bearer grant, which the old check refused: a client may list grants this server does not offer, as long as it uses the code grant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3475eb3 to
fccb46d
Compare
#19, #20, #21) The MCP stack as one change: ftwkeysd, the one part that can open an agent's session (#18); OAuth for assistants with the owner's approval at home (#19); reads at /mcp (#20); control within the owner's grant, and requests that wait for the owner (#21). Reviewed in two passes; merged with main after #17 (scale), go/protocol pinned to srcfl/ftw master f024251. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second of four stacked PRs. It is based on #18 (keys); review that first.
What this adds
The authorization server for MCP clients (ChatGPT, Claude, Grok), after the MCP authorization spec, revision 2026-07-28. The issuer is
https://cloud.ftw.energy.OAuth 2.1 with PKCE S256 only.
plainand a missing challenge are refused. Every authorization response, errors too, carriesiss(RFC 9207). The RFC 8414 metadata says so, and saysclient_id_metadata_document_supported.Protected resource metadata (RFC 9728) for
https://cloud.ftw.energy/mcp, at the path-inserted and the root well-known URIs.resource(RFC 8707). Only that resource is accepted, at authorize and at the token endpoint. Each token records its audience, andAuthenticatechecks it.Client ID metadata documents, fetched behind a fence:
Cache-Controlfor between a minute and a day, even withno-store;A document with
client_secretin it is refused. A document may list grants this server does not offer, as Claude's lists the JWT bearer grant; it must use the code grant.::/96(IPv4-compatible IPv6) joins the blocked ranges.Dynamic registration (RFC 7591), kept for ChatGPT and older clients. A client may be public, or use
client_secret_postorclient_secret_basic, with the secret kept as a hash. Redirect URIs must be https, or loopback http with any port; custom schemes are refused.Short tokens. Access tokens last 10 minutes. Refresh tokens rotate within a family. A retry within 5 s works once more, since its answer may have been lost; a later reuse revokes the family. A reused code revokes what it got, even after its consent was swept: spent codes' hashes outlive it. Tokens, codes and request ids are kept as SHA-256 only.
redirect_uriat the token endpoint is required when authorize named one, and must be the same.Limits per address and for everyone. Authorize, registration and code lookups each have a bucket per address (an IPv6 address by its /64) beside the global one. Authorize takes both before it fetches a document or makes a key. Registered clients that never got an agent go after a day.
No token passthrough. The token stays here. The box sees the agent's own Noise session.
401 and 403 with
WWW-Authenticate(resource_metadata,scope, andinsufficient_scopefor step-up).Built to the agent contract
srcfl/ftw#1642 (
docs/agents.md), merged; #18 pins it at bf5bf50:agent, anagentgrant whoseprincipal.clientIdis the client the offer named, and an end still ahead. An agent row hastun: false: only the keys part opens its session. Support and release rows are not this door's.uplink.OnPurgecallsDropHome: the home's agents, their tokens and keys, and its consents go, and a roster from before the purge is not applied after it.oauth.dbwith the revocation and stays queued, across restarts, until the keys part has forgotten it.KeepandForgetget 5 s each.expiresAtMsby its own clock.protocol.SignedOffer {o, kid, sig}.oisGrantOffer: the agent's key, the client,clientHost(the metadata document's host, or a registered client's redirect host),verified, the scopes, the ops those scopes open, proposed caps, 30 days, the page's code andexp(at most ten minutes). It has no home, because the owner picks the home.kidiskeys.OfferKid.https://app.ftw.energy/a#<request>.<offer>), so neither reaches a server's log, and in the QR./oauth/requests?code=returns it once, rate limited per address and for everyone, to any origin, since the box's page fetches it from the owner's browser. The code is eight characters, drawn evenly (about 39 bits)./.well-known/ftw-offer-keyserves the kid and the key to pin.verifiedmeans a client FTW Cloud knows, by the exact URL of its metadata document. The list holds Claude's (https://claude.ai/oauth/mcp-oauth-client-metadata), read at that URL on 15 October 2026. ChatGPT's and Grok's documents were not found, so they show as unverified, by their host, until someone adds their exact URLs. Support's client id is refused here: support offers never come through this page.ftwat_<route>.<secret>, where the route is the home sealed with a fresh nonce under the route key (oauth-route.key, which every routing instance shares). Any instance opens it (Server.TokenHome) to find the home's shard, as in feat: a seam for shards, a quicker hot path, metrics, deploys without a storm, and a load harness #17, but a client sees no id that is the same in two of its tokens./oauth/authorizeasksftwkeysdfor the agent's key pair; only the public half leaves it. The page shows:Strict-Transport-Security, as the error pages have./oauth/requests/<id>; the box's page reads/oauth/requests?code=, rate limited. Both get the client, the scopes and the agent's public key.__Host-cookie) gets a fresh code.access_denied, and the key is forgotten.A token maps to (shard, home, agent id). The subject has a kind, so a later staff principal fits without changing an agent's token; staff sign-in is not built. The shard is a name, as in the seam from #17. A roster that drops an agent, or the end the box set for it, deletes the agent's tokens and its key.
ftwclouddmounts it with-keys-socket, and keeps its state inoauth.dbbeside the registry.server.goandmain.gogain only routes and wiring.What the tests prove
internal/oauth, with a real keys part and an https metadata server:plainchallenge gets a link back withinvalid_requestandiss, and a wrong verifier is refused.invalid_target,invalid_scope); for a verified one, a 302.protocol.OpenOfferand the key pinned by its kid. Its fields are right (no home;expwithin ten minutes). It does not open with another key or at its end. It is in the page's link fragment, and the code finds it once.access_denied. A roster after that does not approve.review_test.go):redirect_uriis required at the token endpoint when authorize named it;internal/store: an agent's, support's or the release check's row is admitted neither as a phone nor by a token.internal/uplink: a purge callsOnPurgeeach time the box asks.CIMD SSRF:
169.254.169.254, CGNAT, IPv6 ULA and link-local, IPv4-mapped, NAT64, and a name that mixes a public and a private answer. The connect-time check refuses too.client_id, a secret in the document, a custom-scheme redirect,private_key_jwt, no name, HTML.internal/server: no assistant route returns a home's readings.Not in this PR
app.ftw.energy's AASA covers/ponly, so/aneeds a paired change insrcfl/ftw-webapp(public/.well-known/apple-app-site-association) and a handler in the iOS app. The link's fragment is<request>.<offer>. The app then sendsagent.addto its box. That is the core-agent-role contract.🤖 Generated with Claude Code