Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/assistant-access-for-a-year.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Mark the new access duration as a minor change

Use a minor changeset here: extending assistant access from 30 days to one year is a visible new capability, not a fix. Leaving this as patch causes the release tooling to publish the feature under the wrong version increment.

AGENTS.md reference: AGENTS.md:L171-L175

Useful? React with 👍 / 👎.

---

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.
37 changes: 27 additions & 10 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
9 changes: 5 additions & 4 deletions go/cmd/ftw/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions go/cmd/ftw/agents_notice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
48 changes: 42 additions & 6 deletions go/internal/api/api_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import (
"net/http"
"slices"
"strconv"
"time"

"github.com/srcfl/ftw/go/protocol"
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions go/internal/api/api_agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Loading
Loading