Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c2e7c49
feat(keys): ftwkeysd, the one part that can open an agent's session
frahlg Oct 15, 2026
3952606
feat(keys): an offer key that signs consent offers, and nothing else
frahlg Oct 15, 2026
1c6978d
feat(keys): name the offer key by a kid, and print it for pinning
frahlg Oct 15, 2026
5a08a11
fix(keys): review of #18: sign only offers it can vouch for, the offe…
frahlg Oct 15, 2026
0cddf80
chore(keys): pin the merged agent contract, srcfl/ftw#1642 at bf5bf50
frahlg Oct 15, 2026
5018c96
feat(oauth): assistants sign in with OAuth, and the owner approves at…
frahlg Oct 15, 2026
8d50688
fix(oauth): a token names its shard as the shard directory does
frahlg Oct 15, 2026
0783290
fix(oauth): an approval outlasts a keys part that is briefly away
frahlg Oct 15, 2026
d892bf3
feat(oauth): build to the agent contract, with a signed grant offer
frahlg Oct 15, 2026
0224dd8
feat(oauth): the final agent contract: SignedOffer {o, kid, sig}, row…
frahlg Oct 15, 2026
41d6aae
chore(oauth): pin the agent contract at e2559632, where protocol.Offe…
frahlg Oct 15, 2026
79e7c1c
fix(oauth): a token allows only what it carries and the box still grants
frahlg Oct 15, 2026
fccb46d
fix(oauth): review of #19: the newest roster only, purges, no open re…
frahlg Oct 15, 2026
487186a
feat(mcp): assistants read their home at /mcp
frahlg Oct 15, 2026
21dc65f
fix(mcp): ask the shard directory whether a home is held here
frahlg Oct 15, 2026
b944088
test(mcp): the fan-out test holds what does not depend on timing
frahlg Oct 15, 2026
d128a78
fix(mcp): pace an agent's calls inside the door's tun limits
frahlg Oct 15, 2026
220f506
feat(mcp): read through the box's own agent reads, and keep agent row…
frahlg Oct 15, 2026
b0ef034
test(mcp): the stub box counts a request in flight while it works on it
frahlg Oct 15, 2026
7ca55ea
feat(mcp): open an agent's session to the Noise key the box signs in …
frahlg Oct 15, 2026
45faf1d
feat(mcp): the final agent contract's reads, and agent rows without a…
frahlg Oct 15, 2026
83d70b3
fix(mcp): review of #20: a close during an open, tool names, words, a…
frahlg Oct 15, 2026
2ed4d6e
fix(mcp): a request for another shard goes there inside, agent sessio…
frahlg Oct 15, 2026
815a947
feat(mcp): control within the owner's grant, and requests that wait f…
frahlg Oct 15, 2026
3d06c64
fix(mcp): review of #21: charging needs a power, the first command kn…
frahlg Oct 15, 2026
3629f05
fix(mcp): a command whose answer is lost is unknown, not failed
frahlg Oct 15, 2026
3c2c192
Merge main (scale #17) into the MCP stack
frahlg Oct 15, 2026
b2f90f0
style: gofmt uplink_test.go after the merge
frahlg Oct 15, 2026
3669c24
ci: run the checks on the combined MCP stack against main
frahlg Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,14 @@ jobs:
run: go test -run XXX -fuzz FuzzBoxMessage -fuzztime 30s ./internal/uplink
- name: What a watch can post never panics the service
run: go test -run XXX -fuzz FuzzOnceMessage -fuzztime 30s ./internal/appsession
- uses: actions/setup-node@v5
with:
node-version: '22'
- name: The official MCP inspector lists and calls tools on /mcp
env:
MCP_INSPECTOR: npx -y @modelcontextprotocol/inspector@2.3.0
run: go test -count=1 -run TestInspectorSmoke -v ./internal/mcp
- name: Builds for the host
run: GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/ftwcloudd
run: |
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/ftwcloudd
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/ftwkeysd
18 changes: 12 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@ maintains it.

## Invariants

- **FTW Cloud forwards ciphertext it cannot read, and never chooses an
action.** It sends a box only `challenge`, `hello_ok`, `watch`, `resync`,
`purged` and `tun` (`internal/uplink/downlink.go`). Commands go from the app
to the box inside the phone's own Noise session, which rides here as `tun`
under the device id the service's own handshake proved, never one a phone
names.
- **FTW Cloud forwards ciphertext it cannot read. It acts on a home only as
an agent its owner approved, within limits the box enforces.** It sends a
box only `challenge`, `hello_ok`, `watch`, `resync`, `purged` and `tun`
(`internal/uplink/downlink.go`). Commands go from the app to the box inside
the phone's own Noise session, which rides here as `tun` under the device
id the service's own handshake proved, never one a phone names. An agent's
requests ride the agent's own Noise session the same way, and the box
admits it only while its signed roster lists the agent's key.
- **Agent keys live only in `ftwkeysd`** (`internal/keys`, `cmd/ftwkeysd`). It
answers on a local unix socket, seals keys at rest under KMS, unseals a key
only to open one session, and logs no key. `ftwcloudd` never holds an
agent's static key or session keys. Keep it small: nothing else goes in.
- **One way to a home:** `store.For(principal)`. No listing, no second lookup,
no principal for staff. A new route needs a row in `server.Routes` and must
pass `TestNoRouteReadsAHome`. The one other way in is `store.Seed`, called
Expand Down
18 changes: 14 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,25 @@ COPY . .
# No cgo: the SQLite driver is pure Go, so the binary runs on a static base.
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath \
-ldflags "-s -w -X github.com/srcfl/ftw-cloud/internal/server.Revision=${REVISION}" \
-o /out/ftwcloudd ./cmd/ftwcloudd
-o /out/ftwcloudd ./cmd/ftwcloudd \
&& CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags "-s -w" \
-o /out/ftwkeysd ./cmd/ftwkeysd
RUN mkdir -p /out/share && tar -czf /out/share/source.tar.gz \
--exclude=./.git --exclude=./state . \
&& mkdir -p /out/state
&& mkdir -p /out/state /out/keys

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/ftwcloudd /usr/local/bin/ftwcloudd
# The keys part, run as its own container and user (deploy/keys.md).
COPY --from=build /out/ftwkeysd /usr/local/bin/ftwkeysd
COPY --from=build /out/share/source.tar.gz /usr/share/ftw-cloud/source.tar.gz
COPY --from=build --chown=nonroot:nonroot /out/state /state
USER nonroot
# ftwcloudd runs as a user of its own, 65530, not distroless's shared
# nonroot. The host gives its files to the user the image names
# (ftw-cloud-own in deploy/bootstrap.sh).
COPY --from=build --chown=65530:65530 /out/state /state
# Owned by ftwkeysd's user, so a new volume mounted here starts out its own
# and ftwcloudd cannot read it.
COPY --from=build --chown=65533:65533 /out/keys /keys
USER 65530:65530
EXPOSE 443
ENTRYPOINT ["/usr/local/bin/ftwcloudd"]
104 changes: 104 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,110 @@ to it yet.
the roster drops the device, moves its epoch or runs out, an owner
suspends the device, or the home is purged.

- **The keys part (`ftwkeysd`).** A second process on the same host, as
another user, answering only on a unix socket. It makes each assistant's
agent key (X25519) when an authorization starts, seals it at rest with a
data key from AWS KMS, and keeps it pending until the owner approves. It
unseals a key only to open that agent's Noise session to its box, keeps
the session's transport keys, and seals and opens each frame for
`ftwcloudd`, which carries them as `tun`. Forgetting a key ends its
sessions. [deploy/keys.md](deploy/keys.md) has the shape and how to turn
it on.

- **Assistants sign in with OAuth, and the owner approves at home.** The
authorization server follows the MCP authorization spec, revision
2026-07-28: OAuth 2.1 with PKCE S256 only, metadata at
`/.well-known/oauth-authorization-server` with `iss` in every
authorization response (RFC 9207), protected resource metadata for
`https://cloud.ftw.energy/mcp` (RFC 9728), and tokens bound to that
resource (RFC 8707). A client names itself by a client ID metadata
document, fetched behind a fence (https on 443 to a public address checked
as the socket connects, no redirects, no proxy, 16 KiB, 5 s, cached), or
registers at `/oauth/register` (RFC 7591), kept for ChatGPT and older
clients. There is no account and no password: `/oauth/authorize` asks
`ftwkeysd` for a new agent key and has it sign the grant offer
(srcfl/ftw `docs/agents.md`), sent as `{o, kid, sig}`: the key, the
client, the host it is known by and whether FTW Cloud knows it, the
scopes, ops and caps asked for, the page's code, and an end ten minutes
out, with no home, since the owner picks it. The page shows the client's name, where the browser goes back, the
rights, the code, a QR and `https://app.ftw.energy/a#<request>.<offer>`;
the app checks the offer against the offer key it pins by kid, which
`/.well-known/ftw-offer-key` serves. The box's page or an owner who types
the eight-character code finds the offer once at `/oauth/requests?code=`.
A client FTW Cloud knows by the exact URL of its document is shown as
verified, and an error goes back to it at once; any other gets a page
with a link back. Each endpoint a stranger can call is limited per
address and for everyone, before anything is fetched. The owner
approves in the app or on the box's page; the box lists the agent's key,
for that client, in the roster it signs (an agent row, with no tunnel of
its own), and when that roster arrives the
page, waiting on a server-sent event, returns to the client with a code.
Only the browser that started the request gets it. Access tokens last 10
minutes; refresh tokens rotate, a retry within 5 s works once more, and a
later reuse revokes the family, as does a code used twice, even after its
consent is gone. A token carries its home sealed with a fresh nonce under
the route key (`oauth-route.key`, shared by every instance that routes),
so any instance can route it and no client sees an id that links its
tokens; the service keeps hashes, never tokens. A token allows the scopes it
carries that the newest roster still grants, so an owner who narrows a
grant narrows it at once, and it stops at the agent's end by the
service's own clock. A roster without the agent ends
its tokens and its key, and so does a second home listing the same key,
or the box purging its home; a key the keys part could not forget stays
queued until it has. Only each home's newest roster is applied.

- **MCP at `/mcp`, Streamable HTTP.** It speaks revision 2026-07-28, which
is stateless: each request carries its version and capabilities in
`_meta`, mirrored in `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name`,
and a header that disagrees with the body is refused. It also answers
clients of 2025-03-26 to 2025-11-25 that start with `initialize`, without
session ids or a GET stream. The read tools are `homes_list`, `status`,
`chargers_list`, `cars_list`, `plan_get`, `plan_explain`,
`history_query`, `health_get`, `events_list`, `logs_tail` (both with
before and after cursors), `drivers_status`, `config_get` and
`diag_run` (names any model API accepts), each with typed arguments, each one of the box's own reads for
agents (`live.get`, `loadpoints.get`, `cars.get`, `plan.get`, `price.get`,
`hist.query`, `health.get`, `events.get`, `logs.get`, `config.get`,
`diag.run`, `drivers.get`; srcfl/ftw `docs/agents.md`), under the scope
the registry names for it. The box names chargers and cars by its agent
ids, which the control tools take back. The box refuses the passthrough to agents,
and the readable copy is never an agent's: every read goes through the
box, which audits it and enforces revoke. An agent row has no tunnel of
its own. A call without its scope gets
403 `insufficient_scope`, so the client can ask the owner for more. Each
call rides the agent's own Noise session: `ftwkeysd` seals each frame and
the door carries it as `tun`, one forward to the box. The door admits an
agent row only to these sessions, never on `/v1/app` or `/v1/app/once`,
and only before its end. A session stays warm for 60 s and calls on one
home queue, four at most; a process runs 256 at once, and each agent
may send 30 requests, then 10 a second. Agents' sessions have a budget
of their own at each home, so an assistant never takes a phone's place. Agents' calls on a
home are paced under the box's 60 reads a minute and a quarter of the
door's tun limits, which the home's phones share: 15 in a row, then 0.75
a second. Results are data: each says it came from the box and is not
instructions, and characters that can hide text (controls, format and
private-use characters, variation selectors) are removed. The
session goes to the Noise key the box names in the roster it signs
(`noise`); a box that names none takes no assistants yet, and each such
home says so.
- **Control, capped and temporary.** `ev_charge_now` sends one
`loadpoint.hold` with a power and an end (or `clear` with `stop`), the one
hold the contract opens to agents: only when the owner's grant lists it,
with the arguments the registry allows an agent (`RegistryAgentArgs`) and
within the grant's caps (`holdS`, `maxW`), checked here before the box
does and never loosened. Control ends when the assistant stops renewing;
the service never renews a hold by itself, and another hold on the
charger wins. Each `cmd` expects the control revision the chargers'
answer named, read first if need be, so the first command spends nothing
on a conflict; a stale one is learnt from `E_CONFLICT` and tried once
more. A client's retry of the same call within a minute is the same
command id, so the box acts once. `goal_request` asks for a lasting
change (the mode, the planning style, a charger's solar-only charging, a
car's charge level) as `agent.request`, with the assistant's own words for
why; the box keeps it until an owner confirms it with Face ID, and
`requests_list` shows how it ended. A command the box refused is a tool
error, in words.

Not built yet:

| Step | What | Where |
Expand Down
25 changes: 25 additions & 0 deletions cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ import (

"github.com/srcfl/ftw-cloud/internal/apns"
"github.com/srcfl/ftw-cloud/internal/appsession"
"github.com/srcfl/ftw-cloud/internal/keys"
"github.com/srcfl/ftw-cloud/internal/mcp"
"github.com/srcfl/ftw-cloud/internal/metrics"
"github.com/srcfl/ftw-cloud/internal/oauth"
"github.com/srcfl/ftw-cloud/internal/push"
"github.com/srcfl/ftw-cloud/internal/registry"
"github.com/srcfl/ftw-cloud/internal/server"
Expand Down Expand Up @@ -85,6 +88,7 @@ func serve(args []string) error {
apnsWatch := fs.String("apns-watch-topic", "energy.ftw.app.watchkitapp", "the watch app's bundle id")
devPush := fs.String("dev-push-listen", "", "loopback address for registering a token by hand (development builds only)")
devCapture := fs.String("dev-apns-capture", "", "write each alert to this directory instead of sending it to Apple (development builds only)")
keysSocket := fs.String("keys-socket", "/run/ftw-cloud-keys/keys.sock", "ftwkeysd's socket; assistants can connect only while it answers")
shardsPath := fs.String("shards", "", "shard directory, JSON; without it this instance holds every home")
metricsAddr := fs.String("metrics-listen", defaultMetricsAddr, "loopback address for /metrics; empty turns it off")
restartRate := fs.Int("restart-rate", defaultRestartRate, "sockets a second the next instance can take after a restart; sets the spread of the redial")
Expand Down Expand Up @@ -138,6 +142,22 @@ func serve(args []string) error {
tokens.Shards = shards
srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Log: log}

// Assistants: the authorization server, with agent keys in ftwkeysd.
agentKeys := keys.Dial(*keysSocket)
defer agentKeys.Close()
auth, err := oauth.New(filepath.Join(*stateDir, "oauth.db"), agentKeys, log)
if err != nil {
return err
}
defer auth.Close()
srv.OAuth = auth
// Each agent's session goes to its box through the door, as tun, to the
// Noise key the box names in the roster it signs. A box that names none
// takes no agents yet, and the tools say so.
agents := mcp.NewPool(st, up, agentKeys, mcp.RosterBoxKey(reg), log)
auth.OnRevoke = agents.Drop
srv.MCP = mcp.New(auth, agents, log)

// Alerts: the box's signed events to its phones through Apple, and
// box.unreachable when a box is away. Widgets: a push with no data when
// prices or a car change. Without a key the service keeps registrations
Expand All @@ -162,8 +182,11 @@ func serve(args []string) error {
up.OnRoster = func(home string, r *cloudwire.Roster) {
pushSvc.Roster(home, r)
tokens.Roster(home, r)
auth.Roster(home, r)
}
up.OnGone = pushSvc.BoxGone
// A purged home's agents, their tokens and keys, and its consents go.
up.OnPurge = auth.DropHome

// Phones register their tokens over their app session, so without a
// Noise key only the development route registers one.
Expand Down Expand Up @@ -225,6 +248,8 @@ func serve(args []string) error {
pushCtx, stopPush := context.WithCancel(context.Background())
defer stopPush()
go pushSvc.Run(pushCtx)
go auth.Run(ctx)
go agents.Run(ctx)
if err := pushSvc.Resume(); err != nil {
log.Warn("could not restart the dead man's switch", "err", err)
}
Expand Down
13 changes: 13 additions & 0 deletions cmd/ftwkeysd/dev_off.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build !ftwclouddev

package main

import "flag"

// A production build has no file key and no local offer key: both keys live
// in KMS.
const devBuild = false

func addFileKeyFlag(*flag.FlagSet) *string { return new(string) }

func devCommand(string, []string) bool { return false }
39 changes: 39 additions & 0 deletions cmd/ftwkeysd/dev_on.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//go:build ftwclouddev

package main

import (
"flag"
"fmt"
"os"

"github.com/srcfl/ftw-cloud/internal/keys"
)

// A development build seals keys with a local file key and signs offers with
// a local key. Whoever reads the file and the state directory has every
// agent key, so a production build has neither.
const devBuild = true

func addFileKeyFlag(fs *flag.FlagSet) *string {
return fs.String("file-key", "", "a local file key instead of KMS (development only)")
}

// devCommand runs "ftwkeysd filekey -out dev.key".
func devCommand(name string, args []string) bool {
if name != "filekey" {
return false
}
fs := flag.NewFlagSet("filekey", flag.ExitOnError)
out := fs.String("out", "", "file to write the key to")
_ = fs.Parse(args)
if *out == "" {
fmt.Fprintln(os.Stderr, "filekey: -out is required")
os.Exit(1)
}
if err := keys.MakeFileKey(*out); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return true
}
16 changes: 16 additions & 0 deletions cmd/ftwkeysd/harden_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package main

import (
"log/slog"

"golang.org/x/sys/unix"
)

// harden makes the process not dumpable: no core file, and no ptrace attach
// from another process of the same user. Agent keys and session keys live in
// this process's memory while a session is open.
func harden() {
if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil {
slog.Warn("could not turn off core dumps", "err", err)
}
}
6 changes: 6 additions & 0 deletions cmd/ftwkeysd/harden_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//go:build !linux

package main

// harden has nothing to set outside Linux, where the service runs.
func harden() {}
Loading
Loading