diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 609f35e..25a4ef5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest in contributing. This document covers how to get the pr ## Prerequisites -- **Go 1.26.9+** — the version in `go.mod`, which is where the standard +- **Go 1.26.10+** — the version in `go.mod`, which is where the standard library carries the current security fixes. The project uses [mise](https://mise.jdx.dev/) to pin the version. Run `mise install` in the repo root and Go will be available automatically. - A [nan.builders](https://nan.builders) account with an API key (needed to test the TUI at runtime). diff --git a/README.md b/README.md index fa9d30a..3e066a8 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,13 @@ 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. Required with `--byok-secret`. | +| `--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 GitHub repository the container clones. Can be left out with `--context`. | +| `--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. | +| `--context NAME` | | Load one of your [agent contexts](#agent-contexts), by name or id. Repeat it for more, at most 5, loaded in the order given. | | `--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`. | @@ -137,6 +142,44 @@ nan secrets ls # KIND provider_key nan run --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky test" ``` +**Ephemeral runs (no workspace).** `--ephemeral --repo OWNER/NAME --model ID` +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). 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. With `--context`, +`--repo` can be left out: the agent then works in an empty folder with its +contexts and clones nothing. Starting one needs your session or a platform token with the +`runs:ephemeral` scope; API keys cannot. Docs: +. + +| Size | Container | Price per started minute | +|---|---|---| +| `s` (default) | 1 vCPU, 4 GiB RAM, 10 GB disk | €0.003 | +| `m` | 2 vCPU, 8 GiB RAM, 16 GB disk | €0.006 | +| `l` | 4 vCPU, 12 GiB RAM, 20 GB disk | €0.012 | + +A 14 s run is 1 minute. Free minutes every month: Community 100, Inference 300, +Premium 1000; they come with a membership (accounts without one have none). +Free minutes are S-minutes: one M minute uses 2, one L minute 4 (100 free +minutes = 100 minutes of S, 50 of M or 25 of L). Ephemeral runs need a payment +method on file, free minutes included ("Add a payment method" in the portal, +Settings → Ephemeral runs; a subscription billed in USD is moved with "Switch +billing to EUR" there). While billing is not active, usage is +metered and shown, not charged. `nan runs ls` and `nan runs show` show each +ephemeral run's size and cost. The COST column of `nan runs ls` appears only +when an ephemeral run is listed, so scripts should read `nan runs ls --json` +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 run --ephemeral --context release-notes --model glm5.3 "write this week's release notes" # no repository +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) +``` + **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. @@ -179,8 +222,9 @@ and manage your secrets (values go in, only names and metadata come out): ```bash nan automations ls [--json] nan automations show [--json] -nan automations create -f automation.yaml # YAML or JSON, - for stdin +nan automations create -f automation.yaml [--context NAME ...] [--checkout none] # YAML or JSON, - for stdin nan automations edit -f changes.yaml # only the fields in the file change +nan automations edit --context NAME [--context NAME ...] [--checkout none] # no -f needed nan automations enable|disable nan automations rm [--yes] nan automations triggers ls [--json] @@ -264,6 +308,51 @@ A platform token needs `notifications:read` to read and notifications on your account, these commands exit 64 and point at the portal. Docs: . +### Agent contexts + +Keep instructions (`AGENTS.md`), skills (`skills//SKILL.md`) and +reference files (`files/...`) on NaN as a versioned context for your runs and +automations: + +```bash +nan context ls [--json] +nan context show [--json] +nan context create NAME [--description TEXT] [--from DIR] [--json] +nan context push DIR [--create] [--dry-run] [--force] [--allow-secrets] [--skip-invalid] [--allow-empty] [--json] +nan context pull DIR [--force] +nan context edit [--name NEW] [--description TEXT] [--json] +nan context rm [--yes] +``` + +`push` checks every file name before uploading anything (dotfiles are +skipped; other refused names are listed with their reason and the push exits +65 unless `--skip-invalid`), uploads only what NaN does not already hold +(large files in resumable 16 MiB parts) and publishes a new version. Text +that looks like a credential is refused unless `--allow-secrets`, and an +empty folder never empties a context unless `--allow-empty`. `pull` +writes into an empty folder only, after checking every path with the same +rules. Without contexts on your account, these commands exit 64 and point at +the portal. Docs: . + +Use them in runs and automations with `--context NAME` (repeatable, at most 5, +loaded in the order given, each at its latest version when the run starts): + +```bash +nan run --context acme-api --context style-guide "review PR 42" +nan run --ephemeral --context release-notes --model glm5.3 "write the release notes" # no --repo needed +nan automations create -f review.yaml --context acme-api +nan automations create -f notes.yaml --checkout none --context release-notes # ephemeral, no repository +nan automations edit "PR review api" --context acme-api --context style-guide # replaces its contexts +``` + +An ephemeral run or automation without a repository (`--checkout none`, the +file's `checkout: none`) needs at least one context. A file's `contexts:` +field and `--context` are the same thing: give them in one place. Every +mistake (a sixth context, the same one twice, a name that is not a context +name) exits 64 before anything is sent; so does a context the platform does +not find, two contexts that define the same skill, or contexts too large for +one run together (250 MiB, 5000 files, 64 KiB of instructions). + ### Exit codes `nan run`, `nan runs logs -f` and `nan automations run` exit with the run's outcome, so a script can @@ -277,9 +366,9 @@ branch on it without parsing text: | 3 | cancelled (after a double Ctrl-C: cancel requested — `nan runs show` has the final state) | | 4 | the workspace is not set up for it: agent not installed, no inference key, bad configuration | | 10 | awaiting approval: an automation run whose proposed actions wait for you (`nan runs proposal `, approve in the portal) | -| 64 | usage error (also: more than one workspace and no `--ws`) | -| 65 | not signed in, session expired, or not allowed | -| 69 | nan.builders unavailable, or the stream was lost after retries (the run goes on; the id is printed) | +| 64 | usage error (also: more than one workspace and no `--ws`; a `--context` that does not exist, repeats a skill of another, or makes the run too large) | +| 65 | not signed in, session expired, or not allowed (also: a token without `runs:ephemeral`, ephemeral runs not available to you) | +| 69 | nan.builders unavailable, the stream was lost after retries (the run goes on; the id is printed), or an ephemeral run would pass your monthly spend cap | | 75 | `--detach`, or Ctrl-C: the run was accepted and is still going | Full reference, including the event types `nan runs logs --json` prints (one @@ -330,9 +419,32 @@ nan auth login --api-token < token.txt # or pipe it in Never put a token on the command line itself: it would end up in your shell history and in `ps`. +**Two-factor authentication.** With it turned on for your account (Settings > +Account > Two-factor authentication at ), +`nan auth login` and the dashboard's sign-in ask for the code from your +authenticator app after the link, without echo. One of your recovery codes +works in its place. When nan cannot ask, pipe the code in. Never type the code +into the command itself, where it would stay in your shell history: read it +into a variable without echo, or redirect it from a file or a password +manager's command line. + +```bash +read -rs code # type or paste it; it is not shown +printf '%s\n' "$code" | nan auth login --link "" --totp - +nan auth login --link "" --totp - < code.txt +``` + +`--totp` never takes the code as an argument: its only value is `-`, which +reads one line of stdin. With `--totp -` the code gets one try, where the +prompt lets you try again; if stdin is a terminal it is still asked for there, +without echo. + +Versions before 0.1.35 cannot ask for the code: the platform refuses the link +with `mfa enabled update nan cli`. Update, then ask for a new link. + ## Build from source -Requires Go 1.26.9+ ([mise](https://mise.jdx.dev/) recommended) — the version +Requires Go 1.26.10+ ([mise](https://mise.jdx.dev/) recommended) — the version in `go.mod`, which is where the standard library carries the current security fixes: diff --git a/cmd/auth.go b/cmd/auth.go index a9f0e10..ef6ccee 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "os/signal" "strings" "github.com/charmbracelet/x/term" @@ -23,6 +24,7 @@ var ( linkFlag string keepToolsFlag bool apiTokenFlag bool + totpFlag string ) var authCmd = &cobra.Command{ @@ -35,6 +37,12 @@ var loginCmd = &cobra.Command{ Short: "Log in by email, or save a platform token with --api-token", Long: `Log in with a sign-in link sent to your email. +With two-factor authentication on, the link is followed by the code from +your authenticator app, or one of your recovery codes. It is asked for +without echo, again after a wrong one; with no terminal to ask on, pipe it +in with --totp -. With --totp - the code gets one try, and if stdin is a +terminal it is still asked for there without echo. + With --api-token, save a platform token instead, for a machine with nobody at the keyboard (a server, a CI runner). Create the token in Settings > Tokens at https://cloud.nan.builders. It is read from stdin only, so it @@ -45,7 +53,8 @@ need the email sign-in. An API key (sk-...) can be saved the same way; it replaces the one the Setup tab saved.`, Example: ` nan auth login --email you@example.com nan auth login --api-token # paste the token; it is not shown - nan auth login --api-token < token.txt`, + nan auth login --api-token < token.txt + read -rs code; printf '%s\n' "$code" | nan auth login --link "" --totp - # two-factor, no prompt from nan`, RunE: runLogin, } @@ -63,6 +72,7 @@ func init() { loginCmd.Flags().StringVar(&linkFlag, "link", "", `Finish the login with the link from the email ("-" reads it from stdin, keeping the token out of your shell history)`) loginCmd.Flags().StringVar(&tokenFlag, "token", "", `Save a nan_session token directly, skipping the email ("-" reads it from stdin, keeping it out of your shell history)`) loginCmd.Flags().BoolVar(&apiTokenFlag, "api-token", false, "Save a platform token (nan_pat_...) or API key (sk-...) for nan run and nan runs, read from stdin") + loginCmd.Flags().StringVar(&totpFlag, "totp", "", `With two-factor authentication on, read the code (or a recovery code) from stdin instead of asking: "-" is the only value`) logoutCmd.Flags().BoolVar(&keepToolsFlag, "keep-tools", false, "Leave the API key in the tools this CLI configured") } @@ -72,11 +82,14 @@ func init() { // and the command then asked for a cookie that no longer existed. func runLogin(cmd *cobra.Command, args []string) error { if apiTokenFlag { - if tokenFlag != "" || linkFlag != "" || emailFlag != "" { - return fmt.Errorf("--api-token cannot be combined with --email, --link or --token") + if tokenFlag != "" || linkFlag != "" || emailFlag != "" || totpFlag != "" { + return fmt.Errorf("--api-token cannot be combined with --email, --link, --token or --totp") } return loginWithAPIToken(cmd.Context(), newTokenInput(os.Stdin), os.Stdout, os.Stderr) } + if err := checkTOTPFlag(); err != nil { + return err + } if tokenFlag != "" { token, err := flagValue(tokenFlag) if err != nil { @@ -97,11 +110,11 @@ func runLogin(cmd *cobra.Command, args []string) error { if err != nil { return err } - sessionToken, err := auth.ExchangeToken(token) - if err != nil { - return err - } - return saveToken(sessionToken) + // A code is a line of a few characters; nothing past this is one. + stdin := bufio.NewReader(io.LimitReader(os.Stdin, 1024)) + return finishSignIn(token, newCodeInput(os.Stdin, func() (string, error) { + return stdin.ReadString('\n') + }), os.Stdout, os.Stderr) } in := bufio.NewScanner(os.Stdin) @@ -169,11 +182,206 @@ func runLogin(cmd *cobra.Command, args []string) error { return err } + // The code comes from the same scanner as the two answers before it: a + // pipe that carries all three has already been read into its buffer. + return finishSignIn(token, newCodeInput(os.Stdin, func() (string, error) { + if in.Scan() { + return in.Text(), nil + } + if err := in.Err(); err != nil { + return "", err + } + return "", io.EOF + }), os.Stdout, os.Stderr) +} + +// checkTOTPFlag refuses the --totp spellings that cannot work. +// +// Only "-": --totp never takes the code as an argument. A recovery code is +// good until it is used, and one written on the command line is in the +// shell's history and in `ps` for as long as that lasts. The examples keep it +// off the command line too - read into a variable, or from a file - because +// `echo |` would put it in the history just the same. And not next to another flag that reads stdin, because the first one +// would take everything and leave the code nothing. +func checkTOTPFlag() error { + switch { + case totpFlag == "": + return nil + case totpFlag != "-": + return fmt.Errorf(`--totp only takes "-": it never takes the code as an argument, where it would stay in your shell history and in ps. Read it from stdin:%s`, totpHint) + case tokenFlag != "": + return fmt.Errorf("--totp cannot be combined with --token: a saved session token skips the sign-in, and its code") + case linkFlag == "-": + return fmt.Errorf("--link - and --totp - cannot both read stdin: pass the link as an argument (it works once, and is spent by this command):%s", totpHint) + } + return nil +} + +// totpHint is how to pipe the code in without typing it into the command: +// the variable is filled without echo and never appears in the history. +const totpHint = "\n\n read -rs code; printf '%s\\n' \"$code\" | nan auth login --link \"\" --totp -\n\n" + + "or redirect it from a file or a password manager: --totp - < code.txt" + +// finishSignIn spends the link, answers the two-factor challenge when the +// account has one, and saves the session. +func finishSignIn(token string, codes codeInput, out, prompt io.Writer) error { sessionToken, err := auth.ExchangeToken(token) + var challenge *auth.MFARequiredError + if !errors.As(err, &challenge) { + if err != nil { + return err + } + return saveToken(sessionToken) + } + + signIn, err := answerChallenge(challenge, codes, prompt) if err != nil { return err } - return saveToken(sessionToken) + if err := saveToken(signIn.Session); err != nil { + return err + } + if signIn.UsedRecoveryCode { + fmt.Fprintln(out, recoveryCodesLeft(signIn.RecoveryCodesLeft)) + } + return nil +} + +// answerChallenge asks for the code until the platform takes one or the +// sign-in ends. Asking again is for a member at a terminal: a code piped in +// with --totp - gets one try, and anything else ends with the refusal. +func answerChallenge(challenge *auth.MFARequiredError, codes codeInput, prompt io.Writer) (*auth.SignIn, error) { + if !codes.tty && !codes.fromStdin { + // The link is spent by now - the platform consumed it to open the + // challenge - so this cannot just say "run it again with the flag". + return nil, fmt.Errorf("this account uses two-factor authentication, and there is no terminal here to ask for the code. "+ + "The link is spent: ask for a new one, and pipe the code in with --totp -:%s", totpHint) + } + interactive := codes.tty && !codes.fromStdin + if interactive { + fmt.Fprintln(prompt, "This account uses two-factor authentication. Enter the 6-digit code from your") + fmt.Fprintln(prompt, "authenticator app, or one of your recovery codes.") + } + for { + input, err := codes.read(prompt) + if err != nil { + return nil, err + } + signIn, err := challenge.Complete(input) + if err == nil { + return signIn, nil + } + if !interactive || !auth.Retryable(err) { + return nil, err + } + fmt.Fprintln(prompt, "error: "+err.Error()) + } +} + +// recoveryCodesLeft is what the login says after a recovery code got the +// member in: each works once, and running out is the way to be locked out. +func recoveryCodesLeft(n int) string { + const where = "cloud.nan.builders, Settings > Account > Two-factor authentication" + switch n { + case 0: + return "That was your last recovery code. Make new ones before you need one: " + where + "." + case 1: + return "You signed in with a recovery code: 1 left. Make new ones in the browser: " + where + "." + } + return fmt.Sprintf("You signed in with a recovery code: %d left. You can make new ones in the browser: %s.", n, where) +} + +// readHiddenLine reads a line from the terminal f without echo. +// +// term.ReadPassword turns echo off and only puts it back when it returns. A +// ctrl-c in between ends the process first, and the shell it hands back to is +// left without echo: whatever is typed next is invisible until `reset`. So for +// as long as the prompt is up, an interrupt puts the terminal back before the +// command exits. +func readHiddenLine(f *os.File) ([]byte, error) { + fd := f.Fd() + if state, err := termGetState(fd); err == nil { + release := onInterrupt(func() { + _ = termRestore(fd, state) + fmt.Fprintln(os.Stderr) + }) + defer release() + } + return termReadPassword(fd) +} + +// The terminal calls readHiddenLine makes, as variables so a test can stand +// in for a terminal it does not have and see what the interrupt handler puts +// back. +var ( + termGetState = term.GetState + termRestore = term.Restore + termReadPassword = term.ReadPassword +) + +// interrupted ends the command after ctrl-c, with the status a shell gives an +// interrupted one. A variable for the tests. +var interrupted = func() { os.Exit(130) } + +// onInterrupt runs undo and then interrupted if ctrl-c arrives before release +// is called. Until then the interrupt is this function's to handle, not the +// runtime's, which would exit without undoing anything. +func onInterrupt(undo func()) (release func()) { + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + done := make(chan struct{}) + go func() { + select { + case <-sig: + undo() + interrupted() + case <-done: + } + }() + return func() { + signal.Stop(sig) + close(done) + } +} + +// codeInput is where the second factor comes from: a hidden prompt on a +// terminal, or one line of stdin with --totp -. +type codeInput struct { + tty bool + fromStdin bool + readHidden func() ([]byte, error) + readLine func() (string, error) +} + +// newCodeInput asks f, hidden when it is a terminal; readLine is the next +// line of the same stdin, for --totp -. +func newCodeInput(f *os.File, readLine func() (string, error)) codeInput { + return codeInput{ + tty: term.IsTerminal(f.Fd()), + fromStdin: totpFlag == "-", + readHidden: func() ([]byte, error) { return readHiddenLine(f) }, + readLine: readLine, + } +} + +func (in codeInput) read(prompt io.Writer) (string, error) { + if in.tty { + fmt.Fprint(prompt, "Authenticator code: ") + raw, err := in.readHidden() + fmt.Fprintln(prompt) + if err != nil { + return "", fmt.Errorf("could not read the code: %w", err) + } + return strings.TrimSpace(string(raw)), nil + } + line, err := in.readLine() + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("could not read the code from stdin: %w", err) + } + if line = strings.TrimSpace(line); line == "" { + return "", fmt.Errorf("no code arrived on stdin. The link is spent: ask for a new one, and pipe the code in:%s", totpHint) + } + return line, nil } // A flag whose value is a credential, with "-" meaning stdin. @@ -266,7 +474,7 @@ func newTokenInput(f *os.File) tokenInput { return tokenInput{ r: f, tty: term.IsTerminal(f.Fd()), - readHidden: func() ([]byte, error) { return term.ReadPassword(f.Fd()) }, + readHidden: func() ([]byte, error) { return readHiddenLine(f) }, } } diff --git a/cmd/auth_mfa_test.go b/cmd/auth_mfa_test.go new file mode 100644 index 0000000..953877b --- /dev/null +++ b/cmd/auth_mfa_test.go @@ -0,0 +1,439 @@ +package cmd + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nxssie/nan-cli/internal/auth" + "github.com/nxssie/nan-cli/internal/auth/authtest" +) + +// Two-factor sign-in from `nan auth login`, against a fake platform written +// from the MFA API contract (internal/auth/authtest). + +const testLink = "https://cloud-api.nan.builders/api/auth/login/verify?token=" + authtest.LinkToken + +// mfaPlatform points the sign-in at the fake and gives the login flags back +// their defaults when the test ends: cobra writes them into package vars. +func mfaPlatform(t *testing.T, factor bool) *authtest.Platform { + t.Helper() + p := authtest.New(t) + p.Factor = factor + swap(t, &auth.SignInBaseURL, p.URL) + for _, f := range []*string{&linkFlag, &totpFlag, &tokenFlag, &emailFlag} { + swap(t, f, "") + } + swap(t, &apiTokenFlag, false) + return p +} + +// typed is a terminal on which the member types these, one per prompt. +func typed(answers ...string) (codeInput, *int) { + asked := new(int) + return codeInput{tty: true, readHidden: func() ([]byte, error) { + if *asked >= len(answers) { + return nil, io.EOF + } + *asked++ + return []byte(answers[*asked-1] + "\n"), nil + }}, asked +} + +// piped is stdin that is not a terminal: what --totp - reads. +func piped(stdin string, totp bool) codeInput { + r := strings.NewReader(stdin) + line := func() (string, error) { + var b strings.Builder + buf := make([]byte, 1) + for { + n, err := r.Read(buf) + if n == 1 { + if buf[0] == '\n' { + return b.String(), nil + } + b.WriteByte(buf[0]) + } + if err != nil { + return b.String(), err + } + } + } + return codeInput{fromStdin: totp, readLine: line} +} + +func TestLoginWithoutAFactorIsUnchanged(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, false) + var prompt bytes.Buffer + codes, asked := typed() + if err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt); err != nil { + t.Fatal(err) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } + if *asked != 0 || prompt.Len() != 0 || len(p.Challenges()) != 0 { + t.Errorf("asked for a code an account without a factor does not have: %d prompts, %q", *asked, prompt.String()) + } +} + +func TestLoginAsksForTheCodeOnATerminal(t *testing.T) { + home := fakeHome(t, `{"token":"","apiKey":"sk-kept"}`) + mfaPlatform(t, true) + var prompt bytes.Buffer + codes, asked := typed(authtest.Code) + if err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt); err != nil { + t.Fatal(err) + } + sess, _ := readSession(t, home) + if sess["token"] != authtest.Session || sess["apiKey"] != "sk-kept" { + t.Errorf("session.json = %v", sess) + } + if *asked != 1 || !strings.Contains(prompt.String(), "Authenticator code: ") { + t.Errorf("%d prompts: %q", *asked, prompt.String()) + } + if !strings.Contains(prompt.String(), "recovery codes") { + t.Errorf("the prompt does not say a recovery code works too: %q", prompt.String()) + } +} + +// A wrong code, a replayed one and something that is not a code at all are +// all asked again, on the same sign-in, with what went wrong. +func TestLoginAsksAgainAfterAWrongCode(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + var prompt bytes.Buffer + codes, asked := typed("000000", "not a code", authtest.Code) + if err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt); err != nil { + t.Fatal(err) + } + if *asked != 3 { + t.Errorf("%d prompts, want 3", *asked) + } + for _, want := range []string{"not right (4 attempts left)", "neither a 6-digit code"} { + if !strings.Contains(prompt.String(), want) { + t.Errorf("%q does not say %q", prompt.String(), want) + } + } + // "not a code" never left the machine. + if n := len(p.Challenges()); n != 2 { + t.Errorf("%d challenge POSTs, want 2", n) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } +} + +func TestLoginWithARecoveryCodeSaysHowManyAreLeft(t *testing.T) { + for _, c := range []struct { + codes []string + want string + }{ + {[]string{"q7wa-z2xd-pl4r-6wze", "aaaa-bbbb-cccc-dddd", "eeee-ffff-gggg-hhhh"}, "2 left"}, + {[]string{"q7wa-z2xd-pl4r-6wze"}, "last recovery code"}, + } { + t.Run(c.want, func(t *testing.T) { + fakeHome(t, "") + p := mfaPlatform(t, true) + p.RecoveryCodes = c.codes + var out bytes.Buffer + codes, _ := typed("Q7WA Z2XD PL4R 6WZE") + if err := finishSignIn(authtest.LinkToken, codes, &out, io.Discard); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), c.want) || !strings.Contains(out.String(), "Two-factor authentication") { + t.Errorf("%q does not say %q and where to make more", out.String(), c.want) + } + }) + } +} + +// Each way the sign-in ends saves nothing and says what to do next. +func TestLoginThatCannotFinishSavesNothing(t *testing.T) { + for _, c := range []struct { + name string + setup func(*authtest.Platform) + codes []string + want string + }{ + {"five wrong codes", nil, []string{"000000", "000001", "000002", "000003", "000004"}, "too many wrong codes"}, + {"expired", func(p *authtest.Platform) { p.Expire() }, []string{authtest.Code}, "timed out"}, + {"refused", func(p *authtest.Platform) { + p.Next = &authtest.Refusal{Status: 403, Body: `{"error":"login_refused","message":"x"}`} + }, []string{authtest.Code}, "refused the sign-in"}, + {"nothing typed at all", nil, nil, "could not read the code"}, + } { + t.Run(c.name, func(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + codes, _ := typed(c.codes...) + // The challenge opens on the verify POST, which finishSignIn + // makes; the fake is changed as soon as it has. + if c.setup != nil { + inner := codes.readHidden + first := true + codes.readHidden = func() ([]byte, error) { + if first { + first = false + c.setup(p) + } + return inner() + } + } + err := finishSignIn(authtest.LinkToken, codes, io.Discard, io.Discard) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("err = %v, want %q", err, c.want) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } + }) + } +} + +// --totp - reads one line of stdin, with no prompt and no second try: a +// script cannot answer one. +func TestTOTPFromStdin(t *testing.T) { + t.Run("right", func(t *testing.T) { + home := fakeHome(t, "") + mfaPlatform(t, true) + var prompt bytes.Buffer + if err := finishSignIn(authtest.LinkToken, piped(authtest.Code+"\n", true), io.Discard, &prompt); err != nil { + t.Fatal(err) + } + if prompt.Len() != 0 { + t.Errorf("prompted a pipe: %q", prompt.String()) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } + }) + t.Run("wrong, then nothing", func(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + err := finishSignIn(authtest.LinkToken, piped("000000\n"+authtest.Code+"\n", true), io.Discard, io.Discard) + if err == nil || !strings.Contains(err.Error(), "not right") { + t.Fatalf("err = %v", err) + } + if n := len(p.Challenges()); n != 1 { + t.Errorf("%d tries from a pipe, want 1", n) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } + }) + t.Run("empty", func(t *testing.T) { + fakeHome(t, "") + mfaPlatform(t, true) + err := finishSignIn(authtest.LinkToken, piped("", true), io.Discard, io.Discard) + if err == nil || !strings.Contains(err.Error(), "no code arrived") || !strings.Contains(err.Error(), "--totp -") { + t.Fatalf("err = %v", err) + } + }) +} + +// --totp - with a terminal on stdin: the code is still read without echo, +// and it still gets one try, as the help says. +func TestTOTPFromATerminalStdin(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + codes, asked := typed("000000", authtest.Code) + codes.fromStdin = true + var prompt bytes.Buffer + err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt) + if err == nil || !strings.Contains(err.Error(), "not right") { + t.Fatalf("err = %v", err) + } + if *asked != 1 || len(p.Challenges()) != 1 { + t.Errorf("%d prompts, %d POSTs: --totp - gets one try", *asked, len(p.Challenges())) + } + if !strings.Contains(prompt.String(), "Authenticator code: ") { + t.Errorf("not asked on the terminal: %q", prompt.String()) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } +} + +// An account that has not finished signing up and has a factor finishes in +// the browser: nan auth login asks for no code and saves nothing, and says +// what to do next. +func TestLoginForAnUnfinishedSignUpSendsItToTheBrowser(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + p.Onboarding = true + var prompt bytes.Buffer + codes, asked := typed(authtest.Code) + err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt) + const want = "your NaN account is not set up yet — finish it in the browser: ask for a new link at " + + "https://cloud.nan.builders (this one is spent), complete the setup there, then sign in here again" + if err == nil || err.Error() != want { + t.Fatalf("err = %v", err) + } + if *asked != 0 || prompt.Len() != 0 || len(p.Challenges()) != 0 { + t.Errorf("asked for a code with no challenge: %d prompts, %q", *asked, prompt.String()) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } +} + +// No terminal and no --totp: the link is already spent, so the message says +// to ask for another and names the flag that would have worked. +func TestNoTerminalAndNoFlag(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + err := finishSignIn(authtest.LinkToken, piped(authtest.Code+"\n", false), io.Discard, io.Discard) + if err == nil { + t.Fatal("signed in with no way to ask for the code") + } + for _, want := range []string{"two-factor", "new one", "--totp -"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("read a code nobody asked to be read: %d POSTs", n) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } +} + +// The whole command, as a script runs it: link as an argument, code piped in. +func TestLoginCommandWithTOTPFromStdin(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + withStdin(t, authtest.Code+"\n") + rootCmd.SetArgs([]string{"auth", "login", "--link", testLink, "--totp", "-"}) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + if err := rootCmd.Execute(); err != nil { + t.Fatal(err) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } + if f := p.Verifies(); len(f) != 1 || f[0].Get("mfa") != "1" { + t.Errorf("verify forms = %v", f) + } + assertNoChallengeOnDisk(t, home) +} + +func TestTOTPFlagSpellings(t *testing.T) { + for _, c := range []struct { + name string + args []string + want string + }{ + {"a code on the command line", []string{"--link", testLink, "--totp", authtest.Code}, `only takes "-"`}, + {"two flags reading stdin", []string{"--link", "-", "--totp", "-"}, "cannot both read stdin"}, + {"with a saved token", []string{"--token", "x", "--totp", "-"}, "cannot be combined with --token"}, + {"with --api-token", []string{"--api-token", "--totp", "-"}, "cannot be combined"}, + } { + t.Run(c.name, func(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + rootCmd.SetArgs(append([]string{"auth", "login"}, c.args...)) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("err = %v, want %q", err, c.want) + } + if strings.Contains(err.Error(), authtest.Code) { + t.Errorf("the refusal repeats the code: %v", err) + } + if n := len(p.Verifies()); n != 0 { + t.Errorf("spent the link on flags that cannot work: %d verify POSTs", n) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } + }) + } +} + +// No example of --totp - shows the code typed into the command, which would +// put it in the shell history the flag exists to keep it out of: not the +// help, not the hint the errors print, not the README. +func TestTOTPExamplesKeepTheCodeOffTheCommandLine(t *testing.T) { + readme, err := os.ReadFile(filepath.Join("..", "README.md")) + if err != nil { + t.Fatal(err) + } + for name, text := range map[string]string{ + "nan auth login --help": loginCmd.Example + "\n" + loginCmd.Long, + "the errors' hint": totpHint, + "README.md": string(readme), + } { + var examples int + for _, line := range strings.Split(text, "\n") { + if !strings.Contains(line, "--totp -") { + continue + } + examples++ + if strings.Contains(line, "echo ") || strings.Contains(line, "") || strings.Contains(line, authtest.Code) { + t.Errorf("%s puts the code on the command line: %q", name, strings.TrimSpace(line)) + } + } + if examples == 0 { + t.Errorf("%s has no --totp - example left to check", name) + } + } +} + +// The challenge cookie is a credential for ten minutes and is kept in memory +// only. Whatever the sign-in wrote under HOME - on success and on failure - +// does not contain it, and neither does anything it printed. +func TestTheChallengeCookieNeverReachesDiskOrOutput(t *testing.T) { + for _, c := range []struct { + name string + codes []string + }{ + {"signed in", []string{"000000", authtest.Code}}, + {"gave up", []string{"000000"}}, + } { + t.Run(c.name, func(t *testing.T) { + home := fakeHome(t, `{"token":"","apiKey":"sk-kept"}`) + mfaPlatform(t, true) + var out, prompt bytes.Buffer + codes, _ := typed(c.codes...) + err := finishSignIn(authtest.LinkToken, codes, &out, &prompt) + printed := out.String() + prompt.String() + if err != nil { + printed += err.Error() + } + if strings.Contains(printed, authtest.Pending) { + t.Errorf("printed the challenge cookie: %q", printed) + } + assertNoChallengeOnDisk(t, home) + }) + } +} + +func assertNoChallengeOnDisk(t *testing.T, home string) { + t.Helper() + err := filepath.WalkDir(home, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if bytes.Contains(data, []byte(authtest.Pending)) { + t.Errorf("%s holds the challenge cookie", path) + } + return nil + }) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + t.Fatal(err) + } +} diff --git a/cmd/automations.go b/cmd/automations.go index ec2f9ed..76ef844 100644 --- a/cmd/automations.go +++ b/cmd/automations.go @@ -251,7 +251,7 @@ func doAutomationsLs(ctx context.Context, env *runsEnv) error { fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", runs.SanitizeLine(a.ID), runs.Truncate(runs.SanitizeLine(a.Name), 40), - runs.Truncate(runs.SanitizeLine(a.Workspace.Label()), 40), + automationWhere(a), runs.SanitizeLine(a.Agent), automationStatus(a), runs.Truncate(approvalSummary(a.ApprovalKinds), 40)) @@ -259,6 +259,14 @@ func doAutomationsLs(ctx context.Context, env *runsEnv) error { return tw.Flush() } +// automationWhere is where an automation's runs execute, for a table. +func automationWhere(a api.Automation) string { + if a.Executor == api.ExecutorEphemeral { + return "ephemeral " + sizeShort(a.Size) + } + return runs.Truncate(runs.SanitizeLine(a.Workspace.Label()), 40) +} + func automationStatus(a api.Automation) string { if a.Enabled { return "enabled" @@ -313,18 +321,24 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error { if a.Workspace != nil { row("workspace", runs.NameAndID(a.Workspace.Name, a.Workspace.ID)) } + if a.Executor == api.ExecutorEphemeral { + row("executor", "ephemeral: a fresh container per run, size "+sizeLabel(a.Size)) + } agent := runs.SanitizeLine(a.Agent) if a.Model != nil && *a.Model != "" { 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 { - worktree = map[bool]string{true: "yes", false: "no"}[*a.GitIsolation] + if a.Executor != api.ExecutorEphemeral { + // An ephemeral run works in its fresh checkout: neither applies. + row("cwd", runs.SanitizeLine(a.Cwd)) + worktree := "auto" + if a.GitIsolation != nil { + worktree = map[bool]string{true: "yes", false: "no"}[*a.GitIsolation] + } + row("worktree", worktree) } - row("worktree", worktree) if a.TimeoutSeconds > 0 { row("timeout", (time.Duration(a.TimeoutSeconds) * time.Second).String()) } @@ -514,7 +528,7 @@ func doAutomationsRun(ctx context.Context, env *runsEnv, ref string, input json. if name == "" { name = runs.Truncate(runs.SanitizeLine(ref), 64) } - fmt.Fprintf(env.stderr, "run %s of %s queued on %s; Ctrl-C to detach\n", run.ID, name, runs.SanitizeLine(run.Workspace.Name)) + fmt.Fprintf(env.stderr, "run %s of %s queued %s, Ctrl-C to detach\n", run.ID, name, runPlace(run, &api.CreateRunRequest{})) } return follow(ctx, env, run.ID, 0, autoRunJSON, true) } diff --git a/cmd/automations_contexts_test.go b/cmd/automations_contexts_test.go new file mode 100644 index 0000000..959fe4d --- /dev/null +++ b/cmd/automations_contexts_test.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// Agent contexts on automations (devops AGENT-CONTEXT.md §2.5, §6, C2): +// nan automations create|edit --context NAME (repeatable, at most 5) puts +// "contexts" in the body, and --checkout none makes an ephemeral +// automation without a repository, which needs at least one context. + +func automationFile(t *testing.T, content string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "a.yaml") + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +// bodyGolden compares a sent JSON body, indented, with a golden file. +func bodyGolden(t *testing.T, name, body string) { + t.Helper() + var out bytes.Buffer + if err := json.Indent(&out, []byte(body), "", " "); err != nil { + t.Fatalf("%s: %v (%q)", name, err, body) + } + checkGolden(t, "contexts/"+name, out.String()+"\n") +} + +const wsAutomationYAML = "name: PR review api\nworkspace: develop\nagent: pi\ninstructions: Review it.\n" + +const noRepoAutomationYAML = "name: weekly notes\nexecutor: ephemeral\nsize: s\nagent: pi\nmodel: glm5.3\ninstructions: Write the notes.\n" + +func TestAutomationsCreateWithContexts(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", automationFile(t, wsAutomationYAML), + "--context", "acme-api", "--context", "style-guide") + if r.code != 0 { + t.Fatalf("exit %d: %s", r.code, r.msg) + } + posts := fake.sent("POST", "/v1/automations") + if len(posts) != 1 { + t.Fatalf("%v", fake.requests()) + } + bodyGolden(t, "automation_create_workspace.json", posts[0].Body) +} + +func TestAutomationsCreateWithoutRepository(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", automationFile(t, noRepoAutomationYAML), + "--checkout", "none", "--context", "acme-api") + if r.code != 0 { + t.Fatalf("exit %d: %s", r.code, r.msg) + } + posts := fake.sent("POST", "/v1/automations") + if len(posts) != 1 { + t.Fatalf("%v", fake.requests()) + } + bodyGolden(t, "automation_create_ephemeral_none.json", posts[0].Body) + + // The same, written in the file. + fake = newFakeWrite() + r = runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", + automationFile(t, noRepoAutomationYAML+"checkout: none\ncontexts: [acme-api]\n")) + if r.code != 0 || len(fake.sent("POST", "/v1/automations")) != 1 { + t.Fatalf("from the file: exit %d: %s", r.code, r.msg) + } +} + +// Without flags, the file still goes out exactly as written. +func TestAutomationsCreateWithoutContextFlagsIsUntouched(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession, stdin: `{"name":"x","approval_kinds":["github_push"]}`}, "automations", "create", "-f", "-") + if r.code != 0 || fake.sent("POST", "/v1/automations")[0].Body != `{"name":"x","approval_kinds":["github_push"]}` { + t.Fatalf("exit %d %s %v", r.code, r.msg, fake.requests()) + } +} + +func TestAutomationsEditContextsWithoutAFile(t *testing.T) { + path := "/v1/automations/" + testAutoID + for name, tc := range map[string]struct { + args []string + body string + golden string + }{ + "contexts only": {[]string{"--context", "acme-api", "--context", "style-guide"}, `{"contexts":["acme-api","style-guide"]}`, ""}, + "checkout none": {[]string{"--checkout", "none", "--context", "acme-api"}, "", "automation_edit_checkout_none.json"}, + "with a file": {[]string{"-f", automationFile(t, "size: l\n"), "--context", "acme-api"}, `{"contexts":["acme-api"],"size":"l"}`, ""}, + } { + t.Run(name, func(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, append([]string{"automations", "edit", testAutoID}, tc.args...)...) + if r.code != 0 { + t.Fatalf("exit %d: %s", r.code, r.msg) + } + got := fake.sent("PATCH", path) + if len(got) != 1 { + t.Fatalf("%v", fake.requests()) + } + if tc.golden != "" { + bodyGolden(t, tc.golden, got[0].Body) + } else if got[0].Body != tc.body { + t.Errorf("body %s, want %s", got[0].Body, tc.body) + } + }) + } +} + +func TestAutomationsContextMistakesAreUsageErrors(t *testing.T) { + six := []string{} + for _, n := range []string{"a1", "a2", "a3", "a4", "a5", "a6"} { + six = append(six, "--context", n) + } + ws := automationFile(t, wsAutomationYAML) + noRepo := automationFile(t, noRepoAutomationYAML) + withRepo := automationFile(t, noRepoAutomationYAML+"github_repo: acme/api\n") + both := automationFile(t, wsAutomationYAML+"contexts: [acme-api]\n") + bothCheckout := automationFile(t, noRepoAutomationYAML+"checkout: fresh\n") + fileSix := automationFile(t, wsAutomationYAML+"contexts: [a1, a2, a3, a4, a5, a6]\n") + fileBad := automationFile(t, wsAutomationYAML+"contexts: [Acme API]\n") + fileNotList := automationFile(t, wsAutomationYAML+"contexts: acme-api\n") + for _, tc := range []struct { + name string + args []string + wants []string + }{ + {"six on create", append([]string{"automations", "create", "-f", ws}, six...), []string{"at most 5"}}, + {"six on edit", append([]string{"automations", "edit", testAutoID}, six...), []string{"at most 5"}}, + {"repeated", []string{"automations", "create", "-f", ws, "--context", "acme-api", "--context", "acme-api"}, []string{"twice"}}, + {"not a name", []string{"automations", "create", "-f", ws, "--context", "Acme API"}, []string{"context name"}}, + {"in the file and as a flag", []string{"automations", "create", "-f", both, "--context", "b"}, []string{"contexts", "--context", "once"}}, + {"checkout in the file and as a flag", []string{"automations", "create", "-f", bothCheckout, "--checkout", "none", "--context", "a"}, + []string{"checkout", "--checkout", "once"}}, + {"none without a context", []string{"automations", "create", "-f", noRepo, "--checkout", "none"}, []string{`checkout "none"`, "--context"}}, + {"none with a repository", []string{"automations", "create", "-f", withRepo, "--checkout", "none", "--context", "a"}, + []string{`checkout "none"`, "github_repo"}}, + {"none on a workspace automation", []string{"automations", "create", "-f", ws, "--checkout", "none", "--context", "a"}, + []string{"ephemeral"}}, + {"unknown checkout", []string{"automations", "create", "-f", ws, "--checkout", "clone"}, []string{"--checkout", "none"}}, + {"ephemeral with neither", []string{"automations", "create", "-f", noRepo}, []string{"github_repo", "checkout: none"}}, + {"edit with nothing", []string{"automations", "edit", testAutoID}, []string{"-f FILE"}}, + {"six in the file", []string{"automations", "create", "-f", fileSix}, []string{"at most 5"}}, + {"bad name in the file", []string{"automations", "create", "-f", fileBad}, []string{"context name"}}, + {"contexts not a list", []string{"automations", "create", "-f", fileNotList}, []string{"contexts", "list"}}, + {"none edit with a repository", []string{"automations", "edit", testAutoID, "-f", automationFile(t, "github_repo: acme/api\n"), "--checkout", "none"}, + []string{`checkout "none"`, "github_repo"}}, + } { + t.Run(tc.name, func(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, tc.args...) + if r.code != exitUsage { + t.Fatalf("exit %d: %s", r.code, r.msg) + } + wantAll(t, tc.name, r.msg, tc.wants...) + if len(fake.reqs) != 0 { + t.Errorf("sent %d requests", len(fake.reqs)) + } + }) + } +} + +func TestAutomationsContextRefusals(t *testing.T) { + notFound := `{"error":{"message":"context not found \u001b[2J","type":"invalid_request_error","param":"contexts[1]","code":"context_not_found"}}` + ws := automationFile(t, wsAutomationYAML) + + fake := newFakeWrite() + fake.failOn("POST /v1/automations", 404, notFound) + r := runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", ws, "--context", "acme-api", "--context", "style-guide") + if r.code != exitUsage || !strings.Contains(r.msg, `context "style-guide" not found`) || !strings.Contains(r.msg, "nan context ls") { + t.Errorf("create: exit %d: %s", r.code, r.msg) + } + assertNoControls(t, "create", r.msg) + + // On edit a 404 about a context is not "automation not found". + fake = newFakeWrite() + fake.failOn("PATCH /v1/automations/"+testAutoID, 404, notFound) + r = runWrite(t, fake, writeSetup{session: cliSession}, "automations", "edit", testAutoID, "--context", "acme-api", "--context", "style-guide") + if r.code != exitUsage || !strings.Contains(r.msg, `context "style-guide" not found`) || strings.Contains(r.msg, "automation") { + t.Errorf("edit: exit %d: %s", r.code, r.msg) + } + + // A context listed in the file is named too. + fake = newFakeWrite() + fake.failOn("POST /v1/automations", 404, strings.Replace(notFound, "contexts[1]", "contexts[0]", 1)) + r = runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", automationFile(t, wsAutomationYAML+"contexts: [from-file]\n")) + if r.code != exitUsage || !strings.Contains(r.msg, `context "from-file" not found`) { + t.Errorf("file: exit %d: %s", r.code, r.msg) + } +} + +func TestAutomationsHelpExplainsContexts(t *testing.T) { + for _, c := range []struct { + name string + long string + }{{"create", automationsCreateCmd.Long}, {"edit", automationsEditCmd.Long}} { + wantAll(t, c.name, c.long, "--context NAME", "at most 5", "--checkout none") + for _, f := range []string{"context", "checkout"} { + if (map[string]bool{"create": automationsCreateCmd.Flags().Lookup(f) != nil, "edit": automationsEditCmd.Flags().Lookup(f) != nil})[c.name] == false { + t.Errorf("%s has no --%s", c.name, f) + } + } + } +} diff --git a/cmd/automations_write.go b/cmd/automations_write.go index ec971b7..59cf791 100644 --- a/cmd/automations_write.go +++ b/cmd/automations_write.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "text/tabwriter" @@ -29,6 +30,10 @@ var ( trigRotateHard bool trigRotateWhsecFile string trigRotateWhsecStdin bool + // --context (repeatable) and --checkout on create and edit, merged + // into the file's body. + autoWriteContexts []string + autoWriteCheckout string ) const automationFileHelp = `The file is YAML or JSON (- reads it from stdin) with the fields of the @@ -47,8 +52,36 @@ API's automation object, for example: exposure: actions approval_kinds: [github_pr_review] +An ephemeral automation runs in a fresh, throwaway container instead of a +workspace: no workspace, a fresh checkout of its repository, and a model. + + name: nightly review + executor: ephemeral + size: m + checkout: fresh + github_repo: acme/api + agent: pi + model: glm5.3 + instructions: Review the open pull requests. + +size is s (1 vCPU, 4 GiB), m (2 vCPU, 8 GiB) or l (4 vCPU, 12 GiB); +s when left out. A private repository needs an agent secret GITHUB_TOKEN +that can read it. ` + freeMinutesHelp + `. +Creating or running one with a platform token needs the runs:ephemeral +scope; see: nan ephemeral --help. + The full list of fields is in the API reference: https://nan.builders/docs/runs` +const automationContextsHelp = `Agent contexts: + --context NAME (repeat it, at most 5) sets the agent contexts every run + of the automation loads, in that order, at their latest version; it is + the file's contexts: [NAME, ...] field, so give them in one place only. + NAME is a context name or id from: nan context ls. + --checkout none makes an ephemeral automation without a repository: no + github_repo, and its runs work in an empty folder with their contexts, + so it needs at least one --context. It is the file's checkout: none. + Docs: ` + contextsDocs + var automationsCreateCmd = &cobra.Command{ Use: "create -f FILE", Short: "Create an automation from a YAML or JSON file", @@ -60,11 +93,15 @@ An automation receives the secrets you assign it, and approval_kinds: [] lets every action it proposes apply without asking you. Then add a trigger with nan automations triggers add. +` + automationContextsHelp + ` + ` + writeCredentialHelp + ` Docs: ` + writeDocs, Example: ` nan automations create -f pr-review.yaml - nan automations create -f - < pr-review.json`, + nan automations create -f - < pr-review.json + nan automations create -f review.yaml --context acme-api --context style-guide + nan automations create -f notes.yaml --checkout none --context release-notes`, Args: cobra.NoArgs, Annotations: sysexits, RunE: func(cmd *cobra.Command, args []string) error { @@ -72,6 +109,9 @@ Docs: ` + writeDocs, if err != nil { return err } + if body, err = applyAutomationFlags(cmd, body, true); err != nil { + return err + } return withWriteEnv(func(env *runsEnv) error { return doAutomationsCreate(cmd.Context(), env, body) }) }, } @@ -85,13 +125,34 @@ before pull request reviews are posted: approval_kinds: [] +or, for an ephemeral automation, to run it in a bigger container: + + size: l + +With --context or --checkout, -f can be left out: the flags are the +change. --context replaces the automation's contexts with the ones given +(contexts: [] in a file removes them all); --checkout none also sets +github_repo to null unless the file says otherwise. + +` + automationContextsHelp + ` + ` + writeCredentialHelp + ` Docs: ` + writeDocs, + Example: ` nan automations edit "PR review api" -f patch.yaml + nan automations edit "PR review api" --context acme-api --context style-guide + nan automations edit "weekly notes" --checkout none --context release-notes`, Args: oneAutomation, Annotations: sysexits, RunE: func(cmd *cobra.Command, args []string) error { - body, err := readBodyFile(autoWriteFile) + body := json.RawMessage(`{}`) + if autoWriteFile != "" || !automationFlagsSet(cmd) { + var err error + if body, err = readBodyFile(autoWriteFile); err != nil { + return err + } + } + body, err := applyAutomationFlags(cmd, body, false) if err != nil { return err } @@ -297,6 +358,10 @@ func init() { triggersAddCmd, triggersEditCmd, triggersRotateCmd} { c.Flags().BoolVar(&autoWriteJSON, "json", false, "Print the API's answer as JSON") } + for _, c := range []*cobra.Command{automationsCreateCmd, automationsEditCmd} { + c.Flags().StringArrayVar(&autoWriteContexts, "context", nil, "An agent context its runs load, by `NAME` or id (repeat for more, at most 5; replaces the file's contexts)") + c.Flags().StringVar(&autoWriteCheckout, "checkout", "", "The file's checkout field: none for an ephemeral automation without a repository (needs --context)") + } automationsRmCmd.Flags().BoolVar(&autoRmYes, "yes", false, "Delete without asking") triggersRmCmd.Flags().BoolVar(&trigRmYes, "yes", false, "Delete without asking") triggersLsCmd.Flags().BoolVar(&trigLsJSON, "json", false, "Print the triggers as JSON") @@ -337,11 +402,17 @@ func resolveForWrite(ctx context.Context, env *runsEnv, ref string) (string, err } func doAutomationsCreate(ctx context.Context, env *runsEnv, body json.RawMessage) error { + if err := checkAutomationExecutor(body, true); err != nil { + return err + } if err := requireWriteAPI(ctx, env, "automations"); err != nil { return err } a, raw, err := env.client.CreateAutomation(ctx, body) if err != nil { + if exit := automationContextExit(err, body); exit != nil { + return exit + } return writeExit(err, env.cred, "automations", scopeAutomationsWrite) } if autoWriteJSON { @@ -363,12 +434,18 @@ func warnAutomatic(env *runsEnv, a *api.Automation) { } func doAutomationsUpdate(ctx context.Context, env *runsEnv, ref string, body json.RawMessage, verb string) error { + if err := checkAutomationExecutor(body, false); err != nil { + return err + } id, err := resolveForWrite(ctx, env, ref) if err != nil { return err } a, raw, err := env.client.UpdateAutomation(ctx, id, body) if err != nil { + if exit := automationContextExit(err, body); exit != nil { + return exit + } return automationWriteExit(err, ref, env.cred) } if autoWriteJSON { @@ -548,3 +625,73 @@ func triggerWriteExit(err error, ref, triggerID string, cred credential) *ExitEr } return writeExit(err, cred, "automations", scopeAutomationsWrite) } + +// automationFlagsSet reports whether --context or --checkout was given. +func automationFlagsSet(cmd *cobra.Command) bool { + return cmd.Flags().Changed("context") || cmd.Flags().Changed("checkout") +} + +// automationCheckouts are the checkout modes of an automation. +var automationCheckouts = []string{"workspace", "fresh", "none"} + +// applyAutomationFlags merges --context and --checkout into the body of +// create or edit. Without them the body goes out exactly as written. A +// field given both in the file and as a flag is a mistake: which one wins +// would be a guess. +func applyAutomationFlags(cmd *cobra.Command, body json.RawMessage, create bool) (json.RawMessage, error) { + if !automationFlagsSet(cmd) { + return body, nil + } + var m map[string]json.RawMessage + if err := json.Unmarshal(body, &m); err != nil || m == nil { + return nil, usageErrorf("the file must hold one object (the automation's fields)") + } + if cmd.Flags().Changed("context") { + if _, ok := m["contexts"]; ok { + return nil, usageErrorf("contexts is in the file and given with --context: give them once, in one place") + } + if err := checkContextRefs(autoWriteContexts, "--context"); err != nil { + return nil, err + } + m["contexts"], _ = json.Marshal(autoWriteContexts) + } + if cmd.Flags().Changed("checkout") { + if !slices.Contains(automationCheckouts, autoWriteCheckout) { + return nil, usageErrorf("--checkout must be workspace, fresh or none (none: an ephemeral automation without a repository), not %q", + runs.Truncate(runs.SanitizeLine(autoWriteCheckout), 32)) + } + if _, ok := m["checkout"]; ok { + return nil, usageErrorf("checkout is in the file and given with --checkout: give it once, in one place") + } + m["checkout"], _ = json.Marshal(autoWriteCheckout) + if _, ok := m["github_repo"]; !ok && !create && autoWriteCheckout == "none" { + // A repository-less automation has no repository: an edit + // that turns one into it drops the one it had. + m["github_repo"] = json.RawMessage("null") + } + } + out, err := json.Marshal(m) + if err != nil { + return nil, usageErrorf("could not build the request: %v", err) + } + return out, nil +} + +// bodyContexts are the contexts a create or edit body names (nil when none +// or not a list of strings). +func bodyContexts(body json.RawMessage) []string { + var f struct { + Contexts []string `json:"contexts"` + } + _ = json.Unmarshal(body, &f) + return f.Contexts +} + +// automationContextExit reads a refusal of the contexts an automation names. +func automationContextExit(err error, body json.RawMessage) *ExitError { + var apiErr *api.APIError + if !errors.As(err, &apiErr) { + return nil + } + return contextRefusal(apiErr, bodyContexts(body), "--context") +} diff --git a/cmd/context.go b/cmd/context.go new file mode 100644 index 0000000..8f2d559 --- /dev/null +++ b/cmd/context.go @@ -0,0 +1,1656 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "sync" + "text/tabwriter" + "time" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/contexts" + "github.com/nxssie/nan-cli/internal/runs" + "github.com/spf13/cobra" + "golang.org/x/text/unicode/norm" +) + +// Agent contexts (helmcode/nan-devops AGENT-CONTEXT.md §6): a versioned file +// tree a member keeps on NaN and hands to their runs and automations: +// +// AGENTS.md instructions +// skills//... skills (SKILL.md and its files) +// files/... reference files +// +// push hashes a local folder, uploads only the blobs the platform lacks +// (one PUT up to 16 MiB, a resumable multipart upload above) and publishes +// the tree as a new version based on the one it read. pull writes a version +// into an empty folder, through os.OpenRoot and only after every path passed +// the same validator the platform uses (internal/contexts, a verbatim copy). + +const ( + contextsDocs = "https://nan.builders/docs/contexts" + + scopeContextsRead = "contexts:read" + scopeContextsWrite = "contexts:write" + + // exitDataErr is sysexits' EX_DATAERR: the content was refused (a file + // name, a type, a limit, secret-like content). The same number as + // exitAuth, which is EX_NOPERM's neighbour in this CLI's table. + exitDataErr = 65 + + // Limits of one version (AGENT-CONTEXT.md §1.4), checked before any + // upload so a push that cannot be published uploads nothing. + ctxMaxFiles = 1000 + ctxMaxSkills = 30 + ctxMaxTotalBytes = 250 << 20 + ctxMaxAgentsMD = 32 << 10 + ctxMaxSkillMD = 64 << 10 + + // ctxMaxWait is the longest Retry-After a push waits out by itself. + ctxMaxWait = 2 * time.Minute + // ctxRetries is how many times one request is retried after a + // Retry-After, a part after a failure, or a publish after blob_missing + // or (with --force) version_conflict. + ctxRetries = 3 +) + +// Test seams. +var ( + // ctxSleep waits out a Retry-After. + ctxSleep = func(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } + } + // ctxUploadStateDir is where an unfinished multipart upload is + // remembered, so the next push resumes it instead of starting over. + ctxUploadStateDir = func() (string, error) { + d, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(d, "nan", "context-uploads"), nil + } + // ctxConcurrency is how many blobs a push uploads at once. + ctxConcurrency = 4 + // ctxSingleMax is the largest blob sent as one PUT. + ctxSingleMax int64 = api.ContextPartSize + // ctxExecKnown is whether the file system carries an executable bit. + ctxExecKnown = runtime.GOOS != "windows" +) + +var ( + ctxJSON bool + + ctxCreateDescription string + ctxCreateFrom string + + ctxPushCreate bool + ctxPushForce bool + ctxPushAllowSecrets bool + ctxPushDryRun bool + ctxPushSkipInvalid bool + ctxPushAllowEmpty bool + + ctxPullForce bool + + ctxEditName string + ctxEditDescription string + + ctxRmYes bool +) + +var ctxVerbHints = map[string]string{ + "list": "ls", "get": "show", "new": "create", "add": "create", "upload": "push", "publish": "push", + "download": "pull", "clone": "pull", "update": "edit", "rename": "edit NAME --name NEW", "delete": "rm", "remove": "rm", +} + +const ctxCredentialHelp = `Authentication, first match wins: --token-file, your session from +nan auth login, NAN_TOKEN, a platform token saved with nan auth login +--api-token. A platform token needs contexts:read to list, show and pull, +and contexts:write to create, push, edit and delete. API keys (sk-) are +not used by these commands, and workspace keys never read or write +contexts.` + +var contextCmd = &cobra.Command{ + Use: "context", + Aliases: []string{"contexts"}, + Short: "Keep instructions, skills and files for your agent runs", + Long: `Manage your agent contexts: versioned folders of instructions, skills and +reference files that your runs and automations hand to the agent. + +A context has this layout: + AGENTS.md instructions for the agent (at most 32 KiB) + skills// a skill: SKILL.md (name and description in its + frontmatter) plus any files it uses + files/... reference files: docs, specs, CSV, PDF, images, + Office documents + +File names may use letters of any language, digits, spaces and +. _ - ( ) + , @. Dotfiles (.env, .git) are skipped; names with emoji, +symbols, control characters or Windows reserved names (CON, NUL, ...) +are refused, each with its reason. + +Every push publishes a new version; runs pin the version they start +with, automations follow the latest. Do not put credentials in a context: +keep them in nan secrets. A push that looks like it carries one is +refused unless you add --allow-secrets. + +Exit codes: 0 done, 1 failed (the context changed while pushing), 64 +usage, 65 content refused (file names, types, limits, secret-like +content, storage quota) or credential refused, 69 nan.builders +unavailable. + +` + ctxCredentialHelp + ` + +Docs: ` + contextsDocs, + Annotations: sysexits, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + shown := runs.Truncate(runs.SanitizeLine(args[0]), 64) + if hint, ok := ctxVerbHints[args[0]]; ok { + return usageErrorf("there is no nan context %s; try: nan context %s", shown, hint) + } + return usageErrorf("unknown command %q for \"nan context\"; try: nan context --help", shown) + } + return cmd.Help() + }, +} + +var ctxLsCmd = &cobra.Command{ + Use: "ls", + Short: "List your contexts and the storage they use", + Args: cobra.NoArgs, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doContextLs(cmd.Context(), env) }) + }, +} + +var ctxShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show a context and the files of its current version", + Args: cobra.ExactArgs(1), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doContextShow(cmd.Context(), env, args[0]) }) + }, +} + +var ctxCreateCmd = &cobra.Command{ + Use: "create NAME [--description TEXT] [--from DIR]", + Short: "Create a context, empty or from a folder", + Long: `Create a context. NAME is lowercase letters, digits and dashes (at most +63). With --from DIR, the folder is pushed as its first content (see nan +context push --help for what a push checks). + +` + ctxCredentialHelp + ` + +Docs: ` + contextsDocs, + Example: ` nan context create acme-api --description "Acme API conventions" + nan context create acme-api --from ./acme-context`, + Args: cobra.ExactArgs(1), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doContextCreate(cmd.Context(), env, args[0]) }) + }, +} + +var ctxPushCmd = &cobra.Command{ + Use: "push DIR", + Short: "Publish a folder as the context's next version", + Long: `Publish DIR as the next version of a context: the folder becomes the +context's whole content (files no longer in DIR are removed from the new +version; older versions are kept while a run uses them). + +Before anything is uploaded, push checks the layout and every file name: +dotfiles and dot-folders (.git, .env) are skipped with a notice; every +other refused file is printed with its reason (bad_top_level, +bad_character, case_collision, reserved_name, too_long, too_deep, +too_large, skill_invalid, symlink) and push exits 65 without uploading, +unless --skip-invalid leaves those files out. A CLAUDE.md is refused with +a hint to rename it to AGENTS.md. Names are normalised to NFC (macOS +hands out decomposed names). + +Then push hashes every file, asks the platform which ones it does not +already hold, uploads only those (16 MiB in one request, larger files in +16 MiB parts; an interrupted upload resumes on the next push) and +publishes the new version. --dry-run prints what would be added, changed +and removed, and how much would be uploaded, and changes nothing. + +If the context changed since push read it (another push, the portal), +push stops with exit 1. --force shows what changed and publishes on top +of it instead. Text that looks like a credential (API keys, tokens, +private keys) is refused with the file and line; keep credentials in nan +secrets, or publish anyway with --allow-secrets (the agent can read and +copy anything in a context). + +With --create, a context that does not exist yet is created first. A DIR +that is a symbolic link is followed once (links inside it are refused). A +folder with no files is refused over a version that has some, since it +would remove them all; --allow-empty publishes it anyway. + +` + ctxCredentialHelp + ` + +Docs: ` + contextsDocs, + Example: ` nan context push acme-api ./acme-context --dry-run + nan context push acme-api ./acme-context + nan context push acme-api ./acme-context --create --skip-invalid`, + Args: cobra.ExactArgs(2), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { + return doContextPush(cmd.Context(), env, args[0], args[1], pushOptionsFromFlags()) + }) + }, +} + +var ctxPullCmd = &cobra.Command{ + Use: "pull DIR", + Short: "Download a context's current version into an empty folder", + Long: `Write the current version of a context into DIR, which must be empty or +not exist yet (--force writes into a folder that has files, replacing +the ones the context has). + +Every path the platform sends is checked with the same rules a push +uses, and files are written through a handle rooted at DIR: a path +that is absolute, climbs out with .., differs from another only by case, +or is a Windows reserved name is refused before anything is written. +Every file is checked against its sha256 and size. + +` + ctxCredentialHelp + ` + +Docs: ` + contextsDocs, + Example: ` nan context pull acme-api ./acme-context`, + Args: cobra.ExactArgs(2), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doContextPull(cmd.Context(), env, args[0], args[1]) }) + }, +} + +var ctxEditCmd = &cobra.Command{ + Use: "edit [--name NEW] [--description TEXT]", + Short: "Rename a context or change its description (no new version)", + Args: cobra.ExactArgs(1), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("description") { + return usageErrorf("nothing to change: give --name or --description") + } + var patch api.ContextPatch + if cmd.Flags().Changed("name") { + if !contexts.ValidContextName(ctxEditName) { + return usageErrorf("%s", contextNameRule(ctxEditName)) + } + patch.Name = &ctxEditName + } + if cmd.Flags().Changed("description") { + patch.Description = &ctxEditDescription + } + return withWriteEnv(func(env *runsEnv) error { return doContextEdit(cmd.Context(), env, args[0], patch) }) + }, +} + +var ctxRmCmd = &cobra.Command{ + Use: "rm [--yes]", + Short: "Delete a context and all its versions", + Args: cobra.ExactArgs(1), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doContextRm(cmd.Context(), env, args[0]) }) + }, +} + +func init() { + rootCmd.AddCommand(contextCmd) + contextCmd.AddCommand(ctxLsCmd, ctxShowCmd, ctxCreateCmd, ctxPushCmd, ctxPullCmd, ctxEditCmd, ctxRmCmd) + contextCmd.PersistentFlags().StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage) + + for _, c := range []*cobra.Command{ctxLsCmd, ctxShowCmd, ctxCreateCmd, ctxPushCmd, ctxEditCmd} { + c.Flags().BoolVar(&ctxJSON, "json", false, "Print the answer as JSON") + } + ctxCreateCmd.Flags().StringVar(&ctxCreateDescription, "description", "", "What the context is for") + ctxCreateCmd.Flags().StringVar(&ctxCreateFrom, "from", "", "Push this folder as the first content (`DIR`)") + for _, c := range []*cobra.Command{ctxCreateCmd, ctxPushCmd} { + c.Flags().BoolVar(&ctxPushAllowSecrets, "allow-secrets", false, "Publish even if a text file looks like it holds a credential") + c.Flags().BoolVar(&ctxPushSkipInvalid, "skip-invalid", false, "Leave refused files out instead of stopping") + } + ctxPushCmd.Flags().BoolVar(&ctxPushAllowEmpty, "allow-empty", false, "Publish an empty folder over a version that has files (removes them all)") + f := ctxPushCmd.Flags() + f.BoolVar(&ctxPushCreate, "create", false, "Create the context if it does not exist") + f.BoolVar(&ctxPushForce, "force", false, "Publish on top of a version someone else published meanwhile") + f.BoolVar(&ctxPushDryRun, "dry-run", false, "Print what would change and be uploaded; change nothing") + ctxPullCmd.Flags().BoolVar(&ctxPullForce, "force", false, "Write into a folder that is not empty") + ctxEditCmd.Flags().StringVar(&ctxEditName, "name", "", "The new name") + ctxEditCmd.Flags().StringVar(&ctxEditDescription, "description", "", "The new description") + ctxRmCmd.Flags().BoolVar(&ctxRmYes, "yes", false, "Do not ask for confirmation") +} + +type pushOptions struct { + create, force, allowSecrets, dryRun, skipInvalid, allowEmpty, json bool +} + +func pushOptionsFromFlags() pushOptions { + return pushOptions{create: ctxPushCreate, force: ctxPushForce, allowSecrets: ctxPushAllowSecrets, dryRun: ctxPushDryRun, + skipInvalid: ctxPushSkipInvalid, allowEmpty: ctxPushAllowEmpty, json: ctxJSON} +} + +var uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// checkContextRef refuses a NAME|id that is neither a context name nor an +// id before it becomes part of a request path. +func checkContextRef(ref string) error { + if contexts.ValidContextName(ref) || uuidPattern.MatchString(ref) { + return nil + } + return usageErrorf("%q is neither a context name (lowercase letters, digits and dashes) nor a context id (see: nan context ls)", + runs.Truncate(runs.SanitizeLine(ref), 80)) +} + +func contextNameRule(name string) string { + return fmt.Sprintf("%q is not a valid context name: use lowercase letters, digits and dashes, starting with a letter or digit, at most 63 characters (not a Windows reserved name like con or nul)", + runs.Truncate(runs.SanitizeLine(name), 80)) +} + +// ─── errors ──────────────────────────────────────────────────────────────── + +// ctxExit maps a refused contexts request onto an exit code and a message. +func ctxExit(err error, env *runsEnv, scope string) *ExitError { + var exit *ExitError + if errors.As(err, &exit) { + return exit + } + ce, ok := api.AsContextError(err) + if !ok { + return writeExit(err, env.cred, "contexts", scope) + } + msg := runs.SanitizeLine(ce.Message) + switch ce.Code { + case "not_found", "": + if ce.Status == 404 { + return apiOff("using agent contexts") + } + case "context_not_found": + return usageErrorf("no such context (see: nan context ls)") + case "context_name_taken": + return usageErrorf("you already have a context with that name") + case "too_many_contexts": + return exitf(exitDataErr, "%s", msg) + case "context_in_use": + return usageErrorf("automations use this context; remove it from them first (nan automations edit):\n%s", + joinNames(refLabels(ce.Automations))) + case "context_quota_exceeded": + return exitf(exitDataErr, "%s (%s of %s used). Delete contexts or versions you no longer need, or upgrade: %s/membership", + msg, humanBytes(ce.Used), humanBytes(ce.Quota), runs.PortalURL) + case "context_too_large", "unsupported_file_type", "invalid_path", "skill_invalid", "context_secret_detected": + return exitf(exitDataErr, "%s", msg) + case "storage_unavailable", "upload_in_progress", "blob_busy", "part_conflict", "upload_expired": + return exitf(exitUnavailable, "%s", msg) + case "rate_limited": + if ce.RetryAfter > 0 { + return exitf(exitUnavailable, "%s (try again in %s)", msg, ce.RetryAfter.Round(time.Second)) + } + return exitf(exitUnavailable, "%s", msg) + case "blob_hash_mismatch": + return exitf(exitUnavailable, "a file changed while it was being uploaded; push again (%s)", msg) + case "entitlement_required", "tier_restricted": + return exitf(exitAuth, "your membership does not include agent runs, which contexts are for: see %s/membership", runs.PortalURL) + } + return writeExit(ce.APIError, env.cred, "contexts", scope) +} + +// ctxRetry runs fn again after a Retry-After the platform asked for (rate +// limits, an upload of the same blob in progress, storage briefly away), at +// most ctxRetries times and never longer than ctxMaxWait each. +func ctxRetry(ctx context.Context, env *runsEnv, what string, fn func() error) error { + for attempt := 0; ; attempt++ { + err := fn() + ce, ok := api.AsContextError(err) + if !ok || attempt >= ctxRetries || !retryable(ce) { + return err + } + wait := ce.RetryAfter + if wait <= 0 { + wait = 5 * time.Second + } + if wait > ctxMaxWait { + return err + } + fmt.Fprintf(env.stderr, "%s: %s; retrying in %s\n", what, runs.SanitizeLine(ce.Message), wait.Round(time.Second)) + if serr := ctxSleep(ctx, wait); serr != nil { + return err + } + } +} + +func retryable(ce *api.ContextError) bool { + switch ce.Code { + case "rate_limited", "upload_in_progress", "blob_busy", "storage_unavailable": + return true + } + return ce.Status == 429 +} + +func ctxCode(err error) string { + if ce, ok := api.AsContextError(err); ok { + return ce.Code + } + return "" +} + +// ─── ls, show ────────────────────────────────────────────────────────────── + +func doContextLs(ctx context.Context, env *runsEnv) error { + list, err := env.client.ListContexts(ctx) + if err != nil { + return ctxExit(err, env, scopeContextsRead) + } + if ctxJSON { + return printJSON(env.stdout, list, true) + } + if len(list.Data) == 0 { + fmt.Fprintf(env.stderr, "no contexts yet; create one with: nan context create NAME --from DIR\n") + } else { + tw := tabwriter.NewWriter(env.stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tVERSION\tFILES\tSIZE\tUPDATED\tDESCRIPTION") + for _, c := range list.Data { + fmt.Fprintf(tw, "%s\t%d\t%d\t%s\t%s\t%s\n", runs.SanitizeLine(c.Name), c.Version, c.FileCount, humanBytes(c.TotalBytes), + localTime(c.UpdatedAt), runs.Truncate(runs.SanitizeLine(c.Description), 48)) + } + _ = tw.Flush() + } + fmt.Fprintf(env.stdout, "storage: %s of %s used\n", humanBytes(list.Usage.StoredBytes), humanBytes(list.Usage.QuotaBytes)) + return nil +} + +func doContextShow(ctx context.Context, env *runsEnv, ref string) error { + if err := checkContextRef(ref); err != nil { + return err + } + d, err := env.client.GetContext(ctx, ref) + if err != nil { + return ctxExit(err, env, scopeContextsRead) + } + if ctxJSON { + return printJSON(env.stdout, d, true) + } + tw := tabwriter.NewWriter(env.stdout, 0, 4, 2, ' ', 0) + row := func(k, v string) { fmt.Fprintf(tw, "%s\t%s\n", k, v) } + row("name", runs.SanitizeLine(d.Name)) + row("id", runs.SanitizeLine(d.ID)) + if d.Description != "" { + row("description", runs.SanitizeLine(d.Description)) + } + row("version", fmt.Sprintf("%d (%s)", d.Version, localTime(d.UpdatedAt))) + row("files", fmt.Sprintf("%d, %s", d.FileCount, humanBytes(d.TotalBytes))) + row("instructions", yesNo(d.HasInstructions)) + skills := make([]string, len(d.Skills)) + for i, s := range d.Skills { + skills[i] = runs.SanitizeLine(s) + } + if len(skills) == 0 { + row("skills", "none") + } else { + row("skills", strings.Join(skills, ", ")) + } + _ = tw.Flush() + if len(d.Files) > 0 { + fmt.Fprintln(env.stdout) + tw = tabwriter.NewWriter(env.stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "PATH\tSIZE\tSHA256") + files := append([]api.ContextFile(nil), d.Files...) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + for _, f := range files { + p := runs.SanitizeLine(f.Path) + if f.Executable { + p += " (executable)" + } + fmt.Fprintf(tw, "%s\t%s\t%s\n", p, humanBytes(f.Size), runs.Truncate(runs.SanitizeLine(f.SHA256), 12)) + } + _ = tw.Flush() + } + return nil +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} + +// humanBytes is a size as people read it (1024-based, one decimal). +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit && exp < 3; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGT"[exp]) +} + +// ─── create, edit, rm ────────────────────────────────────────────────────── + +func doContextCreate(ctx context.Context, env *runsEnv, name string) error { + if !contexts.ValidContextName(name) { + return usageErrorf("%s", contextNameRule(name)) + } + opts := pushOptions{allowSecrets: ctxPushAllowSecrets, skipInvalid: ctxPushSkipInvalid, allowEmpty: ctxPushAllowEmpty, json: ctxJSON} + var local *localTree + if ctxCreateFrom != "" { + // The folder is checked before the context exists, so a folder that + // cannot be pushed does not leave an empty context behind. + var err error + if local, err = scanLocal(env, ctxCreateFrom, opts.skipInvalid); err != nil { + return err + } + defer local.close() + } + idem := newIdempotencyKey() + var c *api.Context + err := ctxRetry(ctx, env, "create", func() error { + var err error + c, err = env.client.CreateContext(ctx, name, ctxCreateDescription, idem) + return err + }) + if err != nil { + return ctxExit(err, env, scopeContextsWrite) + } + if local == nil { + if ctxJSON { + return printJSON(env.stdout, c, true) + } + fmt.Fprintf(env.stdout, "context %s created (empty, version %d)\n", runs.SanitizeLine(c.Name), c.Version) + fmt.Fprintf(env.stderr, "add content with: nan context push %s DIR\n", runs.SanitizeLine(c.Name)) + return nil + } + fmt.Fprintf(env.stderr, "context %s created\n", runs.SanitizeLine(c.Name)) + return pushTree(ctx, env, &api.ContextDetail{Context: *c, Files: []api.ContextFile{}}, local, opts) +} + +func doContextEdit(ctx context.Context, env *runsEnv, ref string, patch api.ContextPatch) error { + if err := checkContextRef(ref); err != nil { + return err + } + d, err := env.client.GetContext(ctx, ref) + if err != nil { + return ctxExit(err, env, scopeContextsRead) + } + c, err := env.client.UpdateContext(ctx, d.ID, patch) + if err != nil { + return ctxExit(err, env, scopeContextsWrite) + } + if ctxJSON { + return printJSON(env.stdout, c, true) + } + fmt.Fprintf(env.stdout, "context %s updated\n", runs.SanitizeLine(c.Name)) + return nil +} + +func doContextRm(ctx context.Context, env *runsEnv, ref string) error { + if err := checkContextRef(ref); err != nil { + return err + } + d, err := env.client.GetContext(ctx, ref) + if err != nil { + return ctxExit(err, env, scopeContextsRead) + } + name := runs.SanitizeLine(d.Name) + if err := confirm(env, ctxRmYes, "delete context "+name+" and all its versions?"); err != nil { + return err + } + if err := env.client.DeleteContext(ctx, d.ID); err != nil { + return ctxExit(err, env, scopeContextsWrite) + } + fmt.Fprintf(env.stdout, "context %s deleted\n", name) + return nil +} + +// ─── the local tree ──────────────────────────────────────────────────────── + +// localFile is one file of the folder being pushed. +type localFile struct { + path string // NFC, '/'-separated, relative to the folder + rel string // the name on disk, relative to root + root *os.Root + size int64 + exec bool + // execKnown is false where the file system has no executable bit + // (Windows): the bit then comes from the version being replaced. + execKnown bool + sha string +} + +// open reads the file through the folder's root: a name swapped for a +// symlink after the scan is refused instead of followed. +func (f localFile) open() (*os.File, error) { + if st, err := f.root.Lstat(f.rel); err != nil { + return nil, err + } else if !st.Mode().IsRegular() { + return nil, fmt.Errorf("it is no longer a regular file") + } + return f.root.Open(f.rel) +} + +// refusedFile is one file push will not publish, with its reason. +type refusedFile struct { + path, reason, detail string +} + +type localTree struct { + files []localFile + root *os.Root +} + +func (t *localTree) close() { + if t != nil && t.root != nil { + _ = t.root.Close() + } +} + +func (t *localTree) totalBytes() int64 { + var n int64 + for _, f := range t.files { + n += f.size + } + return n +} + +// scanLocal walks dir, checks every name and the layout, applies the +// per-file limits and hashes what is left. Refused files stop the push +// (exit 65) unless skipInvalid; dotfiles are always skipped with a notice. +func scanLocal(env *runsEnv, dir string, skipInvalid bool) (*localTree, error) { + shown := runs.SanitizeLine(dir) + lst, err := os.Lstat(dir) + if err != nil { + return nil, usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + if lst.Mode()&fs.ModeSymlink != 0 { + // The folder the member named is behind a link (a dotfile manager, + // ~/work -> /Volumes/...): walk what it points at. WalkDir would + // otherwise see one non-directory and report an empty tree. + target, err := filepath.EvalSymlinks(dir) + if err != nil { + return nil, usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + dir = target + } + st, err := os.Stat(dir) + if err != nil { + return nil, usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + if !st.IsDir() { + return nil, usageErrorf("%s is not a folder", shown) + } + root, err := os.OpenRoot(dir) + if err != nil { + return nil, usageErrorf("cannot open %s: %v", shown, unwrapPath(err)) + } + tree, err := scanRoot(env, dir, root, skipInvalid) + if err != nil { + _ = root.Close() + return nil, err + } + return tree, nil +} + +func scanRoot(env *runsEnv, dir string, root *os.Root, skipInvalid bool) (*localTree, error) { + shown := runs.SanitizeLine(dir) + var ( + files []localFile + refused []refusedFile + dotted []string + ) + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if p == dir { + return nil + } + rel, rerr := filepath.Rel(dir, p) + if rerr != nil { + return rerr + } + slash := filepath.ToSlash(rel) + name := norm.NFC.String(slash) + if strings.HasPrefix(d.Name(), ".") { + dotted = append(dotted, name) + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + switch { + case d.Type()&fs.ModeSymlink != 0: + refused = append(refused, refusedFile{name, "symlink", "links are not kept; copy the file instead"}) + return nil + case d.IsDir(): + return nil + case !d.Type().IsRegular(): + refused = append(refused, refusedFile{name, "not_a_file", ""}) + return nil + } + info, ierr := d.Info() + if ierr != nil { + return ierr + } + files = append(files, localFile{path: name, rel: rel, root: root, size: info.Size(), + execKnown: ctxExecKnown, exec: ctxExecKnown && info.Mode().Perm()&0o111 != 0}) + return nil + }) + if err != nil { + return nil, usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + for _, p := range dotted { + fmt.Fprintf(env.stderr, "skipped %s (dotfiles are not kept in a context)\n", runs.SanitizeLine(p)) + } + + refused = append(refused, checkTree(files)...) + bad := map[string]bool{} + for _, r := range refused { + bad[r.path] = true + } + // A skill folder that lost its SKILL.md loses the whole skill. + for _, r := range refused { + if r.reason == "skill_invalid" && strings.HasSuffix(r.path, "/"+contexts.SkillFileName) { + prefix := strings.TrimSuffix(r.path, contexts.SkillFileName) + for _, f := range files { + if strings.HasPrefix(f.path, prefix) { + bad[f.path] = true + } + } + } + } + if len(refused) > 0 { + sort.SliceStable(refused, func(i, j int) bool { return refused[i].path < refused[j].path }) + verb := "refused" + if skipInvalid { + verb = "skipped" + } + for _, r := range refused { + line := fmt.Sprintf("%s %s: %s", verb, runs.SanitizeLine(r.path), r.reason) + if r.detail != "" { + line += " (" + r.detail + ")" + } + fmt.Fprintln(env.stderr, line) + } + if !skipInvalid { + return nil, exitf(exitDataErr, "%d %s cannot be published; nothing was uploaded. Rename or remove %s, or leave %s out with --skip-invalid", + len(refused), pluralWord(len(refused), "file", "files"), pluralWord(len(refused), "it", "them"), pluralWord(len(refused), "it", "them")) + } + } + kept := files[:0] + for _, f := range files { + if !bad[f.path] { + kept = append(kept, f) + } + } + tree := &localTree{files: kept, root: root} + if err := checkTreeLimits(tree); err != nil { + return nil, err + } + for i := range tree.files { + f := &tree.files[i] + sum, size, err := hashFile(*f) + if err != nil { + return nil, exitf(exitFailed, "cannot read %s: %v", runs.SanitizeLine(f.path), unwrapPath(err)) + } + f.sha, f.size = sum, size + } + sort.Slice(tree.files, func(i, j int) bool { return tree.files[i].path < tree.files[j].path }) + return tree, nil +} + +func pluralWord(n int, one, many string) string { + if n == 1 { + return one + } + return many +} + +// checkTree is the platform's path validator over the whole tree (names, +// layout, case collisions), plus the per-file limits a publish enforces. +func checkTree(files []localFile) []refusedFile { + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.path + } + var out []refusedFile + for _, pe := range contexts.ValidateTree(paths) { + r := refusedFile{path: pe.Path, reason: string(pe.Reason)} + top := strings.SplitN(pe.Path, "/", 2)[0] + switch { + case pe.Path == "CLAUDE.md": + r.detail = "rename it to AGENTS.md: that is the file the agents read" + case pe.Reason == contexts.ReasonBadTopLevel && !strings.Contains(pe.Path, "/"): + r.detail = "only AGENTS.md lives at the top; put reference files under files/" + case pe.Reason == contexts.ReasonBadTopLevel && top != "skills" && top != "files": + r.detail = "put reference files under files/ and skills under skills//" + case pe.Reason == contexts.ReasonBadCharacter && top == "skills" && strings.Count(pe.Path, "/") >= 2: + if !contexts.ValidSkillName(strings.SplitN(pe.Path, "/", 3)[1]) { + r.detail = "a skill folder name is lowercase letters, digits and dashes" + } + } + out = append(out, r) + } + skillDirs, skillFiles := map[string]bool{}, map[string]bool{} + for _, f := range files { + switch { + case f.size > api.ContextMaxFileBytes: + out = append(out, refusedFile{f.path, "too_large", "a file is at most 100 MiB"}) + case f.path == contexts.InstructionsPath && f.size > ctxMaxAgentsMD: + out = append(out, refusedFile{f.path, "too_large", "AGENTS.md is at most 32 KiB"}) + } + parts := strings.SplitN(f.path, "/", 3) + if len(parts) == 3 && parts[0] == "skills" && contexts.ValidSkillName(parts[1]) { + skillDirs[parts[1]] = true + if parts[2] == contexts.SkillFileName { + skillFiles[parts[1]] = true + if f.size > ctxMaxSkillMD { + out = append(out, refusedFile{f.path, "skill_invalid", "SKILL.md is at most 64 KiB"}) + } + } + } + } + for dir := range skillDirs { + if !skillFiles[dir] { + out = append(out, refusedFile{"skills/" + dir + "/" + contexts.SkillFileName, "skill_invalid", "every skill folder needs a SKILL.md"}) + } + } + return out +} + +func checkTreeLimits(t *localTree) error { + if len(t.files) > ctxMaxFiles { + return exitf(exitDataErr, "%d files: a context version holds at most %d", len(t.files), ctxMaxFiles) + } + if total := t.totalBytes(); total > ctxMaxTotalBytes { + return exitf(exitDataErr, "%s in total: a context version holds at most 250 MiB", humanBytes(total)) + } + skills := map[string]bool{} + for _, f := range t.files { + if parts := strings.SplitN(f.path, "/", 3); len(parts) == 3 && parts[0] == "skills" { + skills[parts[1]] = true + } + } + if len(skills) > ctxMaxSkills { + return exitf(exitDataErr, "%d skills: a context holds at most %d", len(skills), ctxMaxSkills) + } + return nil +} + +func hashFile(lf localFile) (string, int64, error) { + f, err := lf.open() + if err != nil { + return "", 0, err + } + defer f.Close() + h := sha256.New() + n, err := io.Copy(h, f) + if err != nil { + return "", 0, err + } + return hex.EncodeToString(h.Sum(nil)), n, nil +} + +// ─── push ────────────────────────────────────────────────────────────────── + +func doContextPush(ctx context.Context, env *runsEnv, ref, dir string, opts pushOptions) error { + if err := checkContextRef(ref); err != nil { + return err + } + local, err := scanLocal(env, dir, opts.skipInvalid) + if err != nil { + return err + } + defer local.close() + d, err := env.client.GetContext(ctx, ref) + if ctxCode(err) == "context_not_found" && opts.create { + if !contexts.ValidContextName(ref) { + return usageErrorf("%s", contextNameRule(ref)) + } + if opts.dryRun { + d = &api.ContextDetail{Context: api.Context{Name: ref, Version: 0}, Files: []api.ContextFile{}} + err = nil + } else { + var c *api.Context + idem := newIdempotencyKey() + err = ctxRetry(ctx, env, "create", func() error { + var cerr error + c, cerr = env.client.CreateContext(ctx, ref, "", idem) + return cerr + }) + if err == nil { + fmt.Fprintf(env.stderr, "context %s created\n", runs.SanitizeLine(c.Name)) + d = &api.ContextDetail{Context: *c, Files: []api.ContextFile{}} + } + } + } + if err != nil { + if ctxCode(err) == "context_not_found" { + return usageErrorf("no context %s: create it with nan context create %s, or add --create", + runs.SanitizeLine(ref), runs.SanitizeLine(ref)) + } + return ctxExit(err, env, scopeContextsRead) + } + return pushTree(ctx, env, d, local, opts) +} + +// treeDiff is what a push changes against the version it is based on. +type treeDiff struct { + Added []string `json:"added"` + Changed []string `json:"changed"` + Removed []string `json:"removed"` +} + +func (d treeDiff) empty() bool { return len(d.Added)+len(d.Changed)+len(d.Removed) == 0 } + +func diffTree(remote []api.ContextFile, local *localTree) treeDiff { + d := treeDiff{Added: []string{}, Changed: []string{}, Removed: []string{}} + have := make(map[string]api.ContextFile, len(remote)) + for _, f := range remote { + have[f.Path] = f + } + seen := map[string]bool{} + for _, f := range local.files { + seen[f.path] = true + r, ok := have[f.path] + switch { + case !ok: + d.Added = append(d.Added, f.path) + case r.SHA256 != f.sha || r.Executable != f.exec: + d.Changed = append(d.Changed, f.path) + } + } + for _, f := range remote { + if !seen[f.Path] { + d.Removed = append(d.Removed, f.Path) + } + } + sort.Strings(d.Added) + sort.Strings(d.Changed) + sort.Strings(d.Removed) + return d +} + +func printDiff(w io.Writer, d treeDiff, sizes map[string]int64) { + for _, p := range d.Added { + fmt.Fprintf(w, " + %s (%s)\n", runs.SanitizeLine(p), humanBytes(sizes[p])) + } + for _, p := range d.Changed { + fmt.Fprintf(w, " ~ %s (%s)\n", runs.SanitizeLine(p), humanBytes(sizes[p])) + } + for _, p := range d.Removed { + fmt.Fprintf(w, " - %s\n", runs.SanitizeLine(p)) + } +} + +// inheritExec takes the executable bit of a file whose content did not +// change from the version being replaced, where the file system cannot say +// (Windows): pushing an unchanged pulled tree must not strip +x. +func inheritExec(remote []api.ContextFile, local *localTree) { + have := make(map[string]api.ContextFile, len(remote)) + for _, f := range remote { + have[f.Path] = f + } + for i := range local.files { + f := &local.files[i] + if r, ok := have[f.path]; ok && !f.execKnown && r.SHA256 == f.sha { + f.exec = r.Executable + } + } +} + +// checkNotEmptied refuses to publish no files over a version that has some +// unless --allow-empty: an empty or wrong folder would otherwise remove the +// whole context, and automations follow the latest version. +func checkNotEmptied(d *api.ContextDetail, local *localTree, opts pushOptions) error { + if len(local.files) > 0 || len(d.Files) == 0 || opts.allowEmpty { + return nil + } + return usageErrorf("the folder has no files to publish: that would remove all %d %s of context %s (automations use the latest version). "+ + "Check the folder, or add --allow-empty to empty the context", len(d.Files), pluralWord(len(d.Files), "file", "files"), runs.SanitizeLine(d.Name)) +} + +// uploadPlan is the blobs a push sends: one local file per missing sha. +type uploadPlan struct { + files []localFile + bytes int64 +} + +func planUploads(local *localTree, missing []string) uploadPlan { + want := map[string]bool{} + for _, s := range missing { + want[s] = true + } + var p uploadPlan + for _, f := range local.files { + if want[f.sha] { + p.files = append(p.files, f) + p.bytes += f.size + delete(want, f.sha) + } + } + return p +} + +func distinctShas(local *localTree) []string { + seen := map[string]bool{} + var out []string + for _, f := range local.files { + if !seen[f.sha] { + seen[f.sha] = true + out = append(out, f.sha) + } + } + sort.Strings(out) + return out +} + +type dryRunReport struct { + Context string `json:"context"` + BaseVersion int `json:"base_version"` + Diff treeDiff `json:"changes"` + Upload struct { + Files int `json:"files"` + Bytes int64 `json:"bytes"` + } `json:"upload"` +} + +func pushTree(ctx context.Context, env *runsEnv, d *api.ContextDetail, local *localTree, opts pushOptions) error { + name := runs.SanitizeLine(d.Name) + sizes := map[string]int64{} + for _, f := range local.files { + sizes[f.path] = f.size + } + inheritExec(d.Files, local) + if err := checkNotEmptied(d, local, opts); err != nil { + return err + } + diff := diffTree(d.Files, local) + if diff.empty() && d.Version > 0 { + if opts.json { + return printJSON(env.stdout, d.Context, true) + } + fmt.Fprintf(env.stdout, "context %s is up to date (version %d)\n", name, d.Version) + return nil + } + var missing []string + if len(local.files) > 0 { + err := ctxRetry(ctx, env, "check", func() error { + var err error + missing, err = env.client.CheckBlobs(ctx, distinctShas(local)) + return err + }) + if err != nil { + return ctxExit(err, env, scopeContextsWrite) + } + } + plan := planUploads(local, missing) + if opts.dryRun { + if opts.json { + r := dryRunReport{Context: d.Name, BaseVersion: d.Version, Diff: diff} + r.Upload.Files, r.Upload.Bytes = len(plan.files), plan.bytes + return printJSON(env.stdout, r, true) + } + fmt.Fprintf(env.stdout, "context %s: version %d -> %d (dry run, nothing changed)\n", name, d.Version, d.Version+1) + printDiff(env.stdout, diff, sizes) + fmt.Fprintf(env.stdout, "%d added, %d changed, %d removed; %d %s to upload (%s)\n", len(diff.Added), len(diff.Changed), + len(diff.Removed), len(plan.files), pluralWord(len(plan.files), "file", "files"), humanBytes(plan.bytes)) + return nil + } + if err := uploadBlobs(ctx, env, plan); err != nil { + return err + } + + req := api.PublishRequest{BaseVersion: d.Version, AllowSecretLike: opts.allowSecrets} + for _, f := range local.files { + req.Files = append(req.Files, api.PublishFile{Path: f.path, SHA256: f.sha, Executable: f.exec}) + } + idem := newIdempotencyKey() + missingRounds, rebases := 0, 0 + for { + var c *api.Context + err := ctxRetry(ctx, env, "publish", func() error { + var err error + c, err = env.client.PublishContext(ctx, d.ID, req, idem) + return err + }) + if err == nil { + return reportPushed(env, c, diff, plan, opts) + } + ce, ok := api.AsContextError(err) + if !ok { + return ctxExit(err, env, scopeContextsWrite) + } + switch ce.Code { + case "blob_missing": + // The platform's cleanup removed a blob between the check and + // the publish: check again and upload what it lacks. + if missingRounds >= ctxRetries { + return ctxExit(err, env, scopeContextsWrite) + } + missingRounds++ + fmt.Fprintf(env.stderr, "%d %s no longer stored on nan.builders; uploading again\n", len(ce.MissingBlobs), + pluralWord(len(ce.MissingBlobs), "file is", "files are")) + again, cerr := env.client.CheckBlobs(ctx, ce.MissingBlobs) + if cerr != nil { + return ctxExit(cerr, env, scopeContextsWrite) + } + if err := uploadBlobs(ctx, env, planUploads(local, again)); err != nil { + return err + } + continue + case "version_conflict": + current := "a newer version" + if ce.CurrentVersion != nil { + current = fmt.Sprintf("version %d", *ce.CurrentVersion) + } + if !opts.force { + return exitf(exitFailed, "context %s changed while pushing (it is now at %s; this push was based on version %d). "+ + "Nothing was published. Push again to compare with it, or add --force to publish on top of it", name, current, req.BaseVersion) + } + if rebases >= ctxRetries { + return exitf(exitFailed, "context %s keeps changing; nothing was published, try again", name) + } + rebases++ + latest, gerr := env.client.GetContext(ctx, d.ID) + if gerr != nil { + return ctxExit(gerr, env, scopeContextsRead) + } + fmt.Fprintf(env.stderr, "context %s changed to version %d meanwhile; publishing on top of it (--force). Against it, this push:\n", + name, latest.Version) + inheritExec(latest.Files, local) + if err := checkNotEmptied(latest, local, opts); err != nil { + return err + } + diff = diffTree(latest.Files, local) + printDiff(env.stderr, diff, sizes) + req.BaseVersion = latest.Version + idem = newIdempotencyKey() + continue + case "context_secret_detected": + for _, h := range ce.Findings { + fmt.Fprintf(env.stderr, " %s:%d looks like %s\n", runs.SanitizeLine(h.Path), h.Line, runs.SanitizeLine(h.Pattern)) + } + return exitf(exitDataErr, "some files look like they hold credentials; nothing was published. Keep credentials in nan secrets "+ + "(the agent can read and copy anything in a context), or publish anyway with --allow-secrets") + case "invalid_path", "unsupported_file_type", "skill_invalid", "context_too_large": + for _, r := range ce.Files { + line := fmt.Sprintf(" %s: %s", runs.SanitizeLine(r.Path), runs.SanitizeLine(r.Reason)) + if r.Detail != "" { + line += " (" + runs.SanitizeLine(r.Detail) + ")" + } + fmt.Fprintln(env.stderr, line) + } + return exitf(exitDataErr, "%s Nothing was published", runs.SanitizeLine(ce.Message)) + } + return ctxExit(err, env, scopeContextsWrite) + } +} + +func reportPushed(env *runsEnv, c *api.Context, diff treeDiff, plan uploadPlan, opts pushOptions) error { + if opts.json { + return printJSON(env.stdout, c, true) + } + fmt.Fprintf(env.stdout, "context %s: version %d published (%d %s, %s; %d added, %d changed, %d removed; uploaded %d %s, %s)\n", + runs.SanitizeLine(c.Name), c.Version, c.FileCount, pluralWord(c.FileCount, "file", "files"), humanBytes(c.TotalBytes), + len(diff.Added), len(diff.Changed), len(diff.Removed), len(plan.files), pluralWord(len(plan.files), "file", "files"), humanBytes(plan.bytes)) + return nil +} + +// uploadBlobs sends every blob of plan, ctxConcurrency at a time. The first +// failure stops the push after the uploads in flight finish; a multipart +// upload that was cut is resumed by the next push. +func uploadBlobs(ctx context.Context, env *runsEnv, plan uploadPlan) error { + if len(plan.files) == 0 { + return nil + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + jobs := make(chan localFile) + var ( + mu sync.Mutex + firstErr error + wg sync.WaitGroup + ) + workers := max(1, min(ctxConcurrency, len(plan.files))) + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for f := range jobs { + if err := uploadOne(ctx, env, f); err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + cancel() + } + mu.Unlock() + } + } + }() + } + for _, f := range plan.files { + select { + case jobs <- f: + case <-ctx.Done(): + } + } + close(jobs) + wg.Wait() + return firstErr +} + +func uploadOne(ctx context.Context, env *runsEnv, f localFile) error { + if ctx.Err() != nil { + return ctx.Err() + } + var err error + if f.size <= ctxSingleMax { + err = ctxRetry(ctx, env, "upload "+runs.SanitizeLine(f.path), func() error { return putSingle(ctx, env, f) }) + } else { + err = putMultipart(ctx, env, f) + } + if err != nil { + var exit *ExitError + if errors.As(err, &exit) { + return err + } + e := ctxExit(err, env, scopeContextsWrite) + return &ExitError{Code: e.Code, Err: fmt.Errorf("upload of %s failed: %w", runs.SanitizeLine(f.path), e.Err)} + } + return nil +} + +// openChecked opens a file being uploaded and makes sure it is still the +// size it was hashed at (the platform verifies the sha256 too). +func openChecked(f localFile) (*os.File, error) { + fh, err := f.open() + if err != nil { + return nil, exitf(exitFailed, "cannot read %s: %v", runs.SanitizeLine(f.path), unwrapPath(err)) + } + if st, err := fh.Stat(); err != nil || st.Size() != f.size { + fh.Close() + return nil, exitf(exitFailed, "%s changed while pushing; push again", runs.SanitizeLine(f.path)) + } + return fh, nil +} + +func putSingle(ctx context.Context, env *runsEnv, f localFile) error { + fh, err := openChecked(f) + if err != nil { + return err + } + defer fh.Close() + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + _, err = env.client.PutBlob(ctx, f.sha, io.NewSectionReader(fh, 0, f.size), f.size, path.Base(f.path)) + return err +} + +// uploadState is what a push remembers about a multipart upload it did not +// finish: enough to ask the platform where it stands. The id alone grants +// nothing without the member's credential. +type uploadState struct { + UploadID string `json:"upload_id"` + Size int64 `json:"size"` +} + +var shaPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// uploadAccount keys the remembered uploads by API host and credential (a +// hash, never the credential itself): an upload id is only ever sent back +// to the account and endpoint that issued it. +func uploadAccount(env *runsEnv) string { + h := sha256.New() + _, _ = io.WriteString(h, env.client.BaseURL()+"\x00"+env.cred.session+"\x00"+env.cred.bearer) + return hex.EncodeToString(h.Sum(nil))[:32] +} + +func uploadStatePath(env *runsEnv, sha string) (string, error) { + if !shaPattern.MatchString(sha) { + return "", fmt.Errorf("bad sha") + } + dir, err := ctxUploadStateDir() + if err != nil { + return "", err + } + return filepath.Join(dir, uploadAccount(env), sha+".json"), nil +} + +func loadUploadState(env *runsEnv, sha string, size int64) *uploadState { + p, err := uploadStatePath(env, sha) + if err != nil { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return nil + } + var s uploadState + if json.Unmarshal(b, &s) != nil || s.Size != size || s.UploadID == "" { + return nil + } + return &s +} + +func saveUploadState(env *runsEnv, sha string, s uploadState) { + p, err := uploadStatePath(env, sha) + if err != nil { + return + } + if os.MkdirAll(filepath.Dir(p), 0o700) != nil { + return + } + b, _ := json.Marshal(s) + _ = os.WriteFile(p, b, 0o600) +} + +func clearUploadState(env *runsEnv, sha string) { + if p, err := uploadStatePath(env, sha); err == nil { + _ = os.Remove(p) + } +} + +// validUpload checks the platform's description of an upload against the +// file: a part size this client can send and the part count it implies. +func validUpload(u *api.ContextUpload, size int64) bool { + if u == nil || u.PartSize <= 0 || u.PartSize > api.ContextPartSize || u.NextPart < 1 { + return false + } + parts := (size + int64(u.PartSize) - 1) / int64(u.PartSize) + return int64(u.Parts) == parts && u.NextPart <= u.Parts+1 +} + +func putMultipart(ctx context.Context, env *runsEnv, f localFile) error { + fh, err := openChecked(f) + if err != nil { + return err + } + defer fh.Close() + shown := runs.SanitizeLine(f.path) + + var up *api.ContextUpload + if s := loadUploadState(env, f.sha, f.size); s != nil { + st, err := env.client.UploadStatus(ctx, f.sha, s.UploadID) + switch { + case err == nil && validUpload(st, f.size): + up = st + up.UploadID = s.UploadID + fmt.Fprintf(env.stderr, "resuming the upload of %s at part %d of %d\n", shown, up.NextPart, up.Parts) + case err == nil: + // An upload this client cannot continue: drop it, or its marker + // would refuse the new one (upload_in_progress). + _ = env.client.AbortUpload(ctx, f.sha, s.UploadID) + clearUploadState(env, f.sha) + case ctxCode(err) == "upload_not_found", ctxCode(err) == "upload_expired": + clearUploadState(env, f.sha) + default: + return err + } + } + if up == nil { + var blob *api.ContextBlob + err := ctxRetry(ctx, env, "upload "+shown, func() error { + var err error + up, blob, err = env.client.StartUpload(ctx, f.sha, f.size, path.Base(f.path)) + return err + }) + if err != nil { + return err + } + if blob != nil { + return nil // the platform already holds it + } + if !validUpload(up, f.size) { + return exitf(exitUnavailable, "nan.builders answered an upload of %s this CLI cannot send; update nan (nan --version)", shown) + } + saveUploadState(env, f.sha, uploadState{UploadID: up.UploadID, Size: f.size}) + } + + failures := 0 + for n := up.NextPart; n <= up.Parts; { + off := int64(n-1) * int64(up.PartSize) + size := min(int64(up.PartSize), f.size-off) + pctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + next, err := env.client.PutPart(pctx, f.sha, up.UploadID, n, io.NewSectionReader(fh, off, size), size) + cancel() + if err == nil && next > n { + n = next + continue + } + if ctx.Err() != nil { + return ctx.Err() + } + if code := ctxCode(err); code == "upload_not_found" || code == "upload_expired" { + clearUploadState(env, f.sha) + return err + } + failures++ + if failures > ctxRetries { + if err == nil { + err = fmt.Errorf("the platform did not take part %d", n) + } + return err + } + if ce, ok := api.AsContextError(err); ok && retryable(ce) && ce.RetryAfter > 0 && ce.RetryAfter <= ctxMaxWait { + if serr := ctxSleep(ctx, ce.RetryAfter); serr != nil { + return err + } + } + // Ask where the upload stands: the part may have been stored + // before the answer was lost, or another attempt holds it. + st, serr := env.client.UploadStatus(ctx, f.sha, up.UploadID) + if serr != nil { + return err + } + if !validUpload(st, f.size) { + return exitf(exitUnavailable, "nan.builders answered an upload of %s this CLI cannot send; update nan (nan --version)", shown) + } + fmt.Fprintf(env.stderr, "part %d of %s did not go through; resuming at part %d\n", n, shown, st.NextPart) + n = st.NextPart + } + err = ctxRetry(ctx, env, "upload "+shown, func() error { + cctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + _, err := env.client.CompleteUpload(cctx, f.sha, up.UploadID) + return err + }) + if err != nil { + if code := ctxCode(err); code == "upload_not_found" || code == "upload_expired" || code == "blob_hash_mismatch" { + clearUploadState(env, f.sha) + } + return err + } + clearUploadState(env, f.sha) + return nil +} + +// ─── pull ────────────────────────────────────────────────────────────────── + +func doContextPull(ctx context.Context, env *runsEnv, ref, dir string) error { + if err := checkContextRef(ref); err != nil { + return err + } + shown := runs.SanitizeLine(dir) + if st, err := os.Stat(dir); err == nil { + if !st.IsDir() { + return usageErrorf("%s is not a folder", shown) + } + entries, err := os.ReadDir(dir) + if err != nil { + return usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + if len(entries) > 0 && !ctxPullForce { + return usageErrorf("%s is not empty: pull into an empty or new folder, or add --force to write into it", shown) + } + } else if !errors.Is(err, fs.ErrNotExist) { + return usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + + d, err := env.client.GetContext(ctx, ref) + if err != nil { + return ctxExit(err, env, scopeContextsRead) + } + if err := checkServerTree(env, d.Files); err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return usageErrorf("cannot create %s: %v", shown, unwrapPath(err)) + } + root, err := os.OpenRoot(dir) + if err != nil { + return usageErrorf("cannot open %s: %v", shown, unwrapPath(err)) + } + defer root.Close() + + files := append([]api.ContextFile(nil), d.Files...) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + var total int64 + for i, f := range files { + if err := pullOne(ctx, env, root, d, f); err != nil { + if i == 0 { + return err + } + var exit *ExitError + errors.As(err, &exit) + return &ExitError{Code: exit.Code, Err: fmt.Errorf("%v. %d of %d files were already written to %s; run the pull again with --force to finish it", + exit.Err, i, len(files), shown)} + } + total += f.Size + } + fmt.Fprintf(env.stdout, "context %s version %d written to %s (%d %s, %s)\n", runs.SanitizeLine(d.Name), d.Version, shown, + len(files), pluralWord(len(files), "file", "files"), humanBytes(total)) + return nil +} + +// checkServerTree refuses a file list a well-behaved platform never sends: +// paths the shared validator refuses (absolute, .., case collisions, +// reserved names, ...), bad hashes or sizes, more than a version holds. +// Nothing is written then. +func checkServerTree(env *runsEnv, files []api.ContextFile) error { + if len(files) > ctxMaxFiles { + return exitf(exitDataErr, "nan.builders sent %d files, more than a context holds; nothing was written", len(files)) + } + paths := make([]string, len(files)) + var bad []string + var total int64 + for i, f := range files { + paths[i] = f.Path + if !shaPattern.MatchString(f.SHA256) || f.Size < 0 || f.Size > api.ContextMaxFileBytes { + bad = append(bad, fmt.Sprintf("%s: bad hash or size", runs.SanitizeLine(f.Path))) + continue + } + total += f.Size + } + if total > ctxMaxTotalBytes { + return exitf(exitDataErr, "nan.builders sent %s of files, more than a context version holds (250 MiB); nothing was written", humanBytes(total)) + } + for _, pe := range contexts.ValidateTree(paths) { + bad = append(bad, fmt.Sprintf("%s: %s", runs.SanitizeLine(pe.Path), pe.Reason)) + } + if len(bad) == 0 { + return nil + } + sort.Strings(bad) + for _, b := range bad { + fmt.Fprintf(env.stderr, "refused %s\n", b) + } + return exitf(exitDataErr, "nan.builders sent %d %s this CLI will not write; nothing was written", len(bad), pluralWord(len(bad), "path", "paths")) +} + +// pullOne downloads one file into a temporary name next to it, checks it, +// sets its mode and renames it into place: a failed download never leaves +// a half file nor removes one the member had. An existing link or folder at +// the path is never replaced. +func pullOne(ctx context.Context, env *runsEnv, root *os.Root, d *api.ContextDetail, f api.ContextFile) error { + shown := runs.SanitizeLine(f.Path) + local := filepath.FromSlash(f.Path) + parent := filepath.Dir(local) + if parent != "." { + if err := root.MkdirAll(parent, 0o755); err != nil { + return exitf(exitFailed, "cannot create the folder of %s: %v", shown, unwrapPath(err)) + } + } + switch st, err := root.Lstat(local); { + case err == nil && (st.Mode()&fs.ModeSymlink != 0 || !st.Mode().IsRegular()): + return exitf(exitFailed, "%s is a link or a folder in the target folder; it was left as it is and nothing was written there", shown) + case err == nil && !ctxPullForce: + return exitf(exitFailed, "%s already exists (use --force to replace files)", shown) + case err != nil && !errors.Is(err, fs.ErrNotExist): + return exitf(exitFailed, "cannot write %s: %v", shown, unwrapPath(err)) + } + tmp := filepath.Join(parent, ".nan-pull-"+newIdempotencyKey()[len("nan-cli-"):]) + out, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return exitf(exitFailed, "cannot write %s: %v", shown, unwrapPath(err)) + } + fail := func(e *ExitError) error { + out.Close() + _ = root.Remove(tmp) + return e + } + var body io.ReadCloser + err = ctxRetry(ctx, env, "download "+shown, func() error { + var err error + body, err = env.client.OpenContextFile(ctx, d.ID, f.Path, d.Version) + return err + }) + if err != nil { + return fail(ctxExit(err, env, scopeContextsRead)) + } + defer body.Close() + h := sha256.New() + n, err := io.Copy(io.MultiWriter(out, h), io.LimitReader(body, f.Size+1)) + if err != nil { + return fail(exitf(exitUnavailable, "the download of %s was cut: %v", shown, err)) + } + if n != f.Size || hex.EncodeToString(h.Sum(nil)) != f.SHA256 { + return fail(exitf(exitUnavailable, "the download of %s does not match its sha256 and size; it was not kept", shown)) + } + if err := out.Close(); err != nil { + _ = root.Remove(tmp) + return exitf(exitFailed, "cannot write %s: %v", shown, err) + } + mode := os.FileMode(0o644) + if f.Executable { + mode = 0o755 + } + // Both ways: a replaced file that was executable and no longer is + // loses the bit. + if err := root.Chmod(tmp, mode); err != nil && runtime.GOOS != "windows" { + _ = root.Remove(tmp) + return exitf(exitFailed, "cannot write %s: %v", shown, err) + } + if err := root.Rename(tmp, local); err != nil { + _ = root.Remove(tmp) + return exitf(exitFailed, "cannot write %s: %v", shown, unwrapPath(err)) + } + return nil +} diff --git a/cmd/context_fake_test.go b/cmd/context_fake_test.go new file mode 100644 index 0000000..e25d0fe --- /dev/null +++ b/cmd/context_fake_test.go @@ -0,0 +1,558 @@ +package cmd + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/contexts" +) + +// fakeCtx is an in-memory /v1/contexts with the shapes, codes and status +// codes of cloud-api's handlers (internal/handlers/contexts.go, +// contexts_crud.go and internal/contexts/errors.go): the OpenAI error +// envelope with the extra fields at the top level, 201 for a new blob or +// version, 200 for a blob the member already has, Retry-After on 429/409. + +const ( + fakeCtxID = "c7000000-0000-4000-8000-000000000001" + fakeCtxStamp = "2026-10-21T10:00:00Z" + fakeSecretPat = "ghp_" +) + +type fakeContext struct { + ID, Name, Description string + Version int + Files []api.ContextFile +} + +type fakeUpload struct { + sha string + size int64 + partSize int + next int + data []byte +} + +type ctxReq struct { + Method, Path, Query, Body, Idem, Filename string + Size int64 +} + +type fakeResp struct { + status int + body map[string]any + header map[string]string +} + +type fakeCtx struct { + mu sync.Mutex + off bool + contexts []*fakeContext + blobs map[string][]byte + uploads map[string]*fakeUpload + partSize int + reqs []ctxReq + // queued answers "METHOD /path" before the normal handling. + queued map[string][]fakeResp + // onPublish runs before a publish is handled (f.mu held). + onPublish func(f *fakeCtx, n int) + publishes int + // corrupt serves wrong bytes for these paths on download. + corrupt map[string]bool + // failPart answers 500 to the first PUT of these part numbers. + failPart map[int]bool + srv *httptest.Server +} + +// server starts the fake once; every run against it sees the same URL, so +// the remembered uploads (keyed by API host and account) carry over. +func (f *fakeCtx) server(t *testing.T) *httptest.Server { + if f.srv == nil { + f.srv = httptest.NewServer(f) + t.Cleanup(f.srv.Close) + } + return f.srv +} + +// stateFile is where a push run against f remembers an upload of sha. +func (f *fakeCtx) stateFile(t *testing.T, stateDir, sha string) string { + h := sha256.Sum256([]byte(f.server(t).URL + "\x00" + "sess-cli" + "\x00")) + return filepath.Join(stateDir, hex.EncodeToString(h[:])[:32], sha+".json") +} + +// strict decodes a JSON body like cloud-api: unknown fields are a 400. +func strict(w http.ResponseWriter, body []byte, v any) bool { + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + ctxFail(w, 400, "invalid_request", "body has an unknown or malformed field", nil, 0) + return false + } + return true +} + +func newFakeCtx() *fakeCtx { + return &fakeCtx{ + blobs: map[string][]byte{}, + uploads: map[string]*fakeUpload{}, + partSize: api.ContextPartSize, + queued: map[string][]fakeResp{}, + corrupt: map[string]bool{}, + failPart: map[int]bool{}, + } +} + +func shaOf(b []byte) string { + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} + +// seed adds a context whose files hold the given contents. +func (f *fakeCtx) seed(name string, version int, files map[string]string) *fakeContext { + c := &fakeContext{ID: fakeCtxID, Name: name, Description: "Acme API conventions", Version: version} + if len(f.contexts) > 0 { + c.ID = fmt.Sprintf("c7000000-0000-4000-8000-%012d", len(f.contexts)+1) + } + for p, body := range files { + sha := shaOf([]byte(body)) + f.blobs[sha] = []byte(body) + c.Files = append(c.Files, api.ContextFile{Path: p, SHA256: sha, Size: int64(len(body)), Executable: strings.HasSuffix(p, ".sh")}) + } + sort.Slice(c.Files, func(i, j int) bool { return c.Files[i].Path < c.Files[j].Path }) + f.contexts = append(f.contexts, c) + return c +} + +func (f *fakeCtx) queue(route string, r fakeResp) { + f.mu.Lock() + defer f.mu.Unlock() + f.queued[route] = append(f.queued[route], r) +} + +func (f *fakeCtx) requests() []ctxReq { + f.mu.Lock() + defer f.mu.Unlock() + return append([]ctxReq(nil), f.reqs...) +} + +func (f *fakeCtx) sent(method, prefix string) []ctxReq { + var out []ctxReq + for _, r := range f.requests() { + if r.Method == method && strings.HasPrefix(r.Path, prefix) { + out = append(out, r) + } + } + return out +} + +func (c *fakeContext) view() map[string]any { + var total int64 + agents := false + skills := []string{} + seen := map[string]bool{} + for _, f := range c.Files { + total += f.Size + agents = agents || f.Path == "AGENTS.md" + if parts := strings.SplitN(f.Path, "/", 3); len(parts) == 3 && parts[0] == "skills" && !seen[parts[1]] { + seen[parts[1]] = true + skills = append(skills, parts[1]) + } + } + sort.Strings(skills) + return map[string]any{"id": c.ID, "object": "context", "name": c.Name, "description": c.Description, "version": c.Version, + "tree_sha256": strings.Repeat("ab", 32), "file_count": len(c.Files), "total_bytes": total, "has_instructions": agents, + "skills": skills, "created_at": fakeCtxStamp, "updated_at": fakeCtxStamp} +} + +func ctxFail(w http.ResponseWriter, status int, code, msg string, extra map[string]any, retryAfter int) { + if retryAfter > 0 { + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + } + typ := "invalid_request_error" + if status == 429 { + typ = "rate_limit_exceeded" + } else if status >= 500 { + typ = "server_error" + } + body := map[string]any{"error": map[string]any{"message": msg, "type": typ, "param": nil, "code": code}} + for k, v := range extra { + body[k] = v + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func ctxJSONResp(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func (f *fakeCtx) find(ref string) *fakeContext { + for _, c := range f.contexts { + if c.ID == ref || c.Name == ref { + return c + } + } + return nil +} + +func (f *fakeCtx) blobView(sha string) map[string]any { + b := f.blobs[sha] + findings := []any{} + if bytes.Contains(b, []byte(fakeSecretPat)) { + findings = append(findings, map[string]any{"line": 1, "pattern": "github_token"}) + } + return map[string]any{"sha256": sha, "size": len(b), "format": "text", "secret_findings": findings} +} + +func (f *fakeCtx) ServeHTTP(w http.ResponseWriter, r *http.Request) { + isRaw := r.Header.Get("Content-Type") == "application/octet-stream" + if r.Method == http.MethodPut && r.ContentLength < 0 { + ctxFail(w, 411, "invalid_request", "Content-Length is required", nil, 0) + return + } + body, _ := io.ReadAll(r.Body) + f.mu.Lock() + defer f.mu.Unlock() + rec := ctxReq{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Idem: r.Header.Get("Idempotency-Key"), + Filename: r.Header.Get("X-Nan-Filename"), Size: int64(len(body))} + if !isRaw { + rec.Body = string(body) + } + f.reqs = append(f.reqs, rec) + if f.off { + ctxFail(w, 404, "not_found", "Not found.", nil, 0) + return + } + route := r.Method + " " + r.URL.Path + if q := f.queued[route]; len(q) > 0 { + f.queued[route] = q[1:] + for k, v := range q[0].header { + w.Header().Set(k, v) + } + if q[0].body == nil { + w.WriteHeader(q[0].status) + return + } + ctxJSONResp(w, q[0].status, q[0].body) + return + } + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/contexts"), "/") + parts := strings.Split(rest, "/") + switch { + case rest == "" && r.Method == http.MethodGet: + data := []any{} + for _, c := range f.contexts { + data = append(data, c.view()) + } + ctxJSONResp(w, 200, map[string]any{"data": data, "usage": map[string]any{"stored_bytes": 1536, "quota_bytes": 500 << 20}}) + case rest == "" && r.Method == http.MethodPost: + var in struct { + Name string `json:"name"` + Description string `json:"description"` + } + if !strict(w, body, &in) { + return + } + if f.find(in.Name) != nil { + ctxFail(w, 409, "context_name_taken", "You already have a context with this name.", nil, 0) + return + } + c := &fakeContext{ID: fmt.Sprintf("c7000000-0000-4000-8000-%012d", len(f.contexts)+1), Name: in.Name, Description: in.Description, Version: 1} + f.contexts = append(f.contexts, c) + ctxJSONResp(w, 201, c.view()) + case parts[0] == "blobs": + f.serveBlobs(w, r, parts[1:], body) + default: + c := f.find(parts[0]) + if c == nil { + ctxFail(w, 404, "context_not_found", "context not found", nil, 0) + return + } + sub := strings.Join(parts[1:], "/") + switch r.Method + " " + sub { + case "GET ": + v := c.view() + files := []any{} + for _, fl := range c.Files { + files = append(files, map[string]any{"path": fl.Path, "size": fl.Size, "sha256": fl.SHA256, "executable": fl.Executable}) + } + v["files"] = files + ctxJSONResp(w, 200, v) + case "PATCH ": + var in struct { + Name *string `json:"name"` + Description *string `json:"description"` + } + if !strict(w, body, &in) { + return + } + if in.Name != nil { + c.Name = *in.Name + } + if in.Description != nil { + c.Description = *in.Description + } + ctxJSONResp(w, 200, c.view()) + case "DELETE ": + for i, x := range f.contexts { + if x == c { + f.contexts = append(f.contexts[:i], f.contexts[i+1:]...) + break + } + } + w.WriteHeader(204) + case "GET file": + p := r.URL.Query().Get("path") + if v := r.URL.Query().Get("version"); v != strconv.Itoa(c.Version) { + ctxFail(w, 404, "version_not_found", "version not found", nil, 0) + return + } + for _, fl := range c.Files { + if fl.Path == p { + b := f.blobs[fl.SHA256] + if f.corrupt[p] { + b = append([]byte("X"), b[1:]...) + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(b) + return + } + } + ctxFail(w, 404, "file_not_found", "file not found", nil, 0) + case "POST versions": + f.publishes++ + if f.onPublish != nil { + f.onPublish(f, f.publishes) + } + var in api.PublishRequest + if !strict(w, body, &in) { + return + } + // cloud-api's order (service.Publish): the tree (paths, then + // blobs), then base_version. + paths := make([]string, len(in.Files)) + for i, fl := range in.Files { + paths[i] = fl.Path + } + if bad := contexts.ValidateTree(paths); len(bad) > 0 { + refused := []any{} + for _, b := range bad { + refused = append(refused, map[string]any{"path": b.Path, "reason": string(b.Reason)}) + } + ctxFail(w, 400, "invalid_path", "Some file names are not allowed.", map[string]any{"files": refused}, 0) + return + } + var missing []string + for _, fl := range in.Files { + if _, ok := f.blobs[fl.SHA256]; !ok { + missing = append(missing, fl.SHA256) + } + } + if len(missing) > 0 { + ctxFail(w, 409, "blob_missing", "Some files are no longer stored; upload them again and publish.", map[string]any{"sha256": missing}, 0) + return + } + if in.BaseVersion != c.Version { + ctxFail(w, 409, "version_conflict", "The context changed since you loaded it; reload and publish again.", + map[string]any{"current_version": c.Version}, 0) + return + } + if !in.AllowSecretLike { + var hits []any + for _, fl := range in.Files { + if bytes.Contains(f.blobs[fl.SHA256], []byte(fakeSecretPat)) { + hits = append(hits, map[string]any{"path": fl.Path, "line": 1, "pattern": "github_token"}) + } + } + if len(hits) > 0 { + ctxFail(w, 422, "context_secret_detected", "Some files look like they contain secrets. Keep secrets in Secrets, or publish anyway.", + map[string]any{"findings": hits}, 0) + return + } + } + c.Version++ + c.Files = nil + for _, fl := range in.Files { + c.Files = append(c.Files, api.ContextFile{Path: fl.Path, SHA256: fl.SHA256, Size: int64(len(f.blobs[fl.SHA256])), Executable: fl.Executable}) + } + ctxJSONResp(w, 201, c.view()) + default: + ctxFail(w, 404, "not_found", "no route "+route, nil, 0) + } + } +} + +func (f *fakeCtx) serveBlobs(w http.ResponseWriter, r *http.Request, parts []string, body []byte) { + if len(parts) == 1 && parts[0] == "check" { + var in struct { + SHA256 []string `json:"sha256"` + } + if !strict(w, body, &in) { + return + } + missing := []string{} + for _, s := range in.SHA256 { + if _, ok := f.blobs[s]; !ok { + missing = append(missing, s) + } + } + ctxJSONResp(w, 200, map[string]any{"missing": missing}) + return + } + sha := parts[0] + switch { + case len(parts) == 1 && r.Method == http.MethodPut: + if int64(len(body)) > api.ContextPartSize { + ctxFail(w, 413, "invalid_request", "a single upload is at most 16 MiB", nil, 0) + return + } + if _, ok := f.blobs[sha]; ok { + ctxJSONResp(w, 200, f.blobView(sha)) + return + } + if shaOf(body) != sha { + ctxFail(w, 422, "blob_hash_mismatch", "The uploaded bytes do not match the declared sha256 and size; nothing was stored.", nil, 0) + return + } + f.blobs[sha] = body + ctxJSONResp(w, 201, f.blobView(sha)) + case len(parts) == 2 && r.Method == http.MethodPost: + if _, ok := f.blobs[sha]; ok { + ctxJSONResp(w, 200, f.blobView(sha)) + return + } + var in struct { + Size int64 `json:"size"` + Filename string `json:"filename"` + } + if !strict(w, body, &in) { + return + } + id := fmt.Sprintf("u0000000-0000-4000-8000-%012d", len(f.uploads)+1) + u := &fakeUpload{sha: sha, size: in.Size, partSize: f.partSize, next: 1} + f.uploads[id] = u + ctxJSONResp(w, 201, f.uploadView(id, u)) + case len(parts) >= 3: + id := parts[2] + u := f.uploads[id] + if u == nil || u.sha != sha { + ctxFail(w, 404, "upload_not_found", "upload not found", nil, 0) + return + } + sub := strings.Join(parts[3:], "/") + switch { + case r.Method == http.MethodGet && sub == "": + ctxJSONResp(w, 200, f.uploadView(id, u)) + case r.Method == http.MethodPut && strings.HasPrefix(sub, "parts/"): + n, _ := strconv.Atoi(strings.TrimPrefix(sub, "parts/")) + if f.failPart[n] { + delete(f.failPart, n) + ctxFail(w, 500, "internal_error", "Something went wrong. Try again.", nil, 0) + return + } + if n < u.next { + ctxJSONResp(w, 200, map[string]int{"next_part": u.next}) + return + } + if n != u.next { + ctxFail(w, 409, "part_conflict", "out of order", nil, 0) + return + } + u.data = append(u.data, body...) + u.next++ + ctxJSONResp(w, 200, map[string]int{"next_part": u.next}) + case r.Method == http.MethodPost && sub == "complete": + if int64(len(u.data)) != u.size || shaOf(u.data) != sha { + ctxFail(w, 422, "blob_hash_mismatch", "mismatch", nil, 0) + return + } + f.blobs[sha] = u.data + delete(f.uploads, id) + ctxJSONResp(w, 201, f.blobView(sha)) + case r.Method == http.MethodDelete && sub == "": + delete(f.uploads, id) + w.WriteHeader(204) + default: + ctxFail(w, 404, "not_found", "no route", nil, 0) + } + default: + ctxFail(w, 404, "not_found", "no route", nil, 0) + } +} + +func (f *fakeCtx) uploadView(id string, u *fakeUpload) map[string]any { + parts := int((u.size + int64(u.partSize) - 1) / int64(u.partSize)) + return map[string]any{"upload_id": id, "part_size": u.partSize, "parts": parts, "next_part": u.next} +} + +// ctxRun is one nan invocation against a fakeCtx. +type ctxRun struct { + code int + msg string + stdout, stderr string + sleeps []time.Duration + stateDir string +} + +type ctxSetup struct { + stdin string + tty bool + stateDir string // "" = a fresh temp dir +} + +func runCtx(t *testing.T, fake *fakeCtx, setup ctxSetup, args ...string) ctxRun { + t.Helper() + srv := fake.server(t) + fakeHome(t, cliSession) + t.Setenv(tokenEnvVar, "") + swap(t, &runsBaseURL, srv.URL) + swap(t, &writeStdin, io.Reader(strings.NewReader(setup.stdin))) + tty := setup.tty + swap(t, &writeStdinTTY, func() bool { return tty }) + var out, errOut bytes.Buffer + swap(t, &writeStdout, io.Writer(&out)) + swap(t, &writeStderr, io.Writer(&errOut)) + state := setup.stateDir + if state == "" { + state = t.TempDir() + } + swap(t, &ctxUploadStateDir, func() (string, error) { return state, nil }) + var mu sync.Mutex + var sleeps []time.Duration + swap(t, &ctxSleep, func(_ context.Context, d time.Duration) error { + mu.Lock() + defer mu.Unlock() + sleeps = append(sleeps, d) + return nil + }) + code, msg := execute(t, args...) + mu.Lock() + defer mu.Unlock() + return ctxRun{code: code, msg: msg, stdout: out.String(), stderr: errOut.String(), sleeps: sleeps, stateDir: state} +} + +func mustCtxExit(t *testing.T, r ctxRun, code int, wants ...string) { + t.Helper() + if r.code != code { + t.Fatalf("exit %d (want %d): %s\nstdout: %s\nstderr: %s", r.code, code, r.msg, r.stdout, r.stderr) + } + wantAll(t, "message", r.msg, wants...) +} diff --git a/cmd/context_hardening_test.go b/cmd/context_hardening_test.go new file mode 100644 index 0000000..e9ace57 --- /dev/null +++ b/cmd/context_hardening_test.go @@ -0,0 +1,349 @@ +package cmd + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/nxssie/nan-cli/internal/api" +) + +// The review findings of #62 (QA and Security), each as a test first. + +func skipOnWindows(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("needs symlinks and Unix modes") + } +} + +// A DIR that is itself a symlink is the folder the member named: push walks +// what it points at instead of publishing an empty tree. +func TestContextPushThroughASymlinkedFolder(t *testing.T) { + skipOnWindows(t) + fake := newFakeCtx() + fake.seed("acme-api", 2, map[string]string{"files/a.md": "a\n"}) + real := writeTree(t, map[string]string{"files/a.md": "a\n", "files/b.md": "b\n"}) + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", link) + mustCtxExit(t, r, 0) + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 1 || strings.Join(publishPaths(publishBody(t, pubs[0])), ",") != "files/a.md,files/b.md" { + t.Errorf("published %+v", pubs) + } +} + +// An empty folder never silently empties a context. +func TestContextPushRefusesEmptyingAContext(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 2, map[string]string{"files/a.md": "a\n"}) + empty := t.TempDir() + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", empty) + mustCtxExit(t, r, exitUsage, "no files to publish", "remove all 1 file", "--allow-empty") + if len(fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions")) != 0 { + t.Fatal("published an empty tree") + } + // Only dotfiles left is the same thing. + dotted := writeTree(t, map[string]string{".env": "S=1\n"}) + mustCtxExit(t, runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dotted), exitUsage, "--allow-empty") + + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", empty, "--allow-empty") + mustCtxExit(t, r, 0) + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 1 || len(publishBody(t, pubs[0]).Files) != 0 || fake.contexts[0].Version != 3 { + t.Errorf("publishes %+v", pubs) + } + // An empty context takes an empty folder without the flag. + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", empty) + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "is up to date") +} + +// Writes go through os.OpenRoot: a link already inside DIR never leads a +// pull outside it, and a link at a file's path is never replaced. +func TestContextPullNeverWritesThroughALink(t *testing.T) { + skipOnWindows(t) + t.Run("files is a link", func(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, map[string]string{"files/a.md": "a\n"}) + outside := t.TempDir() + dir := t.TempDir() + if err := os.Symlink(outside, filepath.Join(dir, "files")); err != nil { + t.Fatal(err) + } + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir, "--force") + mustCtxExit(t, r, exitFailed) + if entries, _ := os.ReadDir(outside); len(entries) != 0 { + t.Errorf("wrote outside DIR: %v", entries) + } + }) + t.Run("files/a.md is a link", func(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, map[string]string{"files/a.md": "a\n"}) + outside := filepath.Join(t.TempDir(), "victim") + if err := os.WriteFile(outside, []byte("keep\n"), 0o644); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dir, "files", "a.md")); err != nil { + t.Fatal(err) + } + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir, "--force") + mustCtxExit(t, r, exitFailed, "link") + if b, _ := os.ReadFile(outside); string(b) != "keep\n" { + t.Errorf("wrote through the link: %q", b) + } + if st, err := os.Lstat(filepath.Join(dir, "files", "a.md")); err != nil || st.Mode()&os.ModeSymlink == 0 { + t.Errorf("replaced the link: %v", err) + } + }) +} + +// The limits of one version, checked before anything is hashed or sent. +func TestContextPushTreeLimits(t *testing.T) { + sparse := func(t *testing.T, dir, rel string, size int64) { + t.Helper() + p := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(size); err != nil { + t.Fatal(err) + } + f.Close() + } + for name, tc := range map[string]struct { + build func(t *testing.T, dir string) + want string + }{ + "31 skills": {func(t *testing.T, dir string) { + for i := 0; i < 31; i++ { + sparse(t, dir, fmt.Sprintf("skills/s%d/SKILL.md", i), 10) + } + }, "31 skills: a context holds at most 30"}, + "1001 files": {func(t *testing.T, dir string) { + for i := 0; i < 1001; i++ { + sparse(t, dir, fmt.Sprintf("files/f%d.md", i), 0) + } + }, "1001 files: a context version holds at most 1000"}, + "250 MiB": {func(t *testing.T, dir string) { + for i := 0; i < 3; i++ { + sparse(t, dir, fmt.Sprintf("files/big%d.bin", i), 90<<20) + } + }, "270.0 MiB in total: a context version holds at most 250 MiB"}, + "100 MiB file": {func(t *testing.T, dir string) { + sparse(t, dir, "files/huge.bin", 100<<20+1) + }, ""}, + "64 KiB SKILL.md": {func(t *testing.T, dir string) { + sparse(t, dir, "skills/x/SKILL.md", 64<<10+1) + }, ""}, + } { + t.Run(name, func(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := t.TempDir() + tc.build(t, dir) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, tc.want) + switch name { + case "100 MiB file": + wantAll(t, "stderr", r.stderr, "refused files/huge.bin: too_large (a file is at most 100 MiB)") + case "64 KiB SKILL.md": + wantAll(t, "stderr", r.stderr, "refused skills/x/SKILL.md: skill_invalid (SKILL.md is at most 64 KiB)") + } + if len(fake.requests()) != 0 { + t.Errorf("sent %d requests", len(fake.requests())) + } + }) + } + // At the limits exactly, nothing is refused locally. + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := t.TempDir() + for i := 0; i < 30; i++ { + sparse(t, dir, fmt.Sprintf("skills/s%d/SKILL.md", i), 0) + } + sparse(t, dir, "skills/s0/SKILL.md", 64<<10) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--dry-run") + mustCtxExit(t, r, 0) +} + +// A remembered upload the platform describes in a way this CLI cannot +// continue is aborted (its marker would refuse a new one), and a new one +// is started. +func TestContextPushAbortsAnUnusableRememberedUpload(t *testing.T) { + swap(t, &ctxSingleMax, int64(8)) + fake := newFakeCtx() + fake.partSize = 4 + fake.seed("acme-api", 1, nil) + big := "0123456789" + sha := shaOf([]byte(big)) + const id = "u0000000-0000-4000-8000-000000000042" + fake.uploads[id] = &fakeUpload{sha: sha, size: 10, partSize: 32 << 20, next: 1} + state := t.TempDir() + writeState(t, fake.stateFile(t, state, sha), id, 10) + dir := writeTree(t, map[string]string{"files/big.csv": big}) + r := runCtx(t, fake, ctxSetup{stateDir: state}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + if dels := fake.sent("DELETE", "/v1/contexts/blobs/"+sha+"/uploads/"+id); len(dels) != 1 { + t.Errorf("abort not sent: %v", fake.requests()) + } + if _, err := os.Stat(fake.stateFile(t, state, sha)); !os.IsNotExist(err) { + t.Errorf("state file left: %v", err) + } + if string(fake.blobs[sha]) != big { + t.Errorf("stored %q", fake.blobs[sha]) + } +} + +// An upload the platform dropped mid-way (expired, or gone) is forgotten: +// the next push starts a new one instead of asking for it again. +func TestContextPushForgetsAnUploadThePlatformDropped(t *testing.T) { + for code, status := range map[string]int{"upload_not_found": 404, "upload_expired": 409} { + t.Run(code, func(t *testing.T) { + swap(t, &ctxSingleMax, int64(8)) + fake := newFakeCtx() + fake.partSize = 4 + fake.seed("acme-api", 1, nil) + big := "0123456789" + sha := shaOf([]byte(big)) + const id = "u0000000-0000-4000-8000-000000000001" // the fake's first upload id + fake.queue("PUT /v1/contexts/blobs/"+sha+"/uploads/"+id+"/parts/2", fakeResp{status: status, body: map[string]any{ + "error": map[string]any{"message": "The upload expired before it finished; upload the file again.", "type": "invalid_request_error", "param": nil, "code": code}}}) + state := t.TempDir() + dir := writeTree(t, map[string]string{"files/big.csv": big}) + r := runCtx(t, fake, ctxSetup{stateDir: state}, "context", "push", "acme-api", dir) + if r.code == 0 { + t.Fatalf("push succeeded: %s", r.stdout) + } + wantAll(t, "message", r.msg, "upload of files/big.csv failed") + if _, err := os.Stat(fake.stateFile(t, state, sha)); !os.IsNotExist(err) { + t.Errorf("state file left: %v", err) + } + }) + } +} + +func TestContextExitMapping(t *testing.T) { + for _, tc := range []struct { + status int + code string + exit int + want string + }{ + {403, "entitlement_required", exitAuth, "membership does not include agent runs"}, + {503, "storage_unavailable", exitUnavailable, "unavailable"}, + {409, "context_name_taken", exitUsage, "already have a context"}, + {409, "too_many_contexts", exitDataErr, "at most 20"}, + } { + fake := newFakeCtx() + msg := "Context storage is unavailable right now; try again later." + if tc.code == "too_many_contexts" { + msg = "You can keep at most 20 contexts." + } + // Retryable refusals are retried (3 times): queue enough of them. + for i := 0; i < 4; i++ { + fake.queue("POST /v1/contexts", fakeResp{status: tc.status, body: map[string]any{ + "error": map[string]any{"message": msg, "type": "invalid_request_error", "param": nil, "code": tc.code}}}) + } + r := runCtx(t, fake, ctxSetup{}, "context", "create", "acme-api") + mustCtxExit(t, r, tc.exit, tc.want) + } +} + +// Where the file system has no executable bit, an unchanged file keeps the +// bit of the version it replaces. +func TestContextPushKeepsTheRemoteExecutableBit(t *testing.T) { + swap(t, &ctxExecKnown, false) + fake := newFakeCtx() + fake.seed("acme-api", 2, map[string]string{"skills/x/SKILL.md": skillMD, "skills/x/run.sh": "#!/bin/sh\n"}) + dir := writeTree(t, map[string]string{"skills/x/SKILL.md": skillMD, "skills/x/run.sh": "#!/bin/sh\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "is up to date (version 2)") + + // A changed file has no remote bit to inherit. + dir = writeTree(t, map[string]string{"skills/x/SKILL.md": skillMD, "skills/x/run.sh": "#!/bin/sh\necho 2\n"}) + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + for _, f := range publishBody(t, pubs[len(pubs)-1]).Files { + if f.Executable { + t.Errorf("%s published executable", f.Path) + } + } +} + +// pull --force replaces files whole: the mode follows the remote file both +// ways, and a failed download leaves the member's file as it was. +func TestContextPullForceReplacesFilesWhole(t *testing.T) { + skipOnWindows(t) + fake := newFakeCtx() + fake.seed("acme-api", 1, map[string]string{"files/a.md": "new\n", "files/b.md": "fine\n"}) + dir := writeTree(t, map[string]string{"files/a.md": "mine\n", "files/b.md": "keep me\n"}) + if err := os.Chmod(filepath.Join(dir, "files", "a.md"), 0o755); err != nil { + t.Fatal(err) + } + fake.corrupt["files/b.md"] = true + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir, "--force") + mustCtxExit(t, r, exitUnavailable, "does not match", "1 of 2 files were already written", "--force") + if b, _ := os.ReadFile(filepath.Join(dir, "files", "b.md")); string(b) != "keep me\n" { + t.Errorf("a failed download changed the member's file: %q", b) + } + st, _ := os.Stat(filepath.Join(dir, "files", "a.md")) + if b, _ := os.ReadFile(filepath.Join(dir, "files", "a.md")); string(b) != "new\n" || st.Mode().Perm() != 0o644 { + t.Errorf("a.md %q %v", b, st.Mode()) + } + entries, _ := os.ReadDir(filepath.Join(dir, "files")) + if len(entries) != 2 { + t.Errorf("left temporary files: %v", entries) + } +} + +func TestContextPullCapsTheTotal(t *testing.T) { + fake := newFakeCtx() + c := fake.seed("acme-api", 1, nil) + for i := 0; i < 3; i++ { + c.Files = append(c.Files, api.ContextFile{Path: fmt.Sprintf("files/big%d.bin", i), SHA256: shaOf([]byte{byte(i)}), Size: 90 << 20}) + } + dir := filepath.Join(t.TempDir(), "out") + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "more than a context version holds", "nothing was written") + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("created %s", dir) + } +} + +func TestContextRefIsCheckedLocally(t *testing.T) { + for _, ref := range []string{"..", ".", "a/b", "Acme", "%2e%2e"} { + for _, args := range [][]string{{"show", ref}, {"pull", ref, t.TempDir()}, {"rm", ref, "--yes"}, {"edit", ref, "--name", "x"}} { + fake := newFakeCtx() + r := runCtx(t, fake, ctxSetup{}, append([]string{"context"}, args...)...) + mustCtxExit(t, r, exitUsage, "neither a context name") + if len(fake.requests()) != 0 { + t.Errorf("%v sent %v", args, fake.requests()) + } + } + } + // A context id is accepted as is. + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + mustCtxExit(t, runCtx(t, fake, ctxSetup{}, "context", "show", fakeCtxID), 0) + if got := fake.requests(); len(got) != 1 || got[0].Method != http.MethodGet { + t.Errorf("%v", got) + } +} diff --git a/cmd/context_test.go b/cmd/context_test.go new file mode 100644 index 0000000..6014e37 --- /dev/null +++ b/cmd/context_test.go @@ -0,0 +1,689 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/nxssie/nan-cli/internal/api" +) + +// nan context (helmcode/nan-devops AGENT-CONTEXT.md §6, C1 test list), +// against fakeCtx: the shapes and codes of cloud-api's /v1/contexts. + +// writeTree creates files (path -> content) under a new temp folder. A path +// ending in ".sh" is made executable. +func writeTree(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for p, body := range files { + full := filepath.Join(dir, filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + mode := os.FileMode(0o644) + if strings.HasSuffix(p, ".sh") { + mode = 0o755 + } + if err := os.WriteFile(full, []byte(body), mode); err != nil { + t.Fatal(err) + } + } + return dir +} + +const skillMD = "---\nname: pr-review\ndescription: Review a pull request.\n---\nSteps.\n" + +func goodTree() map[string]string { + return map[string]string{ + "AGENTS.md": "Use tabs.\n", + "skills/pr-review/SKILL.md": skillMD, + "skills/pr-review/scripts/run.sh": "#!/bin/sh\necho ok\n", + "files/especificación técnica.md": "Spec.\n", + } +} + +func publishBody(t *testing.T, r ctxReq) api.PublishRequest { + t.Helper() + var in api.PublishRequest + if err := json.Unmarshal([]byte(r.Body), &in); err != nil { + t.Fatalf("publish body %q: %v", r.Body, err) + } + return in +} + +func publishPaths(in api.PublishRequest) []string { + var out []string + for _, f := range in.Files { + out = append(out, f.Path) + } + return out +} + +// ─── ls, show, create, edit, rm ───────────────────────────────────────────── + +func TestContextLsAndShowGoldens(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 3, map[string]string{"AGENTS.md": "Use tabs.\n", "skills/pr-review/SKILL.md": skillMD, "files/spec.md": "Spec.\n"}) + + r := runCtx(t, fake, ctxSetup{}, "context", "ls") + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/ls.golden", r.stdout) + + r = runCtx(t, fake, ctxSetup{}, "context", "show", "acme-api") + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/show.golden", r.stdout) + + r = runCtx(t, fake, ctxSetup{}, "context", "show", "acme-api", "--json") + mustCtxExit(t, r, 0) + var d api.ContextDetail + if err := json.Unmarshal([]byte(r.stdout), &d); err != nil || d.Version != 3 || len(d.Files) != 3 { + t.Errorf("%v %s", err, r.stdout) + } + + r = runCtx(t, fake, ctxSetup{}, "context", "show", "nope") + mustCtxExit(t, r, exitUsage, "no such context") +} + +func TestContextSurfaceOff(t *testing.T) { + fake := newFakeCtx() + fake.off = true + r := runCtx(t, fake, ctxSetup{}, "context", "ls") + mustCtxExit(t, r, exitUsage, "not available for your account yet", "portal") +} + +func TestContextCreateValidatesTheName(t *testing.T) { + for _, name := range []string{"Acme", "con", "blobs", "a_b", "123e4567-e89b-12d3-a456-426614174000"} { + fake := newFakeCtx() + r := runCtx(t, fake, ctxSetup{}, "context", "create", name) + mustCtxExit(t, r, exitUsage, "not a valid context name") + if len(fake.requests()) != 0 { + t.Errorf("%s: sent %v", name, fake.requests()) + } + } +} + +func TestContextCreateFromAFolder(t *testing.T) { + fake := newFakeCtx() + dir := writeTree(t, goodTree()) + r := runCtx(t, fake, ctxSetup{}, "context", "create", "acme-api", "--description", "Acme", "--from", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "version 2 published", "4 files") + creates := fake.sent("POST", "/v1/contexts") + if len(creates) < 1 || !idemKeyPattern.MatchString(creates[0].Idem) || !strings.Contains(creates[0].Body, `"description":"Acme"`) { + t.Errorf("create %+v", creates) + } + pubs := fake.sent("POST", "/v1/contexts/"+fake.contexts[0].ID+"/versions") + if len(pubs) != 1 || publishBody(t, pubs[0]).BaseVersion != 1 { + t.Fatalf("publishes %+v", pubs) + } + + // A folder that cannot be pushed leaves no empty context behind. + fake = newFakeCtx() + bad := writeTree(t, map[string]string{"CLAUDE.md": "x"}) + r = runCtx(t, fake, ctxSetup{}, "context", "create", "acme-api", "--from", bad) + mustCtxExit(t, r, exitDataErr) + if len(fake.requests()) != 0 { + t.Errorf("sent %v", fake.requests()) + } +} + +func TestContextEditAndRm(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 2, nil) + r := runCtx(t, fake, ctxSetup{}, "context", "edit", "acme-api", "--name", "acme-web", "--description", "") + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "context acme-web updated") + p := fake.sent("PATCH", "/v1/contexts/"+fakeCtxID) + if len(p) != 1 || p[0].Body != `{"name":"acme-web","description":""}` { + t.Errorf("patch %+v", p) + } + mustCtxExit(t, runCtx(t, fake, ctxSetup{}, "context", "edit", "acme-web"), exitUsage, "nothing to change") + mustCtxExit(t, runCtx(t, fake, ctxSetup{}, "context", "edit", "acme-web", "--name", "Bad Name"), exitUsage, "not a valid context name") + + // rm asks, and without a terminal needs --yes. + mustCtxExit(t, runCtx(t, fake, ctxSetup{}, "context", "rm", "acme-web"), exitUsage, "--yes") + fake.queue("DELETE /v1/contexts/"+fakeCtxID, fakeResp{status: 409, body: map[string]any{ + "error": map[string]any{"message": "Automations use this context; remove it from them first.", "type": "invalid_request_error", "param": nil, "code": "context_in_use"}, + "automations": []any{map[string]any{"id": testAutoID, "name": "PR review api"}}}}) + r = runCtx(t, fake, ctxSetup{}, "context", "rm", "acme-web", "--yes") + mustCtxExit(t, r, exitUsage, "automations use this context", "PR review api") + r = runCtx(t, fake, ctxSetup{}, "context", "rm", "acme-web", "--yes") + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "context acme-web deleted") + if len(fake.contexts) != 0 { + t.Error("not deleted") + } +} + +// ─── push: local checks ───────────────────────────────────────────────────── + +func TestContextPushLayoutChecks(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{ + "CLAUDE.md": "Use tabs.\n", + "README.md": "top level\n", + "notes/a.md": "a\n", + "skills/no-skill-md/x.md": "x\n", + "skills/Bad Skill/SKILL.md": skillMD, + "files/rocket\U0001F680.md": "emoji\n", + "files/con.txt": "reserved\n", + "files/ok.md": "ok\n", + "files/.env": "SECRET=1\n", + ".git/config": "[core]\n", + "files/sub/.hidden/inner.md": "hidden\n", + }) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "cannot be published", "nothing was uploaded", "--skip-invalid") + checkGolden(t, "contexts/push_refused.stderr.golden", r.stderr) + if n := len(fake.requests()); n != 0 { + t.Errorf("a refused push sent %d requests: %v", n, fake.requests()) + } +} + +func TestContextPushSkipInvalid(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{"CLAUDE.md": "x\n", "files/ok.md": "ok\n", "files/a:b.md": "colon\n", + "skills/no-skill-md/x.md": "x\n", "files/.env": "S=1\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--skip-invalid") + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "skipped CLAUDE.md: bad_top_level (rename it to AGENTS.md", "skipped files/a:b.md: bad_character", + "skipped skills/no-skill-md/SKILL.md: skill_invalid", "skipped files/.env (dotfiles") + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 1 { + t.Fatalf("publishes %v", fake.requests()) + } + if got := publishPaths(publishBody(t, pubs[0])); strings.Join(got, ",") != "files/ok.md" { + t.Errorf("published %v", got) + } +} + +// macOS hands out decomposed (NFD) names; the platform refuses non-NFC +// paths, so push normalises before hashing and sending. +func TestContextPushNormalisesNFD(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + nfd := "files/especificación.md" + dir := writeTree(t, map[string]string{nfd: "spec\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 1 { + t.Fatalf("%v", fake.requests()) + } + if got := publishPaths(publishBody(t, pubs[0])); len(got) != 1 || got[0] != "files/especificación.md" { + t.Errorf("published %q, want the NFC form", got) + } + + // Two names that are the same once normalised collide (a file system + // that is not normalisation-insensitive can hold both). + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + return + } + dir = writeTree(t, map[string]string{nfd: "a\n", "files/especificación.md": "b\n"}) + r = runCtx(t, newFakeCtx(), ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr) + wantAll(t, "stderr", r.stderr, "case_collision") +} + +func TestContextPushRefusesSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need privileges on Windows") + } + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{"files/a.md": "a\n"}) + if err := os.Symlink("/etc/passwd", filepath.Join(dir, "files", "passwd")); err != nil { + t.Fatal(err) + } + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr) + wantAll(t, "stderr", r.stderr, "refused files/passwd: symlink") +} + +func TestContextPushLimits(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{"AGENTS.md": strings.Repeat("a", 32<<10+1)}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr) + wantAll(t, "stderr", r.stderr, "refused AGENTS.md: too_large (AGENTS.md is at most 32 KiB)") +} + +// ─── push: uploads and publish ────────────────────────────────────────────── + +func TestContextPushDryRun(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 3, map[string]string{"AGENTS.md": "Old rules.\n", "files/old.md": "old\n", "files/same.md": "same\n"}) + dir := writeTree(t, map[string]string{"AGENTS.md": "Use tabs.\n", "files/same.md": "same\n", "files/new.md": "new file\n", + "skills/pr-review/SKILL.md": skillMD}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--dry-run") + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/push_dry_run.golden", r.stdout) + + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--dry-run", "--json") + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/push_dry_run.json.golden", r.stdout) + + for _, req := range fake.requests() { + if req.Method != http.MethodGet && !(req.Method == http.MethodPost && req.Path == "/v1/contexts/blobs/check") { + t.Errorf("a dry run sent %s %s", req.Method, req.Path) + } + } +} + +func TestContextPushUploadsOnlyMissingBlobs(t *testing.T) { + fake := newFakeCtx() + c := fake.seed("acme-api", 2, map[string]string{"files/kept.md": "kept\n"}) + tree := goodTree() + tree["files/kept.md"] = "kept\n" + tree["files/copy-of-agents.md"] = tree["AGENTS.md"] // same blob, two paths + dir := writeTree(t, tree) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/push.golden", r.stdout) + + puts := fake.sent("PUT", "/v1/contexts/blobs/") + if len(puts) != 4 { // AGENTS.md (once for two paths), SKILL.md, run.sh, the spec + t.Errorf("uploaded %d blobs, want 4: %+v", len(puts), puts) + } + for _, p := range puts { + if p.Path == "/v1/contexts/blobs/"+shaOf([]byte("kept\n")) { + t.Error("uploaded a blob the platform already had") + } + if p.Filename == "" || strings.Contains(p.Filename, "/") { + t.Errorf("X-Nan-Filename %q: the base name is the type hint", p.Filename) + } + } + pubs := fake.sent("POST", "/v1/contexts/"+c.ID+"/versions") + if len(pubs) != 1 { + t.Fatalf("publishes %d", len(pubs)) + } + in := publishBody(t, pubs[0]) + if in.BaseVersion != 2 || in.AllowSecretLike || len(in.Files) != 6 || !idemKeyPattern.MatchString(pubs[0].Idem) { + t.Errorf("publish %+v idem %q", in, pubs[0].Idem) + } + for _, f := range in.Files { + if f.Executable != (f.Path == "skills/pr-review/scripts/run.sh") && runtime.GOOS != "windows" { + t.Errorf("%s executable=%v", f.Path, f.Executable) + } + } + + // Pushing the same folder again changes nothing. + n := len(fake.requests()) + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "is up to date (version 3)") + if extra := fake.requests()[n:]; len(extra) != 1 || extra[0].Method != http.MethodGet { + t.Errorf("an up-to-date push sent %v", extra) + } +} + +func TestContextPushCreate(t *testing.T) { + fake := newFakeCtx() + dir := writeTree(t, map[string]string{"files/a.md": "a\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitUsage, "nan context create acme-api", "--create") + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--create") + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "context acme-api created") + if len(fake.contexts) != 1 || fake.contexts[0].Version != 2 { + t.Errorf("%+v", fake.contexts) + } +} + +// A blob over the single-PUT limit goes up in parts; a part that fails is +// resumed from where the platform says the upload stands. +func TestContextPushMultipartResumesAfterAFailedPart(t *testing.T) { + swap(t, &ctxSingleMax, int64(8)) + fake := newFakeCtx() + fake.partSize = 4 + fake.failPart[2] = true + fake.seed("acme-api", 1, nil) + big := "0123456789" // 3 parts of 4, 4, 2 bytes + dir := writeTree(t, map[string]string{"files/big.csv": big, "files/small.md": "small\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "part 2 of files/big.csv did not go through; resuming at part 2") + sha := shaOf([]byte(big)) + var parts []string + for _, q := range fake.sent("PUT", "/v1/contexts/blobs/"+sha+"/uploads/") { + parts = append(parts, q.Path[strings.LastIndex(q.Path, "/")+1:]) + } + if strings.Join(parts, ",") != "1,2,2,3" { + t.Errorf("parts sent %v", parts) + } + if string(fake.blobs[sha]) != big { + t.Errorf("stored %q", fake.blobs[sha]) + } + if starts := fake.sent("POST", "/v1/contexts/blobs/"+sha+"/uploads"); len(starts) != 2 { // start + complete + t.Errorf("start/complete %v", starts) + } + if _, err := os.Stat(fake.stateFile(t, r.stateDir, sha)); !os.IsNotExist(err) { + t.Errorf("upload state left behind: %v", err) + } +} + +// An upload a previous push left unfinished is resumed: no new upload, the +// stored parts are not sent again. +func TestContextPushMultipartResumesAcrossRuns(t *testing.T) { + swap(t, &ctxSingleMax, int64(8)) + fake := newFakeCtx() + fake.partSize = 4 + fake.seed("acme-api", 1, nil) + big := "0123456789" + sha := shaOf([]byte(big)) + const id = "u0000000-0000-4000-8000-000000000042" + fake.uploads[id] = &fakeUpload{sha: sha, size: int64(len(big)), partSize: 4, next: 2, data: []byte("0123")} + state := t.TempDir() + writeState(t, fake.stateFile(t, state, sha), id, 10) + dir := writeTree(t, map[string]string{"files/big.csv": big}) + r := runCtx(t, fake, ctxSetup{stateDir: state}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "resuming the upload of files/big.csv at part 2 of 3") + if starts := fake.sent("POST", "/v1/contexts/blobs/"+sha+"/uploads"); len(starts) != 1 || !strings.HasSuffix(starts[0].Path, "/complete") { + t.Errorf("a new upload was started: %v", starts) + } + var parts []string + for _, q := range fake.sent("PUT", "/v1/contexts/blobs/"+sha+"/uploads/"+id+"/parts/") { + parts = append(parts, q.Path[strings.LastIndex(q.Path, "/")+1:]) + } + if strings.Join(parts, ",") != "2,3" { + t.Errorf("parts sent %v", parts) + } + if string(fake.blobs[sha]) != big { + t.Errorf("stored %q", fake.blobs[sha]) + } + + // A remembered upload the platform no longer has starts over. + fake2 := newFakeCtx() + fake2.partSize = 4 + fake2.seed("acme-api", 1, nil) + writeState(t, fake2.stateFile(t, state, sha), id, 10) + r = runCtx(t, fake2, ctxSetup{stateDir: state}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + if string(fake2.blobs[sha]) != big { + t.Errorf("stored %q", fake2.blobs[sha]) + } +} + +func writeState(t *testing.T, path, id string, size int) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(fmt.Sprintf(`{"upload_id":%q,"size":%d}`, id, size)), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestContextPushVersionConflict(t *testing.T) { + newFake := func() *fakeCtx { + fake := newFakeCtx() + fake.seed("acme-api", 2, map[string]string{"files/a.md": "a\n"}) + // Someone publishes version 3 between this push's read and its + // publish. + fake.onPublish = func(f *fakeCtx, n int) { + if n == 1 { + c := f.contexts[0] + c.Version = 3 + c.Files = append(c.Files, api.ContextFile{Path: "files/theirs.md", SHA256: shaOf([]byte("a\n")), Size: 2}) + } + } + return fake + } + dir := writeTree(t, map[string]string{"files/a.md": "a\n", "files/mine.md": "mine\n"}) + + fake := newFake() + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitFailed, "changed while pushing", "now at version 3", "based on version 2", "--force") + if fake.contexts[0].Version != 3 { + t.Error("published anyway") + } + + fake = newFake() + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--force") + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "changed to version 3 meanwhile", "- files/theirs.md") + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 2 || publishBody(t, pubs[1]).BaseVersion != 3 || pubs[0].Idem == pubs[1].Idem { + t.Errorf("publishes %+v", pubs) + } + if fake.contexts[0].Version != 4 { + t.Errorf("version %d", fake.contexts[0].Version) + } +} + +func TestContextPushSecretLikeContent(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{"files/setup.md": "token: " + fakeSecretPat + "abcdef\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "look like they hold credentials", "nan secrets", "--allow-secrets") + wantAll(t, "stderr", r.stderr, "files/setup.md:1 looks like github_token") + if strings.Contains(r.stderr+r.stdout+r.msg, "abcdef") { + t.Error("the secret was printed") + } + + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--allow-secrets") + mustCtxExit(t, r, 0) + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if !publishBody(t, pubs[len(pubs)-1]).AllowSecretLike { + t.Error("allow_secret_like not sent") + } +} + +// The platform's cleanup can remove an unreferenced blob between the check +// and the publish: push checks again, uploads it again and publishes. +func TestContextPushBlobMissingReuploads(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{"files/a.md": "a\n", "files/b.md": "b\n"}) + gone := shaOf([]byte("b\n")) + fake.onPublish = func(f *fakeCtx, n int) { + if n == 1 { + delete(f.blobs, gone) + } + } + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "1 file is no longer stored on nan.builders; uploading again") + if puts := fake.sent("PUT", "/v1/contexts/blobs/"+gone); len(puts) != 2 { + t.Errorf("uploads of the missing blob: %d", len(puts)) + } + // It asks again, for exactly the blobs the publish named, and sends + // only those: the other file goes up once. + checks := fake.sent("POST", "/v1/contexts/blobs/check") + if len(checks) != 2 || checks[1].Body != `{"sha256":["`+gone+`"]}` { + t.Errorf("checks %+v", checks) + } + if puts := fake.sent("PUT", "/v1/contexts/blobs/"+shaOf([]byte("a\n"))); len(puts) != 1 { + t.Errorf("uploads of the file still stored: %d", len(puts)) + } + if pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions"); len(pubs) != 2 { + t.Errorf("publishes %d", len(pubs)) + } +} + +func TestContextPushWaitsOutRetryAfter(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + fake.queue("POST /v1/contexts/"+fakeCtxID+"/versions", fakeResp{status: 429, header: map[string]string{"Retry-After": "7"}, + body: map[string]any{"error": map[string]any{"message": "Publish limit reached (120 attempts per hour); try again later.", + "type": "rate_limit_exceeded", "param": nil, "code": "rate_limited"}}}) + dir := writeTree(t, map[string]string{"files/a.md": "a\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + if len(r.sleeps) != 1 || r.sleeps[0] != 7*time.Second { + t.Errorf("sleeps %v", r.sleeps) + } + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 2 || pubs[0].Idem != pubs[1].Idem { + t.Errorf("a retried publish keeps its Idempotency-Key: %+v", pubs) + } + + // A wait longer than a push waits by itself is reported, not slept. + fake = newFakeCtx() + fake.seed("acme-api", 1, nil) + fake.queue("POST /v1/contexts/"+fakeCtxID+"/versions", fakeResp{status: 429, header: map[string]string{"Retry-After": "3600"}, + body: map[string]any{"error": map[string]any{"message": "Publish limit reached.", "type": "rate_limit_exceeded", "param": nil, "code": "rate_limited"}}}) + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitUnavailable, "try again in 1h0m0s") + if len(r.sleeps) != 0 { + t.Errorf("slept %v", r.sleeps) + } +} + +func TestContextPushServerRefusalsArePerFile(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + fake.queue("POST /v1/contexts/"+fakeCtxID+"/versions", fakeResp{status: 415, body: map[string]any{ + "error": map[string]any{"message": "Some files are not allowed at their path.", "type": "invalid_request_error", "param": nil, "code": "unsupported_file_type"}, + "files": []any{map[string]any{"path": "files/a.md", "reason": "unsupported_file_type"}}}}) + dir := writeTree(t, map[string]string{"files/a.md": "a\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "not allowed at their path") + wantAll(t, "stderr", r.stderr, "files/a.md: unsupported_file_type") + + fake = newFakeCtx() + fake.seed("acme-api", 1, nil) + fake.queue("POST /v1/contexts/blobs/check", fakeResp{status: 413, body: map[string]any{ + "error": map[string]any{"message": "This upload does not fit in your plan's context storage.", "type": "invalid_request_error", "param": nil, "code": "context_quota_exceeded"}, + "used": 500 << 20, "quota": 500 << 20}}) + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "500.0 MiB of 500.0 MiB used") +} + +// ─── pull ─────────────────────────────────────────────────────────────────── + +func TestContextPullWritesVerifiedFiles(t *testing.T) { + fake := newFakeCtx() + files := goodTree() + fake.seed("acme-api", 3, files) + dir := filepath.Join(t.TempDir(), "out") + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir) + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/pull.golden", strings.ReplaceAll(r.stdout, dir, "DIR")) + for p, want := range files { + got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p))) + if err != nil || string(got) != want { + t.Errorf("%s: %q %v", p, got, err) + } + } + if runtime.GOOS != "windows" { + st, _ := os.Stat(filepath.Join(dir, "skills", "pr-review", "scripts", "run.sh")) + if st == nil || st.Mode().Perm()&0o100 == 0 { + t.Errorf("run.sh is not executable: %v", st) + } + } + for _, q := range fake.sent("GET", "/v1/contexts/"+fakeCtxID+"/file") { + if !strings.Contains(q.Query, "version=3") { + t.Errorf("a download is not pinned to the version read: %s", q.Query) + } + } + + // Round trip: what was pulled pushes as no change. + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "is up to date (version 3)") +} + +func TestContextPullRefusesANonEmptyFolder(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, map[string]string{"files/a.md": "new\n"}) + dir := writeTree(t, map[string]string{"files/a.md": "mine\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir) + mustCtxExit(t, r, exitUsage, "is not empty", "--force") + if len(fake.requests()) != 0 { + t.Errorf("sent %v", fake.requests()) + } + if b, _ := os.ReadFile(filepath.Join(dir, "files", "a.md")); string(b) != "mine\n" { + t.Error("overwrote a file") + } + r = runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir, "--force") + mustCtxExit(t, r, 0) + if b, _ := os.ReadFile(filepath.Join(dir, "files", "a.md")); string(b) != "new\n" { + t.Errorf("--force did not write: %q", b) + } +} + +// A platform (or anything answering in its place) that sends a path the +// validator refuses gets nothing written, inside or outside DIR. +func TestContextPullRefusesHostilePaths(t *testing.T) { + for name, paths := range map[string][]string{ + "dotdot": {"files/../../evil.md"}, + "absolute": {"/tmp/evil.md"}, + "top dotdot": {"../evil.md"}, + "backslash": {"files\\..\\..\\evil.md"}, + "case collision": {"files/Readme.md", "files/README.md"}, + "file vs folder": {"files/docs", "files/Docs/a.md"}, + "reserved": {"files/CON"}, + "reserved ext": {"files/nul.txt"}, + "dotfile": {"files/.bashrc"}, + "not nfc": {"files/especificación.md"}, + "bad top": {"evil.md"}, + } { + t.Run(name, func(t *testing.T) { + fake := newFakeCtx() + c := fake.seed("acme-api", 1, nil) + for _, p := range paths { + fake.blobs[shaOf([]byte("x"))] = []byte("x") + c.Files = append(c.Files, api.ContextFile{Path: p, SHA256: shaOf([]byte("x")), Size: 1}) + } + parent := t.TempDir() + dir := filepath.Join(parent, "out") + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "nothing was written") + if len(fake.sent("GET", "/v1/contexts/"+fakeCtxID+"/file")) != 0 { + t.Error("downloaded a file") + } + entries, _ := os.ReadDir(parent) + if len(entries) != 0 { + t.Errorf("wrote %v", entries) + } + }) + } +} + +func TestContextPullRefusesBadBytes(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, map[string]string{"files/a.md": "good\n", "files/b.md": "fine\n"}) + fake.corrupt["files/b.md"] = true + dir := filepath.Join(t.TempDir(), "out") + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir) + mustCtxExit(t, r, exitUnavailable, "does not match its sha256") + if _, err := os.Stat(filepath.Join(dir, "files", "b.md")); !os.IsNotExist(err) { + t.Errorf("kept a bad file: %v", err) + } +} + +func TestContextHelp(t *testing.T) { + var all strings.Builder + all.WriteString(contextCmd.Long) + for _, c := range contextCmd.Commands() { + all.WriteString(c.Long + c.Short + c.Example) + if c.InheritedFlags().Lookup("token-file") == nil { + t.Errorf("nan context %s has no --token-file", c.Name()) + } + } + for _, want := range []string{"contexts:read", "contexts:write", contextsDocs, "AGENTS.md", "--allow-secrets", "--skip-invalid", "65"} { + if !strings.Contains(all.String(), want) { + t.Errorf("nan context help lacks %q", want) + } + } + if strings.Contains(all.String(), "—") { + t.Error("an em-dash in the context help") + } + for verb, want := range map[string]string{"list": "ls", "upload": "push", "delete": "rm", "frobnicate": "unknown command"} { + r := runCtx(t, newFakeCtx(), ctxSetup{}, "context", verb) + mustCtxExit(t, r, exitUsage, want) + } +} diff --git a/cmd/ephemeral.go b/cmd/ephemeral.go new file mode 100644 index 0000000..a8d6ee6 --- /dev/null +++ b/cmd/ephemeral.go @@ -0,0 +1,734 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "strconv" + "strings" + "text/tabwriter" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/runs" + "github.com/nxssie/nan-cli/internal/session" + "github.com/spf13/cobra" +) + +// Ephemeral runs (helmcode/nan-devops AGENT-EPHEMERAL-EXECUTORS.md): an +// agent in a fresh, throwaway container instead of a workspace. It clones +// the GitHub repository the run names, runs the agent with the model the +// run names, streams like any run and is destroyed at the end. Billed per +// started minute by container size, with free minutes every month. + +// ephemeralDocs is where ephemeral runs are explained. +const ephemeralDocs = "https://nan.builders/docs/ephemeral-runs" + +// ephemeralSettingsURL is where the member changes their ephemeral limits: +// writing them needs a browser sign-in. +const ephemeralSettingsURL = runs.PortalURL + "/settings" + +// freeMinutesHelp is how free minutes count, said wherever they are shown +// (owner request). +const freeMinutesHelp = "Free minutes are S-minutes: one M minute uses 2, one L minute 4\n(100 free minutes = 100 minutes of S, 50 of M or 25 of L)" + +// freeMinutesLine is freeMinutesHelp on one line, for an error message. +var freeMinutesLine = strings.ReplaceAll(freeMinutesHelp, "\n", " ") + +// membershipHelp and paymentHelp are the owner's rules: free minutes come +// with a membership, and every ephemeral run needs a payment method. +const ( + membershipHelp = "Free minutes come with a membership (accounts without one have none)" + paymentHelp = "Ephemeral runs need a payment method on file, free minutes included\n(add one in the browser: Settings → Ephemeral runs)" +) + +// paymentMethodRequired and currencyMigrationRequired are what to do about +// the two billing refusals (402) of an ephemeral run. +const ( + paymentMethodRequired = `ephemeral runs need a payment method on file, free minutes included: use "Add a payment method" ` + + "in the portal, Settings → Ephemeral runs at " + ephemeralSettingsURL + currencyMigrationRequired = `your subscription bills in USD; ephemeral runs bill in EUR: use "Switch billing to EUR" ` + + "in the portal, Settings → Ephemeral runs at " + ephemeralSettingsURL + " (also offered in the New run dialog)" +) + +// notChargedNote is what a cost means while billing is off. +const notChargedNote = "not charged yet: billing is not active, usage is metered and shown only" + +// ephemeralScope is the platform-token scope that may start ephemeral work. +const ephemeralScope = "runs:ephemeral" + +// ephemeralSizes are the container sizes, smallest first, with what each +// one is. Prices are the platform's (nan ephemeral usage shows the current +// ones); the help quotes them too. +var ephemeralSizes = []struct { + name, spec string + sMinutes int // S-minutes one minute of this size uses +}{ + {api.SizeS, "1 vCPU, 4 GiB RAM, 10 GB disk", 1}, + {api.SizeM, "2 vCPU, 8 GiB RAM, 16 GB disk", 2}, + {api.SizeL, "4 vCPU, 12 GiB RAM, 20 GB disk", 4}, +} + +func sizeSpec(size string) string { + for _, s := range ephemeralSizes { + if s.name == size { + return s.spec + } + } + return "" +} + +// sizeLabel is a container size as people read it: "M (2 vCPU, ...)". +func sizeLabel(size *string) string { + if size == nil || *size == "" { + return "" + } + label := strings.ToUpper(runs.SanitizeLine(*size)) + if spec := sizeSpec(*size); spec != "" { + label += " (" + spec + ")" + } + return label +} + +// sizeShort is a container size for a table: "M". +func sizeShort(size *string) string { + if size == nil || *size == "" { + return "?" + } + return strings.ToUpper(runs.SanitizeLine(*size)) +} + +// formatEUR prints an amount in micros (millionths of a euro) with two +// decimals, or more when the amount needs them: €0.018, €1.50, €0.00. +func formatEUR(micros int64) string { + sign := "" + if micros < 0 { + sign, micros = "-", -micros + } + frac := strconv.FormatInt(micros%1_000_000+1_000_000, 10)[1:] // six digits + frac = strings.TrimRight(frac, "0") + for len(frac) < 2 { + frac += "0" + } + return sign + "€" + strconv.FormatInt(micros/1_000_000, 10) + "." + frac +} + +// formatCents prints an amount in euro cents: €100.00. +func formatCents(cents int) string { return formatEUR(int64(cents) * 10_000) } + +// startedMinutes is a billed duration in started minutes. +func startedMinutes(seconds int64) int64 { return (seconds + 59) / 60 } + +// runCostLabel is an ephemeral run's metered cost, for show. +func runCostLabel(run *api.Run) string { + c := run.Cost + if c == nil { + if api.Terminal(run.State) { + return "not metered yet: check again in a minute" + } + return "metered when the run ends" + } + label := formatEUR(c.EURMicros) + " for " + plural(int(startedMinutes(c.BilledSeconds)), "started minute") + if run.Size != nil && *run.Size != "" { + label += " of size " + sizeShort(run.Size) + } + return label + ", before your free minutes (nan ephemeral usage)" +} + +// runCostShort is an ephemeral run's cost, for a table. +func runCostShort(run *api.Run) string { + switch { + case !run.Ephemeral(): + return "-" + case run.Cost == nil: + return "pending" + } + return formatEUR(run.Cost.EURMicros) +} + +// runWhere is where a run executes, for a table. +func runWhere(run *api.Run) string { + if run.Ephemeral() { + return "ephemeral " + sizeShort(run.Size) + } + return runs.Truncate(runs.SanitizeLine(refLabel(run.Workspace.Name, run.Workspace.ID)), 40) +} + +// githubRepoPattern is the platform's owner/name grammar. +var githubRepoPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}$`) + +func validGitHubRepo(r string) bool { + if !githubRepoPattern.MatchString(r) { + return false + } + owner, name, _ := strings.Cut(r, "/") + for _, h := range []string{owner, name} { + if h == "." || h == ".." { + return false + } + } + return true +} + +// repoFromURL turns a GitHub URL pasted into --repo into owner/name, when +// it is one. +func repoFromURL(v string) (string, bool) { + for _, p := range []string{"https://github.com/", "http://github.com/", "github.com/", "git@github.com:"} { + if rest, ok := strings.CutPrefix(v, p); ok { + rest = strings.TrimSuffix(strings.TrimSuffix(rest, "/"), ".git") + if validGitHubRepo(rest) { + return rest, true + } + } + } + return "", false +} + +// ephemeralUnavailable is the answer when the member does not have +// ephemeral runs: the platform does not say why, on purpose. +func ephemeralUnavailable() *ExitError { + return exitf(exitAuth, "ephemeral runs are not available for your account") +} + +// ephemeralExit maps the platform's ephemeral refusals onto what to do +// about them. nil: not one of them. +// +// - 400 invalid_request on executor: the member does not have ephemeral +// runs (the platform answers exactly as for an unknown executor, so +// nobody outside can tell the executor exists). +// - 403 token_scope_denied on executor: a credential that may not start +// billable work (an sk- key, or a token without runs:ephemeral). +// - 402 ephemeral_cap_reached: the month's spend plus the worst case of +// the runs going on could pass the member's cap. +// - 402 payment_method_required: no payment method on file. +// - 402 currency_migration_required: the subscription bills in USD. +// +// Any other 402 is left to the usual mapping. +func ephemeralExit(apiErr *api.APIError, cred credential) *ExitError { + switch { + case apiErr.Code == "payment_method_required": + return exitf(exitUnavailable, "%s", paymentMethodRequired) + case apiErr.Code == "currency_migration_required": + return exitf(exitUnavailable, "%s", currencyMigrationRequired) + case apiErr.Code == "ephemeral_cap_reached": + return exitf(exitUnavailable, "this run could take your ephemeral spend past your monthly cap: raise the cap in the portal, "+ + "Settings at %s (signed in through the browser), or wait for your ephemeral runs going on to finish. "+ + "See this month's spend with: nan ephemeral usage", ephemeralSettingsURL) + case apiErr.Code == "token_scope_denied" && apiErr.Param == "executor": + return exitf(exitAuth, "ephemeral runs cost money, so they need your session (nan auth login) or a platform token "+ + "with the %s scope; API keys (sk-) cannot start them. %s", ephemeralScope, ephemeralScopeHint(cred)) + case apiErr.Status == 400 && apiErr.Param == "executor": + return ephemeralUnavailable() + } + return nil +} + +// ephemeralScopeHint is what to do about the credential an ephemeral run +// was refused for. +func ephemeralScopeHint(cred credential) string { + switch cred.kind { + case credEnv, credFile, credStoredToken: + return fmt.Sprintf("%s does not have it: create a token with %s in Settings > Tokens at %s, signed in through the browser "+ + "(existing tokens keep the scopes they were created with)", cred.describe(), ephemeralScope, runs.PortalURL) + case credStoredKey: + return cred.describe() + " is an API key: sign in with nan auth login" + } + return "Sign in again with: nan auth login" +} + +// ─── automation files ────────────────────────────────────────────────────── + +// checkAutomationExecutor checks the executor fields of an automation file +// before it is sent, so a mistake costs a clear message instead of a round +// trip: executor is workspace or ephemeral, size is s, m or l and only for +// ephemeral, and (on create, where the file is the whole automation) an +// ephemeral automation has no workspace, a model, and either a fresh +// checkout of a github_repo or checkout "none" with at least one agent +// context. contexts, when present, is at most 5 context names or ids. +// An edit holds only the fields that change: the platform checks the +// result. +func checkAutomationExecutor(body json.RawMessage, create bool) error { + var f map[string]json.RawMessage + if err := json.Unmarshal(body, &f); err != nil { + return nil // not an object: the platform says so + } + str := func(k string) (string, bool, bool) { + raw, ok := f[k] + if !ok || string(raw) == "null" { + return "", ok, true + } + var s string + if json.Unmarshal(raw, &s) != nil { + return "", true, false + } + return s, true, true + } + executor, hasExecutor, ok := str("executor") + if !ok || (hasExecutor && executor != api.ExecutorWorkspace && executor != api.ExecutorEphemeral) { + return usageErrorf(`executor must be "workspace" (the default: runs in one of your workspaces) or "ephemeral" ` + + `(runs in a fresh container, no workspace); see: nan run --help`) + } + size, hasSize, ok := str("size") + if !ok || (hasSize && size != "" && size != api.SizeS && size != api.SizeM && size != api.SizeL) { + return usageErrorf(`size must be "s", "m" or "l" (the container of an ephemeral automation: %s); %s`, + sizeList(), freeMinutesLine) + } + if hasSize && size != "" && hasExecutor && executor == api.ExecutorWorkspace { + return usageErrorf(`size is for ephemeral automations only: set executor: ephemeral, or remove size`) + } + ctxCount, err := checkBodyContexts(f["contexts"]) + if err != nil { + return err + } + checkout, _, _ := str("checkout") + repo, _, _ := str("github_repo") + if checkout == "none" { + switch { + case repo != "": + return usageErrorf(`checkout "none" is an automation without a repository: remove github_repo (or set it to null), or use checkout: fresh`) + case executor == api.ExecutorWorkspace || (create && executor == ""): + return usageErrorf(`checkout "none" is only for ephemeral automations (executor: ephemeral): a workspace automation works in its workspace`) + case create && ctxCount == 0: + return usageErrorf(`checkout "none" (no repository) needs at least one agent context: add --context NAME or contexts: [NAME]`) + } + } + if !create || executor != api.ExecutorEphemeral { + return nil + } + if ws, has, _ := str("workspace"); has && ws != "" { + return usageErrorf("an ephemeral automation has no workspace: remove workspace (it runs in a fresh container)") + } + switch checkout { + case "none": + case "fresh": + if repo == "" { + return usageErrorf(`an ephemeral automation needs github_repo: OWNER/NAME, the repository each run clones`) + } + default: + return usageErrorf(`an ephemeral automation works on a fresh checkout: set checkout: fresh and github_repo: OWNER/NAME; ` + + `or, without a repository, on its agent contexts: checkout: none and --context NAME`) + } + if cwd, has, _ := str("cwd"); has && cwd != "" { + return usageErrorf("an ephemeral automation works in its fresh checkout: remove cwd") + } + model, _, _ := str("model") + if model == "" { + var inf struct { + Model string `json:"model"` + } + _ = json.Unmarshal(f["inference"], &inf) + model = inf.Model + } + if model == "" { + return usageErrorf("an ephemeral automation must name its model (model: ID): a container has no workspace configuration to default to") + } + return nil +} + +// checkBodyContexts checks an automation file's contexts field: a list of +// at most 5 context names or ids. It returns how many there are. +func checkBodyContexts(raw json.RawMessage) (int, error) { + if raw == nil || string(raw) == "null" { + return 0, nil + } + var refs []string + if err := json.Unmarshal(raw, &refs); err != nil { + return 0, usageErrorf("contexts must be a list of context names or ids, like contexts: [acme-api]") + } + return len(refs), checkContextRefs(refs, "contexts:") +} + +func sizeList() string { + parts := make([]string, len(ephemeralSizes)) + for i, s := range ephemeralSizes { + parts[i] = s.name + " " + s.spec + } + return strings.Join(parts, "; ") +} + +// ─── nan ephemeral ───────────────────────────────────────────────────────── + +var ( + ephemeralUsageJSON bool + ephemeralSettingsJSON bool +) + +const ephemeralCredentialHelp = `These commands need your session (nan auth login): platform tokens and +API keys cannot read them.` + +var ephemeralCmd = &cobra.Command{ + Use: "ephemeral", + Short: "Your ephemeral runs: this month's minutes and cost, and your limits", + Long: `Ephemeral runs execute an agent in a fresh, throwaway container instead of +a workspace: start one with nan run --ephemeral --repo OWNER/NAME --model ID. + +Sizes, billed per started minute (a 14 s run is 1 minute): +` + sizeTable(" ") + ` +Free minutes every calendar month: Community 100, Inference 300, Premium +1000. ` + membershipHelp + `. +` + freeMinutesHelp + `. +` + paymentHelp + `. +While billing is not active, usage is metered and shown, not charged: +nan ephemeral usage says which. + +Your limits, a monthly spend cap (default €100) and how many ephemeral runs +may go on at once (default 50), are changed in the browser only: Settings +at ` + ephemeralSettingsURL + `. + +` + ephemeralCredentialHelp + ` + +Docs: ` + ephemeralDocs, + Annotations: sysexits, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return usageErrorf("unknown command %q for \"nan ephemeral\"; try: nan ephemeral --help", runs.Truncate(runs.SanitizeLine(args[0]), 64)) + } + return cmd.Help() + }, +} + +var ephemeralUsageCmd = &cobra.Command{ + Use: "usage", + Short: "This month's ephemeral minutes, cost and free minutes left", + Long: `This calendar month's ephemeral runs: container minutes, cost after your +free minutes, the free minutes left, your monthly cap and the prices. + +` + freeMinutesHelp + `. +` + membershipHelp + `. +` + paymentHelp + `. +While billing is not active, usage is metered and shown, not charged. + +` + ephemeralCredentialHelp + ` + +Docs: ` + ephemeralDocs, + Args: cobra.NoArgs, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withEphemeralEnv(func(env *runsEnv) error { return doEphemeralUsage(cmd.Context(), env) }) + }, +} + +var ephemeralSettingsCmd = &cobra.Command{ + Use: "settings", + Short: "Your ephemeral limits: monthly spend cap and concurrent runs (read only)", + Long: `Your ephemeral limits: the monthly spend cap (€0 to €1000, default €100) +and how many ephemeral runs may go on at once (1 to 50, default 50). + +They are read only here: change them in the browser, Settings at +` + ephemeralSettingsURL + `, +signed in through the browser, so that nothing that can read this +machine's CLI session (an agent, a CI job) can raise what you spend. + +` + ephemeralCredentialHelp + ` + +Docs: ` + ephemeralDocs, + Args: cobra.NoArgs, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withEphemeralEnv(func(env *runsEnv) error { return doEphemeralSettings(cmd.Context(), env) }) + }, +} + +func init() { + rootCmd.AddCommand(ephemeralCmd) + ephemeralCmd.AddCommand(ephemeralUsageCmd, ephemeralSettingsCmd) + ephemeralUsageCmd.Flags().BoolVar(&ephemeralUsageJSON, "json", false, "Print the API's usage object as JSON") + ephemeralSettingsCmd.Flags().BoolVar(&ephemeralSettingsJSON, "json", false, "Print the API's settings object as JSON") +} + +// sizeTable is the sizes and their prices, for help text, each line +// starting with indent. +func sizeTable(indent string) string { + prices := map[string]string{api.SizeS: "€0.003", api.SizeM: "€0.006", api.SizeL: "€0.012"} + var b strings.Builder + for _, s := range ephemeralSizes { + fmt.Fprintf(&b, "%s%-2s %-32s %s per minute\n", indent, s.name, s.spec, prices[s.name]) + } + return b.String() +} + +// ephemeralSession is the credential of nan ephemeral: the session from +// nan auth login, and nothing else (the platform takes no token here). +func ephemeralSession(getenv func(string) string, tokenFile string) (credential, error) { + const why = "nan ephemeral needs your session: sign in with nan auth login (platform tokens and API keys cannot read ephemeral usage or settings)" + if tokenFile != "" { + return credential{}, exitf(exitAuth, "--token-file was given, and %s", why) + } + sess, err := session.Load() + if err != nil && !errors.Is(err, session.ErrNotLoggedIn) { + return credential{}, exitf(exitAuth, "could not read your session (%v): %s", err, why) + } + if sess == nil || sess.Token == "" { + if getenv(tokenEnvVar) != "" { + return credential{}, exitf(exitAuth, "%s is set, and %s", tokenEnvVar, why) + } + return credential{}, exitf(exitAuth, "%s", why) + } + return credential{kind: credSession, session: sess.Token}, nil +} + +func withEphemeralEnv(fn func(*runsEnv) error) error { + cred, err := ephemeralSession(os.Getenv, tokenFileFlag) + if err != nil { + return err + } + return runtimeError(fn(&runsEnv{client: cred.client(), cred: cred, stdout: writeStdout, stderr: writeStderr})) +} + +// ephemeralReadExit maps a refused read of /api/ephemeral. +func ephemeralReadExit(err error, cred credential) *ExitError { + var apiErr *api.APIError + if errors.As(err, &apiErr) { + switch apiErr.Status { + case 404: + return ephemeralUnavailable() + case 403: + return exitf(exitAuth, "nan.builders did not take %s for this: sign in with nan auth login", cred.describe()) + } + } + return apiExit(err, cred) +} + +func doEphemeralUsage(ctx context.Context, env *runsEnv) error { + u, err := env.client.EphemeralUsage(ctx) + if err != nil { + return ephemeralReadExit(err, env.cred) + } + if ephemeralUsageJSON { + return printJSON(env.stdout, u, true) + } + priceS := u.PricesEURMicrosPerMinute[api.SizeS] + sMinutes := func(micros int64) int64 { + if priceS <= 0 { + return 0 + } + return micros / priceS + } + + month := runs.SanitizeLine(u.Month) + if u.Plan != "" { + month += " (" + runs.SanitizeLine(u.Plan) + " plan)" + } + fmt.Fprintf(env.stdout, "ephemeral runs, %s\n\n", month) + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "container time\t%s\n", plural(int(u.Minutes), "minute")) + switch { + case u.FreeEURMicrosTotal == 0: + fmt.Fprintf(tw, "free minutes\tnone: free minutes come with a membership (%s/membership)\n", runs.PortalURL) + case priceS > 0: + total, used := sMinutes(u.FreeEURMicrosTotal), sMinutes(u.FreeEURMicrosUsed) + fmt.Fprintf(tw, "free minutes left\t%d S-minutes of %d (= %d M or %d L minutes); %d used\n", + u.FreeMinutesLeft, total, u.FreeMinutesLeft/2, u.FreeMinutesLeft/4, used) + } + cost := formatEUR(u.CostEURMicros) + " after your free minutes" + if !u.BillingEnabled { + cost += " (" + notChargedNote + ")" + } + fmt.Fprintf(tw, "cost this month\t%s\n", cost) + fmt.Fprintf(tw, "monthly cap\t%s (change it in the browser, Settings at %s)\n", formatCents(u.MonthlyCapEURCents), ephemeralSettingsURL) + if err := tw.Flush(); err != nil { + return err + } + + fmt.Fprintln(env.stdout, "\nprices per started minute (a 14 s run is 1 minute):") + tw = tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + for _, s := range ephemeralSizes { + price, ok := u.PricesEURMicrosPerMinute[s.name] + if !ok { + continue + } + fmt.Fprintf(tw, " %s\t%s\t%s\ta minute uses %s\n", strings.ToUpper(s.name), s.spec, formatEUR(price), + plural(s.sMinutes, "free S-minute")) + } + if err := tw.Flush(); err != nil { + return err + } + fmt.Fprintf(env.stdout, "\n%s.\n", freeMinutesHelp) + if u.PaymentMethodRequired || u.CurrencyMigrationRequired { + fmt.Fprintln(env.stdout, "\nbefore your next ephemeral run:") + } + if u.PaymentMethodRequired { + fmt.Fprintf(env.stdout, " %s\n", paymentMethodRequired) + } + if u.CurrencyMigrationRequired { + fmt.Fprintf(env.stdout, " %s\n", currencyMigrationRequired) + } + return nil +} + +func doEphemeralSettings(ctx context.Context, env *runsEnv) error { + s, err := env.client.EphemeralSettings(ctx) + if err != nil { + return ephemeralReadExit(err, env.cred) + } + if ephemeralSettingsJSON { + return printJSON(env.stdout, s, true) + } + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "monthly spend cap\t%s (%s to %s)\n", formatCents(s.MonthlyCapEURCents), formatCents(0), formatCents(s.Limits.MonthlyCapEURCentsMax)) + fmt.Fprintf(tw, "concurrent runs\tup to %d at once (1 to %d)\n", s.MaxConcurrent, s.Limits.MaxConcurrentMax) + billing := "active: ephemeral usage past your free minutes is charged" + if !s.BillingEnabled { + billing = "not active yet: usage is metered and shown, not charged" + } + fmt.Fprintf(tw, "billing\t%s\n", billing) + if err := tw.Flush(); err != nil { + return err + } + fmt.Fprintf(env.stderr, "change these in the browser: Settings at %s (signed in through the browser; the CLI only reads them)\n", ephemeralSettingsURL) + return nil +} + +// billingNote says, under an ephemeral run's cost, whether that cost is +// charged. Only a session can ask (GET /api/ephemeral/usage); the lookup is +// bounded like a name lookup and never fails the command. +func (env *runsEnv) billingNote(ctx context.Context) { + known, enabled := false, false + if env.cred.kind == credSession { + lctx, cancel := context.WithTimeout(ctx, nameLookupTimeout) + defer cancel() + if u, err := env.client.EphemeralUsage(lctx); err == nil { + known, enabled = true, u.BillingEnabled + } + } + switch { + case known && enabled: + case known: + fmt.Fprintf(env.stdout, "\nnote: %s\n", notChargedNote) + default: + fmt.Fprintln(env.stdout, "\nnote: while billing is not active, ephemeral costs are metered and shown, not charged (nan ephemeral usage says which)") + } +} + +// ─── nan run --ephemeral ─────────────────────────────────────────────────── + +// checkEphemeralFlags checks the flags of an ephemeral run (and that the +// ephemeral-only flags are not given without --ephemeral). It normalises +// --size to the platform's lowercase name. +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 != "": + return usageErrorf("--size is for ephemeral runs: add --ephemeral (a workspace has its own size, set in the portal)") + } + return nil + } + switch { + case opts.workspace != "": + return usageErrorf("an ephemeral run has no workspace: drop --ws (it runs in a fresh container on a clone of --repo)") + case opts.cwd != "": + return usageErrorf("an ephemeral run works in its fresh clone of --repo: drop --cwd") + case opts.worktree || opts.noWorktree: + return usageErrorf("an ephemeral run works on a fresh clone, never a git worktree: drop --worktree/--no-worktree") + case opts.repo == "" && len(opts.contexts) == 0: + return usageErrorf("--ephemeral needs --repo OWNER/NAME, the GitHub repository the container clones, " + + "or --context NAME, an agent context to work on without a repository (see: nan context ls)") + case opts.repo == "" && (opts.githubTokenSecret != "" || opts.githubTokenSecretSet): + return usageErrorf("--github-token-secret clones a private --repo: a run without --repo clones nothing, so drop it or add --repo OWNER/NAME") + } + if opts.repo != "" && !validGitHubRepo(opts.repo) { + if repo, ok := repoFromURL(opts.repo); ok { + return usageErrorf("--repo takes OWNER/NAME, not a URL: did you mean --repo %s?", repo) + } + return usageErrorf("--repo %q is not a GitHub repository: give it as OWNER/NAME, like acme/api", runs.Truncate(runs.SanitizeLine(opts.repo), 120)) + } + if opts.model == "" { + return usageErrorf("--ephemeral needs --model ID: a container has no workspace configuration to pick a model from") + } + if opts.size != "" { + size := strings.ToLower(opts.size) + if sizeSpec(size) == "" { + return usageErrorf("--size must be s, m or l, not %q: %s. %s", runs.Truncate(runs.SanitizeLine(opts.size), 16), sizeList(), freeMinutesLine) + } + 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 { + if run.Ephemeral() || req.Executor == api.ExecutorEphemeral { + size := run.Size + if size == nil || *size == "" { + s := req.Size + if s == "" { + s = api.SizeS + } + size = &s + } + repo := run.GitHubRepo + if repo == nil { + repo = req.GitHubRepo + } + place := "in an ephemeral container (size " + sizeShort(size) + ")" + if repo != nil && *repo != "" { + place += " for " + runs.SanitizeLine(*repo) + } + return place + } + where := runs.SanitizeLine(run.Workspace.Name) + if where == "" { + where = runs.SanitizeLine(req.Workspace) + } + return "on " + where +} 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 new file mode 100644 index 0000000..f575ac3 --- /dev/null +++ b/cmd/ephemeral_test.go @@ -0,0 +1,772 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/nxssie/nan-cli/internal/api" +) + +// Ephemeral runs (AGENT-EPHEMERAL-EXECUTORS.md, E2): the CLI starts them +// with nan run --ephemeral, shows their executor, size and cost, accepts +// executor/size in automation files, and reads the member's minutes and +// limits with nan ephemeral usage/settings. Free minutes are S-minutes, +// and that is said wherever they are shown. + +func ephemeralOpts() runOptions { + o := defaultOpts() + o.ephemeral = true + o.repo = "acme/api" + o.model = "glm5.3" + return o +} + +func TestEphemeralRunSendsTheRequest(t *testing.T) { + // "not json": an ephemeral run never looks a workspace up. + h := newHarness(t, &fakePlatform{workspaces: "not json", ephemeral: true}) + opts := ephemeralOpts() + opts.size = "M" + opts.timeout, opts.timeoutSet = 3600e9, true + if err := doRun(context.Background(), h.env, opts, []string{"fix the flaky test"}); err != nil { + t.Fatal(err) + } + c := h.fake.created + if c["executor"] != "ephemeral" || c["size"] != "m" || c["github_repo"] != "acme/api" || c["model"] != "glm5.3" || + c["agent"] != "pi" || c["prompt"] != "fix the flaky test" || c["trigger"] != "cli" || c["timeout_seconds"] != float64(3600) { + t.Errorf("created with %v", c) + } + for _, k := range []string{"workspace", "cwd", "git_isolation", "inference"} { + if _, ok := c[k]; ok { + t.Errorf("an ephemeral run sent %s: %v", k, c) + } + } + wantAll(t, "stderr", h.stderr.String(), "in an ephemeral container (size M) for acme/api", "model glm5.3", + "cost: €0.018 for 3 started minutes of size M", "nan ephemeral usage") + if strings.Contains(h.stderr.String(), "—") { + t.Errorf("new output has an em-dash: %q", h.stderr.String()) + } + if !strings.Contains(h.stdout.String(), "all good") { + t.Errorf("did not stream: %q", h.stdout.String()) + } +} + +// Without --size the platform picks s: the CLI does not send a size. +func TestEphemeralRunDefaultSize(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["size"]; ok { + t.Errorf("sent a size nobody asked for: %v", h.fake.created) + } +} + +func TestEphemeralRunDetach(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + opts := ephemeralOpts() + opts.detach = true + err := doRun(context.Background(), h.env, opts, []string{"x"}) + if exitCode(err) != exitDetached { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + if strings.TrimSpace(h.stdout.String()) != testRunID { + t.Errorf("stdout = %q", h.stdout.String()) + } + wantAll(t, "stderr", h.stderr.String(), "queued in an ephemeral container (size M) for acme/api", "nan runs logs "+testRunID) +} + +// BYOK and ephemeral combine: the model is the BYOK model. +func TestEphemeralRunWithBYOK(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + opts := ephemeralOpts() + opts.byokSecret, opts.model = "ANTHROPIC_KEY", "claude-sonnet-4-5" + if err := doRun(context.Background(), h.env, opts, []string{"x"}); err != nil { + t.Fatal(err) + } + inf, _ := h.fake.created["inference"].(map[string]any) + if h.fake.created["executor"] != "ephemeral" || inf["model"] != "claude-sonnet-4-5" || h.fake.created["model"] != nil { + t.Errorf("created with %v", h.fake.created) + } +} + +func TestEphemeralFlagMistakesAreUsageErrors(t *testing.T) { + for _, tc := range []struct { + name string + edit func(*runOptions) + wants []string + }{ + {"no repo", func(o *runOptions) { o.repo = "" }, []string{"--repo OWNER/NAME"}}, + {"no model", func(o *runOptions) { o.model = "" }, []string{"--model"}}, + {"bad repo", func(o *runOptions) { o.repo = "acme" }, []string{"OWNER/NAME", "acme/api"}}, + {"dot repo", func(o *runOptions) { o.repo = "acme/.." }, []string{"OWNER/NAME"}}, + {"repo URL", func(o *runOptions) { o.repo = "https://github.com/acme/api.git" }, []string{"did you mean --repo acme/api?"}}, + {"bad size", func(o *runOptions) { o.size = "xl" }, []string{"s, m or l", "4 vCPU", "S-minutes"}}, + {"with --ws", func(o *runOptions) { o.workspace = "develop" }, []string{"no workspace", "--ws"}}, + {"with --cwd", func(o *runOptions) { o.cwd = "/home/nan/x" }, []string{"--cwd"}}, + {"with --worktree", func(o *runOptions) { o.worktree = true }, []string{"--worktree"}}, + {"with --no-worktree", func(o *runOptions) { o.noWorktree = true }, []string{"--no-worktree"}}, + {"--repo without --ephemeral", func(o *runOptions) { o.ephemeral = false }, []string{"--repo is for ephemeral runs", "--ephemeral"}}, + {"--size without --ephemeral", func(o *runOptions) { o.ephemeral, o.repo, o.size = false, "", "m" }, []string{"--size is for ephemeral runs"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + opts := ephemeralOpts() + 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...) + if h.fake.created != nil { + t.Error("sent the run anyway") + } + }) + } +} + +// The sizes are case-insensitive on the command line and sent lowercase. +func TestEphemeralSizeIsNormalised(t *testing.T) { + for in, want := range map[string]string{"S": "s", "m": "m", "L": "l"} { + opts := ephemeralOpts() + opts.size = in + req, err := buildRunRequest(promptInput{r: strings.NewReader("")}, opts, []string{"x"}) + if err != nil || req.Size != want || req.Executor != api.ExecutorEphemeral || req.Workspace != "" { + t.Errorf("%s: %+v %v", in, req, err) + } + } +} + +func TestEphemeralRefusalsSayWhatToDo(t *testing.T) { + for _, tc := range []struct { + name string + cred credential + status int + code, param string + want int + wants, avoids []string + }{ + {"cap reached", credential{kind: credSession}, 402, "ephemeral_cap_reached", "", exitUnavailable, + []string{"monthly cap", "Settings at https://cloud.nan.builders/settings", "nan ephemeral usage"}, nil}, + {"currency migration", credential{kind: credSession}, 402, "currency_migration_required", "", exitUnavailable, + []string{"USD", "EUR", `"Switch billing to EUR"`, "Settings → Ephemeral runs", "New run dialog"}, []string{"monthly cap", "Settings > Billing"}}, + {"payment method", credential{kind: credSession}, 402, "payment_method_required", "", exitUnavailable, + []string{`"Add a payment method"`, "Settings → Ephemeral runs", "https://cloud.nan.builders/settings", "free minutes included"}, []string{"monthly cap"}}, + {"another 402", credential{kind: credSession}, 402, "something_new", "", exitUnavailable, + nil, []string{"monthly cap", "payment method", "USD"}}, + {"token without scope", credential{kind: credEnv, bearer: testPAT}, 403, "token_scope_denied", "executor", exitAuth, + []string{"runs:ephemeral", "NAN_TOKEN", "Settings > Tokens", "nan auth login"}, []string{testPAT}}, + {"saved API key", credential{kind: credStoredKey, bearer: testKey}, 403, "token_scope_denied", "executor", exitAuth, + []string{"API keys (sk-) cannot", "nan auth login"}, []string{testKey}}, + {"not available", credential{kind: credSession}, 400, "invalid_request", "executor", exitAuth, + []string{"ephemeral runs are not available for your account"}, []string{"workspace"}}, + {"runs off", credential{kind: credSession}, 404, "not_found", "", exitAuth, + []string{"runs are not enabled"}, []string{"workspace"}}, + {"bad repo on the server", credential{kind: credSession}, 400, "invalid_request", "github_repo", exitUsage, + []string{"github_repo"}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + param := "null" + if tc.param != "" { + param = fmt.Sprintf("%q", tc.param) + } + h := newHarness(t, &fakePlatform{ephemeral: true, createStatus: tc.status, + createBody: fmt.Sprintf(`{"error":{"message":"refused \u001b[2J","type":"invalid_request_error","param":%s,"code":%q}}`, param, tc.code)}) + h.env.cred = tc.cred + err := doRun(context.Background(), h.env, ephemeralOpts(), []string{"x"}) + if exitCode(err) != tc.want { + t.Fatalf("exit %d (%v), want %d", exitCode(err), err, tc.want) + } + 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()) + }) + } +} + +// An ephemeral automation run with a token missing runs:ephemeral reads +// the same, and so does the write API's refusal of an ephemeral automation. +func TestEphemeralRefusalsOnEveryPath(t *testing.T) { + cred := credential{kind: credStoredToken, bearer: testPAT} + scope := &api.APIError{Status: 403, Code: "token_scope_denied", Param: "executor", Message: "x"} + for name, exit := range map[string]*ExitError{ + "apiExit": apiExit(scope, cred), + "writeExit": writeExit(scope, cred, "automations", scopeAutomationsWrite), + } { + if exit.Code != exitAuth || !strings.Contains(exit.Error(), "runs:ephemeral") || strings.Contains(exit.Error(), "automations:write scope") { + t.Errorf("%s: %d %v", name, exit.Code, exit) + } + } + off := &api.APIError{Status: 400, Code: "invalid_request", Param: "executor", Message: `executor must be "workspace"`} + if exit := writeExit(off, cred, "automations", scopeAutomationsWrite); exit.Code != exitAuth || exit.Error() != "ephemeral runs are not available for your account" { + t.Errorf("writeExit off: %d %v", exit.Code, exit) + } + // A refusal that is not about ephemeral runs is left to the usual mapping. + other := &api.APIError{Status: 403, Code: "token_scope_denied", Param: "", Message: "needs runs"} + if exit := apiExit(other, cred); strings.Contains(exit.Error(), "runs:ephemeral") { + t.Errorf("a plain scope refusal reads as ephemeral: %v", exit) + } +} + +// nan automations run of an ephemeral automation says where it runs, not +// "queued on " with an empty workspace name. +func TestAutomationsRunEphemeralSaysWhere(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newPhase2Harness(t, &fakePhase2{runs: &fakePlatform{ephemeral: true}}) + if err := doAutomationsRun(context.Background(), h.env, testAutoID, nil); err != nil { + t.Fatal(err) + } + wantAll(t, "stderr", h.stderr.String(), "queued in an ephemeral container (size M) for acme/api, Ctrl-C to detach") + if strings.Contains(h.stderr.String(), "queued on ") || strings.Contains(h.stderr.String(), "—") { + t.Errorf("stderr = %q", h.stderr.String()) + } + // A workspace automation keeps its wording. + h = newPhase2Harness(t, &fakePhase2{}) + if err := doAutomationsRun(context.Background(), h.env, testAutoID, nil); err != nil { + t.Fatal(err) + } + wantAll(t, "stderr", h.stderr.String(), "queued on develop, Ctrl-C to detach") +} + +// The announcement of a workspace run uses the same separator as the +// others (no em-dash). +func TestRunAnnouncementSeparator(t *testing.T) { + h := newHarness(t, &fakePlatform{}) + if err := doRun(context.Background(), h.env, defaultOpts(), []string{"x"}); err != nil { + t.Fatal(err) + } + wantAll(t, "stderr", h.stderr.String(), "queued on develop (pi), Ctrl-C to detach") +} + +// ─── views ───────────────────────────────────────────────────────────────── + +// ephemeralGoldenEnv serves the ephemeral golden responses (rendered by +// cloud-api's own handlers, internal/api/testdata/ephemeral) on the paths +// that return them. usage answers /api/ephemeral/usage (nil: 404). +func ephemeralGoldenEnv(t *testing.T, cred credential, usage []byte) (*runsEnv, *bytes.Buffer, *bytes.Buffer, func() []string) { + t.Helper() + read := func(name string) []byte { + b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", "ephemeral", name)) + if err != nil { + t.Fatal(err) + } + return b + } + auto := read("automation_get_ephemeral.json") + routes := map[string][]byte{ + "/v1/runs/" + testRunID: read("run_get_ephemeral.json"), + "/v1/runs": read("run_list_ephemeral.json"), + "/v1/automations/" + testAutoID: auto, + "/v1/automations": []byte(`{"object":"list","data":[` + string(auto) + `]}`), + "/v1/automations/" + testAutoID + "/triggers": []byte(`{"data":[]}`), + "/api/ephemeral/settings": read("ephemeral_settings.json"), + } + if usage != nil { + routes["/api/ephemeral/usage"] = usage + } + var mu sync.Mutex + var seen []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seen = append(seen, r.Method+" "+r.URL.Path) + mu.Unlock() + b, ok := routes[r.URL.Path] + if !ok || r.Method != http.MethodGet { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"not_found"}`) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(b) + })) + t.Cleanup(srv.Close) + var out, errOut bytes.Buffer + env := &runsEnv{client: api.NewRunsClient(cred.session, cred.bearer).WithBaseURL(srv.URL), cred: cred, stdout: &out, stderr: &errOut} + return env, &out, &errOut, func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), seen...) + } +} + +func goldenUsage(t *testing.T, billing bool) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", "ephemeral", "ephemeral_usage.json")) + if err != nil { + t.Fatal(err) + } + if billing { + b = bytes.Replace(b, []byte(`"billing_enabled": false`), []byte(`"billing_enabled": true`), 1) + } + return b +} + +var sessionCred = credential{kind: credSession, session: "s"} + +// The views of an ephemeral run and an ephemeral automation, rendered from +// what cloud-api serialises. +func TestEphemeralGoldenViews(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + ctx := context.Background() + for _, tc := range []struct { + name string + do func(env *runsEnv) error + }{ + {"runs_show", func(env *runsEnv) error { return doRunsShow(ctx, env, testRunID) }}, + {"runs_ls", func(env *runsEnv) error { return doRunsLs(ctx, env) }}, + {"automations_show", func(env *runsEnv) error { return doAutomationsShow(ctx, env, testAutoID) }}, + {"automations_ls", func(env *runsEnv) error { return doAutomationsLs(ctx, env) }}, + {"usage", func(env *runsEnv) error { return doEphemeralUsage(ctx, env) }}, + {"settings", func(env *runsEnv) error { return doEphemeralSettings(ctx, env) }}, + } { + env, out, _, _ := ephemeralGoldenEnv(t, sessionCred, goldenUsage(t, false)) + if err := tc.do(env); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + checkGolden(t, "ephemeral/"+tc.name+".golden", out.String()) + assertNoControls(t, tc.name, out.String()) + if strings.Contains(out.String(), "—") { + t.Errorf("%s has an em-dash", tc.name) + } + } +} + +func TestEphemeralRunsShowFacts(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + ctx := context.Background() + env, out, _, _ := ephemeralGoldenEnv(t, sessionCred, goldenUsage(t, false)) + if err := doRunsShow(ctx, env, testRunID); err != nil { + t.Fatal(err) + } + wantAll(t, "runs show", out.String(), "executor", "ephemeral", "M (2 vCPU, 8 GiB RAM, 16 GB disk)", "acme/api", + "€0.018 for 3 started minutes of size M", "before your free minutes", "not charged yet") + for _, gone := range []string{"workspace", "worktree", "cwd"} { + if strings.Contains(out.String(), gone+" ") { + t.Errorf("runs show of an ephemeral run shows %s:\n%s", gone, out.String()) + } + } + + // Billing on: no note. + env, out, _, _ = ephemeralGoldenEnv(t, sessionCred, goldenUsage(t, true)) + if err := doRunsShow(ctx, env, testRunID); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "not charged") { + t.Errorf("billing on, still says not charged:\n%s", out.String()) + } + + // A token cannot ask: the note says it conditionally, and nothing is + // asked of /api/ephemeral. + env, out, _, seen := ephemeralGoldenEnv(t, credential{kind: credEnv, bearer: testPAT}, goldenUsage(t, false)) + if err := doRunsShow(ctx, env, testRunID); err != nil { + t.Fatal(err) + } + wantAll(t, "runs show (token)", out.String(), "while billing is not active", "nan ephemeral usage") + for _, r := range seen() { + if strings.HasPrefix(r, "GET /api/ephemeral") { + t.Errorf("a token asked %s", r) + } + } + + // The usage lookup failing never fails the command. + env, out, _, _ = ephemeralGoldenEnv(t, sessionCred, nil) + if err := doRunsShow(ctx, env, testRunID); err != nil || !strings.Contains(out.String(), "while billing is not active") { + t.Errorf("usage 404: %v\n%s", err, out.String()) + } +} + +// A list of workspace runs only keeps its columns. +func TestRunsLsCostColumnOnlyWithEphemeralRuns(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newHarness(t, &fakePlatform{}) + if err := doRunsLs(context.Background(), h.env); err != nil { + t.Fatal(err) + } + if strings.Contains(h.stdout.String(), "COST") { + t.Errorf("workspace runs only, with a cost column:\n%s", h.stdout.String()) + } + env, out, _, _ := ephemeralGoldenEnv(t, sessionCred, nil) + if err := doRunsLs(context.Background(), env); err != nil { + t.Fatal(err) + } + lines := strings.Split(out.String(), "\n") + if !strings.Contains(lines[0], "COST") || !strings.Contains(lines[1], "ephemeral M") || !strings.Contains(lines[1], "€0.018") || + !strings.Contains(lines[2], "pending") || !strings.Contains(lines[3], "develop") || !strings.Contains(lines[3], " - ") { + t.Errorf("runs ls:\n%s", out.String()) + } +} + +func TestEphemeralUsageFacts(t *testing.T) { + ctx := context.Background() + env, out, _, _ := ephemeralGoldenEnv(t, sessionCred, goldenUsage(t, false)) + if err := doEphemeralUsage(ctx, env); err != nil { + t.Fatal(err) + } + wantAll(t, "usage", out.String(), "2026-10 (inference plan)", "49 minutes", + "247 S-minutes of 300 (= 123 M or 61 L minutes); 53 used", "€0.00 after your free minutes", notChargedNote, + "€100.00", "€0.003", "€0.006", "€0.012", "a minute uses 2 free S-minutes", "a minute uses 4 free S-minutes", freeMinutesHelp) + + env, out, _, _ = ephemeralGoldenEnv(t, sessionCred, goldenUsage(t, true)) + if err := doEphemeralUsage(ctx, env); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "not charged") { + t.Errorf("billing on, still not charged:\n%s", out.String()) + } +} + +// Free minutes come with a membership; a payment method is always needed; +// a USD subscription has to move to EUR. The usage view says each one. +func TestEphemeralUsageStates(t *testing.T) { + ctx := context.Background() + usage := goldenUsage(t, false) + noFree := bytes.Replace(bytes.Replace(bytes.Replace(usage, []byte(`"free_eur_micros_total": 900000`), []byte(`"free_eur_micros_total": 0`), 1), + []byte(`"free_eur_micros_left": 741000`), []byte(`"free_eur_micros_left": 0`), 1), []byte(`"free_minutes_left": 247`), []byte(`"free_minutes_left": 0`), 1) + noFree = bytes.Replace(noFree, []byte(`"plan": "inference"`), []byte(`"plan": "none"`), 1) + env, out, _, _ := ephemeralGoldenEnv(t, sessionCred, noFree) + if err := doEphemeralUsage(ctx, env); err != nil { + t.Fatal(err) + } + wantAll(t, "usage without membership", out.String(), "none: free minutes come with a membership") + if strings.Contains(out.String(), "S-minutes of 0") { + t.Errorf("usage without membership:\n%s", out.String()) + } + + flagged := bytes.Replace(usage, []byte(`"billing_enabled": false`), + []byte(`"billing_enabled": false, "payment_method_required": true, "currency_migration_required": true`), 1) + env, out, _, _ = ephemeralGoldenEnv(t, sessionCred, flagged) + if err := doEphemeralUsage(ctx, env); err != nil { + t.Fatal(err) + } + wantAll(t, "usage needing action", out.String(), `"Add a payment method"`, "free minutes included", + "your subscription bills in USD; ephemeral runs bill in EUR", `"Switch billing to EUR"`, "Settings → Ephemeral runs") + + env, out, _, _ = ephemeralGoldenEnv(t, sessionCred, usage) + if err := doEphemeralUsage(ctx, env); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "Add a payment method") || strings.Contains(out.String(), "USD") { + t.Errorf("usage with nothing to do asks for something:\n%s", out.String()) + } +} + +// A help line stays within 80 columns (the S-minutes sentence included). +func TestEphemeralHelpFitsTheTerminal(t *testing.T) { + for name, text := range map[string]string{"nan ephemeral": ephemeralCmd.Long, "nan ephemeral usage": ephemeralUsageCmd.Long, + "nan ephemeral settings": ephemeralSettingsCmd.Long, "nan run": runCmd.Long} { + for _, l := range strings.Split(text, "\n") { + if n := len([]rune(l)); n > 80 { + t.Errorf("%s --help: a %d-column line: %q", name, n, l) + } + } + } +} + +func TestEphemeralSettingsIsReadOnly(t *testing.T) { + env, out, errOut, seen := ephemeralGoldenEnv(t, sessionCred, nil) + if err := doEphemeralSettings(context.Background(), env); err != nil { + t.Fatal(err) + } + wantAll(t, "settings", out.String(), "€100.00 (€0.00 to €1000.00)", "up to 50 at once (1 to 50)", "not active yet") + wantAll(t, "settings stderr", errOut.String(), "change these in the browser", "https://cloud.nan.builders/settings") + for _, r := range seen() { + if !strings.HasPrefix(r, "GET ") { + t.Errorf("settings wrote: %s", r) + } + } + for _, c := range ephemeralSettingsCmd.Commands() { + t.Errorf("nan ephemeral settings has a subcommand %s: writes are browser-only", c.Name()) + } +} + +func TestEphemeralReadsWhenUnavailable(t *testing.T) { + ctx := context.Background() + for name, status := range map[string]int{"off": 404, "not a session": 403} { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"error":"x"}`) + })) + env := &runsEnv{client: api.NewRunsClient("s", "").WithBaseURL(srv.URL), cred: sessionCred, stdout: io.Discard, stderr: io.Discard} + for cmd, do := range map[string]func() error{ + "usage": func() error { return doEphemeralUsage(ctx, env) }, + "settings": func() error { return doEphemeralSettings(ctx, env) }, + } { + err := do() + if exitCode(err) != exitAuth { + t.Errorf("%s %s: exit %d (%v)", name, cmd, exitCode(err), err) + } + if status == 404 && err.Error() != "ephemeral runs are not available for your account" { + t.Errorf("%s %s: %v", name, cmd, err) + } + } + srv.Close() + } +} + +// nan ephemeral takes the session only: the platform takes no token there. +func TestEphemeralNeedsTheSession(t *testing.T) { + fakeHome(t, cliSession) + cred, err := ephemeralSession(func(string) string { return testPAT }, "") + if err != nil || cred.kind != credSession || cred.session != "sess-cli" { + t.Fatalf("with a session: %+v %v", cred, err) + } + for name, tc := range map[string]struct { + session, env, file string + wants []string + }{ + "nobody": {"", "", "", []string{"nan auth login"}}, + "NAN_TOKEN": {"", testPAT, "", []string{"NAN_TOKEN is set", "nan auth login"}}, + "token-file": {cliSession, "", "/tmp/tok", []string{"--token-file", "nan auth login"}}, + "saved token": {`{"platform_token":"` + testPAT + `"}`, "", "", []string{"nan auth login"}}, + "corrupt file": {`{not json`, "", "", []string{"nan auth login"}}, + } { + fakeHome(t, tc.session) + _, err := ephemeralSession(func(k string) string { + if k == tokenEnvVar { + return tc.env + } + return "" + }, tc.file) + if exitCode(err) != exitAuth { + t.Errorf("%s: exit %d (%v)", name, exitCode(err), err) + continue + } + wantAll(t, name, err.Error(), tc.wants...) + if strings.Contains(err.Error(), testPAT) { + t.Errorf("%s: shows the token", name) + } + } +} + +// ─── automation files ────────────────────────────────────────────────────── + +func TestCheckAutomationExecutor(t *testing.T) { + const ok = `{"name":"n","executor":"ephemeral","size":"m","checkout":"fresh","github_repo":"acme/api","agent":"pi","model":"glm5.3"}` + for _, tc := range []struct { + name string + body string + create bool + want string // "" = accepted + }{ + {"ephemeral", ok, true, ""}, + {"ephemeral, size left out", `{"executor":"ephemeral","checkout":"fresh","github_repo":"acme/api","model":"m"}`, true, ""}, + {"ephemeral with BYOK model", `{"executor":"ephemeral","checkout":"fresh","github_repo":"acme/api","inference":{"source":"byok","secret":"K","model":"x"}}`, true, ""}, + {"workspace automation", `{"name":"n","workspace":"develop","agent":"pi"}`, true, ""}, + {"explicit workspace executor", `{"executor":"workspace","workspace":"develop"}`, true, ""}, + {"edit: size only", `{"size":"l"}`, false, ""}, + {"edit: switch to ephemeral", `{"executor":"ephemeral"}`, false, ""}, + {"not an object", `[1]`, true, ""}, + {"unknown executor", `{"executor":"container"}`, true, `executor must be "workspace"`}, + {"executor not a string", `{"executor":1}`, false, `executor must be`}, + {"unknown size", `{"executor":"ephemeral","size":"xl"}`, false, `size must be "s", "m" or "l"`}, + {"size on a workspace executor", `{"executor":"workspace","size":"m"}`, false, "ephemeral automations only"}, + {"size without executor on create", `{"workspace":"develop","size":"m"}`, true, ""}, + {"workspace on ephemeral", `{"executor":"ephemeral","workspace":"develop","checkout":"fresh","github_repo":"a/b","model":"m"}`, true, "no workspace"}, + {"no fresh checkout", `{"executor":"ephemeral","github_repo":"a/b","model":"m"}`, true, "checkout: fresh"}, + {"no repo", `{"executor":"ephemeral","checkout":"fresh","model":"m"}`, true, "github_repo: OWNER/NAME"}, + {"cwd", `{"executor":"ephemeral","checkout":"fresh","github_repo":"a/b","model":"m","cwd":"/home/nan/x"}`, true, "remove cwd"}, + {"no model", `{"executor":"ephemeral","checkout":"fresh","github_repo":"a/b"}`, true, "must name its model"}, + // Agent contexts (C2): checkout "none" is an ephemeral automation + // without a repository, with at least one context. + {"none with a context", `{"executor":"ephemeral","checkout":"none","contexts":["acme-api"],"model":"m"}`, true, ""}, + {"none without a context", `{"executor":"ephemeral","checkout":"none","model":"m"}`, true, "at least one agent context"}, + {"none with an empty list", `{"executor":"ephemeral","checkout":"none","contexts":[],"model":"m"}`, true, "at least one agent context"}, + {"none with a repo", `{"executor":"ephemeral","checkout":"none","github_repo":"a/b","contexts":["c"],"model":"m"}`, true, "remove github_repo"}, + {"none, repo null", `{"executor":"ephemeral","checkout":"none","github_repo":null,"contexts":["c"],"model":"m"}`, true, ""}, + {"none on a workspace automation", `{"workspace":"develop","checkout":"none","contexts":["c"]}`, true, "only for ephemeral"}, + {"edit: none alone", `{"checkout":"none","github_repo":null}`, false, ""}, + {"edit: none to workspace", `{"executor":"workspace","checkout":"none"}`, false, "only for ephemeral"}, + {"none still needs a model", `{"executor":"ephemeral","checkout":"none","contexts":["c"]}`, true, "must name its model"}, + {"contexts on a workspace automation", `{"workspace":"develop","contexts":["a","b"]}`, true, ""}, + {"edit: contexts cleared", `{"contexts":[]}`, false, ""}, + {"six contexts", `{"contexts":["a1","a2","a3","a4","a5","a6"]}`, false, "at most 5"}, + {"contexts not a list", `{"contexts":"a"}`, false, "list of context names"}, + } { + err := checkAutomationExecutor(json.RawMessage(tc.body), tc.create) + switch { + case tc.want == "" && err != nil: + t.Errorf("%s: refused: %v", tc.name, err) + case tc.want != "" && (exitCode(err) != exitUsage || !strings.Contains(err.Error(), tc.want)): + t.Errorf("%s: %v, want %q", tc.name, err, tc.want) + } + } +} + +// A bad ephemeral automation file is refused before anything is sent, and a +// good one (YAML) goes out as written. +func TestAutomationsCreateEphemeralFile(t *testing.T) { + dir := t.TempDir() + bad := filepath.Join(dir, "bad.yaml") + good := filepath.Join(dir, "good.yaml") + for p, body := range map[string]string{ + bad: "name: nightly\nexecutor: ephemeral\nsize: xl\n", + good: "name: nightly\nexecutor: ephemeral\nsize: m\ncheckout: fresh\ngithub_repo: acme/api\nagent: pi\nmodel: glm5.3\ninstructions: Review.\n", + } { + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", bad) + if r.code != exitUsage || !strings.Contains(r.msg, `size must be "s", "m" or "l"`) || !strings.Contains(r.msg, "S-minutes") { + t.Fatalf("bad file: %d %s", r.code, r.msg) + } + if len(fake.reqs) != 0 { + t.Errorf("sent %d requests for a bad file", len(fake.reqs)) + } + + fake = newFakeWrite() + r = runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", good) + if r.code != 0 { + t.Fatalf("good file: %d %s", r.code, r.msg) + } + var sent map[string]any + for _, q := range fake.reqs { + if q.Method == http.MethodPost && q.Path == "/v1/automations" { + _ = json.Unmarshal([]byte(q.Body), &sent) + } + } + if sent["executor"] != "ephemeral" || sent["size"] != "m" || sent["checkout"] != "fresh" { + t.Errorf("sent %v", sent) + } + + // edit: an unknown size never leaves the machine either. + fake = newFakeWrite() + r = runWrite(t, fake, writeSetup{session: cliSession}, "automations", "edit", testAutoID, "-f", bad) + if r.code != exitUsage || len(fake.reqs) != 0 { + t.Errorf("edit with a bad file: %d %s, %d requests", r.code, r.msg, len(fake.reqs)) + } +} + +// ─── formatting and help ─────────────────────────────────────────────────── + +func TestFormatEUR(t *testing.T) { + for micros, want := range map[int64]string{ + 0: "€0.00", 3000: "€0.003", 18000: "€0.018", 1_500_000: "€1.50", 100_000_000: "€100.00", + 123_456: "€0.123456", 10_000: "€0.01", -6000: "-€0.006", + } { + if got := formatEUR(micros); got != want { + t.Errorf("formatEUR(%d) = %q, want %q", micros, got, want) + } + } + if got := formatCents(10000); got != "€100.00" { + t.Errorf("formatCents = %q", got) + } + for secs, want := range map[int64]int64{0: 0, 1: 1, 14: 1, 60: 1, 61: 2, 180: 3} { + if got := startedMinutes(secs); got != want { + t.Errorf("startedMinutes(%d) = %d, want %d", secs, got, want) + } + } +} + +func TestRunCostLabels(t *testing.T) { + m := "m" + for _, tc := range []struct { + run api.Run + long, short string + }{ + {api.Run{Executor: "ephemeral", Size: &m, State: "running"}, "metered when the run ends", "pending"}, + {api.Run{Executor: "ephemeral", Size: &m, State: "succeeded"}, "not metered yet", "pending"}, + {api.Run{Executor: "ephemeral", Size: &m, State: "succeeded", Cost: &api.RunCost{EURMicros: 6000, BilledSeconds: 14}}, + "€0.006 for 1 started minute of size M", "€0.006"}, + {api.Run{Executor: "workspace", State: "succeeded"}, "", "-"}, + } { + if tc.long != "" && !strings.Contains(runCostLabel(&tc.run), tc.long) { + t.Errorf("%+v: %q", tc.run, runCostLabel(&tc.run)) + } + if got := runCostShort(&tc.run); got != tc.short { + t.Errorf("%+v short: %q", tc.run, got) + } + } + bad := "x\x1b[2J" + if got := sizeLabel(&bad); strings.ContainsAny(got, "\x1b") { + t.Errorf("sizeLabel = %q", got) + } +} + +// The owner asked for this sentence wherever free minutes are shown. +func TestEphemeralHelpExplainsSMinutes(t *testing.T) { + const want = "Free minutes are S-minutes: one M minute uses 2, one L minute 4\n(100 free minutes = 100 minutes of S, 50 of M or 25 of L)" + if freeMinutesHelp != want { + t.Fatalf("freeMinutesHelp = %q", freeMinutesHelp) + } + for name, text := range map[string]string{ + "nan ephemeral": ephemeralCmd.Long, + "nan ephemeral usage": ephemeralUsageCmd.Long, + "nan run": runCmd.Long, + "nan automations create": automationsCreateCmd.Long, + } { + if !strings.Contains(strings.Join(strings.Fields(text), " "), strings.Join(strings.Fields(want), " ")) { + t.Errorf("%s --help does not explain S-minutes", name) + } + if strings.Contains(text, "—") { + t.Errorf("%s --help has an em-dash", name) + } + if strings.Contains(strings.ToLower(text), "eu only") { + t.Errorf("%s --help claims EU only", name) + } + } + 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, + "nan ephemeral settings": ephemeralSettingsCmd.Long} { + wantAll(t, name+" --help", c, "Docs: https://nan.builders/docs/ephemeral-runs") + } + wantAll(t, "nan ephemeral settings --help", ephemeralSettingsCmd.Long, "read only", "browser", ephemeralSettingsURL) + wantAll(t, "nan ephemeral --help", ephemeralCmd.Long, "Community 100, Inference 300, Premium", "€100", "50", "nan auth login", + "without one have none", "payment method on file", "free minutes included") + wantAll(t, "nan automations create --help", automationsCreateCmd.Long, "executor: ephemeral", "size: m", "checkout: fresh", "runs:ephemeral") + for _, f := range []string{"ephemeral", "repo", "size"} { + if runCmd.Flags().Lookup(f) == nil { + t.Errorf("nan run has no --%s", f) + } + } + if ephemeralUsageCmd.Annotations[sysexitsAnnotation] == "" || ephemeralSettingsCmd.Annotations[sysexitsAnnotation] == "" { + t.Error("nan ephemeral commands do not use the documented exit codes") + } +} + +// End to end through the command tree: an unknown subcommand and the +// usage command with the session. +func TestEphemeralCommandTree(t *testing.T) { + code, msg := execute(t, "ephemeral", "set-cap") + if code != exitUsage || !strings.Contains(msg, "nan ephemeral --help") { + t.Errorf("unknown subcommand: %d %s", code, msg) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Cookie") != "nan_session=sess-cli" { + w.WriteHeader(http.StatusForbidden) + return + } + _, _ = w.Write(goldenUsage(t, false)) + })) + t.Cleanup(srv.Close) + fakeHome(t, cliSession) + swap(t, &runsBaseURL, srv.URL) + var out, errOut bytes.Buffer + swap(t, &writeStdout, io.Writer(&out)) + swap(t, &writeStderr, io.Writer(&errOut)) + code, msg = execute(t, "ephemeral", "usage") + if code != 0 || !strings.Contains(out.String(), freeMinutesHelp) { + t.Errorf("usage: %d %s\n%s", code, msg, out.String()) + } +} diff --git a/cmd/exit.go b/cmd/exit.go index fe02077..f214562 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -82,6 +82,9 @@ func apiExit(err error, cred credential) *ExitError { if !errors.As(err, &apiErr) { return &ExitError{Code: exitUnavailable, Err: err} } + if exit := ephemeralExit(apiErr, cred); exit != nil { + return exit + } msg := runs.SanitizeLine(apiErr.Error()) switch apiErr.Code { case "tier_restricted": diff --git a/cmd/hidden_unix_test.go b/cmd/hidden_unix_test.go new file mode 100644 index 0000000..35ee927 --- /dev/null +++ b/cmd/hidden_unix_test.go @@ -0,0 +1,82 @@ +//go:build unix + +package cmd + +import ( + "os" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/charmbracelet/x/term" +) + +// Ctrl-c at a hidden prompt used to end the process while echo was off, and +// the shell stayed that way. While the prompt is up, the interrupt is caught: +// the terminal is put back first, and only then does the command exit. +func TestCtrlCAtAHiddenPromptPutsTheTerminalBackFirst(t *testing.T) { + var restored atomic.Bool + exited := make(chan bool, 1) + swap(t, &interrupted, func() { exited <- restored.Load() }) + + release := onInterrupt(func() { restored.Store(true) }) + defer release() + + if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil { + t.Fatal(err) + } + select { + case restoredFirst := <-exited: + if !restoredFirst { + t.Error("exited before putting the terminal back") + } + case <-time.After(5 * time.Second): + t.Fatal("ctrl-c at the prompt was not handled") + } +} + +// readHiddenLine hands the interrupt handler the terminal's own state: a +// ctrl-c while the password is being read restores exactly what GetState +// saved, on the same descriptor, before the command exits. Without a real +// terminal, the three calls are stood in for. +func TestCtrlCWhileReadingRestoresTheSavedState(t *testing.T) { + saved := &term.State{} + f, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + type restore struct { + fd uintptr + state *term.State + } + restored := make(chan restore, 1) + exited := make(chan struct{}) + swap(t, &termGetState, func(uintptr) (*term.State, error) { return saved, nil }) + swap(t, &termRestore, func(fd uintptr, s *term.State) error { restored <- restore{fd, s}; return nil }) + swap(t, &interrupted, func() { close(exited) }) + // The member presses ctrl-c while the prompt is waiting for them. + swap(t, &termReadPassword, func(uintptr) ([]byte, error) { + if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil { + t.Error(err) + } + select { + case <-exited: + case <-time.After(5 * time.Second): + t.Error("ctrl-c while reading was not handled") + } + return nil, os.ErrClosed + }) + + _, _ = readHiddenLine(f) + select { + case got := <-restored: + if got.fd != f.Fd() || got.state != saved { + t.Errorf("restored fd %d state %p, want fd %d state %p", got.fd, got.state, f.Fd(), saved) + } + default: + t.Error("ctrl-c exited without restoring the terminal") + } +} diff --git a/cmd/json_output_test.go b/cmd/json_output_test.go index 720da25..dac7396 100644 --- a/cmd/json_output_test.go +++ b/cmd/json_output_test.go @@ -57,6 +57,8 @@ var hintFragments = []string{ "send a test", "nan notifications", "turned off", + "change these in the browser", + "cost:", } // jsonFixtures writes the files the write commands read. @@ -84,6 +86,9 @@ func jsonCases(t *testing.T) map[string][]jsonCase { if err := os.WriteFile(hook, []byte(testHookURL+"\n"), 0o600); err != nil { t.Fatal(err) } + // A folder to push: skipped and refused files print their notes on + // stderr only. + ctxDir := writeTree(t, map[string]string{"AGENTS.md": "Use tabs.\n", "files/.env": "S=1\n", "CLAUDE.md": "x\n"}) return map[string][]jsonCase{ "nan approvals ls": {{args: []string{"approvals", "ls", "--json"}}}, "nan approvals show": {{args: []string{"approvals", "show", testRunID, "--json"}}}, @@ -112,12 +117,22 @@ func jsonCases(t *testing.T) map[string][]jsonCase { {args: []string{"automations", "triggers", "rotate", testAutoID, triggerUUID, "--json"}}, {args: []string{"automations", "triggers", "rotate", testAutoID, triggerUUID, "--url", "--secret", "--no-grace", "--json"}}, }, + "nan ephemeral settings": {{args: []string{"ephemeral", "settings", "--json"}}}, + "nan ephemeral usage": {{args: []string{"ephemeral", "usage", "--json"}}}, "nan notifications add": { {args: []string{"notifications", "add", "deploys", "--kind", "slack", "--url-file", hook, "--json"}}, // The one-time signing secret is in the JSON; the note about it // goes to stderr only. {args: []string{"notifications", "add", "ci", "--kind", "https", "--url-stdin", "--event", "run.failed", "--json"}, stdin: testHookHTTPS}, }, + "nan context create": {{args: []string{"context", "create", "new-ctx", "--json"}}}, + "nan context edit": {{args: []string{"context", "edit", "acme-api", "--description", "new", "--json"}}}, + "nan context ls": {{args: []string{"context", "ls", "--json"}}}, + "nan context push": { + {args: []string{"context", "push", "acme-api", ctxDir, "--skip-invalid", "--json"}}, + {args: []string{"context", "push", "acme-api", ctxDir, "--skip-invalid", "--dry-run", "--json"}}, + }, + "nan context show": {{args: []string{"context", "show", "acme-api", "--json"}}}, "nan notifications deliveries": {{args: []string{"notifications", "deliveries", "ci receiver", "--limit", "3", "--json"}}}, "nan notifications edit": { {args: []string{"notifications", "edit", "team alerts", "--disable", "--json"}}, @@ -134,6 +149,7 @@ func jsonCases(t *testing.T) map[string][]jsonCase { "nan run": { {args: []string{"run", "review", "--json"}, lines: true}, {args: []string{"run", "review", "--detach", "--json"}, code: exitDetached}, + {args: []string{"run", "--ephemeral", "--repo", "acme/api", "--model", "glm5.3", "--size", "m", "review", "--json"}, lines: true}, }, "nan runs cancel": {{args: []string{"runs", "cancel", testRunID, "--json"}}}, "nan runs logs": { @@ -288,8 +304,23 @@ func runJSONCase(t *testing.T, c jsonCase) (code int, msg, stdout, stderr string phase2.runGet = awaitingRunJSON() } writeHandler := write.handler(t) + ctxFake := newFakeCtx() + ctxFake.seed("acme-api", 2, map[string]string{"files/a.md": "a\n"}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path + if name, ok := strings.CutPrefix(p, "/api/ephemeral/"); ok { + b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", "ephemeral", "ephemeral_"+name+".json")) + if err != nil { + http.NotFound(w, r) + return + } + _, _ = w.Write(b) + return + } + if strings.HasPrefix(p, "/v1/contexts") { + ctxFake.ServeHTTP(w, r) + return + } toWrite := p == "/api/auth/me" || p == "/api/account/secrets" || strings.HasPrefix(p, "/v1/secrets") || strings.HasPrefix(p, "/v1/notification-channels") || strings.HasSuffix(p, "/triggers") || strings.Contains(p, "/triggers/") || diff --git a/cmd/run.go b/cmd/run.go index 1808573..b63ff9e 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -5,15 +5,18 @@ import ( "errors" "fmt" "io" + "net/http" "os" "os/signal" "regexp" "slices" + "strconv" "strings" "time" "unicode/utf8" "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/contexts" "github.com/nxssie/nan-cli/internal/runs" "github.com/spf13/cobra" ) @@ -49,14 +52,25 @@ type runOptions struct { idempotencyKey string file string byokSecret string + 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 + // contexts: the agent contexts the run loads (--context, repeatable), + // by name or id, in order. + contexts []string } var runOpts runOptions var runCmd = &cobra.Command{ Use: `run [flags] ( "prompt" | -f FILE | - )`, - Short: "Run an agent in one of your workspaces", - Long: `Run an agent in one of your workspaces and stream what it does. + Short: "Run an agent in one of your workspaces, or in an ephemeral container", + Long: `Run an agent in one of your workspaces, or in an ephemeral container, and +stream what it does. The prompt is the argument, the contents of -f FILE, or stdin when the argument is "-". The run happens in the workspace, not on this machine: it @@ -87,6 +101,39 @@ Your own provider key (BYOK): 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. +Agent contexts: + --context NAME loads one of your agent contexts into the run: its + instructions, skills and files, at its latest version. Repeat it to load + more, at most 5; they load in the order given. NAME is a context name or + id from: nan context ls. Two contexts cannot define the same skill, and + together they must fit a run (250 MiB, 5000 files, 64 KiB of + instructions). Make one with: nan context create. + Docs: ` + contextsDocs + ` + +Ephemeral runs (no workspace): + --ephemeral --repo OWNER/NAME --model ID runs the agent in a fresh, + 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). + 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. + With --context, --repo can be left out: without --repo the agent works + in an empty folder with its contexts and clones nothing. + --size picks the container (default s), billed per started minute (a + 14 s run is 1 minute): +` + sizeTable(" ") + ` Free minutes every month: Community 100, Inference 300, Premium 1000. + ` + membershipHelp + `. + ` + strings.ReplaceAll(freeMinutesHelp, "\n", "\n ") + `. + ` + strings.ReplaceAll(paymentHelp, "\n", "\n ") + `. + While billing is not active, usage is metered and shown, not charged. + Your minutes, cost and limits: nan ephemeral usage, nan ephemeral settings. + Starting one needs your session (nan auth login) or a platform token with + the runs:ephemeral scope; API keys (sk-) cannot start them. + Docs: ` + ephemeralDocs + ` + In the portal: Folder = --cwd, Separate branch = --worktree/--no-worktree, Time limit = --timeout, Task = the prompt. @@ -122,9 +169,12 @@ Exit codes: 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 (a token for a run with your own key) - 69 nan.builders unavailable, or the stream was lost (the run goes on) + 64 usage error, or a --context that does not exist or does not fit + with the others (same skill twice, too large together) + 65 not signed in, or not allowed (a token for a run with your own key, a + token without runs:ephemeral, ephemeral runs not available to you) + 69 nan.builders unavailable, the stream was lost (the run goes on), or + an ephemeral run would pass your monthly spend cap 75 detached: the run was accepted and is still going Docs: https://nan.builders/docs/runs`, @@ -133,12 +183,17 @@ Docs: https://nan.builders/docs/runs`, 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 --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky test"`, + 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/private --github-token-secret GITHUB_TOKEN --model glm5.3 "review it" + nan run --context acme-api --context style-guide "review PR 42" + nan run --ephemeral --context release-notes --model glm5.3 "write this week's release notes"`, 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) @@ -162,8 +217,13 @@ func init() { rootCmd.AddCommand(runCmd) 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 --model, and --repo or --context)") + 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.StringArrayVar(&runOpts.contexts, "context", nil, "Load one of your agent contexts, by `NAME` or id (repeat for more, at most 5; see: nan context ls)") + 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)") + f.StringVar(&runOpts.model, "model", "", "Model for the agent (default: the agent's own; required with --byok-secret and --ephemeral)") 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)") @@ -216,7 +276,8 @@ func newRunsEnv() (*runsEnv, error) { } func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.CreateRunRequest) error { - if req.Workspace == "" { + ephemeral := req.Executor == api.ExecutorEphemeral + if req.Workspace == "" && !ephemeral { name, err := defaultWorkspace(ctx, env) if err != nil { return err @@ -228,9 +289,21 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat if err != nil { var apiErr *api.APIError if errors.As(err, &apiErr) { + if exit := contextRefusal(apiErr, req.Contexts, "--context"); exit != nil { + return exit + } 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 + } + } + if ephemeral && errors.As(err, &apiErr) && apiErr.Status == 404 { + return exitf(exitAuth, "runs are not enabled for your account") } 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)) @@ -256,22 +329,28 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat } } else { fmt.Fprintln(env.stdout, id) - fmt.Fprintf(env.stderr, "run %s %s on %s\n", id, runs.SanitizeLine(run.State), runs.SanitizeLine(run.Workspace.Name)) + fmt.Fprintf(env.stderr, "run %s %s %s\n", id, runs.SanitizeLine(run.State), runPlace(run, req)) printFollowHints(env.stderr, id) } return runExit(run) } if !replayed { - where := runs.SanitizeLine(run.Workspace.Name) - if where == "" { - where = runs.SanitizeLine(req.Workspace) - } agent := runs.SanitizeLine(run.Agent) - if in := req.Inference; in != nil && in.Source == api.InferenceBYOK { + switch in := req.Inference; { + case in != nil && in.Source == api.InferenceBYOK: agent += ", your key " + runs.SanitizeLine(in.Secret) + ", model " + runs.SanitizeLine(in.Model) + case ephemeral && req.Model != nil: + agent += ", model " + runs.SanitizeLine(*req.Model) } - fmt.Fprintf(env.stderr, "run %s queued on %s (%s) — Ctrl-C to detach\n", id, where, agent) + if len(req.Contexts) > 0 { + names := make([]string, len(req.Contexts)) + for i, c := range req.Contexts { + names[i] = runs.SanitizeLine(c) + } + agent += ", contexts " + strings.Join(names, ", ") + } + fmt.Fprintf(env.stderr, "run %s queued %s (%s), Ctrl-C to detach\n", id, runPlace(run, req), agent) } return follow(ctx, env, run.ID, 0, opts.json, true) } @@ -353,6 +432,9 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d } } else { fmt.Fprintln(env.stderr, runs.Outcome(res.run)) + if res.run.Ephemeral() { + fmt.Fprintf(env.stderr, "cost: %s\n", runCostLabel(res.run)) + } } return runExit(res.run) } @@ -371,7 +453,7 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d } renderer.Flush() safeID := runs.SanitizeLine(id) - fmt.Fprintf(env.stderr, "\ndetached — run %s goes on in the workspace\n", safeID) + fmt.Fprintf(env.stderr, "\ndetached — run %s goes on at nan.builders\n", safeID) printFollowHints(env.stderr, safeID) fmt.Fprintf(env.stderr, "press Ctrl-C again within %s to cancel it now\n", cancelWindow) @@ -401,7 +483,7 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d func streamExit(err error, env *runsEnv, id string) error { safeID := runs.SanitizeLine(id) if errors.Is(err, api.ErrStreamLost) { - return exitf(exitUnavailable, "%s — run %s goes on in the workspace; follow it with: nan runs logs %s -f", runs.SanitizeLine(err.Error()), safeID, safeID) + return exitf(exitUnavailable, "%s — run %s goes on at nan.builders; follow it with: nan runs logs %s -f", runs.SanitizeLine(err.Error()), safeID, safeID) } exit := notFound(err, safeID, env.cred) if exit.Code == exitUnavailable { @@ -456,6 +538,12 @@ func buildRunRequest(in promptInput, opts runOptions, args []string) (*api.Creat return nil, err } } + if err := checkContextRefs(opts.contexts, "--context"); err != nil { + return nil, err + } + if err := checkEphemeralFlags(&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) } @@ -490,6 +578,17 @@ func buildRunRequest(in promptInput, opts runOptions, args []string) (*api.Creat iso := opts.worktree req.GitIsolation = &iso } + if opts.ephemeral { + req.Executor, req.Size = api.ExecutorEphemeral, opts.size + if opts.repo != "" { + repo := opts.repo + req.GitHubRepo = &repo + } + req.GitHubTokenSecret = opts.githubTokenSecret + } + if len(opts.contexts) > 0 { + req.Contexts = slices.Clone(opts.contexts) + } if opts.timeoutSet { if opts.timeout < minTimeout || opts.timeout > maxTimeout { return nil, usageErrorf("--timeout must be between 1m and 2h, not %s", opts.timeout) @@ -577,3 +676,81 @@ func defaultWorkspace(ctx context.Context, env *runsEnv) (string, error) { } return "", usageErrorf("you have %d workspaces; pick one with --ws:\n%s", len(list), joinNames(names)) } + +// maxRunContexts is how many agent contexts a run or an automation loads +// (cloud-api ctxpath.MaxContextsPerRun). +const maxRunContexts = 5 + +// checkContextRefs checks the contexts a run or an automation names (with +// the flag or file field they came from, for the message): at most 5, each +// a context name or id, none twice. Whether they exist is the platform's +// to say. +func checkContextRefs(refs []string, from string) error { + if len(refs) > maxRunContexts { + return usageErrorf("%s: at most %d contexts, not %d", from, maxRunContexts, len(refs)) + } + seen := make(map[string]bool, len(refs)) + for _, ref := range refs { + if ref == "" { + return usageErrorf("%s is empty: give a context name or id (see: nan context ls)", from) + } + key := ref + switch { + case runIDPattern.MatchString(ref): + key = strings.ToLower(ref) + case !contexts.ValidContextName(ref): + return usageErrorf("%s %q is not a context name (lowercase letters, digits and dashes) or a context id; see: nan context ls", + from, runs.Truncate(runs.SanitizeLine(ref), 80)) + } + if seen[key] { + return usageErrorf("%s %q is given twice: each context loads once", from, runs.Truncate(runs.SanitizeLine(ref), 80)) + } + seen[key] = true + } + return nil +} + +// contextRefusal reads the platform's refusal of the contexts a run or an +// automation named (refs, in the order sent). nil when it is about +// something else. +func contextRefusal(apiErr *api.APIError, refs []string, from string) *ExitError { + msg := runs.Truncate(runs.SanitizeLine(apiErr.Message), 200) + ref, named := contextAt(apiErr.Param, refs) + switch apiErr.Code { + case "context_not_found": + if named { + return usageErrorf("context %q not found: see your contexts with nan context ls", ref) + } + return usageErrorf("one of the contexts was not found: see your contexts with nan context ls") + case "context_skill_conflict": + return usageErrorf("two of the contexts define the same skill (%s): load only one of them with %s, "+ + "or rename the skill in one and push it again (nan context push)", msg, from) + case "context_too_large": + return usageErrorf("the contexts are too large for one run together (%s): a run takes up to 250 MiB, "+ + "5000 files and 64 KiB of instructions; load fewer contexts", msg) + } + if apiErr.Status == http.StatusBadRequest && (apiErr.Param == "contexts" || strings.HasPrefix(apiErr.Param, "contexts[")) { + if named { + return usageErrorf("%s %q refused: %s", from, ref, msg) + } + return usageErrorf("%s refused: %s", from, msg) + } + return nil +} + +// contextAt is the context a "contexts[i]" param points at, sanitised. +func contextAt(param string, refs []string) (string, bool) { + idx, ok := strings.CutPrefix(param, "contexts[") + if !ok { + return "", false + } + idx, ok = strings.CutSuffix(idx, "]") + if !ok { + return "", false + } + i, err := strconv.Atoi(idx) + if err != nil || i < 0 || i >= len(refs) { + return "", false + } + return runs.Truncate(runs.SanitizeLine(refs[i]), 80), true +} diff --git a/cmd/run_contexts_test.go b/cmd/run_contexts_test.go new file mode 100644 index 0000000..9fd654c --- /dev/null +++ b/cmd/run_contexts_test.go @@ -0,0 +1,241 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/nxssie/nan-cli/internal/api" +) + +// Agent contexts on runs (devops AGENT-CONTEXT.md §2.5, §6, C2): nan run +// --context NAME (repeatable, at most 5, order kept) sends "contexts" on +// POST /v1/runs; an ephemeral run may leave out --repo only when it loads +// at least one context. Every mistake is a usage error (64) before any +// request; the platform's context refusals read as what to do. + +// requestGolden is the request body as the CLI marshals it. +func requestGolden(t *testing.T, name string, v any) { + t.Helper() + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatal(err) + } + checkGolden(t, "contexts/"+name, string(b)+"\n") +} + +func TestRunContextsAreSentInOrder(t *testing.T) { + h := newHarness(t, &fakePlatform{workspaces: `[{"workspace_uuid":"w1","name":"develop","status":"running"}]`}) + opts := defaultOpts() + opts.contexts = []string{"acme-api", "style-guide"} + if err := doRun(context.Background(), h.env, opts, []string{"review it"}); err != nil { + t.Fatal(err) + } + got, _ := h.fake.created["contexts"].([]any) + if fmt.Sprint(got) != "[acme-api style-guide]" { + t.Errorf("contexts sent: %v (%v)", h.fake.created["contexts"], h.fake.created) + } + wantAll(t, "stderr", h.stderr.String(), "contexts acme-api, style-guide") +} + +func TestRunWithoutContextsSendsNone(t *testing.T) { + req, err := buildRunRequest(promptInput{}, defaultOpts(), []string{"x"}) + if err != nil { + t.Fatal(err) + } + b, _ := json.Marshal(req) + if strings.Contains(string(b), "contexts") { + t.Errorf("a run without --context sent contexts: %s", b) + } +} + +func TestRunContextRequestGoldens(t *testing.T) { + ws := defaultOpts() + ws.workspace, ws.contexts = "develop", []string{"acme-api", "style-guide"} + req, err := buildRunRequest(promptInput{}, ws, []string{"review the open pull requests"}) + if err != nil { + t.Fatal(err) + } + requestGolden(t, "run_request_workspace.json", req) + + eph := ephemeralOpts() + eph.repo, eph.size, eph.contexts = "", "m", []string{"acme-api"} + req, err = buildRunRequest(promptInput{}, eph, []string{"write the release notes"}) + if err != nil { + t.Fatal(err) + } + requestGolden(t, "run_request_ephemeral_no_repo.json", req) + + both := ephemeralOpts() + both.contexts = []string{"style-guide", "acme-api"} + req, err = buildRunRequest(promptInput{}, both, []string{"fix the flaky test"}) + if err != nil { + t.Fatal(err) + } + requestGolden(t, "run_request_ephemeral_repo.json", req) +} + +// An ephemeral run with a context and no repository: no github_repo is +// sent, and the announcement names no repository. +func TestEphemeralRunWithOnlyAContext(t *testing.T) { + h := newHarness(t, &fakePlatform{workspaces: "not json", ephemeral: true}) + opts := ephemeralOpts() + opts.repo, opts.contexts = "", []string{"acme-api"} + if err := doRun(context.Background(), h.env, opts, []string{"write the notes"}); err != nil { + t.Fatal(err) + } + c := h.fake.created + if _, ok := c["github_repo"]; ok || c["executor"] != "ephemeral" || fmt.Sprint(c["contexts"]) != "[acme-api]" { + t.Errorf("created with %v", c) + } +} + +func TestRunContextFlagMistakesAreUsageErrors(t *testing.T) { + six := []string{"a1", "a2", "a3", "a4", "a5", "a6"} + for _, tc := range []struct { + name string + edit func(*runOptions) + wants []string + }{ + {"six contexts", func(o *runOptions) { o.contexts = six }, []string{"at most 5", "--context"}}, + {"repeated", func(o *runOptions) { o.contexts = []string{"acme-api", "acme-api"} }, []string{"acme-api", "twice"}}, + {"repeated id, other case", func(o *runOptions) { + o.contexts = []string{"c0000000-0000-4000-8000-0000000000ab", "C0000000-0000-4000-8000-0000000000AB"} + }, []string{"twice"}}, + {"not a name", func(o *runOptions) { o.contexts = []string{"Acme API"} }, []string{"Acme API", "context name", "nan context ls"}}, + {"empty", func(o *runOptions) { o.contexts = []string{""} }, []string{"empty", "--context"}}, + {"control bytes", func(o *runOptions) { o.contexts = []string{"a\x1b[2Jb"} }, []string{"context name"}}, + {"ephemeral with neither repo nor context", func(o *runOptions) { o.ephemeral, o.model = true, "glm5.3" }, + []string{"--repo OWNER/NAME", "--context NAME"}}, + {"token secret without repo", func(o *runOptions) { + o.ephemeral, o.model, o.contexts = true, "glm5.3", []string{"acme-api"} + o.githubTokenSecret, o.githubTokenSecretSet = "GITHUB_TOKEN", true + }, []string{"--github-token-secret", "--repo"}}, + {"ephemeral context without model", func(o *runOptions) { o.ephemeral, o.contexts = true, []string{"acme-api"} }, + []string{"--model"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, &fakePlatform{}) + opts := defaultOpts() + 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...) + assertNoControls(t, tc.name, err.Error()) + if h.fake.created != nil { + t.Error("sent the run anyway") + } + }) + } +} + +// A context id is accepted in any case, as the platform does. +func TestRunContextAcceptsIDs(t *testing.T) { + opts := defaultOpts() + opts.contexts = []string{"C0000000-0000-4000-8000-0000000000AB", "acme-api"} + req, err := buildRunRequest(promptInput{}, opts, []string{"x"}) + if err != nil || len(req.Contexts) != 2 || req.Contexts[0] != "C0000000-0000-4000-8000-0000000000AB" { + t.Fatalf("%+v %v", req, err) + } +} + +// Through cobra: the flag repeats, and a sixth one stops the command +// before it even looks for a credential. +func TestRunContextFlagThroughTheCommand(t *testing.T) { + fakeHome(t, "") + t.Setenv(tokenEnvVar, "") + args := []string{"run"} + for _, n := range []string{"a1", "a2", "a3", "a4", "a5", "a6"} { + args = append(args, "--context", n) + } + code, msg := execute(t, append(args, "x")...) + if code != exitUsage || !strings.Contains(msg, "at most 5") { + t.Fatalf("exit %d: %s", code, msg) + } + code, msg = execute(t, "run", "--ephemeral", "--model", "glm5.3", "x") + if code != exitUsage || !strings.Contains(msg, "--context NAME") { + t.Fatalf("ephemeral without repo: exit %d: %s", code, msg) + } + // A comma is not a separator: one flag is one context. + code, msg = execute(t, "run", "--context", "a,b", "x") + if code != exitUsage || !strings.Contains(msg, `"a,b"`) { + t.Fatalf("comma: exit %d: %s", code, msg) + } +} + +func TestRunContextRefusalsSayWhatToDo(t *testing.T) { + for _, tc := range []struct { + name string + ephemeral bool + status int + code, param string + wants, avoids []string + }{ + {"unknown context", false, 404, "context_not_found", "contexts[1]", + []string{`context "style-guide" not found`, "nan context ls"}, []string{"workspace", "not enabled"}}, + {"unknown context, ephemeral", true, 404, "context_not_found", "contexts[0]", + []string{`context "acme-api" not found`, "nan context ls"}, []string{"not enabled"}}, + {"unknown context, no index", false, 404, "context_not_found", "contexts", + []string{"not found", "nan context ls"}, []string{"workspace"}}, + {"skill conflict", false, 422, "context_skill_conflict", "contexts", + []string{"same skill", "--context"}, nil}, + {"too large", true, 413, "context_too_large", "contexts", + []string{"too large for one run", "250 MiB", "fewer contexts"}, nil}, + {"bad reference", false, 400, "invalid_request", "contexts[0]", + []string{"--context", "acme-api"}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + param := "null" + if tc.param != "" { + param = fmt.Sprintf("%q", tc.param) + } + h := newHarness(t, &fakePlatform{ephemeral: tc.ephemeral, createStatus: tc.status, + createBody: fmt.Sprintf(`{"error":{"message":"refused \u001b[2J","type":"invalid_request_error","param":%s,"code":%q}}`, param, tc.code)}) + opts := defaultOpts() + opts.workspace = "develop" + if tc.ephemeral { + opts = ephemeralOpts() + } + opts.contexts = []string{"acme-api", "style-guide"} + 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()) + }) + } +} + +// The run object's pinned contexts are part of the client model, so +// nan run --json shows what the run loaded. +func TestRunJSONCarriesPinnedContexts(t *testing.T) { + var r api.Run + if err := json.Unmarshal([]byte(`{"id":"x","contexts":[{"id":"c1","name":"acme-api","version":3,"tree_sha256":"ab"}]}`), &r); err != nil { + t.Fatal(err) + } + if len(r.Contexts) != 1 || r.Contexts[0].Name != "acme-api" || r.Contexts[0].Version != 3 || r.Contexts[0].TreeSHA256 != "ab" { + t.Errorf("%+v", r.Contexts) + } + b, _ := json.Marshal(api.Run{}) + if strings.Contains(string(b), "contexts") { + t.Errorf("a run without contexts serialises them: %s", b) + } +} + +func TestRunHelpExplainsContexts(t *testing.T) { + wantAll(t, "nan run --help", runCmd.Long, "--context NAME", "at most 5", "nan context ls", "without --repo") + wantAll(t, "nan run examples", runCmd.Example, "--context") + if f := runCmd.Flags().Lookup("context"); f == nil || f.Value.Type() != "stringArray" { + t.Fatalf("--context flag: %+v", f) + } +} diff --git a/cmd/run_test.go b/cmd/run_test.go index 313600d..9f04a9d 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -36,6 +36,9 @@ type fakePlatform struct { dropFirst bool // close the first stream after its events, without `end` streamStatus int cancelStatus int + // ephemeral makes every run an ephemeral one (size m, acme/api, no + // workspace), metered once it is terminal. + ephemeral bool created map[string]any idemKey string @@ -51,6 +54,13 @@ func (f *fakePlatform) run(state string) string { if f.finalError != "" && api.Terminal(state) { ec = fmt.Sprintf("%q", f.finalError) } + if f.ephemeral { + cost := "null" + if api.Terminal(state) { + cost = `{"eur":0.018,"eur_micros":18000,"billed_seconds":180}` + } + return fmt.Sprintf(`{"id":%q,"object":"run","workspace":null,"executor":"ephemeral","size":"m","cost":%s,"github_repo":"acme/api","agent":"pi","model":"glm5.3","trigger":"cli","state":%q,"queue_position":null,"prompt_preview":"review","cwd":"/home/nan/work","git_isolation":null,"timeout_seconds":1800,"cancel_requested":false,"exit_code":null,"error_code":%s,"result":null,"usage":null,"last_seq":2,"created_at":"2026-10-10T12:00:00Z","started_at":null,"finished_at":null}`, testRunID, cost, state, ec) + } return fmt.Sprintf(`{"id":%q,"object":"run","workspace":{"id":"w1","name":"develop"},"agent":"pi","model":null,"trigger":"cli","state":%q,"queue_position":null,"prompt_preview":"review","cwd":"/home/nan","git_isolation":null,"timeout_seconds":1800,"cancel_requested":false,"exit_code":null,"error_code":%s,"result":null,"usage":{"tokens_in":null,"tokens_out":null,"source":null},"last_seq":2,"created_at":"2026-10-10T12:00:00Z","started_at":null,"finished_at":null}`, testRunID, state, ec) } diff --git a/cmd/runs.go b/cmd/runs.go index bfb313f..426e7da 100644 --- a/cmd/runs.go +++ b/cmd/runs.go @@ -284,17 +284,32 @@ func doRunsLs(ctx context.Context, env *runsEnv) error { for i := range list.Data { env.nameRunWorkspace(ctx, &list.Data[i]) } + // The COST column shows only when an ephemeral run is listed: a + // workspace run has no cost of its own. + withCost := false + for i := range list.Data { + withCost = withCost || list.Data[i].Ephemeral() + } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) - 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\t%s\n", + if withCost { + fmt.Fprintln(tw, "ID\tSTATE\tWORKSPACE\tAGENT\tINFERENCE\tCOST\tCREATED\tPROMPT") + } else { + fmt.Fprintln(tw, "ID\tSTATE\tWORKSPACE\tAGENT\tINFERENCE\tCREATED\tPROMPT") + } + for i := range list.Data { + r := &list.Data[i] + cols := []string{ runs.SanitizeLine(r.ID), runs.SanitizeLine(r.State), - runs.Truncate(runs.SanitizeLine(refLabel(r.Workspace.Name, r.Workspace.ID)), 40), + runWhere(r), runs.SanitizeLine(r.Agent), inferenceShort(r.Inference), - localTime(r.CreatedAt), - runs.Truncate(runs.SanitizeLine(r.PromptPreview), 60)) + } + if withCost { + cols = append(cols, runCostShort(r)) + } + cols = append(cols, localTime(r.CreatedAt), runs.Truncate(runs.SanitizeLine(r.PromptPreview), 60)) + fmt.Fprintln(tw, strings.Join(cols, "\t")) } if err := tw.Flush(); err != nil { return err @@ -351,7 +366,11 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { if run.ExitCode != nil { row("exit code", fmt.Sprint(*run.ExitCode)) } - row("workspace", runs.NameAndID(run.Workspace.Name, run.Workspace.ID)) + if run.Ephemeral() { + row("executor", "ephemeral: a fresh container, size "+sizeLabel(run.Size)) + } else { + row("workspace", runs.NameAndID(run.Workspace.Name, run.Workspace.ID)) + } agent := runs.SanitizeLine(run.Agent) if run.Model != nil && *run.Model != "" { agent += " · model " + runs.SanitizeLine(*run.Model) @@ -387,12 +406,15 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { for _, apply := range applyRuns(run) { row("apply run", runs.SanitizeLine(apply)) } - row("cwd", runs.SanitizeLine(run.Cwd)) - worktree := "auto" - if run.GitIsolation != nil { - worktree = map[bool]string{true: "yes", false: "no"}[*run.GitIsolation] + if !run.Ephemeral() { + // An ephemeral run works in its fresh checkout: neither applies. + row("cwd", runs.SanitizeLine(run.Cwd)) + worktree := "auto" + if run.GitIsolation != nil { + worktree = map[bool]string{true: "yes", false: "no"}[*run.GitIsolation] + } + row("worktree", worktree) } - row("worktree", worktree) if run.TimeoutSeconds > 0 { row("timeout", (time.Duration(run.TimeoutSeconds) * time.Second).String()) } @@ -430,9 +452,15 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { } row("tokens", tokens) } + if run.Ephemeral() { + row("cost", runCostLabel(run)) + } if err := tw.Flush(); err != nil { return err } + if run.Ephemeral() && run.Cost != nil { + env.billingNote(ctx) + } if run.State == api.StateAwaitingApproval { fmt.Fprintln(env.stdout) fmt.Fprintln(env.stdout, "awaiting your approval:") diff --git a/cmd/testdata/contexts/automation_create_ephemeral_none.json b/cmd/testdata/contexts/automation_create_ephemeral_none.json new file mode 100644 index 0000000..03bbc31 --- /dev/null +++ b/cmd/testdata/contexts/automation_create_ephemeral_none.json @@ -0,0 +1,12 @@ +{ + "agent": "pi", + "checkout": "none", + "contexts": [ + "acme-api" + ], + "executor": "ephemeral", + "instructions": "Write the notes.", + "model": "glm5.3", + "name": "weekly notes", + "size": "s" +} diff --git a/cmd/testdata/contexts/automation_create_workspace.json b/cmd/testdata/contexts/automation_create_workspace.json new file mode 100644 index 0000000..4be9fce --- /dev/null +++ b/cmd/testdata/contexts/automation_create_workspace.json @@ -0,0 +1,10 @@ +{ + "agent": "pi", + "contexts": [ + "acme-api", + "style-guide" + ], + "instructions": "Review it.", + "name": "PR review api", + "workspace": "develop" +} diff --git a/cmd/testdata/contexts/automation_edit_checkout_none.json b/cmd/testdata/contexts/automation_edit_checkout_none.json new file mode 100644 index 0000000..24ae764 --- /dev/null +++ b/cmd/testdata/contexts/automation_edit_checkout_none.json @@ -0,0 +1,7 @@ +{ + "checkout": "none", + "contexts": [ + "acme-api" + ], + "github_repo": null +} diff --git a/cmd/testdata/contexts/ls.golden b/cmd/testdata/contexts/ls.golden new file mode 100644 index 0000000..b462f08 --- /dev/null +++ b/cmd/testdata/contexts/ls.golden @@ -0,0 +1,3 @@ +NAME VERSION FILES SIZE UPDATED DESCRIPTION +acme-api 3 3 83 B 2026-10-21 10:00 Acme API conventions +storage: 1.5 KiB of 500.0 MiB used diff --git a/cmd/testdata/contexts/pull.golden b/cmd/testdata/contexts/pull.golden new file mode 100644 index 0000000..955ac81 --- /dev/null +++ b/cmd/testdata/contexts/pull.golden @@ -0,0 +1 @@ +context acme-api version 3 written to DIR (4 files, 101 B) diff --git a/cmd/testdata/contexts/push.golden b/cmd/testdata/contexts/push.golden new file mode 100644 index 0000000..716ad67 --- /dev/null +++ b/cmd/testdata/contexts/push.golden @@ -0,0 +1 @@ +context acme-api: version 3 published (6 files, 116 B; 5 added, 0 changed, 0 removed; uploaded 4 files, 101 B) diff --git a/cmd/testdata/contexts/push_dry_run.golden b/cmd/testdata/contexts/push_dry_run.golden new file mode 100644 index 0000000..d1c84e7 --- /dev/null +++ b/cmd/testdata/contexts/push_dry_run.golden @@ -0,0 +1,6 @@ +context acme-api: version 3 -> 4 (dry run, nothing changed) + + files/new.md (9 B) + + skills/pr-review/SKILL.md (67 B) + ~ AGENTS.md (10 B) + - files/old.md +2 added, 1 changed, 1 removed; 3 files to upload (86 B) diff --git a/cmd/testdata/contexts/push_dry_run.json.golden b/cmd/testdata/contexts/push_dry_run.json.golden new file mode 100644 index 0000000..8031c09 --- /dev/null +++ b/cmd/testdata/contexts/push_dry_run.json.golden @@ -0,0 +1,20 @@ +{ + "context": "acme-api", + "base_version": 3, + "changes": { + "added": [ + "files/new.md", + "skills/pr-review/SKILL.md" + ], + "changed": [ + "AGENTS.md" + ], + "removed": [ + "files/old.md" + ] + }, + "upload": { + "files": 3, + "bytes": 86 + } +} diff --git a/cmd/testdata/contexts/push_refused.stderr.golden b/cmd/testdata/contexts/push_refused.stderr.golden new file mode 100644 index 0000000..0d2dec0 --- /dev/null +++ b/cmd/testdata/contexts/push_refused.stderr.golden @@ -0,0 +1,10 @@ +skipped .git (dotfiles are not kept in a context) +skipped files/.env (dotfiles are not kept in a context) +skipped files/sub/.hidden (dotfiles are not kept in a context) +refused CLAUDE.md: bad_top_level (rename it to AGENTS.md: that is the file the agents read) +refused README.md: bad_top_level (only AGENTS.md lives at the top; put reference files under files/) +refused files/con.txt: reserved_name +refused files/rocket🚀.md: bad_character +refused notes/a.md: bad_top_level (put reference files under files/ and skills under skills//) +refused skills/Bad Skill/SKILL.md: bad_character (a skill folder name is lowercase letters, digits and dashes) +refused skills/no-skill-md/SKILL.md: skill_invalid (every skill folder needs a SKILL.md) diff --git a/cmd/testdata/contexts/run_request_ephemeral_no_repo.json b/cmd/testdata/contexts/run_request_ephemeral_no_repo.json new file mode 100644 index 0000000..85649ed --- /dev/null +++ b/cmd/testdata/contexts/run_request_ephemeral_no_repo.json @@ -0,0 +1,11 @@ +{ + "agent": "pi", + "prompt": "write the release notes", + "model": "glm5.3", + "trigger": "cli", + "executor": "ephemeral", + "size": "m", + "contexts": [ + "acme-api" + ] +} diff --git a/cmd/testdata/contexts/run_request_ephemeral_repo.json b/cmd/testdata/contexts/run_request_ephemeral_repo.json new file mode 100644 index 0000000..42befd9 --- /dev/null +++ b/cmd/testdata/contexts/run_request_ephemeral_repo.json @@ -0,0 +1,12 @@ +{ + "agent": "pi", + "prompt": "fix the flaky test", + "model": "glm5.3", + "trigger": "cli", + "executor": "ephemeral", + "github_repo": "acme/api", + "contexts": [ + "style-guide", + "acme-api" + ] +} diff --git a/cmd/testdata/contexts/run_request_workspace.json b/cmd/testdata/contexts/run_request_workspace.json new file mode 100644 index 0000000..869ba83 --- /dev/null +++ b/cmd/testdata/contexts/run_request_workspace.json @@ -0,0 +1,10 @@ +{ + "workspace": "develop", + "agent": "pi", + "prompt": "review the open pull requests", + "trigger": "cli", + "contexts": [ + "acme-api", + "style-guide" + ] +} diff --git a/cmd/testdata/contexts/show.golden b/cmd/testdata/contexts/show.golden new file mode 100644 index 0000000..b053ea2 --- /dev/null +++ b/cmd/testdata/contexts/show.golden @@ -0,0 +1,12 @@ +name acme-api +id c7000000-0000-4000-8000-000000000001 +description Acme API conventions +version 3 (2026-10-21 10:00) +files 3, 83 B +instructions yes +skills pr-review + +PATH SIZE SHA256 +AGENTS.md 10 B 2d812d6fdf8… +files/spec.md 6 B 6b04df15eab… +skills/pr-review/SKILL.md 67 B da1195fa204… diff --git a/cmd/testdata/ephemeral/automations_ls.golden b/cmd/testdata/ephemeral/automations_ls.golden new file mode 100644 index 0000000..1d505fd --- /dev/null +++ b/cmd/testdata/ephemeral/automations_ls.golden @@ -0,0 +1,2 @@ +ID NAME WORKSPACE AGENT STATUS APPROVAL +a0000000-0000-4000-8000-000000000001 PR review api ephemeral L pi enabled github_pr_review diff --git a/cmd/testdata/ephemeral/automations_show.golden b/cmd/testdata/ephemeral/automations_show.golden new file mode 100644 index 0000000..34215d0 --- /dev/null +++ b/cmd/testdata/ephemeral/automations_show.golden @@ -0,0 +1,23 @@ +id a0000000-0000-4000-8000-000000000001 +name PR review api +description reviews +status enabled +template pr_review +executor ephemeral: a fresh container per run, size L (4 vCPU, 12 GiB RAM, 20 GB disk) +agent pi · model glm5.3 +inference NaN +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) +updated 2026-10-11 09:00 +last changed from Browser + +instructions: + Review for correctness. + +run it: nan automations run a0000000-0000-4000-8000-000000000001 +change it: nan automations edit a0000000-0000-4000-8000-000000000001 -f FILE, or in the portal: https://cloud.nan.builders/automations/a0000000-0000-4000-8000-000000000001 diff --git a/cmd/testdata/ephemeral/runs_ls.golden b/cmd/testdata/ephemeral/runs_ls.golden new file mode 100644 index 0000000..7814d6f --- /dev/null +++ b/cmd/testdata/ephemeral/runs_ls.golden @@ -0,0 +1,4 @@ +ID STATE WORKSPACE AGENT INFERENCE COST CREATED PROMPT +6f1c2a9b-0000-4000-8000-000000000001 succeeded ephemeral M pi nan €0.018 2026-10-10 09:00 review PR 42 +6f1c2a9b-0000-4000-8000-000000000001 running ephemeral M pi nan pending 2026-10-10 09:00 review PR 42 +6f1c2a9b-0000-4000-8000-000000000001 succeeded develop pi nan - 2026-10-10 09:00 review PR 42 diff --git a/cmd/testdata/ephemeral/runs_show.golden b/cmd/testdata/ephemeral/runs_show.golden new file mode 100644 index 0000000..7e6a39b --- /dev/null +++ b/cmd/testdata/ephemeral/runs_show.golden @@ -0,0 +1,22 @@ +id 6f1c2a9b-0000-4000-8000-000000000001 +state succeeded +executor ephemeral: a fresh container, size M (2 vCPU, 8 GiB RAM, 16 GB disk) +agent pi · model glm5.3 +inference NaN +trigger cli +repository acme/api +timeout 30m0s +created 2026-10-10 09:00 +started 2026-10-10 09:00 +branch nan-run/6f1c2a9b +commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +changed files 3 +cost €0.018 for 3 started minutes of size M, before your free minutes (nan ephemeral usage) + +note: not charged yet: billing is not active, usage is metered and shown only + +prompt: + review PR 42 in full + +summary: + done diff --git a/cmd/testdata/ephemeral/settings.golden b/cmd/testdata/ephemeral/settings.golden new file mode 100644 index 0000000..55a12a9 --- /dev/null +++ b/cmd/testdata/ephemeral/settings.golden @@ -0,0 +1,3 @@ +monthly spend cap €100.00 (€0.00 to €1000.00) +concurrent runs up to 50 at once (1 to 50) +billing not active yet: usage is metered and shown, not charged diff --git a/cmd/testdata/ephemeral/usage.golden b/cmd/testdata/ephemeral/usage.golden new file mode 100644 index 0000000..a7369b9 --- /dev/null +++ b/cmd/testdata/ephemeral/usage.golden @@ -0,0 +1,14 @@ +ephemeral runs, 2026-10 (inference plan) + +container time 49 minutes +free minutes left 247 S-minutes of 300 (= 123 M or 61 L minutes); 53 used +cost this month €0.00 after your free minutes (not charged yet: billing is not active, usage is metered and shown only) +monthly cap €100.00 (change it in the browser, Settings at https://cloud.nan.builders/settings) + +prices per started minute (a 14 s run is 1 minute): + S 1 vCPU, 4 GiB RAM, 10 GB disk €0.003 a minute uses 1 free S-minute + M 2 vCPU, 8 GiB RAM, 16 GB disk €0.006 a minute uses 2 free S-minutes + L 4 vCPU, 12 GiB RAM, 20 GB disk €0.012 a minute uses 4 free S-minutes + +Free minutes are S-minutes: one M minute uses 2, one L minute 4 +(100 free minutes = 100 minutes of S, 50 of M or 25 of L). diff --git a/cmd/writeapi.go b/cmd/writeapi.go index be63acd..525056a 100644 --- a/cmd/writeapi.go +++ b/cmd/writeapi.go @@ -151,6 +151,9 @@ func writeExit(err error, cred credential, what, scope string) *ExitError { if !errors.As(err, &apiErr) { return apiExit(err, cred) } + if exit := ephemeralExit(apiErr, cred); exit != nil { + return exit + } msg := runs.SanitizeLine(apiErr.Message) switch apiErr.Code { case "browser_session_required": diff --git a/go.mod b/go.mod index 66f3047..7ef4447 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/nxssie/nan-cli -go 1.26.9 +go 1.26.10 require ( github.com/charmbracelet/bubbles v1.0.0 @@ -8,6 +8,9 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.2 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 + go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/text v0.39.0 ) require ( @@ -29,9 +32,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index a2f04d3..092b071 100644 --- a/go.sum +++ b/go.sum @@ -60,4 +60,5 @@ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/automations.go b/internal/api/automations.go index eb88c94..94773d7 100644 --- a/internal/api/automations.go +++ b/internal/api/automations.go @@ -64,8 +64,16 @@ type Automation struct { Enabled bool `json:"enabled"` DisabledReason *string `json:"disabled_reason"` UpdatedBy string `json:"updated_by,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + // Checkout is "workspace", "fresh" (a new clone of github_repo per run) + // or "none" (an ephemeral automation without a repository, working on + // its agent contexts). + Checkout string `json:"checkout,omitempty"` + // Executor is where its runs execute ("workspace" or "ephemeral"), Size + // the container size of an ephemeral automation (nil otherwise). + Executor string `json:"executor,omitempty"` + Size *string `json:"size,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type AutomationList struct { diff --git a/internal/api/contexts.go b/internal/api/contexts.go new file mode 100644 index 0000000..4e159ac --- /dev/null +++ b/internal/api/contexts.go @@ -0,0 +1,442 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// Agent contexts (helmcode/nan-devops AGENT-CONTEXT.md §2): versioned file +// trees a member keeps for their runs and automations, under /v1/contexts. +// Reads take a CLI session, the member's sk- key or a platform token with +// contexts:read; writes take a CLI session or a token with contexts:write. +// +// Content goes up as content-addressed blobs (sha256): POST blobs/check says +// which ones the platform lacks, a blob of at most PartSize bytes is one PUT, +// a larger one a multipart upload of PartSize parts that can be resumed. A +// version is then published with the full file list and the version it was +// based on. This client only speaks the protocol; the push and pull logic +// lives with the command. + +// ContextPartSize is the platform's single-upload limit and multipart part +// size (16 MiB). +const ContextPartSize = 16 << 20 + +// ContextMaxFileBytes is the largest file a context holds (100 MiB). +const ContextMaxFileBytes = 100 << 20 + +// maxCheckShas is the platform's cap on one blobs/check body. +const maxCheckShas = 1000 + +// Context is a context's metadata at its current version (or, for a +// publish replay, at the version that publish created). +type Context struct { + ID string `json:"id"` + Object string `json:"object"` + Name string `json:"name"` + Description string `json:"description"` + Version int `json:"version"` + TreeSHA256 string `json:"tree_sha256"` + FileCount int `json:"file_count"` + TotalBytes int64 `json:"total_bytes"` + HasInstructions bool `json:"has_instructions"` + Skills []string `json:"skills"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ContextFile is one file of a version: what a pull writes. +type ContextFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Executable bool `json:"executable"` +} + +// ContextDetail is GET /v1/contexts/{id_or_name}: the context and the files +// of its current version. +type ContextDetail struct { + Context + Files []ContextFile `json:"files"` +} + +type ContextUsage struct { + StoredBytes int64 `json:"stored_bytes"` + QuotaBytes int64 `json:"quota_bytes"` +} + +type ContextList struct { + Data []Context `json:"data"` + Usage ContextUsage `json:"usage"` +} + +// SecretFinding is where a text blob looks like it holds a credential. The +// platform never returns the match itself. +type SecretFinding struct { + Line int `json:"line"` + Pattern string `json:"pattern"` +} + +// ContextBlob is a stored blob. +type ContextBlob struct { + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Format string `json:"format"` + SecretFindings []SecretFinding `json:"secret_findings"` +} + +// ContextUpload is a multipart upload in progress. +type ContextUpload struct { + UploadID string `json:"upload_id"` + PartSize int `json:"part_size"` + Parts int `json:"parts"` + NextPart int `json:"next_part"` +} + +// PublishFile is one entry of a publish. +type PublishFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Executable bool `json:"executable"` +} + +// PublishRequest is the body of POST /v1/contexts/{id}/versions. +type PublishRequest struct { + BaseVersion int `json:"base_version"` + Files []PublishFile `json:"files"` + AllowSecretLike bool `json:"allow_secret_like"` +} + +// ContextPatch is the body of PATCH /v1/contexts/{id}: nil fields stay. +type ContextPatch struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` +} + +// FileRefusal is one refused file of a publish: invalid_path, +// unsupported_file_type, skill_invalid (reason per file, detail optional). +type FileRefusal struct { + Path string `json:"path"` + Reason string `json:"reason"` + Detail string `json:"detail,omitempty"` +} + +// SecretHit is one finding of 422 context_secret_detected. +type SecretHit struct { + Path string `json:"path"` + Line int `json:"line"` + Pattern string `json:"pattern"` +} + +// ContextError is a refusal of the contexts API with the fields the +// platform adds next to the error envelope. It unwraps to the *APIError, so +// the generic exit mapping still sees the code. +type ContextError struct { + *APIError + // RetryAfter is the Retry-After header (0 when absent). + RetryAfter time.Duration + // CurrentVersion is set on 409 version_conflict. + CurrentVersion *int + // MissingBlobs is 409 blob_missing's sha256 list. + MissingBlobs []string + // Files is the per-file refusal list of invalid_path, + // unsupported_file_type and skill_invalid. + Files []FileRefusal + // Findings is 422 context_secret_detected's list. + Findings []SecretHit + // Used and Quota are 413 context_quota_exceeded's figures. + Used, Quota int64 +} + +func (e *ContextError) Error() string { return e.APIError.Error() } +func (e *ContextError) Unwrap() error { return e.APIError } + +// AsContextError finds a *ContextError in err. +func AsContextError(err error) (*ContextError, bool) { + var ce *ContextError + if errors.As(err, &ce) { + return ce, true + } + return nil, false +} + +func (c *RunsClient) contextError(status int, header http.Header, body []byte) error { + err := c.statusError(status, body) + apiErr, ok := err.(*APIError) + if !ok { + return err + } + ce := &ContextError{APIError: apiErr} + if s := header.Get("Retry-After"); s != "" { + if n, perr := strconv.Atoi(s); perr == nil && n >= 0 { + ce.RetryAfter = time.Duration(n) * time.Second + } + } + var extra struct { + CurrentVersion *int `json:"current_version"` + SHA256 []string `json:"sha256"` + Files []FileRefusal `json:"files"` + Findings []SecretHit `json:"findings"` + Used int64 `json:"used"` + Quota int64 `json:"quota"` + Automations []Ref `json:"automations"` + } + if json.Unmarshal(body, &extra) == nil { + ce.CurrentVersion = extra.CurrentVersion + ce.MissingBlobs = extra.SHA256 + ce.Files = extra.Files + ce.Findings = extra.Findings + ce.Used, ce.Quota = extra.Used, extra.Quota + if len(apiErr.Automations) == 0 { + apiErr.Automations = extra.Automations + } + } + return ce +} + +// ctxDo sends req with client and decodes a 2xx JSON answer into out. +func (c *RunsClient) ctxDo(client *http.Client, req *http.Request, out any) (int, error) { + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("could not reach nan.builders: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse)) + if err != nil { + return resp.StatusCode, fmt.Errorf("could not read the answer from nan.builders: %w", err) + } + if resp.StatusCode >= 400 { + return resp.StatusCode, c.contextError(resp.StatusCode, resp.Header, body) + } + if out != nil && len(bytes.TrimSpace(body)) > 0 { + if err := json.Unmarshal(body, out); err != nil { + return resp.StatusCode, fmt.Errorf("unexpected answer from nan.builders: %w", err) + } + } + return resp.StatusCode, nil +} + +func (c *RunsClient) ctxJSON(ctx context.Context, method, path string, body, out any, idemKey string) (int, error) { + req, err := c.newRequest(ctx, method, path, body) + if err != nil { + return 0, err + } + if idemKey != "" { + req.Header.Set("Idempotency-Key", idemKey) + } + return c.ctxDo(c.http, req, out) +} + +// rawRequest is a request whose body is streamed bytes of a known length. +func (c *RunsClient) rawRequest(ctx context.Context, method, path string, body io.Reader, size int64) (*http.Request, error) { + if body == nil || size == 0 { + body = http.NoBody + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, err + } + req.ContentLength = size + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "nan-cli") + c.authorize(req) + return req, nil +} + +// BaseURL is where this client sends its requests. +func (c *RunsClient) BaseURL() string { return c.baseURL } + +func contextPath(id string) string { return "/v1/contexts/" + url.PathEscape(id) } + +func blobPath(sha string) string { return "/v1/contexts/blobs/" + url.PathEscape(sha) } + +func uploadPath(sha, uploadID string) string { + return blobPath(sha) + "/uploads/" + url.PathEscape(uploadID) +} + +// ListContexts is GET /v1/contexts. +func (c *RunsClient) ListContexts(ctx context.Context) (*ContextList, error) { + out := &ContextList{} + _, err := c.ctxJSON(ctx, http.MethodGet, "/v1/contexts", nil, out, "") + return out, err +} + +// GetContext is GET /v1/contexts/{id_or_name}. +func (c *RunsClient) GetContext(ctx context.Context, idOrName string) (*ContextDetail, error) { + out := &ContextDetail{} + _, err := c.ctxJSON(ctx, http.MethodGet, contextPath(idOrName), nil, out, "") + return out, err +} + +// CreateContext is POST /v1/contexts: the context with an empty version 1. +func (c *RunsClient) CreateContext(ctx context.Context, name, description, idemKey string) (*Context, error) { + body := map[string]string{"name": name} + if description != "" { + body["description"] = description + } + out := &Context{} + _, err := c.ctxJSON(ctx, http.MethodPost, "/v1/contexts", body, out, idemKey) + return out, err +} + +// UpdateContext is PATCH /v1/contexts/{id} (name and description only; no +// new version). +func (c *RunsClient) UpdateContext(ctx context.Context, id string, p ContextPatch) (*Context, error) { + out := &Context{} + _, err := c.ctxJSON(ctx, http.MethodPatch, contextPath(id), p, out, "") + return out, err +} + +// DeleteContext is DELETE /v1/contexts/{id}; 409 context_in_use names the +// automations that use it. +func (c *RunsClient) DeleteContext(ctx context.Context, id string) error { + _, err := c.ctxJSON(ctx, http.MethodDelete, contextPath(id), nil, nil, "") + return err +} + +// CheckBlobs is POST /v1/contexts/blobs/check: which of shas the platform +// does not hold for this member (asked in batches of the platform's cap). +func (c *RunsClient) CheckBlobs(ctx context.Context, shas []string) ([]string, error) { + missing := []string{} + for start := 0; start < len(shas); start += maxCheckShas { + end := min(start+maxCheckShas, len(shas)) + var out struct { + Missing []string `json:"missing"` + } + if _, err := c.ctxJSON(ctx, http.MethodPost, "/v1/contexts/blobs/check", map[string][]string{"sha256": shas[start:end]}, &out, ""); err != nil { + return nil, err + } + missing = append(missing, out.Missing...) + } + return missing, nil +} + +// PutBlob is PUT /v1/contexts/blobs/{sha256}: one upload of at most +// ContextPartSize bytes. filename is a type hint for the platform only +// (X-Nan-Filename); it is never stored. The request has no client timeout: +// ctx bounds it. +func (c *RunsClient) PutBlob(ctx context.Context, sha string, body io.Reader, size int64, filename string) (*ContextBlob, error) { + req, err := c.rawRequest(ctx, http.MethodPut, blobPath(sha), body, size) + if err != nil { + return nil, err + } + if filename != "" { + req.Header.Set("X-Nan-Filename", filename) + } + out := &ContextBlob{} + _, err = c.ctxDo(c.stream, req, out) + return out, err +} + +// StartUpload is POST /v1/contexts/blobs/{sha256}/uploads for a blob larger +// than ContextPartSize. When the platform already holds the blob it answers +// it instead of an upload (blob non-nil, upload nil). +func (c *RunsClient) StartUpload(ctx context.Context, sha string, size int64, filename string) (*ContextUpload, *ContextBlob, error) { + req, err := c.newRequest(ctx, http.MethodPost, blobPath(sha)+"/uploads", map[string]any{"size": size, "filename": filename}) + if err != nil { + return nil, nil, err + } + var raw json.RawMessage + status, err := c.ctxDo(c.http, req, &raw) + if err != nil { + return nil, nil, err + } + if status == http.StatusOK { + b := &ContextBlob{} + return nil, b, json.Unmarshal(raw, b) + } + u := &ContextUpload{} + return u, nil, json.Unmarshal(raw, u) +} + +// UploadStatus is GET …/uploads/{upload_id}: where a multipart upload +// stands, to resume it. +func (c *RunsClient) UploadStatus(ctx context.Context, sha, uploadID string) (*ContextUpload, error) { + out := &ContextUpload{} + _, err := c.ctxJSON(ctx, http.MethodGet, uploadPath(sha, uploadID), nil, out, "") + return out, err +} + +// PutPart is PUT …/uploads/{upload_id}/parts/{n}; it answers the next part +// the platform expects. +func (c *RunsClient) PutPart(ctx context.Context, sha, uploadID string, n int, body io.Reader, size int64) (int, error) { + req, err := c.rawRequest(ctx, http.MethodPut, uploadPath(sha, uploadID)+"/parts/"+strconv.Itoa(n), body, size) + if err != nil { + return 0, err + } + var out struct { + NextPart int `json:"next_part"` + } + _, err = c.ctxDo(c.stream, req, &out) + return out.NextPart, err +} + +// CompleteUpload is POST …/uploads/{upload_id}/complete. +func (c *RunsClient) CompleteUpload(ctx context.Context, sha, uploadID string) (*ContextBlob, error) { + req, err := c.newRequest(ctx, http.MethodPost, uploadPath(sha, uploadID)+"/complete", nil) + if err != nil { + return nil, err + } + out := &ContextBlob{} + _, err = c.ctxDo(c.stream, req, out) + return out, err +} + +// AbortUpload is DELETE …/uploads/{upload_id}. +func (c *RunsClient) AbortUpload(ctx context.Context, sha, uploadID string) error { + _, err := c.ctxJSON(ctx, http.MethodDelete, uploadPath(sha, uploadID), nil, nil, "") + return err +} + +// PublishContext is POST /v1/contexts/{id}/versions. The answer is the +// context at the new version. It can take a while (the platform rescans and +// reads every SKILL.md), so ctx bounds it instead of the client timeout. +func (c *RunsClient) PublishContext(ctx context.Context, id string, in PublishRequest, idemKey string) (*Context, error) { + if in.Files == nil { + in.Files = []PublishFile{} + } + req, err := c.newRequest(ctx, http.MethodPost, contextPath(id)+"/versions", in) + if err != nil { + return nil, err + } + if idemKey != "" { + req.Header.Set("Idempotency-Key", idemKey) + } + out := &Context{} + _, err = c.ctxDo(c.stream, req, out) + return out, err +} + +// OpenContextFile is GET /v1/contexts/{id}/file?path=…&version=N: the +// file's bytes as a stream the caller closes. The caller verifies them +// against the manifest; nothing here trusts the length or the content. +func (c *RunsClient) OpenContextFile(ctx context.Context, id, path string, version int) (io.ReadCloser, error) { + q := url.Values{} + q.Set("path", path) + if version > 0 { + q.Set("version", strconv.Itoa(version)) + } + req, err := c.newRequest(ctx, http.MethodGet, contextPath(id)+"/file?"+q.Encode(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/octet-stream") + resp, err := c.stream.Do(req) + if err != nil { + return nil, fmt.Errorf("could not reach nan.builders: %w", err) + } + if resp.StatusCode >= 400 { + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return nil, c.contextError(resp.StatusCode, resp.Header, body) + } + return resp.Body, nil +} diff --git a/internal/api/contexts_test.go b/internal/api/contexts_test.go new file mode 100644 index 0000000..e74a425 --- /dev/null +++ b/internal/api/contexts_test.go @@ -0,0 +1,161 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func ctxServer(t *testing.T, h http.HandlerFunc) *RunsClient { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return NewRunsClient("sess", "").WithBaseURL(srv.URL) +} + +// The contexts API puts its extra fields next to the error envelope (cloud-api +// ContextsHandler.fail); every one of them reaches the caller. +func TestContextErrorCarriesTheExtraFields(t *testing.T) { + for _, tc := range []struct { + body string + check func(*ContextError) bool + }{ + {`{"error":{"message":"changed","type":"invalid_request_error","param":"base_version","code":"version_conflict"},"current_version":7}`, + func(e *ContextError) bool { + return e.CurrentVersion != nil && *e.CurrentVersion == 7 && e.Param == "base_version" + }}, + {`{"error":{"message":"gone","type":"invalid_request_error","param":null,"code":"blob_missing"},"sha256":["aa","bb"]}`, + func(e *ContextError) bool { return strings.Join(e.MissingBlobs, ",") == "aa,bb" }}, + {`{"error":{"message":"names","type":"invalid_request_error","param":null,"code":"invalid_path"},"files":[{"path":"files/.env","reason":"dotfile"}]}`, + func(e *ContextError) bool { return len(e.Files) == 1 && e.Files[0].Reason == "dotfile" }}, + {`{"error":{"message":"secrets","type":"invalid_request_error","param":null,"code":"context_secret_detected"},"findings":[{"path":"AGENTS.md","line":3,"pattern":"github_token"}]}`, + func(e *ContextError) bool { return len(e.Findings) == 1 && e.Findings[0].Line == 3 }}, + {`{"error":{"message":"quota","type":"invalid_request_error","param":null,"code":"context_quota_exceeded"},"used":10,"quota":5,"growing":true}`, + func(e *ContextError) bool { return e.Used == 10 && e.Quota == 5 }}, + {`{"error":{"message":"in use","type":"invalid_request_error","param":null,"code":"context_in_use"},"automations":[{"id":"a1","name":"PR review"}]}`, + func(e *ContextError) bool { return len(e.Automations) == 1 && e.Automations[0].Name == "PR review" }}, + } { + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "5") + w.WriteHeader(409) + _, _ = io.WriteString(w, tc.body) + }) + _, err := c.GetContext(context.Background(), "acme") + ce, ok := AsContextError(err) + if !ok || !tc.check(ce) || ce.RetryAfter != 5*time.Second { + t.Errorf("%s: %+v", tc.body, ce) + continue + } + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Code == "" { + t.Errorf("does not unwrap to the APIError: %v", err) + } + } +} + +func TestContextSessionExpired(t *testing.T) { + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(401) }) + if _, err := c.ListContexts(context.Background()); !errors.Is(err, ErrSessionExpired) { + t.Errorf("%v", err) + } +} + +func TestCheckBlobsBatchesAtTheCap(t *testing.T) { + var sizes []int + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + var in struct { + SHA256 []string `json:"sha256"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + sizes = append(sizes, len(in.SHA256)) + _ = json.NewEncoder(w).Encode(map[string]any{"missing": in.SHA256[:1]}) + }) + shas := make([]string, 2001) + for i := range shas { + shas[i] = fmt.Sprintf("%064x", i) + } + missing, err := c.CheckBlobs(context.Background(), shas) + if err != nil || len(missing) != 3 || fmt.Sprint(sizes) != "[1000 1000 1]" { + t.Errorf("%v %v %v", err, missing, sizes) + } +} + +func TestPutBlobStreamsWithItsLengthAndHint(t *testing.T) { + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + if r.Method != http.MethodPut || r.URL.Path != "/v1/contexts/blobs/abc" || r.ContentLength != 5 || string(b) != "hello" || + r.Header.Get("X-Nan-Filename") != "a.md" || r.Header.Get("Content-Type") != "application/octet-stream" || + r.Header.Get("Cookie") != "nan_session=sess" { + t.Errorf("%s %s len=%d %q %v", r.Method, r.URL.Path, r.ContentLength, b, r.Header) + } + w.WriteHeader(201) + _, _ = io.WriteString(w, `{"sha256":"abc","size":5,"format":"text","secret_findings":[{"line":1,"pattern":"github_token"}]}`) + }) + b, err := c.PutBlob(context.Background(), "abc", strings.NewReader("hello"), 5, "a.md") + if err != nil || b.Size != 5 || len(b.SecretFindings) != 1 { + t.Errorf("%+v %v", b, err) + } +} + +// A blob the member already has answers 200 with it instead of an upload. +func TestStartUploadTellsAStoredBlobFromAnUpload(t *testing.T) { + status := 200 + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + var in map[string]any + _ = json.NewDecoder(r.Body).Decode(&in) + if in["size"] != float64(20<<20) || in["filename"] != "big.pdf" { + t.Errorf("body %v", in) + } + w.WriteHeader(status) + if status == 200 { + _, _ = io.WriteString(w, `{"sha256":"abc","size":20971520,"format":"pdf","secret_findings":[]}`) + return + } + _, _ = io.WriteString(w, `{"upload_id":"u1","part_size":16777216,"parts":2,"next_part":1}`) + }) + up, blob, err := c.StartUpload(context.Background(), "abc", 20<<20, "big.pdf") + if err != nil || up != nil || blob == nil || blob.Format != "pdf" { + t.Errorf("%+v %+v %v", up, blob, err) + } + status = 201 + up, blob, err = c.StartUpload(context.Background(), "abc", 20<<20, "big.pdf") + if err != nil || blob != nil || up == nil || up.UploadID != "u1" || up.Parts != 2 { + t.Errorf("%+v %+v %v", up, blob, err) + } +} + +func TestOpenContextFileRefusalIsAContextError(t *testing.T) { + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("path") != "files/a b.md" || r.URL.Query().Get("version") != "3" { + t.Errorf("query %q", r.URL.RawQuery) + } + w.WriteHeader(404) + _, _ = io.WriteString(w, `{"error":{"message":"file not found","type":"invalid_request_error","param":"path","code":"file_not_found"}}`) + }) + _, err := c.OpenContextFile(context.Background(), "id", "files/a b.md", 3) + if ce, ok := AsContextError(err); !ok || ce.Code != "file_not_found" { + t.Errorf("%v", err) + } +} + +func TestPublishSendsAnEmptyListNotNull(t *testing.T) { + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + if string(b) != `{"base_version":1,"files":[],"allow_secret_like":false}` || r.Header.Get("Idempotency-Key") != "k" { + t.Errorf("%s %v", b, r.Header) + } + w.WriteHeader(201) + _, _ = io.WriteString(w, `{"id":"id","name":"acme","version":2}`) + }) + out, err := c.PublishContext(context.Background(), "id", PublishRequest{BaseVersion: 1}, "k") + if err != nil || out.Version != 2 { + t.Errorf("%+v %v", out, err) + } +} diff --git a/internal/api/ephemeral.go b/internal/api/ephemeral.go new file mode 100644 index 0000000..823f3c0 --- /dev/null +++ b/internal/api/ephemeral.go @@ -0,0 +1,73 @@ +package api + +import "context" + +// Ephemeral runs: an agent in a fresh, throwaway container instead of a +// workspace, billed per started minute by container size. The member's +// limits and this month's metering live under /api/ephemeral, which takes a +// session only (the CLI's or the browser's) and answers 404 to a member who +// does not have ephemeral runs. Writing the limits needs the browser. + +// Container sizes, as the platform names them. +const ( + SizeS = "s" + SizeM = "m" + SizeL = "l" +) + +// EphemeralUsage is GET /api/ephemeral/usage: this calendar month (UTC). +// Money is in EUR, and also in micros (millionths of a euro) to stay exact. +type EphemeralUsage struct { + Month string `json:"month"` + Seconds int64 `json:"seconds"` + Minutes int64 `json:"minutes"` + CostEUR float64 `json:"cost_eur"` + CostEURMicros int64 `json:"cost_eur_micros"` // chargeable: after the free minutes + Plan string `json:"plan"` + + // The free allowance, priced in S-minutes: one M minute uses two, one L + // minute four. + FreeEURMicrosTotal int64 `json:"free_eur_micros_total"` + FreeEURMicrosUsed int64 `json:"free_eur_micros_used"` + FreeEURMicrosLeft int64 `json:"free_eur_micros_left"` + FreeMinutesLeft int64 `json:"free_minutes_left"` // S-minutes + + MonthlyCapEURCents int `json:"monthly_cap_eur_cents"` + PricesEURMicrosPerMinute map[string]int64 `json:"prices_eur_micros_per_minute"` + // BillingEnabled false: usage is metered and shown, not charged. + BillingEnabled bool `json:"billing_enabled"` + + // What blocks the next ephemeral run (false on a platform that does not + // send them): no payment method on file (always needed, free minutes + // included), or a subscription billed in USD (ephemeral runs bill in EUR). + PaymentMethodRequired bool `json:"payment_method_required"` + CurrencyMigrationRequired bool `json:"currency_migration_required"` +} + +// EphemeralLimits are the ranges the platform accepts for the settings. +type EphemeralLimits struct { + MonthlyCapEURCentsMax int `json:"monthly_cap_eur_cents_max"` + MaxConcurrentMax int `json:"max_concurrent_max"` +} + +// EphemeralSettings is GET /api/ephemeral/settings: the member's own +// limits. They are changed in the portal only. +type EphemeralSettings struct { + MonthlyCapEURCents int `json:"monthly_cap_eur_cents"` + MonthlyCapEUR float64 `json:"monthly_cap_eur"` + MaxConcurrent int `json:"max_concurrent"` + Limits EphemeralLimits `json:"limits"` + BillingEnabled bool `json:"billing_enabled"` +} + +// EphemeralUsage is GET /api/ephemeral/usage (a session only). +func (c *RunsClient) EphemeralUsage(ctx context.Context) (*EphemeralUsage, error) { + u := &EphemeralUsage{} + return u, c.getJSON(ctx, "/api/ephemeral/usage", u) +} + +// EphemeralSettings is GET /api/ephemeral/settings (a session only). +func (c *RunsClient) EphemeralSettings(ctx context.Context) (*EphemeralSettings, error) { + s := &EphemeralSettings{} + return s, c.getJSON(ctx, "/api/ephemeral/settings", s) +} diff --git a/internal/api/ephemeral_test.go b/internal/api/ephemeral_test.go new file mode 100644 index 0000000..264147d --- /dev/null +++ b/internal/api/ephemeral_test.go @@ -0,0 +1,157 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +// The golden responses in testdata/ephemeral were rendered by cloud-api's +// own handlers (renderRun, renderAutomation, EphemeralHandler on +// helmcode/nan-cloud-api main), so this client decodes exactly what the +// platform serialises for ephemeral runs. +func ephemeralGolden(t *testing.T, name string, out any) { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "ephemeral", name)) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, out); err != nil { + t.Fatalf("%s: %v", name, err) + } +} + +func TestGoldenEphemeralRun(t *testing.T) { + var run Run + ephemeralGolden(t, "run_get_ephemeral.json", &run) + if !run.Ephemeral() || run.Size == nil || *run.Size != SizeM || run.Workspace.ID != "" || run.Workspace.Name != "" || + run.GitHubRepo == nil || *run.GitHubRepo != "acme/api" || run.Cost == nil || + run.Cost.EURMicros != 18000 || run.Cost.EUR != 0.018 || run.Cost.BilledSeconds != 180 { + t.Fatalf("run = %+v (cost %+v)", run, run.Cost) + } +} + +func TestGoldenEphemeralRunList(t *testing.T) { + var list RunList + ephemeralGolden(t, "run_list_ephemeral.json", &list) + if len(list.Data) != 3 { + t.Fatalf("list = %+v", list) + } + metered, unmetered, ws := list.Data[0], list.Data[1], list.Data[2] + if !metered.Ephemeral() || metered.Cost == nil || !unmetered.Ephemeral() || unmetered.Cost != nil { + t.Errorf("ephemeral runs: %+v / %+v", metered, unmetered) + } + if ws.Ephemeral() || ws.Executor != ExecutorWorkspace || ws.Size != nil || ws.Cost != nil || ws.Workspace.Name != "develop" { + t.Errorf("workspace run: %+v", ws) + } +} + +func TestGoldenEphemeralAutomation(t *testing.T) { + var a Automation + ephemeralGolden(t, "automation_get_ephemeral.json", &a) + if a.Executor != ExecutorEphemeral || a.Size == nil || *a.Size != SizeL || a.Workspace != nil || a.Checkout != "fresh" { + t.Fatalf("automation = %+v", a) + } +} + +func TestGoldenEphemeralUsageAndSettings(t *testing.T) { + var u EphemeralUsage + ephemeralGolden(t, "ephemeral_usage.json", &u) + if u.Month != "2026-10" || u.Minutes != 49 || u.Seconds != 2950 || u.Plan != "inference" || u.BillingEnabled || + u.FreeEURMicrosTotal != 900000 || u.FreeEURMicrosUsed != 159000 || u.FreeEURMicrosLeft != 741000 || u.FreeMinutesLeft != 247 || + u.MonthlyCapEURCents != 10000 || u.PricesEURMicrosPerMinute[SizeS] != 3000 || u.PricesEURMicrosPerMinute[SizeM] != 6000 || + u.PricesEURMicrosPerMinute[SizeL] != 12000 { + t.Fatalf("usage = %+v", u) + } + var s EphemeralSettings + ephemeralGolden(t, "ephemeral_settings.json", &s) + if s.MonthlyCapEURCents != 10000 || s.MonthlyCapEUR != 100 || s.MaxConcurrent != 50 || s.BillingEnabled || + s.Limits.MonthlyCapEURCentsMax != 100000 || s.Limits.MaxConcurrentMax != 50 { + t.Fatalf("settings = %+v", s) + } +} + +// An ephemeral run names no workspace: the platform refuses one ("an +// ephemeral run has no workspace"), so the field must be left out. +func TestCreateEphemeralRunBody(t *testing.T) { + repo := "acme/api" + model := "glm5.3" + b, err := json.Marshal(CreateRunRequest{Agent: "pi", Prompt: "x", Model: &model, Executor: ExecutorEphemeral, Size: SizeM, GitHubRepo: &repo}) + if err != nil { + t.Fatal(err) + } + var got map[string]any + _ = json.Unmarshal(b, &got) + if _, ok := got["workspace"]; ok { + t.Errorf("an ephemeral run sends a workspace: %s", b) + } + 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, "github_token_secret") { + t.Errorf("workspace body = %s", s) + } +} + +func TestEphemeralClientPathsAndSession(t *testing.T) { + var paths, cookies []string + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.Method+" "+r.URL.Path) + cookies = append(cookies, r.Header.Get("Cookie")) + name := "ephemeral_settings.json" + if strings.HasSuffix(r.URL.Path, "/usage") { + name = "ephemeral_usage.json" + } + b, _ := os.ReadFile(filepath.Join("testdata", "ephemeral", name)) + _, _ = w.Write(b) + }), "sess", "") + ctx := context.Background() + if u, err := c.EphemeralUsage(ctx); err != nil || u.FreeMinutesLeft != 247 { + t.Fatalf("usage: %+v %v", u, err) + } + if s, err := c.EphemeralSettings(ctx); err != nil || s.MaxConcurrent != 50 { + t.Fatalf("settings: %+v %v", s, err) + } + if strings.Join(paths, ",") != "GET /api/ephemeral/usage,GET /api/ephemeral/settings" { + t.Errorf("paths = %v", paths) + } + for _, ck := range cookies { + if ck != "nan_session=sess" { + t.Errorf("cookie = %q", ck) + } + } +} + +// Off for the member: the platform answers 404 {"error":"not_found"}; a +// token gets 403 {"error":"session_required"}. Both reach the caller as an +// APIError with the status, which is what it branches on. +func TestEphemeralClientRefusals(t *testing.T) { + for status, body := range map[int]string{404: `{"error":"not_found"}`, 403: `{"error":"session_required"}`} { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + }), "", "nan_pat_x") + _, err := c.EphemeralUsage(context.Background()) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != status { + t.Errorf("%d: %v", status, err) + } + } +} diff --git a/internal/api/runs.go b/internal/api/runs.go index e54062b..536646f 100644 --- a/internal/api/runs.go +++ b/internal/api/runs.go @@ -121,6 +121,45 @@ type Run struct { GitHubRepo *string `json:"github_repo,omitempty"` TriggerDepth int `json:"trigger_depth,omitempty"` Secrets []RunSecret `json:"secrets,omitempty"` + + // Ephemeral executors: where the run executes ("workspace" or + // "ephemeral"; absent on an older platform), the container size of an + // ephemeral run (s, m or l) and its metered cost (nil until metered, + // and always nil for workspace runs). An ephemeral run has no + // workspace: the platform sends "workspace": null. + Executor string `json:"executor,omitempty"` + Size *string `json:"size,omitempty"` + Cost *RunCost `json:"cost,omitempty"` + + // Contexts are the agent contexts the run pinned at admission, in run + // order (absent on an older platform). + Contexts []RunContext `json:"contexts,omitempty"` +} + +// RunContext is one agent context a run loaded: the version pinned when +// the run was admitted, and that version's tree hash. +type RunContext struct { + ID string `json:"id"` + Name string `json:"name"` + Version int `json:"version"` + TreeSHA256 string `json:"tree_sha256"` +} + +// Executors, as the platform names them. +const ( + ExecutorWorkspace = "workspace" + ExecutorEphemeral = "ephemeral" +) + +// Ephemeral reports whether the run executes in an ephemeral container. +func (r *Run) Ephemeral() bool { return r != nil && r.Executor == ExecutorEphemeral } + +// RunCost is an ephemeral run's metered price: the gross price of its +// billed (started) minutes, before the month's free minutes are applied. +type RunCost struct { + EUR float64 `json:"eur"` + EURMicros int64 `json:"eur_micros"` + BilledSeconds int64 `json:"billed_seconds"` } // RunSecret is a secret a run received: its env name and who saw it. @@ -155,7 +194,8 @@ func (r *Run) UnmarshalJSON(b []byte) error { // CreateRunRequest is the body of POST /v1/runs. Pointers are the fields the // platform defaults when they are left out, which is not the same as zero. type CreateRunRequest struct { - Workspace string `json:"workspace"` + // Workspace is left out for an ephemeral run, which has none. + Workspace string `json:"workspace,omitempty"` Agent string `json:"agent"` Prompt string `json:"prompt"` Cwd *string `json:"cwd,omitempty"` @@ -167,6 +207,21 @@ type CreateRunRequest struct { // 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"` + + // Executor "ephemeral" runs the agent in a fresh container of Size + // (s, m or l; the platform defaults to s) on a fresh checkout of + // GitHubRepo, with no workspace. Left out: a workspace run. + 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"` + + // Contexts: 0..5 of the member's agent contexts by name or id, in + // order. An ephemeral run without GitHubRepo needs at least one. + Contexts []string `json:"contexts,omitempty"` } // RunInference is the inference choice of POST /v1/runs: {"source":"byok"} diff --git a/internal/api/testdata/ephemeral/automation_get_ephemeral.json b/internal/api/testdata/ephemeral/automation_get_ephemeral.json new file mode 100644 index 0000000..998b9bc --- /dev/null +++ b/internal/api/testdata/ephemeral/automation_get_ephemeral.json @@ -0,0 +1,42 @@ +{ + "agent": "pi", + "approval_kinds": [ + "github_pr_review" + ], + "approval_ttl_seconds": 259200, + "checkout": "fresh", + "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, + "executor": "ephemeral", + "git_isolation": true, + "github_repo": "acme/api", + "id": "a0000000-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "instructions": "Review for correctness.", + "max_runs_per_hour": 30, + "max_trigger_depth": 2, + "model": "glm5.3", + "name": "PR review api", + "object": "automation", + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "actions", + "secret": "GITHUB_WRITE_TOKEN" + } + ], + "size": "l", + "sticky_pr_comment": false, + "template": "pr_review", + "timeout_seconds": 1800, + "updated_at": "2026-10-11T09:00:00Z", + "updated_by": "browser_session", + "workspace": null +} diff --git a/internal/api/testdata/ephemeral/ephemeral_settings.json b/internal/api/testdata/ephemeral/ephemeral_settings.json new file mode 100644 index 0000000..9e645ea --- /dev/null +++ b/internal/api/testdata/ephemeral/ephemeral_settings.json @@ -0,0 +1,10 @@ +{ + "billing_enabled": false, + "limits": { + "max_concurrent_max": 50, + "monthly_cap_eur_cents_max": 100000 + }, + "max_concurrent": 50, + "monthly_cap_eur": 100, + "monthly_cap_eur_cents": 10000 +} diff --git a/internal/api/testdata/ephemeral/ephemeral_usage.json b/internal/api/testdata/ephemeral/ephemeral_usage.json new file mode 100644 index 0000000..edbf295 --- /dev/null +++ b/internal/api/testdata/ephemeral/ephemeral_usage.json @@ -0,0 +1,19 @@ +{ + "billing_enabled": false, + "cost_eur": 0, + "cost_eur_micros": 0, + "free_eur_micros_left": 741000, + "free_eur_micros_total": 900000, + "free_eur_micros_used": 159000, + "free_minutes_left": 247, + "minutes": 49, + "month": "2026-10", + "monthly_cap_eur_cents": 10000, + "plan": "inference", + "prices_eur_micros_per_minute": { + "l": 12000, + "m": 6000, + "s": 3000 + }, + "seconds": 2950 +} diff --git a/internal/api/testdata/ephemeral/run_get_ephemeral.json b/internal/api/testdata/ephemeral/run_get_ephemeral.json new file mode 100644 index 0000000..1be5334 --- /dev/null +++ b/internal/api/testdata/ephemeral/run_get_ephemeral.json @@ -0,0 +1,66 @@ +{ + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "checkout": { + "base_ref": null, + "label": "fresh @ default branch", + "mode": "fresh", + "pr_number": null, + "sha": null + }, + "cost": { + "billed_seconds": 180, + "eur": 0.018, + "eur_micros": 18000 + }, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/work", + "error": null, + "error_code": null, + "executor": "ephemeral", + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": "acme/api", + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "last_seq": 42, + "model": "glm5.3", + "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": [], + "size": "m", + "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": null +} diff --git a/internal/api/testdata/ephemeral/run_list_ephemeral.json b/internal/api/testdata/ephemeral/run_list_ephemeral.json new file mode 100644 index 0000000..0d803e4 --- /dev/null +++ b/internal/api/testdata/ephemeral/run_list_ephemeral.json @@ -0,0 +1,190 @@ +{ + "data": [ + { + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "checkout": { + "base_ref": null, + "label": "fresh @ default branch", + "mode": "fresh", + "pr_number": null, + "sha": null + }, + "cost": { + "billed_seconds": 180, + "eur": 0.018, + "eur_micros": 18000 + }, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/work", + "error": null, + "error_code": null, + "executor": "ephemeral", + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": "acme/api", + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "last_seq": 42, + "model": "glm5.3", + "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": [], + "size": "m", + "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": null + }, + { + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "checkout": { + "base_ref": null, + "label": "fresh @ default branch", + "mode": "fresh", + "pr_number": null, + "sha": null + }, + "cost": null, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/work", + "error": null, + "error_code": null, + "executor": "ephemeral", + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": "acme/api", + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "last_seq": 42, + "model": "glm5.3", + "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": [], + "size": "m", + "started_at": "2026-10-10T09:00:01Z", + "state": "running", + "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": null + }, + { + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "checkout": null, + "cost": null, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/projects/api", + "error": null, + "error_code": null, + "executor": "workspace", + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": null, + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "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": [], + "size": null, + "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/write.go b/internal/api/write.go index a13a259..cf19e24 100644 --- a/internal/api/write.go +++ b/internal/api/write.go @@ -22,6 +22,9 @@ type Features struct { // Notifications is features.notifications: notification channels are // available to the member. Notifications bool `json:"notifications"` + // Ephemeral is features.ephemeral: runs in ephemeral containers are + // available to the member. + Ephemeral bool `json:"ephemeral"` } // Features is GET /api/auth/me, read for its feature flags. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b4b61e7..57930cc 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -20,8 +20,7 @@ import ( ) const ( - loginRequestURL = "https://cloud-api.nan.builders/api/auth/login/request" - sessionCookie = "nan_session" + sessionCookie = "nan_session" // The domain the platform sends its sign-in links from. Subdomains count: // the link lands on the web app, the API lives next door. linkDomain = "nan.builders" @@ -34,9 +33,30 @@ const ( LinkValidity = "15 minutes" ) -// loginVerifyURL is a variable so a test can point the exchange at a server -// of its own. -var loginVerifyURL = "https://cloud-api.nan.builders/api/auth/login/verify" +// SignInBaseURL is where the three calls of the sign-in go: the link +// request, the link exchange and the two-factor challenge. Nothing else: the +// rest of the CLI (internal/api - nan me, logout, runs, the panel's tabs) +// keeps its own production URLs. +// +// A variable and not a constant for two readers only: the tests, which point +// the sign-in at a server of their own, and a build for QA, which points it at +// the dev platform without a flag or an environment variable that a released +// binary would carry: +// +// go build -ldflags "-X github.com/nxssie/nan-cli/internal/auth.SignInBaseURL=https://cloud-api-dev.nan.builders" -o nan-qa . +// HOME=$(mktemp -d) ./nan-qa auth login --email you@example.com +// +// Run a QA build with a HOME of its own. It saves the dev session in +// ~/.config/nan/session.json, over the member's production one, and every +// command after the sign-in sends that dev session to production: the panel +// takes the refusal for an expired session and clears it, and logout asks +// production, not dev, to end it. +// +// Nothing else sets it. +var SignInBaseURL = "https://cloud-api.nan.builders" + +func loginRequestURL() string { return SignInBaseURL + "/api/auth/login/request" } +func loginVerifyURL() string { return SignInBaseURL + "/api/auth/login/verify" } // clientKind is what this CLI declares on the verify POST, so the platform // records the session it hands back as a CLI session. A CLI session can @@ -61,7 +81,7 @@ func RequestSignInLink(email string) error { return err } client := &http.Client{Timeout: requestTimeout} - resp, err := client.Post(loginRequestURL, "application/json", bytes.NewReader(body)) + resp, err := client.Post(loginRequestURL(), "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("could not reach nan.builders: %w", err) } @@ -119,6 +139,10 @@ func isLinkHost(host string) bool { // cookie back. This does the same POST and keeps the cookie instead of // following the redirect, which is the whole reason the old flow had to send // people into DevTools. +// +// An account with two-factor authentication gets no session from the link +// alone: the answer is a challenge, returned here as *MFARequiredError, and +// the session comes from answering it (MFARequiredError.Complete). func ExchangeToken(token string) (string, error) { client := &http.Client{ Timeout: requestTimeout, @@ -126,23 +150,39 @@ func ExchangeToken(token string) (string, error) { return http.ErrUseLastResponse }, } - form := url.Values{"token": {token}, "client": {clientKind}} - resp, err := client.PostForm(loginVerifyURL, form) + // mfa=1 says this build can answer a two-factor challenge. Without it the + // platform refuses an account that has a factor outright, with a reason + // that tells the member to update - which is what every build before this + // one gets, since none of them could ask for the code. + form := url.Values{"token": {token}, "client": {clientKind}, "mfa": {"1"}} + resp, err := client.PostForm(loginVerifyURL(), form) if err != nil { return "", fmt.Errorf("could not reach nan.builders: %w", err) } defer resp.Body.Close() + var pending string for _, c := range resp.Cookies() { if c.Name == sessionCookie && c.Value != "" { return c.Value, nil } + if c.Name == mfaPendingCookie && c.Value != "" { + pending = c.Value + } } // A spent or expired link redirects to the platform's access-denied page // with the reason in the query, which is more useful than the status code. + // A link that worked for an account with a second factor redirects to the + // page that asks for the code, with the challenge in a cookie. if location, err := resp.Location(); err == nil { - if reason := location.Query().Get("reason"); reason != "" { + reason := location.Query().Get("reason") + switch { + case reason == mfaRequiredReason && pending != "": + return "", &MFARequiredError{pending: pending} + case reason == mfaRequiredReason: + return "", errors.New("nan.builders asked for a two-factor code but sent no sign-in to answer it — ask for a new link and sign in again") + case reason != "": return "", errors.New(linkReasonMessage(reason)) } } @@ -159,11 +199,28 @@ var linkReasons = map[string]string{ "invalid_link": "that link expired or was already used — a link works once and " + "expires " + LinkValidity + " after it is sent, so ask for another one", "missing_token": "that link is incomplete — copy the whole link out of the most recent email", + // What a build from before two-factor sign-in gets for an account that + // has it. This build says it can answer the challenge, so it should never + // be told this; if it is, the sentence is still the right one. + "mfa_enabled_update_nan_cli": "this account uses two-factor authentication, and this nan-cli cannot ask for the code — " + + "update it with the installer (https://github.com/helmcode/nan-cli#install) and ask for a new link", + // An account that has not finished signing up and has two-factor + // authentication on: the setup, behind the code, happens in the browser + // only. The link this was is spent. + "finish_onboarding_in_the_browser": "your NaN account is not set up yet — finish it in the browser: " + + "ask for a new link at https://cloud.nan.builders (this one is spent), complete the setup there, " + + "then sign in here again", } func linkReasonMessage(reason string) string { if message, ok := linkReasons[reason]; ok { return message } + // The reason comes out of a redirect's query, decoded, so it can carry + // anything - an escape sequence, a paragraph. Only something shaped like + // the platform's reasons is put on the terminal. + if !isErrorCode(reason) { + return "the link did not work, and nan.builders did not say why in a way this version can show — ask for a new link" + } return "the link did not work: " + strings.ReplaceAll(reason, "_", " ") } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 3edb67b..b057b2e 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -109,9 +109,9 @@ func TestExchangeTokenDeclaresTheCLI(t *testing.T) { http.Redirect(w, r, "/dashboard", http.StatusSeeOther) })) defer srv.Close() - old := loginVerifyURL - loginVerifyURL = srv.URL - defer func() { loginVerifyURL = old }() + old := SignInBaseURL + SignInBaseURL = srv.URL + defer func() { SignInBaseURL = old }() got, err := ExchangeToken("tok") if err != nil || got != "sess" { diff --git a/internal/auth/authtest/platform.go b/internal/auth/authtest/platform.go new file mode 100644 index 0000000..c747cb2 --- /dev/null +++ b/internal/auth/authtest/platform.go @@ -0,0 +1,330 @@ +// Package authtest is a fake nan.builders sign-in for tests: the link +// exchange and the two-factor challenge, answering the way the platform's API +// contract says they answer (nan-cloud-api openspec/changes/mfa/api-contract.md, +// "POST /api/auth/login/verify", "POST /api/auth/mfa/challenge" and "CLI +// contract"). +// +// It is imported by tests only, so it never reaches the binary. +package authtest + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +const ( + // FrontendURL is the UI the platform redirects to, and the only origin + // the challenge route accepts. + FrontendURL = "https://cloud.nan.builders" + + // PendingCookie is the challenge cookie's name. + PendingCookie = "__Host-nan_mfa_pending" + + // Values a test can look for: in what was sent, and in what must never + // be written anywhere. + LinkToken = "link-token-for-a-test" + Pending = "fake-pending-challenge-0123456789abcdefghij" // 43 base64url characters, like the real one + Session = "fake-session-from-the-platform" + Code = "246810" +) + +// Platform is the fake. Set the exported fields before the first request. +type Platform struct { + *httptest.Server + + // Factor: the account has a confirmed authenticator. Without it the link + // alone signs in, exactly as before two-factor sign-in existed. + Factor bool + // Onboarding: the member is invited or registered and has not finished + // signing up, so a link gets them the onboarding token, never a session. + Onboarding bool + // RecoveryCodes the account has not used yet, in canonical form + // (xxxx-xxxx-xxxx-xxxx). + RecoveryCodes []string + // Next, when set, answers the next challenge POST instead of checking + // it, and is then cleared: a refusal the test wants to see handled. + Next *Refusal + + mu sync.Mutex + // What came in, for the test to look at. + verifies []url.Values + challenges []Request + + spent bool + open bool // a challenge is waiting for its code + ended string // why the last challenge ended: used, exhausted, expired + attempts int // left on the open challenge + codeUsed bool // Code's step has been spent (anti-replay) + lockedUntil time.Time +} + +// Refusal is an answer the test forces on the challenge route. +type Refusal struct { + Status int + Body string + Header map[string]string +} + +// Request is a challenge POST as it arrived. +type Request struct { + Header http.Header + Body string + // Cookie is the challenge cookie it carried, "" without one. + Cookie string +} + +// New starts the fake. It stops when the test ends. +func New(t testing.TB) *Platform { + t.Helper() + p := &Platform{} + mux := http.NewServeMux() + mux.HandleFunc("POST /api/auth/login/request", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + }) + mux.HandleFunc("POST /api/auth/login/verify", p.verify) + mux.HandleFunc("POST /api/auth/mfa/challenge", p.challenge) + p.Server = httptest.NewServer(mux) + t.Cleanup(p.Close) + return p +} + +// Verifies are the forms the verify route received. +func (p *Platform) Verifies() []url.Values { + p.mu.Lock() + defer p.mu.Unlock() + return append([]url.Values(nil), p.verifies...) +} + +// Challenges are the challenge POSTs the platform received. +func (p *Platform) Challenges() []Request { + p.mu.Lock() + defer p.mu.Unlock() + return append([]Request(nil), p.challenges...) +} + +// NewLink lets one more link through, as a new email would. +func (p *Platform) NewLink() { + p.mu.Lock() + defer p.mu.Unlock() + p.spent = false +} + +// Expire ends the open challenge the way ten minutes would. +func (p *Platform) Expire() { + p.mu.Lock() + defer p.mu.Unlock() + if p.open { + p.open, p.ended = false, "expired" + } +} + +// Lock pauses the account's two-factor sign-in until the given time. +func (p *Platform) Lock(until time.Time) { + p.mu.Lock() + defer p.mu.Unlock() + p.lockedUntil = until +} + +func (p *Platform) verify(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + p.mu.Lock() + defer p.mu.Unlock() + p.verifies = append(p.verifies, r.PostForm) + + // The link is consumed in every row of the contract's table. + if r.PostForm.Get("token") != LinkToken || p.spent { + http.Redirect(w, r, FrontendURL+"/access-denied?reason=invalid_link", http.StatusFound) + return + } + p.spent = true + + cli := r.PostForm.Get("client") == "cli" + switch { + case p.Onboarding && !p.Factor: + // Unchanged by two-factor sign-in: the onboarding token, which is not + // a session, and the page that uses it. + http.SetCookie(w, &http.Cookie{Name: "nan_onboarding", Value: "fake-onboarding-token", Path: "/", Secure: true, HttpOnly: true}) + http.Redirect(w, r, FrontendURL+"/onboarding", http.StatusFound) + case p.Onboarding && cli: + // "CLI contract" step 8: with a factor, onboarding is finished in the + // browser, whatever mfa says. No cookie of any kind. + http.Redirect(w, r, FrontendURL+"/access-denied?reason=finish_onboarding_in_the_browser", http.StatusFound) + case !p.Factor: + http.SetCookie(w, &http.Cookie{Name: "nan_session", Value: Session, Path: "/", Domain: ".nan.builders", Secure: true, HttpOnly: true}) + http.Redirect(w, r, FrontendURL+"/", http.StatusFound) + case cli && r.PostForm.Get("mfa") != "1": + http.Redirect(w, r, FrontendURL+"/access-denied?reason=mfa_enabled_update_nan_cli", http.StatusFound) + default: + // A new challenge replaces any earlier one (D8). + p.open, p.ended, p.attempts = true, "", 5 + http.SetCookie(w, &http.Cookie{Name: PendingCookie, Value: Pending, Path: "/", MaxAge: 600, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode}) + http.Redirect(w, r, FrontendURL+"/mfa?reason=mfa_required", http.StatusFound) + } +} + +func (p *Platform) challenge(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16)) + cookie := "" + if c, err := r.Cookie(PendingCookie); err == nil { + cookie = c.Value + } + p.mu.Lock() + defer p.mu.Unlock() + p.challenges = append(p.challenges, Request{Header: r.Header.Clone(), Body: string(body), Cookie: cookie}) + w.Header().Set("Cache-Control", "no-store") + + // The FRONTEND_URL origin only; without an Origin, refused when the + // browser says it came from another site, through otherwise (the CLI). + if o := r.Header.Get("Origin"); o != "" && o != FrontendURL { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "forbidden_origin"}) + return + } + if s := r.Header.Get("Sec-Fetch-Site"); r.Header.Get("Origin") == "" && (s == "cross-site" || s == "same-site") { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "forbidden_origin"}) + return + } + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + + if p.Next != nil { + next := p.Next + p.Next = nil + for k, v := range next.Header { + w.Header().Set(k, v) + } + w.WriteHeader(next.Status) + _, _ = io.WriteString(w, next.Body) + return + } + + if cookie != Pending { + expire(w) + writeJSON(w, http.StatusForbidden, refusal("mfa_challenge_missing")) + return + } + if !p.open { + expire(w) + code := "mfa_challenge_" + p.ended + if p.ended == "" { + code = "mfa_challenge_missing" + } + writeJSON(w, http.StatusForbidden, refusal(code)) + return + } + if time.Now().Before(p.lockedUntil) { + p.locked(w) + return + } + + var answer map[string]string + dec := json.NewDecoder(strings.NewReader(string(body))) + dec.DisallowUnknownFields() + if err := dec.Decode(&answer); err != nil || len(answer) != 1 { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + + if code, ok := answer["code"]; ok { + code = strings.ReplaceAll(code, " ", "") + if len(code) != 6 { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + switch { + case code == Code && !p.codeUsed: + p.codeUsed = true + p.pass(w, nil) + case code == Code: + p.fail(w, "mfa_code_already_used") + default: + p.fail(w, "mfa_code_invalid") + } + return + } + if rc, ok := answer["recovery_code"]; ok { + canonical, ok := canonicalRecovery(rc) + if !ok { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + for i, have := range p.RecoveryCodes { + if have == canonical { + p.RecoveryCodes = append(p.RecoveryCodes[:i:i], p.RecoveryCodes[i+1:]...) + left := len(p.RecoveryCodes) + p.pass(w, &left) + return + } + } + p.fail(w, "mfa_recovery_code_invalid") + return + } + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) +} + +func (p *Platform) pass(w http.ResponseWriter, recoveryLeft *int) { + p.open, p.ended = false, "used" + expire(w) + http.SetCookie(w, &http.Cookie{Name: "nan_session", Value: Session, Path: "/", Domain: ".nan.builders", Secure: true, HttpOnly: true}) + writeJSON(w, http.StatusOK, map[string]any{"redirect": "/", "recovery_codes_remaining": recoveryLeft}) +} + +func (p *Platform) fail(w http.ResponseWriter, code string) { + p.attempts-- + if p.attempts <= 0 { + p.open, p.ended = false, "exhausted" + expire(w) + writeJSON(w, http.StatusForbidden, refusal("mfa_challenge_exhausted")) + return + } + body := refusal(code) + body["attempts_remaining"] = p.attempts + writeJSON(w, http.StatusBadRequest, body) +} + +func (p *Platform) locked(w http.ResponseWriter) { + secs := int(time.Until(p.lockedUntil).Seconds()) + 1 + w.Header().Set("Retry-After", fmt.Sprint(secs)) + body := refusal("mfa_locked") + body["locked_until"] = p.lockedUntil.UTC().Format(time.RFC3339) + body["retry_after"] = secs + writeJSON(w, http.StatusTooManyRequests, body) +} + +// canonicalRecovery is the platform's reading of a recovery code: sixteen +// base32 characters in any case, with or without hyphens and spaces. +func canonicalRecovery(s string) (string, bool) { + s = strings.ToLower(strings.NewReplacer("-", "", " ", "").Replace(s)) + if len(s) != 16 { + return "", false + } + for _, r := range s { + if !(r >= 'a' && r <= 'z' || r >= '2' && r <= '7') { + return "", false + } + } + return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16], true +} + +func refusal(code string) map[string]any { + return map[string]any{"error": code, "message": "A sentence the client must not branch on."} +} + +func expire(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: PendingCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/auth/mfa.go b/internal/auth/mfa.go new file mode 100644 index 0000000..e0c8f5a --- /dev/null +++ b/internal/auth/mfa.go @@ -0,0 +1,321 @@ +package auth + +// Two-factor sign-in. +// +// A member who has turned on two-factor authentication does not get a session +// from the link alone. The verify POST answers with a challenge instead - a +// cookie that names it, and a redirect whose reason says so - and the session +// comes back only once a code from the member's authenticator app, or one of +// their recovery codes, is posted against that challenge. +// +// The challenge cookie is a credential for the next ten minutes, so it lives +// in memory and nowhere else: not in session.json, not in an error message, +// not in a log. A process that exits halfway leaves it to expire on the +// platform, and the member asks for a new link. + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + mfaPendingCookie = "__Host-nan_mfa_pending" + mfaRequiredReason = "mfa_required" + + // What ends every message about a sign-in that cannot go on. The link was + // spent when the challenge was opened, so a new one is the only way back. + newLinkHint = "ask for a new link and sign in again" +) + +func mfaChallengeURL() string { return SignInBaseURL + "/api/auth/mfa/challenge" } + +// MFARequiredError is what ExchangeToken returns when the link was good and +// the account asks for a second factor. Answer it with Complete. +type MFARequiredError struct { + pending string +} + +func (e *MFARequiredError) Error() string { + return "this account uses two-factor authentication: enter the 6-digit code from your authenticator app, or one of your recovery codes" +} + +// Format prints the message whatever the verb. Without it %#v - or %d, or +// anything else fmt does not route to Error - prints the struct, and the +// struct is the challenge. +func (e *MFARequiredError) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, e.Error()) } + +// SignIn is a challenge answered. +type SignIn struct { + Session string + // UsedRecoveryCode is set when the answer was a recovery code, and + // RecoveryCodesLeft is then how many the member still has. + UsedRecoveryCode bool + RecoveryCodesLeft int +} + +// ErrNotACode is input that is neither shape the platform takes, refused here +// without spending one of the sign-in's attempts on it. +var ErrNotACode = errors.New("that is neither a 6-digit code nor a recovery code (xxxx-xxxx-xxxx-xxxx) — " + + "type the code your authenticator app shows for nan.builders") + +// MFAError is the platform refusing a code, or the challenge it was posted to. +type MFAError struct { + // The platform's code for it, e.g. "mfa_code_invalid". Empty when the + // answer carried none. + Code string + Status int + Attempts int // attempts left on this sign-in, for a wrong code + LockedTill time.Time + RetryAfter time.Duration +} + +// Retryable reports whether the same challenge can take another answer: the +// code was wrong, already used or not a code at all, and the sign-in is still +// open. Anything else ends it, and the member needs a new link. +func Retryable(err error) bool { + if errors.Is(err, ErrNotACode) { + return true + } + var e *MFAError + if !errors.As(err, &e) { + return false + } + switch e.Code { + case "mfa_code_invalid", "mfa_recovery_code_invalid", "mfa_code_already_used", + // Nothing was spent: the platform could not read what was sent. + "invalid_request": + return true + } + return false +} + +func (e *MFAError) Error() string { + switch e.Code { + case "mfa_code_invalid": + return "that code is not right" + attemptsLeft(e.Attempts) + case "mfa_recovery_code_invalid": + return "that recovery code is not right, or it was already used" + attemptsLeft(e.Attempts) + case "mfa_code_already_used": + return "that code was already used — wait for the next one in your authenticator app" + attemptsLeft(e.Attempts) + case "invalid_request": + return "nan.builders could not read that — type the 6-digit code from your authenticator app, or a recovery code (xxxx-xxxx-xxxx-xxxx)" + case "mfa_challenge_exhausted": + return "too many wrong codes for this sign-in — " + newLinkHint + case "mfa_challenge_expired": + return "this sign-in timed out: the code has to be entered within 10 minutes of using the link — " + newLinkHint + case "mfa_challenge_used": + return "this sign-in is already over (it was finished, or a newer sign-in replaced it) — " + newLinkHint + case "mfa_challenge_missing": + return "nan.builders has no record of this sign-in — " + newLinkHint + case "login_refused": + return "nan.builders refused the sign-in for this account — " + newLinkHint + case "mfa_locked": + return "too many wrong codes on this account: two-factor sign-in is paused " + e.pausedFor() + " — then " + newLinkHint + case "rate_limited": + return "too many sign-in attempts from this network — wait " + e.wait() + " and " + newLinkHint + } + if e.Code == "" || e.Status >= 500 { + return fmt.Sprintf("nan.builders could not check the code (HTTP %d) — %s", e.Status, newLinkHint) + } + // A code from a newer platform than this build still gets said, rather + // than swallowed. + return fmt.Sprintf("nan.builders refused the code: %s (HTTP %d) — %s", + strings.ReplaceAll(e.Code, "_", " "), e.Status, newLinkHint) +} + +// attemptsLeft is the tail of a wrong-code message. The platform always says +// how many are left on those; a body without the number says nothing rather +// than "0 attempts left", which would read as the end of the sign-in. +func attemptsLeft(n int) string { + switch { + case n <= 0: + return "" + case n == 1: + return " (1 attempt left)" + } + return fmt.Sprintf(" (%d attempts left)", n) +} + +// now is the clock the lock message is read against. A variable for the tests. +var now = time.Now + +// pausedFor says when the lock ends. locked_until is the platform's clock and +// retry_after the seconds it counts from now; the second is what the +// countdown uses, so a machine whose clock is off still waits the right time. +func (e *MFAError) pausedFor() string { + if e.LockedTill.IsZero() { + return "for " + e.wait() + } + until, today := e.LockedTill.Local(), now().Local() + layout := "15:04" + if until.YearDay() != today.YearDay() || until.Year() != today.Year() { + layout = "Mon 2 Jan 15:04" + } + if e.RetryAfter <= 0 { + return "until " + until.Format(layout) + } + return "until " + until.Format(layout) + " (" + e.wait() + " from now)" +} + +func (e *MFAError) wait() string { + d := e.RetryAfter + if d <= 0 { + return "a few minutes" + } + if d < time.Minute { + return fmt.Sprintf("%d seconds", int(d.Seconds())) + } + mins := int((d + time.Minute - 1) / time.Minute) + if mins == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", mins) +} + +// normalizeAnswer decides what the member typed, the way the platform does: six +// digits once the spaces are out is an authenticator code, and sixteen +// characters of the RFC 4648 base32 alphabet (A-Z and 2-7) a recovery code - in +// any case, with or without its hyphens. That is the alphabet the platform +// makes them in, so a 0, 1, 8 or 9 is a typo, refused before it costs one of +// the sign-in's attempts. +func normalizeAnswer(input string) (field, value string, err error) { + compact := strings.Map(func(r rune) rune { + switch r { + case ' ', '\t', '-': + return -1 + } + return r + }, strings.TrimSpace(input)) + + if len(compact) == 6 && allASCII(compact, isDigit) { + return "code", compact, nil + } + if len(compact) == 16 && allASCII(compact, isBase32) { + c := strings.ToLower(compact) + return "recovery_code", c[0:4] + "-" + c[4:8] + "-" + c[8:12] + "-" + c[12:16], nil + } + return "", "", ErrNotACode +} + +// isErrorCode is the shape of the platform's codes, ^[a-z0-9_]{1,64}$: the +// error codes of the challenge and the reasons of a refused link alike. Only +// those are echoed to the terminal. +func isErrorCode(s string) bool { + return s != "" && len(s) <= 64 && allASCII(s, func(b byte) bool { + return isDigit(b) || (b >= 'a' && b <= 'z') || b == '_' + }) +} + +func allASCII(s string, ok func(byte) bool) bool { + for i := 0; i < len(s); i++ { + if !ok(s[i]) { + return false + } + } + return true +} + +func isDigit(b byte) bool { return b >= '0' && b <= '9' } + +// isBase32 is the RFC 4648 alphabet, in either case. +func isBase32(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '2' && b <= '7') +} + +// Complete answers the challenge with what the member typed: the code their +// authenticator app shows, or one of their recovery codes. It returns the +// session on success, and an *MFAError (or ErrNotACode) otherwise; Retryable +// says whether this same challenge can take another try. +func (e *MFARequiredError) Complete(input string) (*SignIn, error) { + field, value, err := normalizeAnswer(input) + if err != nil { + return nil, err + } + body, err := json.Marshal(map[string]string{field: value}) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, mfaChallengeURL(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + // JSON is what the platform requires here: a text/plain POST would skip a + // browser's preflight, so anything else is refused before the code is + // looked at. No Origin and no Sec-Fetch-Site - this is not a browser, and + // the platform lets a request through without them only because the + // challenge cookie can be held by nothing but the client that opened it. + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.AddCookie(&http.Cookie{Name: mfaPendingCookie, Value: e.pending}) + + // Never a redirect. Go forwards the Cookie header to a redirect on the + // same host, whatever the port, and a 307 or 308 replays the body with the + // code in it: following one would hand the challenge and its answer to + // wherever it points. The contract has no redirect here, so one is an + // answer like any other refusal. + client := &http.Client{ + Timeout: requestTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("could not reach nan.builders: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + + if resp.StatusCode == http.StatusOK { + var ok struct { + RecoveryCodesRemaining *int `json:"recovery_codes_remaining"` + } + _ = json.Unmarshal(raw, &ok) + for _, c := range resp.Cookies() { + if c.Name == sessionCookie && c.Value != "" { + in := &SignIn{Session: c.Value} + if ok.RecoveryCodesRemaining != nil { + in.UsedRecoveryCode = true + in.RecoveryCodesLeft = *ok.RecoveryCodesRemaining + } + return in, nil + } + } + return nil, errors.New("nan.builders accepted the code but sent no session back — " + newLinkHint) + } + + var refusal struct { + Error string `json:"error"` + AttemptsRemaining int `json:"attempts_remaining"` + LockedUntil string `json:"locked_until"` + RetryAfter int `json:"retry_after"` + } + _ = json.Unmarshal(raw, &refusal) + out := &MFAError{Status: resp.StatusCode, Attempts: refusal.AttemptsRemaining} + // Only something shaped like the platform's codes is taken as one: an + // unknown code is printed, and a body that is not ours - a proxy's error + // page, a 404 from a platform with two-factor sign-in turned off - should + // not put whatever it carries on the member's terminal. + if isErrorCode(refusal.Error) { + out.Code = refusal.Error + } + if t, err := time.Parse(time.RFC3339, refusal.LockedUntil); err == nil { + out.LockedTill = t + } + switch { + case refusal.RetryAfter > 0: + out.RetryAfter = time.Duration(refusal.RetryAfter) * time.Second + default: + if s, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && s > 0 { + out.RetryAfter = time.Duration(s) * time.Second + } + } + return nil, out +} diff --git a/internal/auth/mfa_test.go b/internal/auth/mfa_test.go new file mode 100644 index 0000000..14392cd --- /dev/null +++ b/internal/auth/mfa_test.go @@ -0,0 +1,519 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/nxssie/nan-cli/internal/auth/authtest" +) + +// fakePlatform starts the contract's fake and points the sign-in at it. +func fakePlatform(t *testing.T) *authtest.Platform { + t.Helper() + p := authtest.New(t) + old := SignInBaseURL + SignInBaseURL = p.URL + t.Cleanup(func() { SignInBaseURL = old }) + return p +} + +// challengeFor runs the link exchange against an account with a factor and +// returns the challenge it comes back with. +func challengeFor(t *testing.T, p *authtest.Platform) *MFARequiredError { + t.Helper() + p.Factor = true + _, err := ExchangeToken(authtest.LinkToken) + var ch *MFARequiredError + if !errors.As(err, &ch) { + t.Fatalf("ExchangeToken = %v, want a two-factor challenge", err) + } + return ch +} + +// An account without a factor signs in exactly as before: one POST, the +// session from its cookie. The POST now says this build can answer a +// challenge, and that is the only thing that changed. +func TestNoFactorSignsInAsBefore(t *testing.T) { + p := fakePlatform(t) + got, err := ExchangeToken(authtest.LinkToken) + if err != nil || got != authtest.Session { + t.Fatalf("ExchangeToken = %q, %v", got, err) + } + forms := p.Verifies() + if len(forms) != 1 { + t.Fatalf("%d verify POSTs", len(forms)) + } + f := forms[0] + if f.Get("token") != authtest.LinkToken || f.Get("client") != "cli" || f.Get("mfa") != "1" || f.Has("flow") { + t.Errorf("verify form = %v", f) + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("%d challenge POSTs for an account without a factor", n) + } +} + +// The new CLI announces it can answer, so a platform that follows the +// contract never sends it the "update nan-cli" refusal that older builds get. +func TestTheNewCLINeverMeetsTheUpdateRefusal(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + if ch == nil || p.Verifies()[0].Get("mfa") != "1" { + t.Fatal("no challenge") + } + // And should it meet it anyway, it says what to do in a sentence. + msg := linkReasonMessage("mfa_enabled_update_nan_cli") + for _, want := range []string{"two-factor", "update", "new link"} { + if !strings.Contains(msg, want) { + t.Errorf("%q does not mention %q", msg, want) + } + } +} + +func TestRightCodeSignsIn(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + + in, err := ch.Complete(" 246 810 ") + if err != nil { + t.Fatal(err) + } + if in.Session != authtest.Session || in.UsedRecoveryCode { + t.Errorf("SignIn = %+v", in) + } + + reqs := p.Challenges() + if len(reqs) != 1 { + t.Fatalf("%d challenge POSTs", len(reqs)) + } + r := reqs[0] + if r.Cookie != authtest.Pending { + t.Errorf("challenge cookie = %q", r.Cookie) + } + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q", ct) + } + // Not a browser, and must not pass for one: the platform lets an + // Origin-less request through only because the cookie proves who opened + // the challenge. + for _, h := range []string{"Origin", "Sec-Fetch-Site", "Referer"} { + if v := r.Header.Get(h); v != "" { + t.Errorf("sent %s: %q", h, v) + } + } + var body map[string]string + if err := json.Unmarshal([]byte(r.Body), &body); err != nil || len(body) != 1 || body["code"] != "246810" { + t.Errorf("body = %s", r.Body) + } +} + +func TestWrongCodeThenRight(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + + _, err := ch.Complete("111111") + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_code_invalid" { + t.Fatalf("err = %v", err) + } + if !Retryable(err) { + t.Error("a wrong code ends the sign-in") + } + if !strings.Contains(err.Error(), "4 attempts left") { + t.Errorf("%q does not say how many tries are left", err) + } + + in, err := ch.Complete(authtest.Code) + if err != nil || in.Session != authtest.Session { + t.Fatalf("second try: %+v %v", in, err) + } +} + +// A recovery code is read the way the platform reads it - any case, with or +// without hyphens and spaces - and sent as one, and the answer says how many +// are left. +func TestRecoveryCodeSignsInAndCountsWhatIsLeft(t *testing.T) { + for _, typed := range []string{"q7wa-z2xd-pl4r-6wze", "Q7WA Z2XD PL4R 6WZE", "q7waz2xdpl4r6wze"} { + t.Run(typed, func(t *testing.T) { + p := fakePlatform(t) + p.RecoveryCodes = []string{"aaaa-bbbb-cccc-dddd", "q7wa-z2xd-pl4r-6wze"} + ch := challengeFor(t, p) + + in, err := ch.Complete(typed) + if err != nil { + t.Fatal(err) + } + if !in.UsedRecoveryCode || in.RecoveryCodesLeft != 1 || in.Session != authtest.Session { + t.Errorf("SignIn = %+v", in) + } + var body map[string]string + _ = json.Unmarshal([]byte(p.Challenges()[0].Body), &body) + if len(body) != 1 || body["recovery_code"] != "q7wa-z2xd-pl4r-6wze" { + t.Errorf("body = %s", p.Challenges()[0].Body) + } + }) + } +} + +func TestUsedRecoveryCodeIsRefused(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + _, err := ch.Complete("zzzz-zzzz-zzzz-zzzz") + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_recovery_code_invalid" || !Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "already used") { + t.Errorf("%q", err) + } +} + +// The same 30-second code twice: the platform keeps one use per step, and the +// member is told to wait for the next. +func TestCodeAlreadyUsed(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + if _, err := ch.Complete(authtest.Code); err != nil { + t.Fatal(err) + } + p.NewLink() + ch = challengeFor(t, p) + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_code_already_used" || !Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "wait for the next one") { + t.Errorf("%q", err) + } +} + +// Five wrong codes end the sign-in, and the last answer says so. +func TestFifthWrongCodeEndsTheSignIn(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + var err error + for i := 0; i < 5; i++ { + _, err = ch.Complete("000000") + } + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_challenge_exhausted" || Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "new link") { + t.Errorf("%q", err) + } +} + +func TestExpiredChallenge(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + p.Expire() + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_challenge_expired" || Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "10 minutes") || !strings.Contains(err.Error(), "new link") { + t.Errorf("%q", err) + } +} + +// A lock is said with its end, by the platform's clock and the seconds it +// counts, and it ends this sign-in. +func TestLockedAccount(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + until := time.Now().Add(14*time.Minute + 30*time.Second) + p.Lock(until) + + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_locked" || Retryable(err) { + t.Fatalf("err = %v", err) + } + if refused.Status != http.StatusTooManyRequests || refused.RetryAfter < 14*time.Minute { + t.Errorf("refusal = %+v", refused) + } + msg := err.Error() + for _, want := range []string{"paused", until.Local().Format("15:04"), "15 minutes from now"} { + if !strings.Contains(msg, want) { + t.Errorf("%q does not say %q", msg, want) + } + } +} + +// Every refusal in the contract, as the platform sends it: whether the same +// sign-in can take another answer, and what the member is told. +func TestEveryRefusalInTheContract(t *testing.T) { + for _, c := range []struct { + name string + refusal authtest.Refusal + code string + retryable bool + want []string + }{ + {"wrong code", r(400, `{"error":"mfa_code_invalid","message":"x","attempts_remaining":1}`), "mfa_code_invalid", true, []string{"not right", "1 attempt left"}}, + {"wrong recovery code", r(400, `{"error":"mfa_recovery_code_invalid","message":"x","attempts_remaining":3}`), "mfa_recovery_code_invalid", true, []string{"recovery code", "3 attempts left"}}, + {"replayed code", r(400, `{"error":"mfa_code_already_used","message":"x","attempts_remaining":2}`), "mfa_code_already_used", true, []string{"next one", "2 attempts left"}}, + {"unreadable", r(400, `{"error":"invalid_request","message":"x"}`), "invalid_request", true, []string{"6-digit"}}, + {"exhausted", r(403, `{"error":"mfa_challenge_exhausted","message":"x"}`), "mfa_challenge_exhausted", false, []string{"too many wrong codes", "new link"}}, + {"expired", r(403, `{"error":"mfa_challenge_expired","message":"x"}`), "mfa_challenge_expired", false, []string{"timed out", "new link"}}, + {"used", r(403, `{"error":"mfa_challenge_used","message":"x"}`), "mfa_challenge_used", false, []string{"already over", "new link"}}, + {"missing", r(403, `{"error":"mfa_challenge_missing","message":"x"}`), "mfa_challenge_missing", false, []string{"no record", "new link"}}, + {"refused account", r(403, `{"error":"login_refused","message":"x"}`), "login_refused", false, []string{"refused the sign-in", "new link"}}, + {"locked, body only", r(429, `{"error":"mfa_locked","message":"x","retry_after":600}`), "mfa_locked", false, []string{"paused for 10 minutes"}}, + {"rate limited", rh(429, `{"error":"rate_limited","message":"x","retry_after":60}`, "Retry-After", "60"), "rate_limited", false, []string{"wait 1 minute", "new link"}}, + {"rate limited, header only", rh(429, `{"error":"rate_limited","message":"x"}`, "Retry-After", "45"), "rate_limited", false, []string{"wait 45 seconds"}}, + {"another origin", r(403, `{"error":"forbidden_origin"}`), "forbidden_origin", false, []string{"forbidden origin", "HTTP 403"}}, + {"server error", r(500, `{"error":"internal_error","message":"x"}`), "internal_error", false, []string{"could not check the code", "HTTP 500"}}, + {"two-factor turned off", r(404, "404 page not found\n"), "", false, []string{"HTTP 404", "new link"}}, + {"not our body", r(502, ""+authtest.Pending+"\x1b[31m"), "", false, []string{"HTTP 502"}}, + {"a code that is not one", r(403, `{"error":"Bad \u001b[2J`+authtest.Pending+`"}`), "", false, []string{"HTTP 403"}}, + {"a code from tomorrow", r(403, `{"error":"mfa_something_new","message":"x"}`), "mfa_something_new", false, []string{"mfa something new"}}, + } { + t.Run(c.name, func(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + p.Next = &c.refusal + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) { + t.Fatalf("err = %v (%T)", err, err) + } + if refused.Code != c.code { + t.Errorf("code = %q, want %q", refused.Code, c.code) + } + if Retryable(err) != c.retryable { + t.Errorf("Retryable = %v, want %v", !c.retryable, c.retryable) + } + for _, want := range c.want { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } + if strings.Contains(err.Error(), authtest.Pending) || strings.ContainsRune(err.Error(), '\x1b') { + t.Errorf("the message carries what the body did: %q", err) + } + }) + } +} + +func r(status int, body string) authtest.Refusal { + return authtest.Refusal{Status: status, Body: body, Header: map[string]string{"Content-Type": "application/json"}} +} + +func rh(status int, body, k, v string) authtest.Refusal { + out := r(status, body) + out.Header[k] = v + return out +} + +// Something that is neither a code nor a recovery code is refused here, and +// spends nothing: no request, no attempt. +func TestNotACodeIsNeverSent(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + for _, typed := range []string{"", "12345", "1234567", "abcdef", "q7wa-z2xd-pl4r", "q7wa-z2xd-pl4r-6wze-xxxx", "246810\x00", "246810", + // Sixteen characters, but not the alphabet recovery codes are made + // in: RFC 4648 base32 has no 0, 1, 8 or 9. + "q7wa-z2xd-pl4r-6wz0", "q7wa-z2xd-pl4r-6wz1", "Q7WA Z2XD PL4R 6WZ8", "q7waz2xdpl4r6wz9"} { + if _, err := ch.Complete(typed); !errors.Is(err, ErrNotACode) || !Retryable(err) { + t.Errorf("Complete(%q) = %v", typed, err) + } + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("%d requests for input that is not a code", n) + } +} + +func TestNormalizeAnswer(t *testing.T) { + for _, c := range []struct{ in, field, value string }{ + {"123456", "code", "123456"}, + {" 123 456 ", "code", "123456"}, + {"123-456", "code", "123456"}, + {"q7wa-z2xd-pl4r-6wze", "recovery_code", "q7wa-z2xd-pl4r-6wze"}, + {"Q7WAZ2XDPL4R6WZE", "recovery_code", "q7wa-z2xd-pl4r-6wze"}, + {"q7wa z2xd\tpl4r 6wze", "recovery_code", "q7wa-z2xd-pl4r-6wze"}, + } { + field, value, err := normalizeAnswer(c.in) + if err != nil || field != c.field || value != c.value { + t.Errorf("normalizeAnswer(%q) = %q %q %v", c.in, field, value, err) + } + } +} + +// A redirect from the challenge route is not followed. Go would forward the +// Cookie header to it - same host, other port - and a 307 or 308 would replay +// the body with the code, so the server it points at answers like a +// successful challenge: following it would also look like a sign-in. +func TestTheChallengeDoesNotFollowRedirects(t *testing.T) { + for _, status := range []int{http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(http.StatusText(status), func(t *testing.T) { + var reached []string + var mu sync.Mutex + elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + reached = append(reached, fmt.Sprintf("%s cookie=%q body=%q", r.Method, r.Header.Get("Cookie"), body)) + mu.Unlock() + http.SetCookie(w, &http.Cookie{Name: "nan_session", Value: "a-session-from-elsewhere"}) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"redirect":"/","recovery_codes_remaining":null}`) + })) + defer elsewhere.Close() + + p := fakePlatform(t) + ch := challengeFor(t, p) + p.Next = &authtest.Refusal{Status: status, Header: map[string]string{"Location": elsewhere.URL + "/api/auth/mfa/challenge"}} + + in, err := ch.Complete(authtest.Code) + if err == nil { + t.Errorf("signed in through a %d: %+v", status, in) + } else if Retryable(err) || !strings.Contains(err.Error(), fmt.Sprintf("HTTP %d", status)) { + t.Errorf("err = %v", err) + } + mu.Lock() + defer mu.Unlock() + if len(reached) != 0 { + t.Errorf("followed the %d: the server it pointed at got %v", status, reached) + } + }) + } +} + +// A redirect that asks for the code without the challenge to answer it with +// is a dead end, and says so instead of prompting for nothing. +func TestMFAReasonWithoutTheCookie(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, authtest.FrontendURL+"/mfa?reason=mfa_required", http.StatusFound) + })) + defer srv.Close() + old := SignInBaseURL + SignInBaseURL = srv.URL + defer func() { SignInBaseURL = old }() + + _, err := ExchangeToken(authtest.LinkToken) + var ch *MFARequiredError + if err == nil || errors.As(err, &ch) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "new link") { + t.Errorf("%q", err) + } +} + +// The challenge cookie is a credential for ten minutes. However the +// challenge is printed or logged, the value is not in it. +func TestTheChallengeNeverPrintsItsCookie(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + + var out []string + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%d", "%x"} { + out = append(out, fmt.Sprintf(verb, ch), fmt.Sprintf(verb, error(ch))) + } + out = append(out, ch.Error(), fmt.Errorf("wrapped: %w", ch).Error()) + if b, err := json.Marshal(ch); err == nil { + out = append(out, string(b)) + } + var logged strings.Builder + slog.New(slog.NewTextHandler(&logged, nil)).Info("sign-in", "err", ch, "challenge", ch) + slog.New(slog.NewJSONHandler(&logged, nil)).Info("sign-in", "err", ch, "challenge", ch) + out = append(out, logged.String()) + + for _, s := range out { + if strings.Contains(s, authtest.Pending) { + t.Errorf("the challenge cookie is in %q", s) + } + } +} + +// An account that has not finished signing up and has a factor is sent to the +// browser, with or without mfa=1 ("CLI contract" step 8): no challenge, no +// cookie, and a sentence that says what to do with a link that is spent. +func TestUnfinishedSignUpWithAFactorIsSentToTheBrowser(t *testing.T) { + p := fakePlatform(t) + p.Factor, p.Onboarding = true, true + got, err := ExchangeToken(authtest.LinkToken) + var ch *MFARequiredError + if err == nil || got != "" || errors.As(err, &ch) { + t.Fatalf("ExchangeToken = %q, %v", got, err) + } + if err.Error() != linkReasons["finish_onboarding_in_the_browser"] { + t.Errorf("message = %q", err) + } + for _, want := range []string{"browser", "new link", "cloud.nan.builders", "spent", "sign in here again"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("%d challenge POSTs with no challenge to answer", n) + } +} + +// Without a factor nothing changed: the onboarding token is not a session, +// and the CLI does not take it for one. +func TestUnfinishedSignUpWithoutAFactorGetsNoSession(t *testing.T) { + p := fakePlatform(t) + p.Onboarding = true + got, err := ExchangeToken(authtest.LinkToken) + if err == nil || got != "" || !strings.Contains(err.Error(), "no session came back") { + t.Fatalf("ExchangeToken = %q, %v", got, err) + } +} + +// The reason of a refused link is read out of a redirect's query, decoded, so +// it can be anything. Only the platform's shape of reason is put on the +// terminal; anything else gets a sentence of our own. +func TestOddReasonsNeverReachTheTerminal(t *testing.T) { + for _, c := range []struct { + name, reason string + echoed bool + }{ + {"one of ours, unknown to this build", "a_reason_from_tomorrow", true}, + {"the longest that is still one", strings.Repeat("a", 64), true}, + {"an escape sequence", "\x1b[2J\x1b]0;owned\x07", false}, + {"a new line", "invalid\nlink", false}, + {"capitals", "Invalid_Link", false}, + {"spaces", "please paste your token here", false}, + {"too long", strings.Repeat("a", 65), false}, + {"not ASCII", "enlace_inválido", false}, + } { + t.Run(c.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, authtest.FrontendURL+"/access-denied?reason="+url.QueryEscape(c.reason), http.StatusFound) + })) + defer srv.Close() + old := SignInBaseURL + SignInBaseURL = srv.URL + defer func() { SignInBaseURL = old }() + + _, err := ExchangeToken(authtest.LinkToken) + if err == nil { + t.Fatal("no error") + } + msg := err.Error() + shown := strings.ReplaceAll(c.reason, "_", " ") + if c.echoed != strings.Contains(msg, shown) { + t.Errorf("echoed = %v, want %v: %q", !c.echoed, c.echoed, msg) + } + if strings.ContainsAny(msg, "\x1b\x07\n") { + t.Errorf("control characters reached the message: %q", msg) + } + if !c.echoed && !strings.Contains(msg, "ask for a new link") { + t.Errorf("%q does not say what to do", msg) + } + }) + } +} diff --git a/internal/contexts/path.go b/internal/contexts/path.go new file mode 100644 index 0000000..8362424 --- /dev/null +++ b/internal/contexts/path.go @@ -0,0 +1,290 @@ +// Package contexts holds the agent-context domain of devops AGENT-CONTEXT.md: +// path validators, content typing, the Office and secret checks, the strict +// SKILL.md decoder and the quota rules. Everything in this file set is pure +// (no database, no R2): the API, the manifest builder, nan-run and the CLI +// share it. +package contexts + +import ( + "errors" + "fmt" + "regexp" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" +) + +// Reason is the per-file refusal code the UI and CLI show next to a file +// (AGENT-CONTEXT.md §1.5). The wire values are part of the API contract. +type Reason string + +const ( + ReasonDotfile Reason = "dotfile" + ReasonBadCharacter Reason = "bad_character" + ReasonNotNFC Reason = "not_nfc" + ReasonTooLong Reason = "too_long" + ReasonTooDeep Reason = "too_deep" + ReasonReservedName Reason = "reserved_name" + ReasonCaseCollision Reason = "case_collision" + ReasonBadTopLevel Reason = "bad_top_level" + ReasonNotSupportedYet Reason = "not_supported_yet" // mcp/ (reserved for v2) +) + +// Stored-path limits (§1.5). +const ( + MaxStoredPathBytes = 512 + MaxStoredPathDepth = 8 + MaxComponentBytes = 128 + InstructionsPath = "AGENTS.md" + SkillFileName = "SKILL.md" + topSkills = "skills" + topFiles = "files" + topMCP = "mcp" + contextNameMaxBytes = 63 +) + +// PathError is one refused path with its reason. +type PathError struct { + Path string + Reason Reason +} + +func (e *PathError) Error() string { return fmt.Sprintf("contexts: invalid path (%s)", e.Reason) } + +// ErrInvalidPath matches every *PathError with errors.Is. +var ErrInvalidPath = errors.New("contexts: invalid path") + +// Is makes errors.Is(err, ErrInvalidPath) hold for a *PathError. +func (e *PathError) Is(target error) bool { return target == ErrInvalidPath } + +func pathErr(p string, r Reason) *PathError { return &PathError{Path: p, Reason: r} } + +var ( + reContextName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + reSkillName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) +) + +// ValidContextName reports whether name is an acceptable context name: the +// contexts_name_shape CHECK of migration 126, and not a Windows reserved +// basename (`con`, `nul`, `com1`, ...). The second rule is not in the SQL +// CHECK: a context's name becomes a directory of the run's manifest +// (files//...), and the manifest validator refuses reserved names, so +// a context named `con` could never deliver its files. +// +// Two more names are refused because the API could never reach them by +// name: a UUID-shaped name (GET /v1/contexts/{x} always reads it as an id) +// and "blobs" (the /v1/contexts/blobs/... upload routes). +func ValidContextName(name string) bool { + return reContextName.MatchString(name) && !isWindowsReserved(name) && name != reservedBlobs && !uuidShaped(name) +} + +const reservedBlobs = "blobs" + +// uuidShaped reports the 8-4-4-4-12 lowercase hex form. +func uuidShaped(s string) bool { + if len(s) != 36 { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + switch i { + case 8, 13, 18, 23: + if c != '-' { + return false + } + default: + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return false + } + } + } + return true +} + +// ValidSkillName reports whether name is an acceptable skill directory name. +func ValidSkillName(name string) bool { return reSkillName.MatchString(name) } + +// ValidateStoredPath checks one path a member publishes (§1.5 "Stored +// paths"). It returns nil or a *PathError carrying the reason. Uniqueness +// (case_collision) is a property of the whole tree: see ValidateTree. +func ValidateStoredPath(p string) error { + comps, err := checkPathShape(p, MaxStoredPathBytes, MaxStoredPathDepth) + if err != nil { + return err + } + switch top := comps[0]; { + case len(comps) == 1 && top == InstructionsPath: + return nil + case top == topSkills: + // skills// + if len(comps) < 3 { + return pathErr(p, ReasonBadTopLevel) + } + if !ValidSkillName(comps[1]) { + return pathErr(p, ReasonBadCharacter) + } + return nil + case top == topFiles: + if len(comps) < 2 { + return pathErr(p, ReasonBadTopLevel) + } + return nil + case top == topMCP: + return pathErr(p, ReasonNotSupportedYet) + default: + return pathErr(p, ReasonBadTopLevel) + } +} + +// checkPathShape applies the rules shared by stored and manifest paths: +// UTF-8, NFC, byte and depth limits, and the component rules. +func checkPathShape(p string, maxBytes, maxDepth int) ([]string, error) { + if !utf8.ValidString(p) { + return nil, pathErr(p, ReasonBadCharacter) + } + if !norm.NFC.IsNormalString(p) { + return nil, pathErr(p, ReasonNotNFC) + } + if len(p) == 0 { + return nil, pathErr(p, ReasonBadCharacter) + } + if len(p) > maxBytes { + return nil, pathErr(p, ReasonTooLong) + } + comps := strings.Split(p, "/") + if len(comps) > maxDepth { + return nil, pathErr(p, ReasonTooDeep) + } + for _, c := range comps { + if r := componentReason(c); r != "" { + return nil, pathErr(p, r) + } + } + return comps, nil +} + +// componentReason returns the reason one path component is refused, or "". +func componentReason(c string) Reason { + if c == "" { + return ReasonBadCharacter // empty component: leading, trailing or doubled '/' + } + if len(c) > MaxComponentBytes { + return ReasonTooLong + } + if c[0] == '.' { + return ReasonDotfile // .git, .env.example, ., .. + } + if c[0] == ' ' || c[len(c)-1] == ' ' || c[len(c)-1] == '.' { + return ReasonBadCharacter + } + for _, r := range c { + if !allowedRune(r) { + return ReasonBadCharacter + } + } + if isWindowsReserved(c) { + return ReasonReservedName + } + return "" +} + +// allowedRune: Unicode letters (L*), combining marks (Mn, Mc), decimal +// digits (Nd), the ASCII space and `. _ - ( ) + , @`, minus every +// Default_Ignorable_Code_Point. Everything else (Cc, Cf, other separators, +// symbols, emoji, private use, unassigned, shell/Windows-hostile ASCII) is +// refused. +func allowedRune(r rune) bool { + switch r { + case ' ', '.', '_', '-', '(', ')', '+', ',', '@': + return true + } + if !(unicode.IsLetter(r) || unicode.In(r, unicode.Mn, unicode.Mc, unicode.Nd)) { + return false + } + return !defaultIgnorable(r) +} + +// defaultIgnorable covers Default_Ignorable_Code_Point among the categories +// allowedRune admits: the derived property is Other_Default_Ignorable_Code_Point +// + Cf + Variation_Selector (minus a few code points that are Cf, refused +// anyway). So Hangul fillers (Lo), U+034F and the Khmer inherent vowels (Mn) +// and every variation selector (Mn) are refused. +func defaultIgnorable(r rune) bool { + return unicode.In(r, unicode.Other_Default_Ignorable_Code_Point, unicode.Variation_Selector, unicode.Cf) +} + +var windowsReserved = map[string]bool{"CON": true, "PRN": true, "AUX": true, "NUL": true} + +func init() { + for i := 1; i <= 9; i++ { + windowsReserved[fmt.Sprintf("COM%d", i)] = true + windowsReserved[fmt.Sprintf("LPT%d", i)] = true + } +} + +// isWindowsReserved: the basename before the first '.' (any extension, any +// case) is CON, PRN, AUX, NUL, COM1..COM9 or LPT1..LPT9. +func isWindowsReserved(c string) bool { + base := c + if i := strings.IndexByte(c, '.'); i >= 0 { + base = c[:i] + } + return windowsReserved[strings.ToUpper(base)] +} + +var folder = cases.Fold() + +// FoldKey is the case-insensitive identity of a path: Unicode full case +// folding of the NFC form, re-normalised (folding İ yields a combining dot). +// Two paths with the same key collide on a case-insensitive file system. +func FoldKey(p string) string { + return norm.NFC.String(folder.String(norm.NFC.String(p))) +} + +// ValidateTree validates every path of one version and their uniqueness: no +// two paths with the same FoldKey, and no path whose folded form is also a +// folder of another path (a file and a folder cannot share a name on any +// file system). It returns every refusal (sorted by path), not only the +// first, so the UI and CLI can mark each file. +func ValidateTree(paths []string) []PathError { + var out []PathError + seen := make(map[string]string, len(paths)) // fold key -> first path + valid := make([]string, 0, len(paths)) + for _, p := range paths { + if err := ValidateStoredPath(p); err != nil { + var pe *PathError + errors.As(err, &pe) + out = append(out, *pe) + continue + } + k := FoldKey(p) + if _, dup := seen[k]; dup { + out = append(out, PathError{Path: p, Reason: ReasonCaseCollision}) + continue + } + seen[k] = p + valid = append(valid, p) + } + // File/folder clash: a valid path whose folded form is a folder prefix + // of another valid path. + dirs := make(map[string]bool) + for _, p := range valid { + k := FoldKey(p) + for i := 0; i < len(k); i++ { + if k[i] == '/' { + dirs[k[:i]] = true + } + } + } + for _, p := range valid { + if dirs[FoldKey(p)] { + out = append(out, PathError{Path: p, Reason: ReasonCaseCollision}) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} diff --git a/internal/contexts/path_test.go b/internal/contexts/path_test.go new file mode 100644 index 0000000..b9c4d82 --- /dev/null +++ b/internal/contexts/path_test.go @@ -0,0 +1,257 @@ +package contexts + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +// Parity table: copied from cloud-api internal/contexts/path_test.go (the +// middleware name-shape test is left out: nan-cli has no route table). The +// copy must stay in step with the server; cloud-api's CI checks that it does. +// +// Stored paths (devops AGENT-CONTEXT.md section 1.5): every refusal carries its +// per-file reason; the table covers the Unicode rules and the exact maxima. + +func reasonOf(err error) Reason { + var pe *PathError + if errors.As(err, &pe) { + return pe.Reason + } + return "" +} + +// rep builds a component of n bytes from an ASCII letter. +func rep(n int) string { return strings.Repeat("a", n) } + +// pathOf builds a files/ path of exactly total bytes with depth components +// (files + depth-1 components), every component at most 128 bytes. +func pathOf(t *testing.T, total, depth int) string { + t.Helper() + comps := []string{"files"} + left := total - len("files") + n := depth - 1 + for i := 0; i < n; i++ { + remainingComps := n - i + // bytes for this component: its share of what is left after the separators + avail := left - remainingComps // one '/' per remaining component + share := avail / remainingComps + if i == n-1 { + share = avail + } + if share > MaxComponentBytes { + share = MaxComponentBytes + } + comps = append(comps, rep(share)) + left -= share + 1 + } + p := strings.Join(comps, "/") + if len(p) != total || len(comps) != depth { + t.Fatalf("pathOf(%d,%d) built %d bytes, %d comps", total, depth, len(p), len(comps)) + } + return p +} + +func TestStoredPathTable(t *testing.T) { + for _, tc := range []struct { + name string + path string + want Reason // "" = accepted + }{ + // Layout. + {"instructions", "AGENTS.md", ""}, + {"skill file", "skills/pr-review/SKILL.md", ""}, + {"skill aux", "skills/pr-review/scripts/run.sh", ""}, + {"file", "files/spec.pdf", ""}, + {"nested file", "files/docs/api/v1.md", ""}, + {"agents not top", "files/AGENTS.md", ""}, + {"agents lowercase", "agents.md", ReasonBadTopLevel}, + {"agents as dir", "AGENTS.md/x", ReasonBadTopLevel}, + {"unknown top", "notes/a.md", ReasonBadTopLevel}, + {"bare files", "files", ReasonBadTopLevel}, + {"skill without file", "skills/pr-review", ReasonBadTopLevel}, + {"file directly in skills", "skills/readme.md", ReasonBadTopLevel}, + {"skill uppercase", "skills/PR/SKILL.md", ReasonBadCharacter}, + {"skill accent", "skills/revisión/SKILL.md", ReasonBadCharacter}, + {"skill 64 bytes", "skills/" + rep(64) + "/SKILL.md", ""}, + {"skill 65 bytes", "skills/" + rep(65) + "/SKILL.md", ReasonBadCharacter}, + {"mcp reserved", "mcp/server.json", ReasonNotSupportedYet}, + // Spaces and Unicode letters are accepted (Lead decision 2026-10-21). + {"spaces", "files/Meeting notes 2026.md", ""}, + {"accents", "files/especificación técnica.md", ""}, + {"cjk", "files/仕様書.md", ""}, + {"cyrillic", "files/документ.txt", ""}, + {"arabic digits", "files/ملف١٢.txt", ""}, + {"devanagari marks", "files/ह\u093Fन\u094Dद\u0940.txt", ""}, + {"punctuation set", "files/a_b-c(d)+e,f@g.txt", ""}, + {"dotless i", "files/ılık.md", ""}, + {"eszett", "files/Straße.md", ""}, + // NFC is required, never rewritten. + {"decomposed e acute", "files/especificacio\u0301n.md", ReasonNotNFC}, + {"composed e acute", "files/especificación.md", ""}, + // Format and ignorable characters. + {"rtl override", "files/invoice\u202Etxt.exe", ReasonBadCharacter}, + {"zero width joiner", "files/a\u200Db.md", ReasonBadCharacter}, + {"zero width space", "files/a\u200Bb.md", ReasonBadCharacter}, + {"bom", "files/\uFEFFa.md", ReasonBadCharacter}, + {"soft hyphen", "files/a\u00ADb.md", ReasonBadCharacter}, + {"nbsp", "files/a\u00A0b.md", ReasonBadCharacter}, + {"ideographic space", "files/a\u3000b.md", ReasonBadCharacter}, + {"tab", "files/a\tb.md", ReasonBadCharacter}, + {"newline", "files/a\nb.md", ReasonBadCharacter}, + {"nul", "files/a\x00b.md", ReasonBadCharacter}, + {"variation selector 16", "files/a\uFE0Fb.md", ReasonBadCharacter}, + {"variation selector supplement", "files/a\U000E0100b.md", ReasonBadCharacter}, + {"mongolian fvs", "files/a\u180Bb.md", ReasonBadCharacter}, + {"combining grapheme joiner", "files/a\u034Fb.md", ReasonBadCharacter}, + {"hangul filler", "files/a\u3164b.md", ReasonBadCharacter}, + {"hangul choseong filler", "files/\u115Fa.md", ReasonBadCharacter}, + {"hangul jungseong filler", "files/\u1160a.md", ReasonBadCharacter}, + {"halfwidth hangul filler", "files/a\uFFA0b.md", ReasonBadCharacter}, + {"khmer inherent vowel", "files/a\u17B4b.md", ReasonBadCharacter}, + {"emoji", "files/rocket\U0001F680.md", ReasonBadCharacter}, + {"symbol", "files/a\u00A9b.md", ReasonBadCharacter}, + {"private use", "files/a\uE000b.md", ReasonBadCharacter}, + {"unassigned", "files/a\U000E0080b.md", ReasonBadCharacter}, + {"invalid utf8", "files/a\xffb.md", ReasonBadCharacter}, + // Shell and Windows hostile ASCII. + {"backslash", "files/a\\b.md", ReasonBadCharacter}, + {"colon", "files/a:b.md", ReasonBadCharacter}, + {"star", "files/a*.md", ReasonBadCharacter}, + {"question", "files/a?.md", ReasonBadCharacter}, + {"quote", "files/a\"b.md", ReasonBadCharacter}, + {"lt", "files/ab.md", ReasonBadCharacter}, + {"pipe", "files/a|b.md", ReasonBadCharacter}, + {"apostrophe", "files/don't.md", ReasonBadCharacter}, + {"backtick", "files/a`b.md", ReasonBadCharacter}, + {"dollar", "files/$HOME.md", ReasonBadCharacter}, + // Dotfiles, leading and trailing space or dot. + {"dotfile", "files/.env", ReasonDotfile}, + {"dot env example", "files/.env.example", ReasonDotfile}, + {"git dir", "files/.git/config", ReasonDotfile}, + {"pi dir", "skills/x/.pi/a.md", ReasonDotfile}, + {"dot", "files/./a.md", ReasonDotfile}, + {"dotdot", "files/../AGENTS.md", ReasonDotfile}, + {"leading space", "files/ a.md", ReasonBadCharacter}, + {"trailing space", "files/a.md ", ReasonBadCharacter}, + {"trailing dot", "files/a.", ReasonBadCharacter}, + {"trailing dot dir", "files/dir./a.md", ReasonBadCharacter}, + {"inner dots ok", "files/v1.2.3.tar.md", ""}, + // Empty components and absolute paths. + {"empty", "", ReasonBadCharacter}, + {"absolute", "/files/a.md", ReasonBadCharacter}, + {"double slash", "files//a.md", ReasonBadCharacter}, + {"trailing slash", "files/a/", ReasonBadCharacter}, + // Windows reserved basenames, any extension, any case. + {"con", "files/CON", ReasonReservedName}, + {"con lower ext", "files/con.txt", ReasonReservedName}, + {"nul double ext", "files/Nul.tar.md", ReasonReservedName}, + {"com1", "files/com1.md", ReasonReservedName}, + {"lpt9 dir", "files/LPT9/a.md", ReasonReservedName}, + {"aux", "files/aux", ReasonReservedName}, + {"prn", "files/prn.md", ReasonReservedName}, + {"com0 ok", "files/com0.md", ""}, + {"console ok", "files/console.md", ""}, + {"con- ok", "files/con-1.md", ""}, + // Exact maxima (512 bytes, 8 components, 128 bytes per component). + {"512 bytes", pathOf(t, 512, 6), ""}, + {"513 bytes", pathOf(t, 513, 6), ReasonTooLong}, + {"8 components", "files/a/b/c/d/e/f/g.md", ""}, + {"9 components", "files/a/b/c/d/e/f/g/h.md", ReasonTooDeep}, + {"component 128", "files/" + rep(128), ""}, + {"component 129", "files/" + rep(129), ReasonTooLong}, + {"component 128 multibyte", "files/" + strings.Repeat("é", 64), ""}, + {"component 129 multibyte", "files/" + strings.Repeat("é", 64) + "a", ReasonTooLong}, + } { + err := ValidateStoredPath(tc.path) + if got := reasonOf(err); got != tc.want { + t.Errorf("%s: %q: reason %q, want %q (err %v)", tc.name, tc.path, got, tc.want, err) + } + if err != nil && !errors.Is(err, ErrInvalidPath) { + t.Errorf("%s: error does not match ErrInvalidPath", tc.name) + } + } +} + +func TestValidContextName(t *testing.T) { + for name, want := range map[string]bool{ + "acme": true, "a": true, "0": true, "pr-review": true, rep(63): true, + rep(64): false, "": false, "-a": false, "Acme": false, "a_b": false, "a.b": false, "a b": false, "ñ": false, + // Windows reserved: the name becomes files// in the manifest. + "con": false, "nul": false, "aux": false, "prn": false, "com1": false, "lpt9": false, + "com0": true, "console": true, "con-1": true, + } { + if got := ValidContextName(name); got != want { + t.Errorf("ValidContextName(%q) = %v", name, got) + } + } +} + +// Uniqueness is case-insensitive with Unicode full case folding of the NFC +// form: ß folds to ss, final sigma to sigma; the Turkish dotless ı has no +// default folding (only the Turkic one), so ı and I do not collide, and İ +// folds to i + U+0307, so it collides with neither i nor I. +func TestValidateTreeCaseCollisions(t *testing.T) { + for _, tc := range []struct { + name string + paths []string + want []PathError + }{ + {"distinct", []string{"AGENTS.md", "files/a.md", "files/b.md", "skills/x/SKILL.md"}, nil}, + {"ascii case", []string{"files/Readme.md", "files/README.md"}, []PathError{{"files/README.md", ReasonCaseCollision}}}, + {"eszett", []string{"files/Straße.md", "files/STRASSE.md"}, []PathError{{"files/STRASSE.md", ReasonCaseCollision}}}, + {"capital eszett", []string{"files/ẞ.md", "files/ss.md"}, []PathError{{"files/ss.md", ReasonCaseCollision}}}, + {"sigma", []string{"files/ΟΔΟΣ.md", "files/οδος.md"}, []PathError{{"files/οδος.md", ReasonCaseCollision}}}, + {"final sigma", []string{"files/οδος.md", "files/οδοσ.md"}, []PathError{{"files/οδοσ.md", ReasonCaseCollision}}}, + {"accented case", []string{"files/Ésta.md", "files/ésta.md"}, []PathError{{"files/ésta.md", ReasonCaseCollision}}}, + {"dotless i is distinct", []string{"files/ı.md", "files/I.md", "files/i.md"}, []PathError{{"files/i.md", ReasonCaseCollision}}}, + {"dotted capital I is distinct", []string{"files/İ.md", "files/i.md"}, nil}, + {"folder case", []string{"files/Docs/a.md", "files/docs/b.md"}, nil}, + {"file vs folder", []string{"files/docs", "files/Docs/a.md"}, []PathError{{"files/docs", ReasonCaseCollision}}}, + {"invalid reported too", []string{"files/.env", "files/a.md", "files/A.md"}, + []PathError{{"files/.env", ReasonDotfile}, {"files/A.md", ReasonCaseCollision}}}, + } { + got := ValidateTree(tc.paths) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s: %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestFoldKeyIsStable(t *testing.T) { + // Folding İ yields a combining sequence; the key is re-normalised so a + // second fold is a no-op. + for _, p := range []string{"files/İstanbul.md", "files/Straße.md", "files/ΟΔΟΣ.md"} { + k := FoldKey(p) + if FoldKey(k) != k { + t.Errorf("FoldKey(%q) not idempotent: %q vs %q", p, k, FoldKey(k)) + } + } +} + +// Names the API could never reach by name are refused: UUID-shaped names +// (always read as ids) and "blobs" (the upload routes). +func TestContextNameReachable(t *testing.T) { + for _, n := range []string{"123e4567-e89b-12d3-a456-426614174000", "blobs"} { + if ValidContextName(n) { + t.Errorf("%q accepted", n) + } + } + for _, n := range []string{"blobs-2", "my-blobs", "123e4567-e89b-12d3-a456-42661417400", "123e4567e89b12d3a456426614174000"} { + if !ValidContextName(n) { + t.Errorf("%q refused", n) + } + } +} + +// File names never carry a quote, a backslash or a percent sign (they would +// need escaping in Content-Disposition). +func TestStoredPathRefusesHeaderCharacters(t *testing.T) { + for _, p := range []string{`files/a"b.txt`, `files/a\b.txt`, "files/a%b.txt"} { + if ValidateStoredPath(p) == nil { + t.Errorf("%q accepted", p) + } + } +} diff --git a/internal/contexts/pins_test.go b/internal/contexts/pins_test.go new file mode 100644 index 0000000..5938c29 --- /dev/null +++ b/internal/contexts/pins_test.go @@ -0,0 +1,51 @@ +package contexts + +import ( + "os" + "path/filepath" + "regexp" + "testing" + "unicode" +) + +// The path rules read Unicode tables from two places: the standard library +// (unicode.IsLetter, the categories) and golang.org/x/text (NFC, case +// folding). cloud-api accepts a name only when its own tables say so, so +// this CLI has to read the same tables or `nan context pull` could refuse a +// name the server stored (devops AGENT-CONTEXT.md §1.5). +// +// These are the pins both repositories must share. x/text is v0.39.0, +// the first release without GO-2026-5970 (an infinite loop in +// unicode/norm on invalid UTF-8, reached by these rules). +// +// The cross-repo check lives in cloud-api's CI +// (scripts/check-nan-cli-text-parity.sh, helmcode/nan-cloud-api#397): it +// reads this repository's public go.mod and compares the x/text version +// and unicode.Version under the go version declared here with its own. +// This repository is public and cannot read cloud-api. +const ( + cloudAPIXText = "v0.39.0" + cloudAPIUnicode = "15.0.0" +) + +var reXText = regexp.MustCompile(`(?m)^\s*golang\.org/x/text\s+(v\S+)`) + +func TestXTextPinnedToCloudAPI(t *testing.T) { + b, err := os.ReadFile(filepath.Join("..", "..", "go.mod")) + if err != nil { + t.Fatal(err) + } + m := reXText.FindSubmatch(b) + if m == nil { + t.Fatal("go.mod does not require golang.org/x/text") + } + if got := string(m[1]); got != cloudAPIXText { + t.Errorf("go.mod pins golang.org/x/text %s, cloud-api builds with %s: the path rules would read other Unicode tables", got, cloudAPIXText) + } +} + +func TestUnicodeTablesMatchCloudAPI(t *testing.T) { + if unicode.Version != cloudAPIUnicode { + t.Errorf("this toolchain's unicode tables are %s, cloud-api's are %s", unicode.Version, cloudAPIUnicode) + } +} diff --git a/internal/runs/proposal_test.go b/internal/runs/proposal_test.go index 63f4c23..04d2673 100644 --- a/internal/runs/proposal_test.go +++ b/internal/runs/proposal_test.go @@ -106,6 +106,19 @@ func TestRunURLFallsBackWithoutAWorkspaceID(t *testing.T) { } } +// An ephemeral run has no workspace: its page is /runs/. +func TestRunURLOfAnEphemeralRun(t *testing.T) { + run := awaitingRun(t) + run.Executor, run.Workspace = api.ExecutorEphemeral, api.RunWorkspace{} + if got := RunURL(run); got != "https://cloud.nan.builders/runs/"+strings.ToLower(run.ID) { + t.Errorf("RunURL = %q", got) + } + run.ID = "../../evil" + if got := RunURL(run); got != "https://cloud.nan.builders/automations?tab=approvals" { + t.Errorf("RunURL with a bad id = %q", got) + } +} + func TestPhase2Events(t *testing.T) { var b bytes.Buffer r := &Renderer{Out: &b} diff --git a/internal/runs/render.go b/internal/runs/render.go index ce4d568..4929f45 100644 --- a/internal/runs/render.go +++ b/internal/runs/render.go @@ -366,9 +366,13 @@ func AutomationURL(id string) string { } // RunURL is a run's page in the portal, where its proposal is approved. -// The workspace id is part of the path; without a well-formed one this -// falls back to the approvals inbox. +// The workspace id is part of the path, except for an ephemeral run (no +// workspace: /runs/); without a well-formed id this falls back to the +// approvals inbox. func RunURL(run *api.Run) string { + if run.Ephemeral() && isUUID(run.ID) { + return PortalURL + "/runs/" + strings.ToLower(run.ID) + } if !isUUID(run.ID) || !isUUID(run.Workspace.ID) { return ApprovalsURL } diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index 0f56b83..581bbbe 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -1980,7 +1980,7 @@ func TestSigningInGivesTheClientTheNewToken(t *testing.T) { t.Fatal(err) } - updated, _ := m.Update(signedInMsg{nil}) + updated, _ := m.Update(signedInMsg{}) after := updated.(model) if after.sess.Token != "a-fresh-session-token" { @@ -2247,7 +2247,7 @@ func TestSigningInLeadsStraightToTheKey(t *testing.T) { t.Fatal(err) } - updated, _ := m.Update(signedInMsg{nil}) + updated, _ := m.Update(signedInMsg{}) after := updated.(model) if !after.editingKey { t.Error("after signing in the member is left to work out that `e` is next") diff --git a/internal/tui/mfa_test.go b/internal/tui/mfa_test.go new file mode 100644 index 0000000..1422161 --- /dev/null +++ b/internal/tui/mfa_test.go @@ -0,0 +1,233 @@ +package tui + +import ( + "bytes" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/nxssie/nan-cli/internal/auth" + "github.com/nxssie/nan-cli/internal/auth/authtest" + "github.com/nxssie/nan-cli/internal/session" +) + +// Two-factor sign-in from the panel: the same flow as `nan auth login`, +// through internal/auth, with the code asked for as a third question. + +const mfaTestLink = "https://cloud-api.nan.builders/api/auth/login/verify?token=" + authtest.LinkToken + +// atTheCode drives the panel up to the code question against a fake platform +// whose account has a factor: the address, the link, and the exchange that +// comes back with a challenge. +func atTheCode(t *testing.T) (model, *authtest.Platform) { + t.Helper() + p := authtest.New(t) + p.Factor = true + old := auth.SignInBaseURL + auth.SignInBaseURL = p.URL + t.Cleanup(func() { auth.SignInBaseURL = old }) + + m := setupModel(t, &session.Session{}) + m.lay = newLayout(100, 40) + m.startLogin() + m.loginEmail = "member@example.com" + mod, _ := m.Update(linkSentMsg{}) + m = mod.(model) + + msg := signIn(mfaTestLink)() + if _, ok := msg.(mfaChallengeMsg); !ok { + t.Fatalf("the link came back as %#v, not a challenge", msg) + } + mod, _ = m.Update(msg) + return mod.(model), p +} + +// enter types value into the field and presses enter, running what that +// starts and handing its answer back to the panel. +func enter(t *testing.T, m model, value string) model { + t.Helper() + m.loginInput.SetValue(value) + mod, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter started nothing") + } + mod, _ = mod.(model).Update(cmd()) + return mod.(model) +} + +func TestThePanelAsksForTheCode(t *testing.T) { + m, _ := atTheCode(t) + if m.loginStage != loginAskCode || m.wizard != wizardLink { + t.Fatalf("stage %v, wizard %v", m.loginStage, m.wizard) + } + if m.loginInput.EchoMode == 0 { + t.Error("the code field echoes: a recovery code works until it is used") + } + flat := strings.Join(strings.Fields(m.View()), " ") + for _, want := range []string{"Authenticator code", "two-factor", "recovery codes"} { + if !strings.Contains(flat, want) { + t.Errorf("the screen does not say %q:\n%s", want, m.View()) + } + } + + m = enter(t, m, authtest.Code) + if m.loginStage != loginOff || m.loginChallenge != nil { + t.Errorf("still signing in after the right code: stage %v", m.loginStage) + } + sess, err := session.Load() + if err != nil || sess.Token != authtest.Session { + t.Fatalf("session = %+v, %v", sess, err) + } + if m.client.Token() != authtest.Session { + t.Error("the panel's client does not carry the new session") + } + assertNoChallengeUnderHome(t) +} + +// A wrong code keeps the same question, empty, with what went wrong and how +// many tries are left. +func TestThePanelAsksAgainAfterAWrongCode(t *testing.T) { + m, p := atTheCode(t) + m = enter(t, m, "000000") + if m.loginStage != loginAskCode { + t.Fatalf("a wrong code left the question: stage %v", m.loginStage) + } + if m.loginInput.Value() != "" { + t.Error("the wrong code is still in the field") + } + if !strings.Contains(m.loginMsg, "4 attempts left") { + t.Errorf("loginMsg = %q", m.loginMsg) + } + m = enter(t, m, authtest.Code) + if m.loginStage != loginOff { + t.Errorf("the right code after a wrong one did not sign in: stage %v", m.loginStage) + } + if n := len(p.Challenges()); n != 2 { + t.Errorf("%d challenge POSTs", n) + } +} + +// A sign-in that cannot go on - timed out, too many wrong codes, refused - +// spent its link, so the panel goes back to the address, filled in: enter +// asks for a new one. +func TestThePanelStartsAgainWhenTheSignInEnds(t *testing.T) { + m, p := atTheCode(t) + p.Expire() + m = enter(t, m, authtest.Code) + if m.loginStage != loginAskEmail || m.wizard != wizardEmail { + t.Fatalf("stage %v, wizard %v", m.loginStage, m.wizard) + } + if m.loginChallenge != nil { + t.Error("the panel keeps a challenge that is over") + } + if m.loginInput.Value() != "member@example.com" || m.loginInput.Prompt != "Email: " { + t.Errorf("field = %q %q", m.loginInput.Prompt, m.loginInput.Value()) + } + if !strings.HasPrefix(m.loginMsg, "error: ") || !strings.Contains(m.loginMsg, "new link") { + t.Errorf("loginMsg = %q", m.loginMsg) + } + if s, err := session.Load(); err == nil && s.Token != "" { + t.Errorf("saved a session: %+v", s) + } +} + +func TestThePanelSaysHowManyRecoveryCodesAreLeft(t *testing.T) { + m, p := atTheCode(t) + p.RecoveryCodes = []string{"q7wa-z2xd-pl4r-6wze", "aaaa-bbbb-cccc-dddd"} + m = enter(t, m, "Q7WA-Z2XD-PL4R-6WZE") + if m.loginStage != loginOff || m.wizard != wizardKey { + t.Fatalf("stage %v, wizard %v", m.loginStage, m.wizard) + } + flat := strings.Join(strings.Fields(m.View()), " ") + if !strings.Contains(flat, "1 left") || !strings.Contains(flat, "Two-factor authentication") { + t.Errorf("the next step does not say how many are left:\n%s", m.View()) + } +} + +// Esc leaves the code question and forgets the challenge; an exchange that +// answers after the member left does not bring the question back. +func TestEscapeForgetsTheChallenge(t *testing.T) { + m, _ := atTheCode(t) + mod, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = mod.(model) + if m.loginStage != loginOff || m.loginChallenge != nil { + t.Errorf("stage %v, challenge kept: %v", m.loginStage, m.loginChallenge != nil) + } + + m2, _ := atTheCode(t) + challenge := m2.loginChallenge + m2.cancelLogin() + mod, _ = m2.Update(mfaChallengeMsg{challenge}) + if got := mod.(model); got.loginStage != loginOff || got.loginChallenge != nil { + t.Errorf("a late challenge reopened the sign-in: stage %v", got.loginStage) + } +} + +// The screen never shows the challenge cookie, and nothing under HOME holds +// it. +func TestThePanelKeepsTheChallengeToItself(t *testing.T) { + m, _ := atTheCode(t) + m = enter(t, m, "000000") + if strings.Contains(m.View(), authtest.Pending) || strings.Contains(m.loginMsg, authtest.Pending) { + t.Error("the challenge cookie is on screen") + } + assertNoChallengeUnderHome(t) +} + +func assertNoChallengeUnderHome(t *testing.T) { + t.Helper() + err := filepath.WalkDir(os.Getenv("HOME"), func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if bytes.Contains(data, []byte(authtest.Pending)) { + t.Errorf("%s holds the challenge cookie", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// An account that has not finished signing up and has a factor: the panel +// stays on the link question with the sentence that sends it to the browser, +// and asks for no code. +func TestThePanelSendsAnUnfinishedSignUpToTheBrowser(t *testing.T) { + p := authtest.New(t) + p.Factor, p.Onboarding = true, true + old := auth.SignInBaseURL + auth.SignInBaseURL = p.URL + t.Cleanup(func() { auth.SignInBaseURL = old }) + + m := setupModel(t, &session.Session{}) + m.lay = newLayout(100, 40) + m.startLogin() + mod, _ := m.Update(linkSentMsg{}) + m = mod.(model) + m = enter(t, m, mfaTestLink) + + if m.loginStage != loginAskLink || m.loginChallenge != nil { + t.Fatalf("stage %v, challenge %v", m.loginStage, m.loginChallenge != nil) + } + const want = "error: your NaN account is not set up yet — finish it in the browser: ask for a new link at " + + "https://cloud.nan.builders (this one is spent), complete the setup there, then sign in here again" + if m.loginMsg != want { + t.Errorf("loginMsg = %q", m.loginMsg) + } + flat := strings.Join(strings.Fields(m.View()), " ") + if !strings.Contains(flat, "finish it in the browser") { + t.Errorf("the screen does not say it:\n%s", m.View()) + } + if s, err := session.Load(); err == nil && s.Token != "" { + t.Errorf("saved a session: %+v", s) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 581fae1..561325d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -153,7 +153,17 @@ type configuredMsg struct { type firstRunMsg struct{} type linkSentMsg struct{ err error } -type signedInMsg struct{ err error } + +// signedInMsg ends the sign-in: err when it failed, and notice for something +// the member should read once it worked (a recovery code spent). +type signedInMsg struct { + err error + notice string +} + +// mfaChallengeMsg is a link that worked for an account with two-factor +// authentication: the session comes once the challenge is answered. +type mfaChallengeMsg struct{ challenge *auth.MFARequiredError } // signedOutMsg is the platform's answer to ending the session (err nil when it // confirmed). @@ -205,6 +215,11 @@ type model struct { loginEmail string loginMsg string loginBusy bool + // The two-factor challenge the link opened, while the code is asked + // for. In memory only, like everything else about it. + loginChallenge *auth.MFARequiredError + // Said on the steps after the sign-in, once: a recovery code was spent. + loginNotice string } // Where a member is in the guided setup. The inner inputs still drive @@ -262,13 +277,15 @@ func (w wizardStep) mood() mascotMood { } // Where a member is in the sign-in flow. It is two questions - an address and -// the link that arrives at it. +// the link that arrives at it - and a third for an account with two-factor +// authentication: the code from the authenticator app. type loginStage int const ( loginOff loginStage = iota loginAskEmail loginAskLink + loginAskCode ) func newModel(client *api.Client, sess *session.Session) model { @@ -425,10 +442,44 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loginInput.EchoCharacter = '•' return m, m.loginInput.Focus() + case mfaChallengeMsg: + m.loginBusy = false + // Left with esc while the link was being exchanged: nothing to ask. + if m.loginStage != loginAskLink { + return m, nil + } + m.loginChallenge = msg.challenge + m.loginStage = loginAskCode + m.loginMsg = "this account uses two-factor authentication — enter the 6-digit code from your" + + " authenticator app, or one of your recovery codes" + m.loginInput.SetValue("") + m.loginInput.Placeholder = "123456" + m.loginInput.Prompt = "Authenticator code: " + // A recovery code works until it is used, so it is not echoed either. + m.loginInput.EchoMode = textinput.EchoPassword + m.loginInput.EchoCharacter = '•' + return m, m.loginInput.Focus() + case signedInMsg: m.loginBusy = false if msg.err != nil { m.loginMsg = "error: " + msg.err.Error() + if m.loginStage == loginAskCode { + if auth.Retryable(msg.err) { + m.loginInput.SetValue("") + } else { + // The challenge is over, and the link that opened it was + // spent opening it: back to the address, filled in, so + // enter asks for a new one. + m.loginChallenge = nil + m.wizard = wizardEmail + m.loginStage = loginAskEmail + m.loginInput.SetValue(m.loginEmail) + m.loginInput.Placeholder = "you@example.com" + m.loginInput.Prompt = "Email: " + m.loginInput.EchoMode = textinput.EchoNormal + } + } return m, m.loginInput.Focus() } // Reload the session so the token is the one just written, and drop @@ -444,6 +495,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.client = api.New(m.sess.Token) wasGuided := m.wizard != wizardOff m.cancelLogin() + m.loginNotice = msg.notice if wasGuided { m.wizard = wizardKey } @@ -507,6 +559,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loginMsg = "sending a link to " + value + "…" return m, sendLink(value) } + if m.loginStage == loginAskCode { + m.loginMsg = "checking the code…" + return m, answerChallenge(m.loginChallenge, value) + } m.loginMsg = "signing in…" return m, signIn(value) case "esc": @@ -771,6 +827,8 @@ func (m *model) startLogin() tea.Cmd { m.wizard = wizardEmail m.loginStage = loginAskEmail m.loginMsg = "" + m.loginNotice = "" + m.loginChallenge = nil // A machine set up with a platform token looks signed in to whoever set // it up. The dashboard cannot use one, and asking for an email with no // word about it reads as the token having been lost. @@ -832,6 +890,7 @@ func (m *model) resumeSetup() tea.Cmd { func (m *model) cancelLogin() { m.wizard = wizardOff m.loginStage = loginOff + m.loginChallenge = nil m.loginBusy = false m.loginInput.Blur() m.loginInput.SetValue("") @@ -846,24 +905,57 @@ func signIn(pasted string) tea.Cmd { return func() tea.Msg { token, err := auth.TokenFromLink(strings.TrimSpace(pasted)) if err != nil { - return signedInMsg{err} + return signedInMsg{err: err} } sessionToken, err := auth.ExchangeToken(token) + var challenge *auth.MFARequiredError + if errors.As(err, &challenge) { + return mfaChallengeMsg{challenge} + } if err != nil { - return signedInMsg{err} + return signedInMsg{err: err} } - current, err := session.Load() + return signedInMsg{err: saveSessionToken(sessionToken)} + } +} + +// answerChallenge posts the code the member typed against the challenge the +// link opened. +func answerChallenge(challenge *auth.MFARequiredError, code string) tea.Cmd { + return func() tea.Msg { + signIn, err := challenge.Complete(code) if err != nil { - current = &session.Session{} + return signedInMsg{err: err} } - current.Token = sessionToken - if err := session.Save(current); err != nil { - return signedInMsg{err} + if err := saveSessionToken(signIn.Session); err != nil { + return signedInMsg{err: err} } - return signedInMsg{nil} + if !signIn.UsedRecoveryCode { + return signedInMsg{} + } + return signedInMsg{notice: recoveryCodesNotice(signIn.RecoveryCodesLeft)} } } +// recoveryCodesNotice is what the panel says after a recovery code got the +// member in: each works once, and running out is the way to be locked out. +func recoveryCodesNotice(left int) string { + const where = "cloud.nan.builders, Settings > Account > Two-factor authentication" + if left == 0 { + return "that was your last recovery code — make new ones before you need one: " + where + } + return fmt.Sprintf("signed in with a recovery code, %d left — you can make new ones at %s", left, where) +} + +func saveSessionToken(token string) error { + current, err := session.Load() + if err != nil { + current = &session.Session{} + } + current.Token = token + return session.Save(current) +} + func (m model) fetchKeyStatus() tea.Cmd { client := m.client return func() tea.Msg { @@ -3380,6 +3472,9 @@ func (m model) renderWizard(l layout) string { b.WriteString("\n" + m.wrapped(l, m.loginMsg) + "\n") } case wizardKey: + if m.loginNotice != "" { + b.WriteString(m.wrapped(l, m.loginNotice) + "\n\n") + } if m.editingKey { b.WriteString(l.indent + m.keyInput.View() + "\n") } @@ -3661,7 +3756,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.34" +const Version = "0.1.35" func renderAbout(l layout) string { var b strings.Builder