Skip to content
Merged
66 changes: 62 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,27 @@ 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\|hermes` | `pi` | Agent to run. |
| `--model M` | the agent's own | Model for the agent. |
| `--cwd PATH` | `/home/nan` | Directory in the workspace. |
| `--worktree` / `--no-worktree` | auto | Run in a separate git worktree (auto: when `--cwd` is in a git repo). |
| `--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`. |
| `--detach` | | Queue the run, print its id and return. |
| `--json` | | Events as JSON lines, then the finished run object. With `--detach`, the run object. |
| `--idempotency-key K` | | Retrying with the same key and request returns the run already created. |
| `-f FILE` | | Read the prompt from a file (`-` for stdin). |
| `--token-file PATH` | | Authenticate with the token on the first line of `PATH`. See [Authentication](#authentication). |

**Git branch.** By default (auto) a run inside a git repository works on its
own branch, `nan-run/<first 8 chars of the run id>`, and commits there. It
never pushes, and your checkout is untouched. Outside a repository it works in
the folder itself. `--worktree` forces a branch (and fails outside a
repository); `--no-worktree` works in the folder even inside a repository.

**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.

**In the portal** the same settings have other names: Folder is `--cwd`,
Separate branch is `--worktree` / `--no-worktree`, Time limit is `--timeout`,
and Task is the prompt.

While it streams, **Ctrl-C** detaches and leaves the run going, and prints how
to pick it up again. A second Ctrl-C within 2 seconds cancels the run.
Expand Down Expand Up @@ -154,8 +168,52 @@ branch on it without parsing text:
| 69 | nan.builders unavailable, or the stream was lost after retries (the run goes on; the id is printed) |
| 75 | `--detach`, or Ctrl-C: the run was accepted and is still going |

The runs commands use your `nan auth login` session, or the API key from the
Setup tab when there is no session.
Full reference, including the event types `nan runs logs --json` prints (one
JSON object per line): <https://nan.builders/docs/runs>.

### Authentication

`nan run` and `nan runs` use the first of these they find:

1. **`NAN_TOKEN`**: an environment variable holding a platform token
(`nan_pat_...`) or an API key (`sk-...`). Read on every command, never
written to disk.
2. **`--token-file PATH`**: the token on the first line of a file. The file
must be private (`chmod 600`); one that other users can read or write is
refused.
3. **A saved token**, from `nan auth login --api-token`.
4. **Your session**, from `nan auth login` (the emailed sign-in link).
5. **A saved API key**, from the Setup tab (or an `sk-...` key saved with
`nan auth login --api-token`).

Create platform tokens in **Settings > Tokens** at
<https://cloud.nan.builders>. A token works for runs and for listing your
workspaces, and nothing else: it is not an inference key, and `nan me`,
`nan metrics` and the dashboard still need `nan auth login`.

**Headless: CI or a server.** Keep the token in your CI's secret store and
expose it as `NAN_TOKEN`. For example, in GitHub Actions:

```yaml
- name: Review the PR with an agent
env:
NAN_TOKEN: ${{ secrets.NAN_TOKEN }}
run: |
curl -fsSL https://nan.builders/install | bash
nan run --ws ci --timeout 20m -f .github/review-task.md
```

On a machine you set up once, save the token instead. It is read from stdin
(hidden when you paste it), checked against nan.builders, and stored in
`~/.config/nan/session.json` with mode `0600`:

```bash
nan auth login --api-token # paste it when asked
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`.

## Build from source

Expand Down
6 changes: 6 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ secrets file of its own: there the key goes into its `.env` and the config
carries a `${NAN_API_KEY}` reference, which is also why it never reaches a
command line.

The same holds for a platform token saved with `nan auth login --api-token`:
it goes into `session.json` (and nowhere else, since it is not an inference
key), read from stdin so it never reaches a command line. `NAN_TOKEN` is
never written anywhere, and a `--token-file` that other users can read or
write is refused rather than used.

**On Windows the mode bits do nothing.** The files inherit the ACL of the user
profile they sit in, which already excludes other non-administrator accounts,
and an administrator can take ownership regardless. If you keep your home
Expand Down
120 changes: 117 additions & 3 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package cmd

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"

"github.com/charmbracelet/x/term"
"github.com/nxssie/nan-cli/internal/api"
"github.com/nxssie/nan-cli/internal/auth"
"github.com/nxssie/nan-cli/internal/session"
"github.com/nxssie/nan-cli/internal/tui"
Expand All @@ -18,6 +22,7 @@ var (
emailFlag string
linkFlag string
keepToolsFlag bool
apiTokenFlag bool
)

var authCmd = &cobra.Command{
Expand All @@ -27,13 +32,26 @@ var authCmd = &cobra.Command{

var loginCmd = &cobra.Command{
Use: "login",
Short: "Log in with a sign-in link sent to your email",
RunE: runLogin,
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 --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
never ends up in your shell history or in ps. It is checked against
nan.builders and saved in ~/.config/nan/session.json, readable only by you.
Tokens work for nan run and nan runs; the other commands and the dashboard
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`,
RunE: runLogin,
}

var logoutCmd = &cobra.Command{
Use: "logout",
Short: "Log out and delete local session",
Short: "Log out: delete the local session, saved token and API key",
RunE: runLogout,
}

Expand All @@ -44,6 +62,7 @@ func init() {
loginCmd.Flags().StringVar(&emailFlag, "email", "", "Email to send the sign-in link to")
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")
logoutCmd.Flags().BoolVar(&keepToolsFlag, "keep-tools", false, "Leave the API key in the tools this CLI configured")
}

Expand All @@ -52,6 +71,12 @@ func init() {
// answered 404 since that flow was retired: the browser landed on an error page
// 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")
}
return loginWithAPIToken(cmd.Context(), newTokenInput(os.Stdin), os.Stdout, os.Stderr)
}
if tokenFlag != "" {
token, err := flagValue(tokenFlag)
if err != nil {
Expand Down Expand Up @@ -212,3 +237,92 @@ func saveToken(token string) error {
fmt.Println("Logged in successfully.")
return nil
}

// tokenInput is where `nan auth login --api-token` reads the token from. On
// a terminal it is read without echo; anywhere else, the first line of stdin.
type tokenInput struct {
r io.Reader
tty bool
readHidden func() ([]byte, error)
}

// newTokenInput reads from f, hidden when f is a terminal. Asking the
// terminal itself, not the file mode: /dev/null is a character device too,
// and under cron or `docker run` without -i that is what stdin is.
func newTokenInput(f *os.File) tokenInput {
return tokenInput{
r: f,
tty: term.IsTerminal(f.Fd()),
readHidden: func() ([]byte, error) { return term.ReadPassword(f.Fd()) },
}
}

func (in tokenInput) read(prompt io.Writer) (string, error) {
if in.tty {
fmt.Fprint(prompt, "Paste the token (it is not shown): ")
raw, err := in.readHidden()
fmt.Fprintln(prompt)
if err != nil {
return "", fmt.Errorf("could not read the token: %w", err)
}
return strings.TrimSpace(string(raw)), nil
}
line, err := bufio.NewReader(io.LimitReader(in.r, maxTokenLen+2)).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", fmt.Errorf("could not read the token from stdin: %w", err)
}
return strings.TrimSpace(line), nil
}

// loginWithAPIToken saves a platform token (or an API key) for the run
// commands, after nan.builders has accepted it. Nothing is saved otherwise,
// and the token is never printed.
func loginWithAPIToken(ctx context.Context, in tokenInput, out, prompt io.Writer) error {
tok, err := in.read(prompt)
if err != nil {
return err
}
if tok == "" {
return fmt.Errorf("no token arrived on stdin: paste it when asked, or pipe it in: nan auth login --api-token < token.txt")
}
if !validTokenShape(tok) {
return fmt.Errorf("that is not a valid token, nothing was saved: %s", tokenShapeHint)
}

client := api.NewRunsClient("", tok).WithBaseURL(runsBaseURL)
if _, err := client.ListRuns(ctx, api.ListRunsParams{Limit: 1}); err != nil {
if errors.Is(err, api.ErrSessionExpired) {
return fmt.Errorf("nan.builders refused that token (revoked, expired or mistyped), nothing was saved: create one in Settings > Tokens at https://cloud.nan.builders")
}
return fmt.Errorf("could not check the token, nothing was saved: %v", apiExit(err, credential{kind: credStoredToken}).Err)
}

current, err := session.Load()
switch {
case errors.Is(err, session.ErrNotLoggedIn):
current = &session.Session{}
case err != nil:
return fmt.Errorf("could not read %s, nothing was saved: %w", session.Path(), err)
}
isKey := strings.HasPrefix(tok, "sk-")
replacedKey := isKey && current.APIKey != "" && current.APIKey != tok
if isKey {
current.APIKey = tok
} else {
current.PlatformToken = tok
}
if err := session.Save(current); err != nil {
return fmt.Errorf("could not save the token: %w", err)
}

fmt.Fprintf(out, "Token saved to %s. nan run and nan runs will use it on this machine.\n", session.Path())
if replacedKey {
fmt.Fprintln(out, "It replaces the API key saved before. Tools the Setup tab configured keep the old key until you apply Setup again.")
}
if isKey && current.Token != "" {
// The session goes first and nothing here knows when it expires, so
// saying "while it lasts" would promise a fallback that never happens.
fmt.Fprintln(out, "You are also signed in by email, and nan run uses that session first, even after it expires. To use only this key: nan auth logout, then save it again.")
}
return nil
}
Loading
Loading