diff --git a/cmd/duty.go b/cmd/duty.go index 15055d0..5f7ede7 100644 --- a/cmd/duty.go +++ b/cmd/duty.go @@ -98,7 +98,7 @@ func resolveNameOrID[T any](arg string, items []T, key func(T) (name, id string) // id, which addresses its nested duty resources. GLOBAL agents (the // praxis duty agent) are fetched via include_global=true. func resolveAgentID(out io.Writer, active credentials.Active, agentArg string) string { - agents, err := agentcatalog.FetchIncludingGlobal(active.Profile.URL, active.Profile.Token) + agents, err := agentcatalog.FetchIncludingGlobal(active.Profile.URL, active.Profile.Auth()) if err != nil { return reportResolveErr(out, active.Name, err) } @@ -111,7 +111,7 @@ func resolveAgentID(out io.Writer, active credentials.Active, agentArg string) s // under the resolved agent. Same name→id-then-passthrough policy as // resolveAgentID. func resolveScheduleID(out io.Writer, active credentials.Active, agentID, dutyArg string) string { - schedules, err := duties.ListSchedules(active.Profile.URL, active.Profile.Token, agentID, "") + schedules, err := duties.ListSchedules(active.Profile.URL, active.Profile.Auth(), agentID, "") if err != nil { return reportResolveErr(out, active.Name, err) } @@ -141,7 +141,7 @@ var dutyListCmd = &cobra.Command{ active := activeOrAuthExit(out) agentID := resolveAgentID(out, active, dutyAgent) - schedules, err := duties.ListSchedules(active.Profile.URL, active.Profile.Token, agentID, "") + schedules, err := duties.ListSchedules(active.Profile.URL, active.Profile.Auth(), agentID, "") if err != nil { return reportHTTPErr(out, active.Name, err) } @@ -176,7 +176,7 @@ var dutyRunsCmd = &cobra.Command{ scheduleID = resolveScheduleID(out, active, agentID, dutyRunsDuty) } - runs, err := duties.ListRuns(active.Profile.URL, active.Profile.Token, agentID, scheduleID, dutyRunsLimit) + runs, err := duties.ListRuns(active.Profile.URL, active.Profile.Auth(), agentID, scheduleID, dutyRunsLimit) if err != nil { return reportHTTPErr(out, active.Name, err) } @@ -203,7 +203,7 @@ var dutyRunCmd = &cobra.Command{ active := activeOrAuthExit(out) agentID := resolveAgentID(out, active, dutyAgent) - run, err := duties.GetRun(active.Profile.URL, active.Profile.Token, agentID, args[0]) + run, err := duties.GetRun(active.Profile.URL, active.Profile.Auth(), agentID, args[0]) if err != nil { return reportHTTPErr(out, active.Name, err) } @@ -227,7 +227,7 @@ var dutyReportCmd = &cobra.Command{ active := activeOrAuthExit(out) agentID := resolveAgentID(out, active, dutyAgent) - run, err := duties.GetRun(active.Profile.URL, active.Profile.Token, agentID, args[0]) + run, err := duties.GetRun(active.Profile.URL, active.Profile.Auth(), agentID, args[0]) if err != nil { return reportHTTPErr(out, active.Name, err) } @@ -239,7 +239,7 @@ var dutyReportCmd = &cobra.Command{ os.Exit(exitcode.Error) } - body, mime, err := duties.FetchArtifactContent(active.Profile.URL, active.Profile.Token, *run.ReportArtifactID) + body, mime, err := duties.FetchArtifactContent(active.Profile.URL, active.Profile.Auth(), *run.ReportArtifactID) if err != nil { return reportHTTPErr(out, active.Name, err) } @@ -279,7 +279,7 @@ var dutyFindingsCmd = &cobra.Command{ agentID := resolveAgentID(out, active, dutyAgent) scheduleID := resolveScheduleID(out, active, agentID, args[0]) - findings, err := duties.ListFindings(active.Profile.URL, active.Profile.Token, agentID, scheduleID, dutyFindingsStatus, dutyFindingsLimit) + findings, err := duties.ListFindings(active.Profile.URL, active.Profile.Auth(), agentID, scheduleID, dutyFindingsStatus, dutyFindingsLimit) if err != nil { return reportHTTPErr(out, active.Name, err) } diff --git a/cmd/duty_test.go b/cmd/duty_test.go index 82fc383..6715c4d 100644 --- a/cmd/duty_test.go +++ b/cmd/duty_test.go @@ -27,7 +27,7 @@ func resetDutyFlags() { func stubAgentResolution(t *testing.T, praxisID string) func() { t.Helper() orig := agentcatalog.FetchIncludingGlobal - agentcatalog.FetchIncludingGlobal = func(baseURL, token string) ([]agentcatalog.Agent, error) { + agentcatalog.FetchIncludingGlobal = func(baseURL string, token map[string]string) ([]agentcatalog.Agent, error) { return []agentcatalog.Agent{{ID: praxisID, Name: "praxis", Scope: "global", IsActive: true}}, nil } return func() { agentcatalog.FetchIncludingGlobal = orig } @@ -37,7 +37,7 @@ func stubAgentResolution(t *testing.T, praxisID string) func() { func stubScheduleResolution(t *testing.T, name, id string) func() { t.Helper() orig := duties.ListSchedules - duties.ListSchedules = func(baseURL, token, agentID, tag string) ([]duties.Schedule, error) { + duties.ListSchedules = func(baseURL string, token map[string]string, agentID, tag string) ([]duties.Schedule, error) { return []duties.Schedule{{ID: id, AgentID: agentID, Name: name, DisplayName: "Prod Watch", Status: "active", Enabled: true}}, nil } return func() { duties.ListSchedules = orig } @@ -65,9 +65,9 @@ func TestDutyList_ResolvesAgentAndEmitsJSON(t *testing.T) { defer restoreAgent() orig := duties.ListSchedules - duties.ListSchedules = func(baseURL, token, agentID, tag string) ([]duties.Schedule, error) { - if baseURL != "https://x.test" || token != "sk_test_T" { - t.Errorf("auth threading: url=%q token=%q", baseURL, token) + duties.ListSchedules = func(baseURL string, auth map[string]string, agentID, tag string) ([]duties.Schedule, error) { + if baseURL != "https://x.test" || auth["Authorization"] != "Bearer sk_test_T" { + t.Errorf("auth threading: url=%q auth=%v", baseURL, auth) } if agentID != "agt_praxis" { t.Errorf("agentID = %q; want agt_praxis (resolved from default --agent praxis)", agentID) @@ -99,7 +99,7 @@ func TestDutyList_EmptyEmitsArray(t *testing.T) { defer stubAgentResolution(t, "agt_praxis")() orig := duties.ListSchedules - duties.ListSchedules = func(baseURL, token, agentID, tag string) ([]duties.Schedule, error) { + duties.ListSchedules = func(baseURL string, token map[string]string, agentID, tag string) ([]duties.Schedule, error) { return nil, nil } defer func() { duties.ListSchedules = orig }() @@ -132,7 +132,7 @@ func TestDutyRuns_ResolvesDutyNameToScheduleID(t *testing.T) { defer stubScheduleResolution(t, "prod-watch", "sch1")() orig := duties.ListRuns - duties.ListRuns = func(baseURL, token, agentID, scheduleID string, limit int) ([]duties.Run, error) { + duties.ListRuns = func(baseURL string, token map[string]string, agentID, scheduleID string, limit int) ([]duties.Run, error) { if agentID != "agt_praxis" || scheduleID != "sch1" { t.Errorf("resolution wrong: agent=%q schedule=%q", agentID, scheduleID) } @@ -168,7 +168,7 @@ func TestDutyRun_EmitsRunDetail(t *testing.T) { defer stubAgentResolution(t, "agt_praxis")() orig := duties.GetRun - duties.GetRun = func(baseURL, token, agentID, runID string) (*duties.Run, error) { + duties.GetRun = func(baseURL string, token map[string]string, agentID, runID string) (*duties.Run, error) { if runID != "run9" { t.Errorf("runID = %q", runID) } @@ -201,14 +201,14 @@ func TestDutyReport_FetchesArtifactContent(t *testing.T) { defer stubAgentResolution(t, "agt_praxis")() origRun := duties.GetRun - duties.GetRun = func(baseURL, token, agentID, runID string) (*duties.Run, error) { + duties.GetRun = func(baseURL string, token map[string]string, agentID, runID string) (*duties.Run, error) { art := "art9" return &duties.Run{ID: runID, ReportArtifactID: &art}, nil } defer func() { duties.GetRun = origRun }() origArt := duties.FetchArtifactContent - duties.FetchArtifactContent = func(baseURL, token, artifactID string) ([]byte, string, error) { + duties.FetchArtifactContent = func(baseURL string, token map[string]string, artifactID string) ([]byte, string, error) { if artifactID != "art9" { t.Errorf("artifactID = %q; want art9 (from run.report_artifact_id)", artifactID) } @@ -244,7 +244,7 @@ func TestDutyFindings_EmitsJSON(t *testing.T) { defer stubScheduleResolution(t, "prod-watch", "sch1")() orig := duties.ListFindings - duties.ListFindings = func(baseURL, token, agentID, scheduleID, status string, limit int) ([]duties.Finding, error) { + duties.ListFindings = func(baseURL string, token map[string]string, agentID, scheduleID, status string, limit int) ([]duties.Finding, error) { if scheduleID != "sch1" || status != "open" { t.Errorf("schedule=%q status=%q", scheduleID, status) } @@ -343,7 +343,7 @@ func TestResolveAgentID_NameIDAndPassthrough(t *testing.T) { defer resetDutyFlags() orig := agentcatalog.FetchIncludingGlobal - agentcatalog.FetchIncludingGlobal = func(baseURL, token string) ([]agentcatalog.Agent, error) { + agentcatalog.FetchIncludingGlobal = func(baseURL string, token map[string]string) ([]agentcatalog.Agent, error) { return []agentcatalog.Agent{ {ID: "agt_praxis", Name: "praxis", IsActive: true}, {ID: "agt_org", Name: "org-bot", IsActive: true}, @@ -374,7 +374,7 @@ func TestResolveScheduleID_NameIDAndPassthrough(t *testing.T) { defer resetDutyFlags() orig := duties.ListSchedules - duties.ListSchedules = func(baseURL, token, agentID, tag string) ([]duties.Schedule, error) { + duties.ListSchedules = func(baseURL string, token map[string]string, agentID, tag string) ([]duties.Schedule, error) { return []duties.Schedule{ {ID: "sch1", Name: "prod-watch"}, {ID: "sch2", Name: "cost-audit"}, diff --git a/cmd/git_credential.go b/cmd/git_credential.go index 022ff9f..0569e7b 100644 --- a/cmd/git_credential.go +++ b/cmd/git_credential.go @@ -41,16 +41,16 @@ are no-ops because the token is ephemeral — nothing is persisted on the laptop }, } -// resolveGateway returns the active profile's gateway URL + token. -func resolveGateway() (string, string, error) { +// resolveGateway returns the active profile's gateway URL + auth headers. +func resolveGateway() (string, map[string]string, error) { active, err := credentials.ResolveActive("") if err != nil { - return "", "", err + return "", nil, err } if !active.Loaded || active.Profile.Token == "" { - return "", "", fmt.Errorf("no credentials for profile %q — run `praxis login`", active.Name) + return "", nil, fmt.Errorf("no credentials for profile %q — run `praxis login`", active.Name) } - return active.Profile.URL, active.Profile.Token, nil + return active.Profile.URL, active.Profile.Auth(), nil } // isGitHubHost reports whether a brokered GitHub token may be handed to host. @@ -74,7 +74,7 @@ func isGitHubHost(host string) bool { } // runGitCredential handles one credential-helper invocation. -func runGitCredential(out io.Writer, in io.Reader, op string, gw func() (string, string, error)) error { +func runGitCredential(out io.Writer, in io.Reader, op string, gw func() (string, map[string]string, error)) error { switch op { case "get": // handled below @@ -99,11 +99,11 @@ func runGitCredential(out io.Writer, in io.Reader, op string, gw func() (string, "path": attrs["path"], }) - baseURL, token, err := gw() + baseURL, auth, err := gw() if err != nil { return err } - raw, status, err := callMCP(baseURL, token, "vcs_cli", "mint_repo_credential", body, 30*time.Second) + raw, status, err := callMCP(baseURL, auth, "vcs_cli", "mint_repo_credential", body, 30*time.Second) if err != nil { return fmt.Errorf("gateway call failed: %w", err) } diff --git a/cmd/git_credential_test.go b/cmd/git_credential_test.go index 416fdca..15ece70 100644 --- a/cmd/git_credential_test.go +++ b/cmd/git_credential_test.go @@ -7,11 +7,17 @@ import ( "time" ) +// bearer builds the Auth() header map a Bearer-mode profile produces. +// Shared across cmd tests that thread an auth header map through a seam. +func bearer(tok string) map[string]string { + return map[string]string{"Authorization": "Bearer " + tok} +} + func TestGitCredentialGet_EmitsUsernamePassword(t *testing.T) { orig := callMCP defer func() { callMCP = orig }() // mint_repo_credential returns an MCP envelope whose text is JSON. - callMCP = func(baseURL, token, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { + callMCP = func(baseURL string, auth map[string]string, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { if mcp != "vcs_cli" || fn != "mint_repo_credential" { t.Fatalf("unexpected call %s/%s", mcp, fn) } @@ -22,7 +28,7 @@ func TestGitCredentialGet_EmitsUsernamePassword(t *testing.T) { in := strings.NewReader("protocol=https\nhost=github.com\npath=owner/x\n\n") var out bytes.Buffer err := runGitCredential(&out, in, "get", - func() (string, string, error) { return "https://gw", "tok", nil }) + func() (string, map[string]string, error) { return "https://gw", bearer("tok"), nil }) if err != nil { t.Fatalf("err: %v", err) } @@ -37,7 +43,7 @@ func TestGitCredentialStoreErase_NoOp(t *testing.T) { var out bytes.Buffer in := strings.NewReader("protocol=https\nhost=github.com\n\n") if err := runGitCredential(&out, in, op, - func() (string, string, error) { return "https://gw", "tok", nil }); err != nil { + func() (string, map[string]string, error) { return "https://gw", bearer("tok"), nil }); err != nil { t.Fatalf("%s should be no-op, got %v", op, err) } if out.Len() != 0 { @@ -50,7 +56,7 @@ func TestGitCredentialGet_ParsesHostAndPath(t *testing.T) { orig := callMCP defer func() { callMCP = orig }() var sentBody []byte - callMCP = func(baseURL, token, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { + callMCP = func(baseURL string, auth map[string]string, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { sentBody = body env := `{"content":[{"type":"text","text":"{\"username\":\"x-access-token\",\"password\":\"ghs_abc\"}"}]}` return []byte(env), 200, nil @@ -58,7 +64,7 @@ func TestGitCredentialGet_ParsesHostAndPath(t *testing.T) { in := strings.NewReader("protocol=https\nhost=github.com\npath=owner/x\n\n") var out bytes.Buffer if err := runGitCredential(&out, in, "get", - func() (string, string, error) { return "https://gw", "tok", nil }); err != nil { + func() (string, map[string]string, error) { return "https://gw", bearer("tok"), nil }); err != nil { t.Fatalf("err: %v", err) } if !strings.Contains(string(sentBody), `"host":"github.com"`) || @@ -80,7 +86,7 @@ func assertSilentFallThrough(t *testing.T, protocol, host string) { orig := callMCP defer func() { callMCP = orig }() called := false - callMCP = func(baseURL, token, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { + callMCP = func(baseURL string, auth map[string]string, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { called = true return nil, 200, nil } @@ -88,7 +94,7 @@ func assertSilentFallThrough(t *testing.T, protocol, host string) { var out bytes.Buffer in := strings.NewReader("protocol=" + protocol + "\nhost=" + host + "\n\n") if err := runGitCredential(&out, in, "get", - func() (string, string, error) { return "https://gw", "tok", nil }); err != nil { + func() (string, map[string]string, error) { return "https://gw", bearer("tok"), nil }); err != nil { t.Fatalf("expected silent fall-through, got err %v", err) } if called { @@ -117,14 +123,14 @@ func TestGitCredentialGet_AllowsGitHubHosts(t *testing.T) { t.Run(host, func(t *testing.T) { orig := callMCP defer func() { callMCP = orig }() - callMCP = func(baseURL, token, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { + callMCP = func(baseURL string, auth map[string]string, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { env := `{"content":[{"type":"text","text":"{\"username\":\"x-access-token\",\"password\":\"ghs_abc\"}"}]}` return []byte(env), 200, nil } var out bytes.Buffer in := strings.NewReader("protocol=https\nhost=" + host + "\n\n") if err := runGitCredential(&out, in, "get", - func() (string, string, error) { return "https://gw", "tok", nil }); err != nil { + func() (string, map[string]string, error) { return "https://gw", bearer("tok"), nil }); err != nil { t.Fatalf("err: %v", err) } if !strings.Contains(out.String(), "password=ghs_abc") { @@ -138,7 +144,7 @@ func TestGitCredential_RejectsUnknownOperation(t *testing.T) { var out bytes.Buffer in := strings.NewReader("protocol=https\nhost=github.com\n\n") err := runGitCredential(&out, in, "gte", - func() (string, string, error) { return "https://gw", "tok", nil }) + func() (string, map[string]string, error) { return "https://gw", bearer("tok"), nil }) if err == nil { t.Fatal("unknown operation must return an error, not silently succeed") } diff --git a/cmd/ig.go b/cmd/ig.go index c5ffc94..b47c0ec 100644 --- a/cmd/ig.go +++ b/cmd/ig.go @@ -191,7 +191,7 @@ func syncOne(active credentials.Active, catalog string) (upToDate bool, err erro local, _ := readSyncState(dir) body, etag, notModified, err := igcatalog.DownloadBundle( - active.Profile.URL, active.Profile.Token, catalog, local.Digest) + active.Profile.URL, active.Profile.Auth(), catalog, local.Digest) if err != nil { return false, err } @@ -419,7 +419,7 @@ func statusOne(active credentials.Active, catalog string) (state, serverVersion, return "", "", "", err } local, synced := readSyncState(catalogDir(home, catalog)) - c, err := igcatalog.GetCatalog(active.Profile.URL, active.Profile.Token, catalog) + c, err := igcatalog.GetCatalog(active.Profile.URL, active.Profile.Auth(), catalog) if err != nil { return "", "", "", err } @@ -518,7 +518,7 @@ var igListCmd = &cobra.Command{ asJSON := render.UseJSON(igJSON, false, out) active := activeOrAuthExitProfile(out, igProfile) - cats, err := igcatalog.ListCatalogs(active.Profile.URL, active.Profile.Token) + cats, err := igcatalog.ListCatalogs(active.Profile.URL, active.Profile.Auth()) if err != nil { return reportHTTPErr(out, active.Name, err) } @@ -557,7 +557,7 @@ live tree.`, targets := args if igSyncAll { - cats, err := igcatalog.ListCatalogs(active.Profile.URL, active.Profile.Token) + cats, err := igcatalog.ListCatalogs(active.Profile.URL, active.Profile.Auth()) if err != nil { return reportHTTPErr(out, active.Name, err) } @@ -687,7 +687,7 @@ var igPublishCmd = &cobra.Command{ active := activeOrAuthExitProfile(out, igProfile) gz := gzipBytes(raw) - if err := igcatalog.PublishMember(active.Profile.URL, active.Profile.Token, + if err := igcatalog.PublishMember(active.Profile.URL, active.Profile.Auth(), igPublishCatalog, igPublishMember, gz, git, sha); err != nil { return reportHTTPErr(out, active.Name, err) } @@ -736,7 +736,7 @@ var igClaimsCmd = &cobra.Command{ } active := activeOrAuthExitProfile(out, igProfile) - names, err := igcatalog.Claims(active.Profile.URL, active.Profile.Token, igClaimsGit) + names, err := igcatalog.Claims(active.Profile.URL, active.Profile.Auth(), igClaimsGit) if err != nil { return reportHTTPErr(out, active.Name, err) } @@ -790,7 +790,7 @@ var igManifestPushCmd = &cobra.Command{ PushedAt: nowFn().UTC().Format(time.RFC3339), GitSHA: gitSHA, } - if err := igcatalog.ManifestPush(active.Profile.URL, active.Profile.Token, igManifestCatalog, m); err != nil { + if err := igcatalog.ManifestPush(active.Profile.URL, active.Profile.Auth(), igManifestCatalog, m); err != nil { return reportHTTPErr(out, active.Name, err) } result := map[string]string{ @@ -813,7 +813,7 @@ var igManifestPullCmd = &cobra.Command{ out := cmd.OutOrStdout() active := activeOrAuthExitProfile(out, igProfile) - m, err := igcatalog.ManifestPull(active.Profile.URL, active.Profile.Token, args[0]) + m, err := igcatalog.ManifestPull(active.Profile.URL, active.Profile.Auth(), args[0]) if err != nil { return reportHTTPErr(out, active.Name, err) } diff --git a/cmd/ig_hook.go b/cmd/ig_hook.go index f47ef5f..12eccb9 100644 --- a/cmd/ig_hook.go +++ b/cmd/ig_hook.go @@ -100,7 +100,7 @@ func serverClaims(canonURL string) ([]string, error) { } ch := make(chan res, 1) go func() { - n, e := igcatalog.Claims(act.Profile.URL, act.Profile.Token, canonURL) + n, e := igcatalog.Claims(act.Profile.URL, act.Profile.Auth(), canonURL) ch <- res{n, e} }() select { diff --git a/cmd/ig_test.go b/cmd/ig_test.go index c21c473..7d059ff 100644 --- a/cmd/ig_test.go +++ b/cmd/ig_test.go @@ -116,10 +116,10 @@ func TestIgSync_WritesSyncStateAndComposesRefresh(t *testing.T) { var gotINM string orig := igcatalog.DownloadBundle - igcatalog.DownloadBundle = func(baseURL, token, catalog, inm string) ([]byte, string, bool, error) { + igcatalog.DownloadBundle = func(baseURL string, auth map[string]string, catalog, inm string) ([]byte, string, bool, error) { gotINM = inm - if baseURL != "https://x.test" || token != "tok" { - t.Errorf("auth threading: url=%q token=%q", baseURL, token) + if baseURL != "https://x.test" || auth["Authorization"] != "Bearer tok" { + t.Errorf("auth threading: url=%q auth=%v", baseURL, auth) } if catalog != "payments" { t.Errorf("catalog = %q", catalog) @@ -198,7 +198,7 @@ func TestIgSync_NotModifiedIsCheapNoOp(t *testing.T) { var gotINM string orig := igcatalog.DownloadBundle - igcatalog.DownloadBundle = func(_, _, _, inm string) ([]byte, string, bool, error) { + igcatalog.DownloadBundle = func(_ string, _ map[string]string, _, inm string) ([]byte, string, bool, error) { gotINM = inm return nil, "sha256:old", true, nil } @@ -353,7 +353,7 @@ func TestIgSync_DigestMismatchFailsAndKeepsTree(t *testing.T) { tarball := makeTarGz(t, map[string]string{"graph.json": "x"}) orig := igcatalog.DownloadBundle - igcatalog.DownloadBundle = func(_, _, _, _ string) ([]byte, string, bool, error) { + igcatalog.DownloadBundle = func(_ string, _ map[string]string, _, _ string) ([]byte, string, bool, error) { return tarball, "sha256:0000thisisthewronghash0000", false, nil } defer func() { igcatalog.DownloadBundle = orig }() @@ -392,7 +392,7 @@ func TestIgSync_ReplacesExistingTreeCleanly(t *testing.T) { tarball := makeTarGz(t, map[string]string{"metadata.json": `{"catalog":"payments"}`, "NEW.txt": "new"}) etag := sha256Etag(tarball) orig := igcatalog.DownloadBundle - igcatalog.DownloadBundle = func(_, _, _, inm string) ([]byte, string, bool, error) { + igcatalog.DownloadBundle = func(_ string, _ map[string]string, _, inm string) ([]byte, string, bool, error) { if inm != "sha256:old" { t.Errorf("If-None-Match = %q; want sha256:old", inm) } @@ -442,7 +442,7 @@ func TestIgSync_GoodBundleWithMetadataAtRootSwapsIn(t *testing.T) { }) etag := sha256Etag(tarball) orig := igcatalog.DownloadBundle - igcatalog.DownloadBundle = func(_, _, _, _ string) ([]byte, string, bool, error) { + igcatalog.DownloadBundle = func(_ string, _ map[string]string, _, _ string) ([]byte, string, bool, error) { return tarball, etag, false, nil } defer func() { igcatalog.DownloadBundle = orig }() @@ -513,7 +513,7 @@ func TestIgSync_RejectsMalformedBundleAndKeepsLiveTree(t *testing.T) { tarball := makeTarGz(t, tc.files) etag := sha256Etag(tarball) orig := igcatalog.DownloadBundle - igcatalog.DownloadBundle = func(_, _, _, _ string) ([]byte, string, bool, error) { + igcatalog.DownloadBundle = func(_ string, _ map[string]string, _, _ string) ([]byte, string, bool, error) { return tarball, etag, false, nil } defer func() { igcatalog.DownloadBundle = orig }() @@ -557,13 +557,13 @@ func TestIgStatus_ComparesWithoutFetchingBundle(t *testing.T) { } origGet := igcatalog.GetCatalog - igcatalog.GetCatalog = func(_, _, name string) (*igcatalog.Catalog, error) { + igcatalog.GetCatalog = func(_ string, _ map[string]string, name string) (*igcatalog.Catalog, error) { return &igcatalog.Catalog{Name: name, Version: "sha256:server-newer"}, nil } defer func() { igcatalog.GetCatalog = origGet }() origDL := igcatalog.DownloadBundle - igcatalog.DownloadBundle = func(_, _, _, _ string) ([]byte, string, bool, error) { + igcatalog.DownloadBundle = func(_ string, _ map[string]string, _, _ string) ([]byte, string, bool, error) { t.Fatal("status must NOT download the bundle") return nil, "", false, nil } @@ -580,7 +580,7 @@ func TestIgStatus_ComparesWithoutFetchingBundle(t *testing.T) { t.Errorf("serverVer=%q localDigest=%q", serverVer, localDigest) } - igcatalog.GetCatalog = func(_, _, name string) (*igcatalog.Catalog, error) { + igcatalog.GetCatalog = func(_ string, _ map[string]string, name string) (*igcatalog.Catalog, error) { return &igcatalog.Catalog{Name: name, Version: "sha256:local"}, nil } state, _, _, err = statusOne(testActive(), "payments") @@ -618,7 +618,7 @@ func TestIgPublish_GzipsGraphAndReadsMeta(t *testing.T) { var gotGz []byte var gotGit, gotSha string orig := igcatalog.PublishMember - igcatalog.PublishMember = func(_, _, cat, mem string, gz []byte, git, sha string) error { + igcatalog.PublishMember = func(_ string, _ map[string]string, cat, mem string, gz []byte, git, sha string) error { if cat != "payments" || mem != "api" { t.Errorf("cat=%q mem=%q", cat, mem) } @@ -655,7 +655,7 @@ func TestIgClaims_OnePerLine(t *testing.T) { igClaimsGit = "https://github.com/acme/api.git" orig := igcatalog.Claims - igcatalog.Claims = func(_, _, git string) ([]string, error) { + igcatalog.Claims = func(_ string, _ map[string]string, git string) ([]string, error) { if git != "https://github.com/acme/api.git" { t.Errorf("git = %q", git) } @@ -692,7 +692,7 @@ func TestIgManifestPush_StampsGitSHA(t *testing.T) { var got igcatalog.Manifest orig := igcatalog.ManifestPush - igcatalog.ManifestPush = func(_, _, cat string, m igcatalog.Manifest) error { + igcatalog.ManifestPush = func(_ string, _ map[string]string, cat string, m igcatalog.Manifest) error { if cat != "payments" { t.Errorf("cat = %q", cat) } @@ -731,7 +731,7 @@ func TestIgManifestPull_WritesServedBytesVerbatim(t *testing.T) { igManifestOut = outFile orig := igcatalog.ManifestPull - igcatalog.ManifestPull = func(_, _, cat string) (*igcatalog.Manifest, error) { + igcatalog.ManifestPull = func(_ string, _ map[string]string, cat string) (*igcatalog.Manifest, error) { if cat != "payments" { t.Errorf("cat = %q", cat) } diff --git a/cmd/login.go b/cmd/login.go index f9c1ef5..6d82ef8 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -45,6 +45,8 @@ var ( loginTimeout time.Duration loginRaptorProfile string loginDryRun bool + loginFacets bool + loginFacetsProfile string ) // browserLoginFn and postAuthSetup are package-level seams so tests can @@ -76,6 +78,10 @@ func init() { "pair this praxis profile with a raptor profile (a ~/.facets/credentials section); 'praxis status' then reports raptor via that profile and AI hosts prefix raptor commands with FACETS_PROFILE=") loginCmd.Flags().BoolVar(&loginDryRun, "dry-run", false, "report what login would do (profile, URL reachability, browser-or-reuse, skill effect) and exit — no browser, no API key, no credential or skill changes") + loginCmd.Flags().BoolVar(&loginFacets, "facets", false, + "authenticate with a control-plane PAT from ~/.facets/credentials (HTTP Basic), instead of a Praxis API key") + loginCmd.Flags().StringVar(&loginFacetsProfile, "facets-profile", credentials.DefaultProfileName, + "profile name inside ~/.facets/credentials to read when --facets is set") rootCmd.AddCommand(loginCmd) } @@ -120,6 +126,13 @@ installed skills — then exits without changing anything.`, if profileName == "" { profileName = credentials.DefaultProfileName } + + // --facets: authenticate with a control-plane PAT from + // ~/.facets/credentials sent as HTTP Basic, not a Praxis API key. + if loginFacets { + return facetsLogin(out, asJSON, profileName, loginURL, loginFacetsProfile, loginLocal) + } + baseURL, err := resolveLoginURL(profileName, loginURL) if err != nil { render.PrintError(out, asJSON, err.Error(), @@ -214,7 +227,7 @@ func tryReuseStoredToken(out io.Writer, asJSON bool, profileName, baseURL string return false, nil } - user, err := fetchAuthMe(baseURL, prof.Token) + user, err := fetchAuthMe(baseURL, prof.Auth()) if err != nil { if errors.Is(err, errTokenRejected) { // The server gave a verdict: this token is dead. Falling back to @@ -241,11 +254,13 @@ func tryReuseStoredToken(out io.Writer, asJSON bool, profileName, baseURL string return true, err } // Persist the canonical (post-redirect) host so a stale stored URL - // self-heals on the next login (issue #19-A). + // self-heals on the next login (issue #19-A). Reuse the stored profile + // otherwise — notably its Username/AuthMode, so a facets profile's + // Basic header keeps working across reuse. if user.canonicalBaseURL != "" { - baseURL = user.canonicalBaseURL + prof.URL = user.canonicalBaseURL } - return true, persistAndSetup(out, asJSON, profileName, baseURL, prof.Token, user.Email, local) + return true, persistAndSetup(out, asJSON, profileName, prof, user.Email, local) } // browserSessionPollLogin opens the browser to the api-keys page with a @@ -404,12 +419,64 @@ func suggestedKeyName() string { return "praxis-cli-" + hex.EncodeToString(b)[:5] } +// facetsLogin authenticates using a control-plane PAT read from +// ~/.facets/credentials, sent as Bearer plus an X-Facets-Username identity +// header. The agent server accepts this in facets auth mode. Verification +// and every post-auth HTTP call go through the profile's Auth() headers. +func facetsLogin(out io.Writer, asJSON bool, profileName, flagURL, facetsProfile string, local bool) error { + // URL comes from --url or the existing profile — never the built-in + // askpraxis.ai default (that's a Praxis SaaS host, not a facets agent). + baseURL := normalizeBaseURL(flagURL) + if baseURL == "" { + store, _ := credentials.Load() + if p, ok := store[profileName]; ok && p.URL != "" { + baseURL = normalizeBaseURL(p.URL) + } + } + if baseURL == "" { + err := fmt.Errorf("no agent server URL for facets login") + render.PrintError(out, asJSON, err.Error(), + "pass --url ", + exitcode.Usage) + return err + } + + _, username, token, err := credentials.ReadFacetsProfile(facetsProfile) + if err != nil { + render.PrintError(out, asJSON, + fmt.Sprintf("could not read ~/.facets/credentials: %v", err), + "run `raptor login` (or create a token in the Facets UI), or use plain `praxis login`", + exitcode.Auth) + return err + } + + prof := credentials.Profile{URL: baseURL, Username: username, Token: token, AuthMode: credentials.AuthModeBasic} + user, err := fetchAuthMe(baseURL, prof.Auth()) + if err != nil { + render.PrintError(out, asJSON, + fmt.Sprintf("control-plane PAT validation failed: %v", err), + "the PAT may be invalid/expired, or the --url isn't a facets-mode agent server", + exitcode.Auth) + os.Exit(exitcode.Auth) + } + if user.canonicalBaseURL != "" { + prof.URL = user.canonicalBaseURL + } + display := user.Email + if display == "" { + display = username + } + return persistAndSetup(out, asJSON, profileName, prof, display, local) +} + // saveAndVerifyToken verifies a freshly-obtained token (from --token or // the browser flow) and persists it. A verification failure here is fatal // — the user explicitly supplied this key, so there's no graceful // fallback to attempt. func saveAndVerifyToken(out io.Writer, asJSON bool, profileName, baseURL, token string, local bool) error { - user, err := fetchAuthMe(baseURL, token) + // --token / browser flow always yields a Praxis API key → Bearer. + // Route through Auth() so "Bearer " is built in exactly one place. + user, err := fetchAuthMe(baseURL, credentials.Profile{Token: token}.Auth()) if err != nil { render.PrintError(out, asJSON, fmt.Sprintf("token validation failed: %v", err), @@ -423,7 +490,8 @@ func saveAndVerifyToken(out io.Writer, asJSON bool, profileName, baseURL, token if user.canonicalBaseURL != "" { baseURL = user.canonicalBaseURL } - return persistAndSetup(out, asJSON, profileName, baseURL, token, user.Email, local) + prof := credentials.Profile{URL: baseURL, Username: user.Email, Token: token} + return persistAndSetup(out, asJSON, profileName, prof, user.Email, local) } // persistAndSetup saves the verified token under profileName, sets the @@ -441,14 +509,14 @@ func saveAndVerifyToken(out io.Writer, asJSON bool, profileName, baseURL, token // accidentally scope the install. // - local: write /.praxis/config.json and install project-scoped, // leaving the global pointer untouched. -func persistAndSetup(out io.Writer, asJSON bool, profileName, baseURL, token, email string, local bool) error { - raptorProfile := resolveRaptorPairing(profileName, baseURL) - prof := credentials.Profile{ - URL: baseURL, - Username: email, - Token: token, - RaptorProfile: raptorProfile, - } +// +// persistAndSetup takes the fully-built profile to save (its URL/Username/ +// Token/AuthMode are authoritative — e.g. a facets profile keeps its +// control-plane username so Auth() can rebuild the X-Facets-Username header +// on reuse) and a displayName used only for the human/JSON "logged in as" line. +func persistAndSetup(out io.Writer, asJSON bool, profileName string, prof credentials.Profile, displayName string, local bool) error { + baseURL := prof.URL + prof.RaptorProfile = resolveRaptorPairing(profileName, baseURL) if err := credentials.Put(profileName, prof); err != nil { return fmt.Errorf("save credentials: %w", err) } @@ -480,14 +548,15 @@ func persistAndSetup(out io.Writer, asJSON bool, profileName, baseURL, token, em } // Post-auth: install meta-skill, wipe previous org skills, install - // this profile's catalog, refresh the MCP tools snapshot. - state := postAuthSetup(out, asJSON, baseURL, token) + // this profile's catalog, refresh the MCP tools snapshot. The HTTP + // calls use the profile's full auth headers (Bearer + X-Facets-Username). + state := postAuthSetup(out, asJSON, baseURL, prof.Auth()) if asJSON { payload := map[string]any{ "ok": true, "profile": profileName, - "username": email, + "username": displayName, "url": baseURL, "scope": scopeLabel(local), "meta_skill": state.metaSkill, @@ -501,16 +570,16 @@ func persistAndSetup(out io.Writer, asJSON bool, profileName, baseURL, token, em if projectRoot != "" { payload["project_root"] = projectRoot } - if raptorProfile != "" { - payload["raptor_profile"] = raptorProfile + if prof.RaptorProfile != "" { + payload["raptor_profile"] = prof.RaptorProfile } return render.JSON(out, payload) } if local { - fmt.Fprintf(out, "\n✓ Logged in as %s and pinned profile %q to %s\n", email, profileName, projectRoot) + fmt.Fprintf(out, "\n✓ Logged in as %s and pinned profile %q to %s\n", displayName, profileName, projectRoot) return nil } - fmt.Fprintf(out, "\n✓ Logged in as %s (profile: %s, url: %s)\n", email, profileName, baseURL) + fmt.Fprintf(out, "\n✓ Logged in as %s (profile: %s, url: %s)\n", displayName, profileName, baseURL) return nil } @@ -567,13 +636,15 @@ type authMeResponse struct { var errTokenRejected = errors.New("token rejected by server") // fetchAuthMe is the seam: tests swap it to avoid hitting a real server. -var fetchAuthMe = func(baseURL, token string) (*authMeResponse, error) { +var fetchAuthMe = func(baseURL string, auth map[string]string) (*authMeResponse, error) { client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequest("GET", baseURL+"/ai-api/auth/me", nil) if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+token) + for k, v := range auth { + req.Header.Set(k, v) + } resp, err := client.Do(req) if err != nil { return nil, err diff --git a/cmd/login_canonical_test.go b/cmd/login_canonical_test.go index 4e387c7..9ffd40e 100644 --- a/cmd/login_canonical_test.go +++ b/cmd/login_canonical_test.go @@ -42,7 +42,7 @@ func canonicalPair(t *testing.T) (stale, final *httptest.Server) { func TestFetchAuthMe_ReportsCanonicalHostAfterRedirect(t *testing.T) { stale, final := canonicalPair(t) - me, err := fetchAuthMe(stale.URL, "sk_test_T") + me, err := fetchAuthMe(stale.URL, bearer("sk_test_T")) if err != nil { t.Fatalf("fetchAuthMe: %v", err) } @@ -57,7 +57,7 @@ func TestFetchAuthMe_ReportsCanonicalHostAfterRedirect(t *testing.T) { func TestFetchAuthMe_NoRedirectKeepsBaseURL(t *testing.T) { _, final := canonicalPair(t) - me, err := fetchAuthMe(final.URL, "sk_test_T") + me, err := fetchAuthMe(final.URL, bearer("sk_test_T")) if err != nil { t.Fatalf("fetchAuthMe: %v", err) } @@ -92,7 +92,7 @@ func TestFetchAuthMe_ClassifiesStatus(t *testing.T) { })) t.Cleanup(srv.Close) - _, err := fetchAuthMe(srv.URL, "sk_test_T") + _, err := fetchAuthMe(srv.URL, bearer("sk_test_T")) if err == nil { t.Fatalf("fetchAuthMe(HTTP %d) returned nil error", tt.status) } diff --git a/cmd/login_dryrun.go b/cmd/login_dryrun.go index 238f913..61dc93e 100644 --- a/cmd/login_dryrun.go +++ b/cmd/login_dryrun.go @@ -33,17 +33,18 @@ func runLoginDryRun(out io.Writer, asJSON bool, profileName, baseURL string, loc active, _ = credentials.ResolveActiveGlobal() } - probeToken, tokenSource := "", "none" + var probeAuth map[string]string + tokenSource := "none" switch { case loginToken != "": - probeToken, tokenSource = loginToken, "supplied" + probeAuth, tokenSource = credentials.Profile{Token: loginToken}.Auth(), "supplied" case exists && prof.Token != "" && prof.URL == baseURL: - probeToken, tokenSource = prof.Token, "stored" + probeAuth, tokenSource = prof.Auth(), "stored" } reachable := true tokenStatus, action := tokenSource, "browser" - _, err := fetchAuthMe(baseURL, probeToken) + _, err := fetchAuthMe(baseURL, probeAuth) switch { case err == nil: switch tokenSource { diff --git a/cmd/login_dryrun_test.go b/cmd/login_dryrun_test.go index f17789e..cb84c8d 100644 --- a/cmd/login_dryrun_test.go +++ b/cmd/login_dryrun_test.go @@ -35,7 +35,7 @@ func TestLoginDryRun_StoredValidToken_ReportsReuse(t *testing.T) { seedProfile(t, "default", "https://stored.test", "tok") browser := stubBrowserLogin(t) setup := stubPostAuth(t) - stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + stubAuthMe(t, func(_ string, _ map[string]string) (*authMeResponse, error) { return &authMeResponse{Email: "u@x"}, nil }) @@ -64,7 +64,7 @@ func TestLoginDryRun_HasNoSideEffects(t *testing.T) { seedProfile(t, "default", "https://stored.test", "tok") stubBrowserLogin(t) stubPostAuth(t) - stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + stubAuthMe(t, func(_ string, _ map[string]string) (*authMeResponse, error) { return &authMeResponse{Email: "u@x"}, nil }) @@ -141,7 +141,7 @@ func TestLoginDryRun_TokenAndReachabilityMatrix(t *testing.T) { stubBrowserLogin(t) stubPostAuth(t) exit := stubOsExit(t) - stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + stubAuthMe(t, func(_ string, _ map[string]string) (*authMeResponse, error) { if tt.authErr != nil { return nil, tt.authErr } @@ -178,7 +178,7 @@ func TestLoginDryRun_ProfileSwitchSkillsEffect(t *testing.T) { } stubBrowserLogin(t) stubPostAuth(t) - stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + stubAuthMe(t, func(_ string, _ map[string]string) (*authMeResponse, error) { return &authMeResponse{Email: "u@x"}, nil }) diff --git a/cmd/login_local_test.go b/cmd/login_local_test.go index 78e1a95..bb1c349 100644 --- a/cmd/login_local_test.go +++ b/cmd/login_local_test.go @@ -27,7 +27,7 @@ func TestLogin_Local_PinsProjectAndLeavesGlobalAlone(t *testing.T) { } // Reuse path: stored token validates without a browser. - stubAuthMe(t, func(baseURL, token string) (*authMeResponse, error) { + stubAuthMe(t, func(baseURL string, auth map[string]string) (*authMeResponse, error) { return &authMeResponse{Email: "u@x", canonicalBaseURL: baseURL}, nil }) stubPostAuth(t) // record-only; we're testing pointer/scoping, not install @@ -65,7 +65,7 @@ func TestLogin_Local_OutsideHome_Errors(t *testing.T) { resetLoginFlags(t) seedProfile(t, "aurva", "https://aurva.test", "tok") - stubAuthMe(t, func(baseURL, token string) (*authMeResponse, error) { + stubAuthMe(t, func(baseURL string, auth map[string]string) (*authMeResponse, error) { return &authMeResponse{Email: "u@x", canonicalBaseURL: baseURL}, nil }) stubPostAuth(t) diff --git a/cmd/login_raptor_test.go b/cmd/login_raptor_test.go index 4cc88e5..b70f8db 100644 --- a/cmd/login_raptor_test.go +++ b/cmd/login_raptor_test.go @@ -28,7 +28,7 @@ func TestLoginRunE_RaptorProfileFlagStoresPairing(t *testing.T) { seedProfile(t, "default", "https://root.test", "tok") seedRaptorCreds(t, "[root]\ncontrol_plane_url = https://root.test\nusername = u@x\ntoken = pat\n") stubPostAuth(t) - stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + stubAuthMe(t, func(_ string, _ map[string]string) (*authMeResponse, error) { return &authMeResponse{Email: "u@x"}, nil }) @@ -54,7 +54,7 @@ func TestLoginRunE_RaptorPairingPreservedOnRelogin(t *testing.T) { t.Fatal(err) } stubPostAuth(t) - stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + stubAuthMe(t, func(_ string, _ map[string]string) (*authMeResponse, error) { return &authMeResponse{Email: "u@x"}, nil }) diff --git a/cmd/login_reuse_test.go b/cmd/login_reuse_test.go index 4ec15ed..2513568 100644 --- a/cmd/login_reuse_test.go +++ b/cmd/login_reuse_test.go @@ -32,7 +32,7 @@ func seedProfile(t *testing.T, name, url, token string) { } // stubAuthMe swaps fetchAuthMe and restores it at test end. -func stubAuthMe(t *testing.T, fn func(baseURL, token string) (*authMeResponse, error)) { +func stubAuthMe(t *testing.T, fn func(baseURL string, auth map[string]string) (*authMeResponse, error)) { t.Helper() orig := fetchAuthMe fetchAuthMe = fn @@ -44,7 +44,7 @@ func stubPostAuth(t *testing.T) *bool { t.Helper() called := false orig := postAuthSetup - postAuthSetup = func(out io.Writer, asJSON bool, baseURL, token string) postAuthState { + postAuthSetup = func(out io.Writer, asJSON bool, baseURL string, auth map[string]string) postAuthState { called = true return postAuthState{} } @@ -229,10 +229,11 @@ func TestTryReuseStoredToken(t *testing.T) { post := stubPostAuth(t) exitCode := stubOsExit(t) authMeCalled := false - stubAuthMe(t, func(baseURL, token string) (*authMeResponse, error) { + stubAuthMe(t, func(baseURL string, auth map[string]string) (*authMeResponse, error) { authMeCalled = true - if baseURL != tt.targetURL || token != tt.storedToken { - t.Errorf("fetchAuthMe(%q,%q), want (%q,%q)", baseURL, token, tt.targetURL, tt.storedToken) + wantAuth := "Bearer " + tt.storedToken + if baseURL != tt.targetURL || auth["Authorization"] != wantAuth { + t.Errorf("fetchAuthMe(%q,%v), want (%q,%q)", baseURL, auth, tt.targetURL, wantAuth) } if tt.authMeErr != nil { return nil, tt.authMeErr @@ -299,7 +300,7 @@ func TestLoginRunE_ValidStoredTokenSkipsBrowser(t *testing.T) { seedProfile(t, "default", "https://stored.test", "tok") browser := stubBrowserLogin(t) stubPostAuth(t) - stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + stubAuthMe(t, func(_ string, _ map[string]string) (*authMeResponse, error) { return &authMeResponse{Email: "u@x"}, nil }) if _, err := runLoginRunE(t); err != nil { @@ -317,7 +318,7 @@ func TestLoginRunE_TransientErrorDoesNotOpenBrowser(t *testing.T) { browser := stubBrowserLogin(t) stubPostAuth(t) exitCode := stubOsExit(t) // production exits here; stub keeps the test alive - stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + stubAuthMe(t, func(_ string, _ map[string]string) (*authMeResponse, error) { return nil, context.DeadlineExceeded // server unreachable, not a rejection }) _, err := runLoginRunE(t) @@ -346,7 +347,7 @@ func TestLoginRunE_RejectedTokenOpensBrowser(t *testing.T) { seedProfile(t, "default", "https://stored.test", "expired") browser := stubBrowserLogin(t) stubPostAuth(t) - stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + stubAuthMe(t, func(_ string, _ map[string]string) (*authMeResponse, error) { return nil, fmt.Errorf("%w (HTTP 401)", errTokenRejected) }) if _, err := runLoginRunE(t); err != nil { @@ -364,7 +365,7 @@ func TestLoginRunE_ForceOpensBrowser(t *testing.T) { loginForce = true browser := stubBrowserLogin(t) stubPostAuth(t) - stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + stubAuthMe(t, func(_ string, _ map[string]string) (*authMeResponse, error) { t.Fatal("--force must not verify/reuse the stored token") return nil, nil }) @@ -396,7 +397,7 @@ func TestLoginRunE_URLRetargetOpensBrowser(t *testing.T) { loginURL = "https://other.test" // re-target away from the stored URL browser := stubBrowserLogin(t) stubPostAuth(t) - stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + stubAuthMe(t, func(_ string, _ map[string]string) (*authMeResponse, error) { t.Fatal("token must not be reused when --url re-targets the profile") return nil, nil }) diff --git a/cmd/login_setup.go b/cmd/login_setup.go index a8127de..86c0653 100644 --- a/cmd/login_setup.go +++ b/cmd/login_setup.go @@ -100,7 +100,7 @@ type agentInstallationLite struct { // active root's receipt + that root's host dirs — so it runs unconditionally // and stays safe in both scopes (a project refresh can't delete the user's // global skills, and vice versa). -func runPostAuthSetup(out io.Writer, asJSON bool, baseURL, token string) postAuthState { +func runPostAuthSetup(out io.Writer, asJSON bool, baseURL string, auth map[string]string) postAuthState { state := postAuthState{} hosts := detectHarnesses() @@ -151,7 +151,7 @@ func runPostAuthSetup(out io.Writer, asJSON bool, baseURL, token string) postAut // re-run on a flaky network without leaving the user empty-handed. // Host-dependent (no point fetching if we can't install). if !noHosts { - skills, fetchErr := fetchCatalog(baseURL, token) + skills, fetchErr := fetchCatalog(baseURL, auth) switch { case fetchErr != nil: if !asJSON { @@ -184,7 +184,7 @@ func runPostAuthSetup(out io.Writer, asJSON bool, baseURL, token string) postAut // Step 3.5: agent catalog. Fetch first, then swap — same fail-safe as // skills. A transient network error leaves existing agents on disk. if !noHosts { - agents, fetchErr := fetchAgents(baseURL, token) + agents, fetchErr := fetchAgents(baseURL, auth) switch { case fetchErr != nil: if !asJSON { @@ -250,7 +250,7 @@ func runPostAuthSetup(out io.Writer, asJSON bool, baseURL, token string) postAut // Step 4: refresh MCP tools snapshot. Host-independent — useful even // without an AI host installed (manifest is consumed by other tools // and by future `praxis mcp` calls). - state.snapshotPath, state.snapshotWarning = refreshMCPSnapshot(out, asJSON, baseURL, token) + state.snapshotPath, state.snapshotWarning = refreshMCPSnapshot(out, asJSON, baseURL, auth) // Step 5: wire the use-ig cwd hooks (claude-code only). Never fatal — a // failed wire must not fail login; skills still installed above. @@ -458,8 +458,8 @@ func installFetchedCatalog(out io.Writer, asJSON bool, skills []skillcatalog.Ski // could not be written (e.g. server too old to expose /v1/mcp/manifest). // Either way the parent flow continues — a missing snapshot just means // AI hosts fall back to live `praxis mcp` calls. -func refreshMCPSnapshot(out io.Writer, asJSON bool, baseURL, token string) (string, string) { - raw, err := mcpmanifest.Fetch(baseURL, token, mcpmanifest.DefaultTimeout) +func refreshMCPSnapshot(out io.Writer, asJSON bool, baseURL string, auth map[string]string) (string, string) { + raw, err := mcpmanifest.Fetch(baseURL, auth, mcpmanifest.DefaultTimeout) if err != nil { if !asJSON { fmt.Fprintf(out, "\nMCP tool snapshot skipped: %v\n", err) diff --git a/cmd/login_setup_test.go b/cmd/login_setup_test.go index f314f8c..4ef5841 100644 --- a/cmd/login_setup_test.go +++ b/cmd/login_setup_test.go @@ -26,7 +26,7 @@ import ( func stubMCPManifestFetch(t *testing.T) { t.Helper() orig := mcpmanifest.Fetch - mcpmanifest.Fetch = func(_ string, _ string, _ time.Duration) ([]byte, error) { + mcpmanifest.Fetch = func(_ string, _ map[string]string, _ time.Duration) ([]byte, error) { return []byte(`{"mcps":{}}`), nil } t.Cleanup(func() { mcpmanifest.Fetch = orig }) @@ -58,7 +58,7 @@ func TestRunPostAuthSetup_CatalogFetchFailure_PreservesExisting(t *testing.T) { installSkill = func(name string, h []harness.Harness) ([]skillinstall.Installation, error) { return []skillinstall.Installation{{SkillName: name, Harness: "claude-code", Path: "/x"}}, nil } - fetchCatalog = func(baseURL, token string) ([]skillcatalog.Skill, error) { + fetchCatalog = func(baseURL string, auth map[string]string) ([]skillcatalog.Skill, error) { return nil, errors.New("simulated network failure") } t.Cleanup(func() { @@ -66,7 +66,7 @@ func TestRunPostAuthSetup_CatalogFetchFailure_PreservesExisting(t *testing.T) { }) var buf bytes.Buffer - state := runPostAuthSetup(&buf, false, "https://x.test", "tok") + state := runPostAuthSetup(&buf, false, "https://x.test", bearer("tok")) // Existing praxis-* installs must still be in the receipt — the // fetch failure must not have triggered UninstallByPrefix. @@ -116,7 +116,7 @@ func TestRunPostAuthSetup_NoHosts_StillRefreshesSnapshot(t *testing.T) { t.Cleanup(func() { detectHarnesses = origDetect }) var buf bytes.Buffer - state := runPostAuthSetup(&buf, false, "https://x.test", "tok") + state := runPostAuthSetup(&buf, false, "https://x.test", bearer("tok")) // Friendly message but flow continues. if !strings.Contains(buf.String(), "No supported AI hosts") { @@ -170,15 +170,15 @@ func TestRunPostAuthSetup_ProjectScope_WritesIntoProjectDir(t *testing.T) { t.Cleanup(func() { detectHarnesses = origDetect }) origFetchSk := fetchCatalog - fetchCatalog = func(_, _ string) ([]skillcatalog.Skill, error) { return nil, nil } + fetchCatalog = func(_ string, _ map[string]string) ([]skillcatalog.Skill, error) { return nil, nil } t.Cleanup(func() { fetchCatalog = origFetchSk }) origFetchAg := fetchAgents - fetchAgents = func(_, _ string) ([]agentcatalog.Agent, error) { return nil, nil } + fetchAgents = func(_ string, _ map[string]string) ([]agentcatalog.Agent, error) { return nil, nil } t.Cleanup(func() { fetchAgents = origFetchAg }) var buf bytes.Buffer - state := runPostAuthSetup(&buf, false, "http://x", "tok") + state := runPostAuthSetup(&buf, false, "http://x", bearer("tok")) if !state.projectScoped { t.Errorf("expected project scope when active root is a project root") } @@ -230,15 +230,15 @@ func TestRunPostAuthSetup_ProjectScope_DoesNotWipeUserLevelInstall(t *testing.T) t.Cleanup(func() { detectHarnesses = origDetect }) origFetchSk := fetchCatalog - fetchCatalog = func(_, _ string) ([]skillcatalog.Skill, error) { return nil, nil } + fetchCatalog = func(_ string, _ map[string]string) ([]skillcatalog.Skill, error) { return nil, nil } t.Cleanup(func() { fetchCatalog = origFetchSk }) origFetchAg := fetchAgents - fetchAgents = func(_, _ string) ([]agentcatalog.Agent, error) { return nil, nil } + fetchAgents = func(_ string, _ map[string]string) ([]agentcatalog.Agent, error) { return nil, nil } t.Cleanup(func() { fetchAgents = origFetchAg }) var buf bytes.Buffer - runPostAuthSetup(&buf, false, "http://x", "tok") + runPostAuthSetup(&buf, false, "http://x", bearer("tok")) if _, err := os.Stat(seededPath); err != nil { t.Errorf("user-level skill must survive a project-scoped refresh, but %s is gone: %v", seededPath, err) @@ -263,18 +263,18 @@ func TestRunPostAuthSetupFetchesAndInstallsAgents(t *testing.T) { origFetchSk := fetchCatalog defer func() { fetchCatalog = origFetchSk }() - fetchCatalog = func(_, _ string) ([]skillcatalog.Skill, error) { return nil, nil } + fetchCatalog = func(_ string, _ map[string]string) ([]skillcatalog.Skill, error) { return nil, nil } origFetchAg := fetchAgents defer func() { fetchAgents = origFetchAg }() - fetchAgents = func(_, _ string) ([]agentcatalog.Agent, error) { + fetchAgents = func(_ string, _ map[string]string) ([]agentcatalog.Agent, error) { return []agentcatalog.Agent{ {Name: "alpha", Description: "a", SystemPrompt: "b", IsActive: true, Kind: agentcatalog.KindAgent}, }, nil } var buf bytes.Buffer - state := runPostAuthSetup(&buf, false, "http://x", "tok") + state := runPostAuthSetup(&buf, false, "http://x", bearer("tok")) if len(state.agents) != 1 { t.Fatalf("want 1 agent installed, got %d", len(state.agents)) } @@ -323,16 +323,16 @@ func TestRunPostAuthSetupAgentFetchFailureLeavesExistingInPlace(t *testing.T) { origFetchSk := fetchCatalog defer func() { fetchCatalog = origFetchSk }() - fetchCatalog = func(_, _ string) ([]skillcatalog.Skill, error) { return nil, nil } + fetchCatalog = func(_ string, _ map[string]string) ([]skillcatalog.Skill, error) { return nil, nil } origFetchAg := fetchAgents defer func() { fetchAgents = origFetchAg }() - fetchAgents = func(_, _ string) ([]agentcatalog.Agent, error) { + fetchAgents = func(_ string, _ map[string]string) ([]agentcatalog.Agent, error) { return nil, fmt.Errorf("simulated network failure") } var buf bytes.Buffer - state := runPostAuthSetup(&buf, false, "http://x", "tok") + state := runPostAuthSetup(&buf, false, "http://x", bearer("tok")) // state.agents reports what THIS invocation installed; with a fetch // failure that should be empty — but the seeded agent must remain @@ -451,14 +451,14 @@ func TestRunPostAuthSetup_EndToEnd_NoGeminiConflict(t *testing.T) { // Stub only the network seams. Catalog returns one real single-file skill; // agents empty. Install/detection/migration all run for real. origFetch, origAgents := fetchCatalog, fetchAgents - fetchCatalog = func(_, _ string) ([]skillcatalog.Skill, error) { + fetchCatalog = func(_ string, _ map[string]string) ([]skillcatalog.Skill, error) { return []skillcatalog.Skill{{Name: "cloudops", Content: "---\nname: cloudops\n---\nbody"}}, nil } - fetchAgents = func(_, _ string) ([]agentcatalog.Agent, error) { return nil, nil } + fetchAgents = func(_ string, _ map[string]string) ([]agentcatalog.Agent, error) { return nil, nil } t.Cleanup(func() { fetchCatalog, fetchAgents = origFetch, origAgents }) var buf bytes.Buffer - runPostAuthSetup(&buf, false, "https://x.test", "tok") + runPostAuthSetup(&buf, false, "https://x.test", bearer("tok")) // 1. The catalog skill and both metas installed at the shared alias. for _, name := range []string{"praxis-cloudops", "praxis", "praxis-memory"} { diff --git a/cmd/mcp.go b/cmd/mcp.go index 61e36c5..05dcd28 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -94,7 +94,7 @@ Examples: os.Exit(exitcode.Usage) } - resp, status, err := callMCP(active.Profile.URL, active.Profile.Token, mcpName, fnName, body, mcpTimeout) + resp, status, err := callMCP(active.Profile.URL, active.Profile.Auth(), mcpName, fnName, body, mcpTimeout) if err != nil { render.PrintError(out, asJSON, fmt.Sprintf("network error: %v", err), @@ -209,7 +209,7 @@ func buildMCPBody(argFlags []string, bodyFlag string, stdin io.Reader) ([]byte, // wants the structured detail they can pipe `praxis mcp --json` through // jq instead. func runManifestList(out io.Writer, asJSON bool, active credentials.Active) error { - raw, err := mcpmanifest.Fetch(active.Profile.URL, active.Profile.Token, mcpTimeout) + raw, err := mcpmanifest.Fetch(active.Profile.URL, active.Profile.Auth(), mcpTimeout) if err != nil { // Auth-failure shape from Fetch: "manifest fetch returned HTTP 401: ..." errStr := err.Error() @@ -311,7 +311,7 @@ func sortStrings(s []string) { } // callMCP is the HTTP seam — tests swap it to avoid hitting the network. -var callMCP = func(baseURL, token, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { +var callMCP = func(baseURL string, auth map[string]string, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { if baseURL == "" { return nil, 0, errors.New("profile has no URL set") } @@ -330,12 +330,14 @@ var callMCP = func(baseURL, token, mcp, fn string, body []byte, timeout time.Dur orig := via[0] req.Method = orig.Method req.Header = orig.Header.Clone() - // Never leak the bearer token to a foreign domain: mirror - // Go's own sensitive-header rule and forward Authorization - // only when the redirect target is the original host or a - // subdomain of it (apex → www stays covered). + // Never leak the bearer token or the facets identity header to + // a foreign domain: mirror Go's own sensitive-header rule and + // forward Authorization / X-Facets-Username only when the + // redirect target is the original host or a subdomain of it + // (apex → www stays covered). if !isDomainOrSubdomain(req.URL.Hostname(), orig.URL.Hostname()) { req.Header.Del("Authorization") + req.Header.Del("X-Facets-Username") } if orig.GetBody != nil { b, err := orig.GetBody() @@ -353,7 +355,9 @@ var callMCP = func(baseURL, token, mcp, fn string, body []byte, timeout time.Dur if err != nil { return nil, 0, err } - req.Header.Set("Authorization", "Bearer "+token) + for k, v := range auth { + req.Header.Set(k, v) + } req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go index 41ee7ce..616b625 100644 --- a/cmd/mcp_test.go +++ b/cmd/mcp_test.go @@ -199,7 +199,7 @@ func TestCallMCP_PreservesPOSTAcrossRedirect(t *testing.T) { defer srv.Close() body := []byte(`{"command":"get projects"}`) - resp, status, err := callMCP(srv.URL, "sk_test_T", "raptor_cli", "run_raptor_cli", body, 5*time.Second) + resp, status, err := callMCP(srv.URL, bearer("sk_test_T"), "raptor_cli", "run_raptor_cli", body, 5*time.Second) if err != nil { t.Fatalf("callMCP error: %v", err) } @@ -225,10 +225,11 @@ func TestCallMCP_PreservesPOSTAcrossRedirect(t *testing.T) { // "foreign" host (localhost — same loopback, different hostname), so the // token must be stripped while method and body still survive. func TestCallMCP_DropsAuthOnCrossDomainRedirect(t *testing.T) { - var gotMethod, gotBody, gotAuth string + var gotMethod, gotBody, gotAuth, gotUser string foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method gotAuth = r.Header.Get("Authorization") + gotUser = r.Header.Get("X-Facets-Username") b, _ := io.ReadAll(r.Body) gotBody = string(b) w.WriteHeader(http.StatusOK) @@ -246,7 +247,10 @@ func TestCallMCP_DropsAuthOnCrossDomainRedirect(t *testing.T) { defer origin.Close() body := []byte(`{"command":"get projects"}`) - _, status, err := callMCP(origin.URL, "sk_test_SECRET", "raptor_cli", "run_raptor_cli", body, 5*time.Second) + // Facets-mode auth: both the Bearer token AND the X-Facets-Username + // identity header must be stripped on a cross-domain redirect. + auth := map[string]string{"Authorization": "Bearer sk_test_SECRET", "X-Facets-Username": "u@corp"} + _, status, err := callMCP(origin.URL, auth, "raptor_cli", "run_raptor_cli", body, 5*time.Second) if err != nil { t.Fatalf("callMCP error: %v", err) } @@ -256,6 +260,9 @@ func TestCallMCP_DropsAuthOnCrossDomainRedirect(t *testing.T) { if gotAuth != "" { t.Errorf("Authorization leaked across domains: got %q, want empty", gotAuth) } + if gotUser != "" { + t.Errorf("X-Facets-Username leaked across domains: got %q, want empty", gotUser) + } if gotMethod != http.MethodPost { t.Errorf("method after redirect = %q, want POST", gotMethod) } @@ -433,7 +440,7 @@ func TestMcpCmd_JsonOutputWiring(t *testing.T) { seedDefaultProfile(t) orig := callMCP - callMCP = func(_, _, _, _ string, _ []byte, _ time.Duration) ([]byte, int, error) { + callMCP = func(_ string, _ map[string]string, _, _ string, _ []byte, _ time.Duration) ([]byte, int, error) { return []byte(envelope), http.StatusOK, nil } defer func() { callMCP = orig }() @@ -467,9 +474,9 @@ func TestMcpCmd_HappyPath(t *testing.T) { var capturedURL, capturedToken string var capturedBody []byte orig := callMCP - callMCP = func(baseURL, token, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { + callMCP = func(baseURL string, auth map[string]string, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) { capturedURL = baseURL + "/ai-api/v1/mcp/" + mcp + "/" + fn - capturedToken = token + capturedToken = auth["Authorization"] capturedBody = body return []byte(`{"integrations":[{"name":"aws-prod"}]}`), http.StatusOK, nil } @@ -486,8 +493,8 @@ func TestMcpCmd_HappyPath(t *testing.T) { if !strings.Contains(capturedURL, "/ai-api/v1/mcp/cloud_cli/list_cloud_integrations") { t.Errorf("URL = %q", capturedURL) } - if capturedToken != "sk_test_T" { - t.Errorf("token = %q", capturedToken) + if capturedToken != "Bearer sk_test_T" { + t.Errorf("auth = %q", capturedToken) } if !strings.Contains(string(capturedBody), `"region"`) { t.Errorf("body missing region: %s", capturedBody) @@ -548,7 +555,7 @@ func TestMcpCmd_NoArgs_JsonPassthrough(t *testing.T) { manifest := []byte(`{"mcps":{"cloud_cli":{}}}`) orig := mcpmanifest.Fetch - mcpmanifest.Fetch = func(_, _ string, _ time.Duration) ([]byte, error) { + mcpmanifest.Fetch = func(_ string, _ map[string]string, _ time.Duration) ([]byte, error) { return manifest, nil } defer func() { mcpmanifest.Fetch = orig }() diff --git a/cmd/memory.go b/cmd/memory.go index db9e018..d450a4d 100644 --- a/cmd/memory.go +++ b/cmd/memory.go @@ -155,7 +155,7 @@ var memoryRecallCmd = &cobra.Command{ active := activeOrAuthExit(out) query := strings.Join(args, " ") - results, err := memory.Recall(active.Profile.URL, active.Profile.Token, memory.RecallRequest{ + results, err := memory.Recall(active.Profile.URL, active.Profile.Auth(), memory.RecallRequest{ Query: query, Limit: memoryRecallLimit, }) @@ -196,7 +196,7 @@ var memoryListCmd = &cobra.Command{ params.Tags = splitCSV(memoryListTagsCSV) } - results, err := memory.List(active.Profile.URL, active.Profile.Token, params) + results, err := memory.List(active.Profile.URL, active.Profile.Auth(), params) if err != nil { return reportHTTPErr(out, active.Name, err) } @@ -267,7 +267,7 @@ var memoryAddCmd = &cobra.Command{ req.Tags = splitCSV(memoryAddTagsCSV) } - m, err := memory.Create(active.Profile.URL, active.Profile.Token, req) + m, err := memory.Create(active.Profile.URL, active.Profile.Auth(), req) if err != nil { return reportHTTPErr(out, active.Name, err) } diff --git a/cmd/memory_test.go b/cmd/memory_test.go index 0c5d7d5..02eb6c5 100644 --- a/cmd/memory_test.go +++ b/cmd/memory_test.go @@ -60,9 +60,9 @@ func TestMemoryRecall_HappyPath_JSON(t *testing.T) { score := 1.42 orig := memory.Recall - memory.Recall = func(baseURL, token string, req memory.RecallRequest) ([]memory.Memory, error) { - if baseURL != "https://x.test" || token != "sk_test_T" { - t.Errorf("auth threading wrong: url=%q token=%q", baseURL, token) + memory.Recall = func(baseURL string, auth map[string]string, req memory.RecallRequest) ([]memory.Memory, error) { + if baseURL != "https://x.test" || auth["Authorization"] != "Bearer sk_test_T" { + t.Errorf("auth threading wrong: url=%q auth=%v", baseURL, auth) } if req.Query != "retry handling" { t.Errorf("query = %q", req.Query) @@ -108,7 +108,7 @@ func TestMemoryRecall_NoResults_EmitsEmptyArray(t *testing.T) { defer resetMemoryFlags() orig := memory.Recall - memory.Recall = func(string, string, memory.RecallRequest) ([]memory.Memory, error) { + memory.Recall = func(string, map[string]string, memory.RecallRequest) ([]memory.Memory, error) { return nil, nil } defer func() { memory.Recall = orig }() @@ -134,7 +134,7 @@ func TestMemoryList_AppliesFilters(t *testing.T) { var captured memory.ListParams orig := memory.List - memory.List = func(_, _ string, p memory.ListParams) ([]memory.Memory, error) { + memory.List = func(_ string, _ map[string]string, p memory.ListParams) ([]memory.Memory, error) { captured = p return []memory.Memory{ {ID: "m1", Slug: "x", Title: "X", Content: "full content body", @@ -182,7 +182,7 @@ func TestMemoryList_EmptyResults_EmitsEmptyArray(t *testing.T) { defer resetMemoryFlags() orig := memory.List - memory.List = func(string, string, memory.ListParams) ([]memory.Memory, error) { + memory.List = func(string, map[string]string, memory.ListParams) ([]memory.Memory, error) { return nil, nil } defer func() { memory.List = orig }() @@ -208,7 +208,7 @@ func TestMemoryAdd_HappyPath_JSON(t *testing.T) { var captured memory.CreateRequest orig := memory.Create - memory.Create = func(_, _ string, req memory.CreateRequest) (*memory.Memory, error) { + memory.Create = func(_ string, _ map[string]string, req memory.CreateRequest) (*memory.Memory, error) { captured = req return &memory.Memory{ ID: "m1", Slug: "retry-budgets", Title: req.Title, Content: req.Content, @@ -252,7 +252,7 @@ func TestMemoryAdd_StdinContent(t *testing.T) { var captured memory.CreateRequest orig := memory.Create - memory.Create = func(_, _ string, req memory.CreateRequest) (*memory.Memory, error) { + memory.Create = func(_ string, _ map[string]string, req memory.CreateRequest) (*memory.Memory, error) { captured = req return &memory.Memory{Title: req.Title, Slug: "s", Content: req.Content, Kind: req.Kind, Audience: req.Audience, Category: "fact", diff --git a/cmd/profiles.go b/cmd/profiles.go index cb67409..cab8b92 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -95,7 +95,7 @@ with "*" and reported as active_profile in JSON output.`, // token. A per-profile failure is recorded, never fatal — the // listing must stay complete even with one revoked token. if profilesRefresh && e.LoggedIn { - if user, ferr := fetchAuthMe(p.URL, p.Token); ferr != nil { + if user, ferr := fetchAuthMe(p.URL, p.Auth()); ferr != nil { e.AuthCheck = &authCheckResult{OK: false, Error: ferr.Error()} } else { e.AuthCheck = &authCheckResult{OK: true, Username: user.Email} diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go index dedc514..84d0276 100644 --- a/cmd/profiles_test.go +++ b/cmd/profiles_test.go @@ -158,7 +158,7 @@ func TestProfilesCmd_DoesNotCallNetworkByDefault(t *testing.T) { called := false orig := fetchAuthMe - fetchAuthMe = func(string, string) (*authMeResponse, error) { + fetchAuthMe = func(string, map[string]string) (*authMeResponse, error) { called = true return nil, nil } @@ -182,8 +182,8 @@ func TestProfilesCmd_Refresh_VerifiesEachLoggedInProfile(t *testing.T) { var seen []string orig := fetchAuthMe - fetchAuthMe = func(baseURL, token string) (*authMeResponse, error) { - seen = append(seen, token) + fetchAuthMe = func(baseURL string, auth map[string]string) (*authMeResponse, error) { + seen = append(seen, auth["Authorization"]) return &authMeResponse{Email: "verified@facets.cloud", UserID: "u1"}, nil } defer func() { fetchAuthMe = orig }() @@ -274,7 +274,7 @@ func TestProfilesCmd_Refresh_RecordsTokenFailure(t *testing.T) { profilesRefresh = true orig := fetchAuthMe - fetchAuthMe = func(string, string) (*authMeResponse, error) { + fetchAuthMe = func(string, map[string]string) (*authMeResponse, error) { return nil, errTokenRevoked } defer func() { fetchAuthMe = orig }() diff --git a/cmd/skill.go b/cmd/skill.go index da206c9..a4ebea4 100644 --- a/cmd/skill.go +++ b/cmd/skill.go @@ -166,7 +166,7 @@ For full setup including auth, use ` + "`praxis login`" + ` instead.`, defer restore() } - state := runPostAuthSetup(out, asJSON, active.Profile.URL, active.Profile.Token) + state := runPostAuthSetup(out, asJSON, active.Profile.URL, active.Profile.Auth()) // Report the *effective* scope (where files actually landed). scope := "user" diff --git a/cmd/skill_test.go b/cmd/skill_test.go index 2e64173..2c7a006 100644 --- a/cmd/skill_test.go +++ b/cmd/skill_test.go @@ -125,10 +125,10 @@ func TestRefreshSkills_ProjectFlag_ScopesToProjectDir(t *testing.T) { }, nil, nil) origFetchSk := fetchCatalog - fetchCatalog = func(_, _ string) ([]skillcatalog.Skill, error) { return nil, nil } + fetchCatalog = func(_ string, _ map[string]string) ([]skillcatalog.Skill, error) { return nil, nil } t.Cleanup(func() { fetchCatalog = origFetchSk }) origFetchAg := fetchAgents - fetchAgents = func(_, _ string) ([]agentcatalog.Agent, error) { return nil, nil } + fetchAgents = func(_ string, _ map[string]string) ([]agentcatalog.Agent, error) { return nil, nil } t.Cleanup(func() { fetchAgents = origFetchAg }) // Drive the flags through Cobra to validate the flag wiring (not just diff --git a/cmd/status.go b/cmd/status.go index 2bbef11..b611e6d 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -108,7 +108,7 @@ current staleness.`, // behavior of the (now deprecated) `whoami` command. Local-only // fields above are still returned even on auth-check failure. if statusRefresh && loggedIn { - user, err := fetchAuthMe(active.Profile.URL, active.Profile.Token) + user, err := fetchAuthMe(active.Profile.URL, active.Profile.Auth()) if err != nil { state["auth_check"] = map[string]any{ "ok": false, diff --git a/cmd/status_test.go b/cmd/status_test.go index e31d96b..f02ef4a 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -161,7 +161,7 @@ func TestStatusCmd_DoesNotCallNetwork(t *testing.T) { // fail because we set it to error. called := false orig := fetchAuthMe - fetchAuthMe = func(string, string) (*authMeResponse, error) { + fetchAuthMe = func(string, map[string]string) (*authMeResponse, error) { called = true return nil, nil } diff --git a/internal/agentcatalog/agentcatalog.go b/internal/agentcatalog/agentcatalog.go index ad92d93..8820084 100644 --- a/internal/agentcatalog/agentcatalog.go +++ b/internal/agentcatalog/agentcatalog.go @@ -75,8 +75,8 @@ const ( // A 404 on /custom-agents is treated as "empty catalog" (the helper // returns nil, nil) rather than an error, so deployments that don't // expose the endpoint install nothing rather than failing login. -var Fetch = func(baseURL, token string) ([]Agent, error) { - return fetchActiveSorted(baseURL, token, customAgentsPath) +var Fetch = func(baseURL string, auth map[string]string) ([]Agent, error) { + return fetchActiveSorted(baseURL, auth, customAgentsPath) } // FetchIncludingGlobal hits /ai-api/custom-agents?include_global=true so @@ -85,8 +85,8 @@ var Fetch = func(baseURL, token string) ([]Agent, error) { // to decide which agents to install on disk; this variant exists so // `praxis duty` can resolve the global praxis agent's id (its nested // schedules/runs/findings are addressed by that id). -var FetchIncludingGlobal = func(baseURL, token string) ([]Agent, error) { - return fetchActiveSorted(baseURL, token, customAgentsPath+"?include_global=true") +var FetchIncludingGlobal = func(baseURL string, auth map[string]string) ([]Agent, error) { + return fetchActiveSorted(baseURL, auth, customAgentsPath+"?include_global=true") } // fetchActiveSorted is the shared core of Fetch and FetchIncludingGlobal: @@ -94,15 +94,15 @@ var FetchIncludingGlobal = func(baseURL, token string) ([]Agent, error) { // name. The two exported seams differ only in the path (the // include_global query) — keeping the validation/filter/sort policy here // means the org-only and global-inclusive listings can't drift apart. -func fetchActiveSorted(baseURL, token, path string) ([]Agent, error) { +func fetchActiveSorted(baseURL string, auth map[string]string, path string) ([]Agent, error) { if baseURL == "" { return nil, fmt.Errorf("baseURL is required") } - if token == "" { + if len(auth) == 0 { return nil, fmt.Errorf("token is required") } - agents, err := fetchOne(baseURL, token, path, KindAgent) + agents, err := fetchOne(baseURL, auth, path, KindAgent) if err != nil { return nil, fmt.Errorf("fetch custom-agents: %w", err) } @@ -120,13 +120,15 @@ func fetchActiveSorted(baseURL, token, path string) ([]Agent, error) { return out, nil } -func fetchOne(baseURL, token, path, kind string) ([]Agent, error) { +func fetchOne(baseURL string, auth map[string]string, path, kind string) ([]Agent, error) { url := strings.TrimRight(baseURL, "/") + path req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+token) + for k, v := range auth { + req.Header.Set(k, v) + } req.Header.Set("Accept", "application/json") client := &http.Client{Timeout: defaultTimeout} diff --git a/internal/agentcatalog/agentcatalog_test.go b/internal/agentcatalog/agentcatalog_test.go index 568bfbe..14d9eef 100644 --- a/internal/agentcatalog/agentcatalog_test.go +++ b/internal/agentcatalog/agentcatalog_test.go @@ -10,6 +10,11 @@ import ( "github.com/Facets-cloud/praxis-cli/internal/render" ) +// bearer builds the Auth() header map a Bearer-mode profile produces. +func bearer(tok string) map[string]string { + return map[string]string{"Authorization": "Bearer " + tok} +} + func TestAgentPrefixedName(t *testing.T) { a := Agent{Name: "foo", Kind: KindAgent} if got := a.PrefixedName(); got != "praxis-foo" { @@ -66,7 +71,7 @@ func TestFetchFiltersInactive(t *testing.T) { })) defer srv.Close() - got, err := Fetch(srv.URL, "test-token") + got, err := Fetch(srv.URL, bearer("test-token")) if err != nil { t.Fatalf("Fetch: %v", err) } @@ -96,7 +101,7 @@ func TestFetchIncludingGlobal(t *testing.T) { })) defer srv.Close() - got, err := FetchIncludingGlobal(srv.URL, "tok") + got, err := FetchIncludingGlobal(srv.URL, bearer("tok")) if err != nil { t.Fatalf("FetchIncludingGlobal: %v", err) } @@ -127,7 +132,7 @@ func TestFetchServerErrorFails(t *testing.T) { http.Error(w, "boom", http.StatusInternalServerError) })) defer srv.Close() - _, err := Fetch(srv.URL, "tok") + _, err := Fetch(srv.URL, bearer("tok")) if err == nil { t.Fatal("expected error on 500, got nil") } @@ -148,7 +153,7 @@ func TestFetchTolerates404(t *testing.T) { http.NotFound(w, r) })) defer srv.Close() - got, err := Fetch(srv.URL, "tok") + got, err := Fetch(srv.URL, bearer("tok")) if err != nil { t.Fatalf("Fetch should tolerate 404 on /custom-agents, got: %v", err) } @@ -158,12 +163,12 @@ func TestFetchTolerates404(t *testing.T) { } func TestFetchRequiresBaseURLAndToken(t *testing.T) { - if _, err := Fetch("", "tok"); err == nil { + if _, err := Fetch("", bearer("tok")); err == nil { t.Error("empty baseURL: expected error") } else if !strings.Contains(err.Error(), "baseURL is required") { t.Errorf("empty baseURL: error should name the missing field, got: %v", err) } - if _, err := Fetch("http://x", ""); err == nil { + if _, err := Fetch("http://x", nil); err == nil { t.Error("empty token: expected error") } else if !strings.Contains(err.Error(), "token is required") { t.Errorf("empty token: error should name the missing field, got: %v", err) diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index dfec1fa..530eea4 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -80,6 +80,33 @@ type Profile struct { // it exists so `praxis status` (and the AI host reading it) can point // raptor at the matching control plane via FACETS_PROFILE. RaptorProfile string + // AuthMode selects how Token is presented on outbound requests. + // "basic" → facets mode: control-plane PAT sent as Bearer plus an + // X-Facets-Username identity header; anything else (including "") → + // plain Bearer (Praxis API key). Persisted as auth_mode in the INI, + // omitted when empty. (The name is historical — the wire shape is + // Bearer, never HTTP Basic.) + AuthMode string +} + +// AuthModeBasic is the AuthMode value for control-plane PAT (facets) profiles. +// Shared so the writer (login) and reader (Auth) can't drift on the spelling. +const AuthModeBasic = "basic" + +// Auth returns the headers that authenticate a request for this profile: +// always Authorization (Bearer ), plus X-Facets-Username for facets +// (AuthModeBasic) profiles — a control-plane PAT the server validates against +// that username. Returns nil when there is no token. Sent as Bearer (never +// HTTP Basic, which browsers cache/replay per origin). +func (p Profile) Auth() map[string]string { + if p.Token == "" { + return nil + } + h := map[string]string{"Authorization": "Bearer " + p.Token} + if p.AuthMode == AuthModeBasic { + h["X-Facets-Username"] = p.Username + } + return h } // Source describes which level produced the active-profile name. Surfaced @@ -522,6 +549,7 @@ func parseINI(data []byte) map[string]Profile { Username: kv["username"], Token: kv["token"], RaptorProfile: kv["raptor_profile"], + AuthMode: kv["auth_mode"], } } return out @@ -546,6 +574,9 @@ func writeINI(store map[string]Profile) []byte { if p.RaptorProfile != "" { fmt.Fprintf(&sb, "raptor_profile = %s\n", p.RaptorProfile) } + if p.AuthMode != "" { + fmt.Fprintf(&sb, "auth_mode = %s\n", p.AuthMode) + } sb.WriteString("\n") } return []byte(sb.String()) diff --git a/internal/credentials/credentials_test.go b/internal/credentials/credentials_test.go index cb49bdc..bb338b3 100644 --- a/internal/credentials/credentials_test.go +++ b/internal/credentials/credentials_test.go @@ -94,6 +94,61 @@ func TestPutLoadGet_RoundTrip(t *testing.T) { } } +func TestAuth(t *testing.T) { + cases := []struct { + name string + prof Profile + want map[string]string + }{ + {"bearer default mode", Profile{Username: "u@x", Token: "sk_live_abc"}, map[string]string{"Authorization": "Bearer sk_live_abc"}}, + {"bearer explicit non-basic mode", Profile{Username: "u@x", Token: "sk_live_abc", AuthMode: "bearer"}, map[string]string{"Authorization": "Bearer sk_live_abc"}}, + {"facets/basic mode", Profile{Username: "user@corp", Token: "pat123", AuthMode: "basic"}, map[string]string{"Authorization": "Bearer pat123", "X-Facets-Username": "user@corp"}}, + {"empty token", Profile{Username: "u@x", AuthMode: "basic"}, nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.prof.Auth() + if len(got) != len(c.want) { + t.Fatalf("Auth() = %v; want %v", got, c.want) + } + for k, v := range c.want { + if got[k] != v { + t.Errorf("Auth()[%q] = %q; want %q", k, got[k], v) + } + } + }) + } +} + +func TestAuthMode_RoundTripsThroughINI(t *testing.T) { + withHome(t) + want := Profile{URL: "https://cp.test", Username: "user@corp", Token: "pat123", AuthMode: "basic"} + if err := Put("facets", want); err != nil { + t.Fatal(err) + } + store, err := Load() + if err != nil { + t.Fatal(err) + } + got, ok := store["facets"] + if !ok { + t.Fatal("facets profile missing after Put") + } + if got != want { + t.Errorf("round-trip mismatch: got %+v want %+v", got, want) + } + // A profile with no AuthMode must NOT emit an auth_mode line. + _ = Put("bearer", Profile{URL: "https://x.test", Token: "sk"}) + path, _ := paths.Credentials() + raw, _ := os.ReadFile(path) + if strings.Contains(string(raw), "auth_mode = \n") || strings.Contains(string(raw), "auth_mode = bearer") { + t.Errorf("empty AuthMode should be omitted from INI; got:\n%s", raw) + } + if !strings.Contains(string(raw), "auth_mode = basic") { + t.Errorf("basic AuthMode not persisted; got:\n%s", raw) + } +} + func TestPut_AddsSecondProfileWithoutClobberingFirst(t *testing.T) { withHome(t) if err := Put("default", Profile{URL: "https://askpraxis.ai", Username: "a@x", Token: "t1"}); err != nil { diff --git a/internal/credentials/facets.go b/internal/credentials/facets.go new file mode 100644 index 0000000..35e223c --- /dev/null +++ b/internal/credentials/facets.go @@ -0,0 +1,38 @@ +package credentials + +import ( + "fmt" + "os" + "path/filepath" +) + +// ReadFacetsProfile reads ~/.facets/credentials and returns the (url, username, token) +// for the named profile (default "default"). Returns an error if the file or profile +// is missing or the username/token are empty. +// +// The facets/raptor credentials file is the same INI shape as ~/.praxis/credentials, +// but its keys are control_plane_url / username / token. +func ReadFacetsProfile(profile string) (url, username, token string, err error) { + if profile == "" { + profile = DefaultProfileName + } + home, err := os.UserHomeDir() + if err != nil { + return "", "", "", err + } + path := filepath.Join(home, ".facets", "credentials") + data, err := os.ReadFile(path) + if err != nil { + return "", "", "", fmt.Errorf("read %s: %w", path, err) + } + sections := parseRawINI(data) + kv, ok := sections[profile] + if !ok { + return "", "", "", fmt.Errorf("profile %q not found in %s", profile, path) + } + url, username, token = kv["control_plane_url"], kv["username"], kv["token"] + if username == "" || token == "" { + return "", "", "", fmt.Errorf("profile %q in %s is missing username or token", profile, path) + } + return url, username, token, nil +} diff --git a/internal/credentials/facets_test.go b/internal/credentials/facets_test.go new file mode 100644 index 0000000..ce78457 --- /dev/null +++ b/internal/credentials/facets_test.go @@ -0,0 +1,76 @@ +package credentials + +import ( + "os" + "path/filepath" + "testing" +) + +func writeFacetsCreds(t *testing.T, body string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".facets") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "credentials"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestReadFacetsProfile_DefaultProfile(t *testing.T) { + writeFacetsCreds(t, `[default] +control_plane_url = https://root.console.facets.cloud +username = user@corp +token = pat_abc123 + +[acme] +control_plane_url = https://acme.console.facets.cloud +username = admin@acme +token = pat_xyz +`) + url, user, tok, err := ReadFacetsProfile("") + if err != nil { + t.Fatal(err) + } + if url != "https://root.console.facets.cloud" || user != "user@corp" || tok != "pat_abc123" { + t.Errorf("got (%q,%q,%q)", url, user, tok) + } +} + +func TestReadFacetsProfile_NamedProfile(t *testing.T) { + writeFacetsCreds(t, `[acme] +control_plane_url = https://acme.console.facets.cloud +username = admin@acme +token = pat_xyz +`) + url, user, tok, err := ReadFacetsProfile("acme") + if err != nil { + t.Fatal(err) + } + if url != "https://acme.console.facets.cloud" || user != "admin@acme" || tok != "pat_xyz" { + t.Errorf("got (%q,%q,%q)", url, user, tok) + } +} + +func TestReadFacetsProfile_MissingProfile(t *testing.T) { + writeFacetsCreds(t, "[default]\nusername = u\ntoken = t\n") + if _, _, _, err := ReadFacetsProfile("nope"); err == nil { + t.Error("want error for missing profile") + } +} + +func TestReadFacetsProfile_EmptyCredentials(t *testing.T) { + writeFacetsCreds(t, "[default]\ncontrol_plane_url = https://x\n") + if _, _, _, err := ReadFacetsProfile("default"); err == nil { + t.Error("want error when username/token empty") + } +} + +func TestReadFacetsProfile_MissingFile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if _, _, _, err := ReadFacetsProfile("default"); err == nil { + t.Error("want error when file absent") + } +} diff --git a/internal/duties/duties.go b/internal/duties/duties.go index 5f7ec31..1193783 100644 --- a/internal/duties/duties.go +++ b/internal/duties/duties.go @@ -7,7 +7,9 @@ // It mirrors the layout of internal/memory: typed structs track the // server's response models (AgentScheduleResponse / AgentRunResponse / // Finding), exported function vars give tests a seam to swap, and every -// transport call sends Authorization: Bearer . +// transport call sets the caller-supplied auth headers (always +// Authorization: Bearer , plus X-Facets-Username for a +// facets-mode PAT). // // Schedules are nested under a custom agent. The CLI resolves the agent // id (the global "praxis" duty agent by default) via internal/agentcatalog @@ -125,7 +127,7 @@ type findingsEnvelope struct { // ListSchedules returns every duty under an agent, optionally filtered by // tag. -var ListSchedules = func(baseURL, token, agentID, tag string) ([]Schedule, error) { +var ListSchedules = func(baseURL string, auth map[string]string, agentID, tag string) ([]Schedule, error) { if agentID == "" { return nil, fmt.Errorf("agentID is required") } @@ -135,12 +137,12 @@ var ListSchedules = func(baseURL, token, agentID, tag string) ([]Schedule, error q.Set("tag", tag) path += "?" + q.Encode() } - return doJSON[[]Schedule](baseURL, token, http.MethodGet, path, nil) + return doJSON[[]Schedule](baseURL, auth, http.MethodGet, path, nil) } // ListRuns returns runs under an agent, newest first. A non-empty // scheduleID filters to one duty; limit is clamped server-side to 1-100. -var ListRuns = func(baseURL, token, agentID, scheduleID string, limit int) ([]Run, error) { +var ListRuns = func(baseURL string, auth map[string]string, agentID, scheduleID string, limit int) ([]Run, error) { if agentID == "" { return nil, fmt.Errorf("agentID is required") } @@ -155,16 +157,16 @@ var ListRuns = func(baseURL, token, agentID, scheduleID string, limit int) ([]Ru if encoded := q.Encode(); encoded != "" { path += "?" + encoded } - return doJSON[[]Run](baseURL, token, http.MethodGet, path, nil) + return doJSON[[]Run](baseURL, auth, http.MethodGet, path, nil) } // GetRun returns a single run's detail, including report_artifact_id. -var GetRun = func(baseURL, token, agentID, runID string) (*Run, error) { +var GetRun = func(baseURL string, auth map[string]string, agentID, runID string) (*Run, error) { if agentID == "" || runID == "" { return nil, fmt.Errorf("agentID and runID are required") } path := agentBase(agentID) + "/runs/" + url.PathEscape(runID) - run, err := doJSON[Run](baseURL, token, http.MethodGet, path, nil) + run, err := doJSON[Run](baseURL, auth, http.MethodGet, path, nil) if err != nil { return nil, err } @@ -173,7 +175,7 @@ var GetRun = func(baseURL, token, agentID, runID string) (*Run, error) { // ListFindings returns a duty's findings deduped by finding_key. status is // one of open|resolved|all; limit is clamped server-side to 1-1000. -var ListFindings = func(baseURL, token, agentID, scheduleID, status string, limit int) ([]Finding, error) { +var ListFindings = func(baseURL string, auth map[string]string, agentID, scheduleID, status string, limit int) ([]Finding, error) { if agentID == "" || scheduleID == "" { return nil, fmt.Errorf("agentID and scheduleID are required") } @@ -188,7 +190,7 @@ var ListFindings = func(baseURL, token, agentID, scheduleID, status string, limi if encoded := q.Encode(); encoded != "" { path += "?" + encoded } - env, err := doJSON[findingsEnvelope](baseURL, token, http.MethodGet, path, nil) + env, err := doJSON[findingsEnvelope](baseURL, auth, http.MethodGet, path, nil) if err != nil { return nil, err } @@ -198,11 +200,11 @@ var ListFindings = func(baseURL, token, agentID, scheduleID, status string, limi // FetchArtifactContent returns an artifact's raw body and its MIME type. // The /content endpoint streams bytes (text/markdown or text/html), not // JSON, so this bypasses doJSON and reads the body + Content-Type directly. -var FetchArtifactContent = func(baseURL, token, artifactID string) (body []byte, mime string, err error) { +var FetchArtifactContent = func(baseURL string, auth map[string]string, artifactID string) (body []byte, mime string, err error) { if baseURL == "" { return nil, "", fmt.Errorf("baseURL is required") } - if token == "" { + if len(auth) == 0 { return nil, "", fmt.Errorf("token is required") } if artifactID == "" { @@ -217,7 +219,9 @@ var FetchArtifactContent = func(baseURL, token, artifactID string) (body []byte, if err != nil { return nil, "", err } - req.Header.Set("Authorization", "Bearer "+token) + for k, v := range auth { + req.Header.Set(k, v) + } client := &http.Client{Timeout: defaultTimeout} resp, err := client.Do(req) @@ -248,12 +252,12 @@ func agentBase(agentID string) string { // can branch on status (401/403 → auth) without re-parsing the URL. // Copied deliberately from internal/memory to keep the two clients' // error contracts identical. -func doJSON[T any](baseURL, token, method, path string, body io.Reader) (T, error) { +func doJSON[T any](baseURL string, auth map[string]string, method, path string, body io.Reader) (T, error) { var zero T if baseURL == "" { return zero, fmt.Errorf("baseURL is required") } - if token == "" { + if len(auth) == 0 { return zero, fmt.Errorf("token is required") } @@ -265,7 +269,9 @@ func doJSON[T any](baseURL, token, method, path string, body io.Reader) (T, erro if err != nil { return zero, err } - req.Header.Set("Authorization", "Bearer "+token) + for k, v := range auth { + req.Header.Set(k, v) + } req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") diff --git a/internal/duties/duties_test.go b/internal/duties/duties_test.go index 5fef310..0c3c79f 100644 --- a/internal/duties/duties_test.go +++ b/internal/duties/duties_test.go @@ -7,6 +7,11 @@ import ( "testing" ) +// bearer builds the Auth() header map a Bearer-mode profile produces. +func bearer(tok string) map[string]string { + return map[string]string{"Authorization": "Bearer " + tok} +} + // assertReq pins the request's method, path, and Bearer token. Shared by // stubServer and the query-asserting tests below so none of them lose the // method/path/auth checks. @@ -23,6 +28,25 @@ func assertReq(t *testing.T, r *http.Request, wantMethod, wantPath, wantBearer s } } +// TestFacetsAuthHeaders pins that a facets-mode auth map sends BOTH the +// Bearer token and the X-Facets-Username identity header. +func TestFacetsAuthHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer pat" { + t.Errorf("Authorization = %q; want %q", got, "Bearer pat") + } + if got := r.Header.Get("X-Facets-Username"); got != "u@corp" { + t.Errorf("X-Facets-Username = %q; want %q", got, "u@corp") + } + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(srv.Close) + auth := map[string]string{"Authorization": "Bearer pat", "X-Facets-Username": "u@corp"} + if _, err := ListSchedules(srv.URL, auth, "agt", ""); err != nil { + t.Fatalf("ListSchedules: %v", err) + } +} + // stubServer spins up an httptest.Server that asserts the request method, // path, and Bearer header, then returns the table-row's status + body. // Optionally streams a Content-Type so the artifact-content path can be @@ -66,7 +90,7 @@ func TestListSchedules_HappyPath(t *testing.T) { {"id":"sch2","agent_id":"agt","name":"cost-audit","display_name":"Cost Audit","cron_expression":"0 0 * * *","timezone":"UTC","enabled":false,"objective":"audit","status":"paused","consecutive_errors":2,"created_by_email":"u@x","created_at":"t","updated_at":"t","open_findings_count":0,"learnings_count":0,"tags":[]} ]` srv := stubServer(t, http.MethodGet, "/ai-api/custom-agents/agt/schedules", 200, body, "", "tok") - got, err := ListSchedules(srv.URL, "tok", "agt", "") + got, err := ListSchedules(srv.URL, bearer("tok"), "agt", "") if err != nil { t.Fatalf("ListSchedules: %v", err) } @@ -87,7 +111,7 @@ func TestListSchedules_TagFilterEncoded(t *testing.T) { t.Errorf("tag = %q; want prod", r.URL.Query().Get("tag")) } }) - if _, err := ListSchedules(srv.URL, "tok", "agt", "prod"); err != nil { + if _, err := ListSchedules(srv.URL, bearer("tok"), "agt", "prod"); err != nil { t.Fatalf("ListSchedules: %v", err) } if !hit { @@ -96,7 +120,7 @@ func TestListSchedules_TagFilterEncoded(t *testing.T) { } func TestListSchedules_RequiresAgentID(t *testing.T) { - _, err := ListSchedules("http://no-such-host.invalid", "tok", "", "") + _, err := ListSchedules("http://no-such-host.invalid", bearer("tok"), "", "") if err == nil || !strings.Contains(err.Error(), "agentID is required") { t.Fatalf("err = %v; want agentID required", err) } @@ -115,7 +139,7 @@ func TestListRuns_HappyPathWithScheduleAndLimit(t *testing.T) { t.Errorf("limit = %q; want 5", r.URL.Query().Get("limit")) } }) - got, err := ListRuns(srv.URL, "tok", "agt", "sch1", 5) + got, err := ListRuns(srv.URL, bearer("tok"), "agt", "sch1", 5) if err != nil { t.Fatalf("ListRuns: %v", err) } @@ -129,7 +153,7 @@ func TestListRuns_HappyPathWithScheduleAndLimit(t *testing.T) { func TestGetRun_CarriesReportArtifactID(t *testing.T) { const body = `{"id":"run9","agent_id":"agt","schedule_id":"sch1","organization_id":"o","status":"completed","started_at":"t","report_artifact_id":"art9","findings":[{"title":"disk full","severity":"high","description":"d","finding_key":"k1","recurrence_count":2,"status":"open"}],"actions":[]}` srv := stubServer(t, http.MethodGet, "/ai-api/custom-agents/agt/runs/run9", 200, body, "", "tok") - got, err := GetRun(srv.URL, "tok", "agt", "run9") + got, err := GetRun(srv.URL, bearer("tok"), "agt", "run9") if err != nil { t.Fatalf("GetRun: %v", err) } @@ -154,7 +178,7 @@ func TestListFindings_UnwrapsItemsEnvelope(t *testing.T) { t.Errorf("status = %q; want open", r.URL.Query().Get("status")) } }) - got, err := ListFindings(srv.URL, "tok", "agt", "sch1", "open", 0) + got, err := ListFindings(srv.URL, bearer("tok"), "agt", "sch1", "open", 0) if err != nil { t.Fatalf("ListFindings: %v", err) } @@ -171,7 +195,7 @@ func TestListFindings_UnwrapsItemsEnvelope(t *testing.T) { func TestFetchArtifactContent_ReturnsBodyAndMime(t *testing.T) { const report = "# Nightly Report\n\nAll clear." srv := stubServer(t, http.MethodGet, "/ai-api/artifacts/art9/content", 200, report, "text/markdown; charset=utf-8", "tok") - body, mime, err := FetchArtifactContent(srv.URL, "tok", "art9") + body, mime, err := FetchArtifactContent(srv.URL, bearer("tok"), "art9") if err != nil { t.Fatalf("FetchArtifactContent: %v", err) } @@ -185,14 +209,14 @@ func TestFetchArtifactContent_ReturnsBodyAndMime(t *testing.T) { func TestFetchArtifactContent_404SurfacesError(t *testing.T) { srv := stubServer(t, http.MethodGet, "/ai-api/artifacts/gone/content", 404, "not found", "", "tok") - _, _, err := FetchArtifactContent(srv.URL, "tok", "gone") + _, _, err := FetchArtifactContent(srv.URL, bearer("tok"), "gone") if err == nil || !strings.Contains(err.Error(), "HTTP 404") { t.Fatalf("err = %v; want HTTP 404", err) } } func TestFetchArtifactContent_RequiresArtifactID(t *testing.T) { - _, _, err := FetchArtifactContent("http://x.test", "tok", "") + _, _, err := FetchArtifactContent("http://x.test", bearer("tok"), "") if err == nil || !strings.Contains(err.Error(), "artifactID is required") { t.Fatalf("err = %v; want artifactID required", err) } @@ -201,10 +225,10 @@ func TestFetchArtifactContent_RequiresArtifactID(t *testing.T) { // --- transport edge: token/baseURL required ---------------------------- func TestDoJSON_RequiresBaseURLAndToken(t *testing.T) { - if _, err := ListRuns("", "tok", "agt", "", 0); err == nil { + if _, err := ListRuns("", bearer("tok"), "agt", "", 0); err == nil { t.Error("want error for empty baseURL") } - if _, err := ListRuns("http://x.test", "", "agt", "", 0); err == nil { + if _, err := ListRuns("http://x.test", nil, "agt", "", 0); err == nil { t.Error("want error for empty token") } } diff --git a/internal/igcatalog/igcatalog.go b/internal/igcatalog/igcatalog.go index 56a16ab..f15b8e3 100644 --- a/internal/igcatalog/igcatalog.go +++ b/internal/igcatalog/igcatalog.go @@ -6,9 +6,10 @@ // // The package mirrors the layout of internal/duties and internal/memory: // typed structs track the server's response models, exported function vars -// give tests a seam to swap, and every transport call sends -// Authorization: Bearer (the same bearer these clients already -// send — the server resolves it via auth_service.validate_user()). +// give tests a seam to swap, and every transport call sets whatever +// headers the profile's Auth() returns — always Authorization: Bearer +// , plus X-Facets-Username for facets-mode control-plane PATs +// (the server resolves the identity via auth_service.validate_user()). // // Backend routes (all under /ai-api/ig, org-scoped): // @@ -115,17 +116,17 @@ type claimsResponse struct { // --- HTTP seams — tests swap these to avoid the network. --------------- // ListCatalogs returns every catalog in the org. -var ListCatalogs = func(baseURL, token string) ([]Catalog, error) { - return doJSON[[]Catalog](baseURL, token, http.MethodGet, apiPrefix+"/catalogs", nil) +var ListCatalogs = func(baseURL string, auth map[string]string) ([]Catalog, error) { + return doJSON[[]Catalog](baseURL, auth, http.MethodGet, apiPrefix+"/catalogs", nil) } // GetCatalog returns one catalog's summary. The server 404s when the // catalog is absent; that surfaces as an `HTTP 404 …` error. -var GetCatalog = func(baseURL, token, name string) (*Catalog, error) { +var GetCatalog = func(baseURL string, auth map[string]string, name string) (*Catalog, error) { if name == "" { return nil, fmt.Errorf("catalog name is required") } - c, err := doJSON[Catalog](baseURL, token, http.MethodGet, + c, err := doJSON[Catalog](baseURL, auth, http.MethodGet, apiPrefix+"/catalogs/"+url.PathEscape(name), nil) if err != nil { return nil, err @@ -136,13 +137,13 @@ var GetCatalog = func(baseURL, token, name string) (*Catalog, error) { // Claims returns the names of catalogs that have a member whose canonical // git URL matches git. Repo CI loops over these to know which catalogs to // refresh after a push. -var Claims = func(baseURL, token, git string) ([]string, error) { +var Claims = func(baseURL string, auth map[string]string, git string) ([]string, error) { if git == "" { return nil, fmt.Errorf("git url is required") } q := url.Values{} q.Set("git", git) - env, err := doJSON[claimsResponse](baseURL, token, http.MethodGet, + env, err := doJSON[claimsResponse](baseURL, auth, http.MethodGet, apiPrefix+"/catalogs/claims?"+q.Encode(), nil) if err != nil { return nil, err @@ -158,7 +159,7 @@ var Claims = func(baseURL, token, git string) ([]string, error) { // part named "graph" carrying the gzipped graph.json bytes, plus optional // "git"/"sha" form fields. On the server those are Optional[...] = Form(None), // so they are written only when non-empty; git/sha are NOT query parameters. -var PublishMember = func(baseURL, token, catalog, member string, gzGraph []byte, git, sha string) error { +var PublishMember = func(baseURL string, auth map[string]string, catalog, member string, gzGraph []byte, git, sha string) error { if catalog == "" || member == "" { return fmt.Errorf("catalog and member are required") } @@ -188,7 +189,7 @@ var PublishMember = func(baseURL, token, catalog, member string, gzGraph []byte, // FormDataContentType() carries the boundary — never hand-roll it. path := apiPrefix + "/catalogs/" + url.PathEscape(catalog) + "/members/" + url.PathEscape(member) - return sendBytes(baseURL, token, http.MethodPost, path, writer.FormDataContentType(), body.Bytes()) + return sendBytes(baseURL, auth, http.MethodPost, path, writer.FormDataContentType(), body.Bytes()) } // DownloadBundle fetches the assembled catalog as a gzipped tarball. @@ -196,11 +197,11 @@ var PublishMember = func(baseURL, token, catalog, member string, gzGraph []byte, // server's current ETag the server returns 304 and this reports // notModified=true with an empty body (a cheap no-op re-sync). On 200 it // returns the tarball bytes and the ETag (the new digest). -var DownloadBundle = func(baseURL, token, catalog, ifNoneMatch string) (body []byte, etag string, notModified bool, err error) { +var DownloadBundle = func(baseURL string, auth map[string]string, catalog, ifNoneMatch string) (body []byte, etag string, notModified bool, err error) { if baseURL == "" { return nil, "", false, fmt.Errorf("baseURL is required") } - if token == "" { + if len(auth) == 0 { return nil, "", false, fmt.Errorf("token is required") } if catalog == "" { @@ -215,7 +216,9 @@ var DownloadBundle = func(baseURL, token, catalog, ifNoneMatch string) (body []b if err != nil { return nil, "", false, err } - req.Header.Set("Authorization", "Bearer "+token) + for k, v := range auth { + req.Header.Set(k, v) + } if ifNoneMatch != "" { req.Header.Set("If-None-Match", quoteETag(ifNoneMatch)) } @@ -274,7 +277,7 @@ func quoteETag(v string) string { // pushed_by/pushed_at itself, so only content and git_sha go on the wire (the // server's IgManifestPushRequest) — m.PushedBy/m.PushedAt are ignored here and // exist only for the cmd layer's local echo. -var ManifestPush = func(baseURL, token, catalog string, m Manifest) error { +var ManifestPush = func(baseURL string, auth map[string]string, catalog string, m Manifest) error { if catalog == "" { return fmt.Errorf("catalog is required") } @@ -283,16 +286,16 @@ var ManifestPush = func(baseURL, token, catalog string, m Manifest) error { return err } path := apiPrefix + "/catalogs/" + url.PathEscape(catalog) + "/manifest" - return sendBytes(baseURL, token, http.MethodPost, path, "application/json", body) + return sendBytes(baseURL, auth, http.MethodPost, path, "application/json", body) } // ManifestPull fetches the served manifest (text + stamps) so a builder // can diff it against their local copy. -var ManifestPull = func(baseURL, token, catalog string) (*Manifest, error) { +var ManifestPull = func(baseURL string, auth map[string]string, catalog string) (*Manifest, error) { if catalog == "" { return nil, fmt.Errorf("catalog is required") } - m, err := doJSON[Manifest](baseURL, token, http.MethodGet, + m, err := doJSON[Manifest](baseURL, auth, http.MethodGet, apiPrefix+"/catalogs/"+url.PathEscape(catalog)+"/manifest", nil) if err != nil { return nil, err @@ -307,12 +310,12 @@ var ManifestPull = func(baseURL, token, catalog string) (*Manifest, error) { // branch on status (401/403 → auth) without re-parsing the URL. Copied // deliberately from internal/duties to keep the clients' error contracts // identical. -func doJSON[T any](baseURL, token, method, path string, body io.Reader) (T, error) { +func doJSON[T any](baseURL string, auth map[string]string, method, path string, body io.Reader) (T, error) { var zero T if baseURL == "" { return zero, fmt.Errorf("baseURL is required") } - if token == "" { + if len(auth) == 0 { return zero, fmt.Errorf("token is required") } @@ -324,7 +327,9 @@ func doJSON[T any](baseURL, token, method, path string, body io.Reader) (T, erro if err != nil { return zero, err } - req.Header.Set("Authorization", "Bearer "+token) + for k, v := range auth { + req.Header.Set(k, v) + } req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") @@ -356,11 +361,11 @@ func doJSON[T any](baseURL, token, method, path string, body io.Reader) (T, erro // body. Used for the two non-JSON-returning uploads: the gzipped member // graph and the manifest push. The error contract matches doJSON so the // cmd layer's reportHTTPErr dispatch works the same. -func sendBytes(baseURL, token, method, path, contentType string, body []byte) error { +func sendBytes(baseURL string, auth map[string]string, method, path, contentType string, body []byte) error { if baseURL == "" { return fmt.Errorf("baseURL is required") } - if token == "" { + if len(auth) == 0 { return fmt.Errorf("token is required") } @@ -372,7 +377,9 @@ func sendBytes(baseURL, token, method, path, contentType string, body []byte) er if err != nil { return err } - req.Header.Set("Authorization", "Bearer "+token) + for k, v := range auth { + req.Header.Set(k, v) + } if contentType != "" { req.Header.Set("Content-Type", contentType) } diff --git a/internal/igcatalog/igcatalog_test.go b/internal/igcatalog/igcatalog_test.go index 4ee55f7..0995e53 100644 --- a/internal/igcatalog/igcatalog_test.go +++ b/internal/igcatalog/igcatalog_test.go @@ -10,6 +10,11 @@ import ( "testing" ) +// bearer builds the Auth() header map a Bearer-mode profile produces. +func bearer(tok string) map[string]string { + return map[string]string{"Authorization": "Bearer " + tok} +} + func assertReq(t *testing.T, r *http.Request, wantMethod, wantPath, wantBearer string) { t.Helper() if r.Method != wantMethod { @@ -23,6 +28,27 @@ func assertReq(t *testing.T, r *http.Request, wantMethod, wantPath, wantBearer s } } +// TestFacetsAuthHeaders pins that a facets-mode auth map sends BOTH the +// Bearer token and the X-Facets-Username identity header — without which +// the agent server would treat the control-plane PAT as a Praxis API key +// and 401. +func TestFacetsAuthHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer pat" { + t.Errorf("Authorization = %q; want %q", got, "Bearer pat") + } + if got := r.Header.Get("X-Facets-Username"); got != "u@corp" { + t.Errorf("X-Facets-Username = %q; want %q", got, "u@corp") + } + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + auth := map[string]string{"Authorization": "Bearer pat", "X-Facets-Username": "u@corp"} + if _, err := ListCatalogs(srv.URL, auth); err != nil { + t.Fatalf("ListCatalogs: %v", err) + } +} + // --- ListCatalogs ------------------------------------------------------ func TestListCatalogs_HappyPath(t *testing.T) { @@ -36,7 +62,7 @@ func TestListCatalogs_HappyPath(t *testing.T) { })) defer srv.Close() - got, err := ListCatalogs(srv.URL, "tok") + got, err := ListCatalogs(srv.URL, bearer("tok")) if err != nil { t.Fatalf("ListCatalogs: %v", err) } @@ -82,7 +108,7 @@ func TestListCatalogs_DecodesMemberObjects(t *testing.T) { })) defer srv.Close() - got, err := ListCatalogs(srv.URL, "tok") + got, err := ListCatalogs(srv.URL, bearer("tok")) if err != nil { t.Fatalf("ListCatalogs: %v", err) } @@ -120,7 +146,7 @@ func TestListCatalogs_InfraMemberHasNoRepo(t *testing.T) { })) defer srv.Close() - got, err := ListCatalogs(srv.URL, "tok") + got, err := ListCatalogs(srv.URL, bearer("tok")) if err != nil { t.Fatalf("ListCatalogs: %v", err) } @@ -142,7 +168,7 @@ func TestGetCatalog_404SurfacesError(t *testing.T) { })) defer srv.Close() - _, err := GetCatalog(srv.URL, "tok", "ghost") + _, err := GetCatalog(srv.URL, bearer("tok"), "ghost") if err == nil || !strings.Contains(err.Error(), "HTTP 404") { t.Fatalf("err = %v; want HTTP 404", err) } @@ -170,7 +196,7 @@ func TestClaims_EncodesGitAndReturnsNames(t *testing.T) { })) defer srv.Close() - got, err := Claims(srv.URL, "tok", git) + got, err := Claims(srv.URL, bearer("tok"), git) if err != nil { t.Fatalf("Claims: %v", err) } @@ -190,7 +216,7 @@ func TestClaims_DecodesLiveEnvelope(t *testing.T) { })) defer srv.Close() - got, err := Claims(srv.URL, "tok", git) + got, err := Claims(srv.URL, bearer("tok"), git) if err != nil { t.Fatalf("Claims: %v", err) } @@ -208,7 +234,7 @@ func TestClaims_UnclaimedRepoIsEmptyNotError(t *testing.T) { })) defer srv.Close() - got, err := Claims(srv.URL, "tok", "github.com/acme/orphan") + got, err := Claims(srv.URL, bearer("tok"), "github.com/acme/orphan") if err != nil { t.Fatalf("Claims: %v", err) } @@ -294,7 +320,7 @@ func TestPublishMember_UploadsMultipartWithGitAndSha(t *testing.T) { // First publish, then a repeat — the server accepts both (idempotent). for i := 0; i < 2; i++ { - if err := PublishMember(srv.URL, "tok", "payments", "api", gz, "https://github.com/acme/api.git", "abc123"); err != nil { + if err := PublishMember(srv.URL, bearer("tok"), "payments", "api", gz, "https://github.com/acme/api.git", "abc123"); err != nil { t.Fatalf("PublishMember #%d: %v", i, err) } } @@ -333,7 +359,7 @@ func TestPublishMember_OmitsEmptyGitAndSha(t *testing.T) { })) defer srv.Close() - if err := PublishMember(srv.URL, "tok", "payments", "api", gz, "", ""); err != nil { + if err := PublishMember(srv.URL, bearer("tok"), "payments", "api", gz, "", ""); err != nil { t.Fatalf("PublishMember: %v", err) } } @@ -349,7 +375,7 @@ func TestDownloadBundle_ReturnsBytesAndETag(t *testing.T) { })) defer srv.Close() - body, etag, notModified, err := DownloadBundle(srv.URL, "tok", "payments", "") + body, etag, notModified, err := DownloadBundle(srv.URL, bearer("tok"), "payments", "") if err != nil { t.Fatalf("DownloadBundle: %v", err) } @@ -376,7 +402,7 @@ func TestDownloadBundle_SendsIfNoneMatchAnd304IsNoOp(t *testing.T) { })) defer srv.Close() - body, etag, notModified, err := DownloadBundle(srv.URL, "tok", "payments", "sha256:current") + body, etag, notModified, err := DownloadBundle(srv.URL, bearer("tok"), "payments", "sha256:current") if err != nil { t.Fatalf("DownloadBundle: %v", err) } @@ -407,7 +433,7 @@ func TestDownloadBundle_StripsQuotedETag(t *testing.T) { })) defer srv.Close() - _, etag, notModified, err := DownloadBundle(srv.URL, "tok", "payments", "") + _, etag, notModified, err := DownloadBundle(srv.URL, bearer("tok"), "payments", "") if err != nil { t.Fatalf("DownloadBundle: %v", err) } @@ -427,7 +453,7 @@ func TestDownloadBundle_StripsWeakValidatorPrefix(t *testing.T) { })) defer srv.Close() - _, etag, _, err := DownloadBundle(srv.URL, "tok", "payments", "") + _, etag, _, err := DownloadBundle(srv.URL, bearer("tok"), "payments", "") if err != nil { t.Fatalf("DownloadBundle: %v", err) } @@ -443,7 +469,7 @@ func TestDownloadBundle_Strips304QuotedETag(t *testing.T) { })) defer srv.Close() - _, etag, notModified, err := DownloadBundle(srv.URL, "tok", "payments", "v1.2.3") + _, etag, notModified, err := DownloadBundle(srv.URL, bearer("tok"), "payments", "v1.2.3") if err != nil { t.Fatalf("DownloadBundle: %v", err) } @@ -472,7 +498,7 @@ func TestDownloadBundle_SendsProperlyQuotedIfNoneMatch(t *testing.T) { })) defer srv.Close() - _, _, notModified, err := DownloadBundle(srv.URL, "tok", "payments", "v1.2.3") + _, _, notModified, err := DownloadBundle(srv.URL, bearer("tok"), "payments", "v1.2.3") if err != nil { t.Fatalf("DownloadBundle: %v", err) } @@ -514,7 +540,7 @@ func TestManifestPush_SendsOnlyContentAndGitSHA(t *testing.T) { })) defer srv.Close() - err := ManifestPush(srv.URL, "tok", "payments", Manifest{ + err := ManifestPush(srv.URL, bearer("tok"), "payments", Manifest{ Content: "manifest-body", PushedBy: "u@x.com", PushedAt: "2026-07-09T10:00:00Z", GitSHA: "cafe42", }) if err != nil { @@ -534,7 +560,7 @@ func TestManifestPush_OmitsEmptyGitSHA(t *testing.T) { })) defer srv.Close() - if err := ManifestPush(srv.URL, "tok", "payments", Manifest{Content: "body-only"}); err != nil { + if err := ManifestPush(srv.URL, bearer("tok"), "payments", Manifest{Content: "body-only"}); err != nil { t.Fatalf("ManifestPush: %v", err) } } @@ -546,7 +572,7 @@ func TestManifestPull_ReturnsServedManifest(t *testing.T) { })) defer srv.Close() - m, err := ManifestPull(srv.URL, "tok", "payments") + m, err := ManifestPull(srv.URL, bearer("tok"), "payments") if err != nil { t.Fatalf("ManifestPull: %v", err) } @@ -563,7 +589,7 @@ func TestManifestPull_ToleratesNullGitSHA(t *testing.T) { })) defer srv.Close() - m, err := ManifestPull(srv.URL, "tok", "payments") + m, err := ManifestPull(srv.URL, bearer("tok"), "payments") if err != nil { t.Fatalf("ManifestPull: %v", err) } @@ -575,13 +601,13 @@ func TestManifestPull_ToleratesNullGitSHA(t *testing.T) { // --- transport edge: token/baseURL required ---------------------------- func TestRequiresBaseURLAndToken(t *testing.T) { - if _, err := ListCatalogs("", "tok"); err == nil { + if _, err := ListCatalogs("", bearer("tok")); err == nil { t.Error("want error for empty baseURL") } - if _, err := ListCatalogs("http://x.test", ""); err == nil { + if _, err := ListCatalogs("http://x.test", nil); err == nil { t.Error("want error for empty token") } - if _, _, _, err := DownloadBundle("", "tok", "c", ""); err == nil { + if _, _, _, err := DownloadBundle("", bearer("tok"), "c", ""); err == nil { t.Error("want error for empty baseURL on DownloadBundle") } } diff --git a/internal/mcpmanifest/mcpmanifest.go b/internal/mcpmanifest/mcpmanifest.go index f258e7d..ecdb0da 100644 --- a/internal/mcpmanifest/mcpmanifest.go +++ b/internal/mcpmanifest/mcpmanifest.go @@ -31,11 +31,11 @@ const DefaultTimeout = 30 * time.Second // // The HTTP transport is exposed as a package var so unit tests can // inject a stub without standing up a fake server. -var Fetch = func(baseURL, token string, timeout time.Duration) ([]byte, error) { +var Fetch = func(baseURL string, auth map[string]string, timeout time.Duration) ([]byte, error) { if baseURL == "" { return nil, errors.New("profile has no URL set") } - if token == "" { + if len(auth) == 0 { return nil, errors.New("profile has no token") } // http.Client.Timeout treats any non-positive value as "no timeout" @@ -49,7 +49,9 @@ var Fetch = func(baseURL, token string, timeout time.Duration) ([]byte, error) { if err != nil { return nil, fmt.Errorf("build request: %w", err) } - req.Header.Set("Authorization", "Bearer "+token) + for k, v := range auth { + req.Header.Set(k, v) + } client := &http.Client{Timeout: timeout} resp, err := client.Do(req) if err != nil { diff --git a/internal/mcpmanifest/mcpmanifest_test.go b/internal/mcpmanifest/mcpmanifest_test.go index ce76e15..eb7551b 100644 --- a/internal/mcpmanifest/mcpmanifest_test.go +++ b/internal/mcpmanifest/mcpmanifest_test.go @@ -10,6 +10,11 @@ import ( "time" ) +// bearer builds the Auth() header map a Bearer-mode profile produces. +func bearer(tok string) map[string]string { + return map[string]string{"Authorization": "Bearer " + tok} +} + func TestFetch_HappyPath(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/ai-api/v1/mcp/manifest" { @@ -22,7 +27,7 @@ func TestFetch_HappyPath(t *testing.T) { })) defer srv.Close() - raw, err := Fetch(srv.URL, "sk_test", 5*time.Second) + raw, err := Fetch(srv.URL, bearer("sk_test"), 5*time.Second) if err != nil { t.Fatalf("Fetch err = %v", err) } @@ -32,13 +37,13 @@ func TestFetch_HappyPath(t *testing.T) { } func TestFetch_NoURL(t *testing.T) { - if _, err := Fetch("", "tok", 0); err == nil { + if _, err := Fetch("", bearer("tok"), 0); err == nil { t.Fatal("expected error for empty URL") } } func TestFetch_NoToken(t *testing.T) { - if _, err := Fetch("https://x.test", "", 0); err == nil { + if _, err := Fetch("https://x.test", nil, 0); err == nil { t.Fatal("expected error for empty token") } } @@ -56,7 +61,7 @@ func TestFetch_NegativeTimeoutDefaulted(t *testing.T) { // our fast test server — so we can't catch the bug by behavior alone. // Instead, we just exercise the path and assert success: the regression // would be a panic or a timeout error, not silent success. - if _, err := Fetch(srv.URL, "tok", -1*time.Second); err != nil { + if _, err := Fetch(srv.URL, bearer("tok"), -1*time.Second); err != nil { t.Fatalf("Fetch with negative timeout should default and succeed; got %v", err) } } @@ -67,7 +72,7 @@ func TestFetch_NonOKStatusReturnsError(t *testing.T) { _, _ = w.Write([]byte(`{"detail":"bad key"}`)) })) defer srv.Close() - _, err := Fetch(srv.URL, "tok", 5*time.Second) + _, err := Fetch(srv.URL, bearer("tok"), 5*time.Second) if err == nil { t.Fatal("expected error on 401") } diff --git a/internal/memory/client.go b/internal/memory/client.go index 61548ba..1b84f74 100644 --- a/internal/memory/client.go +++ b/internal/memory/client.go @@ -2,8 +2,9 @@ // (/ai-api/memories on the deployment). It mirrors the layout of // internal/skillcatalog: types match the server's MemoryResponse / // MemoryCreate / MemoryRecallRequest shapes, exported function vars -// give tests a seam to swap, and every transport call uses -// Authorization: Bearer . +// give tests a seam to swap, and every transport call sets the +// caller-supplied auth headers (always Authorization: Bearer , +// plus X-Facets-Username for a facets-mode control-plane PAT). // // Praxis-cli's memory commands invoke memories at audience=user|org // scope. agent_id is intentionally omitted on POST — the server's @@ -111,7 +112,7 @@ type ListParams struct { // HTTP seams — tests swap these to avoid the network. // Recall posts a RecallRequest and returns the scored matches. -var Recall = func(baseURL, token string, req RecallRequest) ([]Memory, error) { +var Recall = func(baseURL string, auth map[string]string, req RecallRequest) ([]Memory, error) { if req.Query == "" { return nil, fmt.Errorf("query is required") } @@ -119,11 +120,11 @@ var Recall = func(baseURL, token string, req RecallRequest) ([]Memory, error) { if err != nil { return nil, err } - return doJSON[[]Memory](baseURL, token, http.MethodPost, basePath+"/recall", bytes.NewReader(body)) + return doJSON[[]Memory](baseURL, auth, http.MethodPost, basePath+"/recall", bytes.NewReader(body)) } // List fetches memories filtered by ListParams. -var List = func(baseURL, token string, p ListParams) ([]Memory, error) { +var List = func(baseURL string, auth map[string]string, p ListParams) ([]Memory, error) { q := url.Values{} if p.Category != "" { q.Set("category", p.Category) @@ -144,12 +145,12 @@ var List = func(baseURL, token string, p ListParams) ([]Memory, error) { if encoded := q.Encode(); encoded != "" { path = path + "?" + encoded } - return doJSON[[]Memory](baseURL, token, http.MethodGet, path, nil) + return doJSON[[]Memory](baseURL, auth, http.MethodGet, path, nil) } // Create posts a CreateRequest WITHOUT ?agent_id= (audience-driven cell // placement on the server). Returns the persisted Memory. -var Create = func(baseURL, token string, req CreateRequest) (*Memory, error) { +var Create = func(baseURL string, auth map[string]string, req CreateRequest) (*Memory, error) { if req.Title == "" { return nil, fmt.Errorf("title is required") } @@ -160,7 +161,7 @@ var Create = func(baseURL, token string, req CreateRequest) (*Memory, error) { if err != nil { return nil, err } - m, err := doJSON[Memory](baseURL, token, http.MethodPost, basePath, bytes.NewReader(body)) + m, err := doJSON[Memory](baseURL, auth, http.MethodPost, basePath, bytes.NewReader(body)) if err != nil { return nil, err } @@ -175,12 +176,12 @@ var Create = func(baseURL, token string, req CreateRequest) (*Memory, error) { // propagates (noctx lint expectation). The http.Client{Timeout} on top // is belt-and-braces — it also bounds connection + handshake time // before the context deadline kicks in. -func doJSON[T any](baseURL, token, method, path string, body io.Reader) (T, error) { +func doJSON[T any](baseURL string, auth map[string]string, method, path string, body io.Reader) (T, error) { var zero T if baseURL == "" { return zero, fmt.Errorf("baseURL is required") } - if token == "" { + if len(auth) == 0 { return zero, fmt.Errorf("token is required") } @@ -192,7 +193,9 @@ func doJSON[T any](baseURL, token, method, path string, body io.Reader) (T, erro if err != nil { return zero, err } - req.Header.Set("Authorization", "Bearer "+token) + for k, v := range auth { + req.Header.Set(k, v) + } req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") diff --git a/internal/memory/client_test.go b/internal/memory/client_test.go index b5bb475..3ebdf08 100644 --- a/internal/memory/client_test.go +++ b/internal/memory/client_test.go @@ -9,6 +9,11 @@ import ( "testing" ) +// bearer builds the Auth() header map a Bearer-mode profile produces. +func bearer(tok string) map[string]string { + return map[string]string{"Authorization": "Bearer " + tok} +} + // stubServer spins up an httptest.Server with a request-validating // handler. The handler asserts auth headers and content-type, and // returns whatever body the table-row provides. @@ -51,7 +56,7 @@ func TestRecall_HappyPath_ReturnsScoredMatches(t *testing.T) { {"id":"m2","slug":"backoff","title":"Backoff","content":"...","relevance_score":0.87,"organization_id":"o","kind":"feedback","audience":"user","category":"fact","importance":"medium","tags":[]} ]` srv := stubServer(t, http.MethodPost, "/ai-api/memories/recall", 200, body, "tok") - got, err := Recall(srv.URL, "tok", RecallRequest{Query: "retry handling", Limit: 5}) + got, err := Recall(srv.URL, bearer("tok"), RecallRequest{Query: "retry handling", Limit: 5}) if err != nil { t.Fatalf("Recall: %v", err) } @@ -67,7 +72,7 @@ func TestRecall_EmptyQuery_RejectedClientSide(t *testing.T) { // No HTTP call should happen — assert by giving an obviously-broken // baseURL so a network call would error differently than "query is // required". - _, err := Recall("http://no-such-host.invalid", "tok", RecallRequest{Query: ""}) + _, err := Recall("http://no-such-host.invalid", bearer("tok"), RecallRequest{Query: ""}) if err == nil || !strings.Contains(err.Error(), "query is required") { t.Fatalf("err = %v; want 'query is required'", err) } @@ -75,7 +80,7 @@ func TestRecall_EmptyQuery_RejectedClientSide(t *testing.T) { func TestRecall_ServerError_PropagatesStatus(t *testing.T) { srv := stubServer(t, http.MethodPost, "/ai-api/memories/recall", 500, `{"detail":"boom"}`, "tok") - _, err := Recall(srv.URL, "tok", RecallRequest{Query: "x"}) + _, err := Recall(srv.URL, bearer("tok"), RecallRequest{Query: "x"}) if err == nil || !strings.Contains(err.Error(), "HTTP 500") { t.Fatalf("err = %v; want HTTP 500", err) } @@ -93,7 +98,7 @@ func TestList_BuildsQueryStringFromParams(t *testing.T) { })) defer srv.Close() - _, err := List(srv.URL, "tok", ListParams{ + _, err := List(srv.URL, bearer("tok"), ListParams{ Category: "fact", Importance: "high", Tags: []string{"infra", "ops"}, @@ -130,7 +135,7 @@ func TestList_OmitsEmptyParams(t *testing.T) { })) defer srv.Close() - if _, err := List(srv.URL, "tok", ListParams{}); err != nil { + if _, err := List(srv.URL, bearer("tok"), ListParams{}); err != nil { t.Fatalf("List: %v", err) } if capturedQuery != "" { @@ -152,7 +157,7 @@ func TestCreate_PostsBodyWithoutAgentID(t *testing.T) { })) defer srv.Close() - got, err := Create(srv.URL, "tok", CreateRequest{ + got, err := Create(srv.URL, bearer("tok"), CreateRequest{ Title: "New fact", Content: "facts", Audience: AudienceUser, @@ -188,7 +193,7 @@ func TestCreate_MissingFields_RejectedClientSide(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := Create("http://no-such-host.invalid", "tok", tt.req) + _, err := Create("http://no-such-host.invalid", bearer("tok"), tt.req) if err == nil || !strings.Contains(err.Error(), tt.want) { t.Fatalf("err = %v; want %q", err, tt.want) } @@ -200,14 +205,15 @@ func TestCreate_MissingFields_RejectedClientSide(t *testing.T) { func TestDoJSON_RejectsEmptyBaseURLOrToken(t *testing.T) { tests := []struct { - name, baseURL, token, want string + name, baseURL, want string + auth map[string]string }{ - {"no baseURL", "", "tok", "baseURL is required"}, - {"no token", "http://x", "", "token is required"}, + {"no baseURL", "", "baseURL is required", bearer("tok")}, + {"no token", "http://x", "token is required", nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := Recall(tt.baseURL, tt.token, RecallRequest{Query: "x"}) + _, err := Recall(tt.baseURL, tt.auth, RecallRequest{Query: "x"}) if err == nil || !strings.Contains(err.Error(), tt.want) { t.Fatalf("err = %v; want %q", err, tt.want) } diff --git a/internal/skillcatalog/skillcatalog.go b/internal/skillcatalog/skillcatalog.go index 02b11a7..2429e79 100644 --- a/internal/skillcatalog/skillcatalog.go +++ b/internal/skillcatalog/skillcatalog.go @@ -174,11 +174,11 @@ func yamlString(s string) string { } // Fetch is the HTTP seam — tests swap it to avoid hitting the network. -var Fetch = func(baseURL, token string) ([]Skill, error) { +var Fetch = func(baseURL string, auth map[string]string) ([]Skill, error) { if baseURL == "" { return nil, fmt.Errorf("baseURL is required") } - if token == "" { + if len(auth) == 0 { return nil, fmt.Errorf("token is required") } @@ -187,7 +187,9 @@ var Fetch = func(baseURL, token string) ([]Skill, error) { if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+token) + for k, v := range auth { + req.Header.Set(k, v) + } req.Header.Set("Accept", "application/json") client := &http.Client{Timeout: defaultTimeout} diff --git a/internal/skillcatalog/skillcatalog_test.go b/internal/skillcatalog/skillcatalog_test.go index 4cfa9bc..dd99c62 100644 --- a/internal/skillcatalog/skillcatalog_test.go +++ b/internal/skillcatalog/skillcatalog_test.go @@ -7,6 +7,11 @@ import ( "testing" ) +// bearer builds the Auth() header map a Bearer-mode profile produces. +func bearer(tok string) map[string]string { + return map[string]string{"Authorization": "Bearer " + tok} +} + func TestFetch_HappyPath(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/ai-api/v1/skills/bundle" { @@ -25,7 +30,7 @@ func TestFetch_HappyPath(t *testing.T) { })) defer srv.Close() - skills, err := Fetch(srv.URL, "sk_test_X") + skills, err := Fetch(srv.URL, bearer("sk_test_X")) if err != nil { t.Fatalf("err = %v", err) } @@ -47,7 +52,7 @@ func TestFetch_HTTPError_IncludesBody(t *testing.T) { })) defer srv.Close() - _, err := Fetch(srv.URL, "bad_token") + _, err := Fetch(srv.URL, bearer("bad_token")) if err == nil { t.Fatal("expected error") } @@ -57,10 +62,10 @@ func TestFetch_HTTPError_IncludesBody(t *testing.T) { } func TestFetch_RequiresURLAndToken(t *testing.T) { - if _, err := Fetch("", "t"); err == nil { + if _, err := Fetch("", bearer("t")); err == nil { t.Error("expected error for empty URL") } - if _, err := Fetch("https://x", ""); err == nil { + if _, err := Fetch("https://x", nil); err == nil { t.Error("expected error for empty token") } } @@ -74,7 +79,7 @@ func TestFetch_TrailingSlashURL(t *testing.T) { })) defer srv.Close() - if _, err := Fetch(srv.URL+"/", "t"); err != nil { + if _, err := Fetch(srv.URL+"/", bearer("t")); err != nil { t.Fatal(err) } // Path should NOT have double slash @@ -89,7 +94,7 @@ func TestFetch_BadJSON(t *testing.T) { })) defer srv.Close() - _, err := Fetch(srv.URL, "t") + _, err := Fetch(srv.URL, bearer("t")) if err == nil || !strings.Contains(err.Error(), "parse bundle") { t.Errorf("err = %v", err) } @@ -286,7 +291,7 @@ func TestFetch_ParsesSupportingFiles(t *testing.T) { })) defer srv.Close() - skills, err := Fetch(srv.URL, "tok") + skills, err := Fetch(srv.URL, bearer("tok")) if err != nil { t.Fatalf("err = %v", err) }