Repository navigation
feat(agents): enforce agent and support access in Core - #1646
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: 47c8daf8c4
ℹ️ 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".
| var res *CmdResult | ||
| if rec, ok := h.cfg.Commands.lookup(h.cmdRoot(), cmdID); ok { | ||
| res = rec.result | ||
| } | ||
| return protocol.RequestApproved, res, nil |
There was a problem hiding this comment.
Return rejected results from box-page approvals
When the confirmed command is rejected before acceptCmd—for example because a loadpoint disappeared or the request contains invalid arguments—sendCmdResult writes only to discardSender, and no command-log record is created. This lookup therefore leaves res nil and the HTTP route reports {"state":"approved","result":null}, hiding that the requested change never happened. Capture the emitted result or persist early rejections so the box page can distinguish approval from command failure.
AGENTS.md reference: AGENTS.md:L22-L23
Useful? React with 👍 / 👎.
| for _, row := range h.cfg.Access.Agents() { | ||
| if row.ID == req.Agent { | ||
| return slices.Contains(row.Ops, req.Op) && slices.Contains(row.Scopes, protocol.ScopeGoalsRequest) | ||
| } |
There was a problem hiding this comment.
Reject confirmations after the agent grant expires
During the interval after ExpiresAtMs but before the 30-second expiry sweep, Access.Agents() still returns the row and this check considers only its raw scopes and operations. An owner can therefore approve and execute a pending durable request from an already-expired agent; whether expiry cancels the request becomes timing-dependent. Check the row's current expiry/effective grant here rather than relying on the asynchronous sweep.
AGENTS.md reference: AGENTS.md:L28-L30
Useful? React with 👍 / 👎.
| protocol.MsgAgentRequest: true, | ||
| protocol.MsgAgentList: true, | ||
| protocol.MsgAgentRevoke: true, |
There was a problem hiding this comment.
Rate-limit and audit agent list calls
Because agent.list is classified as agentOther, dispatchAgent skips both allowRead and the read audit path before serving it. A connected agent can consequently issue unlimited list requests despite the per-agent 60-reads-per-minute limit, and those calls leave no audit rows even though they expose the agent row and pending requests. Route this no-scope read through the same rate and audit checks as the other agent reads.
Useful? React with 👍 / 👎.
| for key, meta := range i.authorised { | ||
| if len(meta.approvalKey) > 0 { | ||
| out = append(out, protocol.ApprovalKeyInfo{DeviceID: deviceID(key), SetAtMs: meta.approvalKeySetMs}) | ||
| } |
There was a problem hiding this comment.
Filter demoted phones from approval keys
If an owner phone registers an approval key and is later demoted to viewer, the key remains stored and this method continues advertising it in agent.list and /api/agents. However, approveLocked rejects approvals from that phone because it is no longer an owner, so clients are told an unusable key can approve grants. Include only current owner-phone rows in this list.
Useful? React with 👍 / 👎.
Agents and support reach the box only through FTW Cloud's tunnel, with a grant the owner approved: scopes, a per-grant op allowlist, caps, an end time, holds that end, durable changes as owner-confirmed requests, an audit log the owner reads, and logs and config redacted on the box. Built on the contract in #1642. Squashed from claude/agents-core onto master after #1642 merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4655917 to
a8a768f
Compare
Typed reads: logs.get pages with beforeMs and afterMs over the whole ring and says more; phones may send live.get, loadpoints.get, drivers.get and cars.get under their scopes. N4: the box page may reset the clock's mark, with the house password; every agent's grant ends with it. N5: approving a request needs a wall clock the box trusts. Doc: agent hello and sub, safe error args, holds in one step, the hold gap, security notices, audit folding, the clock reset, offer limits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
handler.go: keep both. An agent subscribes to its decided requests and never joins the plan feed; it reads the plan by asking, so its reads stay counted and in the audit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agents test from #1646 builds a tunnel with the new arrivals argument. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1646 landed first, so this does the wiring left to whichever came second. An agent names a car in car.goal.set and car.soc.set by the id cars.get gave it (agent_args kind car), and the box checks the car and the goal as the request arrives. cars.get answers with the car class under the agent key: levels and goals, nothing that names a car. The redactor takes the cars' names, VINs, identifiers and sources out of logs and config. loadpoints.get and FTW Cloud's lp carry each charger's car by an opaque id, and an agent's plan.get and command outcomes name chargers and cars by its own ids. The plan's chargers carry the session's car. A change of car replans before it tells listeners, so a phone sees the new car on its charger at once, with the plan pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1646 landed first, so this does the wiring left to whichever came second. An agent names a car in car.goal.set and car.soc.set by the id cars.get gave it (agent_args kind car), and the box checks the car and the goal as the request arrives. cars.get answers with the car class under the agent key: levels and goals, nothing that names a car. The redactor takes the cars' names, VINs, identifiers and sources out of logs and config. loadpoints.get and FTW Cloud's lp carry each charger's car by an opaque id, and an agent's plan.get and command outcomes name chargers and cars by its own ids. The plan's chargers carry the session's car. A change of car replans before it tells listeners, so a phone sees the new car on its charger at once, with the plan pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(appuplink): send a reading through FTW Cloud as it arrives WIP: arrivals from the driver registry, Push and Beat on tunnel sessions, the readable copy's live on arrival while watched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(appuplink): pace pushes with a token bucket, gather 5 ms; live box rig for the iPhone tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(telemetry): arrivals reach every reader and merge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(appuplink): review fixes for send on arrival A frame on every poll, so timing shows the poll rhythm and not which value moved. A dropped frame is not recorded as sent (appproto.ErrDropped). A source table too big for lane 0 goes in a snapshot, sent in line with lane 0 so no delta overtakes it. Pushes stop at 60 % of the tunnel budget, beats at 90 %. The beat runs and arrivals are heard only while a session is live, and the copy hears them only while watched. OCPP readings signal arrivals. Lane 0's invariant: fixed length, fixed cadence over the relay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(appuplink): a push's cost, the rig's poll period; tunnel comments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(appuplink): merge master; lane 0's rule as Fredrik approved it The agents test from #1646 builds a tunnel with the new arrivals argument. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#1646 landed first, so this does the wiring left to whichever came second. An agent names a car in car.goal.set and car.soc.set by the id cars.get gave it (agent_args kind car), and the box checks the car and the goal as the request arrives. cars.get answers with the car class under the agent key: levels and goals, nothing that names a car. The redactor takes the cars' names, VINs, identifiers and sources out of logs and config. loadpoints.get and FTW Cloud's lp carry each charger's car by an opaque id, and an agent's plan.get and command outcomes name chargers and cars by its own ids. The plan's chargers carry the session's car. A change of car replans before it tells listeners, so a phone sees the new car on its charger at once, with the plan pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(cars): the car apart from the charger, in Core The box keeps its owners' cars in one state.db row and decides which car each charging session is: the charger's identity, the car's own report within a minute, the only or usual car, one tap after car.which, or a visitor. The session's car gives the loadpoint its capacity, goal, surplus choice and level, and the planner reads them. A car reading stays exact until 1 kWh has reached it. The search-based pairing and its refusals on ambiguity are gone. The loadpoint routes and ops act on the session's car for one release. Migration makes Car 1 from the loadpoint and a car from each vehicles[] profile; nothing the owner set is lost. docs/cars.md is the contract; two reviews shaped it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(cars): agents read and ask by car, and the plan push names the car #1646 landed first, so this does the wiring left to whichever came second. An agent names a car in car.goal.set and car.soc.set by the id cars.get gave it (agent_args kind car), and the box checks the car and the goal as the request arrives. cars.get answers with the car class under the agent key: levels and goals, nothing that names a car. The redactor takes the cars' names, VINs, identifiers and sources out of logs and config. loadpoints.get and FTW Cloud's lp carry each charger's car by an opaque id, and an agent's plan.get and command outcomes name chargers and cars by its own ids. The plan's chargers carry the session's car. A change of car replans before it tells listeners, so a phone sees the new car on its charger at once, with the plan pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cars): the reserve holds to the session's car, and a car's goal has a deadline before the replan #1657's reserve took its power from the charger and its target from the goal alone: a Volvo whose own charger takes 7.4 kW reserved 11 kW on an Easee. LimitPower moves into loadpoint, and DeadlineNeed and the plan both hold to the lower of the charger and the car, keeping the charger's lowest step, and to the car's charge limit while its level is exact. The car's binding carries that limit; its phases bound nothing beyond its own rating, so the unread CarPhases goes. A goal the car brings cleared the loadpoint's rolled deadline until the next control tick, and the replan right after a tap planned without the car. The runtime rolls the schedules before every replan and after its first sync, and replans when a sync hands a loadpoint a goal or a surplus choice. A visitor's goal ends its surplus-only charging: someone who asks for a goal wants it met, and the plan and the reserve plan from empty while the level stays unknown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cars): the plan is pending before a charger shows a new car The tap test failed in CI with the Volvo's first plan not pending. The order was right: the feed hears of a change only from the planner, after RequestReplan marks the plan pending. But the feed folds changes within its 100 ms settle, and the test's replan failed at once without prices, so it had ended before the folded push was written. The test now holds the tap's replan in its read of the loadpoints, as a real solve takes seconds, and still asks that the first plan naming the Volvo be pending. A real gap remained: the loadpoint showed the new car a moment before the replan request marked the plan pending, and a push written then showed the car beside the old plan as current. The runtime now asks for a replan before it hands the loadpoints a change and again after the schedules roll; the second supersedes the first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
What
Core enforces agent access as
docs/agents.mdspecifies: an assistant through MCP at FTW Cloud, Sourceful support for one ticket, and the release check. The box decides everything; FTW Cloud can admit an agent but only the box lets it act.Built on the contract in #1642, which has merged. The branch is rebased onto
master: one squashed commit for the work up to the second review, then the review's fixes as their own commits.Enrolment (
appenroll)agent/support/release, scopes, ops, caps,ExpiresAtMs, principal, and who approved it. They survive a restart; a row whose approving phone is gone is dropped, as a watch is.AddAgent/WidenAgentcheck the grant against the role, the registry'sagentscope per op and the box's limits, and verify the owner phone's approval signature (P-256,go/protocolvectors) with a 10-minute window and a replay memory. The box page approves without a signature.approval.key.setregisters an owner phone's key with proof of possession.cloudlink.ClockSynced. Without NTP an agent row may do nothing and nothing is swept.Check(relay) refuses agent keys;CheckTunneladmits them. The watch door refuses them too.The session (
appproto)dispatchAgent: the live grant (revoke →E_GRANT_REVOKED+ terminate, expiry →E_GRANT_EXPIRED+ terminateexpired, no clock →E_UNAVAILABLE {clock}),byon every support call, a scope per read, 60 reads a minute.api.reqis refused.ftw.goals.request→E_NEEDS_OWNER), the per-grant allowlist (E_OP_NOT_GRANTED),perMin(E_RATE_LIMITED, box-wide across sessions), then the caps:hold_s1..holdS(never the persistent 0),power_w≤maxW, boost duration ≤holdS(E_CAP_EXCEEDED, refused, never clipped).agent.requeststores a pending change (4 per agent, 16 per box, 12 h) and pushesagent.requestto owners.agent.confirmapproves with a signature and runs the op as a commandreq:<id>on the owner's own session under the owner's scopes; declining needs no signature.agent:<id>), an audit row for every call and every grant change,audit.getfor owners.logs.getandconfig.getanswer frominternal/redact: names become opaque ids; addresses, hosts, URLs, e-mail, MACs, serial-like words, long numbers, tokens and secrets go; attribute values stay only when they are numbers, flags, durations or short code words; a config number stays only under a key that names its unit or a known setting.logs.getpages withbeforeMs/afterMsand saysmore.health.getcounts drivers and names no device.events.get,diag.runandcars.getanswerE_UNKNOWN_OPuntil built.The rest
appuplink: onecallerOffor both doors;AgentAccessends a removed row's sessions; the roster carriesDevice.agent, leaves out expired rows.state:agent_audittable, 10,000 rows and 90 days, written off the frame path.api:/api/agentsand/api/agent-requestson the box page, tierlocal. Widening, approving, replacing a key and resetting the clock's mark need the house password, from any address; revoking and declining are open to the LAN. Withapi.lan_authon, every one of these routes needs the sign-in. The reads joinprotectedReadPath.notifications: the security notices (agent.added,agent.request,agent.expiring,approval.key.set,owner.added) go whatever the notification settings say; FTW Cloud gets each as a data-freenotice {kind}, copy or not.The box page's sign-in, as checked
With
api.lan_authoff (the default) every LAN client is an owner with no sign-in, so widening refuses until the house password is on. With it on: argon2id, ≥10 characters, five wrong guesses lock sign-in box-wide for 30 s, a 12-hourHttpOnlySameSite=Strictcookie; but plain HTTP on the LAN and one shared password with no person behind it.Tests (the asked-for ones)
TestARevokeFailsTheNextCall,TestARevokeBitesAtOnce,TestAnAgentComesThroughTheTunnelAndNotTheRelay.TestAnExpiredAgentFailsAtTheBoxEvenWhenTheCloudAdmitsIt(open session, read, command, tick, no clock),TestAnExpiredAgentIsRefusedAndSwept,TestTheRosterCarriesAnAgentsGrant.TestAnOpOutsideTheAllowlistIsRefused.TestAHoldPastItsCapIsRefused(alsohold_s0 and no end),TestTheGrantsRateHoldsAcrossSessions.TestADurableChangeWaitsForTheOwner(unsigned, a stranger's key, a signature over another request, a viewer, replay),TestARequestRunsOutAndADeclineNeedsNoSignature.TestASupportGrantEndsWhenItsTicketCloses.internal/redact(20 hostile lines, attributes, config) andTestLogsReachAnAgentRedacted.TestAnAgentNeedsAnOwnersSignature,TestAGrantStaysWithinTheBoxsLimits,TestNoClockNoAgents,TestAnAgentHasACommandLogOfItsOwn,TestWhoMayGiveAnAgentAccessOnTheBoxPage,TestNarrowingIsOpenOnTheLANAndApprovingIsNot,TestTheAgentAuditPagesNewestFirstAndForgetsOldRows.Local:
go test -p 2onprotocol,redact,appenroll,appproto,appuplink,cloudlink,state,api,notificationsandcmd/ftw, then onemake verify(with-p 2): clean.Paired PRs
Contract-pair: srcfl/ftw-webapp@claude/registry-agents
Not here
events.get,diag.runandcars.get; planner timing inhealth.noticeinto a push. Until it does, it drops the class, as it drops any box message it does not know.cloudlink.OfferKeysis empty, so the box page opens no signed offer until a release sets it from FTW Cloud's offer key (feat(keys): ftwkeysd, the one part that can open an agent's session ftw-cloud#18). Phones grant throughagent.addas before.The changeset is
minor(a visible capability), which needs Fredrik's yes.After the review
Core side of the contract review (the contract changes are in #1642):
agent_argsas they arrive; a charger is named by the agent id the agent read, which the box resolves.loadpoint.ManualHold.SetByrecords the setter; an owner's hold wins (E_NEEDS_OWNER); an agent clears only its own; revoke, expiry and the expiry tick release an agent's holds. Tests:TestAHoldPastItsCapIsRefused(now alsomax_amps_per_phase,voltage, the real id, a bad phase mode),TestAnOwnersHoldWinsAndAnAgentsEndsWithItsGrant.cloudlink.OfferKeys, empty until FTW Cloud mints one, so no offer opens yet);POST /api/agents/approval-keyreplaces a lost key.drivers.SetSelfAPIPorts,TestADriverNeverReachesTheBoxsOwnAPI). Narrower than a full loopback ban, since FTW lets a driver reach a loopback service its config names (configuredProbeLoopbackHost). The doc now says so (see B3 below).TestTheSameBytesAreTakenOnceuses the(r, N−s)form),atMsfrom before the start refused,E_CLOCK_SKEW {boxMs}, a replaced key needsprevSig(TestReplacingAnApprovalKeyNeedsTheOldOne), one id per row.agent.decidedto the agent's session (TestARequestsArgsAreCheckedAndTheAgentHearsTheEnd).tun: false, no copy scopes,noise), typed reads with agent ids and nosubfor agents (TestTypedReadsCarryAgentIDs), redaction with allowed attribute keys and no driver free text (go/protocol/testdata/redaction-vectors.json), a persisted clock high-water mark, folded read audit rows with their own budget, and the pushesagent.expiring,approval.key.set,owner.added.After the second review
hellocarries nosub, and the box ignores one (applySubreturns for agents). Test:TestAnAgentGetsNoSubThroughHello.by, result and minute, with a count. Agent rows have their own budget and never push out an owner's or the box's; when the write queue is full, an agent row is dropped and counted, an owner's is written at once. Tests:TestAnAgentCannotWriteIntoTheAuditLog(50 junk messages, one row),TestAnAgentsRowsNeverPushOutAnOwners.agentGoneforgets their requests and writesagent.remove.forAgent: error args keep a fixed list of keys, charger and source names become agent ids, other text goes. Test:TestAPhoneNamesNobodyand the hold tests' error args.principal.clientIdto be Sourceful's support client, onagent.addand on the box page.atMsfrom before this start is refused (sinceMsis the start), so the replay memory needs no disk.agent.requestmeets its op's own argument check as it arrives:site.mode.set,planner.prefs.set(at least one pref, a planner on the box),loadpoint.soc.setandloadpoint.surplus_only.setshare one function with their handlers. An op with no check ahead is refused. Test:TestARequestMeetsItsOpsOwnChecksAsItArrives.notice {kind}(cloudwire allowlist,docs/protocol-cloud.md), queued while the link is down. Tests:TestSecurityNoticesPassTheMasterSwitch,TestANoticeGoesWithTheCopyOff..me,.ai,.xyz). Vectors regenerated. The car store's names join with feat(cars): the car apart from the charger, in Core #1650.logs.getcursors andmore(TestLogsPageWithCursors). Phones may sendlive.get,loadpoints.get,drivers.getandcars.getunder their scopes (TestAPhoneReadsTheTypedReads).cars.get,events.getanddiag.runstayE_UNKNOWN_OP;carIdinloadpoints.getcomes with feat(cars): the car apart from the charger, in Core #1650.SetManualHoldIfOwn,ClearManualHoldIfOwn,TestAnAgentsHoldNeverReplacesAnOwners). N4POST /api/agents/clock-reset, with the house password, makes now the mark and ends every agent's grant. N5 approving needs a trusted clock, on the box page and from a phone (TestApprovingNeedsAClock). N6perMin0 means the default. N7 guards needftw.live.read. N8 already held: both reads are inprotectedReadPath, so withlan_authon a LAN client that has not signed in gets 401 (TestTheAgentRoutesNeedSignInWithLANAuthOn); a wrong password is also refused on the narrowing routes. N9 the sweep ends expired requests and tells the agent. N10 one agent hold per charger every 30 s. N11 promoting a phone to owner sendsowner.added.With #1650 (cars)
Whoever merges last does these, as agreed with the car session (also listed in #1650):
cloudlink.ProjectLP(carId,carCorrectedFrom,choose) and in one exported car projection forcars.get, so agents get them under the box's agent key. The copy cuts no car ids today.agent_argskindcarinappproto/agentargs.go: resolve the agent's car id against each car id cut withh.cfg.AgentKey, asresolveLoadpointdoes for chargers. Cars live ininternal/cars(Runtime.Cars()).agentPorts.names()incmd/ftw/agents.go, sologs.getandconfig.getredact them.car.goal.setandcar.soc.setgetagent: ftw.goals.requestandagent_args; both useE_NOT_FOUNDas worded here. Rebase and regenerate the Go files; no hand-merging.🤖 Generated with Claude Code