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
30 changes: 27 additions & 3 deletions internal/publish/submission.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ type SubMethod struct {
// $APP/secrets.json under SecretKey, from which the byo ${TOKEN} headers resolve
// it). One SubSignup describes one leg, selected by Step.
type SubSignup struct {
Step string `json:"step"` // "create" | "register" | "verify" | "broker" | "account"
Step string `json:"step"` // "create" | "register" | "verify" | "broker" | "account" | "set_key"
URL string `json:"url"` // create/register/verify: the provider endpoint POSTed
BrokerURL string `json:"broker_url"` // broker: the Pilot broker /signup endpoint (signed)
KeyPath string `json:"key_path"` // create/verify: dotted path to the key (create defaults to data.api_key)
Expand Down Expand Up @@ -220,6 +220,20 @@ type SubRoute struct {
// metadata file so a SubLocal method can recall it (e.g. buy_number →
// ~/.pilot/.agentphone). Best-effort; never fails the call.
CaptureTo string `json:"capture_to"`
// Public marks an endpoint the provider serves without credentials; it is
// exempt from the adapter's no-key-yet gate (see scaffold.HTTPRoute.Public).
Public bool `json:"public"`
// SaveKey makes this route the one that issues the byo API key: the string
// at Path in its JSON answer is cached under SecretKey and redacted from the
// reply (see scaffold.HTTPRoute.SaveKey). Start names the signup's first method.
SaveKey *SubSaveKey `json:"save_key,omitempty"`
}

// SubSaveKey is scaffold.SaveKeyRoute in submission form.
type SubSaveKey struct {
Path string `json:"path"`
SecretKey string `json:"secret_key"`
Start string `json:"start,omitempty"`
}

// SubCLIRoute is the backend CLI mapping for a method. Enumerated methods bake
Expand Down Expand Up @@ -468,12 +482,19 @@ func validateSubSignupMethod(n string, m SubMethod) []string {
if strings.TrimSpace(m.Signup.SecretKey) == "" {
e = append(e, fmt.Sprintf("Method %q: a broker signup step needs signup.secret_key", n))
}
case "set_key":
if strings.TrimSpace(m.Signup.SecretKey) == "" {
e = append(e, fmt.Sprintf("Method %q: a set_key step needs signup.secret_key", n))
}
if strings.TrimSpace(m.Signup.URL) != "" {
https("url", m.Signup.URL)
}
case "account":
if strings.TrimSpace(m.Signup.SecretKey) == "" {
e = append(e, fmt.Sprintf("Method %q: an account step needs signup.secret_key", n))
}
default:
e = append(e, fmt.Sprintf("Method %q: signup.step must be create|register|verify|broker|account", n))
e = append(e, fmt.Sprintf("Method %q: signup.step must be create|register|verify|broker|account|set_key", n))
}
if m.HasHTTP() || m.HasCLI() || m.HasLocal() {
e = append(e, fmt.Sprintf("Method %q: a signup method must not also declare an http/cli/local route", n))
Expand Down Expand Up @@ -717,7 +738,10 @@ func (s Submission) ToConfig() *scaffold.Config {
Passthrough: m.CLI.Passthrough,
}
default:
route := &scaffold.HTTPRoute{Verb: orDefault(m.HTTP.Verb, "GET"), Path: m.HTTP.Path, CaptureTo: m.HTTP.CaptureTo}
route := &scaffold.HTTPRoute{Verb: orDefault(m.HTTP.Verb, "GET"), Path: m.HTTP.Path, CaptureTo: m.HTTP.CaptureTo, Public: m.HTTP.Public}
if sk := m.HTTP.SaveKey; sk != nil {
route.SaveKey = &scaffold.SaveKeyRoute{Path: sk.Path, SecretKey: sk.SecretKey, Start: sk.Start}
}
// Carry each param's explicit request location so the generator can
// resolve query/path/path_raw/body/header placement. Omitted `in`
// keeps the verb/path default (back-compat).
Expand Down
140 changes: 135 additions & 5 deletions internal/scaffold/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,19 +312,27 @@ func (c *Config) Provisioned() bool { return c.Backend.Auth == "provisioned" }
// locally, so — like a provisioned app — it needs fs.read+fs.write on that file.
func (c *Config) HasSignup() bool {
for _, m := range c.Methods {
if m.Signup != nil {
if m.Signup != nil || m.saveKey() != nil {
return true
}
}
return false
}

// saveKey returns the method's key-issuing http route config, or nil.
func (m Method) saveKey() *SaveKeyRoute {
if m.HTTP != nil {
return m.HTTP.SaveKey
}
return nil
}

// HasKeyMintSignup reports whether any method mints the byo auth key itself (a
// create/verify/broker signup route). When true, the generated adapter soft-fails
// unauthenticated calls with activation instructions instead of a raw 401.
func (c *Config) HasKeyMintSignup() bool {
for _, m := range c.Methods {
if m.Signup != nil && (m.Signup.IsCreate() || m.Signup.IsVerify() || m.Signup.IsBroker()) {
if (m.Signup != nil && m.Signup.mintsKey()) || m.saveKey() != nil {
return true
}
}
Expand All @@ -335,9 +343,12 @@ func (c *Config) HasKeyMintSignup() bool {
// (the first key-minting signup route's SecretKey), or "".
func (c *Config) AuthSecretKey() string {
for _, m := range c.Methods {
if m.Signup != nil && (m.Signup.IsCreate() || m.Signup.IsVerify() || m.Signup.IsBroker()) {
if m.Signup != nil && m.Signup.mintsKey() {
return m.Signup.SecretKey
}
if sk := m.saveKey(); sk != nil {
return sk.SecretKey
}
}
return ""
}
Expand All @@ -355,9 +366,71 @@ func (c *Config) SignupMethodName() string {
return m.Name
}
}
for _, m := range c.Methods {
if sk := m.saveKey(); sk != nil {
if sk.Start != "" {
return sk.Start
}
return m.Name
}
}
for _, m := range c.Methods {
if m.Signup != nil && m.Signup.IsSetKey() {
return m.Name
}
}
return ""
}

// SignupHint is the one-line activation instruction the adapter returns, in
// place of a doomed 401, when an authenticated call arrives before any key is
// on the host. It names the method SignupMethodName picks and says what to pass.
func (c *Config) SignupHint() string {
name := c.SignupMethodName()
for _, m := range c.Methods {
if sk := m.saveKey(); sk != nil && (name == sk.Start || name == m.Name) {
issuer := m.Name
n := 0
for _, o := range c.Methods {
if o.saveKey() != nil {
n++
}
}
if n > 1 {
issuer = "the provider" // several steps can issue it (e.g. sign-in vs new account)
}
hint := "No API key on this host yet. Call " + name + " to start signup; the key is stored on this host as soon as " +
issuer + " issues it, and injected on every call automatically."
for _, o := range c.Methods {
if o.Signup != nil && o.Signup.IsSetKey() {
hint += " Already have a key? Save it with " + o.Name + "."
}
}
return hint
}
}
for _, m := range c.Methods {
if m.Name != name || m.Signup == nil {
continue
}
switch {
case m.Signup.IsSetKey():
where := "from your account with the provider"
if m.Signup.URL != "" {
where = "at " + m.Signup.URL
}
return "No API key on this host yet. Create one " + where + ", then call " + name +
` once with {"api_key":"..."} — it is stored locally under ~/.pilot and injected on every call automatically.`
case strings.EqualFold(m.Signup.Step, "register"):
return "No API key on this host yet. Call " + name +
" to start signup, then finish with the verify method — the key is then stored locally under ~/.pilot and injected on every call automatically."
}
}
return "No API key on this host yet. Call " + name +
" once (no arguments required) to provision a free account and managed inbox — " +
"the key is then stored locally under ~/.pilot and injected on every call automatically; you never pass it."
}

// HasBrokerSignup reports whether any signup route is the broker step — the
// adapter then needs an ed25519 signer + identity (to sign the keyless broker
// call) and key.sign + net.dial-broker grants, even though its ops stay byo.
Expand Down Expand Up @@ -701,7 +774,7 @@ type Method struct {
// {email, api_key}. The adapter caches BOTH to secrets.json; ops stay byo.
// No user email, no code, one call.
type SignupRoute struct {
Step string `yaml:"step"` // "create" | "register" | "verify" | "broker" | "account"
Step string `yaml:"step"` // "create" | "register" | "verify" | "broker" | "account" | "set_key"
URL string `yaml:"url"` // create/register/verify: the provider endpoint POSTed
BrokerURL string `yaml:"broker_url"` // broker: the Pilot broker /signup endpoint (signed)
KeyPath string `yaml:"key_path"` // create/verify: dotted path to the key in the response (default application.api_key; create defaults to data.api_key)
Expand All @@ -722,6 +795,16 @@ func (s *SignupRoute) IsVerify() bool { return strings.EqualFold(s.Step, "verify
// IsBroker is the fully-autonomous leg that signs a call to the Pilot broker.
func (s *SignupRoute) IsBroker() bool { return strings.EqualFold(s.Step, "broker") }

// IsSetKey is the plain byo leg: the user obtains a key from the provider
// themselves (URL, when set, says where) and hands it to this local method,
// which caches it under SecretKey. No backend call is made.
func (s *SignupRoute) IsSetKey() bool { return strings.EqualFold(s.Step, "set_key") }

// mintsKey reports whether this route is what puts the byo auth key on the host.
func (s *SignupRoute) mintsKey() bool {
return s.IsCreate() || s.IsVerify() || s.IsBroker() || s.IsSetKey()
}

// BodyJSON renders the static create body as a compact JSON object literal (or
// "{}"), baked into the generated adapter and re-parsed at runtime.
func (s *SignupRoute) BodyJSON() string {
Expand All @@ -735,6 +818,15 @@ func (s *SignupRoute) BodyJSON() string {
return string(b)
}

// SaveKeyRoute says where a route's response carries the API key it issues.
type SaveKeyRoute struct {
Path string `yaml:"path"` // dotted path to the key in the JSON response, e.g. apiKey or data.api_key
SecretKey string `yaml:"secret_key"` // secrets.json key it is cached under, e.g. DIAL_API_KEY
// Start names the method an agent calls to begin the signup that ends in
// this route (e.g. dial.signup); it is what the no-key-yet hint points to.
Start string `yaml:"start"`
}

// LocalRoute makes a method run entirely on the host with NO backend call: it
// reads a local JSON metadata file (see HTTPRoute.CaptureTo, which writes it) and
// returns its contents. Used for host-local state an agent should recall without
Expand Down Expand Up @@ -835,6 +927,19 @@ type HTTPRoute struct {
// the provisioned number to ~/.pilot/.agentphone.
CaptureTo string `yaml:"capture_to"`

// Public marks an endpoint the provider serves without credentials (a
// catalogue, a recommendation, a signup handshake). An app whose key comes
// from a signup route soft-fails authenticated calls until a key exists;
// a public route is exempt, so it keeps working before signup.
Public bool `yaml:"public"`

// SaveKey, when set, makes this route the one that issues the byo API key
// (a provider whose signup returns the key from an ordinary endpoint, e.g.
// Dial's /auth/verify-number). On a 2xx JSON answer the string at Path is
// cached under SecretKey in $APP/secrets.json and replaced in the reply, so
// the key is never handed to the agent and every later call carries it.
SaveKey *SaveKeyRoute `yaml:"save_key"`

// Multipart, when set, sends this method as multipart/form-data built from a
// staged blob rather than as a JSON body. See MultipartRoute.
Multipart *MultipartRoute `yaml:"multipart"`
Expand Down Expand Up @@ -1442,13 +1547,21 @@ func (c *Config) validateSignupMethod(i int, m Method) []error {
if strings.TrimSpace(m.Signup.SecretKey) == "" {
errs = append(errs, fmt.Errorf("methods[%d] (%s): a broker signup step needs signup.secret_key (the key the minted secret is cached under)", i, m.Name))
}
case "set_key":
// A local writer of a user-supplied key — no backend call to validate.
if strings.TrimSpace(m.Signup.SecretKey) == "" {
errs = append(errs, fmt.Errorf("methods[%d] (%s): a set_key step needs signup.secret_key (the key the supplied secret is cached under)", i, m.Name))
}
if strings.TrimSpace(m.Signup.URL) != "" {
httpsURL("url", m.Signup.URL)
}
case "account":
// A local reader of the cached account — needs only the secrets keys.
if strings.TrimSpace(m.Signup.SecretKey) == "" {
errs = append(errs, fmt.Errorf("methods[%d] (%s): an account step needs signup.secret_key", i, m.Name))
}
default:
errs = append(errs, fmt.Errorf("methods[%d] (%s): signup.step %q must be create|register|verify|broker|account", i, m.Name, m.Signup.Step))
errs = append(errs, fmt.Errorf("methods[%d] (%s): signup.step %q must be create|register|verify|broker|account|set_key", i, m.Name, m.Signup.Step))
}
if c.Managed() {
errs = append(errs, fmt.Errorf("methods[%d] (%s): a signup (no-broker) route cannot be combined with managed/provisioned auth", i, m.Name))
Expand All @@ -1469,6 +1582,23 @@ func (c *Config) validateHTTPMethod(i int, m Method) []error {
if m.HTTP.Path == "" || !strings.HasPrefix(m.HTTP.Path, "/") {
errs = append(errs, fmt.Errorf("methods[%d].http.path must start with /", i))
}
if sk := m.HTTP.SaveKey; sk != nil {
if strings.TrimSpace(sk.Path) == "" || strings.TrimSpace(sk.SecretKey) == "" {
errs = append(errs, fmt.Errorf("methods[%d] (%s): http.save_key needs both path and secret_key", i, m.Name))
}
if c.Managed() {
errs = append(errs, fmt.Errorf("methods[%d] (%s): http.save_key cannot be combined with managed/provisioned auth", i, m.Name))
}
if sk.Start != "" {
found := false
for _, o := range c.Methods {
found = found || o.Name == sk.Start
}
if !found {
errs = append(errs, fmt.Errorf("methods[%d] (%s): http.save_key.start %q is not a method of this app", i, m.Name, sk.Start))
}
}
}
switch m.HTTP.Verb {
case "GET", "POST", "PATCH", "PUT", "DELETE":
default:
Expand Down
17 changes: 10 additions & 7 deletions internal/scaffold/templates/main.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,12 @@ func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL
rawPathParams: []string{ {{- range $p := .HTTP.RawPathParams}}{{printf "%q" $p}}, {{end -}} },
}, manifestPath, dur("{{.TimeoutFor}}"))) // {{.Duration}} (multipart upload)
{{- else if .Signup}}
{{- if eq .Signup.Step "account"}}
{{- if .Signup.IsSetKey}}
d.Register("{{.Name}}", setKeyHandler(setKeyConfig{
secretKey: {{printf "%q" .Signup.SecretKey}},
keyURL: {{printf "%q" .Signup.URL}},
}, manifestPath)) // {{.Duration}} (local: cache a key you obtained from the provider)
{{- else if eq .Signup.Step "account"}}
d.Register("{{.Name}}", accountHandler(accountConfig{
secretKey: {{printf "%q" .Signup.SecretKey}},
emailKey: {{printf "%q" .Signup.EmailKey}},
Expand Down Expand Up @@ -238,15 +243,15 @@ func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL
}, manifestPath, dur("{{.TimeoutFor}}"))) // {{.Duration}} (no-broker self-signup: register)
{{- end}}
{{- else}}
d.Register("{{.Name}}", {{if $.HasKeyMintSignup}}requireKey(manifestPath, {{printf "%q" $.AuthSecretKey}}, {{printf "%q" $.SignupMethodName}}, {{end}}{{if .HTTP.CaptureTo}}captureWrap(expandHome("{{.HTTP.CaptureTo}}"), {{end}}forward(c, route{
d.Register("{{.Name}}", {{if and $.HasKeyMintSignup (not .HTTP.Public)}}requireKey(manifestPath, {{printf "%q" $.AuthSecretKey}}, {{printf "%q" $.SignupMethodName}}, {{printf "%q" $.SignupHint}}, {{end}}{{if .HTTP.SaveKey}}saveKeyWrap(manifestPath, {{printf "%q" .HTTP.SaveKey.Path}}, {{printf "%q" .HTTP.SaveKey.SecretKey}}, {{end}}{{if .HTTP.CaptureTo}}captureWrap(expandHome("{{.HTTP.CaptureTo}}"), {{end}}forward(c, route{
method: "{{.HTTP.Verb}}", pathTmpl: "{{.HTTP.Path}}", bodyVerb: {{.HTTP.BodyVerb}},
pathParams: []string{ {{- range $p := .HTTP.PathParams}}{{printf "%q" $p}}, {{end -}} },
rawPathParams: []string{ {{- range $p := .HTTP.RawPathParams}}{{printf "%q" $p}}, {{end -}} },
queryParams: []string{ {{- range $p := .HTTP.QueryParams}}{{printf "%q" $p}}, {{end -}} },
bodyParams: []string{ {{- range $p := .HTTP.BodyParams}}{{printf "%q" $p}}, {{end -}} },
headerParams: []string{ {{- range $p := .HTTP.HeaderParams}}{{printf "%q" $p}}, {{end -}} },
bodyRawParam: "{{.HTTP.BodyRawParam}}",
}, dur("{{.TimeoutFor}}")){{if .HTTP.CaptureTo}}){{end}}{{if $.HasKeyMintSignup}}){{end}}) // {{.Duration}}
}, dur("{{.TimeoutFor}}")){{if .HTTP.CaptureTo}}){{end}}{{if .HTTP.SaveKey}}){{end}}{{if and $.HasKeyMintSignup (not .HTTP.Public)}}){{end}}) // {{.Duration}}
{{- end}}
{{- end}}
d.Register("{{.Namespace}}.help", helpHandler(version, backendURL))
Expand Down Expand Up @@ -288,16 +293,14 @@ func localRead(store string) ipc.Handler {
// method mints the key, it is stored locally under $APP/secrets.json (in ~/.pilot)
// and the adapter injects it into the Authorization header on every subsequent
// call automatically — the agent never sees or passes the key.
func requireKey(manifestPath, secretKey, signupMethod string, h ipc.Handler) ipc.Handler {
func requireKey(manifestPath, secretKey, signupMethod, hint string, h ipc.Handler) ipc.Handler {
return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) {
if os.Getenv(secretKey) == "" && loadSecrets(manifestPath)[secretKey] == "" {
msg := map[string]any{
"ok": false,
"needs_signup": true,
"activate": signupMethod,
"message": "No API key on this host yet. Call " + signupMethod +
" once (no arguments required) to provision a free account and managed inbox — " +
"the key is then stored locally under ~/.pilot and injected on every call automatically; you never pass it.",
"message": hint,
}
b, _ := json.Marshal(msg)
return json.RawMessage(b), nil
Expand Down
Loading
Loading