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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ the deployment URL and selects operation authority without exposing either to
the caller. Absolute URLs remain available only for unregistered public HTTP
targets.

When a published operation requires an `Idempotency-Key` header, Toolbox
generates an RFC 8941 key automatically for both generated and generic
resource-first commands. One logical invocation reuses that key across
transient retries. Callers may still pass `--idempotency-key` on generated
commands or `--header 'Idempotency-Key: "known-key"'` on generic commands when
recovering a request whose outcome is unknown.

`platform` and `sync` are reserved Toolbox command names. `platform` always maps to the Resource Server whose published
identifier is `realmroot`. Resource Server names also cannot collide with the
generic HTTP verbs.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.25.3

require (
github.com/oapi-codegen/runtime v1.6.0
github.com/saltbo/restish/v2 v2.3.1-0.20260828035857-92c8f86bdb55
github.com/saltbo/restish/v2 v2.3.1-0.20260902155202-ca916d7113a0
github.com/spf13/cobra v1.10.2
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/saltbo/restish/v2 v2.3.1-0.20260828035857-92c8f86bdb55 h1:66B4rNBVWBvEtMQsT2q/Kdf0ihta/7gfTm7lmBwJLtA=
github.com/saltbo/restish/v2 v2.3.1-0.20260828035857-92c8f86bdb55/go.mod h1:nB2f22CFu5X6R8Az9GDJ5+WTobImIs4l2cgczEUSWQY=
github.com/saltbo/restish/v2 v2.3.1-0.20260902155202-ca916d7113a0 h1:YbfmSZcD2VPZ8l4Ww8naxeB8BNYD6WmngoNuIkUCYOs=
github.com/saltbo/restish/v2 v2.3.1-0.20260902155202-ca916d7113a0/go.mod h1:nB2f22CFu5X6R8Az9GDJ5+WTobImIs4l2cgczEUSWQY=
github.com/sandrolain/httpcache v1.4.0 h1:Jf4Vx62X2ybvNPSpPvI1kT3xvMdDG1AsApQjOQKO9E0=
github.com/sandrolain/httpcache v1.4.0/go.mod h1:kHBuXveitSn39SNPBhdf/ybG272X706HJ2RJqOQ+Em0=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
Expand Down
51 changes: 49 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,7 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca
if err != nil {
return err
}
var genericOperation *restish.OperationInspection
if server, ok := selectedResourceServer(servers, args); ok {
profile := "default"
inspection, inspectErr := runtime.InspectAPI(ctx, server.CommandName, profile)
Expand Down Expand Up @@ -949,6 +950,7 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca
if !operationSelected {
return fmt.Errorf("%s %s does not match one published operation for Resource Server %q", strings.ToUpper(args[0]), args[1], server.CommandName)
}
genericOperation = &operation
if operationRequiresAuthority(operation) {
details, detailsErr := client.AuthorizationDetails(ctx, server)
if detailsErr != nil {
Expand All @@ -972,19 +974,63 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca
}
}
argvArgs := append([]string(nil), args...)
if genericOperation != nil {
argvArgs, err = prepareGenericIdempotency(*genericOperation, argvArgs)
if err != nil {
return err
}
}
if !hasRuntimeFlag(argvArgs, "--rsh-print") {
argvArgs = append(argvArgs, "--rsh-print", "b")
}
if a.json {
argvArgs = append(argvArgs, "--rsh-output-format", "json")
}
argv := append([]string{"realmroot toolbox"}, argvArgs...)
if err := runtime.Run(argv); err != nil {
runOptions := restish.RunOptions{}
if genericOperation != nil {
runOptions.IdempotencyProtected = genericOperation.RequiresIdempotencyKey
}
if err := runtime.RunWithOptions(argv, runOptions); err != nil {
return toolboxRuntimeError{cause: err}
}
return nil
}

func prepareGenericIdempotency(operation restish.OperationInspection, args []string) ([]string, error) {
prepared := append([]string(nil), args...)
if !operation.RequiresIdempotencyKey {
return prepared, nil
}
if !hasHeader(prepared, "Idempotency-Key") {
key, err := restish.NewIdempotencyKey()
if err != nil {
return nil, err
}
prepared = append(prepared, "--rsh-header", "Idempotency-Key: "+key)
}
return prepared, nil
}

func hasHeader(args []string, name string) bool {
for index, argument := range args {
var header string
switch {
case argument == "--rsh-header" && index+1 < len(args):
header = args[index+1]
case strings.HasPrefix(argument, "--rsh-header="):
header = strings.TrimPrefix(argument, "--rsh-header=")
default:
continue
}
headerName, _, found := strings.Cut(header, ":")
if found && strings.EqualFold(strings.TrimSpace(headerName), name) {
return true
}
}
return false
}

func hasRuntimeFlag(args []string, name string) bool {
for _, argument := range args {
if argument == name || strings.HasPrefix(argument, name+"=") {
Expand Down Expand Up @@ -1015,7 +1061,8 @@ func (a *App) newRestishRuntime(service *agent.Service, config *restish.Config)
return a.newRestishRuntimeWithCommandSurface(service, config, restish.CommandSurface{
HTTPMethods: []string{"get", "head", "post", "put", "patch", "delete"}, RegisteredAPIs: true, HideSupportCommands: true,
MetadataRefreshTimeout: 30 * time.Second, IgnoreUserConfig: true, DisablePlugins: true, HideInternalFlags: true,
CompactOperationHelp: true,
CompactOperationHelp: true,
AutomaticIdempotencyKeys: true,
})
}

Expand Down
72 changes: 72 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
Expand All @@ -20,6 +21,77 @@ import (
restish "github.com/saltbo/restish/v2"
)

func TestPrepareGenericIdempotencyLeavesUnprotectedOperationUnchanged(t *testing.T) {
args := []string{"post", "https://api.example.com/tasks", "title:example"}

prepared, err := prepareGenericIdempotency(restish.OperationInspection{}, args)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(prepared, args) {
t.Fatalf("prepared args = %#v, want unchanged %#v", prepared, args)
}
}

func TestPrepareGenericIdempotencyInjectsRFC8941Key(t *testing.T) {
args := []string{"post", "https://api.example.com/tasks", "title:example"}

prepared, err := prepareGenericIdempotency(restish.OperationInspection{RequiresIdempotencyKey: true}, args)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(prepared[:len(args)], args) {
t.Fatalf("prepared prefix = %#v, want original args %#v", prepared[:len(args)], args)
}
if len(prepared) != len(args)+2 || prepared[len(args)] != "--rsh-header" {
t.Fatalf("prepared args = %#v, want generated header only", prepared)
}
header := strings.TrimPrefix(prepared[len(args)+1], "Idempotency-Key: ")
if !regexp.MustCompile(`^"[0-9a-f]{32}"$`).MatchString(header) {
t.Fatalf("generated Idempotency-Key = %q, want RFC 8941 quoted 128-bit hex string", header)
}
}

func TestPrepareGenericIdempotencyPreservesExistingHeaderWithoutDuplication(t *testing.T) {
for _, test := range []struct {
name string
args []string
}{
{name: "separate", args: []string{"post", "https://api.example.com/tasks", "--rsh-header", `iDeMpOtEnCy-KeY: "caller-key"`}},
{name: "equals", args: []string{"post", "https://api.example.com/tasks", `--rsh-header=IDEMPOTENCY-KEY: "caller-key"`}},
} {
t.Run(test.name, func(t *testing.T) {
prepared, err := prepareGenericIdempotency(restish.OperationInspection{RequiresIdempotencyKey: true}, test.args)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(prepared[:len(test.args)], test.args) {
t.Fatalf("prepared prefix = %#v, want existing header preserved %#v", prepared[:len(test.args)], test.args)
}
if !slices.Equal(prepared, test.args) {
t.Fatalf("prepared args = %#v, want existing header unchanged %#v", prepared, test.args)
}
})
}
}

func TestPrepareGenericIdempotencyGeneratesOneKeyPerLogicalCall(t *testing.T) {
operation := restish.OperationInspection{RequiresIdempotencyKey: true}
args := []string{"post", "https://api.example.com/tasks"}

first, err := prepareGenericIdempotency(operation, args)
if err != nil {
t.Fatal(err)
}
second, err := prepareGenericIdempotency(operation, args)
if err != nil {
t.Fatal(err)
}
if first[len(args)+1] == second[len(args)+1] {
t.Fatalf("independent logical calls reused Idempotency-Key %q", first[len(args)+1])
}
}

func TestConfigureRestishPathsUsesVersionedCache(t *testing.T) {
t.Setenv("RSH_CONFIG_DIR", "")
t.Setenv("RSH_CACHE_DIR", "")
Expand Down