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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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:
<https://nan.builders/docs/ephemeral-runs>.

Expand All @@ -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)
```
Expand Down
54 changes: 54 additions & 0 deletions cmd/ephemeral.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "":
Expand Down Expand Up @@ -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 {
Expand Down
126 changes: 126 additions & 0 deletions cmd/ephemeral_ghtoken_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 2 additions & 2 deletions cmd/ephemeral_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 17 additions & 6 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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)")
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion internal/api/ephemeral_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
4 changes: 4 additions & 0 deletions internal/api/runs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
Loading