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..cc8aa71b3 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,20 @@ 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. 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 + +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 @@ -572,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` @@ -594,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 @@ -668,7 +685,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 +744,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.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 b2f4efb08..50b4bcd89 100644 --- a/go/cmd/ftw/agents_notice_test.go +++ b/go/cmd/ftw/agents_notice_test.go @@ -46,3 +46,49 @@ 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) + } +} + +// 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..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" ) @@ -78,6 +77,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. @@ -249,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"` @@ -289,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) @@ -384,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 { @@ -435,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/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..7bba69b26 100644 --- a/go/internal/appproto/agents.go +++ b/go/internal/appproto/agents.go @@ -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 16e8acd31..7d0b91ae0 100644 --- a/go/internal/appproto/agents_test.go +++ b/go/internal/appproto/agents_test.go @@ -575,11 +575,29 @@ 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) } } +// 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..ba82f253b 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. 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 +// 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. 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} diff --git a/web/settings/tabs/access.js b/web/settings/tabs/access.js index 488a2ae39..6758cad17 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,17 @@ 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 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) : UNSTATED_S[role] || 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 +681,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 +825,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 +1003,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..619097229 100644 --- a/web/settings/tabs/access.test.mjs +++ b/web/settings/tabs/access.test.mjs @@ -186,6 +186,25 @@ 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.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]); + // 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);