Skip to content

feat(web): the box page lets an assistant in with no phone app - #1682

Merged
frahlg merged 4 commits into
masterfrom
claude/box-access-page
Oct 17, 2026
Merged

frahlg merged 4 commits into
masterfrom
claude/box-access-page

Conversation

@frahlg

@frahlg frahlg commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Why

On 17 October Fredrik asked that an owner can connect an assistant over MCP with no app involved. Many owners have no iOS app: Android, web only, Home Assistant. FTW Cloud's consent page already tells them to "open Access" on the box's own page and type a code, but there was no Access page. Core's API for it shipped in #1646; this is the page, and the few things the page needed from Core.

Paired with srcfl/ftw-cloud#25: the consent page's words, and a code lookup whose refusals the box page can read. Either can merge first.

What changes

Settings → Access (web/settings/tabs/access.js), beside FTW app:

  • The owner types the code from FTW Cloud's page. The page asks FTW Cloud's code lookup from the owner's browser, then asks the box to read the signed offer. What the owner sees is what the box read and checked, not what a web page claimed: client name, clientHost, whether FTW Cloud verified it, the scopes and ops asked for, caps and length.
  • Reads are ticked by default. Control is off until the owner turns it on; then each op can be unticked, and the hold length and power can go down but never past what the offer asks. The length goes down too. The grant sent to the box is never wider than the offer, and the box checks that again.
  • If the house password is off, the page says so and links to System → LAN password before it asks for a code, because a code works once. It does the same for a build that pins no offer key, a box clock NTP has not set, and a clock behind its mark (with the reset, behind a confirm).
  • It lists who has access, with expiry, last use and who let them in, and lets the owner renew or narrow (never widen) and remove each one. Requests for lasting changes wait under "Waiting for your yes", with the registry's labels, the assistant's own words in quotes, and Approve and Decline.
  • Names from outside go in as text only, with control and direction marks removed. An unknown client that uses a name like Sourceful's or FTW's gets a warning.

Core, kept small:

  • POST /api/agents/offer {offer} reads an offer for the page and grants nothing. It and POST /api/agents now say why the box will not take an offer: {error, reason, boxMs} with reason one of no_offer_keys, unsigned, expired, malformed, other_home. A clock the box cannot trust answers E_UNAVAILABLE {subsystem: "clock"} first, since every offer would otherwise read as expired. Before, all of these were one sentence.
  • GET /api/agents also carries box {offers, lookup, clock, nowMs, markMs} and registry {agentOps, writeScopes, agentArgs}. The page checks the first before asking for a code, and takes scope, op and argument names from the second instead of writing them by hand. lookup follows cloudlink.Endpoint, so a local build with its own FTW Cloud finds its own lookup.
  • A box with only an assistant now links to FTW Cloud. cloudlink.wantsLink looked only at phones with a tunnel and at a pairing code. An agent row has no tunnel, so a box with no phone never linked, and FTW Cloud never saw the assistant in a roster: the grant did nothing. An idle link would also have ended with an assistant still on it. TestAnAgentRowLinksTheBox fails without the one-line fix.
  • docs/agents.md and docs/protocol-cloud.md say both.

The offer key: #1680, now merged, pins FTW Cloud's real offer key (kid s0xLrCPjFa-AoMMl) in cloudlink.OfferKeys, and this branch carries master. A release build with this PR takes real offers. The "cannot let assistants in yet" message stays for a build that pins none.

After review

  • No link for an agent the box cannot serve. With the app link off the box takes no tun, so an agent row no longer links it: no dial, no cloud.key, no roster naming the client. wantsLink now asks d.Tun || (d.Agent != nil && l.opts.Tunnel != nil), as TakesTun does. TestAnAgentRowLinksNoBoxThatTakesNoTun fails without it.
  • On the box itself the password never counts. resolveLANSecret does not read the sign-in from loopback, so through ssh -L or a browser on the box the page read as ready, the lookup spent the code, and the grant got 403. GET /api/agents now says box.widen for this very request, and the page stops first: "Open this page from another device on your home network, and sign in with the house password there." Renew and Approve use the same answer. docs/agents.md and the comments in api_agents.go no longer imply that a password works on loopback. TestTheListSaysWhetherThisRequestCanWiden runs both cases through the real middleware.
  • Power caps exact to the watt. One decimal of kW turned a 3,680 W cap into 3.7 kW, and the page refused its own default when renewing a phone-approved row. The field now holds the exact value with step="any"; a test round-trips 1 W to 99,999 W. Checked in the browser: granted and renewed at 3,680 W.
  • Names cleaned harder. Double quote marks go, so a name cannot end the page's quotes early. So do U+00AD, U+034F, U+061C, U+FE00–FE0F and U+E0000–E007F, and names are cut by code point. An unknown argument's name is cleaned too.
  • A request shows the client's host beside the name, so two assistants that call themselves the same can be told apart.
  • A grant lasts a week by default, or less when the offer asks for less. A week is enough to try an assistant and short enough that one forgotten here ends; renewing is a button on the page, and the box warns a day before the end.
  • Not fixed, by choice: Facts() reads as ready on a build that pins an offer key but no service key. No release build is like that: every release names the service.

Tests

  • web/settings/tabs/access.test.mjs, 32 tests: the grant is never wider than the choice and has no caps without a hold; reads in the page's order; each block and refusal has its own words, including the clock-skew case where FTW Cloud and the browser still count the offer as good; request arguments in the registry's order; the page's words cover every agent scope and op in contract/registry.yaml and nothing else.
  • Go: TestTheBoxPageReadsAnOfferBeforeGranting (each reason, grants nothing, the clock first), TestTheAgentListCarriesTheBoxFacts, TestTheCodeLookupFollowsTheUplink, TestAnAgentRowLinksTheBox, and the new route in the local-route and sign-in sweeps.
  • make verify passes.

Reviewed in a browser

Against a locally running Core from this branch, built with a test offer key pinned through -ldflags -X cloudlink.OfferKeys, as Core's tests do, and ServiceKeys empty so it never dialled FTW Cloud. A small stand-in for FTW Cloud's code lookup signed fresh offers with that key. The browser pane refuses a page's requests to another origin (ERR_BLOCKED_BY_CLIENT), so the stand-in also served the box from one origin; the box saw a LAN client with no forwarding headers, as from any device on the Wi-Fi. Signed in with a house password, the page:

  • refused a malformed code, a wrong code (404), an expired offer, an offer for another home, an offer signed by an unknown key, and a rate-limited lookup, each in its own words;
  • granted reads with control narrowed (one op off, 15 min, 7.4 kW): the box stored exactly that;
  • renewed and narrowed the row (logs off, 7 days; epoch 2), and removed it;
  • showed the clock behind its mark (set in the test database), and the reset revoked every row and cleared it.

The pending request in shot 10 was added to the box's real answer in the page, since this rig has no agent session to ask; Decline then reached the real box and got E_NOT_FOUND, which the page now answers by reloading the list. The page fits 375 px with no sideways scroll and reads in the light theme.

House password off Wrong code
Offer, control off Control narrowed
Granted Unknown client
Renew or narrow Clock behind its mark
A request waiting Phone width
No offer key pinned App link off
Clock not set Light theme
On the box itself

For the owner

  • The changeset is minor: a visible new capability. Fredrik approved it in chat (quoted on this PR).
  • Not tried against the real FTW Cloud or a real assistant.

Left for later

  • A request names a charger or car by the assistant's opaque id. On the box page, where the owner is local, the box could name them.
  • contract/registry.yaml writes label: Ready by, in minutes after midnight inside a flow mapping, so YAML ends the label at the comma and the label reads "Ready by". The page shows that argument as a time of day.
  • FTW Cloud's words for ftw.dispatch.hold still say "Hold the battery", but battery.hold is no agent op.

🤖 Generated with Claude Code

Settings has an Access tab. The owner types the code from FTW Cloud's
consent page; the browser fetches the signed offer and the box reads it,
so the owner sees what the box checked. Reads are the default, control is
opt-in, and the grant is never wider than the offer. Granting, renewing,
narrowing and approving need the house password, and the page says so
before a code is spent. The tab lists who has access and lets the owner
renew, narrow, remove and answer waiting requests.

Core: POST /api/agents/offer reads an offer for the page and grants
nothing. It and POST /api/agents say why the box will not take an offer.
GET /api/agents carries the box's facts and the registry's agent ops. A box
with only an assistant now links to FTW Cloud, which learns of the
assistant from the roster.

Paired with the consent page's words in srcfl/ftw-cloud.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-17T05:44:18.119080Z 73bb9f6 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@frahlg

frahlg commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Fredrik approved the minor bump in chat on 17 October 2026: "ja minor ok, kör hela vägen".

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 73bb9f6773

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +632 to +634
? "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.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Sanitize the client host before displaying it

For an unverified client, clientHost originates from its metadata document or redirect URI, but it is inserted without the cleanText processing applied to the client name and request text. A crafted host containing Unicode bidi or other formatting controls can visually reorder or conceal parts of this identity warning immediately before the owner grants access; sanitize the host before rendering it, including hosts derived for existing rows.

Useful? React with 👍 / 👎.

Comment on lines +943 to +945
if (state.note) setNote(state.note.text, state.note.kind);
draw(ctx);
refresh(ctx);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh requests while the Access tab remains open

When an agent creates a durable-change request after this one-time refresh(ctx) completes, nothing reloads /api/agents, so the request remains invisible for as long as the owner leaves the Access tab open; this is especially problematic in the new no-phone flow, where this page is the only approval surface. Poll while the tab is active or subscribe to an existing update signal so pending requests and expirations stay current.

AGENTS.md reference: AGENTS.md:L22-L25

Useful? React with 👍 / 👎.

frahlg and others added 2 commits October 17, 2026 08:03
…mes harder

Review of #1682:

- A box that takes no tun (app link off) cannot serve an agent, so an agent
  row no longer links it to FTW Cloud.
- On loopback the house password never counts, so the grant failed after
  the code was spent. GET /api/agents says box.widen for this request, and
  the page stops with plain words first. Docs and comments no longer imply
  the password works on the box itself.
- Power caps are exact to the watt: 3,680 W read back as 3.7 kW and the
  page refused its own default.
- Names lose double quote marks, soft hyphens, joiners, variation selectors
  and tag characters, and are cut by code point. An unknown argument's name
  is cleaned. A request shows the client's host beside its name.
- A grant lasts a week unless the owner picks another length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit e3c0bac into master Oct 17, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant