Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ installations, or other Contexts defined by that Resource Server. `context show`
prints its service-defined description and safe attributes; `context use`
selects the default. Context selection is independent of permission requests
and credential storage.
The ordinary Context list includes Agent-granted scopes, requestable scopes, and
published scopes not currently requestable. These describe current permissions;
external accounts may require connection or expanded authorization.

Access requests always go to the server for authorization checks. CLI does not
precheck permissions or change Contexts. Server errors retain a nonzero exit
status; `--json` preserves the server error body on stdout, with diagnostics on
stderr.

Use `realmroot toolbox sync <resource-server>` after that Resource Server
publishes a changed OpenAPI contract. Sync bypasses the cached OpenAPI document
Expand Down
13 changes: 12 additions & 1 deletion internal/access/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,17 @@ func receipt(server catalog.ResourceServer, resource string, scopes []string) Re
return Receipt{Status: "ready", ResourceServer: server.CommandName, ResourceIndicator: resource, Scopes: scopes}
}

// ResponseError preserves the server response without inventing a client error code.
type ResponseError struct {
Operation string
StatusCode int
Body []byte
}

func (e *ResponseError) Error() string {
return fmt.Sprintf("%s: HTTP %d: %s", e.Operation, e.StatusCode, strings.TrimSpace(string(e.Body)))
}

func apiError(operation string, status int, body []byte) error {
return fmt.Errorf("%s: HTTP %d: %s", operation, status, strings.TrimSpace(string(body)))
return &ResponseError{Operation: operation, StatusCode: status, Body: append([]byte(nil), body...)}
}
35 changes: 35 additions & 0 deletions internal/access/access_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"

Expand All @@ -16,6 +17,40 @@ var (
errAccessRequested = errors.New("access requested")
)

// Keep the real generated HTTP client; only bypass identity signing in this transport test.
type unsignedHTTPClient struct {
*realmrootapi.ClientWithResponses
}

func (c unsignedHTTPClient) CreateAgentAuthorizationRequestWithResponse(ctx context.Context, body realmrootapi.CreateAgentAuthorizationRequestJSONRequestBody, _ ...realmrootapi.RequestEditorFn) (*realmrootapi.CreateAgentAuthorizationRequestResponse, error) {
return c.ClientWithResponses.CreateAgentAuthorizationRequestWithResponse(ctx, body)
}

func TestRequestPreservesHTTPServerError(t *testing.T) {
calls := 0
body := `{"error":{"code":"bad_request","message":"Controller cannot grant these scopes. No approval request was created.","requestId":"request-1","details":{"context":{"id":"user-1","type":"user"},"scopes":["applications:read"]}}}`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Method != "POST" || !strings.HasSuffix(r.URL.Path, "/agent/access-requests") {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(body))
}))
defer server.Close()
client, err := realmrootapi.NewClientWithResponses(server.URL)
if err != nil {
t.Fatal(err)
}
service := &Service{api: unsignedHTTPClient{client}}
_, err = service.Request(context.Background(), catalog.ResourceServer{ID: "resource-1"}, []string{"applications:read"}, []map[string]any{{"type": "realmroot_authority", "authority": "user", "id": "user-1"}}, "inspect", RequestOptions{})
var responseError *ResponseError
if !errors.As(err, &responseError) || responseError.StatusCode != 400 || string(responseError.Body) != body || calls != 1 {
t.Fatalf("HTTP error not preserved: %v (calls=%d)", err, calls)
}
}

type recordingClient struct {
accessRequests int
}
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,12 @@ func (a *App) requestCommand() *cobra.Command {
}
receipt, err := accessService.Request(ctx, server, scopes, details, 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 {
return printErr
}
}
return err
}
return a.printJSON(receipt)
Expand Down
43 changes: 43 additions & 0 deletions internal/cli/context_permissions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cli

import (
"bytes"
"encoding/json"
"github.com/realmroot/cli/internal/catalog"
"reflect"
"strings"
"testing"
)

func TestContextListShowsPermissionNamesWithoutExtraFlags(t *testing.T) {
// [spec: cli/resource-server-context]
details := []catalog.AuthorizationDetail{
{ID: "user-1", Name: "Ambor", AuthorizationDetail: map[string]any{"type": "realmroot_authority", "authority": "user", "id": "user-1"}, AuthorizedScopes: []string{"agents:read"}, RequestableScopes: []string{"agents:write"}},
{ID: "org-1", Name: "Platform", AuthorizationDetail: map[string]any{"type": "realmroot_authority", "authority": "organization", "id": "org-1"}, AuthorizedScopes: []string{"applications:read"}},
}
items := listContexts(details, []map[string]any{details[0].AuthorizationDetail}, catalog.Scope{Value: "agents:read"}, catalog.Scope{Value: "agents:write"}, catalog.Scope{Value: "applications:read"})
if len(items) != 2 || !items[0].Current || items[1].Current || !reflect.DeepEqual(items[0].UnavailableScopes, []string{"applications:read"}) || !reflect.DeepEqual(items[0].AuthorizedScopes, []string{"agents:read"}) || !reflect.DeepEqual(items[0].RequestableScopes, []string{"agents:write"}) {
t.Fatalf("incorrect Context list: %+v", items)
}
for _, asJSON := range []bool{false, true} {
var output bytes.Buffer
app := &App{stdout: &output, json: asJSON}
if err := app.printContexts(contextResult{ResourceServer: "platform", Contexts: items}); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Ambor", "Platform", "user-1", "org-1", "agents:read", "agents:write", "applications:read"} {
if !strings.Contains(output.String(), expected) {
t.Fatalf("list omitted %s: %s", expected, &output)
}
}
if asJSON {
var result contextResult
if err := json.Unmarshal(output.Bytes(), &result); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result.Contexts, items) {
t.Fatalf("JSON changed permission facts: %s", &output)
}
}
}
}
39 changes: 31 additions & 8 deletions internal/cli/contexts.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"os"
"slices"
"sort"
"strings"
"text/tabwriter"
Expand All @@ -26,10 +27,14 @@ type contextSummary struct {
}

type contextListItem struct {
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"`
}

type contextResult struct {
Expand All @@ -50,10 +55,21 @@ func (e contextUnavailableError) Error() string {
return fmt.Sprintf("Context ID %q is not available", e.id)
}

func listContexts(details []catalog.AuthorizationDetail, selected []map[string]any) []contextListItem {
func listContexts(details []catalog.AuthorizationDetail, selected []map[string]any, scopes ...catalog.Scope) []contextListItem {
result := make([]contextListItem, 0, len(details))
for _, detail := range details {
kind := "resource"
if detail.AuthorizationDetail["type"] == "realmroot_authority" {
kind, _ = detail.AuthorizationDetail["authority"].(string)
}
unavailable := []string{}
for _, scope := range scopes {
if !slices.Contains(detail.AuthorizedScopes, scope.Value) && !slices.Contains(detail.RequestableScopes, scope.Value) {
unavailable = append(unavailable, scope.Value)
}
}
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),
})
Expand Down Expand Up @@ -100,7 +116,7 @@ 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)})
return a.printContexts(contextResult{ResourceServer: server.CommandName, Contexts: listContexts(details, selected, server.Scopes...)})
}
if len(args) != 2 || (args[0] != "show" && args[0] != "use") {
return fmt.Errorf("usage: realmroot toolbox %s context [show|use] <context-id> | clear", server.CommandName)
Expand Down Expand Up @@ -213,7 +229,7 @@ func (a *App) printContexts(result contextResult) error {
return nil
}
w := tabwriter.NewWriter(a.stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "CURRENT\tID\tNAME\tACCOUNT")
fmt.Fprintln(w, "CURRENT\tID\tNAME\tTYPE\tACCOUNT\tAGENT GRANTED\tREQUESTABLE\tNOT CURRENTLY REQUESTABLE")
for _, item := range result.Contexts {
current := ""
if item.Current {
Expand All @@ -223,7 +239,7 @@ func (a *App) printContexts(result contextResult) error {
if id == "" {
id = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", current, id, item.Name, item.AccountAuthorizationStatus)
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))
}
return w.Flush()
}
Expand Down Expand Up @@ -257,3 +273,10 @@ func (a *App) printContext(resourceServer string, item contextSummary) error {
}
return nil
}

func scopeNames(scopes []string) string {
if len(scopes) == 0 {
return "-"
}
return strings.Join(scopes, ", ")
}
4 changes: 2 additions & 2 deletions internal/cli/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, selected)
overview.Contexts = listContexts(details, selected, server.Scopes...)
if len(overview.Contexts) > maxCompactAuthorization {
overview.Contexts = overview.Contexts[:maxCompactAuthorization]
overview.ContextTruncated = true
Expand All @@ -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, selected)
overview.Contexts = listContexts(details, selected, server.Scopes...)
overview.Operations = summarizeOperations(operations, "")
return overview
}
Expand Down
10 changes: 9 additions & 1 deletion specs/cli.feature
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ 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 each stable Context ID, display name, authorization status, and current selection
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 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
Expand Down Expand Up @@ -170,3 +171,10 @@ Feature: Realmroot Toolbox command line
Then Wrangler API traffic is routed through the Cloudflare Resource Server
And existing Cloudflare credentials are removed from the child environment
And Cloudflare asset-upload credentials remain process-local and are accepted only for their matching upload session

@journey:server-access-error @entrypoint:agent-request
Scenario: Preserve the server decision when requesting authority
When the Agent requests scopes in a selected Context
Then CLI submits the request without a permission precheck
And an unsuccessful server response exits with code 1
And JSON output preserves the server error code, message, request ID, and details