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: 2 additions & 2 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 1 addition & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<account-id>.console.facets.cloud
```

Once saved, you don't need to pass `--url` again. Re-running
Expand Down
28 changes: 11 additions & 17 deletions cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"

Expand All @@ -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 <https://your-praxis-deployment> to create this profile",
"pass --url https://<account-id>.console.facets.cloud to create this profile",
exitcode.Usage)
return err
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions cmd/login_canonical_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 34 additions & 5 deletions cmd/login_reuse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/logout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.aiaskpraxis.ai,
// but evilaskpraxis.aiaskpraxis.ai). This is the same rule net/http
// or a label-aligned subdomain of it (www.example.testexample.test,
// but evilexample.testexample.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)
Expand Down
16 changes: 8 additions & 8 deletions cmd/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 1 addition & 7 deletions internal/credentials/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_…
//
Expand Down Expand Up @@ -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"
Expand Down
17 changes: 3 additions & 14 deletions internal/credentials/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"} {
Comment on lines +195 to +198

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Handle fixture setup errors instead of discarding them.

Put, os.UserHomeDir, and os.ReadFile errors are ignored in this modified test block. A setup failure can produce a misleading assertion or read the wrong file. Check each error and include the operation and error value in the failure.

As per coding guidelines, tests must assert error types or contents rather than only err != nil.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/credentials/credentials_test.go` around lines 195 - 198, Update the
fixture setup in the credentials serialization test around Put, os.UserHomeDir,
and os.ReadFile to check each returned error, reporting the operation and error
value through the test failure mechanism. Assert the expected error type or
contents where applicable rather than only checking err != nil, and only
continue to inspect the credentials file after setup succeeds.

Source: Coding guidelines

if !strings.Contains(string(body), want) {
t.Errorf("ini output missing %q\nfile:\n%s", want, body)
}
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions internal/raptorstate/raptorstate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
Expand Down
3 changes: 1 addition & 2 deletions internal/render/preamble.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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" +
Expand Down
5 changes: 2 additions & 3 deletions internal/skillinstall/dummy.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,8 @@ Act on it:
raptor command: ` + "`FACETS_PROFILE=<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;
Comment on lines +233 to +234

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Align both generated guidance paths with the matches_praxis_url contract.

matches_praxis_url == false means that the hosts do not match or cannot be verified. It does not prove that raptor targets a different control plane. Keep confirmation before writes for every false result. Name two hosts and report a different control plane only when both parsed hosts differ.

  • internal/skillinstall/dummy.go#L233-L234: distinguish missing or invalid hosts from a confirmed mismatch.
  • internal/render/preamble.go#L46-L46: use the same wording in the execution preamble.
📍 Affects 2 files
  • internal/skillinstall/dummy.go#L233-L234 (this comment)
  • internal/render/preamble.go#L46-L46
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/skillinstall/dummy.go` around lines 233 - 234, Update the guidance
in internal/skillinstall/dummy.go lines 233-234 and internal/render/preamble.go
line 46 to align with matches_praxis_url: every false result must require
confirmation before any write, while naming both hosts and reporting a different
control plane only when both parsed hosts are present and differ; otherwise
describe the hosts as missing or unverifiable. Apply the same wording and
distinction in both generated guidance paths.

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
Expand Down