From ee5ccdfd7f012604c0724b14bf58702838433f70 Mon Sep 17 00:00:00 2001 From: barckcode Date: Tue, 20 Oct 2026 19:36:33 +0200 Subject: [PATCH] feat(run): --github-token-secret for ephemeral runs on private repos nan run --ephemeral --repo OWNER/NAME --github-token-secret NAME names one of the member's secrets holding a GitHub token. The platform uses it only to clone the private repository and never gives it to the agent (helmcode/nan-cloud-api#382). - Usage error (64) without --ephemeral, without --repo, for an empty value or a value that is not a secret name; a pasted GitHub token is never echoed and the member is told to revoke it. - The platform's 404 not_found and 400 on github_token_secret map to clear usage errors instead of "runs are not enabled". - nan run --help and README no longer say an ephemeral run started from the CLI has to use a public repository. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +-- cmd/ephemeral.go | 54 ++++++++++++++ cmd/ephemeral_ghtoken_test.go | 126 +++++++++++++++++++++++++++++++++ cmd/ephemeral_test.go | 4 +- cmd/run.go | 23 ++++-- internal/api/ephemeral_test.go | 10 ++- internal/api/runs.go | 4 ++ 7 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 cmd/ephemeral_ghtoken_test.go diff --git a/README.md b/README.md index d28fd66..b12890b 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,8 @@ id=$(nan run --detach "upgrade the dependencies") | `--model M` | the agent's own | Model for the agent. Required with `--byok-secret` and `--ephemeral`. | | `--byok-secret NAME` | NaN inference | Run on your own provider key: the name of a provider key secret saved in the portal. See below. | | `--ephemeral` | | Run in a fresh, throwaway container instead of a workspace. See below. | -| `--repo OWNER/NAME` | | With `--ephemeral`: the public GitHub repository the container clones. | +| `--repo OWNER/NAME` | | With `--ephemeral`: the GitHub repository the container clones. | +| `--github-token-secret NAME` | | With `--ephemeral` and `--repo`: the name of your secret holding a GitHub token, to clone a private repository. | | `--size s\|m\|l` | `s` | With `--ephemeral`: the container size. | | `--cwd PATH` | `/home/nan` | Folder in the workspace. | | `--worktree` / `--no-worktree` | auto | Work on a separate branch, or in the folder itself. See below. | @@ -144,10 +145,9 @@ nan run --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky tes runs the agent in a fresh, throwaway container: it clones the repository, runs the agent with the model you name, streams like any run, and is destroyed at the end. The container can only reach the internet over HTTP and HTTPS (no -SSH, no SMTP). A run started from the CLI gets no secrets, so the repository -has to be public; for a private one, use an ephemeral automation (`executor: -ephemeral`, `checkout: fresh`, `github_repo`, `model`, and an agent secret -`GITHUB_TOKEN`). Starting one needs your session or a platform token with the +SSH, no SMTP). For a private repository, `--github-token-secret NAME` names +one of your secrets holding a GitHub token that can read it (`nan secrets ls`): +it is used only to clone and is never given to the agent. Starting one needs your session or a platform token with the `runs:ephemeral` scope; API keys cannot. Docs: . @@ -171,6 +171,7 @@ rather than the columns. ```bash nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test" +nan run --ephemeral --repo acme/private --github-token-secret GITHUB_TOKEN --model glm5.3 "review the open PRs" nan ephemeral usage # this month's minutes, cost, free S-minutes left, prices nan ephemeral settings # your monthly spend cap and concurrent runs (change them in the browser) ``` diff --git a/cmd/ephemeral.go b/cmd/ephemeral.go index 4a6afa6..e0bbd64 100644 --- a/cmd/ephemeral.go +++ b/cmd/ephemeral.go @@ -576,6 +576,9 @@ func (env *runsEnv) billingNote(ctx context.Context) { func checkEphemeralFlags(opts *runOptions) error { if !opts.ephemeral { switch { + case opts.githubTokenSecret != "" || opts.githubTokenSecretSet: + return usageErrorf("--github-token-secret is for ephemeral runs: add --ephemeral and --repo OWNER/NAME " + + "(a workspace run reads the repositories already in the workspace)") case opts.repo != "": return usageErrorf("--repo is for ephemeral runs: add --ephemeral (a workspace run works in the workspace's own folders; pick one with --cwd)") case opts.size != "": @@ -609,9 +612,60 @@ func checkEphemeralFlags(opts *runOptions) error { } opts.size = size } + if opts.githubTokenSecret != "" || opts.githubTokenSecretSet { + return checkGitHubTokenSecret(opts.githubTokenSecret) + } return nil } +// gitHubTokenPrefixes are how GitHub tokens start: a --github-token-secret +// that starts like one is the token itself, pasted by mistake. +var gitHubTokenPrefixes = []string{"ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_"} + +func looksLikeAGitHubToken(v string) bool { + for _, p := range gitHubTokenPrefixes { + if strings.HasPrefix(v, p) { + return true + } + } + return looksLikeAKey(v) +} + +// checkGitHubTokenSecret checks the NAME given to --github-token-secret. +// The value is never repeated in an error when it is not a secret name: it +// may be the token. +func checkGitHubTokenSecret(name string) error { + if secretNamePattern.MatchString(name) { + return nil + } + const what = "--github-token-secret takes the NAME of a secret you saved with your GitHub token (like GITHUB_TOKEN: " + + "capital letters, digits and _), never the token itself; your names are in: nan secrets ls" + switch upper := strings.ToUpper(name); { + case looksLikeAGitHubToken(name): + return usageErrorf("%s. If that was the token, it is now in your shell history: revoke it on GitHub, "+ + "then save the new one with nan secrets set GITHUB_TOKEN --value-stdin", what) + case name != "" && len(name) <= 24 && secretNamePattern.MatchString(upper): + return usageErrorf("%s. Did you mean %s?", what, upper) + } + return usageErrorf("%s", what) +} + +// githubTokenRefusal is the platform refusing the --github-token-secret of +// an ephemeral run: no secret by that name (404), or one that cannot hold a +// GitHub token, like a provider key (400). +func githubTokenRefusal(apiErr *api.APIError, req *api.CreateRunRequest) *ExitError { + if apiErr.Param != "github_token_secret" { + return nil + } + name := runs.SanitizeLine(req.GitHubTokenSecret) + if apiErr.Status == 404 { + return usageErrorf("you have no secret named %s: save your GitHub token in the portal, Settings > Secrets at %s, "+ + "or with nan secrets set NAME --value-stdin; your secret names are in: nan secrets ls", name, secretsURL) + } + return usageErrorf("--github-token-secret %s: %s. It takes a secret that holds a GitHub token, not a provider key "+ + "(see nan secrets ls)", name, runs.SanitizeLine(apiErr.Message)) +} + // runPlace says where a run executes, for the line that announces it: // "on develop", or "in an ephemeral container (size M) for acme/api". func runPlace(run *api.Run, req *api.CreateRunRequest) string { diff --git a/cmd/ephemeral_ghtoken_test.go b/cmd/ephemeral_ghtoken_test.go new file mode 100644 index 0000000..57ac786 --- /dev/null +++ b/cmd/ephemeral_ghtoken_test.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" +) + +// Ad-hoc ephemeral runs on a private repository (cloud-api #382): nan run +// --ephemeral --repo OWNER/NAME --github-token-secret NAME names one of the +// member's secrets holding a GitHub token. The platform clones with it and +// never gives it to the agent. The flag needs --ephemeral and --repo; the +// value is a secret NAME, never the token (a pasted token is not echoed). +// The platform answers 404 not_found (no such secret) or 400 (a provider +// key, an empty name) with param github_token_secret. + +func TestEphemeralGitHubTokenSecretIsSent(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + opts := ephemeralOpts() + opts.githubTokenSecret, opts.githubTokenSecretSet = "GH_READ", true + if err := doRun(context.Background(), h.env, opts, []string{"x"}); err != nil { + t.Fatal(err) + } + c := h.fake.created + if c["github_token_secret"] != "GH_READ" || c["github_repo"] != "acme/api" || c["executor"] != "ephemeral" { + t.Errorf("created with %v", c) + } +} + +func TestEphemeralWithoutGitHubTokenSecretSendsNone(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + if err := doRun(context.Background(), h.env, ephemeralOpts(), []string{"x"}); err != nil { + t.Fatal(err) + } + if _, ok := h.fake.created["github_token_secret"]; ok { + t.Errorf("sent a token secret nobody asked for: %v", h.fake.created) + } +} + +func TestGitHubTokenSecretFlagMistakesAreUsageErrors(t *testing.T) { + const pasted = "ghp_0123456789abcdefghijABCDEFGHIJ012345" + for _, tc := range []struct { + name string + edit func(*runOptions) + wants, avoids []string + }{ + {"without --ephemeral", func(o *runOptions) { o.ephemeral, o.repo, o.model = false, "", "" }, + []string{"--github-token-secret is for ephemeral runs", "--ephemeral", "--repo"}, nil}, + {"without --repo", func(o *runOptions) { o.repo = "" }, []string{"--repo OWNER/NAME"}, nil}, + {"empty", func(o *runOptions) { o.githubTokenSecret = "" }, []string{"--github-token-secret", "NAME"}, nil}, + {"lowercase name", func(o *runOptions) { o.githubTokenSecret = "gh_read" }, []string{"Did you mean GH_READ?"}, nil}, + {"bad name", func(o *runOptions) { o.githubTokenSecret = "my token" }, []string{"capital letters, digits and _", "nan secrets ls"}, []string{"my token"}}, + {"pasted token", func(o *runOptions) { o.githubTokenSecret = pasted }, []string{"never the token", "shell history", "revoke it"}, []string{pasted, "ghp_"}}, + {"pasted fine-grained token", func(o *runOptions) { o.githubTokenSecret = "github_pat_11ABCDEFG0123456789_abcdefghijk" }, + []string{"shell history"}, []string{"github_pat_11"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + opts := ephemeralOpts() + opts.githubTokenSecret, opts.githubTokenSecretSet = "GH_READ", true + tc.edit(&opts) + err := doRun(context.Background(), h.env, opts, []string{"x"}) + if exitCode(err) != exitUsage { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + wantAll(t, tc.name, err.Error(), tc.wants...) + for _, a := range tc.avoids { + if strings.Contains(err.Error(), a) { + t.Errorf("%q repeats %q", err.Error(), a) + } + } + if h.fake.created != nil { + t.Error("sent the run anyway") + } + }) + } +} + +func TestGitHubTokenSecretRefusalsSayWhatToDo(t *testing.T) { + for _, tc := range []struct { + name string + status int + code, message string + wants, avoids []string + }{ + {"unknown secret", 404, "not_found", "secret not found", + []string{"no secret named GH_READ", "Settings > Secrets", "nan secrets ls"}, []string{"runs are not enabled"}}, + {"a provider key", 400, "invalid_request", "the secret is a provider key, not a GitHub token \u001b[2J", + []string{"--github-token-secret GH_READ", "provider key, not a GitHub token", "nan secrets ls"}, []string{"\u001b"}}, + } { + t.Run(tc.name, func(t *testing.T) { + msg, _ := json.Marshal(tc.message) + h := newHarness(t, &fakePlatform{ephemeral: true, createStatus: tc.status, + createBody: fmt.Sprintf(`{"error":{"message":%s,"type":"invalid_request_error","param":"github_token_secret","code":%q}}`, msg, tc.code)}) + h.env.cred = credential{kind: credSession} + opts := ephemeralOpts() + opts.githubTokenSecret, opts.githubTokenSecretSet = "GH_READ", true + err := doRun(context.Background(), h.env, opts, []string{"x"}) + if exitCode(err) != exitUsage { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + wantAll(t, tc.name, err.Error(), tc.wants...) + for _, a := range tc.avoids { + if strings.Contains(err.Error(), a) { + t.Errorf("%q mentions %q", err.Error(), a) + } + } + assertNoControls(t, tc.name, err.Error()) + }) + } +} + +// nan run --help tells how to run on a private repository, and no longer +// says an ephemeral run started here has to use a public one. +func TestRunHelpExplainsGitHubTokenSecret(t *testing.T) { + wantAll(t, "nan run --help", runCmd.Long, "--github-token-secret NAME", "private repository", "never") + if strings.Contains(runCmd.Long, "has to be public") { + t.Error("help still says the repository has to be public") + } + f := runCmd.Flags().Lookup("github-token-secret") + if f == nil || !strings.Contains(f.Usage, "--ephemeral") || !strings.Contains(f.Usage, "never the token") { + t.Fatalf("flag: %+v", f) + } +} diff --git a/cmd/ephemeral_test.go b/cmd/ephemeral_test.go index a64429a..64d8629 100644 --- a/cmd/ephemeral_test.go +++ b/cmd/ephemeral_test.go @@ -708,8 +708,8 @@ func TestEphemeralHelpExplainsSMinutes(t *testing.T) { t.Errorf("%s --help claims EU only", name) } } - wantAll(t, "nan run --help", runCmd.Long, "--ephemeral --repo OWNER/NAME --model ID", "HTTP and HTTPS", "public", - "GITHUB_TOKEN", "1 vCPU, 4 GiB RAM, 10 GB disk", "€0.012 per minute", "14 s run is 1 minute", "runs:ephemeral", + wantAll(t, "nan run --help", runCmd.Long, "--ephemeral --repo OWNER/NAME --model ID", "HTTP and HTTPS", "private repository", + "--github-token-secret NAME", "1 vCPU, 4 GiB RAM, 10 GB disk", "€0.012 per minute", "14 s run is 1 minute", "runs:ephemeral", "metered and shown, not charged", "nan ephemeral usage", "come with a membership", "payment method", "Docs: https://nan.builders/docs/ephemeral-runs", runsDocs) wantAll(t, "nan run examples", runCmd.Example, "nan run --ephemeral --repo acme/api --model glm5.3 --size m") for name, c := range map[string]string{"nan ephemeral": ephemeralCmd.Long, "nan ephemeral usage": ephemeralUsageCmd.Long, diff --git a/cmd/run.go b/cmd/run.go index 1b998bc..d8cbe76 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -52,6 +52,10 @@ type runOptions struct { ephemeral bool repo string size string + // githubTokenSecret: with --ephemeral, the NAME of the member's secret + // holding the GitHub token that clones a private --repo. + githubTokenSecret string + githubTokenSecretSet bool } var runOpts runOptions @@ -96,10 +100,10 @@ Ephemeral runs (no workspace): throwaway container instead of a workspace: it clones the GitHub repository, runs the agent (pi, pi_gentle or hermes) with the model you name, streams like any run, and is destroyed at the end. The container - can only reach the internet over HTTP and HTTPS (no SSH, no SMTP). A run - started here gets no secrets, so the repository has to be public; for a - private one, use an ephemeral automation with an agent secret - GITHUB_TOKEN (nan automations create --help). --ws, --cwd, --worktree + can only reach the internet over HTTP and HTTPS (no SSH, no SMTP). + For a private repository, --github-token-secret NAME names one of your + secrets holding a GitHub token that can read it (nan secrets ls): it is + used only to clone, never given to the agent. --ws, --cwd, --worktree and --no-worktree do not apply. --size picks the container (default s), billed per started minute (a 14 s run is 1 minute): @@ -162,12 +166,14 @@ Docs: https://nan.builders/docs/runs`, 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 --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky test" - nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test"`, + nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test" + nan run --ephemeral --repo acme/private --github-token-secret GITHUB_TOKEN --model glm5.3 "review it"`, Args: cobra.ArbitraryArgs, Annotations: sysexits, RunE: func(cmd *cobra.Command, args []string) error { opts := runOpts opts.timeoutSet = cmd.Flags().Changed("timeout") + opts.githubTokenSecretSet = cmd.Flags().Changed("github-token-secret") // The arguments first: a usage mistake is the thing to report, even // on a machine that is not signed in. req, err := buildRunRequest(promptInput{r: os.Stdin, tty: isTerminal(os.Stdin), notice: os.Stderr}, opts, args) @@ -192,7 +198,8 @@ func init() { f := runCmd.Flags() f.StringVar(&runOpts.workspace, "ws", "", "Workspace to run in (default: your only workspace)") f.BoolVar(&runOpts.ephemeral, "ephemeral", false, "Run in a fresh, throwaway container instead of a workspace (needs --repo and --model)") - f.StringVar(&runOpts.repo, "repo", "", "With --ephemeral: the public GitHub repository to clone, `OWNER/NAME`") + f.StringVar(&runOpts.repo, "repo", "", "With --ephemeral: the GitHub repository to clone, `OWNER/NAME`") + f.StringVar(&runOpts.githubTokenSecret, "github-token-secret", "", "With --ephemeral and --repo: the `NAME` of your secret holding a GitHub token, to clone a private repository (never the token itself)") f.StringVar(&runOpts.size, "size", "", "With --ephemeral: the container, s, m or l (default s; see Ephemeral runs above)") 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; required with --byok-secret and --ephemeral)") @@ -264,6 +271,9 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat if exit := byokRefusal(apiErr, req); exit != nil { return exit } + if exit := githubTokenRefusal(apiErr, req); exit != nil { + return exit + } if exit := ephemeralExit(apiErr, env.cred); exit != nil { return exit } @@ -537,6 +547,7 @@ func buildRunRequest(in promptInput, opts runOptions, args []string) (*api.Creat if opts.ephemeral { repo := opts.repo req.Executor, req.Size, req.GitHubRepo = api.ExecutorEphemeral, opts.size, &repo + req.GitHubTokenSecret = opts.githubTokenSecret } if opts.timeoutSet { if opts.timeout < minTimeout || opts.timeout > maxTimeout { diff --git a/internal/api/ephemeral_test.go b/internal/api/ephemeral_test.go index 5eb3519..264147d 100644 --- a/internal/api/ephemeral_test.go +++ b/internal/api/ephemeral_test.go @@ -94,10 +94,18 @@ func TestCreateEphemeralRunBody(t *testing.T) { if got["executor"] != "ephemeral" || got["size"] != "m" || got["github_repo"] != "acme/api" || got["model"] != "glm5.3" { t.Errorf("body = %s", b) } + // No token secret named: the field is left out (an anonymous clone). + if _, ok := got["github_token_secret"]; ok { + t.Errorf("sends github_token_secret nobody named: %s", b) + } + b, _ = json.Marshal(CreateRunRequest{Agent: "pi", Prompt: "x", Model: &model, Executor: ExecutorEphemeral, GitHubRepo: &repo, GitHubTokenSecret: "GH_READ"}) + if !strings.Contains(string(b), `"github_token_secret":"GH_READ"`) { + t.Errorf("token secret body = %s", b) + } // A workspace run keeps its shape: no executor, size or repository. b, _ = json.Marshal(CreateRunRequest{Workspace: "develop", Agent: "pi", Prompt: "x"}) if s := string(b); !strings.Contains(s, `"workspace":"develop"`) || strings.Contains(s, "executor") || - strings.Contains(s, "size") || strings.Contains(s, "github_repo") { + strings.Contains(s, "size") || strings.Contains(s, "github_repo") || strings.Contains(s, "github_token_secret") { t.Errorf("workspace body = %s", s) } } diff --git a/internal/api/runs.go b/internal/api/runs.go index 575fd6e..3bc2006 100644 --- a/internal/api/runs.go +++ b/internal/api/runs.go @@ -201,6 +201,10 @@ type CreateRunRequest struct { Executor string `json:"executor,omitempty"` Size string `json:"size,omitempty"` GitHubRepo *string `json:"github_repo,omitempty"` + // GitHubTokenSecret (ephemeral, with GitHubRepo): the NAME of the + // member's secret holding a GitHub token, used only to clone a private + // repository and never given to the agent. Left out: anonymous clone. + GitHubTokenSecret string `json:"github_token_secret,omitempty"` } // RunInference is the inference choice of POST /v1/runs: {"source":"byok"}