diff --git a/.changeset/assistant-access-on-the-box.md b/.changeset/assistant-access-on-the-box.md new file mode 100644 index 000000000..6e6d93f4a --- /dev/null +++ b/.changeset/assistant-access-on-the-box.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index 822ec006a..f24415a72 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 | @@ -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 @@ -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 diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index f175c586a..22f9dd562 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -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 @@ -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. diff --git a/go/cmd/ftw/agents.go b/go/cmd/ftw/agents.go index 83cb1c101..626af4f9c 100644 --- a/go/cmd/ftw/agents.go +++ b/go/cmd/ftw/agents.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "encoding/json" "log/slog" + "net/url" "slices" "strconv" "strings" @@ -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 { @@ -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) diff --git a/go/cmd/ftw/agents_clock_test.go b/go/cmd/ftw/agents_clock_test.go index 0553a0c36..2f49535ff 100644 --- a/go/cmd/ftw/agents_clock_test.go +++ b/go/cmd/ftw/agents_clock_test.go @@ -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) + } + } +} diff --git a/go/internal/api/api_agents.go b/go/internal/api/api_agents.go index 522113d25..dbbfa8b5f 100644 --- a/go/internal/api/api_agents.go +++ b/go/internal/api/api_agents.go @@ -5,8 +5,10 @@ package api // An owner phone manages agents over its session, each grant signed by its // approval key. On the home network the box is itself the authority, so // these routes ask for no signature. They ask for something else: widening -// access needs the box itself or the house password. With api.lan_auth off, -// which is the default, every device on the home network is an owner with no +// access needs another device on the home network, signed in with the house +// password. The box itself never counts, password or not: a driver's Lua and +// the session passthrough reach it from inside. With api.lan_auth off, which +// is the default, every device on the home network is an owner with no // sign-in at all, and granting a remote agent from there would let any of // them open the house to the outside. Narrowing stays open to any LAN // client, as DELETE /api/cloud/grant does. @@ -51,11 +53,48 @@ type AgentAdmin interface { // ResetClock makes the box trust its clock again after it once ran // ahead, and ends every agent's grant. ResetClock(by string) error + // Facts are what the box page checks before it asks the owner for a + // code, which works once. + Facts() AgentFacts } +// AgentFacts say whether this box can take a grant now, and where the owner's +// browser looks a code up. +type AgentFacts struct { + // Offers says this build pins an FTW Cloud offer key. A build that pins + // none opens no offer. + Offers bool `json:"offers"` + // Lookup is FTW Cloud's code lookup. The owner's browser asks it, and + // posts the signed offer it gets here. + Lookup string `json:"lookup"` + // Clock is ClockOK, ClockUnsynced or ClockBehind. + Clock string `json:"clock"` + // NowMs is the box's wall clock, and MarkMs the highest wall time it + // has seen. + NowMs int64 `json:"nowMs"` + MarkMs int64 `json:"markMs"` + // Widen says this request would pass the gate for widening access: a + // LAN client signed in with the house password, never the box itself. + // 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"` +} + +// The agent clock as the box page reports it. +const ( + ClockOK = "ok" + // ClockUnsynced is a clock NTP has not set. + ClockUnsynced = "unsynced" + // ClockBehind is a set clock below the highest time the box has seen. + // Agents stay locked out until it passes that time again, or an owner + // resets the mark. + ClockBehind = "behind" +) + func (s *Server) agentRoutes() { s.handle("GET /api/agents", Local, s.handleAgentsList) s.handle("GET /api/agents/audit", Local, s.handleAgentsAudit) + s.handle("POST /api/agents/offer", Local, s.handleAgentsOffer) s.handle("POST /api/agents", Local, s.handleAgentsAdd) s.handle("POST /api/agents/{id}/widen", Local, s.handleAgentsWiden) s.handle("DELETE /api/agents/{id}", Local, s.handleAgentsRevoke) @@ -80,9 +119,9 @@ func (s *Server) handleAgentsClockReset(w http.ResponseWriter, r *http.Request) } // agentsGate admits a client on the home network, and with widen only one -// signed in with the house password, from any address: a driver's Lua and -// the session passthrough can both reach the box from inside it, so loopback -// proves nothing. It returns the caller's name for the audit log. +// signed in with the house password. Loopback never is: the sign-in is not +// read there, since a driver's Lua and the session passthrough can both reach +// the box from inside it. It returns the caller's name for the audit log. func (s *Server) agentsGate(w http.ResponseWriter, r *http.Request, widen bool) (string, bool) { if s.deps == nil || s.deps.Agents == nil { writeAppLinkError(w, http.StatusServiceUnavailable, "agents are not available on this box") @@ -134,13 +173,56 @@ func agentError(w http.ResponseWriter, err error) { writeJSON(w, status, map[string]any{"error": r.Code, "args": r.Args}) } +// agentsPage is agent.list as the box page reads it, with the box's facts and +// the registry's view of agent ops, so the page names nothing by hand. +type agentsPage struct { + protocol.AgentList + Box AgentFacts `json:"box"` + Registry agentsRegistry `json:"registry"` +} + +type agentsRegistry struct { + // AgentOps maps each op an agent may name to the scope that opens it. + AgentOps map[string]string `json:"agentOps"` + // WriteScopes change something, now or once an owner says yes. + WriteScopes []string `json:"writeScopes"` + // AgentArgs are each op's arguments, in the registry's order, with the + // label an owner reads. + AgentArgs map[string][]agentArgView `json:"agentArgs"` +} + +type agentArgView struct { + Name string `json:"name"` + Label string `json:"label"` + Kind string `json:"kind"` + Unit string `json:"unit,omitempty"` + // Cap names the grant's cap that bounds the argument too. + Cap string `json:"cap,omitempty"` +} + +func registryForPage() agentsRegistry { + args := map[string][]agentArgView{} + for op, list := range protocol.RegistryAgentArgs { + for _, a := range list { + args[op] = append(args[op], agentArgView{Name: a.Name, Label: a.Label, Kind: a.Kind, Unit: a.Unit, Cap: a.Cap}) + } + } + return agentsRegistry{AgentOps: protocol.RegistryAgentOps, WriteScopes: protocol.WriteScopes, AgentArgs: args} +} + func (s *Server) handleAgentsList(w http.ResponseWriter, r *http.Request) { if _, ok := s.agentsGate(w, r, false); !ok { return } a := s.deps.Agents - writeJSON(w, http.StatusOK, protocol.AgentList{ - Agents: nonNil(a.Agents()), Requests: nonNil(a.Requests()), Keys: nonNil(a.ApprovalKeys()), + facts := a.Facts() + facts.Widen, _ = lanSecretFrom(r.Context()) + writeJSON(w, http.StatusOK, agentsPage{ + AgentList: protocol.AgentList{ + Agents: nonNil(a.Agents()), Requests: nonNil(a.Requests()), Keys: nonNil(a.ApprovalKeys()), + }, + Box: facts, + Registry: registryForPage(), }) } @@ -186,18 +268,8 @@ func (s *Server) handleAgentsAdd(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body is not a grant"}) return } - offer, err := s.deps.Agents.OpenOffer(body.Offer) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offer is not signed, has expired or is not readable"}) - return - } - if home := s.deps.Agents.Home(); offer.Home != "" && offer.Home != home { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offer is for another box"}) - return - } - now, synced := s.deps.Agents.Wall() - if !synced { - agentError(w, protocol.Refuse(protocol.ErrUnavailable, map[string]any{"subsystem": "clock"})) + offer, now, ok := s.openOffer(w, body.Offer) + if !ok { return } if !withinOffer(offer, body) { @@ -230,6 +302,88 @@ func (s *Server) handleAgentsAdd(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, row) } +// Why the box will not take an offer, for the page to say in its own words. +const ( + OfferNoKeys = "no_offer_keys" + OfferUnsigned = "unsigned" + OfferExpired = "expired" + OfferMalformed = "malformed" + OfferOtherHome = "other_home" +) + +// openOffer reads an offer as the box would grant it. When it will not, it +// answers why and returns false. A clock the box cannot trust comes first: +// without one every offer would read as expired. +func (s *Server) openOffer(w http.ResponseWriter, raw string) (protocol.GrantOffer, int64, bool) { + a := s.deps.Agents + now, synced := a.Wall() + if !synced { + agentError(w, protocol.Refuse(protocol.ErrUnavailable, map[string]any{"subsystem": "clock"})) + return protocol.GrantOffer{}, 0, false + } + refuse := func(reason, text string) (protocol.GrantOffer, int64, bool) { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": text, "reason": reason, "boxMs": now}) + return protocol.GrantOffer{}, 0, false + } + offer, err := a.OpenOffer(raw) + switch { + case errors.Is(err, protocol.ErrOfferSignature) && !a.Facts().Offers: + return refuse(OfferNoKeys, "this build of FTW pins no FTW Cloud offer key, so it takes no offer") + case errors.Is(err, protocol.ErrOfferSignature): + return refuse(OfferUnsigned, "no offer key this box knows signed the offer") + case errors.Is(err, protocol.ErrOfferExpired): + return refuse(OfferExpired, "the offer has expired by this box's clock") + case err != nil: + return refuse(OfferMalformed, "the offer is not readable") + } + if home := a.Home(); offer.Home != "" && offer.Home != home { + return refuse(OfferOtherHome, "the offer is for another home") + } + return offer, now, true +} + +// offerView is a signed offer as the box read it, for the owner to check +// before granting. +type offerView struct { + Role string `json:"role"` + Client string `json:"client"` + ClientID string `json:"clientId"` + ClientHost string `json:"clientHost"` + Verified bool `json:"verified"` + Ticket string `json:"ticket"` + Scopes []string `json:"scopes"` + Ops []string `json:"ops"` + Caps protocol.Caps `json:"caps"` + TTLS uint64 `json:"ttlS"` + Code string `json:"code"` + ExpMs int64 `json:"expMs"` +} + +// handleAgentsOffer opens a signed offer for the box page, so the owner sees +// what the box read in it, or why it will not take it, before granting. It +// grants nothing, so it asks only what reading the agents asks. +func (s *Server) handleAgentsOffer(w http.ResponseWriter, r *http.Request) { + if _, ok := s.agentsGate(w, r, false); !ok { + return + } + var body struct { + Offer string `json:"offer"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body needs offer"}) + return + } + o, _, ok := s.openOffer(w, body.Offer) + if !ok { + return + } + writeJSON(w, http.StatusOK, offerView{ + Role: o.Role, Client: o.Client, ClientID: o.ClientID, ClientHost: o.ClientHost, Verified: o.Verified, + Ticket: o.Ticket, Scopes: nonNil(o.Scopes), Ops: nonNil(o.Ops), Caps: o.Caps, TTLS: o.TTLS, + Code: o.Code, ExpMs: o.ExpMs, + }) +} + // 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 { diff --git a/go/internal/api/api_agents_test.go b/go/internal/api/api_agents_test.go index 7002db703..1d9e442df 100644 --- a/go/internal/api/api_agents_test.go +++ b/go/internal/api/api_agents_test.go @@ -3,8 +3,12 @@ package api import ( "context" "encoding/base64" + "encoding/json" + "io" "net/http" "net/http/httptest" + "slices" + "strings" "testing" "github.com/srcfl/ftw/go/internal/apiauth" @@ -22,6 +26,8 @@ type stubAgents struct { home string unsynced bool resets int + // noKeys is a build that pins no offer key. + noKeys bool } func (a *stubAgents) Agents() []protocol.AgentRow { return nil } @@ -54,6 +60,9 @@ func (a *stubAgents) ResetClock(by string) error { a.by = append(a.by, by) return nil } +func (a *stubAgents) Facts() AgentFacts { + return AgentFacts{Offers: !a.noKeys, Lookup: "https://cloud.ftw.energy/oauth/requests", Clock: ClockOK, NowMs: 1_000_000} +} func (a *stubAgents) OpenOffer(raw string) (protocol.GrantOffer, error) { signed, err := base64.RawURLEncoding.DecodeString(raw) if err != nil { @@ -63,7 +72,11 @@ func (a *stubAgents) OpenOffer(raw string) (protocol.GrantOffer, error) { if err := protocol.UnmarshalSigned(signed, &so); err != nil { return protocol.GrantOffer{}, err } - return protocol.OpenOffer(a.keys, so, 1_000_000) + keys := a.keys + if a.noKeys { + keys = nil + } + return protocol.OpenOffer(keys, so, 1_000_000) } func (a *stubAgents) ReplaceApprovalKey(by, deviceID string, _ []byte) error { a.replaced = append(a.replaced, deviceID) @@ -85,11 +98,16 @@ func offerBody(t *testing.T, home string) string { func signedOfferBody(t *testing.T, home string, signer *approvaltest.Signer) string { t.Helper() - o, err := protocol.Marshal(protocol.GrantOffer{ + return offerOf(t, protocol.GrantOffer{ V: protocol.GrantOfferVersion, Home: home, Key: make([]byte, 32), Role: apiauth.RoleAgent, Client: "Claude", Scopes: []string{protocol.ScopeLiveRead}, Ops: []string{}, TTLS: 3600, Code: "ABCD", ExpMs: 1_000_000 + 60_000, - }) + }, signer) +} + +func offerOf(t *testing.T, offer protocol.GrantOffer, signer *approvaltest.Signer) string { + t.Helper() + o, err := protocol.Marshal(offer) if err != nil { t.Fatal(err) } @@ -187,6 +205,139 @@ func TestTheBoxPageTakesASignedOfferAndItsLength(t *testing.T) { } } +// The box page shows the owner what the box read in an offer before it +// grants, and each reason it will not take one, in words of the page's own. +// Reading an offer grants nothing, so any LAN client may ask. +func TestTheBoxPageReadsAnOfferBeforeGranting(t *testing.T) { + agents := newStubAgents("home-1") + s := New(&Deps{Agents: agents}) + check := func(body string) (int, map[string]any) { + w := httptest.NewRecorder() + s.handleAgentsOffer(w, localRequest(http.MethodPost, "/api/agents/offer", body)) + var got map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &got) + return w.Code, got + } + full := protocol.GrantOffer{ + V: protocol.GrantOfferVersion, Key: make([]byte, 32), Role: apiauth.RoleAgent, + Client: "Claude", ClientID: "https://claude.ai/oauth/mcp-oauth-client-metadata", ClientHost: "claude.ai", Verified: true, + Scopes: []string{protocol.ScopeDispatchHold, protocol.ScopeLiveRead}, Ops: []string{protocol.OpLoadpointHold}, + Caps: protocol.Caps{HoldS: 1800, MaxW: 11000, PerMin: 6}, TTLS: 2_592_000, Code: "ABCDEFGH", ExpMs: 1_000_000 + 60_000, + } + code, got := check(offerOf(t, full, offerSigner)) + if code != http.StatusOK || got["client"] != "Claude" || got["clientHost"] != "claude.ai" || got["verified"] != true || + got["code"] != "ABCDEFGH" || got["ttlS"] != float64(2_592_000) || len(got["ops"].([]any)) != 1 { + t.Fatalf("a good offer: %d %v", code, got) + } + if caps := got["caps"].(map[string]any); caps["holdS"] != float64(1800) || caps["maxW"] != float64(11000) { + t.Errorf("caps %v", caps) + } + if len(agents.added) != 0 { + t.Fatal("reading an offer granted it") + } + + expired := full + expired.ExpMs = 1_000_000 + other := full + other.Home = "another-home" + for _, c := range []struct { + name, body, reason string + }{ + {"signed by a key the box does not pin", signedOfferBody(t, "", approvaltest.New()), OfferUnsigned}, + {"expired by the box's clock", offerOf(t, expired, offerSigner), OfferExpired}, + {"for another home", offerOf(t, other, offerSigner), OfferOtherHome}, + {"not an offer", `{"offer":"bm90IGFuIG9mZmVy"}`, OfferMalformed}, + } { + code, got := check(c.body) + if code != http.StatusBadRequest || got["reason"] != c.reason || got["boxMs"] != float64(1_000_000) { + t.Errorf("%s: %d %v", c.name, code, got) + } + } + + agents.noKeys = true + if code, got := check(offerOf(t, full, offerSigner)); code != http.StatusBadRequest || got["reason"] != OfferNoKeys { + t.Errorf("a build that pins no offer key: %d %v", code, got) + } + agents.noKeys = false + agents.unsynced = true + if code, got := check(offerOf(t, full, offerSigner)); code != http.StatusServiceUnavailable || got["error"] != protocol.ErrUnavailable { + t.Errorf("without a clock: %d %v", code, got) + } + + // Granting answers with the same reasons. + agents.unsynced = false + w := httptest.NewRecorder() + s.handleAgentsAdd(w, signedIn(localRequest(http.MethodPost, "/api/agents", offerOf(t, expired, offerSigner)))) + if !strings.Contains(w.Body.String(), `"reason":"expired"`) { + t.Errorf("granting an expired offer: %d %s", w.Code, w.Body.String()) + } +} + +// The list carries what the page checks before it asks for a code, which +// works once, and the registry's view of agent ops, so the page names +// nothing by hand. +func TestTheAgentListCarriesTheBoxFacts(t *testing.T) { + s := New(&Deps{Agents: newStubAgents("")}) + w := httptest.NewRecorder() + s.handleAgentsList(w, localRequest(http.MethodGet, "/api/agents", "")) + var got struct { + Agents []protocol.AgentRow `json:"agents"` + Box AgentFacts `json:"box"` + Registry struct { + AgentOps map[string]string `json:"agentOps"` + WriteScopes []string `json:"writeScopes"` + AgentArgs map[string][]agentArgView `json:"agentArgs"` + } `json:"registry"` + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || w.Code != http.StatusOK { + t.Fatalf("%d %v %s", w.Code, err, w.Body.String()) + } + if got.Agents == nil || !got.Box.Offers || got.Box.Clock != ClockOK || got.Box.Lookup == "" { + t.Errorf("box %+v agents %v", got.Box, got.Agents) + } + if got.Registry.AgentOps[protocol.OpLoadpointHold] != protocol.ScopeDispatchHold || + !slices.Contains(got.Registry.WriteScopes, protocol.ScopeGoalsRequest) { + t.Errorf("registry %+v", got.Registry) + } + args := got.Registry.AgentArgs[protocol.OpLoadpointHold] + if len(args) < 2 || args[0].Name != "id" || args[1].Name != "power_w" || args[1].Label == "" || + args[1].Unit != "W" || args[1].Cap != "maxW" { + t.Errorf("loadpoint.hold's arguments, in the registry's order: %+v", args) + } +} + +// On the box itself the house password does not count, so the list tells the +// page that this request cannot widen access before the owner spends a code +// on a grant the box would refuse. Another device signed in can. +func TestTheListSaysWhetherThisRequestCanWiden(t *testing.T) { + resetLANGuesses(t) + policy := lanAuthPolicy(testHousePassword) + s := New(&Deps{Agents: newStubAgents("")}) + widen := func(url, remote string) (bool, int) { + var after *http.Request + serveLANAuth(policy, lanAuthRequest(http.MethodGet, url, remote, "Bearer "+testHousePassword), + func(r *http.Request) { after = r }) + w := httptest.NewRecorder() + s.handleAgentsList(w, after) + var got struct { + Box AgentFacts `json:"box"` + } + _ = json.Unmarshal(w.Body.Bytes(), &got) + add := httptest.NewRecorder() + post := after.Clone(after.Context()) + post.Method = http.MethodPost + post.Body = io.NopCloser(strings.NewReader(offerBody(t, ""))) + s.handleAgentsAdd(add, post) + return got.Box.Widen, add.Code + } + if ok, code := widen("http://127.0.0.1:8080/api/agents", "127.0.0.1:1234"); ok || code != http.StatusForbidden { + t.Errorf("on the box itself, with the house password: widen %v, grant %d", ok, code) + } + if ok, code := widen("http://192.168.1.1:8080/api/agents", "192.168.1.5:1234"); !ok || code != http.StatusCreated { + t.Errorf("another device, signed in: widen %v, grant %d", ok, code) + } +} + func TestALostApprovalKeyIsReplacedOnlyWithThePassword(t *testing.T) { agents := newStubAgents("") s := New(&Deps{Agents: agents}) @@ -275,6 +426,7 @@ func TestAgentRoutesAreLocal(t *testing.T) { for _, c := range []struct{ method, path string }{ {http.MethodGet, "/api/agents"}, {http.MethodGet, "/api/agents/audit"}, + {http.MethodPost, "/api/agents/offer"}, {http.MethodPost, "/api/agents"}, {http.MethodPost, "/api/agents/abc/widen"}, {http.MethodDelete, "/api/agents/abc"}, @@ -295,6 +447,7 @@ func TestTheAgentRoutesNeedSignInWithLANAuthOn(t *testing.T) { for _, c := range []struct{ method, path string }{ {http.MethodGet, "/api/agents"}, {http.MethodGet, "/api/agents/audit"}, + {http.MethodPost, "/api/agents/offer"}, {http.MethodDelete, "/api/agents/agent001"}, {http.MethodPost, "/api/agent-requests/r1"}, } { diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index e1b20d640..077896a35 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -471,14 +471,17 @@ func (l *Link) RosterChanged() { } // wantsLink reports whether anything needs the link: a phone that may open a -// tunnel, or a pairing code on the screen. A grant needs it too, and makes it -// itself. +// tunnel, an agent an owner let in, or a pairing code on the screen. An agent +// row has no tunnel, but it reaches the box only through FTW Cloud, which +// learns of it from the roster; an owner with no phone grants one on the box +// page. A box that takes no tun cannot serve an agent, so its rows want no +// link. A grant needs the link too, and makes it itself. func (l *Link) wantsLink() bool { if _, _, ok := l.opts.Roster.Pending(); ok { return true } for _, d := range l.opts.Roster.Devices() { - if d.Tun { + if d.Tun || (d.Agent != nil && l.opts.Tunnel != nil) { return true } } diff --git a/go/internal/cloudlink/link_test.go b/go/internal/cloudlink/link_test.go index 065097285..5dec9dc0c 100644 --- a/go/internal/cloudlink/link_test.go +++ b/go/internal/cloudlink/link_test.go @@ -113,9 +113,11 @@ type fakeRoster struct { } type fakeRosterState struct { - mu sync.Mutex - tun bool - gone bool + mu sync.Mutex + tun bool + gone bool + // agent adds an assistant's row, which has no tunnel. + agent bool code []byte expires time.Time } @@ -130,8 +132,14 @@ func (r fakeRoster) Devices() []RosterDevice { } tun = r.state.tun } - return []RosterDevice{{ID: "q5Lw0Zb1", Key: bytes.Repeat([]byte{5}, 32), Role: "owner", Epoch: 1, + out := []RosterDevice{{ID: "q5Lw0Zb1", Key: bytes.Repeat([]byte{5}, 32), Role: "owner", Epoch: 1, Scopes: []string{protocol.ScopeLiveRead, protocol.ScopeDispatchWrite, protocol.ScopeEventsRead}, Tun: tun}} + if r.state != nil && r.state.agent { + out = append(out, RosterDevice{ID: "Ag3nt0001", Key: bytes.Repeat([]byte{7}, 32), Role: protocol.RoleAgent, + Epoch: 1, Scopes: []string{}, Agent: &cloudwire.Agent{Scopes: []string{protocol.ScopeLiveRead}, + Ops: []string{}, ExpiresAtMs: 1_791_821_000_000}}) + } + return out } func (r fakeRoster) Noise() []byte { return make([]byte, 32) } @@ -514,6 +522,57 @@ func TestOffUntilAnOwnerGrants(t *testing.T) { } } +// An owner with no phone lets an assistant in on the box page. The agent row +// has no tunnel, but FTW Cloud learns of it only from the roster, so the box +// links for it, and an idle link does not end while it stays. +func TestAnAgentRowLinksTheBox(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.setRoster(func(r *fakeRosterState) { r.agent = true }) + r := h.nextRoster(t) + agents := 0 + for _, d := range r.Devices { + if d.Agent != nil && !d.Tun { + agents++ + } + } + if len(r.Devices) != 2 || agents != 1 { + t.Fatalf("roster devices %+v", r.Devices) + } + if st := h.link.Status(); st.Link != "on" || st.Home == "" { + t.Fatalf("status %+v", st) + } + h.link.endIfIdle() + h.link.mu.Lock() + ending := h.link.link != nil && h.link.link.Ending + h.link.mu.Unlock() + if ending { + t.Fatal("the link began to end while an agent still has access") + } +} + +// With the app link off the box takes no tun, so it cannot serve an agent. +// A row left from before must not dial FTW Cloud, make a home id or send a +// roster that names its client. +func TestAnAgentRowLinksNoBoxThatTakesNoTun(t *testing.T) { + svc := newFakeService(t) + h := newHarnessWith(t, svc, []*ecdsa.PublicKey{&svc.key.PublicKey}, nil, func(o *Options) { o.Tunnel = nil }) + h.setRoster(func(r *fakeRosterState) { r.agent = true }) + time.Sleep(300 * time.Millisecond) + svc.mu.Lock() + conns := svc.conns + svc.mu.Unlock() + if conns != 0 { + t.Fatalf("the box dialled FTW Cloud %d times for an agent it cannot serve", conns) + } + if st := h.link.Status(); st.Link == "on" || st.Home != "" { + t.Fatalf("status %+v", st) + } + if _, err := os.Stat(h.keyPath); !os.IsNotExist(err) { + t.Fatal("cloud.key exists for an agent the box cannot serve") + } +} + func TestGrantStreamsSignedStateWithoutNames(t *testing.T) { svc := newFakeService(t) h := newHarness(t, svc) diff --git a/web/index.html b/web/index.html index 9233aff44..a954343f1 100644 --- a/web/index.html +++ b/web/index.html @@ -100,6 +100,7 @@

Settings

+ @@ -1002,6 +1003,7 @@

Price bars (top of the chart)

+ diff --git a/web/settings.js b/web/settings.js index e7b5aaee4..b096a9e94 100644 --- a/web/settings.js +++ b/web/settings.js @@ -400,7 +400,8 @@ function renderTab(tab) { var hadBodyFocus = bodyEl.contains(document.activeElement); - saveBtn.hidden = tab === "loadpoints" || (tab === "devices" && !!S.chargerSetup); + // Access changes nothing in the config: each of its buttons asks the box. + saveBtn.hidden = tab === "loadpoints" || tab === "access" || (tab === "devices" && !!S.chargerSetup); saveBtn.style.display = saveBtn.hidden ? "none" : ""; var def = S.tabs[tab]; if (!def) { diff --git a/web/settings/tabs/access.js b/web/settings/tabs/access.js new file mode 100644 index 000000000..488a2ae39 --- /dev/null +++ b/web/settings/tabs/access.js @@ -0,0 +1,1004 @@ +// Settings → Access: let an AI assistant reach this home, with no phone app. +// +// An assistant such as Claude or ChatGPT connects to FTW Cloud's MCP door, +// and FTW Cloud's consent page shows a short code. The owner types it here. +// This page asks FTW Cloud's code lookup, from the owner's browser, for the +// signed offer the code stands for, and asks the box to read it. The box +// checks FTW Cloud's signature against the keys built into it, so what the +// owner sees here is what the box read, not what a web page claimed. The +// owner then grants it, or less of it, never more. +// +// Granting needs another device on the home network, signed in with the +// house password. With it off, every device on the Wi-Fi is an owner with no +// sign-in, and letting the outside world in must not be one click from any of +// them; on the box itself the password does not count. The box refuses; this +// page says so before it asks for a code, because a code works once and +// should not be spent on a refusal. Removing access and declining a request +// stay open, as removing a phone does. +// +// Names that come from outside — the assistant's name, its host, its own +// words in a request — only ever go into the page as text. +(function () { + var S = (window.FTWSettings = window.FTWSettings || { tabs: {} }); + S.tabs = S.tabs || {}; + + // What each scope lets an assistant do, in this page's words. The box names + // the scopes and says which change something; a name with no words here + // still shows, as itself. + var SCOPE_WORDS = { + "ftw.live.read": "See power, battery and the car right now", + "ftw.plan.read": "See the plan and why it looks that way", + "ftw.history.read": "See past readings", + "ftw.health.read": "See how the box and its devices are doing", + "ftw.events.read": "See alerts and events", + "ftw.logs.read": "Read the box's logs, with names and secrets taken out", + "ftw.config.read": "Read the settings, with names and secrets taken out", + "ftw.diag.run": "Run checks that change nothing", + "ftw.dispatch.hold": "Act at once, for a few minutes at a time, within these limits", + "ftw.goals.request": "Ask for lasting changes, each waiting for your yes here", + }; + + var OP_WORDS = { + "loadpoint.hold": "Charge the car now", + "site.mode.set": "Change the operating mode", + "planner.prefs.set": "Change how the plan works", + "loadpoint.soc.set": "Correct the car's charge level", + "loadpoint.surplus_only.set": "Charge the car from solar only", + "car.goal.set": "Set how and when a car charges", + "car.soc.set": "Set a car's charge level", + }; + + var DAY_S = 24 * 3600; + + // The box's answers, kept while Settings is open: the grant on screen + // holds a code that worked once, and switching tabs must not lose it. + var state = { + list: null, // GET /api/agents + unavailable: "", // why the box has no agents, when it has none + auth: null, // GET /api/auth/status + held: null, // {raw, view, lookup, fetchedAt} + note: null, // {text, kind} under the code field + done: null, // what the last grant gave + editing: "", // the row being changed + }; + + // ---- pure helpers, tested in access.test.mjs ---- + + // A code as an owner may type it: any case, with a dash or spaces. + function normalizeCode(s) { + var code = String(s || "").toUpperCase().replace(/[\s-]/g, ""); + return /^[A-Z0-9]{8}$/.test(code) ? code : ""; + } + + // Text from outside the box. Control, direction, joining, variation and tag + // marks go, so it cannot turn the page's own words around it or hide + // letters, and it is cut to max characters. + function cleanText(s, max) { + var text = String(s || "") + .replace(/[\u0000-\u001f\u007f-\u009f\u00ad\u034f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufe00-\ufe0f\ufeff\u{e0000}-\u{e007f}]/gu, "") + .trim(); + // By code point, so a cut never splits an emoji into half a character. + var chars = Array.from(text); + return chars.length > max ? chars.slice(0, max - 1).join("") + "…" : text; + } + + // An assistant names itself, at a length a row can hold. Double quote marks + // go too: the page puts the name in quotes, and one in the name could end + // them early and make the page's own words look like the name's. + function cleanName(s) { return cleanText(String(s || "").replace(/["“”„‟«»″"]/g, ""), 64); } + + // A name like Sourceful's or FTW's, from a client FTW Cloud does not know. + function borrowsOurName(name, verified) { + return !verified && /sourceful|ftw/i.test(name); + } + + function scopeWords(scope) { return SCOPE_WORDS[scope] || scope; } + function opWords(op) { return OP_WORDS[op] || op; } + + // The host a row's client id names, when the id is a metadata document's + // address. A client that registered itself has an id that names nothing. + function clientHost(clientId) { + var m = /^https:\/\/([^/?#:]+)/i.exec(String(clientId || "")); + return m ? m[1].toLowerCase() : ""; + } + + // Splits what an offer or a row holds into what it reads and what it may + // change, by the box's own list of scopes that change something. Control + // ops go under the scope that opens them; a control scope with no op + // grants nothing, so it is left out. + function splitGrant(scopes, ops, registry) { + var write = (registry && registry.writeScopes) || []; + var agentOps = (registry && registry.agentOps) || {}; + var reads = [], groups = []; + // In the page's order, which runs from now to the past to the box itself; + // a scope the page has no words for goes last. + var order = Object.keys(SCOPE_WORDS); + var rank = function (scope) { var i = order.indexOf(scope); return i < 0 ? order.length : i; }; + (scopes || []).slice().sort(function (a, b) { return rank(a) - rank(b); }).forEach(function (scope) { + if (write.indexOf(scope) < 0) { + reads.push(scope); + return; + } + var opOrder = Object.keys(OP_WORDS); + var opRank = function (op) { var i = opOrder.indexOf(op); return i < 0 ? opOrder.length : i; }; + var under = (ops || []).filter(function (op) { return agentOps[op] === scope; }) + .sort(function (a, b) { return opRank(a) - opRank(b); }); + if (under.length) groups.push({ scope: scope, ops: under }); + }); + return { reads: reads, groups: groups }; + } + + // An op runs at once, under the grant's caps, when one of its arguments is + // bounded by a cap; the others wait for the owner. + function capped(op, registry) { + var args = ((registry && registry.agentArgs) || {})[op] || []; + return args.some(function (a) { return !!a.cap; }); + } + + function sortedUnique(list) { + var out = []; + list.slice().sort().forEach(function (v) { if (out[out.length - 1] !== v) out.push(v); }); + return out; + } + + // The grant the owner chose, as POST /api/agents and /widen take it. choice + // is {reads, ops, holdMin, maxKW, ttlS}. The scopes are the reads ticked and + // the scope of each op ticked; holds need caps, and caps without holds are + // zero, as the box insists. + function chosenGrant(choice, perMin, registry) { + var agentOps = (registry && registry.agentOps) || {}; + var ops = sortedUnique(choice.ops || []); + var scopes = (choice.reads || []).slice(); + ops.forEach(function (op) { if (agentOps[op]) scopes.push(agentOps[op]); }); + scopes = sortedUnique(scopes); + var caps = { holdS: 0, maxW: 0, perMin: perMin || 0 }; + if (ops.some(function (op) { return capped(op, registry); })) { + caps.holdS = Math.round((Number(choice.holdMin) || 0) * 60); + caps.maxW = Math.round((Number(choice.maxKW) || 0) * 1000); + } + return { scopes: scopes, ops: ops, caps: caps, ttlS: Math.floor(Number(choice.ttlS) || 0) }; + } + + // What is wrong with a choice before the box is asked, or "". + function choiceProblem(grant, limits) { + if (!grant.scopes.length) return "Tick at least one thing it may do."; + if (grant.caps.holdS || grant.caps.maxW) { + if (grant.caps.holdS < 60 || grant.caps.holdS > limits.holdS) { + return "A hold lasts from 1 to " + Math.floor(limits.holdS / 60) + " minutes."; + } + if (grant.caps.maxW < 1 || grant.caps.maxW > limits.maxW) { + return "The power goes up to " + kwExact(limits.maxW) + " kW."; + } + } + if (grant.ttlS < 60) return "Choose how long it may last."; + return ""; + } + + function kw(w) { + var v = (Number(w) || 0) / 1000; + return v % 1 === 0 ? String(v) : v.toFixed(1); + } + + // A power cap in kW, exact to the watt, for a field the owner edits: a cap + // of 3,680 W must read back as 3,680 W, not as 3.7 kW the box refuses. + function kwExact(w) { + return String(Math.round(Number(w) || 0) / 1000); + } + + // How long a grant may last: the lengths the page offers, none longer than + // the offer asked for. + function lengthOptions(maxS) { + var out = [DAY_S, 7 * DAY_S, 30 * DAY_S].filter(function (s) { return s <= maxS; }); + if (!out.length && maxS >= 60) out.push(maxS); + return out; + } + + // The length chosen until the owner picks another: a week, or less when the + // offer asks for less. A week is long enough to try an assistant and short + // enough that one forgotten here ends; renewing is a button on this page. + function defaultLength(options) { + var week = options.filter(function (s) { return s <= 7 * DAY_S; }); + return week.length ? week[week.length - 1] : options[0]; + } + + function lengthText(s) { + 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"; + } + + function whenText(ms) { + if (!ms) return "—"; + return new Date(ms).toLocaleString(undefined, { + day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", + }); + } + + function clockText(ms) { + return new Date(ms).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + } + + function agoText(ms, nowMs) { + if (!ms) return "not used yet"; + var d = (nowMs || Date.now()) - ms; + if (d < 90 * 1000) return "used just now"; + if (d < 90 * 60 * 1000) return "used " + Math.round(d / 60000) + " min ago"; + if (d < 36 * 3600 * 1000) return "used " + Math.round(d / 3600000) + " h ago"; + return "used " + Math.round(d / 86400000) + " days ago"; + } + + // What stops a grant before the owner types a code, or null. The order is + // the order to fix them in. + function readiness(list, unavailable, auth) { + if (unavailable) return { kind: "unavailable" }; + if (!list) return { kind: "loading" }; + if (!auth || !auth.lan_auth || !auth.configured) return { kind: "password" }; + var box = list.box || {}; + // The box's answer for this very request: on the box itself, or through + // an SSH tunnel to it, the house password does not count. + if (!box.widen) return { kind: "here" }; + if (!box.offers) return { kind: "keys" }; + if (box.clock === "behind") return { kind: "behind", nowMs: box.nowMs, markMs: box.markMs }; + if (box.clock !== "ok") return { kind: "clock" }; + if (!box.lookup) return { kind: "keys" }; + return null; + } + + var KEYS_TEXT = "This version of FTW cannot let assistants in yet: it does not know FTW " + + "Cloud's signing key. An update brings it."; + var HERE_TEXT = "Open this page from another device on your home network, and sign in with " + + "the house password there. On the box itself, or through a tunnel to it, the password " + + "does not count, because anything running on the box could use it too."; + var CLOCK_TEXT = "The box's clock is not set, so it cannot time an assistant's access. It sets " + + "itself over the internet; check that the box is online."; + + // Why FTW Cloud found nothing for a code, by its answer. + function lookupFailureText(status) { + if (status === 404) { + return "No request is waiting with that code. Check it against the assistant's page. " + + "A code works once, for ten minutes; if it ran out, connect again from the " + + "assistant for a new one."; + } + if (status === 429) return "Too many tries. Wait a minute, then try again."; + if (status === 0) { + // A browser hides why a request to another site failed, so a lookup + // FTW Cloud refused without saying who may read the answer looks the + // same as no network at all. + return "No answer from FTW Cloud. Check the code against the assistant's page, and " + + "that this device is online, then try again."; + } + return "FTW Cloud answered " + status + ". Try again in a moment."; + } + + // Why the box will not take an offer, from its answer. lookup is what FTW + // Cloud said with the offer; nowMs is this device's clock. + function offerRefusalText(body, lookup, nowMs) { + body = body || {}; + switch (body.reason) { + case "no_offer_keys": + return KEYS_TEXT; + case "unsigned": + return "FTW Cloud's signature on this offer does not match a key this box knows. If " + + "FTW is up to date, connect again from the assistant for a new code."; + case "expired": { + var cloudEnd = lookup && lookup.expiresAtMs; + var boxMs = Number(body.boxMs) || 0; + // FTW Cloud and this device still count the offer as good, and the + // box reads another time: the box's clock is off, not the offer. + if (cloudEnd && nowMs < cloudEnd && boxMs && Math.abs(boxMs - nowMs) > 60 * 1000) { + return "The box's clock reads " + clockText(boxMs) + ", but this device reads " + + clockText(nowMs) + ", so the box counts the offer as run out. The box sets its " + + "clock over the internet; check that it is online, then connect again from the " + + "assistant for a new code."; + } + return "This offer has run out. Connect again from the assistant for a new code."; + } + case "other_home": + return "This code is for another FTW home. Type it on the page of the home you meant " + + "to connect."; + case "malformed": + return "The box cannot read this offer. Connect again from the assistant for a new " + + "code; if it happens again, FTW may need an update."; + } + return refusalText(body); + } + + // A refusal the box gave as a code, or a sentence. + function refusalText(body) { + body = body || {}; + var args = body.args || {}; + switch (body.error) { + case "E_UNAVAILABLE": + return args.subsystem === "clock" ? CLOCK_TEXT : "The box cannot do that right now."; + case "E_CAP_EXCEEDED": + if (args.cap === "agents") return "This box already lets in " + args.max + " assistants. Remove one first."; + if (args.cap === "expiresAtMs") return "That is longer than this kind of access may last."; + if (args.cap === "holdS") return "A hold lasts from 1 to " + Math.floor((args.max || 1800) / 60) + " minutes."; + if (args.cap === "maxW") return "The power is out of range."; + return "That is past a limit the box keeps."; + case "E_UNKNOWN_OP": + return args.field === "key" + ? "This assistant already has access here." + : "The box did not accept that grant."; + case "E_NOT_FOUND": + return "That is gone already. The list below is up to date."; + case "E_CONFLICT": + return "Someone changed this access a moment ago. Look at it again, then change it."; + case "E_SCOPE_DENIED": + case "E_OP_NOT_GRANTED": + return "The box did not accept that grant."; + } + return typeof body.error === "string" && body.error ? body.error : "That did not work."; + } + + // A request's arguments, in the registry's order and by the labels it + // gives them, as the box sent them. One the registry does not name comes + // last, by its own name. + function requestArgs(req, registry) { + var known = ((registry && registry.agentArgs) || {})[req.op] || []; + var args = req.args || {}; + var out = []; + known.forEach(function (a) { + if (Object.prototype.hasOwnProperty.call(args, a.name)) { + out.push({ label: a.label || a.name, value: argValue(args[a.name], a) }); + } + }); + Object.keys(args).sort().forEach(function (name) { + if (!known.some(function (a) { return a.name === name; })) out.push({ label: cleanText(name, 40), value: argValue(args[name], {}) }); + }); + return out; + } + + function argValue(v, arg) { + if (typeof v === "boolean") return v ? "yes" : "no"; + if (typeof v !== "number") return cleanText(v, 80); + if (!Number.isFinite(v)) return "?"; + if (arg.kind === "ratio") return Math.round(v * 100) + " %"; + if (arg.unit === "W") return kw(v) + " kW"; + if (arg.unit === "s") return Math.round(v / 60) + " min"; + // The box's convention: a time of day is minutes after midnight, in an + // argument named _min. + if (/_min$/.test(arg.name || "") && v >= 0 && v < 1440) { + return String(Math.floor(v / 60)).padStart(2, "0") + ":" + String(v % 60).padStart(2, "0"); + } + return String(v); + } + + // How a request the owner approved ended, from the box's command result. + function resultText(answer) { + answer = answer || {}; + if (answer.state === "declined") return "Declined. The assistant hears that you said no."; + var r = answer.result || {}; + var st = r.State || r.state || ""; + var err = r.Error || r.error || {}; + if (st === "applied") return "Approved. The box made the change."; + if (st === "rejected") return "Approved, but the box refused the change" + (err.Code || err.code ? " (" + (err.Code || err.code) + ")." : "."); + if (st === "unconfirmed") return "Approved. The box sent the change but could not confirm it took."; + return "Approved." + (st ? " Outcome: " + st + "." : ""); + } + + // ---- talking to the box ---- + + function readJSON(r) { + return r.json().catch(function () { return {}; }); + } + + function load() { + return Promise.all([ + fetch("/api/agents").then(function (r) { + return readJSON(r).then(function (body) { + if (r.status === 503) return { unavailable: true }; + if (!r.ok) throw new Error(body.error || "HTTP " + r.status); + return body; + }); + }), + fetch("/api/auth/status").then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }), + ]).then(function (got) { + state.unavailable = got[0].unavailable ? "off" : ""; + state.list = got[0].unavailable ? null : got[0]; + state.auth = got[1]; + }); + } + + function postJSON(path, body) { + return fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + // ---- drawing ---- + + function el(tag, attrs, children) { + var node = document.createElement(tag); + Object.keys(attrs || {}).forEach(function (k) { + if (k === "text") node.textContent = attrs[k]; + else if (k === "className") node.className = attrs[k]; + else if (k.indexOf("on") === 0) node.addEventListener(k.slice(2), attrs[k]); + else node.setAttribute(k, attrs[k]); + }); + (children || []).forEach(function (c) { if (c) node.appendChild(c); }); + return node; + } + + function quoted(name) { + return "“" + (cleanName(name) || "An assistant") + "”"; + } + + function draw(ctx) { + drawReady(ctx); + drawHeld(); + drawRequests(); + drawRows(); + } + + function drawReady(ctx) { + var slot = document.getElementById("access-ready"); + var input = document.getElementById("access-code"); + var button = document.getElementById("access-lookup"); + if (!slot) return; + slot.textContent = ""; + var problem = readiness(state.list, state.unavailable, state.auth); + var blocked = !!problem; + if (input) input.disabled = blocked; + if (button) button.disabled = blocked; + if (!problem) return; + + var box = el("div", { className: "access-blocked", role: "status" }); + slot.appendChild(box); + switch (problem.kind) { + case "loading": + box.className = "access-quiet"; + box.textContent = "Checking the box…"; + return; + case "unavailable": + box.appendChild(el("p", { text: "Assistants reach this box over the same encrypted link as the " + + "FTW app, and it is off. Turn on “Let the FTW app connect to this box” under FTW app, " + + "save, and restart." })); + box.appendChild(el("button", { type: "button", className: "btn-send", text: "Open FTW app settings", + onclick: function () { ctx.navigateTab("app"); } })); + return; + case "password": + box.appendChild(el("p", { text: "Turn on the house password first. Without it, any device on " + + "your Wi-Fi could let an assistant into your home." })); + box.appendChild(el("button", { type: "button", className: "btn-send", text: "Set a house password", + onclick: function () { ctx.navigateTab("system"); } })); + box.appendChild(el("p", { className: "access-quiet", text: "It is under System, LAN password. " + + "Come back here once it is on." })); + return; + case "here": + box.appendChild(el("p", { text: HERE_TEXT })); + return; + case "keys": + box.appendChild(el("p", { text: KEYS_TEXT })); + return; + case "clock": + box.appendChild(el("p", { text: CLOCK_TEXT })); + return; + case "behind": + box.appendChild(el("p", { text: "The box's clock reads " + whenText(problem.nowMs) + + ", which is before " + whenText(problem.markMs) + ", a time it has already seen. " + + "Assistants stay locked out until the clock passes that time, so a clock set back " + + "cannot bring back access that ran out." })); + box.appendChild(el("p", { text: "If the clock is right now, reset the mark. Every assistant " + + "loses its access and must be let in again." })); + var reset = el("button", { type: "button", className: "btn-ghost", text: "The clock is right: reset the mark" }); + reset.addEventListener("click", function () { + if (!confirm("Reset the clock mark? Every assistant loses its access now.")) return; + reset.disabled = true; + postJSON("/api/agents/clock-reset", {}).then(function (r) { + if (r.ok) return refresh(ctx); + return readJSON(r).then(function (body) { + box.appendChild(el("p", { className: "access-error", text: refusalText(body) })); + reset.disabled = false; + }); + }).catch(function () { reset.disabled = false; }); + }); + box.appendChild(reset); + return; + } + } + + function setNote(text, kind) { + state.note = text ? { text: text, kind: kind || "" } : null; + var slot = document.getElementById("access-note"); + if (!slot) return; + slot.textContent = state.note ? state.note.text : ""; + slot.className = "access-note" + (state.note && state.note.kind ? " access-" + state.note.kind : ""); + } + + function lookUp(ctx) { + var input = document.getElementById("access-code"); + var button = document.getElementById("access-lookup"); + if (!input || !button || !state.list) return; + var code = normalizeCode(input.value); + state.done = null; + if (!code) { + setNote("A code has eight letters and digits, like ABCD-EFGH.", "error"); + input.focus(); + return; + } + button.disabled = true; + setNote("Asking FTW Cloud…"); + var lookup = null; + fetch(state.list.box.lookup + "?code=" + encodeURIComponent(code), { + cache: "no-store", credentials: "omit", referrerPolicy: "no-referrer", + }) + .catch(function () { return { ok: false, status: 0 }; }) + .then(function (r) { + if (!r.ok) throw new Error(lookupFailureText(r.status)); + return r.json(); + }) + .then(function (body) { + lookup = body || {}; + if (lookup.status && lookup.status !== "pending") { + throw new Error("That request is " + cleanText(lookup.status, 20) + ". Connect again from the assistant for a new code."); + } + if (!lookup.offer) throw new Error("FTW Cloud sent no offer for that code."); + setNote("Asking the box to check it…"); + return postJSON("/api/agents/offer", { offer: lookup.offer }); + }) + .then(function (r) { + return readJSON(r).then(function (body) { + if (!r.ok) throw new Error(offerRefusalText(body, lookup, Date.now())); + state.held = { raw: lookup.offer, view: body, lookup: lookup, fetchedAt: Date.now() }; + input.value = ""; + setNote(""); + drawHeld(); + }); + }) + .catch(function (e) { + setNote(e.message || String(e), "error"); + }) + .then(function () { + button.disabled = !!readiness(state.list, state.unavailable, state.auth); + }); + } + + function checkRow(name, value, label, checked, extra) { + var box = el("input", { type: "checkbox", name: name, value: value }); + box.checked = !!checked; + return el("label", { className: "checkbox-row access-check" }, [box, el("span", { text: label }), extra || null]); + } + + function numberField(id, label, value, max, min, step, unit) { + var input = el("input", { type: "number", id: id, min: String(min), max: String(max), step: String(step), value: String(value) }); + return el("div", { className: "access-cap" }, [ + el("label", { for: id, text: label }), + el("div", { className: "access-cap-input" }, [input, el("span", { className: "access-quiet", + text: unit + ", at most " + Number(max).toLocaleString(undefined, { maximumFractionDigits: 3 }) })]), + ]); + } + + // The choices for a grant: reads ticked, control off until the owner turns + // it on, caps no higher than given, and a length. limits are the most it + // may be given; current, when set, is a row being changed. + function grantForm(form, given, registry, current) { + var split = splitGrant(given.scopes, given.ops, registry); + form.appendChild(el("p", { className: "access-heading", text: current ? "It may" : "It asks to" })); + split.reads.forEach(function (scope) { + form.appendChild(checkRow("read", scope, scopeWords(scope), true)); + }); + + if (split.groups.length) { + var controlOn = !!current; + var control = el("input", { type: "checkbox", id: "access-control-" + (current ? current.id : "new") }); + control.checked = controlOn; + var label = current ? "It may control" : "Also let it control"; + form.appendChild(el("label", { className: "checkbox-row access-control-toggle" }, [control, el("span", { text: label })])); + if (!current) { + form.appendChild(el("p", { className: "access-quiet", text: "Off unless you turn it on. A hold is " + + "short and capped, and a lasting change waits for your yes here." })); + } + var detail = el("div", { className: "access-control" }); + split.groups.forEach(function (g) { + detail.appendChild(el("p", { className: "access-subheading", text: scopeWords(g.scope) })); + g.ops.forEach(function (op) { detail.appendChild(checkRow("op", op, opWords(op), true)); }); + if (g.ops.some(function (op) { return capped(op, registry); })) { + var holdMin = Math.floor((given.caps.holdS || 0) / 60); + detail.appendChild(numberField("access-hold-" + (current ? current.id : "new"), "Longest hold", + holdMin, holdMin, 1, 1, "minutes")); + detail.appendChild(numberField("access-power-" + (current ? current.id : "new"), "Highest power", + kwExact(given.caps.maxW), kwExact(given.caps.maxW), 0.001, "any", "kW")); + } + }); + if (given.caps.perMin) { + detail.appendChild(el("p", { className: "access-quiet", text: "At most " + given.caps.perMin + + " holds or requests a minute." })); + } + detail.hidden = !controlOn; + control.addEventListener("change", function () { detail.hidden = !control.checked; }); + form.appendChild(detail); + } + return { perMin: given.caps.perMin || 0 }; + } + + function readChoice(form, current) { + var reads = [], ops = []; + form.querySelectorAll('input[name="read"]').forEach(function (b) { if (b.checked) reads.push(b.value); }); + var control = form.querySelector('[id^="access-control-"]'); + if (!control || control.checked) { + form.querySelectorAll('input[name="op"]').forEach(function (b) { if (b.checked) ops.push(b.value); }); + } + var suffix = current ? current.id : "new"; + var hold = document.getElementById("access-hold-" + suffix); + var power = document.getElementById("access-power-" + suffix); + var length = form.querySelector('select[name="length"]'); + return { + reads: reads, ops: ops, + holdMin: hold ? hold.value : 0, maxKW: power ? power.value : 0, + length: length ? length.value : "", + }; + } + + function drawHeld() { + var slot = document.getElementById("access-offer"); + if (!slot) return; + slot.textContent = ""; + if (state.done) { + slot.appendChild(el("div", { className: "access-card access-done", role: "status" }, [ + el("p", { text: quoted(state.done.client) + " can reach this home until " + whenText(state.done.until) + "." }), + el("p", { className: "access-quiet", text: "The assistant's page moves on by itself once FTW Cloud " + + "hears from this box. It is listed below, where you can change or remove it." }), + ])); + return; + } + var held = state.held; + if (!held) return; + var v = held.view; + var registry = (state.list && state.list.registry) || {}; + var name = cleanName(v.client); + var card = el("form", { className: "access-card" }); + slot.appendChild(card); + + var title = v.role === "support" + ? "Let Sourceful support into your home, for ticket " + cleanName(v.ticket) + "?" + : "Let " + quoted(name) + " into your home?"; + card.appendChild(el("h3", { className: "access-title", text: title })); + card.appendChild(el("p", { + className: v.verified ? "access-quiet" : "access-warn", + text: v.verified + ? "A client FTW Cloud knows, from " + (v.clientHost || "an address it checked") + "." + : "FTW Cloud does not know this client. The name is what it calls itself" + + (v.clientHost ? "; it comes from " + v.clientHost : "") + ". Let it in only if you just connected it.", + })); + if (borrowsOurName(name, v.verified)) { + card.appendChild(el("p", { className: "access-warn", text: "It uses a name like Sourceful's or FTW's. " + + "Neither asks for access this way." })); + } + card.appendChild(el("p", { className: "access-quiet", text: "Code " + (v.code ? v.code.slice(0, 4) + "-" + v.code.slice(4) : "") + + ". Check that it matches the assistant's page." })); + + var form = grantForm(card, v, registry, null); + var lengths = lengthOptions(v.ttlS || 0); + var chosen = defaultLength(lengths); + var select = el("select", { name: "length", id: "access-length" }); + lengths.forEach(function (s) { + var opt = el("option", { value: String(s), text: lengthText(s) }); + if (s === chosen) opt.selected = true; + select.appendChild(opt); + }); + card.appendChild(el("label", { for: "access-length", text: "For how long" })); + card.appendChild(select); + card.appendChild(el("p", { className: "access-quiet", text: "Decide before " + clockText(v.expMs) + + ", or ask the assistant for a new code. You can remove it here at any time." })); + + var err = el("p", { className: "access-error", role: "alert" }); + var give = el("button", { type: "submit", className: "btn-send", text: "Give access" }); + // The offer stays while something above stops a grant, such as the house + // password turned off since: its code worked once. + give.disabled = !!readiness(state.list, state.unavailable, state.auth); + var not = el("button", { type: "button", className: "btn-ghost", text: "Not now" }); + card.appendChild(el("div", { className: "access-actions" }, [give, not])); + card.appendChild(err); + not.addEventListener("click", function () { + state.held = null; + drawHeld(); + }); + card.addEventListener("submit", function (e) { + e.preventDefault(); + var c = readChoice(card, null); + var grant = chosenGrant({ reads: c.reads, ops: c.ops, holdMin: c.holdMin, maxKW: c.maxKW, ttlS: c.length }, + form.perMin, registry); + var why = choiceProblem(grant, v.caps); + if (why) { + err.textContent = why; + return; + } + err.textContent = ""; + give.disabled = true; + postJSON("/api/agents", { offer: held.raw, scopes: grant.scopes, ops: grant.ops, caps: grant.caps, ttlS: grant.ttlS }) + .then(function (r) { + return readJSON(r).then(function (body) { + if (!r.ok) throw new Error(body.reason ? offerRefusalText(body, held.lookup, Date.now()) : refusalText(body)); + state.held = null; + state.done = { client: v.role === "support" ? "Sourceful support" : name, until: body.expiresAtMs }; + return refresh(); + }); + }) + .catch(function (e) { + err.textContent = e.message || String(e); + give.disabled = false; + }); + }); + } + + function rowName(row) { + var p = row.principal || {}; + if (row.role === "support") return "Sourceful support" + (p.ticket ? ", ticket " + cleanName(p.ticket) : ""); + if (row.role === "release") return "The release check"; + return quoted(p.client); + } + + function boxNow() { + var box = (state.list && state.list.box) || {}; + return box.nowMs ? box.nowMs + (Date.now() - (state.list._at || Date.now())) : Date.now(); + } + + // Renewing, narrowing and approving need the house password, as granting + // does. Without it the buttons say so rather than meet a refusal. + function canWiden() { + return !!(state.list && state.list.box && state.list.box.widen); + } + + function drawRows() { + var slot = document.getElementById("access-rows"); + if (!slot) return; + slot.textContent = ""; + var rows = (state.list && state.list.agents) || []; + var section = document.getElementById("access-rows-section"); + if (section) section.hidden = !state.list; + if (!rows.length) { + slot.appendChild(el("p", { className: "access-quiet", text: "No assistant has access." })); + return; + } + var registry = state.list.registry || {}; + var now = boxNow(); + rows.slice().sort(function (a, b) { return (b.lastSeenMs || 0) - (a.lastSeenMs || 0); }).forEach(function (row) { + var split = splitGrant(row.scopes, row.ops, registry); + var item = el("div", { className: "access-row" }); + var host = clientHost((row.principal || {}).clientId); + item.appendChild(el("div", { className: "access-row-head" }, [ + el("span", { className: "access-row-name", text: rowName(row) }), + host ? el("span", { className: "access-quiet mono", text: host }) : null, + ])); + var ended = row.expiresAtMs && row.expiresAtMs <= now; + item.appendChild(el("p", { className: "access-quiet", text: [ + split.groups.length ? "Reads and controls" : "Reads", + ended ? "ran out " + whenText(row.expiresAtMs) : "until " + whenText(row.expiresAtMs), + agoText(row.lastSeenMs, now), + row.approvedBy === "lan" ? "let in on this page" : "let in from a phone", + ].join(" · ") })); + + var list = el("ul", { className: "access-may" }); + split.reads.forEach(function (s) { list.appendChild(el("li", { text: scopeWords(s) })); }); + split.groups.forEach(function (g) { + g.ops.forEach(function (op) { list.appendChild(el("li", { text: opWords(op) })); }); + }); + item.appendChild(el("details", {}, [el("summary", { text: "What it may do" }), list])); + + var actions = el("div", { className: "access-actions" }); + var change = el("button", { type: "button", className: "btn-ghost", text: state.editing === row.id ? "Close" : "Renew or narrow" }); + change.disabled = !canWiden(); + if (change.disabled) change.title = "Changing access needs another device on your network, signed in with the house password."; + change.addEventListener("click", function () { + state.editing = state.editing === row.id ? "" : row.id; + drawRows(); + }); + var remove = el("button", { type: "button", className: "btn-remove", text: "Remove" }); + remove.addEventListener("click", function () { + if (!confirm("Remove " + rowName(row) + "? It loses access at once, and any hold it set ends.")) return; + remove.disabled = true; + fetch("/api/agents/" + encodeURIComponent(row.id), { method: "DELETE" }).then(function (r) { + if (r.ok || r.status === 404) return refresh(); + return readJSON(r).then(function (body) { + item.appendChild(el("p", { className: "access-error", text: refusalText(body) })); + remove.disabled = false; + }); + }).catch(function () { remove.disabled = false; }); + }); + actions.appendChild(change); + actions.appendChild(remove); + item.appendChild(actions); + if (state.editing === row.id) item.appendChild(editForm(row, registry, now)); + slot.appendChild(item); + }); + } + + // Renew or narrow one row. It may keep what it has or less of it, for as + // long as before or from now. Widening past what it has is an offer's job. + function editForm(row, registry, now) { + var form = el("form", { className: "access-card access-edit" }); + var given = { scopes: row.scopes || [], ops: row.ops || [], caps: row.caps || {} }; + var f = grantForm(form, given, registry, row); + 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) { + select.appendChild(el("option", { value: String(s), text: lengthText(s) + " from now" })); + }); + form.appendChild(el("label", { for: select.id, text: "How long" })); + form.appendChild(select); + var err = el("p", { className: "access-error", role: "alert" }); + var save = el("button", { type: "submit", className: "btn-send", text: "Save" }); + form.appendChild(el("div", { className: "access-actions" }, [save])); + form.appendChild(err); + form.addEventListener("submit", function (e) { + e.preventDefault(); + var c = readChoice(form, row); + var ttlS = c.length === "keep" ? Math.floor((row.expiresAtMs - boxNow()) / 1000) : Number(c.length); + var grant = chosenGrant({ reads: c.reads, ops: c.ops, holdMin: c.holdMin, maxKW: c.maxKW, ttlS: ttlS }, + f.perMin, registry); + var why = choiceProblem(grant, { holdS: (row.caps || {}).holdS || 0, maxW: (row.caps || {}).maxW || 0 }); + if (why) { + err.textContent = why; + return; + } + save.disabled = true; + postJSON("/api/agents/" + encodeURIComponent(row.id) + "/widen", { + epoch: row.epoch, scopes: grant.scopes, ops: grant.ops, caps: grant.caps, ttlS: grant.ttlS, + }).then(function (r) { + return readJSON(r).then(function (body) { + if (!r.ok) throw new Error(refusalText(body)); + state.editing = ""; + return refresh(); + }); + }).catch(function (e2) { + err.textContent = e2.message || String(e2); + save.disabled = false; + }); + }); + return form; + } + + function drawRequests() { + var slot = document.getElementById("access-requests"); + var section = document.getElementById("access-requests-section"); + if (!slot || !section) return; + slot.textContent = ""; + var pending = ((state.list && state.list.requests) || []).filter(function (r) { return r.state === "pending"; }); + section.hidden = pending.length === 0; + var registry = (state.list && state.list.registry) || {}; + var rows = (state.list && state.list.agents) || []; + pending.forEach(function (req) { + var row = rows.filter(function (a) { return a.id === req.agent; })[0]; + // Two assistants may call themselves the same, so the host goes beside + // the name, as in the list below. + var host = row ? clientHost((row.principal || {}).clientId) : ""; + var who = (row ? rowName(row) : "An assistant") + (host ? " (" + host + ")" : ""); + var item = el("div", { className: "access-row access-request" }); + item.appendChild(el("p", { className: "access-row-name", text: who + " asks to: " + opWords(req.op).replace(/^./, function (c) { return c.toLowerCase(); }) })); + var args = requestArgs(req, registry); + if (args.length) { + var dl = el("dl", { className: "access-args" }); + args.forEach(function (a) { + dl.appendChild(el("dt", { text: a.label })); + dl.appendChild(el("dd", { text: a.value })); + }); + item.appendChild(dl); + } + if (req.why) { + item.appendChild(el("p", { className: "access-why" }, [ + el("span", { className: "access-quiet", text: "In its own words: " }), + el("q", { text: cleanText(req.why, 280) }), + ])); + } + item.appendChild(el("p", { className: "access-quiet", text: "Waits until " + whenText(req.expiresAtMs) + "." })); + var answer = el("p", { className: "access-quiet", role: "status" }); + var approve = el("button", { type: "button", className: "btn-send", text: "Approve" }); + var decline = el("button", { type: "button", className: "btn-ghost", text: "Decline" }); + approve.disabled = !canWiden(); + if (approve.disabled) approve.title = "Approving needs another device on your network, signed in with the house password."; + function decide(yes) { + approve.disabled = decline.disabled = true; + postJSON("/api/agent-requests/" + encodeURIComponent(req.reqId), { approve: yes }).then(function (r) { + return readJSON(r).then(function (body) { + if (r.status === 404) { + answer.className = "access-quiet"; + answer.textContent = "That request is no longer waiting: it was answered or ran out."; + setTimeout(function () { refresh(); }, 2500); + return; + } + if (!r.ok) throw new Error(refusalText(body)); + answer.className = "access-done"; + answer.textContent = resultText(body); + setTimeout(function () { refresh(); }, 2500); + }); + }).catch(function (e) { + answer.className = "access-error"; + answer.textContent = e.message || String(e); + approve.disabled = !canWiden(); + decline.disabled = false; + }); + } + approve.addEventListener("click", function () { decide(true); }); + decline.addEventListener("click", function () { decide(false); }); + item.appendChild(el("div", { className: "access-actions" }, [approve, decline])); + item.appendChild(answer); + slot.appendChild(item); + }); + } + + var lastCtx = null; + + function refresh(ctx) { + if (ctx) lastCtx = ctx; + return load().then(function () { + if (state.list) state.list._at = Date.now(); + if (document.getElementById("access-ready")) draw(lastCtx); + }).catch(function (e) { + var slot = document.getElementById("access-ready"); + if (slot) slot.textContent = "Could not read the box: " + (e.message || e); + }); + } + + S.tabs.access = { + render: function () { + return ( + '
' + + "Assistants" + + '

Let an AI assistant, such as Claude or ChatGPT, read this home and, ' + + "if you allow it, control it within limits. It connects through FTW Cloud. This box " + + "decides what it may do and checks every call. No phone app is needed.

" + + '
' + + '
' + + '' + + '
' + + '' + + '' + + "
" + + '

Connect FTW in your assistant. FTW Cloud then shows a page with a ' + + "code. Type it here. A code works once, for ten minutes.

" + + '

' + + "
" + + '
' + + "
" + + '" + + '
' + + "Assistants with access" + + '

Checking the box…

' + + "
" + ); + }, + + after: function (ctx) { + var form = document.getElementById("access-code-form"); + if (form) { + form.addEventListener("submit", function (e) { + e.preventDefault(); + lookUp(ctx); + }); + } + if (state.note) setNote(state.note.text, state.note.kind); + draw(ctx); + refresh(ctx); + }, + }; + + S.tabs.access._pure = { + SCOPE_WORDS: SCOPE_WORDS, + OP_WORDS: OP_WORDS, + normalizeCode: normalizeCode, + cleanName: cleanName, + capped: capped, + borrowsOurName: borrowsOurName, + clientHost: clientHost, + splitGrant: splitGrant, + chosenGrant: chosenGrant, + choiceProblem: choiceProblem, + lengthOptions: lengthOptions, + defaultLength: defaultLength, + kwExact: kwExact, + readiness: readiness, + lookupFailureText: lookupFailureText, + offerRefusalText: offerRefusalText, + refusalText: refusalText, + requestArgs: requestArgs, + resultText: resultText, + }; +})(); diff --git a/web/settings/tabs/access.test.mjs b/web/settings/tabs/access.test.mjs new file mode 100644 index 000000000..1c8bd327f --- /dev/null +++ b/web/settings/tabs/access.test.mjs @@ -0,0 +1,310 @@ +// node --test web/settings/tabs/access.test.mjs +// +// The Access tab lets an owner with no phone app give an AI assistant a way +// into the house. These tests hold the parts that decide what is granted and +// what the owner is told: the grant is never wider than the owner chose, the +// box's names come from the box, and each refusal has words of its own. + +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +globalThis.window = {}; +await import("./access.js"); +const tab = globalThis.window.FTWSettings.tabs.access; +const P = tab._pure; + +const read = (path) => readFileSync(new URL(path, import.meta.url), "utf8"); +const registryYaml = read("../../../contract/registry.yaml"); +const index = read("../../index.html"); +const shell = read("../../settings.js"); + +// The registry slice GET /api/agents carries, as the box builds it. +const registry = { + agentOps: { + "site.mode.set": "ftw.goals.request", + "planner.prefs.set": "ftw.goals.request", + "loadpoint.hold": "ftw.dispatch.hold", + "loadpoint.soc.set": "ftw.goals.request", + "loadpoint.surplus_only.set": "ftw.goals.request", + "car.goal.set": "ftw.goals.request", + "car.soc.set": "ftw.goals.request", + }, + writeScopes: ["ftw.dispatch.write", "ftw.mode.write", "ftw.dispatch.hold", "ftw.goals.request"], + agentArgs: { + "loadpoint.hold": [ + { name: "id", label: "Charger", kind: "loadpoint" }, + { name: "power_w", label: "Charging power", kind: "power", unit: "W", cap: "maxW" }, + { name: "hold_s", label: "For how long", kind: "duration", unit: "s", cap: "holdS" }, + ], + "car.goal.set": [ + { name: "id", label: "Car", kind: "car" }, + { name: "target", label: "The level to reach", kind: "ratio" }, + { name: "ready_by_min", label: "Ready by", kind: "number" }, + { name: "surplus_only", label: "Charge from solar only", kind: "bool" }, + ], + }, +}; + +describe("the page's words follow the registry", () => { + // The page owns its prose, as the app does. The names it keys them by are + // the registry's, so a renamed scope or op shows here rather than as a raw + // name on an owner's screen. + const agentScopes = /\n agent:\n label: [^\n]+\n scopes: \[([^\]]+)\]/.exec(registryYaml)[1] + .split(",").map((s) => s.trim()); + const agentOps = [...registryYaml.matchAll(/- \{ name: ([\w.]+), scope: [\w.]+, agent: ([\w.]+)/g)] + .map((m) => m[1]); + + it("has words for every scope an assistant may hold", () => { + assert.ok(agentScopes.length >= 8); + for (const scope of agentScopes) assert.ok(P.SCOPE_WORDS[scope], scope); + for (const scope of Object.keys(P.SCOPE_WORDS)) assert.ok(agentScopes.includes(scope), scope); + }); + + it("has words for every op an assistant may name", () => { + assert.ok(agentOps.length >= 5); + for (const op of agentOps) assert.ok(P.OP_WORDS[op], op); + for (const op of Object.keys(P.OP_WORDS)) assert.ok(agentOps.includes(op), op); + }); +}); + +describe("a code as an owner types it", () => { + it("takes any case, a dash and spaces", () => { + assert.equal(P.normalizeCode("abcd-efgh"), "ABCDEFGH"); + assert.equal(P.normalizeCode(" AB CD EF GH "), "ABCDEFGH"); + }); + + it("refuses what cannot be a code before FTW Cloud is asked", () => { + assert.equal(P.normalizeCode("abc"), ""); + assert.equal(P.normalizeCode("ABCD-EFGH-I"), ""); + assert.equal(P.normalizeCode("ABCD/EFGH"), ""); + }); +}); + +describe("names from outside", () => { + const rlo = String.fromCharCode(0x202e); + const zws = String.fromCharCode(0x200b); + + it("lose control and direction marks, and are cut to a row", () => { + assert.equal(P.cleanName("Helper " + rlo + "exe.pdf"), "Helper exe.pdf"); + assert.equal(P.cleanName("Cla" + zws + "ude\n"), "Claude"); + assert.equal(P.cleanName("x".repeat(100)).length, 64); + }); + + it("lose the marks that hide letters: soft hyphen, joiners, variation selectors and tags", () => { + const hidden = [0xad, 0x34f, 0x61c, 0xfe0f, 0xe0041, 0xe007f].map((c) => String.fromCodePoint(c)).join(""); + assert.equal(P.cleanName("Cl" + hidden + "aude"), "Claude"); + }); + + it("lose double quote marks, so a name cannot end the page's quotes early", () => { + assert.equal(P.cleanName('Claude" into your home? Yes. "Evil'), "Claude into your home? Yes. Evil"); + assert.equal(P.cleanName("“Claude”"), "Claude"); + assert.equal(P.cleanName("Fred's helper"), "Fred's helper"); + }); + + it("are cut by character, never through an emoji", () => { + const cut = P.cleanName("x".repeat(62) + String.fromCodePoint(0x1f600, 0x1f600, 0x1f600)); + assert.equal(Array.from(cut).length, 64); + assert.ok(cut.endsWith("…")); + assert.ok(!/[\ud800-\udbff]…$/.test(cut)); + }); + + it("are flagged when an unknown client borrows Sourceful's or FTW's name", () => { + assert.equal(P.borrowsOurName("FTW Helper", false), true); + assert.equal(P.borrowsOurName("sourceful support", false), true); + assert.equal(P.borrowsOurName("FTW Helper", true), false); + assert.equal(P.borrowsOurName("Claude", false), false); + }); + + it("show a client's host only when its id is a document's address", () => { + assert.equal(P.clientHost("https://claude.ai/oauth/mcp-oauth-client-metadata"), "claude.ai"); + assert.equal(P.clientHost("dcr_1b2c3d"), ""); + assert.equal(P.clientHost("http://evil.example/x"), ""); + }); +}); + +describe("what the owner grants", () => { + const offer = { + scopes: ["ftw.config.read", "ftw.dispatch.hold", "ftw.goals.request", "ftw.live.read", "ftw.plan.read"], + ops: ["car.goal.set", "loadpoint.hold", "site.mode.set"], + caps: { holdS: 1800, maxW: 11000, perMin: 6 }, + }; + + it("splits reads from control by the box's own list, in the page's order", () => { + const split = P.splitGrant(offer.scopes, offer.ops, registry); + assert.deepEqual(split.reads, ["ftw.live.read", "ftw.plan.read", "ftw.config.read"]); + assert.deepEqual(split.groups.map((g) => g.scope), ["ftw.dispatch.hold", "ftw.goals.request"]); + assert.deepEqual(split.groups[1].ops, ["site.mode.set", "car.goal.set"]); + }); + + it("leaves out a control scope with no op, which grants nothing", () => { + const split = P.splitGrant(["ftw.goals.request", "ftw.live.read"], [], registry); + assert.deepEqual(split.groups, []); + }); + + it("knows an op runs under caps by its capped arguments", () => { + assert.equal(P.capped("loadpoint.hold", registry), true); + assert.equal(P.capped("car.goal.set", registry), false); + }); + + it("grants reads alone with control off: no ops and no caps", () => { + const g = P.chosenGrant({ reads: ["ftw.plan.read", "ftw.live.read"], ops: [], ttlS: 86400 }, 0, registry); + assert.deepEqual(g, { + scopes: ["ftw.live.read", "ftw.plan.read"], ops: [], caps: { holdS: 0, maxW: 0, perMin: 0 }, ttlS: 86400, + }); + }); + + it("adds each ticked op's scope, sorted as the box insists, with the caps the owner set", () => { + const g = P.chosenGrant({ + reads: ["ftw.live.read"], ops: ["loadpoint.hold", "car.goal.set"], holdMin: "15", maxKW: "7.4", ttlS: "604800", + }, 6, registry); + assert.deepEqual(g.scopes, ["ftw.dispatch.hold", "ftw.goals.request", "ftw.live.read"]); + assert.deepEqual(g.ops, ["car.goal.set", "loadpoint.hold"]); + assert.deepEqual(g.caps, { holdS: 900, maxW: 7400, perMin: 6 }); + assert.equal(g.ttlS, 604800); + }); + + it("keeps caps at zero when only lasting changes are ticked", () => { + const g = P.chosenGrant({ reads: [], ops: ["site.mode.set"], holdMin: 30, maxKW: 11, ttlS: 86400 }, 6, registry); + assert.deepEqual(g.caps, { holdS: 0, maxW: 0, perMin: 6 }); + }); + + it("stops a grant of nothing, or caps past the offer, before the box is asked", () => { + const none = P.chosenGrant({ reads: [], ops: [], ttlS: 86400 }, 0, registry); + assert.match(P.choiceProblem(none, offer.caps), /at least one/); + const tooLong = P.chosenGrant({ reads: [], ops: ["loadpoint.hold"], holdMin: 31, maxKW: 11, ttlS: 86400 }, 6, registry); + assert.match(P.choiceProblem(tooLong, offer.caps), /1 to 30 minutes/); + const tooStrong = P.chosenGrant({ reads: [], ops: ["loadpoint.hold"], holdMin: 30, maxKW: 12, ttlS: 86400 }, 6, registry); + assert.match(P.choiceProblem(tooStrong, offer.caps), /up to 11 kW/); + const fine = P.chosenGrant({ reads: [], ops: ["loadpoint.hold"], holdMin: 30, maxKW: 11, ttlS: 86400 }, 6, registry); + assert.equal(P.choiceProblem(fine, offer.caps), ""); + }); + + it("offers no length longer than the offer asks", () => { + assert.deepEqual(P.lengthOptions(30 * 86400), [86400, 7 * 86400, 30 * 86400]); + assert.deepEqual(P.lengthOptions(3 * 86400), [86400]); + assert.deepEqual(P.lengthOptions(3600), [3600]); + }); + + 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); + assert.equal(P.defaultLength(P.lengthOptions(3600)), 3600); + }); + + it("keeps a power cap exact to the watt, so the default is never refused", () => { + // A phone may approve 3,680 W, which one decimal of kW would round up to + // 3,700 W, past the row's own cap. + for (const w of [3680, 11000, 7400, 1, 99999, 16560]) { + const g = P.chosenGrant({ reads: [], ops: ["loadpoint.hold"], holdMin: 30, maxKW: P.kwExact(w), ttlS: 86400 }, 6, registry); + assert.equal(g.caps.maxW, w, String(w)); + assert.equal(P.choiceProblem(g, { holdS: 1800, maxW: w }), "", String(w)); + } + }); +}); + +describe("what stops a grant before a code is spent", () => { + const ok = { box: { offers: true, clock: "ok", lookup: "https://cloud.ftw.energy/oauth/requests", widen: true } }; + const on = { lan_auth: true, configured: true }; + + it("is nothing once the house password is on and the box can take an offer", () => { + assert.equal(P.readiness(ok, "", on), null); + }); + + it("names each block, in the order to fix them", () => { + assert.equal(P.readiness(null, "off", on).kind, "unavailable"); + assert.equal(P.readiness(null, "", on).kind, "loading"); + assert.equal(P.readiness(ok, "", { lan_auth: false, configured: true }).kind, "password"); + assert.equal(P.readiness(ok, "", { lan_auth: true, configured: false }).kind, "password"); + assert.equal(P.readiness(ok, "", null).kind, "password"); + // On the box itself, or through a tunnel, the password does not count. + assert.equal(P.readiness({ box: { ...ok.box, widen: false } }, "", on).kind, "here"); + assert.equal(P.readiness({ box: { ...ok.box, offers: false } }, "", on).kind, "keys"); + assert.equal(P.readiness({ box: { ...ok.box, clock: "behind", nowMs: 1, markMs: 2 } }, "", on).kind, "behind"); + assert.equal(P.readiness({ box: { ...ok.box, clock: "unsynced" } }, "", on).kind, "clock"); + }); +}); + +describe("refusals in the page's words", () => { + it("tells a wrong or spent code from FTW Cloud's other answers", () => { + assert.match(P.lookupFailureText(404), /No request is waiting with that code/); + assert.match(P.lookupFailureText(429), /Too many tries/); + assert.match(P.lookupFailureText(0), /No answer from FTW Cloud/); + assert.match(P.lookupFailureText(502), /502/); + }); + + it("names each reason the box will not take an offer", () => { + const now = 1_800_000_000_000; + assert.match(P.offerRefusalText({ reason: "no_offer_keys" }, {}, now), /does not know FTW Cloud's signing key/); + assert.match(P.offerRefusalText({ reason: "unsigned" }, {}, now), /signature/); + assert.match(P.offerRefusalText({ reason: "other_home" }, {}, now), /another FTW home/); + assert.match(P.offerRefusalText({ reason: "malformed" }, {}, now), /cannot read this offer/); + assert.match(P.offerRefusalText({ reason: "expired", boxMs: now }, { expiresAtMs: now - 1 }, now), /has run out/); + }); + + it("blames the box's clock when FTW Cloud and this device still count the offer as good", () => { + const now = 1_800_000_000_000; + const text = P.offerRefusalText({ reason: "expired", boxMs: now + 20 * 60_000 }, { expiresAtMs: now + 5 * 60_000 }, now); + assert.match(text, /The box's clock reads/); + assert.match(text, /this device reads/); + }); + + it("says what the box's refusal codes mean", () => { + assert.match(P.refusalText({ error: "E_CAP_EXCEEDED", args: { cap: "agents", max: 16 } }), /16 assistants/); + assert.match(P.refusalText({ error: "E_CAP_EXCEEDED", args: { cap: "expiresAtMs" } }), /longer than/); + assert.match(P.refusalText({ error: "E_UNKNOWN_OP", args: { field: "key" } }), /already has access/); + assert.match(P.refusalText({ error: "E_UNAVAILABLE", args: { subsystem: "clock" } }), /clock is not set/); + assert.match(P.refusalText({ error: "E_CONFLICT" }), /changed this access/); + assert.equal(P.refusalText({ error: "give access in the app" }), "give access in the app"); + }); +}); + +describe("a request waiting for the owner", () => { + it("lists its arguments in the registry's order, by its labels, and the rest last", () => { + const rows = P.requestArgs({ + op: "car.goal.set", + args: { surplus_only: false, ready_by_min: 420, target: 0.8, id: "car_7f3a", extra: "x" }, + }, registry); + assert.deepEqual(rows, [ + { label: "Car", value: "car_7f3a" }, + { label: "The level to reach", value: "80 %" }, + { label: "Ready by", value: "07:00" }, + { label: "Charge from solar only", value: "no" }, + { label: "extra", value: "x" }, + ]); + }); + + it("cleans an argument's name that the registry does not know", () => { + const rlo = String.fromCharCode(0x202e); + const rows = P.requestArgs({ op: "x", args: { ["evil" + rlo + "gnp"]: 1 } }, registry); + assert.equal(rows[0].label, "evilgnp"); + }); + + it("shows power, length and odd numbers plainly", () => { + const rows = P.requestArgs({ op: "loadpoint.hold", args: { power_w: 7400, hold_s: 900, id: "lp_1" } }, registry); + assert.deepEqual(rows.map((r) => r.value), ["lp_1", "7.4 kW", "15 min"]); + assert.equal(P.requestArgs({ op: "x", args: { n: Infinity } }, registry)[0].value, "?"); + }); + + it("says how an approved request ended", () => { + assert.match(P.resultText({ state: "declined" }), /Declined/); + assert.match(P.resultText({ state: "approved", result: { State: "applied" } }), /made the change/); + assert.match(P.resultText({ state: "approved", result: { State: "rejected", Error: { Code: "E_PRECONDITION" } } }), /refused the change \(E_PRECONDITION\)/); + assert.match(P.resultText({ state: "approved", result: { State: "unconfirmed" } }), /could not confirm/); + }); +}); + +describe("the tab", () => { + it("asks for a code with the field off until the box says it can take one", () => { + const html = tab.render(); + assert.match(html, /id="access-code"[^>]*disabled/); + assert.match(html, /id="access-lookup"[^>]*disabled/); + assert.match(html, /No phone app is needed/); + }); + + it("sits in Settings beside the FTW app, without a Save button", () => { + assert.match(index, /