Repository navigation
feat(mcp): control within the owner's grant, and requests that wait for the owner - #21
Conversation
A second process on the same host, as another user, behind a unix socket whose peer uid it checks. It makes an assistant's X25519 agent key when an authorization starts and seals it at rest: one AES-256 data key per agent key from AWS KMS (a file key in development), with an encryption context that names the purpose and the key id. A key stays pending until the owner approves, and opens no session until then; an unapproved key goes after an hour. It unseals a key only to open one Noise IK session to one box, with the box session's prologue set by ftwkeysd itself, and wipes the secret once message 1 is written. It keeps the transport keys and seals and opens each frame for ftwcloudd, which carries them as tun. Forget deletes the key and ends its sessions. Errors on the socket are a few fixed words, and nothing logs a key. Deploy files, not run: a compose service under the `agents` profile on the host's network (the one container that reaches the instance role with an IMDS hop limit of 1), the KMS key policy (root may administer, only the host role may use, only with the purpose), the role's inline policy, and a host script for the socket directory. The guide's first invariant changes as decided on 15 October 2026. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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. |
0469392 to
24dd255
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 046939268e
ℹ️ 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".
| if a.PowerW != nil { | ||
| power = *a.PowerW | ||
| } | ||
| if power < 0 || power > maxBatteryW || (a.Direction != "idle" && power == 0) { |
There was a problem hiding this comment.
Reject nonzero power for idle battery holds
When direction is idle, any power_w from 1 through 20,000 passes this check and is sent to the box, despite the schema defining idle power as zero. This allows a contradictory control command such as {direction:"idle", power_w:20000} to bypass the cloud-side bounds and leaves its interpretation to the box; reject nonzero power whenever the direction is idle.
Useful? React with 👍 / 👎.
| if err != nil { | ||
| return nil, err | ||
| } | ||
| out.(map[string]any)["waitsForOwner"] = true |
There was a problem hiding this comment.
Report owner waiting only after goal acceptance
If the box rejects goal.request (for example with E_UNKNOWN_OP) or does not answer, command still returns a report and this unconditional assignment adds waitsForOwner: true. The resulting response can simultaneously say state: rejected or state: unknown and claim that the request is waiting for approval, causing the assistant to tell the user that a nonexistent or unconfirmed request is pending; set this only for a state that confirms acceptance.
Useful? React with 👍 / 👎.
| if res.Observed != nil { | ||
| out["observed"] = map[string]any{"value": res.Observed.Value, "source": clean(res.Observed.Src)} | ||
| } |
There was a problem hiding this comment.
Sanitize observed command values before returning them
When a command result contains a string (or nested strings) in Observed.Value, it is copied directly into MCP structured and text output, unlike API results which pass all box-provided values through cleanAll. A box or device-provided value containing bidi/formatting or control characters therefore bypasses the service's output sanitization and can hide or misrepresent text shown to the user; clean the observed value recursively before returning it.
Useful? React with 👍 / 👎.
24dd255 to
e66cbbb
Compare
The agent contract (srcfl/ftw#1642) has FTW Cloud sign each grant offer the consent page hands the owner's app, with a key the app pins. It lives in the keys part, apart from the agent keys: one P-256 key, made at the first start, sealed like an agent key under its own context, kept in memory. It signs only the deterministic CBOR ["ftw.offer.v1", offer]. VerifyOffer is the app's check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e66cbbb to
f9cadfc
Compare
7d6f279 to
341d90b
Compare
The contract's signed offer is {o, kid, sig}, and Core and the apps pin
offer keys by kid. The kid is the first 16 characters of the base64url
SHA-256 of the key's SEC1 point. ftwkeysd logs kid and key when it makes
the offer key, and `ftwkeysd offerkey` prints them from the same state
directory; deploy/keys.md adds the pinning step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341d90b to
b46f09e
Compare
b46f09e to
0d3f197
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>
0d3f197 to
8169964
Compare
…r key in KMS, a role of its own - signOffer decodes the GrantOffer strictly and signs only version 1, the agent role, at most OfferTTLMs ahead, and a key that is a pending kid's. The pin of the agent contract (e2559632) moves here from #19 for it. - The offer key is an asymmetric KMS key (ECC_NIST_P256, SIGN_VERIFY) that only the keys role may sign with. A local offer key and the file sealer exist only in a development build (-tags ftwclouddev). - ftwkeysd leaves the host network for a bridge of its own and uses the ftw-cloud-keys role, which may use the two KMS keys and nothing else. The host renews its credentials into tmpfs every 15 minutes. - Key policies: kms:CreateAlias instead of kms:Create*, an explicit deny on kms:CreateGrant, and keys.md says plainly that PutKeyPolicy lets an admin take use. - ftwcloudd runs as 65530, not distroless's shared nonroot. ftw-cloud-own gives its files to the user its image runs as at every start, and ftw-cloud-deploy keeps the lines keys.sh adds to compose.env. - Pin the offer key from ftwkeysd on the host through SSM, not from the public endpoint. - The socket refuses a connection that is not a unix socket's, and a message 2 that does not split ends its session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
…or the owner Following the agent contract (srcfl/ftw#1642 at b7d1cc80, docs/agents.md): - ev.charge_now sends loadpoint.hold, the one hold op open to agents, or its clear with stop: only when the grant's allowlist names it, with the arguments protocol.RegistryAgentArgs allows an agent (id, power_w up to maxW, hold_s from 1 to holdS, phase_mode, clear), each of its kind and range, checked here before the box and never loosened. hold_s is never 0, the operator's endless hold; the service never renews a hold by itself, and an owner's own hold wins (E_NEEDS_OWNER, in words). Each cmd has a fresh UUIDv7, 30 s of validity in the box's uptime, and the control revision the box last named; a stale one is learnt from E_CONFLICT and tried once more. battery.hold is gone: the contract no longer opens it to agents. - goal.request sends agent.request {op, args, why} for site.mode.set, planner.prefs.set (safety_k 0 to 2, battery_export), loadpoint.surplus_ only.set and loadpoint.soc.set, held to the same registry rules, with the assistant's own reason; the box keeps it until an owner confirms it with Face ID. requests.list (agent.list) shows the agent's own grant and how each request ended. - Arguments are typed and refuse unknown fields, so text a model read in a log cannot become an instruction to a home. The report says only what the box confirmed. The official MCP inspector (2.3.0, CLI) lists all 16 tools and calls two through the whole path in TestInspectorSmoke, which runs when MCP_INSPECTOR names it; CI runs it with npx. It speaks 2025-11-25 with initialize, so it exercises the earlier-era path. 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>
Once a cmd is sent, a session that ends (a restart's drain, a reconnect, the box) or a call that runs out loses the answer, not the command. The tool says so: state "unknown", "Unknown, not failed", and check status before trying again. Never "failed". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8169964 to
3629f05
Compare
main.go: keep both the keys/MCP/OAuth flags and the shard, metrics and restart-spread flags; push keeps its own context past the signal, auth and agents stop with the signal. go/protocol pinned to Core master f024251. The pacing test reads the door's drops through uplink.TunDropped, now that #17 counts drops in the metrics set. 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>
Fourth of four stacked PRs. It is based on #20 (MCP read), which is based on #19 (OAuth) and #18 (keys).
What this adds
Built to the agent contract (srcfl/ftw#1642 at b7d1cc80,
docs/agents.md).ev_charge_now(marked destructive)cmd loadpoint.hold {id, power_w, hold_s, phase_mode}, or{id, clear}withstopprotocol.RegistryAgentArgs(kind, range);power_wfrom 1 tocaps.maxW(the box reads a missing power as a pause);hold_s1 tocaps.holdS, never 0, 15 minutes by defaultgoal_requestagent.request {op, args, why}forsite.mode.set,planner.prefs.set,loadpoint.surplus_only.set,loadpoint.soc.setwhy≤ 280 bytesrequests_listagent.listE_NEEDS_OWNER, in words). The caps are the owner's, from the roster. The service may hold a call to them; it never loosens them.battery.holdis gone. The contract no longer opens it to agents.agent.requestand wait for an owner's Face ID.requests_listshows how each ended.perMinon a conflict. A stale revision is learnt fromE_CONFLICTand tried once more.state: unknown, "Unknown, not failed", and to checkchargers_listbefore trying again.isError), in words: another hold wins, a cap, no hold to stop, no clock.The official inspector
TestInspectorSmokeruns the MCP inspector's CLI (2.3.0) against the whole path: a real token, the stub box,tools/list(all 16 tools) and twotools/calls. It runs whenMCP_INSPECTORnames the command. It passed here offline, from the npm cache, and CI now runs it withnpx. The inspector speaks 2025-11-25 withinitialize, so it exercises the earlier-era path; the 2026-07-28 path is covered byTestTheTransport.What the tests prove
insufficient_scopenaming the scope, and nothing reaches the box.hold_s600 for 10 minutes,phase_mode), the revision the chargers' answer named, a UUIDv7 id, and a validity within a minute of the box's uptime. Without minutes it lasts 15 minutes;stopsendsclear.power_w, or with 0, nothing is sent.holdSormaxW(the message names the cap), a phase mode the registry lacks, a path as an id, an extra field, a mixed or reasonless goal,safety_k3,battery_export"maybe", a level of 101 %.TestArgumentsFollowTheRegistry).ev_charge_nowis marked destructive, andstatusis not.agent.request, says it waits for the owner, andrequests_listreads the agent's own row.Not in this PR
noiseyet. Until a box names its Noise key in its signed roster, its home takes no assistants. Core's side is feat(agents): enforce agent and support access in Core ftw#1646.by, staff sign-in) are not built.🤖 Generated with Claude Code