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
7 changes: 7 additions & 0 deletions .changeset/assistant-access-on-the-box.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": minor
---

Settings has an Access tab, where an owner with no phone app lets an AI assistant such as Claude or ChatGPT into the home. The owner types the code from FTW Cloud's page, sees what the box read in the signed offer, and grants it or less, never more. Reads are the default; control is something the owner turns on, with caps no higher than the assistant asked for. Granting, renewing, narrowing and approving a request need the house password, and the page says so before it asks for a code. The tab lists who has access, with expiry and last use, and lets the owner renew, narrow or remove each one and answer waiting requests.

The box now says why it will not take an offer: unsigned, expired, for another home, unreadable, or a build that pins no offer key. A box with only an assistant, and no phone, now links to FTW Cloud, which learns of the assistant from the box's roster.
26 changes: 22 additions & 4 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,8 +557,9 @@ above and its approval key, and an agent can never reach them.

| Route | Who |
|---|---|
| `GET /api/agents` | A client on the LAN. Rows, requests and keys |
| `GET /api/agents` | A client on the LAN. Rows, requests and keys, the box's facts and the registry's agent ops |
| `GET /api/agents/audit?before=&n=` | A client on the LAN |
| `POST /api/agents/offer {offer}` | A client on the LAN. The offer as the box reads it; grants nothing |
| `POST /api/agents {offer, scopes, ops, caps, ttlS}` | A LAN client signed in with the house password |
| `POST /api/agents/{id}/widen {epoch, scopes, ops, caps, ttlS}` | The same |
| `POST /api/agent-requests/{reqId} {approve: true}` | The same |
Expand All @@ -569,6 +570,19 @@ above and its approval key, and an agent can never reach them.

- A request that brings a house password, and a wrong one, is refused on
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
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`
and `POST /api/agents` answer an offer the box will not take with 400 and
`{error, reason, boxMs}`: `reason` is `no_offer_keys`, `unsigned`,
`expired`, `malformed` or `other_home`. A clock the box cannot trust is
`E_UNAVAILABLE {subsystem: "clock"}` first, since every offer would
otherwise read as expired.
- `clock-reset` is for a clock that once ran ahead. The box never trusts a
wall time below the highest it has seen, so until the clock passes that
time again every agent is locked out. The reset makes now the mark and
Expand All @@ -581,9 +595,13 @@ above and its approval key, and an agent can never reach them.
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.
- **Every route that widens access needs the house password,** from any
address, loopback included: a driver's Lua and the session passthrough can
both reach the box from inside it.
- **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
tunnel to it, cannot widen access with or without the password: a
driver's Lua and the session passthrough can both reach the box from
inside it. `box.widen` in `GET /api/agents` says whether this request
would pass, so the page stops before a code is spent.
- **Drivers never reach the box's API.** A driver's HTTP, TCP or WebSocket
connection to a port the API listens on is refused, whether it goes to a
loopback address or to an address of the box. The check runs on the
Expand Down
16 changes: 10 additions & 6 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ for both live in `go/protocol`, under Apache-2.0
## Parties and connections

- **Box:** FTW Core linked to FTW Cloud. A box links when it first shows a
pairing code or an owner turns the readable copy on, and stays linked while
any phone may reach it (see [Link](#link)).
pairing code, an owner lets an agent in or an owner turns the readable copy
on, and stays linked while any phone or agent may reach it (see
[Link](#link)).
- **Service:** `ftwcloudd`, at `cloud.ftw.energy`.
- **Phone:** the iPhone app, paired with the box over Noise as today.
- **Extension:** a widget, notification service extension or Live Activity
Expand Down Expand Up @@ -1114,12 +1115,15 @@ box, which the box sends from its first pairing on.
### Link

- A box links when it first shows a QR pairing code, when a device that may
open a tunnel is paired, or when an owner turns the readable copy on. It
makes `cloud.key` then, and the home id with it.
open a tunnel is paired, when an owner lets an agent in, or when an owner
turns the readable copy on. It makes `cloud.key` then, and the home id with
it. An agent row has no tunnel, but FTW Cloud learns of it only from the
roster, and an owner with no phone grants one on the box page
([agents.md](agents.md)).
- A linked box sends its roster and takes `tun`. It sends no readings until
the owner turns the readable copy on.
- The link ends when no paired device may open a tunnel, no code is shown
and the readable copy is off. The box then sends
- The link ends when no paired device may open a tunnel, no agent row is
left, no code is shown and the readable copy is off. The box then sends
`bye {why: "revoked", purge: true}` and deletes `cloud.key` on the
`purged {what: "home"}` receipt. The next link has a new home id.
- A build that names no service never links.
Expand Down
42 changes: 42 additions & 0 deletions go/cmd/ftw/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"encoding/base64"
"encoding/json"
"log/slog"
"net/url"
"slices"
"strconv"
"strings"
Expand Down Expand Up @@ -89,6 +90,13 @@ func (w *wallMark) reset(st *state.Store, nowMs int64) error {
return st.SaveConfig(wallHighKey, strconv.FormatInt(nowMs, 10))
}

// mark is the highest wall time the box has seen.
func (w *wallMark) mark() int64 {
w.mu.Lock()
defer w.mu.Unlock()
return w.highMs
}

// save keeps the mark, at most once a sweep.
func (w *wallMark) save(st *state.Store) {
if st == nil {
Expand Down Expand Up @@ -438,6 +446,40 @@ func (b boxPageAgents) OpenOffer(raw string) (protocol.GrantOffer, error) {
return protocol.OpenOffer(cloudlink.ParseOfferKeys(cloudlink.OfferKeys), so, now)
}

// Facts tell the box page whether it can take a grant now, before the owner
// spends a code that works once.
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(),
}
switch {
case ok:
f.Clock = api.ClockOK
case cloudlink.ClockSynced() && f.NowMs < f.MarkMs:
f.Clock = api.ClockBehind
default:
f.Clock = api.ClockUnsynced
}
return f
}

// codeLookup is FTW Cloud's code lookup on the host this build links to.
func codeLookup(endpoint string) string {
u, err := url.Parse(endpoint)
if err != nil || u.Host == "" {
return ""
}
scheme := "https"
if u.Scheme == "ws" {
scheme = "http"
}
return scheme + "://" + u.Host + "/oauth/requests"
}

// ReplaceApprovalKey sets a phone's approval key from the box page.
func (b boxPageAgents) ReplaceApprovalKey(by, deviceID string, pub []byte) error {
_, err := b.p.access.Enroll.ReplaceApprovalKey(deviceID, pub)
Expand Down
16 changes: 16 additions & 0 deletions go/cmd/ftw/agents_clock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,19 @@ func TestTheClockMarkResetsLower(t *testing.T) {
t.Fatalf("mark %d, after a restart %d", w.highMs, again.highMs)
}
}

// The box page's browser looks a code up on the host this build links to,
// so a local test build with its own FTW Cloud finds its own lookup.
func TestTheCodeLookupFollowsTheUplink(t *testing.T) {
for endpoint, want := range map[string]string{
"wss://cloud.ftw.energy/v1/box": "https://cloud.ftw.energy/oauth/requests",
"wss://127.0.0.1:18943/v1/box": "https://127.0.0.1:18943/oauth/requests",
"ws://127.0.0.1:18944/v1/box": "http://127.0.0.1:18944/oauth/requests",
"": "",
"not a url with a host": "",
} {
if got := codeLookup(endpoint); got != want {
t.Errorf("%q: %q, want %q", endpoint, got, want)
}
}
}
Loading
Loading