diff --git a/README.md b/README.md index 336eb7b..9cf72bb 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ id=$(nan run --detach "upgrade the dependencies") |---|---|---| | `--ws NAME` | your only workspace | Workspace to run in. Required when you have more than one. | | `--agent pi\|pi_gentle\|hermes` | `pi` | Agent to run: `pi_gentle` is Pi with Gentle Shell. The workspace must have it installed. | -| `--model M` | the agent's own | Model for the agent. | +| `--model M` | the agent's own | Model for the agent. Required with `--byok-secret`. | +| `--byok-secret NAME` | NaN inference | Run on your own provider key: the name of a provider key secret saved in the portal. See below. | | `--cwd PATH` | `/home/nan` | Folder in the workspace. | | `--worktree` / `--no-worktree` | auto | Work on a separate branch, or in the folder itself. See below. | | `--timeout D` | `30m` | Stop the run after this long, `1m` to `2h`. | @@ -120,6 +121,22 @@ never pushes, and your checkout is untouched. Outside a repository it works in the folder itself. `--worktree` forces a branch (and fails outside a repository); `--no-worktree` works in the folder even inside a repository. +**Your own provider key (BYOK).** `--byok-secret NAME --model ID` runs the +agent on your own key instead of NaN inference. `NAME` is a secret of kind +`provider_key` you saved in the portal (Settings > Secrets > Provider key: +Anthropic, OpenAI, Google Gemini or any OpenAI-compatible endpoint), never the +key itself; `ID` is a model your provider serves. Your provider bills the run +and NaN does not meter it. Without a NaN inference plan, an active workspace +of your own is enough. These runs need your `nan auth login` session: a +platform token or an API key cannot start them (a prompt can make the agent +print the key it holds), but can trigger an automation that uses your key. +Hermes cannot use an Anthropic key yet. Docs: . + +```bash +nan secrets ls # KIND provider_key, PROVIDER, BASE URL +nan run --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky test" +``` + **Queue.** If the workspace is already running as many runs as its size allows, the run waits as `queued` and starts when one of them ends. diff --git a/cmd/automations.go b/cmd/automations.go index e57a62c..bd26006 100644 --- a/cmd/automations.go +++ b/cmd/automations.go @@ -323,6 +323,7 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error { agent += " · model " + runs.SanitizeLine(*a.Model) } row("agent", agent) + row("inference", inferenceLabel(a.Inference)) row("cwd", runs.SanitizeLine(a.Cwd)) worktree := "auto" if a.GitIsolation != nil { @@ -361,11 +362,25 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error { fmt.Fprintf(env.stderr, "could not read the triggers: %s\n", runs.SanitizeLine(apiExit(trigErr, env.cred).Error())) } + if a.Inference.BYOK() && trigErr == nil && hasWebhook(triggers.Data) { + fmt.Fprintln(env.stdout, "\nnote: this automation runs on your own key, and a webhook starts it: what a delivery "+ + "carries is untrusted input to an agent that holds your key in its environment") + } + printBlock(env, "instructions", a.Instructions) fmt.Fprintf(env.stdout, "\nrun it: nan automations run %s\nedit it in the portal: %s\n", runs.SanitizeLine(a.ID), runs.AutomationURL(a.ID)) return nil } +func hasWebhook(ts []api.Trigger) bool { + for _, t := range ts { + if t.Kind == "webhook" && t.Enabled { + return true + } + } + return false +} + func exposureNote(exposure string) string { switch exposure { case "agent": diff --git a/cmd/byok.go b/cmd/byok.go new file mode 100644 index 0000000..a53a167 --- /dev/null +++ b/cmd/byok.go @@ -0,0 +1,181 @@ +package cmd + +import ( + "errors" + "regexp" + "strings" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/runs" + "github.com/nxssie/nan-cli/internal/session" +) + +// BYOK (bring your own key): a run that uses the member's own provider key, +// saved in the portal as a secret of kind provider_key, instead of NaN's +// inference. The flag names the secret, never the key: the key is written +// in the portal only and never leaves the platform except into the run's +// agent, in the member's own workspace. + +// secretNamePattern is the platform's secret name grammar. +var secretNamePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`) + +// byokDocs is where BYOK is explained. +const byokDocs = "https://nan.builders/docs/runs#use-your-own-provider-key-byok" + +// byokSessionRequired is the refusal of an ad-hoc BYOK run with a token or +// an API key (AGENT-AUTOMATIONS-PHASE3-BYOK.md §4.3). +const byokSessionRequired = "runs with your own provider key need your session: sign in with nan auth login. " + + "Platform tokens (nan_pat_...) and API keys (sk-...) cannot start them, because the prompt of a run can make " + + "the agent print the key it holds. A token can trigger an automation that uses your key: nan automations run " + +// keyPrefixes are how provider keys usually start: a --byok-secret that +// starts like one is probably the key itself, pasted by mistake. +var keyPrefixes = []string{"sk-", "sk_", "AIza", "gsk_", "xai-", "pk-", "key-", "nan_pat_"} + +func hasKeyPrefix(v string) bool { + for _, p := range keyPrefixes { + if strings.HasPrefix(v, p) { + return true + } + } + return false +} + +// looksLikeAKey is a secret name that is probably the key itself. A model id +// is checked by its prefix only: model ids can be long. +func looksLikeAKey(v string) bool { return hasKeyPrefix(v) || len(v) >= 32 } + +// pastedKeyHint is what to do about a key typed on the command line. +const pastedKeyHint = "If that was the key, it is now in your shell history: revoke it at your provider, " + + "then save the new one in the portal, Settings > Secrets at " + secretsURL + +// checkBYOKFlags checks --byok-secret and its --model. The value is never +// repeated in an error when it is not a secret name: it may be the key. +func checkBYOKFlags(opts runOptions) error { + name := opts.byokSecret + if !secretNamePattern.MatchString(name) { + const what = "--byok-secret takes the NAME of a provider key you saved in the portal (like ANTHROPIC_KEY: " + + "capital letters, digits and _), never the key itself; your names are in: nan secrets ls" + switch upper := strings.ToUpper(name); { + case looksLikeAKey(name): + return usageErrorf("%s. %s", what, pastedKeyHint) + case len(name) <= 24 && secretNamePattern.MatchString(upper): + return usageErrorf("%s. Did you mean %s?", what, upper) + } + return usageErrorf("%s", what) + } + if opts.model == "" { + return usageErrorf("--byok-secret needs --model: the id of a model your provider serves (like claude-sonnet-4-5); " + + "NaN does not pick one for your key") + } + if hasKeyPrefix(opts.model) { + return usageErrorf("--model takes the id of a model (like claude-sonnet-4-5), never a key. %s", pastedKeyHint) + } + if strings.HasPrefix(opts.model, "-") { + return usageErrorf("--model must not start with -") + } + return nil +} + +// byokCredential is the credential of an ad-hoc BYOK run: the session from +// nan auth login, and nothing else. NAN_TOKEN and --token-file are an +// explicit choice, so they are refused rather than silently skipped; a saved +// platform token or API key is skipped for the session it would otherwise +// outrank. +func byokCredential(getenv func(string) string, tokenFile string) (credential, error) { + switch { + case getenv(tokenEnvVar) != "": + return credential{}, exitf(exitAuth, "%s is set, and %s", tokenEnvVar, byokSessionRequired) + case tokenFile != "": + return credential{}, exitf(exitAuth, "--token-file was given, and %s", byokSessionRequired) + } + sess, err := session.Load() + if err != nil && !errors.Is(err, session.ErrNotLoggedIn) { + return credential{}, exitf(exitAuth, "could not read your session (%v): %s", err, byokSessionRequired) + } + if sess == nil || sess.Token == "" { + return credential{}, exitf(exitAuth, "%s", byokSessionRequired) + } + return credential{kind: credSession, session: sess.Token}, nil +} + +// byokRefusal maps the platform's refusals of a run's inference choice onto +// what to do about them. nil: not one of them. +func byokRefusal(apiErr *api.APIError, req *api.CreateRunRequest) *ExitError { + byok := req.Inference != nil && req.Inference.Source == api.InferenceBYOK + msg := runs.SanitizeLine(apiErr.Message) + switch { + case apiErr.Code == "session_required": + return exitf(exitAuth, "%s", byokSessionRequired) + case apiErr.Code == "tier_restricted" && byok: + return exitf(exitAuth, "runs with your own provider key need a workspace of yours that is active "+ + "(or a NaN inference plan): see %s", runs.PortalURL) + case apiErr.Code == "tier_restricted": + return exitf(exitAuth, "your membership does not include runs on NaN inference: see %s/membership. "+ + "With a workspace of your own you can run with your own provider key: "+ + "nan run --byok-secret NAME --model ID (%s)", runs.PortalURL, byokDocs) + case !byok: + return nil + case apiErr.Param == "inference.secret" && apiErr.Status == 404: + return usageErrorf("you have no secret named %s: save your provider key as a Provider key in the portal, "+ + "Settings > Secrets at %s; your secret names are in: nan secrets ls", req.Inference.Secret, secretsURL) + case apiErr.Param == "inference.secret": + return usageErrorf("--byok-secret %s: %s. A run uses a secret of kind provider_key (see nan secrets ls); "+ + "save your key as a Provider key in the portal, Settings > Secrets at %s", req.Inference.Secret, msg, secretsURL) + case apiErr.Param == "inference.model": + return usageErrorf("--model: %s", msg) + case apiErr.Param == "inference": + return exitf(exitAuth, "runs with your own provider key are not available for your account: %s", msg) + } + return nil +} + +// providerNames are the providers a provider key can be for. +var providerNames = map[string]string{ + "openai_compatible": "OpenAI-compatible", + "anthropic": "Anthropic", + "openai": "OpenAI", + "gemini": "Google Gemini", +} + +func providerName(p *string) string { + if p == nil || *p == "" { + return "" + } + if n, ok := providerNames[*p]; ok { + return n + } + return runs.SanitizeLine(*p) +} + +// inferenceLabel is the inference of a run or an automation, for show. +func inferenceLabel(in *api.Inference) string { + switch { + case in == nil || in.Source == "" || in.Source == api.InferenceNaN: + return "NaN" + case !in.BYOK(): + return runs.SanitizeLine(in.Source) + } + label := "your key" + if in.Secret != nil && *in.Secret != "" { + label += " " + runs.SanitizeLine(*in.Secret) + } + if p := providerName(in.Provider); p != "" { + label += " (" + p + ")" + } + return label + ": billed by your provider, not metered by NaN" +} + +// inferenceShort is the inference of a run, for a table. +func inferenceShort(in *api.Inference) string { + switch { + case in == nil || in.Source == "" || in.Source == api.InferenceNaN: + return "nan" + case !in.BYOK(): + return runs.SanitizeLine(in.Source) + } + if in.Secret != nil && *in.Secret != "" { + return "key " + runs.Truncate(runs.SanitizeLine(*in.Secret), 32) + } + return "key" +} diff --git a/cmd/byok_test.go b/cmd/byok_test.go new file mode 100644 index 0000000..88c2db7 --- /dev/null +++ b/cmd/byok_test.go @@ -0,0 +1,332 @@ +package cmd + +import ( + "bytes" + "context" + "flag" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/nxssie/nan-cli/internal/api" +) + +// BYOK (bring your own key), AGENT-AUTOMATIONS-PHASE3-BYOK.md §2: a run or +// an automation may use the member's provider key, named by its secret, with +// a model id. Ad-hoc BYOK runs need a session (§4.3). + +var updateGolden = flag.Bool("update", false, "rewrite the cmd/testdata golden files") + +func byokOpts() runOptions { + o := defaultOpts() + o.byokSecret = "ANTHROPIC_KEY" + o.model = "claude-sonnet-4-5" + return o +} + +func TestBYOKRunSendsTheInferenceObject(t *testing.T) { + h := newHarness(t, &fakePlatform{}) + if err := doRun(context.Background(), h.env, byokOpts(), []string{"review PR 42"}); err != nil { + t.Fatal(err) + } + inf, ok := h.fake.created["inference"].(map[string]any) + if !ok || len(inf) != 3 || inf["source"] != "byok" || inf["secret"] != "ANTHROPIC_KEY" || inf["model"] != "claude-sonnet-4-5" { + t.Fatalf("inference = %v", h.fake.created["inference"]) + } + // With BYOK the model is inference.model: a top-level model is a 400. + if _, ok := h.fake.created["model"]; ok { + t.Errorf("sent a top-level model with byok: %v", h.fake.created) + } + if !strings.Contains(h.stderr.String(), "your key ANTHROPIC_KEY") || !strings.Contains(h.stderr.String(), "claude-sonnet-4-5") { + t.Errorf("the start line does not say which key runs it: %q", h.stderr.String()) + } + + // A NaN run sends no inference at all: every existing caller unchanged. + h = newHarness(t, &fakePlatform{}) + opts := defaultOpts() + opts.model = "glm5.3" + if err := doRun(context.Background(), h.env, opts, []string{"x"}); err != nil { + t.Fatal(err) + } + if _, ok := h.fake.created["inference"]; ok || h.fake.created["model"] != "glm5.3" { + t.Errorf("a NaN run sent %v", h.fake.created) + } +} + +func TestBYOKFlagMistakesAreUsageErrors(t *testing.T) { + const pasted = "sk-ant-api03-PastedTheKeyItself0123456789" + for name, tc := range map[string]struct { + mutate func(*runOptions) + wants []string + }{ + "no model": {func(o *runOptions) { o.model = "" }, []string{"--model", "your provider"}}, + "model starting with -": {func(o *runOptions) { o.model = "-x" }, []string{"--model"}}, + "lower case name": {func(o *runOptions) { o.byokSecret = "anthropic_key" }, []string{"NAME", "ANTHROPIC_KEY", "nan secrets ls"}}, + "the key itself": {func(o *runOptions) { o.byokSecret = pasted }, []string{"NAME", "shell history", "revoke"}}, + "the key as the model": {func(o *runOptions) { o.model = pasted }, []string{"--model", "shell history", "revoke"}}, + "a long lower case name": {func(o *runOptions) { o.byokSecret = "my_very_long_lower_case_name_x" }, []string{"NAME"}}, + } { + h := newHarness(t, &fakePlatform{}) + opts := byokOpts() + tc.mutate(&opts) + err := doRun(context.Background(), h.env, opts, []string{"x"}) + if exitCode(err) != exitUsage || h.fake.created != nil { + t.Errorf("%s: exit %d, sent %v (%v)", name, exitCode(err), h.fake.created, err) + continue + } + wantAll(t, name, err.Error(), tc.wants...) + if strings.Contains(err.Error(), "MY_VERY_LONG") { + t.Errorf("%s: suggested a name for a long value: %v", name, err) + } + if strings.Contains(err.Error(), "PastedTheKey") { + t.Errorf("%s: the error repeats what may be a key: %v", name, err) + } + } +} + +// §4.3: an ad-hoc BYOK run needs a session. A token or an API key is refused +// here, before anything is sent, with what to do instead; a saved platform +// token is skipped for the session it would otherwise outrank. +func TestBYOKRunNeedsTheSession(t *testing.T) { + fakeHome(t, `{"token":"sess-1","platformToken":"`+testPAT+`","apiKey":"`+testKey+`"}`) + cred, err := byokCredential(os.Getenv, "") + if err != nil || cred.kind != credSession || cred.session != "sess-1" { + t.Fatalf("saved token + session: %+v %v", cred, err) + } + + for name, setup := range map[string]func(t *testing.T) string{ + "NAN_TOKEN": func(t *testing.T) string { + fakeHome(t, `{"token":"sess-1"}`) + t.Setenv(tokenEnvVar, testPAT) + return "" + }, + "--token-file": func(t *testing.T) string { + fakeHome(t, `{"token":"sess-1"}`) + p := filepath.Join(t.TempDir(), "tok") + _ = os.WriteFile(p, []byte(testKey+"\n"), 0o600) + return p + }, + "saved token only": func(t *testing.T) string { fakeHome(t, `{"platformToken":"`+testPAT+`"}`); return "" }, + "saved key only": func(t *testing.T) string { fakeHome(t, `{"apiKey":"`+testKey+`"}`); return "" }, + "nothing": func(t *testing.T) string { fakeHome(t, ""); return "" }, + } { + file := setup(t) + _, err := byokCredential(os.Getenv, file) + if exitCode(err) != exitAuth { + t.Errorf("%s: exit %d (%v)", name, exitCode(err), err) + continue + } + wantAll(t, name, err.Error(), "nan auth login", "nan automations run") + if strings.Contains(err.Error(), testPAT) || strings.Contains(err.Error(), testKey) { + t.Errorf("%s: the error shows the credential: %v", name, err) + } + } +} + +func TestBYOKRefusalsSayWhatToDo(t *testing.T) { + for _, tc := range []struct { + name string + opts runOptions + status int + code, param string + want int + wants, avoids []string + }{ + {"session required", byokOpts(), 403, "session_required", "inference", exitAuth, + []string{"nan auth login", "nan automations run"}, nil}, + {"no such secret", byokOpts(), 404, "not_found", "inference.secret", exitUsage, + []string{"ANTHROPIC_KEY", "nan secrets ls", "Provider key"}, []string{"workspace"}}, + {"not a provider key", byokOpts(), 400, "invalid_request", "inference.secret", exitUsage, + []string{"ANTHROPIC_KEY", "provider_key", "nan secrets ls"}, nil}, + {"bad model", byokOpts(), 400, "invalid_request", "inference.model", exitUsage, []string{"--model"}, nil}, + {"not available", byokOpts(), 400, "invalid_request", "inference", exitAuth, []string{"not available"}, nil}, + {"no workspace, no plan", byokOpts(), 403, "tier_restricted", "", exitAuth, + []string{"workspace"}, []string{"--byok-secret"}}, + {"no plan, nan run", defaultOpts(), 403, "tier_restricted", "", exitAuth, + []string{"membership", "--byok-secret", "--model"}, nil}, + } { + param := "null" + if tc.param != "" { + param = fmt.Sprintf("%q", tc.param) + } + h := newHarness(t, &fakePlatform{ + createStatus: tc.status, + createBody: fmt.Sprintf(`{"error":{"message":"refused \u001b[2J","type":"invalid_request_error","param":%s,"code":%q}}`, param, tc.code), + }) + err := doRun(context.Background(), h.env, tc.opts, []string{"x"}) + if exitCode(err) != tc.want { + t.Errorf("%s: exit %d (%v), want %d", tc.name, exitCode(err), err, tc.want) + continue + } + wantAll(t, tc.name, err.Error(), tc.wants...) + for _, a := range tc.avoids { + if strings.Contains(err.Error(), a) { + t.Errorf("%s: %q mentions %q", tc.name, err.Error(), a) + } + } + assertNoControls(t, tc.name, err.Error()) + } +} + +// byokGoldenEnv serves the BYOK golden responses (internal/api/testdata/phase3, +// rendered by cloud-api's own handlers) on the paths that return them. +func byokGoldenEnv(t *testing.T) (*runsEnv, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + const run = "6f1c2a9b-0000-4000-8000-000000000001" + const auto = "a0000000-0000-4000-8000-000000000001" + routes := map[string]string{ + "/v1/runs/" + run: "phase3/run_get_byok.json", + "/v1/runs": "phase3/run_list_byok.json", + "/v1/automations/" + auto: "phase3/automation_get_byok.json", + "/v1/automations/" + auto + "/triggers": "phase2/automation_triggers.json", + "/api/account/secrets": "phase3/secrets_list_byok.json", + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name, ok := routes[r.URL.Path] + if !ok || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", name)) + if err != nil { + t.Error(err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(b) + })) + t.Cleanup(srv.Close) + var out, errOut bytes.Buffer + return &runsEnv{client: api.NewRunsClient("s", "").WithBaseURL(srv.URL), stdout: &out, stderr: &errOut}, &out, &errOut +} + +func checkTextGolden(t *testing.T, name, got string) { + t.Helper() + path := filepath.Join("testdata", "byok", name+".golden") + if *updateGolden { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: %v (run go test ./cmd -run %s -update)", name, err, t.Name()) + } + if got != string(want) { + t.Errorf("%s drifted from %s:\n--- got\n%s\n--- want\n%s", name, path, got, want) + } +} + +// The views of a BYOK run, a BYOK automation and provider keys, rendered from +// what cloud-api serialises. +func TestBYOKGoldenViews(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + loc := time.Local + time.Local = time.UTC + defer func() { time.Local = loc }() + ctx := context.Background() + + for _, tc := range []struct { + name string + do func(env *runsEnv) error + }{ + {"runs_show", func(env *runsEnv) error { return doRunsShow(ctx, env, "6f1c2a9b-0000-4000-8000-000000000001") }}, + {"runs_ls", func(env *runsEnv) error { return doRunsLs(ctx, env) }}, + {"automations_show", func(env *runsEnv) error { return doAutomationsShow(ctx, env, "a0000000-0000-4000-8000-000000000001") }}, + {"secrets_ls", func(env *runsEnv) error { return doSecretsLs(ctx, env) }}, + } { + env, out, _ := byokGoldenEnv(t) + if err := tc.do(env); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + checkTextGolden(t, tc.name, out.String()) + if strings.Contains(out.String(), "GoldenValue") { + t.Errorf("%s shows a secret value", tc.name) + } + } +} + +// A long model id is a model id, and the BASE URL column and the webhook +// note only show when they apply. +func TestBYOKOnlyWhenItApplies(t *testing.T) { + h := newHarness(t, &fakePlatform{}) + opts := byokOpts() + opts.model = "meta-llama/Llama-3.3-70B-Instruct-Turbo-with-a-long-name" + if err := doRun(context.Background(), h.env, opts, []string{"x"}); err != nil { + t.Fatal(err) + } + + resetPhase2Flags() + defer resetPhase2Flags() + ph := newPhase2Harness(t, &fakePhase2{}) + if err := doSecretsLs(context.Background(), ph.env); err != nil { + t.Fatal(err) + } + if strings.Contains(ph.stdout.String(), "BASE URL") || !strings.Contains(ph.stdout.String(), "PROVIDER") { + t.Errorf("secrets ls without base URLs:\n%s", ph.stdout.String()) + } + + for name, trig := range map[string][]api.Trigger{ + "no trigger": nil, + "schedule only": {{Kind: "schedule", Enabled: true}}, + "disabled webhook": {{Kind: "webhook", Enabled: false}}, + } { + if hasWebhook(trig) { + t.Errorf("%s: counted as a webhook", name) + } + } + if !hasWebhook([]api.Trigger{{Kind: "schedule", Enabled: true}, {Kind: "webhook", Enabled: true}}) { + t.Error("an enabled webhook is not seen") + } +} + +func TestBYOKCorruptSession(t *testing.T) { + fakeHome(t, `{not json`) + _, err := byokCredential(os.Getenv, "") + if exitCode(err) != exitAuth || !strings.Contains(err.Error(), "nan auth login") { + t.Fatalf("exit %d: %v", exitCode(err), err) + } +} + +func TestBYOKInferenceLabels(t *testing.T) { + s := func(v string) *string { return &v } + for _, tc := range []struct { + in *api.Inference + long, short string + }{ + {nil, "NaN", "nan"}, + {&api.Inference{Source: "nan"}, "NaN", "nan"}, + {&api.Inference{Source: "byok", Provider: s("openai_compatible"), Secret: s("TOGETHER_KEY"), Model: s("m")}, + "your key TOGETHER_KEY (OpenAI-compatible): billed by your provider, not metered by NaN", "key TOGETHER_KEY"}, + {&api.Inference{Source: "byok", Provider: s("gemini"), Secret: s("G")}, "your key G (Google Gemini): billed by your provider, not metered by NaN", "key G"}, + {&api.Inference{Source: "byok"}, "your key: billed by your provider, not metered by NaN", "key"}, + {&api.Inference{Source: "byok", Provider: s("x\x1b[2J"), Secret: s("K\x07")}, "your key K (x): billed by your provider, not metered by NaN", "key K"}, + {&api.Inference{Source: "future"}, "future", "future"}, + } { + if got := inferenceLabel(tc.in); got != tc.long { + t.Errorf("long %+v = %q, want %q", tc.in, got, tc.long) + } + if got := inferenceShort(tc.in); got != tc.short { + t.Errorf("short %+v = %q, want %q", tc.in, got, tc.short) + } + } +} + +func TestBYOKHelp(t *testing.T) { + f := runCmd.Flags().Lookup("byok-secret") + if f == nil || !strings.Contains(f.Usage, "NAME") { + t.Fatalf("--byok-secret: %+v", f) + } + wantAll(t, "nan run --help", runCmd.Long, "--byok-secret", "nan auth login", "nan secrets ls", "https://nan.builders/docs/runs") + wantAll(t, "nan run examples", runCmd.Example, "--byok-secret ANTHROPIC_KEY --model") + wantAll(t, "nan secrets --help", secretsCmd.Long, "provider key") +} diff --git a/cmd/run.go b/cmd/run.go index c2cb49c..1808573 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -48,6 +48,7 @@ type runOptions struct { json bool idempotencyKey string file string + byokSecret string } var runOpts runOptions @@ -73,6 +74,19 @@ Queue: If the workspace is already running as many runs as its size allows, the run waits as queued and starts when one of them ends. +Your own provider key (BYOK): + --byok-secret NAME --model ID runs the agent on your own provider key + instead of NaN inference: NAME is a secret of kind provider_key that you + saved in the portal (Settings > Secrets > Provider key: Anthropic, OpenAI, + Google Gemini or any OpenAI-compatible endpoint), ID a model your provider + serves. Your provider bills the run; NaN does not meter it. Without a NaN + inference plan, a workspace of your own is enough. List your keys with: + nan secrets ls. + These runs need your session (nan auth login): a platform token or an API + key cannot start them, because a prompt can make the agent print the key + it holds. A token can trigger an automation that uses your key instead. + Hermes cannot use an Anthropic key yet: use pi or pi_gentle with it. + In the portal: Folder = --cwd, Separate branch = --worktree/--no-worktree, Time limit = --timeout, Task = the prompt. @@ -88,6 +102,7 @@ Authentication (the first one found is used): saved with nan auth login --api-token Create platform tokens in Settings > Tokens at https://cloud.nan.builders. Never pass a token as an argument: it would end up in your shell history. + A run with --byok-secret uses your session (4) and nothing else. Detaching: Ctrl-C detaches and leaves the run going. A second Ctrl-C within 2 @@ -104,11 +119,11 @@ Exit codes: 2 the run timed out 3 the run was cancelled (after a double Ctrl-C: cancel requested) 4 the workspace is not set up for it (agent not installed, no inference - key, bad configuration) + key, bad configuration, an agent that cannot use your provider key) 10 awaiting approval: an automation run whose proposed actions wait for you (nan runs proposal ; approve in the portal) 64 usage error - 65 not signed in, or not allowed + 65 not signed in, or not allowed (a token for a run with your own key) 69 nan.builders unavailable, or the stream was lost (the run goes on) 75 detached: the run was accepted and is still going @@ -117,7 +132,8 @@ Docs: https://nan.builders/docs/runs`, nan run --ws develop --cwd /home/nan/projects/api -f task.md git diff | nan run - id=$(nan run --detach "upgrade the dependencies") - nan run --ws ci -f task.md # in CI, with NAN_TOKEN set from a secret`, + nan run --ws ci -f task.md # in CI, with NAN_TOKEN set from a secret + nan run --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky test"`, Args: cobra.ArbitraryArgs, Annotations: sysexits, RunE: func(cmd *cobra.Command, args []string) error { @@ -129,7 +145,12 @@ Docs: https://nan.builders/docs/runs`, if err != nil { return err } - env, err := newRunsEnv() + var env *runsEnv + if opts.byokSecret != "" { + env, err = newBYOKRunsEnv() + } else { + env, err = newRunsEnv() + } if err != nil { return err } @@ -142,7 +163,8 @@ func init() { f := runCmd.Flags() f.StringVar(&runOpts.workspace, "ws", "", "Workspace to run in (default: your only workspace)") f.StringVar(&runOpts.agent, "agent", "pi", "Agent to run: pi, pi_gentle (Pi + Gentle Shell) or hermes, if the workspace has it installed") - f.StringVar(&runOpts.model, "model", "", "Model for the agent (default: the agent's own)") + f.StringVar(&runOpts.model, "model", "", "Model for the agent (default: the agent's own; required with --byok-secret)") + f.StringVar(&runOpts.byokSecret, "byok-secret", "", "Run on your own provider key: the `NAME` of a provider key secret saved in the portal (never the key itself); needs --model") f.StringVar(&runOpts.cwd, "cwd", "", "Folder in the workspace to run in (default: /home/nan)") f.BoolVar(&runOpts.worktree, "worktree", false, "Always work on a separate branch, nan-run/ (fails outside a git repository)") f.BoolVar(&runOpts.noWorktree, "no-worktree", false, "Work in the folder itself, even inside a git repository") @@ -170,6 +192,16 @@ type runsEnv struct { names nameCache } +// newBYOKRunsEnv is newRunsEnv for a run with the member's own provider +// key, which only the session can start (byokCredential). +func newBYOKRunsEnv() (*runsEnv, error) { + cred, err := byokCredential(os.Getenv, tokenFileFlag) + if err != nil { + return nil, err + } + return &runsEnv{client: cred.client(), cred: cred, stdout: os.Stdout, stderr: os.Stderr}, nil +} + func newRunsEnv() (*runsEnv, error) { cred, err := resolveCredential(os.Getenv, tokenFileFlag) if err != nil { @@ -195,6 +227,11 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat run, replayed, err := env.client.CreateRun(ctx, *req, opts.idempotencyKey) if err != nil { var apiErr *api.APIError + if errors.As(err, &apiErr) { + if exit := byokRefusal(apiErr, req); exit != nil { + return exit + } + } if errors.As(err, &apiErr) && apiErr.Status == 404 { return usageErrorf("workspace %q not found (or runs are not enabled for your account)", runs.SanitizeLine(req.Workspace)) } @@ -230,7 +267,11 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat if where == "" { where = runs.SanitizeLine(req.Workspace) } - fmt.Fprintf(env.stderr, "run %s queued on %s (%s) — Ctrl-C to detach\n", id, where, runs.SanitizeLine(run.Agent)) + agent := runs.SanitizeLine(run.Agent) + if in := req.Inference; in != nil && in.Source == api.InferenceBYOK { + agent += ", your key " + runs.SanitizeLine(in.Secret) + ", model " + runs.SanitizeLine(in.Model) + } + fmt.Fprintf(env.stderr, "run %s queued on %s (%s) — Ctrl-C to detach\n", id, where, agent) } return follow(ctx, env, run.ID, 0, opts.json, true) } @@ -410,6 +451,11 @@ func buildRunRequest(in promptInput, opts runOptions, args []string) (*api.Creat if opts.worktree && opts.noWorktree { return nil, usageErrorf("--worktree and --no-worktree contradict each other; pick one") } + if opts.byokSecret != "" { + if err := checkBYOKFlags(opts); err != nil { + return nil, err + } + } if opts.model != "" && !modelPattern.MatchString(opts.model) { return nil, usageErrorf("--model %q is not a model name (letters, digits and . _ : / -, up to 128)", opts.model) } @@ -432,7 +478,11 @@ func buildRunRequest(in promptInput, opts runOptions, args []string) (*api.Creat cwd := opts.cwd req.Cwd = &cwd } - if opts.model != "" { + switch { + case opts.byokSecret != "": + // With BYOK the model is inference.model; a top-level one is a 400. + req.Inference = &api.RunInference{Source: api.InferenceBYOK, Secret: opts.byokSecret, Model: opts.model} + case opts.model != "": model := opts.model req.Model = &model } diff --git a/cmd/runs.go b/cmd/runs.go index 84bd303..bfb313f 100644 --- a/cmd/runs.go +++ b/cmd/runs.go @@ -285,13 +285,14 @@ func doRunsLs(ctx context.Context, env *runsEnv) error { env.nameRunWorkspace(ctx, &list.Data[i]) } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "ID\tSTATE\tWORKSPACE\tAGENT\tCREATED\tPROMPT") + fmt.Fprintln(tw, "ID\tSTATE\tWORKSPACE\tAGENT\tINFERENCE\tCREATED\tPROMPT") for _, r := range list.Data { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", runs.SanitizeLine(r.ID), runs.SanitizeLine(r.State), runs.Truncate(runs.SanitizeLine(refLabel(r.Workspace.Name, r.Workspace.ID)), 40), runs.SanitizeLine(r.Agent), + inferenceShort(r.Inference), localTime(r.CreatedAt), runs.Truncate(runs.SanitizeLine(r.PromptPreview), 60)) } @@ -356,6 +357,7 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { agent += " · model " + runs.SanitizeLine(*run.Model) } row("agent", agent) + row("inference", inferenceLabel(run.Inference)) row("trigger", runs.SanitizeLine(run.Trigger)) if run.Phase != "" && run.Phase != "run" { row("phase", runs.SanitizeLine(run.Phase)+phaseNote(run.Phase)) @@ -422,7 +424,11 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { } } if u := run.Usage; u != nil && (u.TokensIn != nil || u.TokensOut != nil) { - row("tokens", fmt.Sprintf("%s in, %s out", optInt(u.TokensIn), optInt(u.TokensOut))) + tokens := fmt.Sprintf("%s in, %s out", optInt(u.TokensIn), optInt(u.TokensOut)) + if u.Source != nil && *u.Source == api.InferenceBYOK { + tokens += " (as the agent reported them; your provider's bill is the reference)" + } + row("tokens", tokens) } if err := tw.Flush(); err != nil { return err diff --git a/cmd/secrets.go b/cmd/secrets.go index 8601a24..3286802 100644 --- a/cmd/secrets.go +++ b/cmd/secrets.go @@ -38,6 +38,11 @@ var secretsCmd = &cobra.Command{ characters as a hint, and which automations use each one. Values are never shown, here or anywhere else, after they are saved. +Kinds: generic (a value an automation hands to its runs) and provider_key +(your own key for Anthropic, OpenAI, Google Gemini or an OpenAI-compatible +endpoint, with its base URL). Run on a provider key with: + nan run --byok-secret NAME --model ID "" + Portal-only, and why: adding, replacing and deleting secrets happen in the portal, Settings > Secrets at ` + runs.PortalURL + `, signed in through the browser. A secret written from a credential an agent or a CI job @@ -113,16 +118,28 @@ func doSecretsLs(ctx context.Context, env *runsEnv) error { fmt.Fprintf(env.stderr, "No secrets yet. Add them in the portal: Settings > Secrets at %s\n", secretsURL) return nil } + // The base URL column only when there is one: it is set on + // OpenAI-compatible provider keys only. + withURL := false + for _, s := range list.Data { + if s.BaseURL != nil && *s.BaseURL != "" { + withURL = true + } + } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "NAME\tKIND\tHINT\tUSED BY\tLAST USED\tUPDATED") + header := "NAME\tKIND\tPROVIDER\tHINT\tUSED BY\tLAST USED\tUPDATED" + if withURL { + header += "\tBASE URL" + } + fmt.Fprintln(tw, header) for _, s := range list.Data { hint := "-" if s.Hint != nil && *s.Hint != "" { hint = runs.Truncate(runs.SanitizeLine(*s.Hint), 12) } - kind := runs.SanitizeLine(s.Kind) + provider := "-" if s.Provider != nil && *s.Provider != "" { - kind += " (" + runs.SanitizeLine(*s.Provider) + ")" + provider = runs.Truncate(runs.SanitizeLine(*s.Provider), 24) } used := make([]string, 0, len(s.UsedBy)) for _, u := range s.UsedBy { @@ -136,9 +153,17 @@ func doSecretsLs(ctx context.Context, env *runsEnv) error { if s.LastUsedAt != nil { lastUsed = localTime(*s.LastUsedAt) } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + line := fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s\t%s", runs.Truncate(runs.SanitizeLine(s.Name), 64), - kind, hint, usedBy, lastUsed, localTime(s.UpdatedAt)) + runs.SanitizeLine(s.Kind), provider, hint, usedBy, lastUsed, localTime(s.UpdatedAt)) + if withURL { + baseURL := "-" + if s.BaseURL != nil && *s.BaseURL != "" { + baseURL = runs.Truncate(runs.SanitizeLine(*s.BaseURL), 60) + } + line += "\t" + baseURL + } + fmt.Fprintln(tw, line) } if err := tw.Flush(); err != nil { return err diff --git a/cmd/testdata/byok/automations_show.golden b/cmd/testdata/byok/automations_show.golden new file mode 100644 index 0000000..ad49535 --- /dev/null +++ b/cmd/testdata/byok/automations_show.golden @@ -0,0 +1,27 @@ +id a0000000-0000-4000-8000-000000000001 +name PR review api +description reviews +status enabled +template pr_review +workspace 11111111-2222-4333-8444-555555555555 +agent pi · model qwen/qwen3-coder +inference your key TOGETHER_KEY (OpenAI-compatible): billed by your provider, not metered by NaN +cwd /home/nan/projects/api +worktree yes +timeout 30m0s +repository acme/api +approval github_pr_review +approval ttl 72h0m0s +concurrency skip if active +runs per hour up to 30 +secret GITHUB_TOKEN ← GITHUB_WRITE_TOKEN (actions only, never the agent) +trigger webhook github · nan_whk_AbC1… +updated 2026-10-11 09:00 + +note: this automation runs on your own key, and a webhook starts it: what a delivery carries is untrusted input to an agent that holds your key in its environment + +instructions: + Review for correctness. + +run it: nan automations run a0000000-0000-4000-8000-000000000001 +edit it in the portal: https://cloud.nan.builders/automations/a0000000-0000-4000-8000-000000000001 diff --git a/cmd/testdata/byok/runs_ls.golden b/cmd/testdata/byok/runs_ls.golden new file mode 100644 index 0000000..84e1757 --- /dev/null +++ b/cmd/testdata/byok/runs_ls.golden @@ -0,0 +1,3 @@ +ID STATE WORKSPACE AGENT INFERENCE CREATED PROMPT +6f1c2a9b-0000-4000-8000-000000000001 succeeded develop pi key ANTHROPIC_KEY 2026-10-10 09:00 review PR 42 +6f1c2a9b-0000-4000-8000-000000000002 succeeded develop pi nan 2026-10-10 09:00 review PR 42 diff --git a/cmd/testdata/byok/runs_show.golden b/cmd/testdata/byok/runs_show.golden new file mode 100644 index 0000000..fe79db4 --- /dev/null +++ b/cmd/testdata/byok/runs_show.golden @@ -0,0 +1,21 @@ +id 6f1c2a9b-0000-4000-8000-000000000001 +state succeeded +workspace develop (11111111-2222-4333-8444-555555555555) +agent pi · model claude-sonnet-4-5 +inference your key ANTHROPIC_KEY (Anthropic): billed by your provider, not metered by NaN +trigger cli +cwd /home/nan/projects/api +worktree auto +timeout 30m0s +created 2026-10-10 09:00 +started 2026-10-10 09:00 +branch nan-run/6f1c2a9b +commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +changed files 3 +tokens 12840 in, 1630 out (as the agent reported them; your provider's bill is the reference) + +prompt: + review PR 42 in full + +summary: + done diff --git a/cmd/testdata/byok/secrets_ls.golden b/cmd/testdata/byok/secrets_ls.golden new file mode 100644 index 0000000..3c88b3a --- /dev/null +++ b/cmd/testdata/byok/secrets_ls.golden @@ -0,0 +1,4 @@ +NAME KIND PROVIDER HINT USED BY LAST USED UPDATED BASE URL +GITHUB_WRITE_TOKEN generic - …6789 - never 2026-10-11 12:00 - +TOGETHER_KEY provider_key openai_compatible …6789 - never 2026-10-11 12:00 https://api.together.xyz/v1 +ANTHROPIC_KEY provider_key anthropic …6789 - never 2026-10-11 12:00 - diff --git a/internal/api/automations.go b/internal/api/automations.go index 692773d..3e94dcb 100644 --- a/internal/api/automations.go +++ b/internal/api/automations.go @@ -17,10 +17,26 @@ type SecretAssignment struct { Exposure string `json:"exposure"` } +// Inference is where a run's or an automation's model calls go: NaN's own +// inference ({"source":"nan"}), or the member's provider key +// ({"source":"byok"}, BYOK) with the key's provider, the secret's name and +// the model. The key itself is never part of it. type Inference struct { - Source string `json:"source"` + Source string `json:"source"` + Provider *string `json:"provider,omitempty"` + Secret *string `json:"secret,omitempty"` + Model *string `json:"model,omitempty"` } +// Inference sources, as the platform names them. +const ( + InferenceNaN = "nan" + InferenceBYOK = "byok" +) + +// BYOK reports whether the inference is the member's own provider key. +func (in *Inference) BYOK() bool { return in != nil && in.Source == InferenceBYOK } + // Automation mirrors the automation object of /v1/automations. It is the // member's own configuration, read here and never written: creating and // editing automations happens in the portal. diff --git a/internal/api/golden_test.go b/internal/api/golden_test.go index 7280c50..e9fd298 100644 --- a/internal/api/golden_test.go +++ b/internal/api/golden_test.go @@ -116,3 +116,71 @@ func TestGoldenTriggersAndDeliveries(t *testing.T) { t.Fatalf("delivery = %+v", d) } } + +// BYOK (AGENT-AUTOMATIONS-PHASE3-BYOK.md §2): the inference object of a run +// and an automation, usage.source, and a provider key's provider and base +// URL. testdata/phase3 is rendered by cloud-api's own handlers. +func TestGoldenBYOK(t *testing.T) { + var run Run + golden3(t, "run_get_byok.json", &run) + in := run.Inference + if !in.BYOK() || *in.Provider != "anthropic" || *in.Secret != "ANTHROPIC_KEY" || *in.Model != "claude-sonnet-4-5" || + *run.Model != "claude-sonnet-4-5" || run.Usage == nil || *run.Usage.Source != "byok" || *run.Usage.TokensIn != 12840 { + t.Fatalf("byok run = %+v %+v", in, run.Usage) + } + + var list RunList + golden3(t, "run_list_byok.json", &list) + if len(list.Data) != 2 || !list.Data[0].Inference.BYOK() || list.Data[1].Inference.BYOK() || + list.Data[1].Inference.Source != InferenceNaN || list.Data[1].Inference.Secret != nil { + t.Fatalf("list = %+v", list.Data) + } + + var a Automation + golden3(t, "automation_get_byok.json", &a) + if !a.Inference.BYOK() || *a.Inference.Provider != "openai_compatible" || *a.Inference.Secret != "TOGETHER_KEY" || + *a.Inference.Model != "qwen/qwen3-coder" { + t.Fatalf("automation inference = %+v", a.Inference) + } + + var secrets SecretList + golden3(t, "secrets_list_byok.json", &secrets) + byName := map[string]Secret{} + for _, s := range secrets.Data { + byName[s.Name] = s + } + if s := byName["TOGETHER_KEY"]; s.Kind != SecretKindProviderKey || *s.Provider != "openai_compatible" || *s.BaseURL != "https://api.together.xyz/v1" { + t.Fatalf("TOGETHER_KEY = %+v", s) + } + if s := byName["ANTHROPIC_KEY"]; s.Kind != SecretKindProviderKey || *s.Provider != "anthropic" || s.BaseURL != nil { + t.Fatalf("ANTHROPIC_KEY = %+v", s) + } + if s := byName["GITHUB_WRITE_TOKEN"]; s.Kind != SecretKindGeneric || s.Provider != nil || s.BaseURL != nil { + t.Fatalf("GITHUB_WRITE_TOKEN = %+v", s) + } +} + +func golden3(t *testing.T, name string, out any) { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "phase3", name)) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, out); err != nil { + t.Fatalf("%s: %v", name, err) + } +} + +// The request of a BYOK run: inference with the secret's name and the +// model, no top-level model; a NaN run sends no inference at all. +func TestCreateRunRequestInference(t *testing.T) { + b, _ := json.Marshal(CreateRunRequest{Workspace: "w", Agent: "pi", Prompt: "p", + Inference: &RunInference{Source: InferenceBYOK, Secret: "K", Model: "m"}}) + if want := `{"workspace":"w","agent":"pi","prompt":"p","inference":{"source":"byok","secret":"K","model":"m"}}`; string(b) != want { + t.Fatalf("%s", b) + } + b, _ = json.Marshal(CreateRunRequest{Workspace: "w", Agent: "pi", Prompt: "p"}) + if string(b) != `{"workspace":"w","agent":"pi","prompt":"p"}` { + t.Fatalf("%s", b) + } +} diff --git a/internal/api/runs.go b/internal/api/runs.go index d9016ea..1f5cda0 100644 --- a/internal/api/runs.go +++ b/internal/api/runs.go @@ -163,6 +163,18 @@ type CreateRunRequest struct { TimeoutSeconds *int `json:"timeout_seconds,omitempty"` Model *string `json:"model,omitempty"` Trigger string `json:"trigger,omitempty"` + + // Inference is left out for NaN inference (the platform's default). + // With BYOK the model goes here, never in Model. + Inference *RunInference `json:"inference,omitempty"` +} + +// RunInference is the inference choice of POST /v1/runs: {"source":"byok"} +// names the provider key by its secret NAME, never by its value. +type RunInference struct { + Source string `json:"source"` + Secret string `json:"secret,omitempty"` + Model string `json:"model,omitempty"` } type RunList struct { diff --git a/internal/api/secrets.go b/internal/api/secrets.go index 31de6f9..aa50641 100644 --- a/internal/api/secrets.go +++ b/internal/api/secrets.go @@ -4,15 +4,25 @@ import ( "context" ) +// Secret kinds: a generic secret an automation hands to its runs, or a +// provider key a run or an automation uses for inference (BYOK). +const ( + SecretKindGeneric = "generic" + SecretKindProviderKey = "provider_key" +) + // Secret is a member secret's metadata. The platform never returns a value // (nor its ciphertext): Hint is at most the last characters, as the portal -// shows them, so a member can tell two tokens apart. +// shows them, so a member can tell two tokens apart. A provider key (BYOK) +// also has its Provider and, for openai_compatible only, its BaseURL, which +// is configuration, not a credential. type Secret struct { ID string `json:"id"` Object string `json:"object,omitempty"` Name string `json:"name"` Kind string `json:"kind"` Provider *string `json:"provider"` + BaseURL *string `json:"base_url"` Hint *string `json:"hint"` ValueBytes int `json:"value_bytes"` Description string `json:"description"` diff --git a/internal/api/testdata/phase3/automation_get_byok.json b/internal/api/testdata/phase3/automation_get_byok.json new file mode 100644 index 0000000..4272690 --- /dev/null +++ b/internal/api/testdata/phase3/automation_get_byok.json @@ -0,0 +1,42 @@ +{ + "agent": "pi", + "approval_kinds": [ + "github_pr_review" + ], + "approval_ttl_seconds": 259200, + "checkout": "workspace", + "checkout_untrusted_heads": false, + "concurrency_policy": "skip_if_active", + "created_at": "2026-10-11T09:00:00Z", + "cwd": "/home/nan/projects/api", + "description": "reviews", + "disabled_reason": null, + "enabled": true, + "git_isolation": true, + "github_repo": "acme/api", + "id": "a0000000-0000-4000-8000-000000000001", + "inference": { + "model": "qwen/qwen3-coder", + "provider": "openai_compatible", + "secret": "TOGETHER_KEY", + "source": "byok" + }, + "instructions": "Review for correctness.", + "max_runs_per_hour": 30, + "max_trigger_depth": 2, + "model": "qwen/qwen3-coder", + "name": "PR review api", + "object": "automation", + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "actions", + "secret": "GITHUB_WRITE_TOKEN" + } + ], + "sticky_pr_comment": false, + "template": "pr_review", + "timeout_seconds": 1800, + "updated_at": "2026-10-11T09:00:00Z", + "workspace": "11111111-2222-4333-8444-555555555555" +} diff --git a/internal/api/testdata/phase3/run_get_byok.json b/internal/api/testdata/phase3/run_get_byok.json new file mode 100644 index 0000000..46a27be --- /dev/null +++ b/internal/api/testdata/phase3/run_get_byok.json @@ -0,0 +1,59 @@ +{ + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "checkout": null, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/projects/api", + "error": null, + "error_code": null, + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": null, + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "inference": { + "model": "claude-sonnet-4-5", + "provider": "anthropic", + "secret": "ANTHROPIC_KEY", + "source": "byok" + }, + "last_seq": 42, + "model": "claude-sonnet-4-5", + "object": "run", + "parent_run_id": null, + "phase": "run", + "prompt": "review PR 42 in full", + "prompt_preview": "review PR 42", + "queue_position": null, + "result": { + "actions_applied": [], + "artifacts": [], + "branch": "nan-run/6f1c2a9b", + "changed_files": 3, + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "status": "succeeded", + "summary": "done" + }, + "secrets": [], + "started_at": "2026-10-10T09:00:01Z", + "state": "succeeded", + "sticky_pr_comment": false, + "superseded_by": null, + "timeout_seconds": 1800, + "trigger": "cli", + "trigger_depth": 0, + "usage": { + "source": "byok", + "tokens_in": 12840, + "tokens_out": 1630 + }, + "workspace": { + "id": "11111111-2222-4333-8444-555555555555", + "name": "develop" + } +} diff --git a/internal/api/testdata/phase3/run_list_byok.json b/internal/api/testdata/phase3/run_list_byok.json new file mode 100644 index 0000000..013df2c --- /dev/null +++ b/internal/api/testdata/phase3/run_list_byok.json @@ -0,0 +1,119 @@ +{ + "data": [ + { + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "checkout": null, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/projects/api", + "error": null, + "error_code": null, + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": null, + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "inference": { + "model": "claude-sonnet-4-5", + "provider": "anthropic", + "secret": "ANTHROPIC_KEY", + "source": "byok" + }, + "last_seq": 42, + "model": "claude-sonnet-4-5", + "object": "run", + "parent_run_id": null, + "phase": "run", + "prompt_preview": "review PR 42", + "queue_position": null, + "result": { + "actions_applied": [], + "artifacts": [], + "branch": "nan-run/6f1c2a9b", + "changed_files": 3, + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "status": "succeeded", + "summary": "done" + }, + "secrets": [], + "started_at": "2026-10-10T09:00:01Z", + "state": "succeeded", + "sticky_pr_comment": false, + "superseded_by": null, + "timeout_seconds": 1800, + "trigger": "cli", + "trigger_depth": 0, + "usage": { + "source": "byok", + "tokens_in": 12840, + "tokens_out": 1630 + }, + "workspace": { + "id": "11111111-2222-4333-8444-555555555555", + "name": "develop" + } + }, + { + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "checkout": null, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/projects/api", + "error": null, + "error_code": null, + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": null, + "id": "6f1c2a9b-0000-4000-8000-000000000002", + "inference": { + "source": "nan" + }, + "last_seq": 42, + "model": null, + "object": "run", + "parent_run_id": null, + "phase": "run", + "prompt_preview": "review PR 42", + "queue_position": null, + "result": { + "actions_applied": [], + "artifacts": [], + "branch": "nan-run/6f1c2a9b", + "changed_files": 3, + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "status": "succeeded", + "summary": "done" + }, + "secrets": [], + "started_at": "2026-10-10T09:00:01Z", + "state": "succeeded", + "sticky_pr_comment": false, + "superseded_by": null, + "timeout_seconds": 1800, + "trigger": "cli", + "trigger_depth": 0, + "usage": { + "source": null, + "tokens_in": null, + "tokens_out": null + }, + "workspace": { + "id": "11111111-2222-4333-8444-555555555555", + "name": "develop" + } + } + ], + "next_cursor": null, + "object": "list" +} diff --git a/internal/api/testdata/phase3/secrets_list_byok.json b/internal/api/testdata/phase3/secrets_list_byok.json new file mode 100644 index 0000000..9a51b48 --- /dev/null +++ b/internal/api/testdata/phase3/secrets_list_byok.json @@ -0,0 +1,49 @@ +{ + "data": [ + { + "base_url": null, + "created_at": "2026-10-11T12:00:03Z", + "description": "", + "hint": "…6789", + "id": "fea4b364-ddf7-477d-a0f3-836a33cdadd0", + "kind": "generic", + "last_used_at": null, + "name": "GITHUB_WRITE_TOKEN", + "object": "secret", + "provider": null, + "updated_at": "2026-10-11T12:00:03Z", + "used_by": [], + "value_bytes": 32 + }, + { + "base_url": "https://api.together.xyz/v1", + "created_at": "2026-10-11T12:00:02Z", + "description": "", + "hint": "…6789", + "id": "4c0adf39-11ee-4309-81e1-95ad7e8c8110", + "kind": "provider_key", + "last_used_at": null, + "name": "TOGETHER_KEY", + "object": "secret", + "provider": "openai_compatible", + "updated_at": "2026-10-11T12:00:02Z", + "used_by": [], + "value_bytes": 29 + }, + { + "base_url": null, + "created_at": "2026-10-11T12:00:01Z", + "description": "personal", + "hint": "…6789", + "id": "f93d7c47-ece6-4e24-8086-d195c0e30ce2", + "kind": "provider_key", + "last_used_at": null, + "name": "ANTHROPIC_KEY", + "object": "secret", + "provider": "anthropic", + "updated_at": "2026-10-11T12:00:01Z", + "used_by": [], + "value_bytes": 35 + } + ] +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4f6f428..3ee0842 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3661,7 +3661,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.31" +const Version = "0.1.32" func renderAbout(l layout) string { var b strings.Builder