diff --git a/README.md b/README.md index fbc2011..76f5d71 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,9 @@ realmroot toolbox realmroot toolbox github realmroot toolbox sync github realmroot toolbox github context -realmroot toolbox github context show realmroot -realmroot toolbox github context use realmroot -realmroot agent request --resource-server github --context realmroot --scope contents:read +realmroot toolbox github context show +realmroot toolbox github context use +realmroot agent request --resource-server github --context --scope contents:read realmroot toolbox cloudflare --search "list zones" realmroot toolbox cloudflare --scope zone.read realmroot toolbox cloudflare --all @@ -93,7 +93,7 @@ realmroot exec realmroot exec github realmroot exec github -- git fetch origin realmroot exec github -- gh pr list --repo realmroot/realmroot -realmroot exec github --context realmroot -- gh pr merge 42 --repo realmroot/realmroot +realmroot exec github --context -- gh pr merge 42 --repo realmroot/realmroot realmroot exec cloudflare -- wrangler deployments list --name realmroot-adapters ``` @@ -143,7 +143,7 @@ Resource authority or change the selected Context. Generated operations automatically choose the least-privileged approved offer inside the selected Context. `agent request`, generated operations, and `exec` -accept `--context ` as a one-command override without changing the +accept `--context ` as a one-command override without changing the default. `exec` uses all already-approved authority in that Context so opaque native protocols such as GraphQL work without exposing scope-selection or credential-selection internals. It never requests or expands authority. diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 3e5ef2e..8c93bbd 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -41,6 +41,7 @@ type ResourceServer struct { } type AuthorizationDetail struct { + ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` AuthorizationDetail map[string]any `json:"authorizationDetail"` @@ -177,6 +178,10 @@ func (c *Client) AuthorizationDetails(ctx context.Context, server ResourceServer return nil, responseError("list Resource Server authorization details", response.StatusCode(), response.Body) } for _, item := range response.JSON200.Items { + id := "" + if item.Id != nil { + id = *item.Id + } detail := map[string]any{"type": item.AuthorizationDetail.Type} for name, value := range item.AuthorizationDetail.AdditionalProperties { detail[name] = value @@ -184,7 +189,7 @@ func (c *Client) AuthorizationDetails(ctx context.Context, server ResourceServer description := "" description = item.Description result = append(result, AuthorizationDetail{ - Name: item.Name, Description: description, AuthorizationDetail: detail, + ID: id, Name: item.Name, Description: description, AuthorizationDetail: detail, AccountAuthorizationStatus: string(item.AccountAuthorizationStatus), AuthorizedScopes: append([]string(nil), item.AuthorizedScopes...), RequestableScopes: append([]string(nil), item.RequestableScopes...), Metadata: item.Metadata, diff --git a/internal/catalog/command_surface.go b/internal/catalog/command_surface.go index 9a189e5..4dc7091 100644 --- a/internal/catalog/command_surface.go +++ b/internal/catalog/command_surface.go @@ -13,8 +13,8 @@ var toolboxCommands = []CommandHelp{ var resourceServerCommands = []CommandHelp{ {Name: "context", Usage: " context", Description: "list available Contexts"}, - {Name: "context", Usage: " context show ", Description: "show one Context"}, - {Name: "context", Usage: " context [use |clear]", Description: "select or clear the default Context"}, + {Name: "context", Usage: " context show ", Description: "show one Context"}, + {Name: "context", Usage: " context [use |clear]", Description: "select or clear the default Context"}, } var genericHTTPMethods = []string{"get", "head", "post", "put", "patch", "delete"} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 038abf5..aa7322c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -189,7 +189,7 @@ func (a *App) execCommand() *cobra.Command { return err }, } - command.Flags().String("context", "", "Resource Server Context name for this command") + command.Flags().String("context", "", "Resource Server Context ID for this command") return command } @@ -275,7 +275,7 @@ func (a *App) agentCommand() *cobra.Command { func (a *App) requestCommand() *cobra.Command { var resourceServer string var scopes []string - var contextName string + var contextID string var reason string var handoff bool command := &cobra.Command{ @@ -300,7 +300,7 @@ func (a *App) requestCommand() *cobra.Command { if err != nil { return err } - details, err := a.resolveContext(agentService, server, contexts, contextName) + details, err := a.resolveContext(agentService, server, contexts, contextID) if err != nil { return err } @@ -317,7 +317,7 @@ func (a *App) requestCommand() *cobra.Command { } command.Flags().StringVar(&resourceServer, "resource-server", "", "Toolbox Resource Server name, such as github or platform") command.Flags().StringArrayVar(&scopes, "scope", nil, "exact published scope to request (repeatable)") - command.Flags().StringVar(&contextName, "context", "", "Resource Server Context name for this request") + command.Flags().StringVar(&contextID, "context", "", "Resource Server Context ID for this request") command.Flags().StringVar(&reason, "reason", "", "controller-facing reason for the request") command.Flags().BoolVar(&handoff, "handoff", false, "hand the approval URL to a remote controller without opening a browser or waiting") return command @@ -381,7 +381,7 @@ func (a *App) toolboxCommand() *cobra.Command { command.Flags().Bool("include", false, "include response headers") command.Flags().String("search", "", "find operations by command, summary, method, path, or operation ID") command.Flags().String("scope", "", "filter a Resource Server overview by published scope") - command.Flags().String("context", "", "Resource Server Context name for this operation") + command.Flags().String("context", "", "Resource Server Context ID for this operation") command.Flags().Bool("all", false, "show the complete Resource Server inventory") command.Flags().Bool("no-browser", false, "do not open controller approval pages") command.Flags().Bool("no-paginate", false, "return only the first page") @@ -636,22 +636,15 @@ func (a *App) showResourceServer(ctx context.Context, service *agent.Service, cl if err != nil { return err } - overview := buildResourceServerOverview(server, details, inspection.Operations, a.discoveryOptions()) selected, selectedErr := service.SelectedContext(server.ResourceURL) if selectedErr != nil && !errors.Is(selectedErr, os.ErrNotExist) { return selectedErr } - for index := range overview.Contexts { - for _, detail := range details { - if detail.Name == overview.Contexts[index].Name && sameDetails(detail.AuthorizationDetail, selected) { - overview.Contexts[index].Current = true - } - } - } + overview := buildResourceServerOverview(server, details, inspection.Operations, a.discoveryOptions(), selected) effectiveSelected := selected var effectiveDetail *catalog.AuthorizationDetail if a.context != "" { - detail, detailErr := namedContext(details, a.context) + detail, detailErr := contextBySelector(details, a.context) if detailErr != nil { return detailErr } @@ -886,7 +879,11 @@ func (a *App) printContextSummary(overview resourceServerOverview) { if item.Current { current = " (current)" } - fmt.Fprintf(a.stdout, " %s%s — %s\n", item.Name, current, item.AccountAuthorizationStatus) + if item.ID == "" { + fmt.Fprintf(a.stdout, " %s%s — %s\n", item.Name, current, item.AccountAuthorizationStatus) + continue + } + fmt.Fprintf(a.stdout, " %s %s%s — %s\n", item.ID, item.Name, current, item.AccountAuthorizationStatus) } if overview.ContextTruncated { fmt.Fprintf(a.stdout, " Showing %d of %d Contexts. Run `realmroot toolbox %s context` to show every Context.\n", len(overview.Contexts), overview.ContextCount, overview.ResourceServer.CommandName) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c1ae7b9..3347cb5 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -232,8 +232,8 @@ func TestToolboxHelpDocumentsLocalCommandSurface(t *testing.T) { "sync ", "get|head|post|put|patch|delete /", " context", - " context show ", - " context [use |clear]", + " context show ", + " context [use |clear]", } { if !strings.Contains(output, expected) { t.Fatalf("help omitted %q:\n%s", expected, output) @@ -313,7 +313,7 @@ func TestParseExecFlagsConsumesLogLevelBeforeNativeSeparator(t *testing.T) { func TestResourceServerContextUsesDisplayContractWithoutRawDetails(t *testing.T) { // [spec: cli/resource-server-context] details := []catalog.AuthorizationDetail{{ - Name: "realmroot", Description: "Organization GitHub App installation", + ID: "ctx_github_realmroot", Name: "realmroot", Description: "Organization GitHub App installation", AuthorizationDetail: map[string]any{"type": "github_installation", "installation_id": "42"}, Metadata: map[string]string{"accountType": "Organization"}, AccountAuthorizationStatus: "authorized", AuthorizedScopes: []string{"issues:read"}, @@ -325,7 +325,7 @@ func TestResourceServerContextUsesDisplayContractWithoutRawDetails(t *testing.T) t.Fatal(err) } output := string(encoded) - if !summaries[0].Current || !strings.Contains(output, `"name":"realmroot"`) || + if !summaries[0].Current || !strings.Contains(output, `"id":"ctx_github_realmroot"`) || !strings.Contains(output, `"name":"realmroot"`) || strings.Contains(output, "installation_id") || strings.Contains(output, "authorizationDetail") { t.Fatalf("Context summaries = %s", output) } @@ -859,7 +859,7 @@ func TestSmallResourceServerOverviewIncludesCompleteInventory(t *testing.T) { details := []catalog.AuthorizationDetail{{Name: "wallet"}} operations := []restish.OperationInspection{{ID: "showWallet", Command: []string{"wallet", "show"}, Method: "GET"}} - overview := buildResourceServerOverview(server, details, operations, discoveryOptions{}) + overview := buildResourceServerOverview(server, details, operations, discoveryOptions{}, nil) if overview.Mode != overviewModeExpanded || len(overview.Scopes) != 1 || len(overview.Contexts) != 1 || len(overview.Operations) != 1 { t.Fatalf("overview = %#v", overview) @@ -870,7 +870,7 @@ func TestLargeResourceServerOverviewIsCompact(t *testing.T) { server := catalog.ResourceServer{CommandName: "cloudflare", ConnectionScopes: []string{"zone.read"}, Scopes: make([]catalog.Scope, 252)} operations := makeOperations(2652) - overview := buildResourceServerOverview(server, nil, operations, discoveryOptions{}) + overview := buildResourceServerOverview(server, nil, operations, discoveryOptions{}, nil) if overview.Mode != overviewModeCompact || len(overview.Scopes) != 0 || len(overview.Operations) != 0 { t.Fatalf("overview = %#v", overview) @@ -887,7 +887,7 @@ func TestCompactOverviewKeepsBoundedAuthorizationDetails(t *testing.T) { details[index] = catalog.AuthorizationDetail{Name: "account", AuthorizationDetail: map[string]any{"type": "cloudflare_account"}} } - overview := buildResourceServerOverview(server, details, makeOperations(80), discoveryOptions{}) + overview := buildResourceServerOverview(server, details, makeOperations(80), discoveryOptions{}, nil) if overview.Mode != overviewModeCompact || len(overview.Contexts) != maxCompactAuthorization || !overview.ContextTruncated { t.Fatalf("overview = %#v", overview) @@ -902,7 +902,7 @@ func TestResourceServerSearchIsBoundedAndKeepsOperationSecurity(t *testing.T) { operations[index].CredentialAlternatives = [][]restish.CredentialRequirementInspection{{{ID: "oauth2", Needs: []string{"workers-routes.read"}}}} } - overview := buildResourceServerOverview(server, nil, operations, discoveryOptions{Search: "worker routes"}) + overview := buildResourceServerOverview(server, nil, operations, discoveryOptions{Search: "worker routes"}, nil) if overview.Mode != overviewModeFiltered || overview.MatchCount != 80 || len(overview.Operations) != maxDiscoveryResults || !overview.Truncated { t.Fatalf("overview = %#v", overview) @@ -920,7 +920,7 @@ func TestScopeFilterKeepsOnlyMatchingScopeAlternativesAndHidesCredentialSchemes( {{ID: "realmrootOidc", Kind: "oauth2", Needs: []string{"metadata:read"}}}, }, }} - overview := buildResourceServerOverview(catalog.ResourceServer{CommandName: "github"}, nil, operations, discoveryOptions{Scope: "contents:read"}) + overview := buildResourceServerOverview(catalog.ResourceServer{CommandName: "github"}, nil, operations, discoveryOptions{Scope: "contents:read"}, nil) if got := operationScopeSummary(overview.Operations[0]); got != "contents:read" { t.Fatalf("scope summary = %q", got) @@ -966,7 +966,7 @@ func TestResourceServerAllExplicitlyExpandsLargeInventory(t *testing.T) { server := catalog.ResourceServer{CommandName: "cloudflare", Scopes: make([]catalog.Scope, 252)} operations := makeOperations(80) - overview := buildResourceServerOverview(server, nil, operations, discoveryOptions{All: true}) + overview := buildResourceServerOverview(server, nil, operations, discoveryOptions{All: true}, nil) if overview.Mode != overviewModeExpanded || len(overview.Scopes) != 252 || len(overview.Operations) != 80 || overview.Truncated { t.Fatalf("overview = %#v", overview) diff --git a/internal/cli/contexts.go b/internal/cli/contexts.go index 4c76c47..c4c7dd7 100644 --- a/internal/cli/contexts.go +++ b/internal/cli/contexts.go @@ -15,6 +15,7 @@ import ( ) type contextSummary struct { + ID string `json:"id,omitempty"` Name string `json:"name"` Description string `json:"description,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` @@ -25,6 +26,7 @@ type contextSummary struct { } type contextListItem struct { + ID string `json:"id,omitempty"` Name string `json:"name"` AccountAuthorizationStatus string `json:"accountAuthorizationStatus"` Current bool `json:"current"` @@ -37,21 +39,22 @@ type contextResult struct { type contextSelectionResult struct { ResourceServer string `json:"resourceServer"` - Context string `json:"context,omitempty"` + ContextID string `json:"contextId,omitempty"` + Name string `json:"name,omitempty"` Current bool `json:"current"` } -type contextUnavailableError struct{ name string } +type contextUnavailableError struct{ id string } func (e contextUnavailableError) Error() string { - return fmt.Sprintf("Context %q is not available", e.name) + return fmt.Sprintf("Context ID %q is not available", e.id) } func listContexts(details []catalog.AuthorizationDetail, selected []map[string]any) []contextListItem { result := make([]contextListItem, 0, len(details)) for _, detail := range details { result = append(result, contextListItem{ - Name: detail.Name, AccountAuthorizationStatus: detail.AccountAuthorizationStatus, + ID: detail.ID, Name: detail.Name, AccountAuthorizationStatus: detail.AccountAuthorizationStatus, Current: sameDetails(detail.AuthorizationDetail, selected), }) } @@ -62,7 +65,7 @@ func summarizeContexts(details []catalog.AuthorizationDetail, selected []map[str result := make([]contextSummary, 0, len(details)) for _, detail := range details { result = append(result, contextSummary{ - Name: detail.Name, Description: detail.Description, Metadata: detail.Metadata, + ID: detail.ID, Name: detail.Name, Description: detail.Description, Metadata: detail.Metadata, AccountAuthorizationStatus: detail.AccountAuthorizationStatus, AuthorizedScopes: append([]string(nil), detail.AuthorizedScopes...), RequestableScopes: append([]string(nil), detail.RequestableScopes...), @@ -100,9 +103,9 @@ func (a *App) contextCommand(ctx context.Context, service *agent.Service, client return a.printContexts(contextResult{ResourceServer: server.CommandName, Contexts: listContexts(details, selected)}) } if len(args) != 2 || (args[0] != "show" && args[0] != "use") { - return fmt.Errorf("usage: realmroot toolbox %s context [show|use] | clear", server.CommandName) + return fmt.Errorf("usage: realmroot toolbox %s context [show|use] | clear", server.CommandName) } - detail, err := namedContext(details, args[1]) + detail, err := contextBySelector(details, args[1]) if err != nil { return err } @@ -110,11 +113,11 @@ func (a *App) contextCommand(ctx context.Context, service *agent.Service, client if err := service.StoreContext(server.ResourceURL, []map[string]any{detail.AuthorizationDetail}); err != nil { return err } - result := contextSelectionResult{ResourceServer: server.CommandName, Context: detail.Name, Current: true} + result := contextSelectionResult{ResourceServer: server.CommandName, ContextID: detail.ID, Name: detail.Name, Current: true} if a.json { return a.printJSON(result) } - fmt.Fprintf(a.stdout, "Current Context for %s: %s\n", result.ResourceServer, result.Context) + fmt.Fprintf(a.stdout, "Current Context for %s: %s (%s)\n", result.ResourceServer, result.Name, result.ContextID) return nil } summary := summarizeContexts([]catalog.AuthorizationDetail{detail}, selected)[0] @@ -124,9 +127,9 @@ func (a *App) contextCommand(ctx context.Context, service *agent.Service, client return a.printContext(server.CommandName, summary) } -func (a *App) resolveContext(service *agent.Service, server catalog.ResourceServer, details []catalog.AuthorizationDetail, name string) ([]map[string]any, error) { - if name != "" { - detail, err := namedContext(details, name) +func (a *App) resolveContext(service *agent.Service, server catalog.ResourceServer, details []catalog.AuthorizationDetail, contextID string) ([]map[string]any, error) { + if contextID != "" { + detail, err := contextBySelector(details, contextID) if err != nil { var unavailable contextUnavailableError if errors.As(err, &unavailable) { @@ -160,7 +163,7 @@ func (a *App) resolveContext(service *agent.Service, server catalog.ResourceServ case 1: return []map[string]any{details[0].AuthorizationDetail}, 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) } } @@ -171,32 +174,25 @@ func disconnectedAuthorizationDetails(server catalog.ResourceServer) []map[strin return server.AuthorizationDetails } -func namedContext(details []catalog.AuthorizationDetail, name string) (catalog.AuthorizationDetail, error) { - var exactMatches []catalog.AuthorizationDetail +func contextBySelector(details []catalog.AuthorizationDetail, selector string) (catalog.AuthorizationDetail, error) { for _, detail := range details { - if detail.Name == name { - exactMatches = append(exactMatches, detail) + if detail.ID != "" && detail.ID == selector { + return detail, nil } } - if len(exactMatches) == 1 { - return exactMatches[0], nil - } - if len(exactMatches) > 1 { - return catalog.AuthorizationDetail{}, fmt.Errorf("Context name %q is ambiguous", name) - } - var matches []catalog.AuthorizationDetail + var legacyMatches []catalog.AuthorizationDetail for _, detail := range details { - if strings.EqualFold(detail.Name, name) { - matches = append(matches, detail) + if detail.ID == "" && strings.EqualFold(detail.Name, selector) { + legacyMatches = append(legacyMatches, detail) } } - if len(matches) == 0 { - return catalog.AuthorizationDetail{}, contextUnavailableError{name: name} + if len(legacyMatches) == 1 { + return legacyMatches[0], nil } - if len(matches) > 1 { - return catalog.AuthorizationDetail{}, fmt.Errorf("Context name %q is ambiguous", name) + if len(legacyMatches) > 1 { + return catalog.AuthorizationDetail{}, fmt.Errorf("legacy Context name %q is ambiguous", selector) } - return matches[0], nil + return catalog.AuthorizationDetail{}, contextUnavailableError{id: selector} } func sameDetails(detail map[string]any, selected []map[string]any) bool { @@ -217,19 +213,27 @@ func (a *App) printContexts(result contextResult) error { return nil } w := tabwriter.NewWriter(a.stdout, 0, 4, 2, ' ', 0) - fmt.Fprintln(w, "CURRENT\tNAME\tACCOUNT") + fmt.Fprintln(w, "CURRENT\tID\tNAME\tACCOUNT") for _, item := range result.Contexts { current := "" if item.Current { current = "*" } - fmt.Fprintf(w, "%s\t%s\t%s\n", current, item.Name, item.AccountAuthorizationStatus) + id := item.ID + if id == "" { + id = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", current, id, item.Name, item.AccountAuthorizationStatus) } return w.Flush() } func (a *App) printContext(resourceServer string, item contextSummary) error { - fmt.Fprintf(a.stdout, "Context: %s\nResource Server: %s\nAccount: %s\n", item.Name, resourceServer, item.AccountAuthorizationStatus) + fmt.Fprintf(a.stdout, "Context: %s\n", item.Name) + if item.ID != "" { + fmt.Fprintf(a.stdout, "Context ID: %s\n", item.ID) + } + fmt.Fprintf(a.stdout, "Resource Server: %s\nAccount: %s\n", resourceServer, item.AccountAuthorizationStatus) if item.Description != "" { fmt.Fprintf(a.stdout, "Description: %s\n", item.Description) } diff --git a/internal/cli/contexts_test.go b/internal/cli/contexts_test.go index 2c938ea..035855e 100644 --- a/internal/cli/contexts_test.go +++ b/internal/cli/contexts_test.go @@ -59,7 +59,7 @@ func TestResolveContextUsesResourceTemplatesBeforeTheAccountIsConnected(t *testi } } -func TestResolveContextGuidesAMissingNamedContextToConnections(t *testing.T) { +func TestResolveContextGuidesAMissingContextIDToConnections(t *testing.T) { // [spec: cli/missing-context-guidance] t.Setenv("REALMROOT_STATE_DIR", t.TempDir()) service, err := agent.NewService("https://id.example.com", http.DefaultClient) @@ -70,43 +70,86 @@ func TestResolveContextGuidesAMissingNamedContextToConnections(t *testing.T) { CommandName: "github", ResourceURL: "https://api.example.com", ConnectionStatus: "connected", } existing := []catalog.AuthorizationDetail{{ - Name: "existing-org", AuthorizationDetail: map[string]any{"type": "installation", "installation_id": "701"}, + ID: "ctx_existing", Name: "existing-org", AuthorizationDetail: map[string]any{"type": "installation", "installation_id": "701"}, }} - selected, err := (&App{}).resolveContext(service, server, existing, "new-org") + selected, err := (&App{}).resolveContext(service, server, existing, "ctx_missing") if selected != nil || err == nil { t.Fatalf("selected=%#v err=%v", selected, err) } - want := `Context "new-org" is not available; connect or update it in Realmroot Connections: https://id.example.com/connections` + want := `Context ID "ctx_missing" is not available; connect or update it in Realmroot Connections: https://id.example.com/connections` if err.Error() != want { t.Fatalf("error = %q, want %q", err, want) } } -func TestNamedContextUsesAUniqueCaseInsensitiveProviderName(t *testing.T) { +func TestContextByIDSelectsStableIDDespiteDuplicateNames(t *testing.T) { + // [spec: cli/resource-server-context] + details := []catalog.AuthorizationDetail{ + {ID: "ctx_first", Name: "wakatoken", AuthorizationDetail: map[string]any{"installation_id": "701"}}, + {ID: "ctx_second", Name: "wakatoken", AuthorizationDetail: map[string]any{"installation_id": "702"}}, + } + selected, err := contextBySelector(details, "ctx_second") + if err != nil || selected.AuthorizationDetail["installation_id"] != "702" { + t.Fatalf("selected=%#v err=%v", selected, err) + } + if _, err := contextBySelector(details, "wakatoken"); err == nil || err.Error() != `Context ID "wakatoken" is not available` { + t.Fatalf("name selection error = %v", err) + } +} + +func TestContextSelectorKeepsTemporaryNameCompatibilityOnlyWithoutIDs(t *testing.T) { detail := catalog.AuthorizationDetail{ - Name: "wakatoken", - AuthorizationDetail: map[string]any{"type": "installation", "installation_id": "702"}, + Name: "legacy-workspace", + AuthorizationDetail: map[string]any{"type": "workspace", "id": "workspace-1"}, + } + selected, err := contextBySelector([]catalog.AuthorizationDetail{detail}, "LEGACY-WORKSPACE") + if err != nil || !sameDetails(detail.AuthorizationDetail, []map[string]any{selected.AuthorizationDetail}) { + t.Fatalf("selected=%#v err=%v", selected, err) } - selected, err := namedContext([]catalog.AuthorizationDetail{detail}, "WakaToken") +} + +func TestResolveContextAutomaticallyUsesOnlyContextWithoutSaving(t *testing.T) { + // [spec: cli/resource-server-context] + t.Setenv("REALMROOT_STATE_DIR", t.TempDir()) + service, err := agent.NewService("https://id.example.com", http.DefaultClient) if err != nil { t.Fatal(err) } - if !sameDetails(detail.AuthorizationDetail, []map[string]any{selected.AuthorizationDetail}) { - t.Fatalf("selected Context = %#v", selected) + server := catalog.ResourceServer{CommandName: "example", ResourceURL: "https://api.example.com"} + detail := catalog.AuthorizationDetail{ID: "ctx_only", AuthorizationDetail: map[string]any{"type": "workspace", "id": "only"}} + selected, err := (&App{}).resolveContext(service, server, []catalog.AuthorizationDetail{detail}, "") + if err != nil || !sameDetails(detail.AuthorizationDetail, selected) { + t.Fatalf("selected=%#v err=%v", selected, err) + } + if _, err := service.SelectedContext(server.ResourceURL); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("automatic selection was saved: %v", err) + } + if _, err := (&App{}).resolveContext(service, server, []catalog.AuthorizationDetail{detail}, "ctx_missing"); err == nil { + t.Fatal("explicit missing Context must fail") + } + if err := service.StoreContext(server.ResourceURL, []map[string]any{{"type": "workspace", "id": "removed"}}); err != nil { + t.Fatal(err) + } + if _, err := (&App{}).resolveContext(service, server, []catalog.AuthorizationDetail{detail}, ""); err == nil { + t.Fatal("stale selection must not switch to the only Context") } } -func TestNamedContextPrefersExactCaseAndRejectsFoldedAmbiguity(t *testing.T) { +func TestOverviewMarksOnlySelectedLegacyContext(t *testing.T) { + // [spec: cli/resource-server-context] details := []catalog.AuthorizationDetail{ - {Name: "wakatoken", AuthorizationDetail: map[string]any{"installation_id": "701"}}, - {Name: "WakaToken", AuthorizationDetail: map[string]any{"installation_id": "702"}}, - } - selected, err := namedContext(details, "WakaToken") - if err != nil || selected.AuthorizationDetail["installation_id"] != "702" { - t.Fatalf("exact selected=%#v err=%v", selected, err) - } - if _, err := namedContext(details, "WAKATOKEN"); err == nil || err.Error() != `Context name "WAKATOKEN" is ambiguous` { - t.Fatalf("folded ambiguity error = %v", err) + {Name: "first", AuthorizationDetail: map[string]any{"type": "workspace", "id": "first"}}, + {Name: "second", AuthorizationDetail: map[string]any{"type": "workspace", "id": "second"}}, + } + for _, compact := range []bool{false, true} { + server := catalog.ResourceServer{CommandName: "example"} + if compact { + server.Scopes = make([]catalog.Scope, 252) + } + overview := buildResourceServerOverview(server, details, nil, discoveryOptions{}, []map[string]any{details[1].AuthorizationDetail}) + if len(overview.Contexts) != 2 || overview.Contexts[0].Current || !overview.Contexts[1].Current { + t.Fatalf("compact=%v contexts=%#v", compact, overview.Contexts) + } } } diff --git a/internal/cli/discovery.go b/internal/cli/discovery.go index 24a64f2..b4c9454 100644 --- a/internal/cli/discovery.go +++ b/internal/cli/discovery.go @@ -178,7 +178,7 @@ func executionScopes(details []catalog.AuthorizationDetail, selected []map[strin return result } -func buildResourceServerOverview(server catalog.ResourceServer, details []catalog.AuthorizationDetail, operations []restish.OperationInspection, options discoveryOptions) resourceServerOverview { +func buildResourceServerOverview(server catalog.ResourceServer, details []catalog.AuthorizationDetail, operations []restish.OperationInspection, options discoveryOptions, selected []map[string]any) resourceServerOverview { overview := resourceServerOverview{ ResourceServer: summarizeResourceServer(server), ScopeCount: len(server.Scopes), ContextCount: len(details), OperationCount: len(operations), Search: options.Search, Scope: options.Scope, @@ -195,7 +195,7 @@ func buildResourceServerOverview(server catalog.ResourceServer, details []catalo } if !options.All && resourceServerInventoryIsLarge(server, details, operations) { overview.Mode = overviewModeCompact - overview.Contexts = listContexts(details, nil) + overview.Contexts = listContexts(details, selected) if len(overview.Contexts) > maxCompactAuthorization { overview.Contexts = overview.Contexts[:maxCompactAuthorization] overview.ContextTruncated = true @@ -204,7 +204,7 @@ func buildResourceServerOverview(server catalog.ResourceServer, details []catalo } overview.Mode = overviewModeExpanded overview.Scopes = append([]catalog.Scope(nil), server.Scopes...) - overview.Contexts = listContexts(details, nil) + overview.Contexts = listContexts(details, selected) overview.Operations = summarizeOperations(operations, "") return overview } diff --git a/internal/realmrootapi/client.gen.go b/internal/realmrootapi/client.gen.go index 8817c4f..dbe36a8 100644 --- a/internal/realmrootapi/client.gen.go +++ b/internal/realmrootapi/client.gen.go @@ -4785,6 +4785,7 @@ type ListResourceServerAuthorizationDetailsResponse struct { AuthorizationDetail ListResourceServerAuthorizationDetails200JSONResponseBody_Items_AuthorizationDetail `json:"authorizationDetail"` AuthorizedScopes []string `json:"authorizedScopes"` Description string `json:"description"` + Id *string `json:"id"` Metadata map[string]string `json:"metadata"` Name string `json:"name"` RequestableScopes []string `json:"requestableScopes"` @@ -5707,6 +5708,7 @@ func ParseListResourceServerAuthorizationDetailsResponse(rsp *http.Response) (*L AuthorizationDetail ListResourceServerAuthorizationDetails200JSONResponseBody_Items_AuthorizationDetail `json:"authorizationDetail"` AuthorizedScopes []string `json:"authorizedScopes"` Description string `json:"description"` + Id *string `json:"id"` Metadata map[string]string `json:"metadata"` Name string `json:"name"` RequestableScopes []string `json:"requestableScopes"` diff --git a/specs/cli.feature b/specs/cli.feature index a94c23e..140e451 100644 --- a/specs/cli.feature +++ b/specs/cli.feature @@ -80,11 +80,12 @@ Feature: Realmroot Toolbox command line Scenario: Inspect and select one Resource Server Context Given the Resource Server exposes one or more Contexts with service-defined names and attributes When the Agent runs "realmroot toolbox github context" - Then Toolbox lists only the Context name, authorization status, and current selection + Then Toolbox lists each stable Context ID, display name, authorization status, and current selection And Context details show the Resource Server supplied description and attributes - When the Agent runs "realmroot toolbox github context use realmroot" + When the Agent selects the Context by its stable ID Then subsequent GitHub operations use that Context by default And "--context" can override it for one operation without changing the default + And an external Context without a published ID temporarily remains selectable by its unique display name But authorization details and credential references are never exposed @journey:resource-server-sync @entrypoint:toolbox-sync @@ -114,9 +115,9 @@ Feature: Realmroot Toolbox command line And the same URL continues through account connection, Context selection, and Permission approval @journey:missing-context-guidance @entrypoint:agent-request - Scenario: A named Context must already exist - Given the requested Context name is not published by the Resource Server - When the Agent requests Resource access with that Context name + Scenario: A Context ID must already exist + Given the requested Context ID is not published by the Resource Server + When the Agent requests Resource access with that Context ID Then Toolbox does not create an access request And directs the controller to Realmroot Connections to connect or update it manually