From efbcbee2c036106a7c2764911454056f096c9716 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 08:14:50 +0200 Subject: [PATCH 1/4] feat(agents): an assistant's grant may last a year, and agent.list says so AgentMaxMs goes from 30 days to 365. support (14 days) and release (90 days) stay. The box refuses anything longer with E_CAP_EXCEEDED, as before. agent.list now carries longestMs, the longest grant the box takes for each role, so a newer app offers an older box no more than the 30 days it takes. A box without longestMs is taken to allow 30 days for an agent. agent.expiring still fires once, in a grant's last day, whatever its length. Co-Authored-By: Claude Opus 5.5 --- .changeset/assistant-access-for-a-year.md | 5 +++ docs/agents.md | 25 ++++++++--- go/cmd/ftw/agents_notice_test.go | 37 ++++++++++++++++ go/internal/appenroll/agents_test.go | 51 ++++++++++++++++++++++- go/internal/appproto/agents.go | 2 +- go/internal/appproto/agents_test.go | 15 +++++++ go/protocol/agents.go | 13 +++++- 7 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 .changeset/assistant-access-for-a-year.md diff --git a/.changeset/assistant-access-for-a-year.md b/.changeset/assistant-access-for-a-year.md new file mode 100644 index 000000000..dc611fc09 --- /dev/null +++ b/.changeset/assistant-access-for-a-year.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +An owner can let an assistant in for up to a year, up from 30 days. The box tells the app how long it lets each kind of access last, so a newer app offers an older box no more than 30 days. diff --git a/docs/agents.md b/docs/agents.md index f24415a72..942b31d00 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -36,7 +36,7 @@ whose source table carries names. | Role | Who | Granted by | Longest grant | Ends | |---|---|---|---|---| -| `agent` | A personal assistant | An owner, in the app or on the box page | 30 days, renewable | Expiry, revoke, or removal of the approving phone | +| `agent` | A personal assistant | An owner, in the app or on the box page | One year, renewable | Expiry, revoke, or removal of the approving phone | | `support` | Sourceful, for one ticket | An owner, from "Get help" | 14 days | Expiry, revoke, the ticket closing, or removal of the approving phone | | `release` | The release check | An owner, on joining the beta | 90 days, renewable | Expiry, revoke, or removal of the approving phone | @@ -300,8 +300,9 @@ roster leaves it out. Answer `{id}`. Error: `E_NOT_FOUND`. ### `agent.list` — who has access `{}`. To an owner: `{agents: [agent row], requests: [request], keys: -[{deviceId, setAtMs}]}`, every row on the box. To an agent: its own row and -its own requests. A viewer gets `E_SCOPE_DENIED`. +[{deviceId, setAtMs}], longestMs: {role: ms}}`, every row on the box and the +longest grant it takes for each role. To an agent: its own row and its own +requests. A viewer gets `E_SCOPE_DENIED`. ### `agent.request` — an agent asks for a durable change @@ -494,7 +495,19 @@ key ends as from a stranger. The watch exchange refuses it too. - **Revoke is immediate.** The row goes, the sessions end, the holds end, and the next call fails with `E_GRANT_REVOKED`, whatever the cloud admits. - **A warning first.** A day before a grant runs out, the box pushes - `agent.expiring` to every owner. + `agent.expiring` to every owner, whatever the grant's length. +- **The owner picks the length.** For an `agent`, the app and the box page + offer 1, 7, 30 and 90 days and a year, and start at 30 days. They offer + nothing past the offer's `ttlS` or the box's `longestMs` for the role. + +### Mixed versions + +Boxes before `longestMs` took at most 30 days for an `agent`, and refuse a +longer expiry with `E_CAP_EXCEEDED` rather than cut it short. A client that +finds no `longestMs` in `agent.list` therefore offers an `agent` 30 days at +most. FTW Cloud asks for a year in every offer, since it makes the offer +before the owner picks a home; apps and box pages from before this change +offer 30 days at most whatever the offer asks. ## The approval signature @@ -668,7 +681,7 @@ old one signed has expired. | `verified` | bool | FTW Cloud knows the client from its own list | | `scopes`, `ops` | [text] | What the client asks for | | `caps` | `{holdS, maxW, perMin}` | Proposed caps | -| `ttlS` | uint | Proposed length | +| `ttlS` | uint | The longest the owner may grant. FTW Cloud asks for an `agent`'s longest, a year | | `ticket` | text | The ticket id, for `support`; empty otherwise | | `code` | text | The code on the consent page | | `exp` | int | Wall-clock ms when the offer stops being good, at most 10 minutes after FTW Cloud made it | @@ -727,7 +740,7 @@ FTW Cloud keeps its own log of what it forwarded; neither replaces the other. | What | Value | |---|---| -| Longest grant | `agent` 30 days, `support` 14 days, `release` 90 days | +| Longest grant | `agent` one year, `support` 14 days, `release` 90 days | | Agent rows per box | 16 | | `holdS` | 60 to 1,800 s | | `maxW` | 1 to 100,000 W, either direction | diff --git a/go/cmd/ftw/agents_notice_test.go b/go/cmd/ftw/agents_notice_test.go index b2f4efb08..dad34acda 100644 --- a/go/cmd/ftw/agents_notice_test.go +++ b/go/cmd/ftw/agents_notice_test.go @@ -46,3 +46,40 @@ func TestAnAgentAddedAtTheBoxPageNamesItsClient(t *testing.T) { t.Fatalf("a widened grant: notices %+v", notices) } } + +// A grant of a year warns the owners once, in its last day, as a short one +// does: never on the day it was given or months before its end. +func TestAYearLongGrantWarnsInItsLastDay(t *testing.T) { + enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "box.key")) + if err != nil { + t.Fatal(err) + } + now := time.UnixMilli(1_792_000_000_000) + clock := func() (time.Time, bool) { return now, true } + enroll.SetClock(clock) + expiring := 0 + p := &agentPorts{access: &appuplink.AgentAccess{Enroll: enroll, Clock: clock}, + notice: func(kind, _ string) bool { + if kind == protocol.PushAgentExpiring { + expiring++ + } + return true + }} + year := 365 * 24 * time.Hour + if _, err := (boxPageAgents{p}).AddAgent("owner", protocol.AgentAdd{ + Key: bytes.Repeat([]byte{7}, 32), Role: apiauth.RoleAgent, Scopes: []string{protocol.ScopeLiveRead}, + ExpiresAtMs: now.Add(year).UnixMilli(), Principal: protocol.Principal{Client: "Claude"}}); err != nil { + t.Fatal(err) + } + end := now.Add(year) + for _, at := range []time.Time{now, now.Add(300 * 24 * time.Hour), end.Add(-25 * time.Hour)} { + if p.warnExpiring(at.UnixMilli()); expiring != 0 { + t.Fatalf("warned %s before the end", end.Sub(at)) + } + } + p.warnExpiring(end.Add(-23 * time.Hour).UnixMilli()) + p.warnExpiring(end.Add(-time.Hour).UnixMilli()) + if expiring != 1 { + t.Fatalf("%d warnings in the last day, want 1", expiring) + } +} diff --git a/go/internal/appenroll/agents_test.go b/go/internal/appenroll/agents_test.go index f2ade8881..aab2a835f 100644 --- a/go/internal/appenroll/agents_test.go +++ b/go/internal/appenroll/agents_test.go @@ -41,7 +41,8 @@ func (b *agentBox) signedFor(device string) protocol.SignedFor { return protocol.SignedFor{BoxKey: b.id.StaticKey().Public(), DeviceID: device} } -// readGrant is an assistant that reads live data and the plan for 30 days. +// readGrant is an assistant that reads live data and the plan for as long as +// an assistant may have. func (b *agentBox) readGrant(key []byte) protocol.AgentAdd { return protocol.AgentAdd{ Key: key, Role: apiauth.RoleAgent, @@ -181,6 +182,54 @@ func TestAGrantStaysWithinTheBoxsLimits(t *testing.T) { } } +// An assistant may have a year, renewed for a year from the day it renews, +// and not a millisecond more. The refusal names the latest end the box takes, +// so a client knows what it may ask for. +func TestAnAssistantMayHaveAYearAndNoMore(t *testing.T) { + b := newAgentBox(t) + year := (365 * 24 * time.Hour).Milliseconds() + over := func(err error) bool { + var r *protocol.Refusal + return errors.As(err, &r) && r.Code == protocol.ErrCapExceeded && + r.Args["cap"] == "expiresAtMs" && r.Args["max"] == b.ms()+year + } + m := b.readGrant(appKey(t)) + m.ExpiresAtMs = b.ms() + year + 1 + if _, err := b.id.AddAgent(AtTheBoxPage, m); !over(err) { + t.Fatalf("a year and a millisecond: %v", err) + } + m.ExpiresAtMs = b.ms() + year + row := b.add(t, m) + + b.now = b.now.Add(200 * 24 * time.Hour) + by := Approver{DeviceID: b.owner.DeviceID} + renew := protocol.AgentWiden{ID: row.ID, Epoch: row.Epoch, Scopes: row.Scopes, Ops: row.Ops, Caps: row.Caps, + ExpiresAtMs: b.ms() + year + 1, AtMs: b.ms()} + if _, err := b.id.WidenAgent(by, b.signer.Widen(b.signedFor(b.owner.DeviceID), renew, row.Key)); !over(err) { + t.Fatalf("renewed past a year: %v", err) + } + renew.ExpiresAtMs = b.ms() + year + renewed, err := b.id.WidenAgent(by, b.signer.Widen(b.signedFor(b.owner.DeviceID), renew, row.Key)) + if err != nil || renewed.ExpiresAtMs != b.ms()+year { + t.Fatalf("renewed for a year: %+v %v", renewed, err) + } +} + +// The limits agent.list tells owners are the ones the box holds a grant to. +func TestTheBoxTellsTheLimitsItHolds(t *testing.T) { + day := (24 * time.Hour).Milliseconds() + want := map[string]int64{apiauth.RoleAgent: 365 * day, apiauth.RoleSupport: 14 * day, apiauth.RoleRelease: 90 * day} + got := protocol.LongestMs() + if len(got) != len(want) { + t.Fatalf("LongestMs = %v", got) + } + for role, ms := range want { + if got[role] != ms || longest(role) != ms { + t.Errorf("%s: told %d, holds %d, want %d", role, got[role], longest(role), ms) + } + } +} + func TestNoClockNoAgents(t *testing.T) { b := newAgentBox(t) key := appKey(t) diff --git a/go/internal/appproto/agents.go b/go/internal/appproto/agents.go index 649284a83..88295c103 100644 --- a/go/internal/appproto/agents.go +++ b/go/internal/appproto/agents.go @@ -626,7 +626,7 @@ func (h *Handler) onAgentList(env Envelope) error { } defer h.cfg.Agents.tell("", protocol.AgentDecided{}) now, _ := h.wall() - out := protocol.AgentList{Agents: []protocol.AgentRow{}, Keys: []protocol.ApprovalKeyInfo{}} + out := protocol.AgentList{Agents: []protocol.AgentRow{}, Keys: []protocol.ApprovalKeyInfo{}, LongestMs: protocol.LongestMs()} if h.isAgent() { for _, row := range h.cfg.Access.Agents() { if row.ID == h.selfID() { diff --git a/go/internal/appproto/agents_test.go b/go/internal/appproto/agents_test.go index 16e8acd31..064fb0954 100644 --- a/go/internal/appproto/agents_test.go +++ b/go/internal/appproto/agents_test.go @@ -580,6 +580,21 @@ func TestADurableChangeWaitsForTheOwner(t *testing.T) { } } +// An owner's agent.list says, under longestMs, how long the box lets each +// role have, so the app offers no length the box would refuse. +func TestAgentListTellsAnOwnerTheLongestGrant(t *testing.T) { + r := newAgentRig(t) + owner, orec := r.ownerSession() + deliver(t, owner, protocol.MsgAgentList, ptrU32(1), struct{}{}) + got := body[struct { + LongestMs map[string]int64 `cbor:"longestMs"` + }](t, orec.last(t, protocol.MsgAgentOK)).LongestMs + if got[protocol.RoleAgent] != protocol.AgentMaxMs || got[protocol.RoleSupport] != protocol.SupportMaxMs || + got[protocol.RoleRelease] != protocol.ReleaseMaxMs { + t.Errorf("longestMs = %v", got) + } +} + func TestARequestRunsOutAndADeclineNeedsNoSignature(t *testing.T) { r := newAgentRig(t) row := r.controlAgent() diff --git a/go/protocol/agents.go b/go/protocol/agents.go index c0824d9ef..60f4054d7 100644 --- a/go/protocol/agents.go +++ b/go/protocol/agents.go @@ -78,7 +78,8 @@ const ( // Limits the box enforces on every grant, whatever the grant says. const ( // AgentMaxMs, SupportMaxMs and ReleaseMaxMs are the longest grants. - AgentMaxMs int64 = 30 * 24 * 60 * 60 * 1000 + // The box tells owners its own in agent.list, as LongestMs. + AgentMaxMs int64 = 365 * 24 * 60 * 60 * 1000 SupportMaxMs int64 = 14 * 24 * 60 * 60 * 1000 ReleaseMaxMs int64 = 90 * 24 * 60 * 60 * 1000 @@ -370,6 +371,16 @@ type AgentList struct { Agents []AgentRow `cbor:"agents" json:"agents"` Requests []Request `cbor:"requests" json:"requests"` Keys []ApprovalKeyInfo `cbor:"keys" json:"keys"` + // LongestMs is the longest grant the box takes for each role, so a + // client offers no length the box would refuse. + LongestMs map[string]int64 `cbor:"longestMs" json:"longestMs"` +} + +// LongestMs is the longest grant for each agent role, as agent.list tells +// owners. A box whose answer leaves it out takes at most 30 days for an +// agent (docs/agents.md, Mixed versions). +func LongestMs() map[string]int64 { + return map[string]int64{RoleAgent: AgentMaxMs, RoleSupport: SupportMaxMs, RoleRelease: ReleaseMaxMs} } // AuditGet pages through the audit log, newest first. From 49b6445cb4e78504e8e69f4f51c1d3402d16e1bf Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 08:17:57 +0200 Subject: [PATCH 2/4] test(protocol): keep the approval vectors' 30-day ends The app checks the same bytes; the length is only data in them. Co-Authored-By: Claude Opus 5.5 --- go/protocol/agents_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/go/protocol/agents_test.go b/go/protocol/agents_test.go index f58c6bbbb..91ceb32df 100644 --- a/go/protocol/agents_test.go +++ b/go/protocol/agents_test.go @@ -119,6 +119,9 @@ func vectorMessages(t *testing.T, key *ecdsa.PrivateKey) []struct { t.Helper() f := vectorFor() const atMs = 1_792_000_000_000 + // The vectors keep the 30-day ends they were cut with: the app checks + // the same bytes, and the length is only data here. + const month = 30 * 24 * 60 * 60 * 1000 keySet := ApprovalKeySet{Pub: vectorPub(key), AtMs: atMs} add := AgentAdd{ @@ -126,7 +129,7 @@ func vectorMessages(t *testing.T, key *ecdsa.PrivateKey) []struct { Scopes: []string{ScopeDispatchHold, ScopeLiveRead, ScopePlanRead}, Ops: []string{OpLoadpointHold}, Caps: Caps{HoldS: 900, MaxW: 11_000, PerMin: 6}, - ExpiresAtMs: atMs + AgentMaxMs, + ExpiresAtMs: atMs + month, Principal: Principal{Client: "Claude", ClientID: "https://claude.ai/oauth/mcp-client-metadata"}, AtMs: atMs, } @@ -141,7 +144,7 @@ func vectorMessages(t *testing.T, key *ecdsa.PrivateKey) []struct { ID: "oKGio6Sl", Epoch: 2, Scopes: []string{ScopeLiveRead, ScopePlanRead}, Caps: Caps{PerMin: 6}, - ExpiresAtMs: atMs + AgentMaxMs, AtMs: atMs, + ExpiresAtMs: atMs + month, AtMs: atMs, } confirm := AgentConfirm{ReqID: vectorRequest().ReqID, Approve: true, AtMs: atMs} From 70c7719c8ea4cb4f13c25f283d41dcb6d3a82606 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 08:35:35 +0200 Subject: [PATCH 3/4] feat(web): the box page offers up to a year where the box takes it The Access tab now offers 1, 7, 30 and 90 days and a year, never past the offer's ttlS or the box's longest grant for the role, which GET /api/agents now carries in box.longestMs. It still starts at a week. Renewing follows the same rule, so a support row is no longer offered 30 days that the box would refuse. Co-Authored-By: Claude Opus 5.5 --- docs/agents.md | 12 +++++++----- go/cmd/ftw/agents.go | 9 +++++---- go/cmd/ftw/agents_notice_test.go | 9 +++++++++ go/internal/api/api_agents.go | 3 +++ web/settings/tabs/access.js | 18 ++++++++++++++---- web/settings/tabs/access.test.mjs | 17 +++++++++++++++++ 6 files changed, 55 insertions(+), 13 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 942b31d00..dc26a0d6a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -497,8 +497,9 @@ key ends as from a stranger. The watch exchange refuses it too. - **A warning first.** A day before a grant runs out, the box pushes `agent.expiring` to every owner, whatever the grant's length. - **The owner picks the length.** For an `agent`, the app and the box page - offer 1, 7, 30 and 90 days and a year, and start at 30 days. They offer - nothing past the offer's `ttlS` or the box's `longestMs` for the role. + offer 1, 7, 30 and 90 days and a year. The app starts at 30 days, the box + page at a week. Neither offers more than the offer's `ttlS` or the box's + `longestMs` for the role. ### Mixed versions @@ -585,9 +586,10 @@ above and its approval key, and an agent can never reach them. every route, the narrowing ones too. - **The page checks before it asks for a code,** since a code works once. `GET /api/agents` carries `box {offers, lookup, clock, nowMs, markMs, - widen}`: whether this build pins an offer key, FTW Cloud's code lookup on - the host this build links to, whether the clock is `ok`, `unsynced` or - `behind` its mark, and whether this request could widen access. It also + widen, longestMs}`: whether this build pins an offer key, FTW Cloud's code + lookup on the host this build links to, whether the clock is `ok`, + `unsynced` or `behind` its mark, whether this request could widen access, + and the longest grant the box takes for each role. It also carries `registry {agentOps, writeScopes, agentArgs}`, so the page names no scope, op or argument by hand. - **The box says why it will not take an offer.** `POST /api/agents/offer` diff --git a/go/cmd/ftw/agents.go b/go/cmd/ftw/agents.go index 626af4f9c..458d103f4 100644 --- a/go/cmd/ftw/agents.go +++ b/go/cmd/ftw/agents.go @@ -451,10 +451,11 @@ func (b boxPageAgents) OpenOffer(raw string) (protocol.GrantOffer, error) { func (b boxPageAgents) Facts() api.AgentFacts { now, ok := agentClock() f := api.AgentFacts{ - Offers: len(cloudlink.ParseOfferKeys(cloudlink.OfferKeys)) > 0, - Lookup: codeLookup(cloudlink.Endpoint), - NowMs: now.UnixMilli(), - MarkMs: agentWall.mark(), + Offers: len(cloudlink.ParseOfferKeys(cloudlink.OfferKeys)) > 0, + Lookup: codeLookup(cloudlink.Endpoint), + NowMs: now.UnixMilli(), + MarkMs: agentWall.mark(), + LongestMs: protocol.LongestMs(), } switch { case ok: diff --git a/go/cmd/ftw/agents_notice_test.go b/go/cmd/ftw/agents_notice_test.go index dad34acda..50b4bcd89 100644 --- a/go/cmd/ftw/agents_notice_test.go +++ b/go/cmd/ftw/agents_notice_test.go @@ -83,3 +83,12 @@ func TestAYearLongGrantWarnsInItsLastDay(t *testing.T) { t.Fatalf("%d warnings in the last day, want 1", expiring) } } + +// The box page learns from the box's facts how long it may let each kind of +// access last, so it offers nothing the box would refuse. +func TestTheBoxPageIsToldTheLongestGrant(t *testing.T) { + got := boxPageAgents{&agentPorts{}}.Facts().LongestMs + if got[protocol.RoleAgent] != protocol.AgentMaxMs || got[protocol.RoleSupport] != protocol.SupportMaxMs { + t.Fatalf("longestMs = %v", got) + } +} diff --git a/go/internal/api/api_agents.go b/go/internal/api/api_agents.go index dbbfa8b5f..ba637af03 100644 --- a/go/internal/api/api_agents.go +++ b/go/internal/api/api_agents.go @@ -78,6 +78,9 @@ type AgentFacts struct { // The list sets it per request, so the page stops before a code is // spent on a grant the box would refuse. Widen bool `json:"widen"` + // LongestMs is the longest grant the box takes for each role, so the + // page offers no length the box would refuse. + LongestMs map[string]int64 `json:"longestMs"` } // The agent clock as the box page reports it. diff --git a/web/settings/tabs/access.js b/web/settings/tabs/access.js index 488a2ae39..4b35f943b 100644 --- a/web/settings/tabs/access.js +++ b/web/settings/tabs/access.js @@ -186,9 +186,9 @@ } // How long a grant may last: the lengths the page offers, none longer than - // the offer asked for. + // maxS, the least of what the offer asked for and what the box takes. function lengthOptions(maxS) { - var out = [DAY_S, 7 * DAY_S, 30 * DAY_S].filter(function (s) { return s <= maxS; }); + var out = [DAY_S, 7 * DAY_S, 30 * DAY_S, 90 * DAY_S, 365 * DAY_S].filter(function (s) { return s <= maxS; }); if (!out.length && maxS >= 60) out.push(maxS); return out; } @@ -201,7 +201,15 @@ return week.length ? week[week.length - 1] : options[0]; } + // The longest grant the box takes for a role, in seconds, as its facts + // say. A box that says nothing is taken to allow 30 days. + function longestS(box, role) { + var ms = Number(((box && box.longestMs) || {})[role]); + return ms > 0 ? Math.floor(ms / 1000) : 30 * DAY_S; + } + function lengthText(s) { + if (s === 365 * DAY_S) return "1 year"; if (s % DAY_S === 0) return s === DAY_S ? "1 day" : s / DAY_S + " days"; if (s >= 3600) return Math.round(s / 3600) + " hours"; return Math.round(s / 60) + " minutes"; @@ -671,7 +679,7 @@ ". Check that it matches the assistant's page." })); var form = grantForm(card, v, registry, null); - var lengths = lengthOptions(v.ttlS || 0); + var lengths = lengthOptions(Math.min(v.ttlS || 0, longestS(state.list && state.list.box, v.role))); var chosen = defaultLength(lengths); var select = el("select", { name: "length", id: "access-length" }); lengths.forEach(function (s) { @@ -815,7 +823,7 @@ var select = el("select", { name: "length", id: "access-length-" + row.id }); var left = Math.floor((row.expiresAtMs - now) / 1000); if (left > 60) select.appendChild(el("option", { value: "keep", text: "Keep the end, " + whenText(row.expiresAtMs) })); - [DAY_S, 7 * DAY_S, 30 * DAY_S].forEach(function (s) { + lengthOptions(longestS(state.list && state.list.box, row.role)).forEach(function (s) { select.appendChild(el("option", { value: String(s), text: lengthText(s) + " from now" })); }); form.appendChild(el("label", { for: select.id, text: "How long" })); @@ -993,6 +1001,8 @@ choiceProblem: choiceProblem, lengthOptions: lengthOptions, defaultLength: defaultLength, + longestS: longestS, + lengthText: lengthText, kwExact: kwExact, readiness: readiness, lookupFailureText: lookupFailureText, diff --git a/web/settings/tabs/access.test.mjs b/web/settings/tabs/access.test.mjs index 1c8bd327f..e9d1fb00e 100644 --- a/web/settings/tabs/access.test.mjs +++ b/web/settings/tabs/access.test.mjs @@ -186,6 +186,23 @@ describe("what the owner grants", () => { assert.deepEqual(P.lengthOptions(3600), [3600]); }); + it("offers up to a year where the box takes one, and 30 days where it says nothing", () => { + const day = 86400; + const box = { longestMs: { agent: 365 * day * 1000, support: 14 * day * 1000 } }; + assert.equal(P.longestS(box, "agent"), 365 * day); + assert.equal(P.longestS(box, "support"), 14 * day); + assert.equal(P.longestS({}, "agent"), 30 * day); + assert.equal(P.longestS(null, "agent"), 30 * day); + assert.deepEqual(P.lengthOptions(Math.min(365 * day, P.longestS(box, "agent"))), + [day, 7 * day, 30 * day, 90 * day, 365 * day]); + assert.deepEqual(P.lengthOptions(Math.min(365 * day, P.longestS({}, "agent"))), [day, 7 * day, 30 * day]); + // Renewing Sourceful offers nothing past its 14 days. + assert.deepEqual(P.lengthOptions(P.longestS(box, "support")), [day, 7 * day]); + assert.equal(P.lengthText(365 * day), "1 year"); + // The page still starts at a week. + assert.equal(P.defaultLength(P.lengthOptions(365 * day)), 7 * day); + }); + it("chooses a week unless the offer asks for less", () => { assert.equal(P.defaultLength(P.lengthOptions(30 * 86400)), 7 * 86400); assert.equal(P.defaultLength(P.lengthOptions(3 * 86400)), 86400); From e0ddb2c5506f8c7f984ad26ca25b1dbda54bd078 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 10:34:16 +0200 Subject: [PATCH 4/4] fix(agents): bound a box page grant's length, and tell only owners the limits Review of srcfl/ftw#1686: - POST /api/agents without ttlS now grants 30 days, or the offer's length if shorter, not the offer's year. - A ttlS past the role's longest grant is refused up front with E_CAP_EXCEEDED {cap: expiresAtMs, max}, so a huge one cannot wrap (18446744073709552 s used to become 384 ms). - agent.list carries longestMs to owners only; an agent's answer leaves it out, as docs/agents.md says. GET /api/agents no longer carries a stray top-level "longestMs": null beside box.longestMs. - The box page falls back per role when box.longestMs is missing: 30 days for an assistant, 14 for Sourceful, 90 for the release check. Co-Authored-By: Claude Opus 5.5 --- docs/agents.md | 4 +- go/internal/api/api_agents.go | 45 +++++++++++++-- go/internal/api/api_agents_test.go | 86 +++++++++++++++++++++++++++++ go/internal/appproto/agents.go | 3 +- go/internal/appproto/agents_test.go | 3 + go/protocol/agents.go | 4 +- web/settings/tabs/access.js | 6 +- web/settings/tabs/access.test.mjs | 2 + 8 files changed, 141 insertions(+), 12 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index dc26a0d6a..cc8aa71b3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -609,7 +609,9 @@ above and its approval key, and an agent can never reach them. the owner types the consent page's code, and the page, in the owner's browser, fetches the signed offer from FTW Cloud's code lookup and posts it here. The owner may grant less: `scopes`, `ops`, `caps` and `ttlS` - replace the offer's when given. + replace the offer's when given. Without `ttlS` the grant lasts 30 days, or + the offer's length if shorter. A `ttlS` past the role's longest grant is + `E_CAP_EXCEEDED {cap: "expiresAtMs", max}`. - **Every route that widens access needs another device on the home network, signed in with the house password.** The box never reads the sign-in from loopback, so a browser on the box, or one through an SSH diff --git a/go/internal/api/api_agents.go b/go/internal/api/api_agents.go index ba637af03..bf7b0f005 100644 --- a/go/internal/api/api_agents.go +++ b/go/internal/api/api_agents.go @@ -24,7 +24,6 @@ import ( "net/http" "slices" "strconv" - "time" "github.com/srcfl/ftw/go/protocol" ) @@ -252,7 +251,7 @@ func (s *Server) handleAgentsAudit(w http.ResponseWriter, r *http.Request) { // agentAddBody is a grant offer, as FTW Cloud's consent page shows it, and // what the owner chose to grant of it. A field left out grants what the -// offer asks. +// offer asks, except ttlS, which grants at most 30 days. type agentAddBody struct { Offer string `json:"offer"` Scopes []string `json:"scopes,omitempty"` @@ -292,11 +291,16 @@ func (s *Server) handleAgentsAdd(w http.ResponseWriter, r *http.Request) { if body.Caps != nil { m.Caps = *body.Caps } - ttl := offer.TTLS + ttl := min(offer.TTLS, defaultTTLS) if body.TTLS != 0 { ttl = body.TTLS } - m.ExpiresAtMs = now + int64(ttl)*int64(time.Second/time.Millisecond) + end, err := grantEnd(now, ttl, m.Role) + if err != nil { + agentError(w, err) + return + } + m.ExpiresAtMs = end row, err := s.deps.Agents.AddAgent(by, m) if err != nil { agentError(w, err) @@ -387,6 +391,24 @@ func (s *Server) handleAgentsOffer(w http.ResponseWriter, r *http.Request) { }) } +// defaultTTLS is the length a grant gets when the request names none and the +// offer asks for more: 30 days, where the app starts. +const defaultTTLS = 30 * 24 * 60 * 60 + +// grantEnd is the end of a grant of ttlS seconds from now. A length past the +// role's longest grant is refused here, as the box would refuse it, before +// it can overflow. +func grantEnd(now int64, ttlS uint64, role string) (int64, error) { + most, ok := protocol.LongestMs()[role] + if !ok { + most = protocol.AgentMaxMs + } + if ttlS > uint64(most/1000) { + return 0, protocol.Refuse(protocol.ErrCapExceeded, map[string]any{"cap": "expiresAtMs", "max": now + most}) + } + return now + int64(ttlS)*1000, nil +} + // withinOffer reports whether what the owner chose on the page gives no more // than the offer the agent's client signed for. func withinOffer(offer protocol.GrantOffer, body agentAddBody) bool { @@ -438,9 +460,20 @@ func (s *Server) handleAgentsWiden(w http.ResponseWriter, r *http.Request) { agentError(w, protocol.Refuse(protocol.ErrUnavailable, map[string]any{"subsystem": "clock"})) return } + id := r.PathValue("id") + role := protocol.RoleAgent + for _, row := range s.deps.Agents.Agents() { + if row.ID == id { + role = row.Role + } + } + end, err := grantEnd(now, body.TTLS, role) + if err != nil { + agentError(w, err) + return + } row, err := s.deps.Agents.WidenAgent(by, protocol.AgentWiden{ - ID: r.PathValue("id"), Epoch: body.Epoch, Scopes: body.Scopes, Ops: body.Ops, Caps: body.Caps, - ExpiresAtMs: now + int64(body.TTLS)*int64(time.Second/time.Millisecond), + ID: id, Epoch: body.Epoch, Scopes: body.Scopes, Ops: body.Ops, Caps: body.Caps, ExpiresAtMs: end, }) if err != nil { agentError(w, err) diff --git a/go/internal/api/api_agents_test.go b/go/internal/api/api_agents_test.go index 1d9e442df..90f640a13 100644 --- a/go/internal/api/api_agents_test.go +++ b/go/internal/api/api_agents_test.go @@ -459,3 +459,89 @@ func TestTheAgentRoutesNeedSignInWithLANAuthOn(t *testing.T) { t.Errorf("signed in: %d", rr.Code) } } + +// yearOfferBody is an offer that asks for a year, as FTW Cloud's does. +func yearOfferBody(t *testing.T, extra string) string { + t.Helper() + o, err := protocol.Marshal(protocol.GrantOffer{ + V: protocol.GrantOfferVersion, Key: make([]byte, 32), Role: apiauth.RoleAgent, Client: "Claude", + Scopes: []string{protocol.ScopeLiveRead}, Ops: []string{}, TTLS: 365 * 24 * 3600, Code: "ABCD", + ExpMs: 1_000_000 + 60_000, + }) + if err != nil { + t.Fatal(err) + } + msg, err := protocol.OfferBytes(o) + if err != nil { + t.Fatal(err) + } + raw, err := protocol.Marshal(protocol.SignedOffer{O: o, Kid: "k1", Sig: offerSigner.Sign(msg)}) + if err != nil { + t.Fatal(err) + } + return `{"offer":"` + base64.RawURLEncoding.EncodeToString(raw) + `"` + extra + `}` +} + +// A grant with no length named gets 30 days of an offer's year; one past a +// year, or so long it would wrap, is refused as the box would refuse it, +// with the latest end it takes. +func TestTheBoxPageBoundsALength(t *testing.T) { + const day = int64(24 * 3600 * 1000) + agents := newStubAgents("") + s := New(&Deps{Agents: agents}) + post := func(path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := signedIn(localRequest(http.MethodPost, path, body)) + if strings.HasSuffix(path, "/widen") { + r.SetPathValue("id", "agent001") + s.handleAgentsWiden(w, r) + } else { + s.handleAgentsAdd(w, r) + } + return w + } + capped := func(w *httptest.ResponseRecorder) bool { + var got struct { + Error string `json:"error"` + Args map[string]any `json:"args"` + } + _ = json.Unmarshal(w.Body.Bytes(), &got) + return got.Error == protocol.ErrCapExceeded && got.Args["cap"] == "expiresAtMs" && + got.Args["max"] == float64(1_000_000+365*day) + } + + if w := post("/api/agents", yearOfferBody(t, "")); w.Code != http.StatusCreated || + agents.added[0].ExpiresAtMs != 1_000_000+30*day { + t.Fatalf("no length named: %d, ends %d", w.Code, agents.added[len(agents.added)-1].ExpiresAtMs) + } + if w := post("/api/agents", yearOfferBody(t, `,"ttlS":31536000`)); w.Code != http.StatusCreated || + agents.added[1].ExpiresAtMs != 1_000_000+365*day { + t.Fatalf("a year: %d", w.Code) + } + for _, ttl := range []string{"31536001", "18446744073709552"} { + if w := post("/api/agents/agent001/widen", `{"epoch":1,"scopes":["ftw.live.read"],"ops":[],"caps":{},"ttlS":`+ttl+`}`); !capped(w) { + t.Errorf("renewed for %s s: %d %s", ttl, w.Code, w.Body.String()) + } + } + if w := post("/api/agents/agent001/widen", `{"epoch":1,"scopes":["ftw.live.read"],"ops":[],"caps":{},"ttlS":31536000}`); w.Code != http.StatusOK { + t.Errorf("renewed for a year: %d %s", w.Code, w.Body.String()) + } + if len(agents.added) != 2 { + t.Errorf("a refused length reached the box: %d grants", len(agents.added)) + } +} + +// The box page reads the longest grant from box.longestMs, and no stray +// top-level copy of it. +func TestTheBoxPageListCarriesNoStrayLongest(t *testing.T) { + s := New(&Deps{Agents: newStubAgents("")}) + w := httptest.NewRecorder() + s.handleAgentsList(w, localRequest(http.MethodGet, "/api/agents", "")) + var got map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if _, stray := got["longestMs"]; stray { + t.Errorf("GET /api/agents carries a top-level longestMs: %s", w.Body.String()) + } +} diff --git a/go/internal/appproto/agents.go b/go/internal/appproto/agents.go index 88295c103..7bba69b26 100644 --- a/go/internal/appproto/agents.go +++ b/go/internal/appproto/agents.go @@ -626,7 +626,7 @@ func (h *Handler) onAgentList(env Envelope) error { } defer h.cfg.Agents.tell("", protocol.AgentDecided{}) now, _ := h.wall() - out := protocol.AgentList{Agents: []protocol.AgentRow{}, Keys: []protocol.ApprovalKeyInfo{}, LongestMs: protocol.LongestMs()} + out := protocol.AgentList{Agents: []protocol.AgentRow{}, Keys: []protocol.ApprovalKeyInfo{}} if h.isAgent() { for _, row := range h.cfg.Access.Agents() { if row.ID == h.selfID() { @@ -643,6 +643,7 @@ func (h *Handler) onAgentList(env Envelope) error { out.Agents = append(out.Agents, h.cfg.Access.Agents()...) out.Requests = h.cfg.Agents.list("", now, h.liveAgents()) out.Keys = append(out.Keys, h.cfg.Access.ApprovalKeys()...) + out.LongestMs = protocol.LongestMs() return h.agentOK(env.ID, out) } diff --git a/go/internal/appproto/agents_test.go b/go/internal/appproto/agents_test.go index 064fb0954..7d0b91ae0 100644 --- a/go/internal/appproto/agents_test.go +++ b/go/internal/appproto/agents_test.go @@ -575,6 +575,9 @@ func TestADurableChangeWaitsForTheOwner(t *testing.T) { if len(mine.Agents) != 1 || mine.Agents[0].ID != row.ID || len(mine.Requests) != 1 || mine.Requests[0].State != protocol.RequestApproved { t.Errorf("the agent's own list: %+v", mine) } + if mine.LongestMs != nil { + t.Errorf("an agent was told the box's limits: %v", mine.LongestMs) + } if rows := r.audit.find("agent.request.run"); len(rows) != 1 || rows[0].Result != CmdApplied { t.Errorf("audit of the run: %+v", rows) } diff --git a/go/protocol/agents.go b/go/protocol/agents.go index 60f4054d7..ba82f253b 100644 --- a/go/protocol/agents.go +++ b/go/protocol/agents.go @@ -372,8 +372,8 @@ type AgentList struct { Requests []Request `cbor:"requests" json:"requests"` Keys []ApprovalKeyInfo `cbor:"keys" json:"keys"` // LongestMs is the longest grant the box takes for each role, so a - // client offers no length the box would refuse. - LongestMs map[string]int64 `cbor:"longestMs" json:"longestMs"` + // client offers no length the box would refuse. Owners only. + LongestMs map[string]int64 `cbor:"longestMs,omitempty" json:"longestMs,omitempty"` } // LongestMs is the longest grant for each agent role, as agent.list tells diff --git a/web/settings/tabs/access.js b/web/settings/tabs/access.js index 4b35f943b..6758cad17 100644 --- a/web/settings/tabs/access.js +++ b/web/settings/tabs/access.js @@ -202,10 +202,12 @@ } // The longest grant the box takes for a role, in seconds, as its facts - // say. A box that says nothing is taken to allow 30 days. + // say. A box that says nothing took 30 days for an assistant, 14 for + // Sourceful and 90 for the release check. + var UNSTATED_S = { agent: 30 * DAY_S, support: 14 * DAY_S, release: 90 * DAY_S }; function longestS(box, role) { var ms = Number(((box && box.longestMs) || {})[role]); - return ms > 0 ? Math.floor(ms / 1000) : 30 * DAY_S; + return ms > 0 ? Math.floor(ms / 1000) : UNSTATED_S[role] || 30 * DAY_S; } function lengthText(s) { diff --git a/web/settings/tabs/access.test.mjs b/web/settings/tabs/access.test.mjs index e9d1fb00e..619097229 100644 --- a/web/settings/tabs/access.test.mjs +++ b/web/settings/tabs/access.test.mjs @@ -193,6 +193,8 @@ describe("what the owner grants", () => { assert.equal(P.longestS(box, "support"), 14 * day); assert.equal(P.longestS({}, "agent"), 30 * day); assert.equal(P.longestS(null, "agent"), 30 * day); + assert.equal(P.longestS({}, "support"), 14 * day); + assert.equal(P.longestS({}, "release"), 90 * day); assert.deepEqual(P.lengthOptions(Math.min(365 * day, P.longestS(box, "agent"))), [day, 7 * day, 30 * day, 90 * day, 365 * day]); assert.deepEqual(P.lengthOptions(Math.min(365 * day, P.longestS({}, "agent"))), [day, 7 * day, 30 * day]);