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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/waired/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ func runAuthStatusBody(stateDirVal string) error {
id, err := identity.Load(dir)
if err != nil {
if errors.Is(err, fs.ErrPermission) {
if notice, ok := unreadableSystemStateNotice(dir, "waired auth status"); ok {
fmt.Println(notice)
return nil
}
return fmt.Errorf("permission denied reading state in %s — %s",
dir, elevationHint("waired auth status"))
}
Expand Down
35 changes: 24 additions & 11 deletions cmd/waired/init_defaults_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,34 @@ func TestSkipClaudeRouteFlagDefaultsFromEnv(t *testing.T) {
}
}

// TestInitStateDirMode covers all three OSes against the fact each one
// actually has. The windows/elevated row INVERTS what this table pinned
// before #313: `os.Geteuid()` is -1 on Windows, so the euid guard was a
// no-op there and even an elevated `waired init` resolved the per-user
// %AppData% dir — never the %ProgramData% one the daemon reads. The old
// comment deferred to "System via the SCM probe", but that probe only
// fires for paths.AutoDetect and this decision passes Interactive.
func TestInitStateDirMode(t *testing.T) {
cases := []struct {
goos string
euid int
want paths.Mode
name string
goos string
euid int
elevated bool
want paths.Mode
}{
{"linux", 0, paths.System}, // sudo waired init -> /var/lib/waired (daemon's dir)
{"linux", 1000, paths.Interactive}, // non-root dev -> per-user
{"darwin", 0, paths.System}, // sudo waired init -> /Library (system LaunchDaemon's dir, #520)
{"darwin", 501, paths.Interactive}, // non-root dev / tray -> ~/Library
{"windows", -1, paths.Interactive}, // Geteuid()==-1 on Windows (System via SCM probe)
{"linux root", "linux", 0, true, paths.System}, // sudo waired init -> /var/lib/waired (daemon's dir)
{"linux user", "linux", 1000, false, paths.Interactive}, // non-root dev -> per-user
{"darwin root", "darwin", 0, true, paths.System}, // sudo waired init -> /Library (system LaunchDaemon's dir, #520)
{"darwin user", "darwin", 501, false, paths.Interactive}, // non-root dev / tray -> ~/Library
{"windows admin", "windows", -1, true, paths.System}, // #313: the daemon's %ProgramData%\waired
{"windows user", "windows", -1, false, paths.Interactive}, // standard user -> %AppData%
{"linux root not elevated", "linux", 0, false, paths.System}, // euid decides on Unix; elevated is the Windows fact
}
for _, c := range cases {
if got := initStateDirMode(c.goos, c.euid); got != c.want {
t.Errorf("initStateDirMode(%q, %d) = %v, want %v", c.goos, c.euid, got, c.want)
}
t.Run(c.name, func(t *testing.T) {
if got := initStateDirMode(c.goos, c.euid, c.elevated); got != c.want {
t.Errorf("initStateDirMode(%q, %d, %v) = %v, want %v", c.goos, c.euid, c.elevated, got, c.want)
}
})
}
}
116 changes: 116 additions & 0 deletions cmd/waired/init_resume.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package main

import (
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/waired-ai/waired-agent/internal/identity"
"github.com/waired-ai/waired-agent/internal/management"
)

// `waired init` on a device that is already signed in used to be an
// error — and on Windows it was the ONLY outcome, because the CLI
// resolved a state dir the daemon does not use, found no identity there,
// asked for a plain login, and reported the daemon's idempotent no-op
// ("active, no session id") as a protocol failure (#313). NAVI hands
// operators that exact command to resume a stuck setup, so setup was
// unresumable on Windows by any documented means.
//
// The model is `tailscale up`: the command is idempotent. An auth key is
// not spent while the existing credentials are valid (tailscale#19501 —
// "if valid state exists, reuse it"), re-authenticating is an explicit
// --force-reauth, and — unlike tailscale#7995, where the key is dropped
// in silence — an unused key is said out loud.

// daemonIdentityTimeout bounds the enrollment probe. It runs before the
// route is chosen, so an absent daemon must cost a connection refusal,
// not a wait.
const daemonIdentityTimeout = 3 * time.Second

// daemonIdentity asks the daemon what it is enrolled as. A nil answer
// means "no answer" — no daemon, a daemon too old to serve the route, or
// a malformed reply — and every caller must read that as "unknown",
// never as "not enrolled": the whole point is that the CLI's own view of
// the disk is the thing under suspicion.
//
// A package var so tests can answer without a daemon.
var daemonIdentity = func(mgmtURL string) *management.IdentityView {
cl := &http.Client{Timeout: daemonIdentityTimeout}
resp, err := cl.Get(mgmtURL + "/waired/v1/identity")
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
var v management.IdentityView
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
return nil
}
return &v
}

// identityFromView renders a daemon-reported enrollment as the local
// record the renew summary prints. Returns nil unless the daemon says it
// is enrolled, so callers can use it exactly where identity.Load's own
// nil means "nothing here".
//
// It is deliberately NOT written to disk: the daemon owns that file, and
// this is a read of the daemon's answer, not a copy of its state.
func identityFromView(v *management.IdentityView) *identity.Identity {
if v == nil || !v.Enrolled {
return nil
}
return &identity.Identity{
DeviceID: v.DeviceID,
DeviceName: v.DeviceName,
NetworkName: v.NetworkName,
AccountEmail: v.AccountEmail,
ControlURL: v.ControlURL,
}
}

// reauthWanted decides whether this run should re-authenticate rather
// than resume.
//
// Two ways to say yes, and no third: the operator asked
// (--force-reauth), or the credentials are what is broken — auto-refresh
// has given up and only a fresh sign-in can fix it, which is what makes
// `waired init` the documented recovery for a reauth_required device.
// Everything else resumes, because re-signing a device that is signed in
// rotates its tokens for nothing.
func reauthWanted(force bool, v *management.IdentityView) bool {
if force {
return true
}
return v != nil && v.Enrolled && v.AuthState == management.AuthStateReauthRequired
}

// resumeLines is what the terminal says when it finds the device already
// signed in. Kept as a pure function so the copy is testable and lives
// in one place.
func resumeLines(accountEmail string, authKeyGiven bool) []string {
head := "This device is already signed in — resuming setup."
if accountEmail != "" {
head = fmt.Sprintf("Already signed in as %s — resuming setup.", accountEmail)
}
lines := []string{head}
if authKeyGiven {
// tailscale#7995 is the counter-example: dropping the key in
// silence leaves an operator believing they switched something.
lines = append(lines, dim("The auth key was not used. Pass --force-reauth to sign in again with it."))
}
return lines
}

// accountEmailFromView is nil-safe sugar for the resume notice's one
// use of the daemon's answer.
func accountEmailFromView(v *management.IdentityView) string {
if v == nil {
return ""
}
return v.AccountEmail
}
123 changes: 123 additions & 0 deletions cmd/waired/init_resume_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package main

import (
"strings"
"testing"

"github.com/waired-ai/waired-agent/internal/management"
)

// TestReauthWanted is the #313 decision table, modelled on `tailscale
// up`: a run against a device whose credentials still work does not
// re-authenticate, and an auth key does not change that (tailscale#19501
// — if valid state exists, reuse it). Re-auth happens when the operator
// asks for it, or when the credentials are the thing that is broken.
func TestReauthWanted(t *testing.T) {
cases := []struct {
name string
force bool
view *management.IdentityView
want bool
}{
{"no daemon answer, no flag", false, nil, false},
{"unenrolled", false, &management.IdentityView{}, false},
{"enrolled and healthy", false,
&management.IdentityView{Enrolled: true, AuthState: management.AuthStateOK}, false},
{"enrolled, older daemon reports no auth state", false,
&management.IdentityView{Enrolled: true}, false},
{"enrolled but re-auth required", false,
&management.IdentityView{Enrolled: true, AuthState: management.AuthStateReauthRequired}, true},
{"--force-reauth on a healthy device", true,
&management.IdentityView{Enrolled: true, AuthState: management.AuthStateOK}, true},
{"--force-reauth with no daemon answer", true, nil, true},
// reauth_required on a device that is not enrolled cannot happen;
// pinned so the predicate reads as "enrolled AND broken".
{"not enrolled but re-auth flagged", false,
&management.IdentityView{AuthState: management.AuthStateReauthRequired}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := reauthWanted(tc.force, tc.view); got != tc.want {
t.Errorf("reauthWanted(%v, %+v) = %v, want %v", tc.force, tc.view, got, tc.want)
}
})
}
}

// TestResumeLines pins the copy of the resume notice, including the
// half that keeps Waired out of tailscale#7995 ("silently ignores
// authkey argument when already authed"): an auth key that was not used
// is said out loud, with the flag that would have used it.
func TestResumeLines(t *testing.T) {
cases := []struct {
name string
email string
authKeyGiven bool
want []string
absent []string
}{
{
name: "known account",
email: "you@example.com",
want: []string{"Already signed in as you@example.com — resuming setup."},
// Nothing about a key that was never passed.
absent: []string{"auth key"},
},
{
name: "account unknown",
want: []string{"This device is already signed in — resuming setup."},
absent: []string{"auth key", " as "},
},
{
name: "auth key not used",
email: "you@example.com",
authKeyGiven: true,
want: []string{
"Already signed in as you@example.com — resuming setup.",
"The auth key was not used. Pass --force-reauth to sign in again with it.",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := strings.Join(resumeLines(tc.email, tc.authKeyGiven), "\n")
for _, want := range tc.want {
if !strings.Contains(got, want) {
t.Errorf("resume notice missing %q:\n%s", want, got)
}
}
for _, absent := range tc.absent {
if strings.Contains(got, absent) {
t.Errorf("resume notice should not mention %q:\n%s", absent, got)
}
}
})
}
}

// The daemon is the authority on enrollment: the CLI's own state dir can
// be the wrong one (Windows, #313) or unreadable (a standard user
// against the ACL'd ProgramData tree, waired#751). identityFromView is
// how a daemon-reported enrollment reaches the renew summary that used
// to require a readable identity.json.
func TestIdentityFromView(t *testing.T) {
if got := identityFromView(nil); got != nil {
t.Errorf("identityFromView(nil) = %+v, want nil", got)
}
if got := identityFromView(&management.IdentityView{}); got != nil {
t.Errorf("identityFromView(unenrolled) = %+v, want nil", got)
}
v := &management.IdentityView{
Enrolled: true, AccountEmail: "you@example.com", DeviceName: "dev-1",
DeviceID: "dev_abc", NetworkName: "personal", ControlURL: "https://cp.example",
}
got := identityFromView(v)
if got == nil {
t.Fatal("identityFromView dropped an enrolled device")
}
if got.AccountEmail != v.AccountEmail || got.DeviceName != v.DeviceName ||
got.DeviceID != v.DeviceID || got.NetworkName != v.NetworkName ||
got.ControlURL != v.ControlURL {
t.Errorf("identityFromView lost fields: %+v", got)
}
}
53 changes: 40 additions & 13 deletions cmd/waired/login_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,16 @@ type daemonInitOpts struct {
// AuthKey enrolls without a browser sign-in, for servers and
// containers.
AuthKey string
// Reauth is set when this host already has an identity. Without it the
// daemon treats a Start on an active session as an idempotent no-op
// and this function would print a successful sign-in for a run that
// renewed nothing (#175).
Reauth bool
Inference daemonInitInference
// Reauth asks the daemon to re-authenticate a device that is already
// signed in. Without it a Start on an active session is an idempotent
// no-op, and this function resumes rather than printing a successful
// sign-in for a run that renewed nothing (#175, #313).
Reauth bool
// AccountEmail is what the daemon reports it is enrolled as, when the
// caller already asked. Used only to name the account in the resume
// notice: the no-op answer carries no session and no email of its own.
AccountEmail string
Inference daemonInitInference
// Owner is the process-wide stdin reader, or nil off a TTY.
Owner *stdinReader
}
Expand Down Expand Up @@ -105,21 +109,44 @@ func runInitViaDaemon(o daemonInitOpts) error {
if err := json.Unmarshal(out, &st); err != nil {
return fmt.Errorf("decode login start: %w", err)
}
// A daemon that is already signed in answers with its idempotent
// no-op: phase active, no session. There is nothing to poll and
// nothing to sign in to — the run picks up from there.
resuming := false
if st.SessionID == "" {
if st.Phase != management.LoginPhaseActive {
// No phase name in the copy: "unenrolled" / "logging_in" are
// this protocol's words, not the operator's, and the previous
// wording ("no login session id") is the whole reason #313
// read as a protocol bug rather than a working daemon.
return errors.New("the background service did not start a sign-in.\n" +
" Run `waired init` again; if it keeps happening, `waired doctor` says what is wrong with the service")
}
// An agent too old to know about `reauth` ignores the field and
// answers with its idempotent no-op: active, no session. Saying
// "no session id" there would send the operator looking for a bug
// in a daemon that is working exactly as it was built to (#175).
if reauth && st.Phase == management.LoginPhaseActive {
// answers with the same no-op. Saying "no session id" there would
// send the operator looking for a bug in a daemon that is working
// exactly as it was built to (#175).
if reauth {
return errors.New("this device is signed in, but the background service is too old to renew that sign-in.\n" +
" Update Waired, then run `waired init` again")
}
return errors.New("daemon did not return a login session id")
// #313: this is the resume NAVI prescribes for a stuck setup. It
// used to be reported as a protocol failure, which on Windows was
// the only outcome `waired init` had on an enrolled device.
resuming = true
if st.AccountEmail == "" {
st.AccountEmail = o.AccountEmail
}
}

if authKey != "" {
switch {
case resuming:
for _, line := range resumeLines(st.AccountEmail, authKey != "") {
fmt.Println(line)
}
case authKey != "":
fmt.Println(bold("Signing in with an auth key"))
} else {
default:
fmt.Println(bold("Sign in"))
}

Expand Down
Loading
Loading