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: 144c44c372
ℹ️ 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".
| // box's Noise key comes from the box with the agent contract; until a | ||
| // box says it, its home takes no agents and the tools say so. | ||
| agents := mcp.NewPool(st, up, agentKeys, func(string) ([]byte, bool) { return nil, false }, log) | ||
| auth.OnRevoke = agents.Drop |
There was a problem hiding this comment.
Keep the revocation callback nonblocking
When a roster revokes an agent with a warm session, oauth.Server invokes OnRevoke inline on its single Run goroutine, but Pool.Drop synchronously closes the connection, writes to the uplink, and waits for keys.End. A slow box or keys daemon can therefore stall subsequent roster approvals, revocations, and expiry sweeps—repeated once per removed agent—despite the callback contract requiring it not to block; dispatch this cleanup asynchronously or make Drop nonblocking.
Useful? React with 👍 / 👎.
| out := map[string]any{"jsonrpc": "2.0", "error": e} | ||
| if len(id) > 0 { | ||
| out["id"] = id | ||
| } |
There was a problem hiding this comment.
Include a null ID in request-level JSON-RPC errors
For parse errors, rejected batches, oversized bodies, and other failures where no request ID can be recovered, this omits the id member entirely. JSON-RPC 2.0 requires such error responses to contain "id": null; strict MCP/JSON-RPC clients may reject the response instead of surfacing the intended error. Initialize the response with a null ID and replace it only when a valid ID is available.
Useful? React with 👍 / 👎.
cad61b0 to
fc61829
Compare
fc61829 to
cd878ad
Compare
d48c811 to
2b1e69f
Compare
…nd limits From the review of #20: - S3: what an open acquires (store session, keys session, sid) is set under the session's lock and only while it is not closing; a Drop or an eviction during an open releases what exists, and the open releases what it gets after. The reader takes its session and sid as arguments. TestADropDuringOpenLeaksNothing holds it under -race. - S2: hello carries an id. A box without a clock refuses it with E_UNAVAILABLE {subsystem: clock}; the refusal now ends the wait for hello at once and comes back in words. - S4: tool names use underscores (homes_list, plan_get, ...), which every model API accepts. - S6: E_UNKNOWN_OP {arg: id} is an id the box did not give; E_NEEDS_OWNER on a hold may be another assistant's; session.terminate says revoked, expired, taken over or restarting. - S7: clean also removes private-use characters, variation selectors, the combining grapheme joiner and the fillers that show as nothing. - S8: an MCP-Protocol-Version this server does not speak gets 400 on the initialize-based path too. - N4: each request gets 10 s to send its body, and each agent 30 requests, then 10 a second (429). - N9: a box's error code reaches a model only in the registry's shape. - N10: agents' sessions have their own budget at each home, apart from the phones' 16. - N11: history returns no more points per series than asked for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ows the revision From the review of #21: - B1: ev_charge_now without power_w paused the car and said it worked, since the box reads a missing power as 0. Charging now needs power_w of at least 1 (the schema says so too); stop is the way to release. - S1: the control revision comes from the chargers' answer, read before the first command when this session has not seen one, so the first command no longer spends the grant's rate on a conflict. - S2: a box without a clock refuses control at once, in words (hello's refusal now reaches the command; see #20). - S5: ev_charge_now carries destructiveHint. - S6: stop where this assistant holds nothing says so (E_NOT_FOUND). - N5: a hold without minutes lasts the default 15, shortened to holdS. - N6: a command the box refused is a tool error (isError), in words. - N15: a client's retry of the same call (same JSON-RPC id, op and arguments) within a minute reuses the command id, so the box acts once; a renewal later is a new command. - Tool names use underscores (ev_charge_now, goal_request, requests_list). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MCP server, Streamable HTTP at https://cloud.ftw.energy/mcp. Transport. Revision 2026-07-28: stateless, version and client capabilities in each request's _meta, the mirrored MCP-Protocol-Version, Mcp-Method and Mcp-Name headers checked against the body (400 -32020, the Base64 form decoded), 400 -32022 with the supported versions, 404 -32601, server/discover, 202 for notifications, 405 for GET and DELETE. Clients of 2025-03-26 to 2025-11-25 that start with initialize are answered too, with no session id and no stream. Origin is checked; batches are refused. Auth. Every request needs an access token from package oauth, for this resource; 401 and 403 carry WWW-Authenticate, and a tool whose scope the token lacks answers 403 insufficient_scope for step-up. A token for another shard gets 421. The token never leaves the service. Read tools, with typed arguments that refuse unknown fields: homes.list, status, plan.get, plan.explain, history.query, health.get, events.list, logs.tail, drivers.status, config.get, diag.run. Each maps onto the box's own API through api.req, or onto the box's hello_ok. Results are data: a note says so in each, and control and formatting characters are removed from every string, so a device name or log line cannot hide text. The path to the box. A call opens or reuses the agent's own Noise session: admission by store.For with the agent's key (the box's signed roster must list it), message 1 from ftwkeysd, carried as tun by the door, one forward; message 2 back the same way. hello goes without waiting. One warm session per agent, closed after 60 s idle, at once when the owner revokes, and the box hears it end. The box serves one api.req per session, so calls on one home queue, four at most; 256 run at once per process; 1,024 sessions per process. The box's Noise key comes from the box with the agent contract; until then ftwcloudd knows none and every home says it takes no assistants yet. The tests run the whole path against a stub box on the real /v1/box: consent through a signed roster, reads, refusal at another home, revocation at the box and by roster, expiry at both ends, the transport in both eras, step-up, bounded fan-out and the idle close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under -race the calls arrive spread out, so how many get a turn varies. That the box never has two at once, that some are turned away and that none are lost does not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The door drops tun past a home's limits (240 messages, then 60 a second, each way). Every answer is at least three messages from the box, so about 80 calls in a row lost one, and that call waited out its 15 s. The home's phones share the same limits. Agents' calls on a home now take from a budget of a quarter of those limits: 20 calls in a row, then 4 a second, and a quarter of the bytes, counted by what each answer brought back. A call waits up to 3 s for its turn, then is told to slow down. TestManyCallsLoseNothing runs past the burst with nothing dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s off the phone path Following the agent contract (srcfl/ftw#1642, docs/agents.md): - The door: store.Agent is a principal for sessions the keys part opens. An agent row (a roster Device with `agent`) is admitted only as Agent, never as a phone (so never on /v1/app or /v1/app/once) and never by a token; a phone's row is never an Agent. The store stops admitting an agent at agent.expiresAtMs by its own clock, without waiting for a roster, and gives an agent nothing from the readable copy. - The reads: the box refuses api.req to agents, so each read tool now asks one of the box's reads for agents: live.get, plan.get (+ price.get for plan.explain), hist.query (tiles unpacked to points, null for MISSING), health.get, events.get, logs.get (level and lines), config.get (section), diag.run (clock, link, meter, planner, driver with its src), drivers.get. Answers pass through as data, CBOR to JSON's shapes, bytes as base64url, every string cleaned. An answer without an id goes to the one call in flight. - Pacing: 15 calls in a row, then 0.75 a second per home, so no rolling minute holds more than the box's 60 reads, and a quarter of the door's bytes. - Words for the new refusals: E_GRANT_EXPIRED, E_OP_NOT_GRANTED, E_CAP_EXCEEDED with the cap and its max, E_RATE_LIMITED with the wait, E_NEEDS_OWNER, E_NOT_FOUND, E_RANGE_TOO_LARGE, and a box without a clock. session.terminate "expired" says the access ran out. live.get and drivers.get are named here until go/protocol names them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It counted the answer's send too, and hello, so a request that arrived as the previous answer left looked like two at once under -race. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its roster The agent contract puts the box's Noise static key in the roster it signs (`noise`, forwarded to srcfl/ftw#1642). RosterBoxKey reads it from the newest roster the registry holds, checked again with the home's key, so the service opens a session only to a key the box itself named. Today's uplink keeps unknown roster fields, so this works as soon as a box sends it; a box that names none takes no agents yet and says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tunnel srcfl/ftw#1642 at b7d1cc80: - The store admits an agent row, which now has tun false and empty copy scopes, only as store.Agent; a phone row still needs a tunnel or the copy, and never passes as an agent. - RosterBoxKey reads the typed cloudwire.Roster.Noise. - Each read tool's scope is the registry's (protocol.RegistryReads). chargers.list (loadpoints.get) and cars.list (cars.get) are new; events.list and logs.tail take before and after cursors and say whether more remain. The box names chargers and cars by its agent ids, which the control tools take back. - An answer comes back only by its request's id; agent.decided, pushed to an open session, answers no call. - E_CLOCK_SKEW in words; E_NEEDS_OWNER says whether a durable op belongs in goal.request or an owner's hold holds the charger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd limits From the review of #20: - S3: what an open acquires (store session, keys session, sid) is set under the session's lock and only while it is not closing; a Drop or an eviction during an open releases what exists, and the open releases what it gets after. The reader takes its session and sid as arguments. TestADropDuringOpenLeaksNothing holds it under -race. - S2: hello carries an id. A box without a clock refuses it with E_UNAVAILABLE {subsystem: clock}; the refusal now ends the wait for hello at once and comes back in words. - S4: tool names use underscores (homes_list, plan_get, ...), which every model API accepts. - S6: E_UNKNOWN_OP {arg: id} is an id the box did not give; E_NEEDS_OWNER on a hold may be another assistant's; session.terminate says revoked, expired, taken over or restarting. - S7: clean also removes private-use characters, variation selectors, the combining grapheme joiner and the fillers that show as nothing. - S8: an MCP-Protocol-Version this server does not speak gets 400 on the initialize-based path too. - N4: each request gets 10 s to send its body, and each agent 30 requests, then 10 a second (429). - N9: a box's error code reaches a model only in the registry's shape. - N10: agents' sessions have their own budget at each home, apart from the phones' 16. - N11: history returns no more points per series than asked for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns drain for a restart - An assistant's client follows no wrong-shard answer, so /mcp stays on cloud.ftw.energy: a token's route names its home, and a request for a home another instance holds goes there whole (Server.Elsewhere) before this instance reads it. No more 421. - The pool is a drainer as #17 has them: Sockets counts the warm agent sessions in the restart's spread, Drain closes each with the box hearing it, and turns calls after it away in words. - The store's agent-row rule moves into admit, since an agent row is admitted as Agent and as nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3475eb3 to
fccb46d
Compare
…ows the revision From the review of #21: - B1: ev_charge_now without power_w paused the car and said it worked, since the box reads a missing power as 0. Charging now needs power_w of at least 1 (the schema says so too); stop is the way to release. - S1: the control revision comes from the chargers' answer, read before the first command when this session has not seen one, so the first command no longer spends the grant's rate on a conflict. - S2: a box without a clock refuses control at once, in words (hello's refusal now reaches the command; see #20). - S5: ev_charge_now carries destructiveHint. - S6: stop where this assistant holds nothing says so (E_NOT_FOUND). - N5: a hold without minutes lasts the default 15, shortened to holdS. - N6: a command the box refused is a tool error (isError), in words. - N15: a client's retry of the same call (same JSON-RPC id, op and arguments) within a minute reuses the command id, so the box acts once; a renewal later is a new command. - Tool names use underscores (ev_charge_now, goal_request, requests_list). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#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>
Third of four stacked PRs. It is based on #19 (OAuth), which is based on #18 (keys). It is built to the agent contract, srcfl/ftw#1642 (merged; #18 pins it at bf5bf50); Core's enforcement is srcfl/ftw#1646.
What this adds
The MCP server: Streamable HTTP at
https://cloud.ftw.energy/mcp.Transport.
_meta. The mirroredMCP-Protocol-Version,Mcp-MethodandMcp-Nameheaders are checked against the body, with the Base64 form decoded.-32020; an unsupported version 400-32022with the supported list; an unknown method 404-32601.server/discoveranswers, a notification gets 202, and GET and DELETE get 405.initializeare answered, with no session id and no stream. A version header this server does not speak gets 400 there too.Auth.
oauth, for this resource. 401 and 403 carryWWW-Authenticate, and a tool whose scope the token lacks answers 403insufficient_scope, so the client can step up./mcpstays oncloud.ftw.energyand never answers 421. A token's route names its home (feat(oauth): assistants sign in with OAuth, and the owner approves at home #19), and a request for a home another instance holds goes there whole, inside FTW Cloud, before this instance reads it (Server.Elsewhere, whichshard.Directoryfills once there is more than one shard).The tools, with typed arguments that refuse unknown fields. Their names fit
^[a-zA-Z0-9_-]{1,64}$, which every model API accepts. Each tool is one of the box's own reads for agents, under the scopeprotocol.RegistryReadsnames:homes_liststatuslive.getchargers_listloadpoints.getcars_listcars.getplan_get(hours)plan.get, trimmedplan_explain(hours)plan.getandprice.gethistory_query(range,points)hist.query: tiles unpacked, null for missing, no more points than askedhealth_gethealth.getevents_list(limit,before,after)events.getlogs_tail(lines,level,before,after)logs.getdrivers_statusdrivers.getconfig_get(section)config.getdiag_run(check,src)diag.runapi.reqto agents, so nothing goes through the passthrough.The door.
Devicewithagent,tun: false, empty copy scopes) is admitted only asstore.Agent, for a session the keys part opens, and never afteragent.expiresAtMsby the store's own clock. An agent's, support's or the release check's row is never admitted as a phone (so never on/v1/appor/v1/app/once) or by a token.Roster.Noisein the newest signed roster, checked again with the home's key. A box that names none takes no assistants yet, and says so.The path to the box: one forward.
store.For(Agent)admits the call.ftwkeysdwrites message 1, and the door carries it astun. Message 2 comes back the same way.Dropor an eviction during an open releases what the open has acquired, and the open releases what it gets after.Socketscounts the warm sessions in the restart's spread, andDraincloses each, with the box hearing it, and turns later calls away in words.main.goadds it to the drainers when this stack is rebased onto feat: a seam for shards, a quicker hot path, metrics, deploys without a storm, and a load harness #17.agent.decidedanswers no call. Calls on one home queue, four at most. A process runs 256 calls and holds 1,024 sessions. Each agent may send 30 requests, then 10 a second (429). Each request gets 10 s to send its body.Refusals in words. Each names its code, and only in the registry's shape:
E_SCOPE_DENIED,E_OP_NOT_GRANTED,E_GRANT_REVOKEDandE_GRANT_EXPIRED;E_CAP_EXCEEDEDwith the cap, andE_RATE_LIMITEDwith the wait;E_NEEDS_OWNER: a durable op, or another hold;E_UNKNOWN_OPon an id the box did not give;E_CLOCK_SKEW,E_NOT_FOUND, and a box without a clock.session.terminatecomes back as revoked, expired, taken over or restarting.What the tests prove
internal/mcpruns against a stub box that stands in for Core as #1642 describes. It dials the real/v1/boxand signs a roster with agent rows (no tunnel) andnoise. It checks every handshake, grant, expiry and read scope, and refusesapi.req.api.req, and never the token.E_GRANT_REVOKED, then no handshake). A roster without the agent gives 401 and leaves no key.E_GRANT_EXPIRED, terminated "expired") and at the door, atexpiresAtMs.noisetakes no agents, and the key is believed only from the box's own signed roster.-race.Not in this PR
noisein the roster, and enforcement.events.getanddiag.runanswerE_UNKNOWN_OPuntil Core builds them.by, staff sign-in).🤖 Generated with Claude Code