Skip to content

feat(contract): agents, their approval signature and roster entry - #1642

Merged
frahlg merged 9 commits into
masterfrom
claude/agents-contract
Oct 15, 2026
Merged

frahlg merged 9 commits into
masterfrom
claude/agents-contract

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

What

The contract for agents: an assistant through MCP at FTW Cloud, Sourceful support for one ticket, and the release check. docs/agents.md is the normative text. Core enforcement follows in a stacked PR; this one changes no behaviour except one refusal (below).

  • docs/agents.md. Principals, scopes, which messages each scope opens, the op allowlist, caps, durable requests that wait for an owner, the seven messages with their fields and errors, the roster entry, expiry, the approval signature, approval on the box page, the grant offer FTW Cloud's consent page shows, push kinds, the audit log, and the numbers in one place.
  • Registry copy from registry: agents, their scopes, refusals and push kinds ftw-webapp#90: roles agent, support, release; six scopes; an agent scope on each op; access.agents; eight refusals; push kinds agent.added, agent.request.
  • gencontract reads the ops' agent scope into RegistryAgentOps, emits the role names into go/protocol (so FTW Cloud stops spelling owner by hand), and counts hold and request scopes as writes.
  • go/protocol/agents.go: the message shapes and limits, and the approval signature: ECDSA P-256 over the deterministic CBOR of ["ftw.approval.v1", what, boxKey, deviceId, atMs, ...fields], r ‖ s. Golden vectors in go/protocol/testdata/approval-vectors.json for the phone and FTW Cloud.
  • cloudwire: a roster Device may carry agent {scopes, ops, caps, expiresAtMs, principal}; docs/protocol-cloud.md gains the tables. Envelopes may carry by, the staff id on a support call.
  • One refusal now: appenroll refuses an agent's role on a pairing code, a box code or a role change. Without it, the new roles would be valid phone roles the moment the registry names them.

The box page's sign-in, as checked

docs/agents.md, "Approval on the box page", says it plainly: with api.lan_auth off (the default) every LAN client is an owner with no sign-in, so widening routes need the box itself or the house password. With it on: argon2id, at least 10 characters, five wrong guesses lock sign-in for 30 s box-wide, a 12-hour HttpOnly SameSite=Strict cookie, but plain HTTP on the LAN and one shared password with no person behind it.

Paired PRs

Contract-pair: srcfl/ftw-webapp@claude/registry-agents

Tests

  • go test -p 2 for ./protocol/... (vectors, tamper, unsorted lists, envelope by, allowlist against the document), apiauth, appenroll (new: an agent's role is never a phone's), appproto/..., appuplink, cloudlink, notifications: all pass.

The changeset is patch: names and a refusal, nothing a user sees.

After the review (adversarial, at f557f6d)

Fixed before merge, the three blockers and everything that changes signed bytes or wire shapes:

  • 1. The public door. Agent rows carry tun: false and no copy scopes; only FTW Cloud's keys part opens an agent's session, never /v1/app, /v1/app/once or the relay (docs/protocol-cloud.md). Offers are signed (SignedOffer {o, kid, sig}, protocol.OpenOffer) by a pinned offer key, live at most 10 minutes, carry clientHost and verified, leave home empty, and a support offer comes only from SupportClientID.
  • 2. Holds. battery.hold and loadpoint.boost lose their agent scope. A new agent_args block allowlists each op's arguments, with labels (so voltage, max_amps_per_phase and the rest stay with owners). Holds carry their setter, an owner's hold wins (E_NEEDS_OWNER), an agent clears only its own, maxW is |W|, and revoke or expiry ends an agent's holds.
  • 3. The box page. Every widening route needs the house password, loopback included; drivers never reach the box's own API.
  • 5. Replay memory keyed by the signed bytes; agent.confirm signs [reqId, agent, op, sha256(args)]; request ids are 128 bits; an atMs from before the box started is refused.
  • 6. The signed offer's fields as above; the owner types the code on the universal-link path; the app refuses an unknown home.
  • 9. Request args checked on arrival against agent_args and the op's own checks.
  • 11. by is staff:<id> or bot:<name>, refused from any session but support.
  • 16. agent.widen signs the row's key.
  • From cloud-mcp: the roster carries the box's Noise key (Roster.noise); typed reads live.get, loadpoints.get, cars.get, drivers.get in a registry reads table, cursors on logs and events, agent ids under the box's own key.
  • Also in the wire now: approval.key.set takes prevSig, audit rows carry count, agent.decided, E_CLOCK_SKEW, push kinds agent.expiring, approval.key.set, owner.added, ftw.update.request marked reserved, E_NOT_FOUND worded for any id (the car contract can reuse it).

Vectors regenerated: approval (widen and confirm bytes changed), a signed offer, and the roster (with noise and an agent row).

🤖 Generated with Claude Code

Names and wire for agents: an assistant through MCP, Sourceful support
for one ticket, and the release check. docs/agents.md is the normative
text; Core enforces it in a later pull request.

- Registry copy: roles agent, support and release; scopes for logs,
  config, diagnostics, capped holds, owner-confirmed requests and update
  requests; an agent scope on each op; access.agents; eight refusals;
  push kinds agent.added and agent.request.
- gencontract reads the ops' agent scope into RegistryAgentOps, emits
  role names into go/protocol for FTW Cloud, and counts hold and request
  scopes as writes.
- go/protocol/agents.go: the agent messages, limits, and the approval
  signature: ECDSA P-256 over deterministic CBOR, with golden vectors in
  testdata/approval-vectors.json for the phone and FTW Cloud.
- cloudwire: a roster Device may carry an agent grant (scopes, ops, caps,
  expiry, principal). Envelopes may carry by, the staff id on a support
  call.
- appenroll refuses an agent's role on a pairing code or a role change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T17:09:05.115816Z ce0e361 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce0e361afa

ℹ️ 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".

Comment thread go/protocol/agents.go
Comment on lines +361 to +363
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
if !ecdsa.Verify(key, digest[:], r, s) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Canonicalize ECDSA signatures before replay tracking

ecdsa.Verify accepts both (r, s) and (r, N-s) for the same P-256 approval, so a party that obtains an accepted request can replace the final 32 signature bytes and produce a second valid signature over identical approval bytes. Because the contract says the box remembers accepted signatures to reject replays, tracking the raw 64-byte value can therefore be bypassed; reject high-S signatures or canonicalize them before verification/replay storage (and add a vector for the alternate-S form).

Useful? React with 👍 / 👎.

…ollows the build

The grant offer is a type FTW Cloud's consent page and the box page share.
Request routes on the box page move to /api/agent-requests, config.get
reads one section, every read fits one bulk frame, and E_RATE_LIMITED
carries retryAfterMs in the error body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…or the owner

Following the agent contract (srcfl/ftw#1642, docs/agents.md):

- battery.hold and ev.charge_now send one cmd each, for an op the grant's
  allowlist names and within its caps (holdS, maxW), checked here before
  the box and never loosened. A hold always has an end (hold_s is never 0,
  the operator's endless hold), so control ends when the assistant stops
  renewing; the service never renews a hold by itself. ev.charge_now with
  stop releases the charger's hold. 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.
- goal.request sends agent.request {op, args, why} for site.mode.set,
  planner.prefs.set (safety_k, battery_export), loadpoint.surplus_only.set
  and loadpoint.soc.set, 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 bounded 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, and the box's refusals (E_CAP_EXCEEDED
  with the cap, E_RATE_LIMITED with the wait, E_OP_NOT_GRANTED) come back
  in words.

The official MCP inspector (2.3.0, CLI) lists all 15 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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…or the owner

Following the agent contract (srcfl/ftw#1642, docs/agents.md):

- battery.hold and ev.charge_now send one cmd each, for an op the grant's
  allowlist names and within its caps (holdS, maxW), checked here before
  the box and never loosened. A hold always has an end (hold_s is never 0,
  the operator's endless hold), so control ends when the assistant stops
  renewing; the service never renews a hold by itself. ev.charge_now with
  stop releases the charger's hold. 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.
- goal.request sends agent.request {op, args, why} for site.mode.set,
  planner.prefs.set (safety_k, battery_export), loadpoint.surplus_only.set
  and loadpoint.soc.set, 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 bounded 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, and the box's refusals (E_CAP_EXCEEDED
  with the cap, E_RATE_LIMITED with the wait, E_OP_NOT_GRANTED) come back
  in words.

The official MCP inspector (2.3.0, CLI) lists all 15 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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…or the owner

Following the agent contract (srcfl/ftw#1642, docs/agents.md):

- battery.hold and ev.charge_now send one cmd each, for an op the grant's
  allowlist names and within its caps (holdS, maxW), checked here before
  the box and never loosened. A hold always has an end (hold_s is never 0,
  the operator's endless hold), so control ends when the assistant stops
  renewing; the service never renews a hold by itself. ev.charge_now with
  stop releases the charger's hold. 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.
- goal.request sends agent.request {op, args, why} for site.mode.set,
  planner.prefs.set (safety_k, battery_export), loadpoint.surplus_only.set
  and loadpoint.soc.set, 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 bounded 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, and the box's refusals (E_CAP_EXCEEDED
  with the cap, E_RATE_LIMITED with the wait, E_OP_NOT_GRANTED) come back
  in words.

The official MCP inspector (2.3.0, CLI) lists all 15 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>
Blockers:
- Only FTW Cloud's keys part opens an agent's session: agent rows carry
  tun false and no copy scopes, and the cloud admits them on no door.
- Offers are signed by a pinned offer key (SignedOffer, OpenOffer), live
  at most ten minutes, carry clientHost and verified, leave home empty,
  and a support offer comes only from SupportClientID.
- Agent holds: battery.hold and loadpoint.boost lose their agent scope;
  agent_args allowlists each op's arguments; holds carry their setter, an
  owner's hold wins, and revoke or expiry ends an agent's holds.
- The box page needs the house password for every widening route, from
  loopback too, and drivers never reach the box.

Signed bytes and wire shapes:
- agent.widen signs the row's key; agent.confirm signs the agent, the op
  and the hash of the args; request ids are 128 bits; replay memory keys
  on the signed bytes.
- by is staff:<id> or bot:<name>, and only a support session may send it.
- The roster carries the box's Noise key (noise) for the keys part.
- Typed reads (live.get, loadpoints.get, cars.get, drivers.get) and
  cursors on logs and events, listed in the registry's reads table.
- approval.key.set takes prevSig; audit rows carry count; agent.decided
  tells an agent how a request ended; E_CLOCK_SKEW; push kinds
  agent.expiring, approval.key.set and owner.added.

Vectors regenerated: approval, offer and roster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
… 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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
frahlg and others added 3 commits October 15, 2026 20:25
…gent key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b952561)
TestOneDeviceCannotStarveTheOthersOfHandshakes gave twelve handshakes
two seconds; under -race on a loaded runner one came late. It now waits
for the budget to be spent, then half a second for any answer past it.
TestTunDoesNotSpendTheCopysBudget read the counts before the last tun
was counted, and failed if a heartbeat went meanwhile; it now waits for
the count and checks that the tunnel's bytes never land on the copy's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OfferKid is the first 16 characters of base64url(SHA-256(SEC1 point)), as
FTW Cloud's keys part names its offer key. The offer vector uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
… count

Handshakes run side by side and a device keeps at most four unproved
sessions, so on a loaded runner one could be evicted before it answered
and the test saw 11 of 12. The property is that a device never gets more
than its budget and another phone still gets in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
… 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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
@frahlg
frahlg merged commit bf5bf50 into master Oct 15, 2026
17 checks passed
frahlg added a commit that referenced this pull request Oct 15, 2026
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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
…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>
frahlg added a commit that referenced this pull request Oct 15, 2026
* feat(agents): enforce agent and support access in Core

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>

* fix(agents): log cursors, phone reads, clock rules, doc

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>

* test(agents): the agent routes need sign-in with lan_auth on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(agents): no sub through an agent's hello

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit to srcfl/ftw-app that referenced this pull request Oct 16, 2026
srcfl/ftw#1642 added agent.added, agent.request, agent.expiring,
approval.key.set and owner.added to the push catalogue; the app's copy
missed them, so srcfl/ftw's 'the registry has not drifted from the app'
check is red on master. The catalogue is copied byte for byte, with the
English it holds and the Swedish beside it. These kinds are owners' news
and come as the box's notice: as an event they reach viewers too, so an
event that names one reads as generic.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant