From eec57a3888cf02693ebd5010af7621f09c5b5219 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Fri, 25 Sep 2026 15:07:59 +0200 Subject: [PATCH 1/4] scaffold: set_key signup step for plain bring-your-own-key apps A byo app whose users create their key on the provider's site had no way to hand that key to the adapter short of editing $APP/secrets.json by hand and restarting the app. The new set_key step generates a local .set_key method that caches a caller-supplied key (overwriting, so a revoked key can be replaced) and is read per request like any minted key. Calls made before a key exists soft-fail with a hint naming set_key and where to get a key. The activation hint is now step-aware: it previously told agents to call the signup method with no arguments even for register flows that need an email. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/publish/submission.go | 11 +- internal/scaffold/config.go | 58 ++++++++++- internal/scaffold/templates/main.go.tmpl | 15 +-- internal/scaffold/templates/signup.go.tmpl | 49 +++++++++ internal/scaffold/zz_set_key_test.go | 111 +++++++++++++++++++++ 5 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 internal/scaffold/zz_set_key_test.go diff --git a/internal/publish/submission.go b/internal/publish/submission.go index 4df1707..0c03f22 100644 --- a/internal/publish/submission.go +++ b/internal/publish/submission.go @@ -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) @@ -468,12 +468,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)) diff --git a/internal/scaffold/config.go b/internal/scaffold/config.go index bf15347..307dd0f 100644 --- a/internal/scaffold/config.go +++ b/internal/scaffold/config.go @@ -324,7 +324,7 @@ func (c *Config) HasSignup() bool { // 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() { return true } } @@ -335,7 +335,7 @@ 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 } } @@ -355,9 +355,41 @@ func (c *Config) SignupMethodName() string { 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 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. @@ -701,7 +733,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) @@ -722,6 +754,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 { @@ -1442,13 +1484,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)) diff --git a/internal/scaffold/templates/main.go.tmpl b/internal/scaffold/templates/main.go.tmpl index b0d53c9..fcaf146 100644 --- a/internal/scaffold/templates/main.go.tmpl +++ b/internal/scaffold/templates/main.go.tmpl @@ -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}}, @@ -238,7 +243,7 @@ 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 $.HasKeyMintSignup}}requireKey(manifestPath, {{printf "%q" $.AuthSecretKey}}, {{printf "%q" $.SignupMethodName}}, {{printf "%q" $.SignupHint}}, {{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 -}} }, @@ -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 diff --git a/internal/scaffold/templates/signup.go.tmpl b/internal/scaffold/templates/signup.go.tmpl index c9e4a78..1fb0b4d 100644 --- a/internal/scaffold/templates/signup.go.tmpl +++ b/internal/scaffold/templates/signup.go.tmpl @@ -285,6 +285,50 @@ func accountHandler(ac accountConfig, manifestPath string) ipc.Handler { } } +// setKeyConfig / setKeyHandler are the plain byo leg: the user creates a key at +// the provider (keyURL says where) and hands it over once. It is cached under +// secretKey in $APP/secrets.json and injected on every later call. Unlike the +// minting steps this OVERWRITES a cached key, so a revoked or mistyped key can be +// replaced without touching files by hand. Local, no backend call. +type setKeyConfig struct { + secretKey string + keyURL string +} + +func setKeyHandler(sc setKeyConfig, manifestPath string) ipc.Handler { + return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { + var in struct { + APIKey string `json:"api_key"` + } + if len(req.Payload) > 0 { + _ = json.Unmarshal(req.Payload, &in) + } + in.APIKey = strings.TrimSpace(in.APIKey) + if in.APIKey == "" { + where := "" + if sc.keyURL != "" { + where = " (create one at " + sc.keyURL + ")" + } + return nil, errors.New("set_key: api_key is required — pass {\"api_key\":\"...\"}" + where) + } + dir := signupAppDir(manifestPath) + if dir == "" { + return nil, errors.New("set_key: cannot locate the app directory to cache the key") + } + path := filepath.Join(dir, "secrets.json") + m := signupReadSecrets(path) + replaced := m[sc.secretKey] != "" + m[sc.secretKey] = in.APIKey + if err := signupWriteSecrets(path, m); err != nil { + return nil, fmt.Errorf("set_key: cache key: %w", err) + } + return signupResult(map[string]any{ + "ok": true, "key_field": sc.secretKey, "api_key": "saved", "replaced": replaced, + "message": "key cached to secrets.json and used on every subsequent call", + }) + } +} + // ── shared helpers ────────────────────────────────────────────────────────── func signupResult(m map[string]any) (json.RawMessage, error) { @@ -326,6 +370,11 @@ func signupMergeSecrets(path string, add map[string]string) error { m[k] = v } } + return signupWriteSecrets(path, m) +} + +// signupWriteSecrets atomically replaces path (0600) with m. +func signupWriteSecrets(path string, m map[string]string) error { out, err := json.MarshalIndent(m, "", " ") if err != nil { return err diff --git a/internal/scaffold/zz_set_key_test.go b/internal/scaffold/zz_set_key_test.go new file mode 100644 index 0000000..05d804e --- /dev/null +++ b/internal/scaffold/zz_set_key_test.go @@ -0,0 +1,111 @@ +package scaffold + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// setKeySpec is a plain byo app: the user creates a key at the provider and +// hands it to .set_key; every other method resolves it from secrets.json. +const setKeySpec = ` +id: io.pilot.rentahuman +app_version: 0.2.1 +description: "Hire humans for real-world tasks with your own RentAHuman API key." +backend: + type: http + base_url: https://rentahuman.ai + auth: byo + headers: + X-API-Key: "${RENTAHUMAN_API_KEY}" +methods: + - name: rentahuman.set_key + summary: "Save the RentAHuman API key this host uses." + duration: fast + signup: + step: set_key + url: https://rentahuman.ai/account/api-keys + secret_key: RENTAHUMAN_API_KEY + - name: rentahuman.list_bounties + summary: "List open bounties." + duration: fast + http: {verb: GET, path: /api/partner/v1/bounties} +` + +func TestSetKeyStepGeneratesAndCompiles(t *testing.T) { + cfg := parseSpec(t, setKeySpec) + if !cfg.HasSignup() || !cfg.HasKeyMintSignup() { + t.Fatal("HasSignup() and HasKeyMintSignup() should be true for a set_key step") + } + if got := cfg.AuthSecretKey(); got != "RENTAHUMAN_API_KEY" { + t.Errorf("AuthSecretKey()=%q, want RENTAHUMAN_API_KEY", got) + } + if got := cfg.SignupMethodName(); got != "rentahuman.set_key" { + t.Errorf("SignupMethodName()=%q, want rentahuman.set_key", got) + } + hint := cfg.SignupHint() + for _, want := range []string{"rentahuman.set_key", "https://rentahuman.ai/account/api-keys", `"api_key"`} { + if !strings.Contains(hint, want) { + t.Errorf("SignupHint() %q missing %q", hint, want) + } + } + if strings.Contains(hint, "no arguments required") { + t.Errorf("set_key hint must not claim no arguments are needed: %q", hint) + } + + dir := t.TempDir() + if _, err := Generate(cfg, dir); err != nil { + t.Fatalf("generate: %v", err) + } + main, _ := os.ReadFile(filepath.Join(dir, "cmd", cfg.BinaryName, "main.go")) + for _, want := range []string{"setKeyHandler(setKeyConfig{", "requireKey(", "rentahuman.ai/account/api-keys", "HeaderFunc:"} { + if !strings.Contains(string(main), want) { + t.Errorf("generated main.go missing %q", want) + } + } + mf, _ := os.ReadFile(filepath.Join(dir, "manifest.json")) + if !strings.Contains(string(mf), `"cap": "fs.write", "target": "$APP/secrets.json"`) { + t.Errorf("manifest must grant fs.write on secrets.json for set_key") + } + if testing.Short() { + return + } + compileGenerated(t, dir) +} + +func TestSetKeyStepValidation(t *testing.T) { + validate := func(spec string) []error { + t.Helper() + cfg, err := Parse([]byte(spec)) + if err != nil { + t.Fatalf("parse: %v", err) + } + cfg.Resolve() + return cfg.Validate() + } + if errs := validate(setKeySpec); len(errs) != 0 { + t.Fatalf("valid set_key spec rejected: %v", errs) + } + bad := strings.Replace(setKeySpec, " secret_key: RENTAHUMAN_API_KEY\n", "", 1) + errs := validate(bad) + found := false + for _, e := range errs { + found = found || strings.Contains(e.Error(), "set_key step needs signup.secret_key") + } + if !found { + t.Errorf("missing secret_key: errs=%v, want the set_key secret_key error", errs) + } + insecure := strings.Replace(setKeySpec, "https://rentahuman.ai/account/api-keys", "http://rentahuman.ai/account/api-keys", 1) + if errs := validate(insecure); len(errs) == 0 { + t.Error("an http:// set_key url should be rejected") + } +} + +// The register flow's hint must not tell agents to call it with no arguments. +func TestSignupHintRegisterStep(t *testing.T) { + cfg := parseSpec(t, signupSpec) + if h := cfg.SignupHint(); strings.Contains(h, "no arguments required") || !strings.Contains(h, "didit.signup") { + t.Errorf("register hint = %q", h) + } +} From 34a33e23175d3d7e734f9a75d657dcbf59cdcd10 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Fri, 25 Sep 2026 15:10:48 +0200 Subject: [PATCH 2/4] scaffold: public http routes skip the no-key-yet gate Apps whose key comes from a signup route wrap every http method in requireKey, so endpoints the provider serves without credentials (method catalogues, recommendations, the signup handshake itself) were unreachable until a key existed. A route marked public: true is forwarded as-is. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/publish/submission.go | 5 ++++- internal/scaffold/config.go | 6 ++++++ internal/scaffold/templates/main.go.tmpl | 4 ++-- internal/scaffold/zz_set_key_test.go | 13 +++++++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/internal/publish/submission.go b/internal/publish/submission.go index 0c03f22..f47d61e 100644 --- a/internal/publish/submission.go +++ b/internal/publish/submission.go @@ -220,6 +220,9 @@ 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"` } // SubCLIRoute is the backend CLI mapping for a method. Enumerated methods bake @@ -724,7 +727,7 @@ 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} // 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). diff --git a/internal/scaffold/config.go b/internal/scaffold/config.go index 307dd0f..c1d3f25 100644 --- a/internal/scaffold/config.go +++ b/internal/scaffold/config.go @@ -877,6 +877,12 @@ 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"` + // 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"` diff --git a/internal/scaffold/templates/main.go.tmpl b/internal/scaffold/templates/main.go.tmpl index fcaf146..1aace9f 100644 --- a/internal/scaffold/templates/main.go.tmpl +++ b/internal/scaffold/templates/main.go.tmpl @@ -243,7 +243,7 @@ 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}}, {{printf "%q" $.SignupHint}}, {{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.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 -}} }, @@ -251,7 +251,7 @@ func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL 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 and $.HasKeyMintSignup (not .HTTP.Public)}}){{end}}) // {{.Duration}} {{- end}} {{- end}} d.Register("{{.Namespace}}.help", helpHandler(version, backendURL)) diff --git a/internal/scaffold/zz_set_key_test.go b/internal/scaffold/zz_set_key_test.go index 05d804e..78f2dde 100644 --- a/internal/scaffold/zz_set_key_test.go +++ b/internal/scaffold/zz_set_key_test.go @@ -31,6 +31,10 @@ methods: summary: "List open bounties." duration: fast http: {verb: GET, path: /api/partner/v1/bounties} + - name: rentahuman.list_humans + summary: "Browse humans (public)." + duration: fast + http: {verb: GET, path: /api/humans, public: true} ` func TestSetKeyStepGeneratesAndCompiles(t *testing.T) { @@ -64,6 +68,15 @@ func TestSetKeyStepGeneratesAndCompiles(t *testing.T) { t.Errorf("generated main.go missing %q", want) } } + // The gated route is wrapped; the public one is not. + if !strings.Contains(string(main), `requireKey(manifestPath, "RENTAHUMAN_API_KEY", "rentahuman.set_key", `) { + t.Error("list_bounties should be wrapped in requireKey") + } + for _, line := range strings.Split(string(main), "\n") { + if strings.Contains(line, `d.Register("rentahuman.list_humans"`) && strings.Contains(line, "requireKey(") { + t.Errorf("public route must not be gated on a key: %s", line) + } + } mf, _ := os.ReadFile(filepath.Join(dir, "manifest.json")) if !strings.Contains(string(mf), `"cap": "fs.write", "target": "$APP/secrets.json"`) { t.Errorf("manifest must grant fs.write on secrets.json for set_key") From bd83f54dc0a6c7fec17ae06966ac49301b73a193 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Fri, 25 Sep 2026 17:59:18 +0200 Subject: [PATCH 3/4] scaffold: http.save_key stores a key a provider returns from a normal endpoint Some providers issue the API key in the body of an ordinary call rather than through a signup flow the generator knows: Dial returns it once from /auth/verify (existing account) or /auth/verify-number (new account). Those apps forwarded the key to the agent in plain JSON and never stored it, so every later call was unauthenticated. A route with save_key: {path, secret_key, start} now caches the string at path into $APP/secrets.json on a 2xx answer, replaces it in the reply, and counts as the app's key-minting route: other calls soft-fail with a hint that points at start until a key exists, and send the key once it does. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/publish/submission.go | 14 ++ internal/scaffold/config.go | 68 ++++++++- internal/scaffold/templates/main.go.tmpl | 4 +- internal/scaffold/templates/signup.go.tmpl | 54 +++++++ internal/scaffold/zz_save_key_e2e_test.go | 167 +++++++++++++++++++++ 5 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 internal/scaffold/zz_save_key_e2e_test.go diff --git a/internal/publish/submission.go b/internal/publish/submission.go index f47d61e..de937ac 100644 --- a/internal/publish/submission.go +++ b/internal/publish/submission.go @@ -223,6 +223,17 @@ type SubRoute struct { // 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 @@ -728,6 +739,9 @@ func (s Submission) ToConfig() *scaffold.Config { } default: 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). diff --git a/internal/scaffold/config.go b/internal/scaffold/config.go index c1d3f25..594e534 100644 --- a/internal/scaffold/config.go +++ b/internal/scaffold/config.go @@ -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.mintsKey() { + if (m.Signup != nil && m.Signup.mintsKey()) || m.saveKey() != nil { return true } } @@ -338,6 +346,9 @@ func (c *Config) AuthSecretKey() string { if m.Signup != nil && m.Signup.mintsKey() { return m.Signup.SecretKey } + if sk := m.saveKey(); sk != nil { + return sk.SecretKey + } } return "" } @@ -355,6 +366,14 @@ 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 @@ -368,6 +387,18 @@ func (c *Config) SignupMethodName() string { // 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) { + hint := "No API key on this host yet. Call " + name + " to start signup; the key is stored on this host as soon as " + + m.Name + " 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 @@ -777,6 +808,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 @@ -883,6 +923,13 @@ type HTTPRoute struct { // 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"` @@ -1525,6 +1572,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: diff --git a/internal/scaffold/templates/main.go.tmpl b/internal/scaffold/templates/main.go.tmpl index 1aace9f..7fbf31f 100644 --- a/internal/scaffold/templates/main.go.tmpl +++ b/internal/scaffold/templates/main.go.tmpl @@ -243,7 +243,7 @@ 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 and $.HasKeyMintSignup (not .HTTP.Public)}}requireKey(manifestPath, {{printf "%q" $.AuthSecretKey}}, {{printf "%q" $.SignupMethodName}}, {{printf "%q" $.SignupHint}}, {{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 -}} }, @@ -251,7 +251,7 @@ func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL 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 and $.HasKeyMintSignup (not .HTTP.Public)}}){{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)) diff --git a/internal/scaffold/templates/signup.go.tmpl b/internal/scaffold/templates/signup.go.tmpl index 1fb0b4d..eec2a5b 100644 --- a/internal/scaffold/templates/signup.go.tmpl +++ b/internal/scaffold/templates/signup.go.tmpl @@ -329,6 +329,60 @@ func setKeyHandler(sc setKeyConfig, manifestPath string) ipc.Handler { } } +// saveKeyWrap wraps a route whose response issues the API key (http.save_key). +// On a successful JSON object answer carrying a non-empty string at keyPath, +// the key is cached under secretKey in $APP/secrets.json — overwriting, since +// the provider has just issued a new one — and the reply is rewritten so the +// agent sees that it was saved rather than the key itself. Any other answer +// passes through untouched. +func saveKeyWrap(manifestPath, keyPath, secretKey string, h ipc.Handler) ipc.Handler { + return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { + out, err := h(ctx, req) + if err != nil { + return out, err + } + key := signupDigString(out, keyPath) + if key == "" { + return out, nil + } + dir := signupAppDir(manifestPath) + if dir == "" { + return nil, errors.New("save_key: cannot locate the app directory to cache the key — the key was not stored; sign in again") + } + path := filepath.Join(dir, "secrets.json") + m := signupReadSecrets(path) + m[secretKey] = key + if err := signupWriteSecrets(path, m); err != nil { + return nil, fmt.Errorf("save_key: cache key: %w", err) + } + var obj map[string]any + if json.Unmarshal(out, &obj) != nil { + return signupResult(map[string]any{"ok": true, "key_field": secretKey, "api_key": "saved"}) + } + redactPath(obj, keyPath, "saved on this host as "+secretKey+"; sent on every call") + return signupResult(obj) + } +} + +// redactPath replaces the value at a dotted path in decoded JSON. +func redactPath(obj map[string]any, dotted, with string) { + parts := strings.Split(dotted, ".") + cur := obj + for i, p := range parts { + if i == len(parts)-1 { + if _, ok := cur[p]; ok { + cur[p] = with + } + return + } + next, ok := cur[p].(map[string]any) + if !ok { + return + } + cur = next + } +} + // ── shared helpers ────────────────────────────────────────────────────────── func signupResult(m map[string]any) (json.RawMessage, error) { diff --git a/internal/scaffold/zz_save_key_e2e_test.go b/internal/scaffold/zz_save_key_e2e_test.go new file mode 100644 index 0000000..6604c08 --- /dev/null +++ b/internal/scaffold/zz_save_key_e2e_test.go @@ -0,0 +1,167 @@ +//go:build !windows + +package scaffold + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" +) + +// saveKeySpec mirrors Dial: signup is ordinary endpoints, and the key arrives +// once in the body of a verify call rather than from a signup route. +const saveKeySpec = ` +id: io.pilot.savekeyx +app_version: 0.1.0 +description: "App whose verify endpoint issues the API key." +namespace: savekeyx +backend: + base_url: https://placeholder.invalid + auth: byo + headers: + Authorization: "Bearer ${SAVEKEYX_API_KEY}" +methods: + - name: savekeyx.signup + summary: "Start signup." + http: { verb: POST, path: "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/auth/signup", public: true } + params: { email: the email } + - name: savekeyx.verify + summary: "Finish signup; returns the key once." + http: { verb: POST, path: "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/auth/verify", public: true, save_key: { path: account.apiKey, secret_key: SAVEKEYX_API_KEY, start: savekeyx.signup } } + params: { code: the code } + - name: savekeyx.me + summary: "Authenticated read." + http: { verb: GET, path: "/me" } +` + +func TestSaveKeyStoresRedactsAndSends(t *testing.T) { + cfg := parseSpec(t, saveKeySpec) + if errs := cfg.Validate(); len(errs) != 0 { + t.Fatalf("spec invalid: %v", errs) + } + if !cfg.HasSignup() || !cfg.HasKeyMintSignup() || cfg.AuthSecretKey() != "SAVEKEYX_API_KEY" { + t.Fatalf("save_key route should count as the key-minting route") + } + if got := cfg.SignupMethodName(); got != "savekeyx.signup" { + t.Errorf("SignupMethodName()=%q, want the save_key start method", got) + } + if testing.Short() { + t.Skip("builds and runs a real adapter binary; skipped under -short") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go toolchain not available") + } + + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "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/auth/verify": + _, _ = w.Write([]byte(`{"account":{"id":"acc_1","apiKey":"sk_live_secret"},"created":true}`)) + case "/me": + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"id":"acc_1"}`)) + default: + _, _ = w.Write([]byte(`{"verificationId":"v_1"}`)) + } + })) + defer srv.Close() + + root := t.TempDir() + proj := filepath.Join(root, "proj") + if _, err := Generate(cfg, proj); err != nil { + t.Fatalf("generate: %v", err) + } + if sum, err := os.ReadFile(filepath.Join("..", "..", "go.sum")); err == nil { + _ = os.WriteFile(filepath.Join(proj, "go.sum"), sum, 0o644) + } + bin := filepath.Join(root, "adapter") + build := exec.Command("go", "build", "-o", bin, "./cmd/"+cfg.BinaryName) + build.Dir = proj + build.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build adapter: %v\n%s", err, out) + } + sockDir, err := os.MkdirTemp("", "skx") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(sockDir) + sock := filepath.Join(sockDir, "a.sock") + adapter := exec.Command(bin, "--socket", sock, "--manifest", filepath.Join(proj, "manifest.json")) + adapter.Stderr = os.Stderr + adapter.Env = append(os.Environ(), "SAVEKEYX_BACKEND_URL="+srv.URL) + if err := adapter.Start(); err != nil { + t.Fatalf("start adapter: %v", err) + } + defer func() { _ = adapter.Process.Kill(); _, _ = adapter.Process.Wait() }() + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { + if _, err := os.Stat(sock); err == nil { + break + } + } + call := func(method, args string) string { + t.Helper() + conn, err := net.DialTimeout("unix", sock, 3*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + var out json.RawMessage + if err := ipc.Call(conn, method, json.RawMessage(args), &out); err != nil { + t.Fatalf("call %s: %v", method, err) + } + return string(out) + } + + // Before any key: the authenticated method points at the signup start. + if out := call("savekeyx.me", `{}`); !strings.Contains(out, `"activate":"savekeyx.signup"`) { + t.Errorf("no-key call should point at savekeyx.signup: %s", out) + } + // Public signup works keyless. + if out := call("savekeyx.signup", `{"email":"a@b.c"}`); !strings.Contains(out, "v_1") { + t.Errorf("signup: %s", out) + } + // verify issues the key: it is stored, and the reply no longer carries it. + out := call("savekeyx.verify", `{"code":"123456"}`) + if strings.Contains(out, "sk_live_secret") { + t.Fatalf("key leaked to the agent: %s", out) + } + if !strings.Contains(out, "saved on this host") || !strings.Contains(out, "acc_1") { + t.Errorf("verify reply should keep the rest and mark the key saved: %s", out) + } + sec, _ := os.ReadFile(filepath.Join(proj, "secrets.json")) + if !strings.Contains(string(sec), `"SAVEKEYX_API_KEY": "sk_live_secret"`) { + t.Errorf("key not cached: %s", sec) + } + // Later calls carry it. + call("savekeyx.me", `{}`) + if gotAuth != "Bearer sk_live_secret" { + t.Errorf("Authorization = %q, want the saved key", gotAuth) + } +} + +func TestSaveKeyValidation(t *testing.T) { + for name, spec := range map[string]string{ + "missing secret_key": strings.Replace(saveKeySpec, ", secret_key: SAVEKEYX_API_KEY", "", 1), + "unknown start": strings.Replace(saveKeySpec, "start: savekeyx.signup", "start: savekeyx.nope", 1), + } { + cfg, err := Parse([]byte(spec)) + if err != nil { + t.Fatalf("%s: parse: %v", name, err) + } + cfg.Resolve() + if errs := cfg.Validate(); len(errs) == 0 { + t.Errorf("%s: expected a validation error", name) + } + } +} From 6c69c7a44cd655efc8df0d057ce78afe57f1c512 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Fri, 25 Sep 2026 18:17:35 +0200 Subject: [PATCH 4/4] scaffold: don't name one issuer in the hint when several routes issue the key Dial issues its key from /auth/verify (existing account) or /auth/verify-number (new account); naming only the first sent new users to the wrong step. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/scaffold/config.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/scaffold/config.go b/internal/scaffold/config.go index 594e534..afd4fd7 100644 --- a/internal/scaffold/config.go +++ b/internal/scaffold/config.go @@ -389,8 +389,18 @@ 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 " + - m.Name + " issues it, and injected on every call automatically." + 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 + "."