Skip to content

Commit 589feca

Browse files
sre-helmcodebarckcodeclaude
authored
feat: platform-token auth for runs, run help text, nan --version fix (0.1.25) (#42)
* chore: 0.1.25 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(runs): authenticate with NAN_TOKEN or --token-file Precedence: NAN_TOKEN > --token-file > session > saved platform token > saved API key. A token file readable or writable by others is refused. Refused credentials are named, never shown. nan me and nan metrics usage explain that they need a session when only a token is configured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(auth): nan auth login --api-token Reads a platform token or API key from stdin (hidden on a terminal), checks it with GET /v1/runs?limit=1 and saves it 0600. Platform tokens go to platformToken, never apiKey, which the Setup tab writes into tools. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: nan --version prints one line; the dashboard explains platform tokens nan --version and nan -v print "nan <version>" and exit 0 instead of the full-screen wordmark. The sign-in step of the dashboard says that a platform token only works for nan run and nan runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(help): branch behaviour, queue, portal labels and auth in nan run --help nan runs --help and nan runs logs --help link the docs; logs lists the event types and the --json line format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: authentication for runs, headless use with NAN_TOKEN Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(auth): review feedback on token login and precedence - A saved platform token now goes before the session: a session expires on the server unseen, and ranked after it the token was never reached. - --api-token asks the terminal itself whether stdin is one, so </dev/null (cron, docker run without -i) says no token arrived. - The login output says what nan run will actually use, and warns when an sk- key replaces the one the Setup tab saved. - --token-file paths are sanitised in errors; a missing file says so. - NAN_TOKEN is one constant (session.TokenEnvVar); usingKey() removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(runs): sanitised --token-file paths on every error, with tests Stat and read errors drop the raw path from *os.PathError too. Help and README say an sk- key saved with --api-token counts as a saved API key, and a refused saved token says the logout way back to the session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(runs): cover every --token-file error branch for sanitised paths Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: barckcode <cristian@helmcode.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 45afdb7 commit 589feca

21 files changed

Lines changed: 1389 additions & 101 deletions

‎README.md‎

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,27 @@ id=$(nan run --detach "upgrade the dependencies")
105105
| `--ws NAME` | your only workspace | Workspace to run in. Required when you have more than one. |
106106
| `--agent pi\|hermes` | `pi` | Agent to run. |
107107
| `--model M` | the agent's own | Model for the agent. |
108-
| `--cwd PATH` | `/home/nan` | Directory in the workspace. |
109-
| `--worktree` / `--no-worktree` | auto | Run in a separate git worktree (auto: when `--cwd` is in a git repo). |
108+
| `--cwd PATH` | `/home/nan` | Folder in the workspace. |
109+
| `--worktree` / `--no-worktree` | auto | Work on a separate branch, or in the folder itself. See below. |
110110
| `--timeout D` | `30m` | Stop the run after this long, `1m` to `2h`. |
111111
| `--detach` | | Queue the run, print its id and return. |
112112
| `--json` | | Events as JSON lines, then the finished run object. With `--detach`, the run object. |
113113
| `--idempotency-key K` | | Retrying with the same key and request returns the run already created. |
114114
| `-f FILE` | | Read the prompt from a file (`-` for stdin). |
115+
| `--token-file PATH` | | Authenticate with the token on the first line of `PATH`. See [Authentication](#authentication). |
116+
117+
**Git branch.** By default (auto) a run inside a git repository works on its
118+
own branch, `nan-run/<first 8 chars of the run id>`, and commits there. It
119+
never pushes, and your checkout is untouched. Outside a repository it works in
120+
the folder itself. `--worktree` forces a branch (and fails outside a
121+
repository); `--no-worktree` works in the folder even inside a repository.
122+
123+
**Queue.** If the workspace is already running as many runs as its size
124+
allows, the run waits as `queued` and starts when one of them ends.
125+
126+
**In the portal** the same settings have other names: Folder is `--cwd`,
127+
Separate branch is `--worktree` / `--no-worktree`, Time limit is `--timeout`,
128+
and Task is the prompt.
115129

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

157-
The runs commands use your `nan auth login` session, or the API key from the
158-
Setup tab when there is no session.
171+
Full reference, including the event types `nan runs logs --json` prints (one
172+
JSON object per line): <https://nan.builders/docs/runs>.
173+
174+
### Authentication
175+
176+
`nan run` and `nan runs` use the first of these they find:
177+
178+
1. **`NAN_TOKEN`**: an environment variable holding a platform token
179+
(`nan_pat_...`) or an API key (`sk-...`). Read on every command, never
180+
written to disk.
181+
2. **`--token-file PATH`**: the token on the first line of a file. The file
182+
must be private (`chmod 600`); one that other users can read or write is
183+
refused.
184+
3. **A saved token**, from `nan auth login --api-token`.
185+
4. **Your session**, from `nan auth login` (the emailed sign-in link).
186+
5. **A saved API key**, from the Setup tab (or an `sk-...` key saved with
187+
`nan auth login --api-token`).
188+
189+
Create platform tokens in **Settings > Tokens** at
190+
<https://cloud.nan.builders>. A token works for runs and for listing your
191+
workspaces, and nothing else: it is not an inference key, and `nan me`,
192+
`nan metrics` and the dashboard still need `nan auth login`.
193+
194+
**Headless: CI or a server.** Keep the token in your CI's secret store and
195+
expose it as `NAN_TOKEN`. For example, in GitHub Actions:
196+
197+
```yaml
198+
- name: Review the PR with an agent
199+
env:
200+
NAN_TOKEN: ${{ secrets.NAN_TOKEN }}
201+
run: |
202+
curl -fsSL https://nan.builders/install | bash
203+
nan run --ws ci --timeout 20m -f .github/review-task.md
204+
```
205+
206+
On a machine you set up once, save the token instead. It is read from stdin
207+
(hidden when you paste it), checked against nan.builders, and stored in
208+
`~/.config/nan/session.json` with mode `0600`:
209+
210+
```bash
211+
nan auth login --api-token # paste it when asked
212+
nan auth login --api-token < token.txt # or pipe it in
213+
```
214+
215+
Never put a token on the command line itself: it would end up in your shell
216+
history and in `ps`.
159217

160218
## Build from source
161219

‎SECURITY.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ secrets file of its own: there the key goes into its `.env` and the config
5151
carries a `${NAN_API_KEY}` reference, which is also why it never reaches a
5252
command line.
5353

54+
The same holds for a platform token saved with `nan auth login --api-token`:
55+
it goes into `session.json` (and nowhere else, since it is not an inference
56+
key), read from stdin so it never reaches a command line. `NAN_TOKEN` is
57+
never written anywhere, and a `--token-file` that other users can read or
58+
write is refused rather than used.
59+
5460
**On Windows the mode bits do nothing.** The files inherit the ACL of the user
5561
profile they sit in, which already excludes other non-administrator accounts,
5662
and an administrator can take ownership regardless. If you keep your home

‎cmd/auth.go‎

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ package cmd
22

33
import (
44
"bufio"
5+
"context"
6+
"errors"
57
"fmt"
68
"io"
79
"os"
810
"strings"
911

12+
"github.com/charmbracelet/x/term"
13+
"github.com/nxssie/nan-cli/internal/api"
1014
"github.com/nxssie/nan-cli/internal/auth"
1115
"github.com/nxssie/nan-cli/internal/session"
1216
"github.com/nxssie/nan-cli/internal/tui"
@@ -18,6 +22,7 @@ var (
1822
emailFlag string
1923
linkFlag string
2024
keepToolsFlag bool
25+
apiTokenFlag bool
2126
)
2227

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

2833
var loginCmd = &cobra.Command{
2934
Use: "login",
30-
Short: "Log in with a sign-in link sent to your email",
31-
RunE: runLogin,
35+
Short: "Log in by email, or save a platform token with --api-token",
36+
Long: `Log in with a sign-in link sent to your email.
37+
38+
With --api-token, save a platform token instead, for a machine with nobody
39+
at the keyboard (a server, a CI runner). Create the token in Settings >
40+
Tokens at https://cloud.nan.builders. It is read from stdin only, so it
41+
never ends up in your shell history or in ps. It is checked against
42+
nan.builders and saved in ~/.config/nan/session.json, readable only by you.
43+
Tokens work for nan run and nan runs; the other commands and the dashboard
44+
need the email sign-in. An API key (sk-...) can be saved the same way; it
45+
replaces the one the Setup tab saved.`,
46+
Example: ` nan auth login --email you@example.com
47+
nan auth login --api-token # paste the token; it is not shown
48+
nan auth login --api-token < token.txt`,
49+
RunE: runLogin,
3250
}
3351

3452
var logoutCmd = &cobra.Command{
3553
Use: "logout",
36-
Short: "Log out and delete local session",
54+
Short: "Log out: delete the local session, saved token and API key",
3755
RunE: runLogout,
3856
}
3957

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

@@ -52,6 +71,12 @@ func init() {
5271
// answered 404 since that flow was retired: the browser landed on an error page
5372
// and the command then asked for a cookie that no longer existed.
5473
func runLogin(cmd *cobra.Command, args []string) error {
74+
if apiTokenFlag {
75+
if tokenFlag != "" || linkFlag != "" || emailFlag != "" {
76+
return fmt.Errorf("--api-token cannot be combined with --email, --link or --token")
77+
}
78+
return loginWithAPIToken(cmd.Context(), newTokenInput(os.Stdin), os.Stdout, os.Stderr)
79+
}
5580
if tokenFlag != "" {
5681
token, err := flagValue(tokenFlag)
5782
if err != nil {
@@ -212,3 +237,92 @@ func saveToken(token string) error {
212237
fmt.Println("Logged in successfully.")
213238
return nil
214239
}
240+
241+
// tokenInput is where `nan auth login --api-token` reads the token from. On
242+
// a terminal it is read without echo; anywhere else, the first line of stdin.
243+
type tokenInput struct {
244+
r io.Reader
245+
tty bool
246+
readHidden func() ([]byte, error)
247+
}
248+
249+
// newTokenInput reads from f, hidden when f is a terminal. Asking the
250+
// terminal itself, not the file mode: /dev/null is a character device too,
251+
// and under cron or `docker run` without -i that is what stdin is.
252+
func newTokenInput(f *os.File) tokenInput {
253+
return tokenInput{
254+
r: f,
255+
tty: term.IsTerminal(f.Fd()),
256+
readHidden: func() ([]byte, error) { return term.ReadPassword(f.Fd()) },
257+
}
258+
}
259+
260+
func (in tokenInput) read(prompt io.Writer) (string, error) {
261+
if in.tty {
262+
fmt.Fprint(prompt, "Paste the token (it is not shown): ")
263+
raw, err := in.readHidden()
264+
fmt.Fprintln(prompt)
265+
if err != nil {
266+
return "", fmt.Errorf("could not read the token: %w", err)
267+
}
268+
return strings.TrimSpace(string(raw)), nil
269+
}
270+
line, err := bufio.NewReader(io.LimitReader(in.r, maxTokenLen+2)).ReadString('\n')
271+
if err != nil && !errors.Is(err, io.EOF) {
272+
return "", fmt.Errorf("could not read the token from stdin: %w", err)
273+
}
274+
return strings.TrimSpace(line), nil
275+
}
276+
277+
// loginWithAPIToken saves a platform token (or an API key) for the run
278+
// commands, after nan.builders has accepted it. Nothing is saved otherwise,
279+
// and the token is never printed.
280+
func loginWithAPIToken(ctx context.Context, in tokenInput, out, prompt io.Writer) error {
281+
tok, err := in.read(prompt)
282+
if err != nil {
283+
return err
284+
}
285+
if tok == "" {
286+
return fmt.Errorf("no token arrived on stdin: paste it when asked, or pipe it in: nan auth login --api-token < token.txt")
287+
}
288+
if !validTokenShape(tok) {
289+
return fmt.Errorf("that is not a valid token, nothing was saved: %s", tokenShapeHint)
290+
}
291+
292+
client := api.NewRunsClient("", tok).WithBaseURL(runsBaseURL)
293+
if _, err := client.ListRuns(ctx, api.ListRunsParams{Limit: 1}); err != nil {
294+
if errors.Is(err, api.ErrSessionExpired) {
295+
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")
296+
}
297+
return fmt.Errorf("could not check the token, nothing was saved: %v", apiExit(err, credential{kind: credStoredToken}).Err)
298+
}
299+
300+
current, err := session.Load()
301+
switch {
302+
case errors.Is(err, session.ErrNotLoggedIn):
303+
current = &session.Session{}
304+
case err != nil:
305+
return fmt.Errorf("could not read %s, nothing was saved: %w", session.Path(), err)
306+
}
307+
isKey := strings.HasPrefix(tok, "sk-")
308+
replacedKey := isKey && current.APIKey != "" && current.APIKey != tok
309+
if isKey {
310+
current.APIKey = tok
311+
} else {
312+
current.PlatformToken = tok
313+
}
314+
if err := session.Save(current); err != nil {
315+
return fmt.Errorf("could not save the token: %w", err)
316+
}
317+
318+
fmt.Fprintf(out, "Token saved to %s. nan run and nan runs will use it on this machine.\n", session.Path())
319+
if replacedKey {
320+
fmt.Fprintln(out, "It replaces the API key saved before. Tools the Setup tab configured keep the old key until you apply Setup again.")
321+
}
322+
if isKey && current.Token != "" {
323+
// The session goes first and nothing here knows when it expires, so
324+
// saying "while it lasts" would promise a fallback that never happens.
325+
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.")
326+
}
327+
return nil
328+
}

0 commit comments

Comments
 (0)