From d65c19f12e0fcd9854dc00c62d471bc2fb1a03d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0tetiar?= Date: Thu, 8 Oct 2026 07:17:56 +0200 Subject: [PATCH 1/2] feat: add login --manual, a sign-in that takes the final address on stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prusactl login` asks for the Prusa Account password on the terminal. An AI agent cannot answer this prompt, and it must not receive the password. `login --manual` prints the Prusa Account authorize link on its own line. It opens a browser unless `--no-open` is given. Then it reads one line from stdin: the address that the browser shows after approval, or only its `code=` value. It compares the state in this address with the state in the link. It exchanges the code for tokens with the same PKCE flow as the password login. There is no loopback variant. Prusa Account rejects a `127.0.0.1` redirect for the Connect client ("Mismatching redirect URI"). The code must come from the address bar. If `--manual` is not given and there is no terminal, `login` now tells the user to use `--manual`. Before, it failed at the password prompt. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Petr Štetiar --- CHANGELOG.md | 10 ++++++ README.md | 12 ++++++- cmd/prusactl/help.go | 20 +++++++++--- cmd/prusactl/main.go | 57 ++++++++++++++++++++++++++++++--- internal/auth/manual.go | 42 +++++++++++++++++++++++++ internal/auth/manual_test.go | 61 ++++++++++++++++++++++++++++++++++++ internal/auth/session.go | 12 ++++++- 7 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 internal/auth/manual.go create mode 100644 internal/auth/manual_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 936c72a..8dd524c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- `prusactl login --manual` signs in without a password passing through the + terminal, so an AI agent can run it. It prints the Prusa Account link on its + own line, then reads the address the browser ends on + (`https://connect.prusa3d.com/login/auth-callback?code=...`) from stdin, so an + agent can keep stdin open, read the address from a browser tab, and write it + back. `--no-open` stops it starting a browser. A loopback mode doesn't exist + because Prusa Account rejects a localhost redirect for the Connect client + ("Mismatching redirect URI"). +- `prusactl login` without `--manual` says so when it has no terminal to ask for + a password on, rather than failing on the prompt. - `prusactl status` and the printer summary show how long a running print has left, how long it has run, and when it should finish on the local clock, for example `PRINTING 89%, 23m left (ends ~12:41), 2h10m elapsed, nozzle 250/250°C, diff --git a/README.md b/README.md index c88be06..eb5df16 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,16 @@ code if your account has one. It fills in Prusa's own login page over HTTPS the same way a browser would. It then trades the resulting code for tokens, using the OAuth + PKCE flow of the Connect web app. +**Letting an AI agent sign in for you.** `prusactl login --manual` never sees +a password. It prints a Prusa Account link on a line of its own (and opens it +in your browser unless you pass `--no-open`), then reads one line from stdin: +the address the browser ends on after you approve, which looks like +`https://connect.prusa3d.com/login/auth-callback?code=...&state=...` (the bare +`code=` value also works). An agent can drive it. +There is no loopback mode: Prusa Account rejects a +`127.0.0.1` redirect for the Connect client ("Mismatching redirect URI"), so +the code can only be read from the address bar. + - **Your password** is sent only to account.prusa3d.com and is never stored. - **Where tokens are kept:** the OS keychain (macOS Keychain, Secret Service, or Windows Credential Manager), under the service `prusactl`. A machine without @@ -223,7 +233,7 @@ printer you can leave it out. `via: "direct"` or `via: "connect"` forces a route ```text prusactl setup [ADDRESS] connect directly to the printer on your network -prusactl login optional: sign in to Prusa Connect +prusactl login [--manual] optional: sign in to Prusa Connect prusactl logout forget the Prusa Connect session prusactl status printer state and how it is reachable prusactl mcp run the MCP server on stdio diff --git a/cmd/prusactl/help.go b/cmd/prusactl/help.go index 210a7f3..ee20809 100644 --- a/cmd/prusactl/help.go +++ b/cmd/prusactl/help.go @@ -62,10 +62,22 @@ Settings > Network > PrusaLink.`, { Name: "login", Summary: "Sign in to Prusa Connect (optional)", - Help: `Asks for your Prusa Account email, password, and two-factor code in the -terminal. The password goes only to account.prusa3d.com and is never saved; -the session is saved and renews itself. Prusa Connect adds remote access, the -camera, on-screen dialogs, the print queue, and history.`, + Args: "[flags]", + Help: `Signs in to Prusa Connect, which adds remote access, the camera, on-screen +dialogs, the print queue, and history. The session is saved and renews itself. + +By default it asks for your Prusa Account email, password, and two-factor code +in the terminal. The password goes only to account.prusa3d.com and is never +saved. + +--manual avoids the password: it prints a Prusa Account link on its own line of +stdout, you approve it in any browser, and you give it the address the browser +ends on (https://connect.prusa3d.com/login/auth-callback?code=...&state=...) as +one line on stdin. The address may also be just its code= value.`, + Flags: []flagSpec{ + {Name: "manual", Usage: "approve in a browser and give the final address on stdin"}, + {Name: "no-open", Usage: "only print the link; don't start a browser (with --manual)"}, + }, }, { Name: "logout", diff --git a/cmd/prusactl/main.go b/cmd/prusactl/main.go index dc76265..5061acc 100644 --- a/cmd/prusactl/main.go +++ b/cmd/prusactl/main.go @@ -16,7 +16,9 @@ import ( "net/http" "net/url" "os" + "os/exec" "os/signal" + "runtime" "runtime/debug" "strings" "time" @@ -115,7 +117,14 @@ func run(ctx context.Context, args []string) error { lc, lcErr := link.Open() switch name { case "login": - return noArguments(cmd, rest, func() error { return login(ctx, session, cc, lc, lcErr) }) + opts, pos, err := cmd.parse(rest) + if err != nil { + return err + } + if len(pos) > 0 { + return fmt.Errorf("login: takes no arguments, got %q (see `prusactl help login`)", pos[0]) + } + return login(ctx, opts, session, cc, lc, lcErr) case "status": opts, pos, err := cmd.parse(rest) if err != nil { @@ -305,9 +314,30 @@ func (terminalPrompter) OneTimeCode() (string, error) { return ask("Two-factor code from your authenticator app: ") } -func login(ctx context.Context, session *auth.Session, cc *connect.Client, lc *link.Client, lcErr error) error { - fmt.Fprintln(os.Stderr, "Signing in to Prusa Connect with your Prusa Account.") - if _, err := session.Login(ctx, terminalPrompter{host: accountHost(session)}); err != nil { +func login(ctx context.Context, o options, session *auth.Session, cc *connect.Client, lc *link.Client, lcErr error) error { + if o.given("no-open") && !o.on("manual") { + return errors.New("login: --no-open only applies to --manual") + } + var err error + if o.on("manual") { + // The link goes to stdout on a line of its own, so a script or an + // agent can pick it out; everything around it goes to stderr. + _, err = session.LoginManual(ctx, func(u string) { + fmt.Fprintln(os.Stderr, "Open this link in a browser and approve the sign-in to Prusa Connect:") + fmt.Println(u) + if !o.on("no-open") && openBrowser(u) == nil { + fmt.Fprintln(os.Stderr, "(Opened it in your browser.)") + } + fmt.Fprintln(os.Stderr, "Then give the address the browser ended on (https://connect.prusa3d.com/login/auth-callback?code=...) on one line of stdin.") + }, func() (string, error) { return ask("> ") }) + } else { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return errors.New("login: asking for a password needs an interactive terminal; use `prusactl login --manual` and write the final browser address to stdin (see `prusactl help login`)") + } + fmt.Fprintln(os.Stderr, "Signing in to Prusa Connect with your Prusa Account.") + _, err = session.Login(ctx, terminalPrompter{host: accountHost(session)}) + } + if err != nil { return err } if err := server.RegisterUser(ctx, cc); err != nil { @@ -317,6 +347,25 @@ func login(ctx context.Context, session *auth.Session, cc *connect.Client, lc *l return status(ctx, server.New(session, cc, lc, lcErr, buildVersion()), lc) } +// openBrowser asks the desktop to show u. It reports whether it could start a +// browser, not whether one appeared. +func openBrowser(u string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", u) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u) + default: + cmd = exec.Command("xdg-open", u) + } + if err := cmd.Start(); err != nil { + return err + } + go cmd.Wait() + return nil +} + func status(ctx context.Context, srv *server.Server, lc *link.Client) error { st := srv.Status(ctx) direct, _ := st["direct"].(map[string]any) diff --git a/internal/auth/manual.go b/internal/auth/manual.go new file mode 100644 index 0000000..c520f6c --- /dev/null +++ b/internal/auth/manual.go @@ -0,0 +1,42 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// ManualLogin signs in without a password passing through this process: +// announce gets the Prusa Account authorize URL, the user (or an agent driving +// a browser) approves it anywhere, and read returns what comes back, either the +// full URL the browser ended on or just its code. Prusa Account only accepts +// the Connect web app's own redirect for this client (it rejects a loopback +// one as "Mismatching redirect URI"), so the browser ends on +// https://connect.prusa3d.com/login/auth-callback with the code in the address +// bar, and nothing can listen for it. +func (c Config) ManualLogin(ctx context.Context, announce func(authURL string), read func() (string, error)) (*Token, error) { + p, err := newPKCE() + if err != nil { + return nil, err + } + announce(c.authorizeURL(p)) + pasted, err := read() + if err != nil { + return nil, fmt.Errorf("reading the pasted sign-in URL: %w", err) + } + pasted = strings.TrimSpace(pasted) + if pasted == "" { + return nil, errors.New("nothing was pasted") + } + code := pasted + if strings.ContainsAny(pasted, "?&=") { + if !strings.Contains(pasted, "://") { + pasted = "?" + strings.TrimPrefix(pasted, "?") + } + if code, err = c.codeFromCallback(pasted, p); err != nil { + return nil, err + } + } + return c.exchangeCode(ctx, code, p) +} diff --git a/internal/auth/manual_test.go b/internal/auth/manual_test.go new file mode 100644 index 0000000..3784b21 --- /dev/null +++ b/internal/auth/manual_test.go @@ -0,0 +1,61 @@ +package auth + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// fakeTokenServer answers /o/token/ and records the form it was sent. +func fakeTokenServer(t *testing.T, form *url.Values) Config { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/o/token/" { + http.NotFound(w, r) + return + } + r.ParseForm() + *form = r.PostForm + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"access_token":"acc","refresh_token":"ref","expires_in":3600}`) + })) + t.Cleanup(srv.Close) + return Config{AccountURL: srv.URL, ClientID: "cid", RedirectURI: "https://app.example/cb", HTTP: srv.Client()} +} + +func TestManualLoginAcceptsAURLOrACode(t *testing.T) { + for name, paste := range map[string]func(state string) string{ + "url": func(s string) string { return "https://app.example/cb?code=pasted&state=" + s }, + "query": func(s string) string { return "code=pasted&state=" + s }, + "code": func(string) string { return "pasted" }, + } { + t.Run(name, func(t *testing.T) { + var form url.Values + c := fakeTokenServer(t, &form) + var state string + tok, err := c.ManualLogin(context.Background(), + func(u string) { au, _ := url.Parse(u); state = au.Query().Get("state") }, + func() (string, error) { return " " + paste(state) + "\n", nil }) + if err != nil || tok.AccessToken != "acc" { + t.Fatalf("got %+v, %v", tok, err) + } + if form.Get("code") != "pasted" || form.Get("redirect_uri") != "https://app.example/cb" { + t.Fatalf("token request %v", form) + } + }) + } +} + +func TestManualLoginRejectsBadPastes(t *testing.T) { + for _, paste := range []string{"", "https://app.example/cb?code=x&state=forged", "https://app.example/cb?state=s"} { + var form url.Values + c := fakeTokenServer(t, &form) + _, err := c.ManualLogin(context.Background(), func(string) {}, func() (string, error) { return paste, nil }) + if err == nil || form != nil { + t.Errorf("paste %q: err %v, redeemed %v", paste, err, form) + } + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go index b032ccf..8835f8e 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -127,7 +127,17 @@ func (s *Session) Invalidate(access string) { // Login signs in with the user's Prusa Account credentials (see // PasswordLogin) and stores the resulting tokens. func (s *Session) Login(ctx context.Context, prompt Prompter) (*Token, error) { - tok, err := s.Config.PasswordLogin(ctx, prompt) + return s.save(s.Config.PasswordLogin(ctx, prompt)) +} + +// LoginManual signs in with a pasted redirect URL (see Config.ManualLogin) and +// stores the resulting tokens. +func (s *Session) LoginManual(ctx context.Context, announce func(authURL string), read func() (string, error)) (*Token, error) { + return s.save(s.Config.ManualLogin(ctx, announce, read)) +} + +// save stores the tokens a login produced. +func (s *Session) save(tok *Token, err error) (*Token, error) { if err != nil { return nil, err } From ffcb25f157f8c58ed9f76c871b2b5ffebd378041 Mon Sep 17 00:00:00 2001 From: Trevin Lee <134039857+trevin-lee@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:55:24 -0700 Subject: [PATCH 2/2] docs: say what login --manual relies on, and speak to the person at the prompt The README said "no browser involved", which stopped being true the moment --manual opened one. No browser is needed; the password login still works without one. --manual depends on something the README didn't say: Connect's own auth-callback page leaves a code alone when it carries a state the page didn't issue. That is how the page behaves today and nothing promises it will tomorrow, so the note says what breaks if it changes. The prompt said "on one line of stdin", which is the right description for an agent and jargon for a person. It says "paste it here and press Enter" now; the help text keeps the precise version. Co-Authored-By: Claude Fable 5.1 --- README.md | 7 +++++-- cmd/prusactl/main.go | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index eb5df16..7319031 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ runout or errors. Needs Connect. - **Run G-code:** any G-code, over the direct connection, while the printer is idle. -It is one Go binary with no browser involved. Setup is two terminal prompts. +It is one Go binary; no browser is needed. Setup is two terminal prompts. ## Things you can ask @@ -188,7 +188,10 @@ the address the browser ends on after you approve, which looks like `code=` value also works). An agent can drive it. There is no loopback mode: Prusa Account rejects a `127.0.0.1` redirect for the Connect client ("Mismatching redirect URI"), so -the code can only be read from the address bar. +the code can only be read from the address bar. It also relies on Connect's +`auth-callback` page leaving a code alone when its `state` isn't one the page +issued, which is how the page behaves today. If Prusa changes that, the sign-in +fails at the token step, and the fix is an update to prusactl. - **Your password** is sent only to account.prusa3d.com and is never stored. - **Where tokens are kept:** the OS keychain (macOS Keychain, Secret Service, or diff --git a/cmd/prusactl/main.go b/cmd/prusactl/main.go index 5061acc..b8c5f5c 100644 --- a/cmd/prusactl/main.go +++ b/cmd/prusactl/main.go @@ -328,7 +328,7 @@ func login(ctx context.Context, o options, session *auth.Session, cc *connect.Cl if !o.on("no-open") && openBrowser(u) == nil { fmt.Fprintln(os.Stderr, "(Opened it in your browser.)") } - fmt.Fprintln(os.Stderr, "Then give the address the browser ended on (https://connect.prusa3d.com/login/auth-callback?code=...) on one line of stdin.") + fmt.Fprintln(os.Stderr, "Then paste the address the browser ended on (https://connect.prusa3d.com/login/auth-callback?code=...) here and press Enter.") }, func() (string, error) { return ask("> ") }) } else { if !term.IsTerminal(int(os.Stdin.Fd())) {