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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,16 @@ to it yet.
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
`ftwkeysd` for a new agent key. If the client omits `scope`, the original
browser first chooses read/troubleshoot or read/configure. Nothing exposes
the key, offer, QR or owner code before that choice. Configure adds
`ftw.setup.write` to the read scopes; neither choice adds control. An
explicit scope parameter keeps its existing meaning, including an explicitly
blank value. Only the original cookie and exact issuer Origin can save a
mode, once, without changing the key or deadline. Competing choices read
the saved result; a late signature cannot revive a cancelled request.
Signing uses the same global and address limits as authorization.
Then `ftwkeysd` signs 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
Expand Down Expand Up @@ -241,8 +250,13 @@ to it yet.
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
403 `insufficient_scope`, so the client can ask the owner for more. A
setup write asks for both `ftw.diag.run` and `ftw.setup.write`, so a fresh
connection can read its schema and save receipt. Tool metadata and the
runtime OAuth challenge name the same scopes, as required by
[ChatGPT's authentication flow](https://developers.openai.com/plugins/build/auth#triggering-authentication-ui).
Existing token scopes stay in the request; no scope is granted by inference.
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
Expand Down
5 changes: 3 additions & 2 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,9 +372,10 @@ func (s *Server) call(w http.ResponseWriter, r *http.Request, id json.RawMessage
writeRPCError(w, status, id, codeInvalidParams, "Unknown tool: "+p.Name, nil)
return
}
if t.scope != "" && !g.Has(t.scope) {
required := t.requiredScopes()
if slices.ContainsFunc(required, func(scope string) bool { return !g.Has(scope) }) {
// Step up: the client asks the owner for the right, then retries.
scopes := append(slices.Clone(g.Scopes), t.scope)
scopes := append(slices.Clone(g.Scopes), required...)
slices.Sort(scopes)
scopes = slices.Compact(scopes)
challenge := oauth.InsufficientScope(scopes...)
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func setupTools() []*tool {
{"setup_reset", "Start a new draft from current settings", protocol.MsgSetupReset,
"Discard this unfinished draft only when the user asks, or start another after a completed save. Requires its exact current revision. Keeps all current settings and credentials; does not restart the box. Carry useful answers forward when resolving a conflict.", object(map[string]any{"revision": integer("Current draft revision", 0, 9007199254740991, 0)}, "revision"), false},
{"setup_validate", "Check a setup draft", protocol.MsgSetupValidate,
"Validate the exact current draft against Core's own checks. Review changes, assumptions and any required restart with the user. Validation does not prove a device connects, measures or obeys control.", object(checks, "revision", "base_revision", "sha256"), false},
"Validate the exact current draft against Core's own checks. Core may normalize fields into a new draft revision and checksum; review that returned configuration, changes, warnings, assumptions and any required restart with the user. Confirm and commit only the returned revision and checksum. Validation does not prove a device connects, measures or obeys control.", object(checks, "revision", "base_revision", "sha256"), false},
{"setup_commit", "Save the setup the user confirmed", protocol.MsgSetupCommit,
"Save only the exact draft the user reviewed and confirmed after setup_validate. Requires the separately owner-approved setup grant. Uses the box's existing durable Settings save and apply path, with a receipt committed in the same transaction. Retry the same revision and digest after a lost reply; never guess whether a save failed. Report saved, applied, restart_required and hardware_verified separately. This tool does not command hardware or approve Cloud sharing.", object(commit, "revision", "base_revision", "sha256", "confirmed"), false},
{"setup_restart", "Apply saved setup that needs a restart", protocol.MsgSetupRestart,
Expand Down
17 changes: 15 additions & 2 deletions internal/mcp/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func TestSetupScopeUpgradePreservesReadsAndSignalsChatGPT(t *testing.T) {
}
meta, _ := result["_meta"].(map[string]any)
challenge := meta["mcp/www_authenticate"].([]any)[0].(string)
if challenge != r.Header.Get("WWW-Authenticate") || !strings.Contains(challenge, `scope="ftw.config.read ftw.setup.write"`) {
if challenge != r.Header.Get("WWW-Authenticate") || !strings.Contains(challenge, `scope="ftw.config.read ftw.diag.run ftw.setup.write"`) {
t.Fatal(challenge)
}
}
Expand All @@ -82,9 +82,22 @@ func TestSetupScopeUpgradePreservesReadsAndSignalsChatGPT(t *testing.T) {
for _, tool := range w.mcp.listing() {
if tool["name"] == "setup_edit" {
schemes := tool["securitySchemes"].([]map[string]any)
if schemes[0]["scopes"].([]string)[0] != oauth.ScopeSetupWrite {
if !slices.Equal(schemes[0]["scopes"].([]string), []string{oauth.ScopeDiagRun, oauth.ScopeSetupWrite}) {
t.Fatal(schemes)
}
}
}
}

func TestSetupWriteWithoutDiagnosticReadsRequestsBothRights(t *testing.T) {
w := newWorld(t)
b := w.connectBox()
a := w.connect(b, oauth.ScopeSetupWrite, oauth.ScopeSetupWrite)
r := a.rpc("tools/call", map[string]any{"name": "setup_edit", "arguments": map[string]any{"revision": 0, "patch": map[string]any{}}}, false)
if r.Status != http.StatusForbidden || !strings.Contains(r.Header.Get("WWW-Authenticate"), `scope="ftw.diag.run ftw.setup.write"`) {
t.Fatal("a write-only grant bypassed the setup read requirement", r.Status, r.Body)
}
if _, _, asked := b.counts(); len(asked) != 0 {
t.Fatal("a write-only setup reached the box", asked)
}
}
17 changes: 13 additions & 4 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ type tool struct {
run func(ctx context.Context, cc *callCtx, args json.RawMessage) (any, error)
}

// Setup writes need the diagnostic reads that let the assistant understand
// the schema, review a draft and check a save after a lost reply.
func (t *tool) requiredScopes() []string {
if t.scope == oauth.ScopeSetupWrite {
return []string{oauth.ScopeDiagRun, oauth.ScopeSetupWrite}
}
if t.scope == "" {
return []string{}
}
return []string{t.scope}
}

// callCtx is one call's grant and the way to its home.
type callCtx struct {
s *Server
Expand Down Expand Up @@ -90,10 +102,7 @@ func (s *Server) tool(name string) *tool {
func (s *Server) listing() []map[string]any {
out := make([]map[string]any, 0, len(s.tools))
for _, t := range s.tools {
scopes := []string{}
if t.scope != "" {
scopes = append(scopes, t.scope)
}
scopes := t.requiredScopes()
schemes := []map[string]any{{"type": "oauth2", "scopes": scopes}}
out = append(out, map[string]any{
"name": t.name,
Expand Down
16 changes: 13 additions & 3 deletions internal/oauth/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) {
if len(req.scope) == 0 {
req.scope = slices.Clone(ReadScopes)
}
chooseMode := !q.Has("scope")
if chooseMode {
req.scope = nil
}

// The agent's key pair comes first; only its public half leaves the
// keys part, to be shown to the owner and listed by the box.
Expand All @@ -145,9 +149,11 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) {
Resource: Resource, Kid: kid, AgentKey: pub, Created: now, Expires: now.Add(RequestTTL), Status: statusPending}
for try := 0; ; try++ {
con.Code = shortCode()
if con.Offer, err = s.signOffer(ctx, kid, buildOffer(con)); err != nil {
s.log.Warn("the keys part could not sign an offer", "err", err)
break
if !chooseMode {
if con.Offer, err = s.signOffer(ctx, kid, buildOffer(con)); err != nil {
s.log.Warn("the keys part could not sign an offer", "err", err)
break
}
}
err = s.st.putConsent(rid, browser, con)
if err == nil || try == 3 {
Expand Down Expand Up @@ -291,6 +297,10 @@ func (s *Server) serveRequest(w http.ResponseWriter, r *http.Request) {
return
}
allowApp(w, r)
if c.Offer == "" {
writeJSON(w, http.StatusConflict, map[string]string{"status": "mode_required"}, false)
return
}
v := s.view(c)
v.Request = r.PathValue("rid")
writeJSON(w, http.StatusOK, v, false)
Expand Down
4 changes: 4 additions & 0 deletions internal/oauth/invite.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ func (s *Server) serveInvite(w http.ResponseWriter, r *http.Request) {
http.Error(w, "This connection request ended. Connect again from your assistant.", http.StatusGone)
return
}
if c.Offer == "" {
http.Error(w, "Choose what this connection is for first.", http.StatusConflict)
return
}
var body struct {
Code string `json:"code"`
}
Expand Down
120 changes: 120 additions & 0 deletions internal/oauth/mode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package oauth

import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"slices"
"strings"
"time"
)

// Only the browser that started a request may choose its default rights.
// A client that names scopes keeps exactly that request, without this step.
func (s *Server) modeBrowser(w http.ResponseWriter, r *http.Request) (string, string, bool) {
w.Header().Set("Cache-Control", "no-store")
rid := r.PathValue("rid")
id, err := base64.RawURLEncoding.DecodeString(rid)
if err != nil || len(id) != 16 || (r.Method == http.MethodPost && r.Header.Get("Origin") != Issuer) {
http.Error(w, "Open this connection page from your assistant.", http.StatusForbidden)
return "", "", false
}
ck, err := r.Cookie(cookieName(rid))
if err != nil || !s.st.browserMatches(rid, ck.Value) {
http.Error(w, "Return to the browser that started this connection.", http.StatusForbidden)
return "", "", false
}
return rid, ck.Value, true
}

func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
rid, _, ok := s.modeBrowser(w, r)
if !ok {
return
}
c, exists, err := s.st.consentByRID(rid)
if err != nil || !exists {
http.NotFound(w, r)
return
}
if c.Status == statusDenied || !s.now().Before(c.Expires) {
s.authorizePage(w, http.StatusGone, pageError{Title: "Connect again", Text: "This request ended. Start a new connection from your assistant."})
return
}
s.consentPage(w, rid, c)
}

func (s *Server) serveMode(w http.ResponseWriter, r *http.Request) {
rid, browser, ok := s.modeBrowser(w, r)
if !ok {
return
}
var body struct {
Mode string `json:"mode"`
}
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256))
d.DisallowUnknownFields()
if d.Decode(&body) != nil || d.Decode(new(any)) != io.EOF || (body.Mode != "read" && body.Mode != "setup") {
http.Error(w, "Choose read and troubleshoot, or set up and configure.", http.StatusBadRequest)
return
}
c, exists, err := s.st.consentByRID(rid)
if err != nil || !exists {
http.NotFound(w, r)
return
}
if (c.Status != statusPending && c.Status != statusApproved) || !s.now().Before(c.Expires) {
http.Error(w, "This request ended. Connect again from your assistant.", http.StatusGone)
return
}
scopes := slices.Clone(ReadScopes)
if body.Mode == "setup" {
scopes = append(scopes, ScopeSetupWrite)
}
wanted := strings.Join(scopes, " ")
if c.Status == statusPending && c.Scope == "" && c.Offer == "" {
if s.limited(&s.authorizeLimit, s.authorizeByIP, r) {
w.Header().Set("Retry-After", "10")
http.Error(w, "FTW Cloud is busy connecting assistants. Try the same choice again shortly.", http.StatusTooManyRequests)
return
}
// Signing can wait on the key process. Do it outside SQLite's lock;
// the write below checks the browser, phase and deadline again.
c.Scope = wanted
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second)
defer cancel()
c.Offer, err = s.signOffer(ctx, c.Kid, buildOffer(c))
if err != nil {
http.Error(w, "The choice was not saved. Try the same choice again.", http.StatusServiceUnavailable)
return
}
_, err = s.st.db.Exec(`UPDATE requests SET scope = ?, offer = ?
WHERE rid = ? AND browser = ? AND scope = '' AND offer = '' AND status = ? AND expires_ms > ?`,
c.Scope, c.Offer, hash(rid), hash(browser), statusPending, s.now().UnixMilli())
if err != nil {
http.Error(w, "The choice is not confirmed. Try the same choice again.", http.StatusServiceUnavailable)
return
}
c, exists, err = s.st.consentByRID(rid)
if err != nil || !exists {
http.Error(w, "The choice is not confirmed. Try the same choice again.", http.StatusServiceUnavailable)
return
}
}
if (c.Status != statusPending && c.Status != statusApproved) || !s.now().Before(c.Expires) {
http.Error(w, "This request ended. Connect again from your assistant.", http.StatusGone)
return
}
if c.Offer == "" {
http.Error(w, "This request is incomplete. Connect again from your assistant.", http.StatusGone)
return
}
status := http.StatusOK
state := "ready"
if c.Scope != wanted {
status, state = http.StatusConflict, "choice_locked"
}
writeJSON(w, status, map[string]string{"state": state, "connect": "/oauth/requests/" + rid + "/connect"}, false)
}
Loading
Loading