diff --git a/.goreleaser.yml b/.goreleaser.yml index 585f9a9..0942c0c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -69,7 +69,7 @@ homebrew_casks: # repo secret of the same name. token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" directory: Casks - homepage: https://askpraxis.ai + homepage: https://facets.cloud description: Bring Praxis cloud capabilities to any local AI host license: MIT # Helps `brew audit` pass when homepage domain differs from the @@ -95,7 +95,7 @@ homebrew_casks: system_command "#{HOMEBREW_PREFIX}/bin/praxis", args: ["setup"], must_succeed: false commit_author: name: praxis-bot - email: bot@askpraxis.ai + email: bot@facets.cloud changelog: sort: asc diff --git a/CLAUDE.md b/CLAUDE.md index 816c9bd..372ed2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,8 +176,7 @@ Override at build time: `make build VERSION=v0.5.0-dev`. Released via Homebrew (`Facets-cloud/homebrew-tap`) and direct GitHub Releases binary download. `praxis update` self-updates against GitHub -Releases. `install.askpraxis.ai` is a separate shell-script install -path (not yet built). +Releases. ## Shipping a change (merge → release → upgrade → test) diff --git a/README.md b/README.md index f83758d..e2bb90c 100644 --- a/README.md +++ b/README.md @@ -101,14 +101,11 @@ that does everything you need: ### Where does login go? -By default `praxis login` points at the public SaaS instance -(`https://askpraxis.ai`). If your team runs Praxis at a different URL -— e.g. a Facets-hosted deployment for your org, or a self-hosted -install — pass `--url` the first time you log in. Ask your Praxis -admin if you don't know the URL. +Pass your organization's Praxis deployment URL the first time you log in. +Ask your Praxis administrator if you don't know it. ```bash -praxis login --url https://praxis.your-org.example +praxis login --url https://.console.facets.cloud ``` Once saved, you don't need to pass `--url` again. Re-running diff --git a/cmd/login.go b/cmd/login.go index f9c1ef5..1f6957a 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -62,7 +62,7 @@ var osExit = os.Exit func init() { loginCmd.Flags().StringVar(&loginProfile, "profile", "", "save under this profile name (default: \"default\")") - loginCmd.Flags().StringVar(&loginURL, "url", "", "Praxis deployment URL (default: existing profile URL or "+credentials.DefaultURL+")") + loginCmd.Flags().StringVar(&loginURL, "url", "", "Praxis deployment URL (required for a new profile; existing profiles reuse their saved URL)") loginCmd.Flags().StringVar(&loginToken, "token", "", "skip browser flow; save and verify the given API key directly") loginCmd.Flags().BoolVar(&loginForce, "force", false, "skip reusing a stored token; always open the browser") loginCmd.Flags().BoolVar(&loginLocal, "local", false, @@ -99,7 +99,7 @@ var loginCmd = &cobra.Command{ Multiple deployments? Use --profile to keep them separate: - praxis login → "default" + praxis login --url https://acme.console.facets.cloud → "default" praxis login --profile acme --url https://... → "acme" praxis login --profile bigcorp --url https://.. → "bigcorp" @@ -123,7 +123,7 @@ installed skills — then exits without changing anything.`, baseURL, err := resolveLoginURL(profileName, loginURL) if err != nil { render.PrintError(out, asJSON, err.Error(), - "pass --url to create this profile", + "pass --url https://.console.facets.cloud to create this profile", exitcode.Usage) return err } @@ -154,12 +154,9 @@ installed skills — then exits without changing anything.`, } // resolveLoginURL resolves the URL for a NEW or EXISTING profile during -// login: explicit --url > existing profile's saved URL > built-in default. -// -// A NEW *named* profile (one not present in the store) without --url is -// an error — there's no URL to reuse and guessing askpraxis.ai for a -// named deployment would be wrong. The "default" profile keeps the -// built-in fallback so a zero-config first run still works. +// login: explicit --url > existing profile's saved URL. A new profile, +// including "default", must provide --url because the CLI cannot safely +// infer which organization deployment the user intends to authenticate to. func resolveLoginURL(profileName, flagURL string) (string, error) { if flagURL != "" { return normalizeBaseURL(flagURL), nil @@ -168,10 +165,7 @@ func resolveLoginURL(profileName, flagURL string) (string, error) { if p, ok := store[profileName]; ok && p.URL != "" { return normalizeBaseURL(p.URL), nil } - if profileName == credentials.DefaultProfileName { - return credentials.DefaultURL, nil - } - return "", fmt.Errorf("profile %q does not exist yet; pass --url to create it", profileName) + return "", fmt.Errorf("profile %q has no saved URL; pass --url to create it", profileName) } // normalizeBaseURL strips trailing slashes so path concatenation @@ -263,7 +257,7 @@ func browserSessionPollLogin(out io.Writer, asJSON bool, profileName, baseURL st openURL, err := buildLoginURL(baseURL, sessionNonce, suggestedKeyName()) if err != nil { render.PrintError(out, asJSON, err.Error(), - "check the --url value (or PRAXIS_URL) — it must be a valid URL", + "check the --url value — it must be a valid URL", exitcode.Usage) os.Exit(exitcode.Usage) } @@ -549,9 +543,9 @@ type authMeResponse struct { Username string `json:"username"` // canonicalBaseURL is the deployment base URL the /auth/me call - // actually landed on after following redirects (e.g. the apex - // askpraxis.ai 301s to www). Login persists this instead of the URL - // the user typed, so later MCP invokes never pay that redirect. + // actually landed on after following redirects. Login persists this + // instead of the URL the user typed, so later MCP invokes never pay + // that redirect. // Empty when a test stub doesn't set it — callers fall back to the // URL they already have. canonicalBaseURL string diff --git a/cmd/login_canonical_test.go b/cmd/login_canonical_test.go index 4e387c7..8dae05e 100644 --- a/cmd/login_canonical_test.go +++ b/cmd/login_canonical_test.go @@ -12,11 +12,10 @@ import ( // ─── issue #19-A: canonicalize the host at login ───────────────────────── // -// The apex https://askpraxis.ai 301-redirects to www. fetchAuthMe's GET -// follows the redirect, so login *works* against the apex — but if the -// CLI then stores the apex URL, every later MCP invoke pays (and used to -// fail on) that redirect. Login must store the scheme://host the -// /auth/me call actually landed on. +// A deployment URL can redirect to its canonical host. fetchAuthMe's GET +// follows the redirect, but storing the original URL makes every later MCP +// invoke pay that redirect. Login must store the scheme://host the /auth/me +// call actually landed on. // canonicalPair spins up a "final" server serving /ai-api/auth/me and a // "stale" server that 301-redirects everything to it, mimicking the diff --git a/cmd/login_reuse_test.go b/cmd/login_reuse_test.go index 4ec15ed..484fdbd 100644 --- a/cmd/login_reuse_test.go +++ b/cmd/login_reuse_test.go @@ -80,11 +80,11 @@ func stubOsExit(t *testing.T) *int { func resetLoginFlags(t *testing.T) { t.Helper() loginProfile, loginURL, loginToken, loginRaptorProfile = "", "", "", "" - loginForce, loginLocal, loginJSON = false, false, false + loginForce, loginLocal, loginJSON, loginDryRun = false, false, false, false loginTimeout = 90 * time.Second t.Cleanup(func() { loginProfile, loginURL, loginToken, loginRaptorProfile = "", "", "", "" - loginForce, loginLocal, loginJSON = false, false, false + loginForce, loginLocal, loginJSON, loginDryRun = false, false, false, false loginTimeout = 90 * time.Second }) } @@ -123,9 +123,9 @@ func TestResolveLoginURL(t *testing.T) { wantURL: "https://acme.test", }, { - name: "default profile falls back to the built-in URL", + name: "new default profile without --url errors", profile: "default", flagURL: "", - wantURL: credentials.DefaultURL, + wantErr: true, errContains: "--url", }, { name: "new named profile without --url errors", @@ -180,7 +180,7 @@ func TestTryReuseStoredToken(t *testing.T) { { name: "no stored token → no reuse, browser fallback", seed: false, - targetURL: credentials.DefaultURL, + targetURL: "https://new.test", }, { name: "URL re-target skips reuse without verifying", @@ -286,6 +286,34 @@ func TestTryReuseStoredToken(t *testing.T) { } } +func TestLoginRunE_NewDefaultRequiresURLBeforeSideEffects(t *testing.T) { + isolateHome(t) + resetLoginFlags(t) + browser := stubBrowserLogin(t) + post := stubPostAuth(t) + + out, err := runLoginRunE(t) + if err == nil || !strings.Contains(err.Error(), "--url") { + t.Fatalf("err = %v, want missing --url error", err) + } + if !strings.Contains(out, "console.facets.cloud") { + t.Errorf("output = %q, want organization URL guidance", out) + } + if *browser { + t.Error("browser flow ran before a URL was configured") + } + if *post { + t.Error("post-auth setup ran before a URL was configured") + } + store, loadErr := credentials.Load() + if loadErr != nil { + t.Fatalf("credentials.Load(): %v", loadErr) + } + if len(store) != 0 { + t.Errorf("credentials were written: %+v", store) + } +} + // ─── RunE precedence ───────────────────────────────────────────────────── // // These stay as separate functions rather than a table: each exercises a @@ -379,6 +407,7 @@ func TestLoginRunE_ForceOpensBrowser(t *testing.T) { func TestLoginRunE_NoStoredTokenOpensBrowser(t *testing.T) { isolateHome(t) resetLoginFlags(t) + loginURL = "https://new.test" browser := stubBrowserLogin(t) stubPostAuth(t) if _, err := runLoginRunE(t); err != nil { diff --git a/cmd/logout_test.go b/cmd/logout_test.go index 971389e..a5fa05b 100644 --- a/cmd/logout_test.go +++ b/cmd/logout_test.go @@ -44,7 +44,7 @@ func TestLogoutCmd_RemovesActive(t *testing.T) { resetLogoutFlags() if err := credentials.Put("default", credentials.Profile{ - URL: "https://askpraxis.ai", + URL: "https://default.test", Username: "x@x.com", Token: "sk_live_abc", }); err != nil { diff --git a/cmd/mcp.go b/cmd/mcp.go index 61e36c5..6783b6b 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -318,8 +318,8 @@ var callMCP = func(baseURL, token, mcp, fn string, body []byte, timeout time.Dur client := &http.Client{ Timeout: timeout, // Go's default redirect policy downgrades POST→GET and drops the - // body on 301/302/303. A gateway that 301-redirects (e.g. the - // askpraxis.ai apex → www) would therefore turn every invoke into + // body on 301/302/303. A gateway that 301-redirects to its canonical + // host would therefore turn every invoke into // a body-less GET that 404s — misreported downstream as "unknown // mcp/fn". Preserve the method, body, and Authorization header so // the invoke survives the redirect intact. @@ -368,8 +368,8 @@ var callMCP = func(baseURL, token, mcp, fn string, body []byte, timeout time.Dur } // isDomainOrSubdomain reports whether child is the same host as parent -// or a label-aligned subdomain of it (www.askpraxis.ai ⊂ askpraxis.ai, -// but evilaskpraxis.ai ⊄ askpraxis.ai). This is the same rule net/http +// or a label-aligned subdomain of it (www.example.test ⊂ example.test, +// but evilexample.test ⊄ example.test). This is the same rule net/http // uses to decide whether sensitive headers may follow a redirect. func isDomainOrSubdomain(child, parent string) bool { child, parent = strings.ToLower(child), strings.ToLower(parent) diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go index 41ee7ce..7d06b20 100644 --- a/cmd/mcp_test.go +++ b/cmd/mcp_test.go @@ -176,8 +176,8 @@ func TestPrettyJSON(t *testing.T) { } // TestCallMCP_PreservesPOSTAcrossRedirect reproduces the issue #18 P0: -// the default host https://askpraxis.ai 301-redirects to www, and Go's -// default http.Client downgrades POST→GET and drops the body on a 301, +// a canonical-host redirect exposed that Go's default http.Client +// downgrades POST→GET and drops the body on a 301, // so every invoke hits a body-less GET that 404s and gets misreported // as "unknown mcp/fn". callMCP must preserve the method, body, and // Authorization header across the gateway's redirect. @@ -269,12 +269,12 @@ func TestIsDomainOrSubdomain(t *testing.T) { child, parent string want bool }{ - {"askpraxis.ai", "askpraxis.ai", true}, - {"www.askpraxis.ai", "askpraxis.ai", true}, // apex → www - {"WWW.ASKPRAXIS.AI", "askpraxis.ai", true}, // case-insensitive - {"askpraxis.ai", "www.askpraxis.ai", false}, // parent isn't a suffix domain of child - {"evilaskpraxis.ai", "askpraxis.ai", false}, // suffix must be label-aligned - {"evil.com", "askpraxis.ai", false}, + {"example.test", "example.test", true}, + {"www.example.test", "example.test", true}, // apex → www + {"WWW.EXAMPLE.TEST", "example.test", true}, // case-insensitive + {"example.test", "www.example.test", false}, // parent isn't a suffix domain of child + {"evilexample.test", "example.test", false}, // suffix must be label-aligned + {"evil.test", "example.test", false}, {"localhost", "127.0.0.1", false}, } for _, tt := range tests { diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index dfec1fa..0869030 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -3,7 +3,7 @@ // users coming from facets-cli have zero learning curve. // // [default] -// url = https://askpraxis.ai +// url = https://acme.console.facets.cloud // username = anshul@facets.cloud // token = sk_live_… // @@ -59,12 +59,6 @@ func validateProfileName(name string) error { return nil } -// DefaultURL is the built-in fallback when a profile has no URL set. -// Use the canonical www host: the apex https://askpraxis.ai -// 301-redirects to www, and a stored apex URL forces every MCP invoke -// to pay (and, before the callMCP redirect fix, fail on) that redirect. -const DefaultURL = "https://www.askpraxis.ai" - // DefaultProfileName is the literal section name used when no other // signal selects a profile. const DefaultProfileName = "default" diff --git a/internal/credentials/credentials_test.go b/internal/credentials/credentials_test.go index cb49bdc..67de855 100644 --- a/internal/credentials/credentials_test.go +++ b/internal/credentials/credentials_test.go @@ -96,7 +96,7 @@ func TestPutLoadGet_RoundTrip(t *testing.T) { func TestPut_AddsSecondProfileWithoutClobberingFirst(t *testing.T) { withHome(t) - if err := Put("default", Profile{URL: "https://askpraxis.ai", Username: "a@x", Token: "t1"}); err != nil { + if err := Put("default", Profile{URL: "https://default.test", Username: "a@x", Token: "t1"}); err != nil { t.Fatal(err) } if err := Put("acme", Profile{URL: "https://acme.test", Username: "b@x", Token: "t2"}); err != nil { @@ -192,10 +192,10 @@ func TestSave_FilePerm0600(t *testing.T) { func TestINI_FormatMatchesFacetsConvention(t *testing.T) { withHome(t) - _ = Put("default", Profile{URL: "https://askpraxis.ai", Username: "a@x", Token: "t"}) + _ = Put("default", Profile{URL: "https://default.test", Username: "a@x", Token: "t"}) home, _ := os.UserHomeDir() body, _ := os.ReadFile(filepath.Join(home, ".praxis", "credentials")) - for _, want := range []string{"[default]", "url = https://askpraxis.ai", "username = a@x", "token = t"} { + for _, want := range []string{"[default]", "url = https://default.test", "username = a@x", "token = t"} { if !strings.Contains(string(body), want) { t.Errorf("ini output missing %q\nfile:\n%s", want, body) } @@ -284,17 +284,6 @@ url = https://y } } -// TestDefaultURL_IsCanonicalHost guards issue #18: the apex -// https://askpraxis.ai 301-redirects to www, which (before the callMCP -// redirect fix) broke every MCP invoke on a fresh install. Default to -// the canonical host so fresh logins don't redirect at all. -func TestDefaultURL_IsCanonicalHost(t *testing.T) { - const want = "https://www.askpraxis.ai" - if DefaultURL != want { - t.Errorf("DefaultURL = %q, want %q (canonical host, no 301 redirect)", DefaultURL, want) - } -} - // ─── Project-local (local mode) resolution ────────────────────────────── // setCwd is a helper to point project-root discovery at dir for the test. diff --git a/internal/raptorstate/raptorstate_test.go b/internal/raptorstate/raptorstate_test.go index 260a264..c823ff8 100644 --- a/internal/raptorstate/raptorstate_test.go +++ b/internal/raptorstate/raptorstate_test.go @@ -210,9 +210,9 @@ func TestMatchesHost(t *testing.T) { {"same host", "https://root.console.facets.cloud", "https://root.console.facets.cloud", true}, {"case-insensitive", "https://Root.Console.Facets.Cloud", "https://root.console.facets.cloud", true}, {"scheme and path ignored", "https://acme.console.facets.cloud/ai-api", "http://acme.console.facets.cloud", true}, - {"different hosts", "https://www.askpraxis.ai", "https://root.console.facets.cloud", false}, + {"different hosts", "https://praxis.example.test", "https://root.console.facets.cloud", false}, {"empty praxis URL", "", "https://root.console.facets.cloud", false}, - {"empty cp URL", "https://www.askpraxis.ai", "", false}, + {"empty cp URL", "https://praxis.example.test", "", false}, {"unparseable", "://bad", "https://root.console.facets.cloud", false}, {"hostless (no scheme)", "root.console.facets.cloud", "https://root.console.facets.cloud", false}, } diff --git a/internal/render/preamble.go b/internal/render/preamble.go index ca265f8..3bbe0b2 100644 --- a/internal/render/preamble.go +++ b/internal/render/preamble.go @@ -43,8 +43,7 @@ const ExecutionPreamble = "" + "> Raptor's profile is NOT praxis's profile — check the `raptor` block in\n" + "> `praxis status --json`. If `pinned` is true, prefix EVERY raptor command\n" + "> with `FACETS_PROFILE=` (env vars don't persist across shell\n" + - "> calls). If `matches_praxis_url` is false unexpectedly (a praxis profile\n" + - "> on askpraxis.ai never matches — that's fine), raptor is aimed at a\n" + + "> calls). If `matches_praxis_url` is false unexpectedly, raptor is aimed at a\n" + "> DIFFERENT control plane than praxis: ask the user before any write.\n" + ">\n" + "> **Discovering what's available** — to see every MCP and function the\n" + diff --git a/internal/skillinstall/dummy.go b/internal/skillinstall/dummy.go index 031081a..98572e0 100644 --- a/internal/skillinstall/dummy.go +++ b/internal/skillinstall/dummy.go @@ -230,9 +230,8 @@ Act on it: raptor command: ` + "`FACETS_PROFILE= raptor …`" + `. Per-command prefix, never ` + "`export`" + ` — each shell call starts fresh. - ` + "`matches_praxis_url: false`" + ` — raptor targets a different control - plane than this praxis profile. Expected when praxis points at - askpraxis.ai (no raptor CP matches it). Otherwise say which two hosts - you see and ask the user which is intended BEFORE any raptor write; + plane than this praxis profile. Say which two hosts you see and ask the + user which is intended BEFORE any raptor write; read-only exploration may proceed with a note. - ` + "`installed: false`" + ` — raptor isn't on this machine at all, so every control-plane command will fail. The block carries an