From a4a2ccaab4a7d2b9eda43c25479875f306cd6f59 Mon Sep 17 00:00:00 2001 From: jarvis Date: Tue, 29 Sep 2026 14:20:56 -0400 Subject: [PATCH] feat(cli): preview Context authority before access requests Signed-off-by: jarvis --- README.md | 9 ++ internal/catalog/command_surface.go | 1 + internal/cli/cli.go | 13 +-- internal/cli/context_access.go | 175 ++++++++++++++++++++++++++++ internal/cli/context_access_test.go | 157 +++++++++++++++++++++++++ internal/cli/contexts.go | 89 ++++++++------ specs/cli.feature | 12 ++ 7 files changed, 412 insertions(+), 44 deletions(-) create mode 100644 internal/cli/context_access.go create mode 100644 internal/cli/context_access_test.go diff --git a/README.md b/README.md index 6085a0e..1e76957 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,15 @@ precheck permissions or change Contexts. Server errors retain a nonzero exit status; `--json` preserves the server error body on stdout, with diagnostics on stderr. +The list also includes permission counts. Use repeated `--scope` options on +`realmroot toolbox platform context` to compare permission matches across the +complete list without changing the selected Context. Before an access +request, CLI prints the selected Context's available ID, name, type, and +selection source to stderr. JSON request results include that Context and the +requested scopes. +Permission matches are informational: the server decides whether to accept the +request, and its JSON error response is preserved on stdout with `--json`. + Use `realmroot toolbox sync ` after that Resource Server publishes a changed OpenAPI contract. Sync bypasses the cached OpenAPI document and atomically refreshes the generated command catalog. It does not request diff --git a/internal/catalog/command_surface.go b/internal/catalog/command_surface.go index 4dc7091..ef3b3b1 100644 --- a/internal/catalog/command_surface.go +++ b/internal/catalog/command_surface.go @@ -13,6 +13,7 @@ var toolboxCommands = []CommandHelp{ var resourceServerCommands = []CommandHelp{ {Name: "context", Usage: " context", Description: "list available Contexts"}, + {Name: "context", Usage: " context --scope ...", Description: "compare permission matches across all Contexts"}, {Name: "context", Usage: " context show ", Description: "show one Context"}, {Name: "context", Usage: " context [use |clear]", Description: "select or clear the default Context"}, } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d128eb1..321b7f7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -165,7 +165,7 @@ func (a *App) execCommand() *cobra.Command { return err } observability.LogDuration(logger, observability.LevelTrace, "authorization_context.discover", phaseStartedAt, "resource_server", server.CommandName) - selected, err := a.resolveContext(service, server, details, options.context) + selected, source, err := a.resolveContextSelection(service, server, details, options.context) if err != nil { return err } @@ -182,7 +182,7 @@ func (a *App) execCommand() *cobra.Command { ExactAuthorizationContext: true, EffectiveScopes: executionScopes(details, selected, server.Scopes), RequestAuthority: func(ctx context.Context, scopes []string) error { - _, err := accessService.Request(ctx, server, scopes, selected, "Run the requested native command", access.RequestOptions{}) + _, err := a.requestAccess(ctx, accessService, server, scopes, details, selected, source, "Run the requested native command", access.RequestOptions{}) return err }, }) @@ -303,7 +303,7 @@ func (a *App) requestCommand() *cobra.Command { if err != nil { return err } - details, err := a.resolveContext(agentService, server, contexts, contextID) + details, source, err := a.resolveContextSelection(agentService, server, contexts, contextID) if err != nil { return err } @@ -311,11 +311,10 @@ func (a *App) requestCommand() *cobra.Command { if err != nil { return err } - receipt, err := accessService.Request(ctx, server, scopes, details, reason, access.RequestOptions{Handoff: handoff}) + receipt, err := a.requestAccess(ctx, accessService, server, scopes, contexts, details, source, reason, access.RequestOptions{Handoff: handoff}) if err != nil { - var responseError *access.ResponseError - if a.json && errors.As(err, &responseError) && json.Valid(responseError.Body) { - if printErr := a.printJSON(json.RawMessage(responseError.Body)); printErr != nil { + if a.json && len(receipt.Error) > 0 { + if printErr := a.printJSON(receipt.Error); printErr != nil { return printErr } } diff --git a/internal/cli/context_access.go b/internal/cli/context_access.go new file mode 100644 index 0000000..8cbaa32 --- /dev/null +++ b/internal/cli/context_access.go @@ -0,0 +1,175 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" + "text/tabwriter" + + "github.com/realmroot/cli/internal/access" + "github.com/realmroot/cli/internal/catalog" +) + +type scopeMatch struct { + Status string `json:"status"` + Authorized []string `json:"authorizedScopes"` + Requestable []string `json:"requestableScopes"` + Unavailable []string `json:"unavailableScopes"` +} + +type selectedContextSummary struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Source string `json:"source"` +} + +type accessRequestResult struct { + access.Receipt + Context selectedContextSummary `json:"context"` + RequestedScopes []string `json:"requestedScopes"` + Contexts []contextListItem `json:"contexts"` + Error json.RawMessage `json:"-"` +} + +type resourceAccessRequester interface { + Request(context.Context, catalog.ResourceServer, []string, []map[string]any, string, access.RequestOptions) (access.Receipt, error) +} + +func requestedScopes(scopes []string) []string { + result := make([]string, 0, len(scopes)) + for _, scope := range scopes { + if scope = strings.TrimSpace(scope); scope != "" { + result = append(result, scope) + } + } + slices.Sort(result) + return slices.Compact(result) +} + +func contextIdentity(detail catalog.AuthorizationDetail) (string, string) { + if detail.AuthorizationDetail["type"] == "realmroot_authority" { + kind, _ := detail.AuthorizationDetail["authority"].(string) + return detail.ID, kind + } + return detail.ID, "resource" +} + +func matchContextScopes(detail catalog.AuthorizationDetail, scopes []string) scopeMatch { + match := scopeMatch{Status: "authorized", Authorized: []string{}, Requestable: []string{}, Unavailable: []string{}} + for _, scope := range requestedScopes(scopes) { + switch { + case slices.Contains(detail.AuthorizedScopes, scope): + match.Authorized = append(match.Authorized, scope) + case slices.Contains(detail.RequestableScopes, scope): + match.Requestable = append(match.Requestable, scope) + default: + match.Unavailable = append(match.Unavailable, scope) + } + } + if len(match.Requestable) > 0 { + match.Status = "requestable" + } + if len(match.Unavailable) > 0 { + match.Status = "unavailable" + // An external account can be connected or expanded through approval. + if detail.AccountAuthorizationStatus != "not_required" { + match.Status = "account_authorization_required" + } + } + return match +} + +func contextMatches(details []catalog.AuthorizationDetail, selected []map[string]any, scopes []string, published ...catalog.Scope) []contextListItem { + items := listContexts(details, selected, published...) + for index, detail := range details { + match := matchContextScopes(detail, scopes) + items[index].Match = &match + } + return items +} + +func summarizeAccessRequest(server catalog.ResourceServer, scopes []string, details []catalog.AuthorizationDetail, selected []map[string]any, source string) accessRequestResult { + scopes = requestedScopes(scopes) + result := accessRequestResult{ + Receipt: access.Receipt{ResourceServer: server.CommandName}, + Context: selectedContextSummary{Source: source}, + RequestedScopes: scopes, + Contexts: contextMatches(details, selected, scopes, server.Scopes...), + } + for _, detail := range details { + if !sameDetails(detail.AuthorizationDetail, selected) { + continue + } + id, kind := contextIdentity(detail) + result.Context = selectedContextSummary{ID: id, Name: detail.Name, Type: kind, Source: source} + } + return result +} + +func (a *App) requestAccess(ctx context.Context, service resourceAccessRequester, server catalog.ResourceServer, scopes []string, details []catalog.AuthorizationDetail, selected []map[string]any, source, reason string, options access.RequestOptions) (accessRequestResult, error) { + result := summarizeAccessRequest(server, scopes, details, selected, source) + if printErr := printAccessContext(a.stderr, result); printErr != nil { + return result, printErr + } + var err error + result.Receipt, err = service.Request(ctx, server, result.RequestedScopes, selected, reason, options) + if err != nil { + var responseError *access.ResponseError + if errors.As(err, &responseError) && json.Valid(responseError.Body) { + result.Error = append(json.RawMessage(nil), responseError.Body...) + } + } + return result, err +} + +func printAccessContext(w io.Writer, result accessRequestResult) error { + if _, err := fmt.Fprintf(w, "Resource Server: %s\nSelection source: %s\nRequested scopes: %s\n", + result.ResourceServer, result.Context.Source, strings.Join(result.RequestedScopes, ", ")); err != nil { + return err + } + if result.Context.Name != "" { + if _, err := fmt.Fprintf(w, "Context: %s (%s)\n", result.Context.Name, result.Context.Type); err != nil { + return err + } + } + if result.Context.ID != "" { + if _, err := fmt.Fprintf(w, "Context ID: %s\n", result.Context.ID); err != nil { + return err + } + } + if len(result.Contexts) == 0 { + return nil + } + return printContextRows(w, result.Contexts) +} + +func printContextRows(w io.Writer, items []contextListItem) error { + table := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + fmt.Fprintln(table, "CURRENT\tID\tNAME\tTYPE\tACCOUNT\tAGENT GRANTED\tREQUESTABLE\tNOT CURRENTLY REQUESTABLE\tGRANTED COUNT\tREQUESTABLE COUNT\tMATCH") + for _, item := range items { + current, match := "", "" + if item.Current { + current = "*" + } + id := item.ID + if id == "" { + id = "-" + } + if item.Match != nil { + match = item.Match.Status + if len(item.Match.Unavailable) > 0 { + match += ": " + strings.Join(item.Match.Unavailable, ", ") + } + } + fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%d\t%s\n", + current, id, item.Name, item.Type, item.AccountAuthorizationStatus, + scopeNames(item.AuthorizedScopes), scopeNames(item.RequestableScopes), scopeNames(item.UnavailableScopes), + item.AuthorizedScopeCount, item.RequestableScopeCount, match) + } + return table.Flush() +} diff --git a/internal/cli/context_access_test.go b/internal/cli/context_access_test.go new file mode 100644 index 0000000..0102ffc --- /dev/null +++ b/internal/cli/context_access_test.go @@ -0,0 +1,157 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "reflect" + "testing" + + "github.com/realmroot/cli/internal/access" + "github.com/realmroot/cli/internal/agent" + "github.com/realmroot/cli/internal/catalog" +) + +func authorityContexts() []catalog.AuthorizationDetail { + return []catalog.AuthorizationDetail{ + {ID: "ctx_user", Name: "Ambor", AccountAuthorizationStatus: "not_required", AuthorizationDetail: map[string]any{"type": "realmroot_authority", "authority": "user", "id": "user-1"}, AuthorizedScopes: []string{"agents:read"}}, + {ID: "ctx_org", Name: "Platform", AccountAuthorizationStatus: "not_required", AuthorizationDetail: map[string]any{"type": "realmroot_authority", "authority": "organization", "id": "org-1"}, AuthorizedScopes: []string{"applications:read"}, RequestableScopes: []string{"permissions:read"}}, + } +} + +type accessRecorder struct { + calls int + details []map[string]any + scopes []string + status string + err error +} + +func (r *accessRecorder) Request(_ context.Context, server catalog.ResourceServer, scopes []string, details []map[string]any, _ string, _ access.RequestOptions) (access.Receipt, error) { + r.calls++ + r.details, r.scopes = details, scopes + return access.Receipt{Status: r.status, ResourceServer: server.CommandName, Scopes: scopes}, r.err +} + +func TestAccessRequestDefersBoundaryDecisionToServer(t *testing.T) { + // [spec: cli/access-context-preflight] + details := authorityContexts() + selected := []map[string]any{details[0].AuthorizationDetail} + body := []byte(`{"error":{"code":"requested_scopes_exceed_controller_boundary","message":"Controller cannot grant these scopes. No approval request was created.","requestId":"server-request-1","details":{"context":{"id":"user-1","type":"user"},"scopes":["applications:read"]}}}`) + service := &accessRecorder{err: &access.ResponseError{StatusCode: 403, Body: body}} + var stderr bytes.Buffer + result, err := (&App{stderr: &stderr}).requestAccess(context.Background(), service, catalog.ResourceServer{CommandName: "platform", ConnectionStatus: "not_required"}, []string{"applications:read"}, details, selected, "saved_default", "inspect", access.RequestOptions{}) + if err != service.err || service.calls != 1 || !reflect.DeepEqual(service.details, selected) { + t.Fatalf("must call server without switching Context: err=%v service=%+v", err, service) + } + if !bytes.Equal(result.Error, body) { + t.Fatalf("server error changed: %s", result.Error) + } + if len(result.Contexts) != 2 || !result.Contexts[0].Current { + t.Fatalf("Contexts changed: %+v", result.Contexts) + } + // Discovery can be stale: a missing catalog permission must not override server success. + service.err, service.status = nil, "ready" + result, err = (&App{stderr: &stderr}).requestAccess(context.Background(), service, catalog.ResourceServer{ConnectionStatus: "not_required"}, []string{"applications:read"}, details, selected, "saved_default", "inspect", access.RequestOptions{}) + if err != nil || result.Status != "ready" || len(result.Error) != 0 || service.calls != 2 { + t.Fatalf("server decision ignored: %+v %v", result, err) + } +} + +func TestContextScopeComparisonKeepsEveryContextAndItsPermissionFacts(t *testing.T) { + // [spec: cli/resource-server-context] + details := authorityContexts() + selected := []map[string]any{details[0].AuthorizationDetail} + items := contextMatches(details, selected, []string{"permissions:read"}, + catalog.Scope{Value: "agents:read"}, catalog.Scope{Value: "permissions:read"}) + if len(items) != 2 || !items[0].Current || items[1].Current { + t.Fatalf("comparison filtered or changed Contexts: %+v", items) + } + if items[0].ID != "ctx_user" || items[0].Match.Status != "unavailable" || items[0].AuthorizedScopeCount != 1 || len(items[0].AuthorizedScopes) != 1 { + t.Fatalf("user Context facts changed: %+v", items[0]) + } + if items[1].ID != "ctx_org" || items[1].Match.Status != "requestable" || items[1].RequestableScopeCount != 1 || len(items[1].RequestableScopes) != 1 { + t.Fatalf("organization Context facts changed: %+v", items[1]) + } + var output bytes.Buffer + if err := (&App{stdout: &output}).printContexts(contextResult{ + ResourceServer: "platform", Contexts: items, RequestedScopes: []string{"permissions:read"}, + }); err != nil { + t.Fatal(err) + } + for _, expected := range []string{"ctx_user", "ctx_org", "agents:read", "permissions:read", "Requested scopes:"} { + if !bytes.Contains(output.Bytes(), []byte(expected)) { + t.Fatalf("comparison omitted %q: %s", expected, &output) + } + } +} + +func TestContextScopeFlagCannotChangeSelection(t *testing.T) { + var stderr bytes.Buffer + err := (&App{stderr: &stderr}).contextCommand(t.Context(), nil, nil, "platform", []string{"--scope", "agents:read", "use", "ctx_user"}) + if err == nil || err.Error() != "--scope applies only to the Context list" { + t.Fatalf("selection with scope error = %v", err) + } +} + +func TestAccessResultsKeepContextMetadataAndExternalExpansion(t *testing.T) { + // [spec: cli/access-context-preflight] + for _, external := range []bool{false, true} { + for _, status := range []string{"pending", "ready"} { + details := authorityContexts()[1:] + server := catalog.ResourceServer{CommandName: "platform", ConnectionStatus: "not_required"} + if external { + server.ConnectionStatus = "connected" + details[0].AccountAuthorizationStatus = "authorized" + details[0].AuthorizedScopes, details[0].RequestableScopes = nil, nil + } + selected := []map[string]any{details[0].AuthorizationDetail} + service := &accessRecorder{status: status} + var stderr bytes.Buffer + result, err := (&App{stderr: &stderr, json: true}).requestAccess(context.Background(), service, server, []string{" permissions:read ", "permissions:read"}, details, selected, "command_line", "inspect", access.RequestOptions{Handoff: true}) + if err != nil || service.calls != 1 || result.Status != status || !reflect.DeepEqual(service.details, selected) || !reflect.DeepEqual(service.scopes, []string{"permissions:read"}) { + t.Fatalf("external=%v result=%+v err=%v service=%+v", external, result, err, service) + } + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"id":"ctx_org"`, `"type":"organization"`, `"source":"command_line"`} { + if !bytes.Contains(encoded, []byte(want)) { + t.Fatalf("missing %s in %s", want, encoded) + } + } + if stderr.Len() == 0 { + t.Fatal("missing pre-approval diagnostics in JSON mode") + } + } + } +} + +func TestContextSelectionReportsSourceWithoutChangingDefault(t *testing.T) { + // [spec: cli/access-context-preflight] + t.Setenv("REALMROOT_STATE_DIR", t.TempDir()) + service, err := agent.NewService("https://id.example.com", http.DefaultClient) + if err != nil { + t.Fatal(err) + } + server := catalog.ResourceServer{ResourceURL: "https://api.example.com"} + details := authorityContexts() + app := &App{} + _, source, err := app.resolveContextSelection(service, server, details[:1], "") + if err != nil || source != "only_available" { + t.Fatalf("source=%s err=%v", source, err) + } + if err := service.StoreContext(server.ResourceURL, []map[string]any{details[0].AuthorizationDetail}); err != nil { + t.Fatal(err) + } + selected, source, err := app.resolveContextSelection(service, server, details, "ctx_org") + if err != nil || source != "command_line" || !sameDetails(details[1].AuthorizationDetail, selected) { + t.Fatalf("source=%s selected=%v err=%v", source, selected, err) + } + selected, source, err = app.resolveContextSelection(service, server, details, "") + if err != nil || source != "saved_default" || !sameDetails(details[0].AuthorizationDetail, selected) { + t.Fatalf("source=%s selected=%v err=%v", source, selected, err) + } +} diff --git a/internal/cli/contexts.go b/internal/cli/contexts.go index 66c92ca..b46efbd 100644 --- a/internal/cli/contexts.go +++ b/internal/cli/contexts.go @@ -9,10 +9,10 @@ import ( "slices" "sort" "strings" - "text/tabwriter" "github.com/realmroot/cli/internal/agent" "github.com/realmroot/cli/internal/catalog" + "github.com/spf13/pflag" ) type contextSummary struct { @@ -27,19 +27,23 @@ type contextSummary struct { } type contextListItem struct { - Type string `json:"type"` - AuthorizedScopes []string `json:"authorizedScopes"` - RequestableScopes []string `json:"requestableScopes"` - UnavailableScopes []string `json:"unavailableScopes"` - ID string `json:"id,omitempty"` - Name string `json:"name"` - AccountAuthorizationStatus string `json:"accountAuthorizationStatus"` - Current bool `json:"current"` + Type string `json:"type"` + AuthorizedScopes []string `json:"authorizedScopes"` + RequestableScopes []string `json:"requestableScopes"` + UnavailableScopes []string `json:"unavailableScopes"` + ID string `json:"id,omitempty"` + Name string `json:"name"` + AccountAuthorizationStatus string `json:"accountAuthorizationStatus"` + Current bool `json:"current"` + AuthorizedScopeCount int `json:"authorizedScopeCount"` + RequestableScopeCount int `json:"requestableScopeCount"` + Match *scopeMatch `json:"match,omitempty"` } type contextResult struct { - ResourceServer string `json:"resourceServer"` - Contexts []contextListItem `json:"contexts"` + ResourceServer string `json:"resourceServer"` + Contexts []contextListItem `json:"contexts"` + RequestedScopes []string `json:"requestedScopes,omitempty"` } type contextSelectionResult struct { @@ -71,7 +75,8 @@ func listContexts(details []catalog.AuthorizationDetail, selected []map[string]a result = append(result, contextListItem{ Type: kind, AuthorizedScopes: append([]string{}, detail.AuthorizedScopes...), RequestableScopes: append([]string{}, detail.RequestableScopes...), UnavailableScopes: unavailable, ID: detail.ID, Name: detail.Name, AccountAuthorizationStatus: detail.AccountAuthorizationStatus, - Current: sameDetails(detail.AuthorizationDetail, selected), + Current: sameDetails(detail.AuthorizationDetail, selected), + AuthorizedScopeCount: len(detail.AuthorizedScopes), RequestableScopeCount: len(detail.RequestableScopes), }) } return result @@ -92,6 +97,17 @@ func summarizeContexts(details []catalog.AuthorizationDetail, selected []map[str } func (a *App) contextCommand(ctx context.Context, service *agent.Service, client *catalog.Client, serverName string, args []string) error { + flags := pflag.NewFlagSet("context", pflag.ContinueOnError) + flags.SetOutput(a.stderr) + scopes := flags.StringArray("scope", nil, "show permission matches without filtering Contexts (repeatable)") + if err := flags.Parse(args); err != nil { + return err + } + args = flags.Args() + *scopes = requestedScopes(*scopes) + if len(*scopes) > 0 && len(args) > 0 { + return errors.New("--scope applies only to the Context list") + } server, err := client.Find(ctx, serverName) if err != nil { return err @@ -116,7 +132,11 @@ func (a *App) contextCommand(ctx context.Context, service *agent.Service, client return selectedErr } if len(args) == 0 { - return a.printContexts(contextResult{ResourceServer: server.CommandName, Contexts: listContexts(details, selected, server.Scopes...)}) + items := listContexts(details, selected, server.Scopes...) + if len(*scopes) > 0 { + items = contextMatches(details, selected, *scopes, server.Scopes...) + } + return a.printContexts(contextResult{ResourceServer: server.CommandName, Contexts: items, RequestedScopes: *scopes}) } if len(args) != 2 || (args[0] != "show" && args[0] != "use") { return fmt.Errorf("usage: realmroot toolbox %s context [show|use] | clear", server.CommandName) @@ -144,42 +164,47 @@ func (a *App) contextCommand(ctx context.Context, service *agent.Service, client } func (a *App) resolveContext(service *agent.Service, server catalog.ResourceServer, details []catalog.AuthorizationDetail, contextID string) ([]map[string]any, error) { + selected, _, err := a.resolveContextSelection(service, server, details, contextID) + return selected, err +} + +func (a *App) resolveContextSelection(service *agent.Service, server catalog.ResourceServer, details []catalog.AuthorizationDetail, contextID string) ([]map[string]any, string, error) { if contextID != "" { detail, err := contextBySelector(details, contextID) if err != nil { var unavailable contextUnavailableError if errors.As(err, &unavailable) { - return nil, fmt.Errorf("%w; connect or update it in Realmroot Connections: %s/connections", err, service.Origin()) + return nil, "", fmt.Errorf("%w; connect or update it in Realmroot Connections: %s/connections", err, service.Origin()) } - return nil, err + return nil, "", err } - return []map[string]any{detail.AuthorizationDetail}, nil + return []map[string]any{detail.AuthorizationDetail}, "command_line", nil } selected, err := service.SelectedContext(server.ResourceURL) if err == nil { for _, detail := range details { if sameDetails(detail.AuthorizationDetail, selected) { - return selected, nil + return selected, "saved_default", nil } } if len(details) == 0 { if err := service.ClearContext(server.ResourceURL); err != nil { - return nil, err + return nil, "", err } - return disconnectedAuthorizationDetails(server), nil + return disconnectedAuthorizationDetails(server), "resource_default", nil } - return nil, fmt.Errorf("the selected %s Context is no longer available; run `realmroot toolbox %s context`", server.CommandName, server.CommandName) + return nil, "", fmt.Errorf("the selected %s Context is no longer available; run `realmroot toolbox %s context`", server.CommandName, server.CommandName) } if !errors.Is(err, os.ErrNotExist) { - return nil, err + return nil, "", err } switch len(details) { case 0: - return disconnectedAuthorizationDetails(server), nil + return disconnectedAuthorizationDetails(server), "resource_default", nil case 1: - return []map[string]any{details[0].AuthorizationDetail}, nil + return []map[string]any{details[0].AuthorizationDetail}, "only_available", nil default: - return nil, fmt.Errorf("Resource Server %q has multiple Contexts; select one with `realmroot toolbox %s context use ` or pass --context ", server.CommandName, server.CommandName) + return nil, "", fmt.Errorf("Resource Server %q has multiple Contexts; select one with `realmroot toolbox %s context use ` or pass --context ", server.CommandName, server.CommandName) } } @@ -228,20 +253,10 @@ func (a *App) printContexts(result contextResult) error { fmt.Fprintf(a.stdout, "Resource Server %q does not define Contexts.\n", result.ResourceServer) return nil } - w := tabwriter.NewWriter(a.stdout, 0, 4, 2, ' ', 0) - fmt.Fprintln(w, "CURRENT\tID\tNAME\tTYPE\tACCOUNT\tAGENT GRANTED\tREQUESTABLE\tNOT CURRENTLY REQUESTABLE") - for _, item := range result.Contexts { - current := "" - if item.Current { - current = "*" - } - id := item.ID - if id == "" { - id = "-" - } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", current, id, item.Name, item.Type, item.AccountAuthorizationStatus, scopeNames(item.AuthorizedScopes), scopeNames(item.RequestableScopes), scopeNames(item.UnavailableScopes)) + if len(result.RequestedScopes) > 0 { + fmt.Fprintf(a.stdout, "Requested scopes: %s\n", strings.Join(result.RequestedScopes, ", ")) } - return w.Flush() + return printContextRows(a.stdout, result.Contexts) } func (a *App) printContext(resourceServer string, item contextSummary) error { diff --git a/specs/cli.feature b/specs/cli.feature index 0e9349b..c5e6121 100644 --- a/specs/cli.feature +++ b/specs/cli.feature @@ -82,6 +82,7 @@ Feature: Realmroot Toolbox command line When the Agent runs "realmroot toolbox github context" Then Toolbox lists every stable Context ID, display name, identity type, authorization status, and current selection And the ordinary list includes Agent-granted scopes, requestable scopes, and published scopes not currently requestable + And repeated "--scope" options show matching authorized, requestable, and unavailable scopes without filtering Contexts And Context details show the Resource Server supplied description and attributes When the Agent selects the Context by its stable ID Then subsequent GitHub operations use that Context by default @@ -107,6 +108,17 @@ Feature: Realmroot Toolbox command line And the resulting credential offer is stored without a target private key or access token And the command returns only the ready authority without exposing the internal credential binding + @journey:access-context-preflight @entrypoint:agent-request + Scenario: Inspect the selected Context before requesting authority + Given the Resource Server publishes Contexts and their authorized and requestable scopes + When the Agent requests scopes using an explicit, saved, or sole available Context + Then Toolbox reports the stable Context ID, name, type, and selection source before requesting approval + And it shows every Context's match for the requested scopes without changing the selected Context + And the CLI submits the selected Context and scopes to the server even when discovery reports missing permissions + And a server rejection exits with code 1 and preserves its JSON error body + And pending and completed JSON results preserve the selected Context and selection source + But external account connection or scope expansion can still proceed through controller approval + @journey:task-scoped-access-handoff @entrypoint:agent-request Scenario: Hand an approval link to a remote controller Given the controller is not using the Agent's computer