From 173e2bd7d2bc047cbdd563221c8623448e5993a9 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:09:16 +0000 Subject: [PATCH 01/12] Add project-scoped vault payment commands --- .github/workflows/test.yaml | 18 +- README.md | 104 +++++++++ cmd/browser_vaults.go | 32 +++ cmd/browser_vaults_test.go | 103 +++++++++ cmd/browsers.go | 48 +++- cmd/vaults.go | 312 +++++++++++++++++++++++++ cmd/vaults_commands.go | 295 ++++++++++++++++++++++++ cmd/vaults_output.go | 296 ++++++++++++++++++++++++ cmd/vaults_output_test.go | 159 +++++++++++++ cmd/vaults_test.go | 447 ++++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 4 +- 12 files changed, 1806 insertions(+), 14 deletions(-) create mode 100644 cmd/browser_vaults.go create mode 100644 cmd/browser_vaults_test.go create mode 100644 cmd/vaults.go create mode 100644 cmd/vaults_commands.go create mode 100644 cmd/vaults_output.go create mode 100644 cmd/vaults_output_test.go create mode 100644 cmd/vaults_test.go diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5f10d664..1d43d529 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,7 +21,21 @@ jobs: uses: actions/setup-go@v6 with: go-version-file: "go.mod" - cache: true + cache: false + + - name: Create read-only token for the preview SDK + id: sdk-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.ADMIN_APP_ID }} + private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} + repositories: kernel-go-sdk-staging + permission-contents: read - name: Run tests - run: make test + env: + GOPRIVATE: github.com/kernel/kernel-go-sdk-staging + GH_TOKEN: ${{ steps.sdk-token.outputs.token }} + run: | + gh auth setup-git + make test diff --git a/README.md b/README.md index d0a18b8c..60fc39a9 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ Commands with JSON output support: - **Proxies**: `create`, `list`, `get`, `update`, `check` - **API Keys**: `create`, `list`, `get`, `update`, `rotate` - **Auth Connections**: `timeline` +- **Vaults**: `create`, `list`, `get`, `items list/get/events`, `wallets create/payment-methods`, `cards create/update/authorize` (display-safe public fields only) - **Projects**: `update` - **Org**: `limits get/set` - **Apps**: `list`, `history` @@ -223,6 +224,7 @@ Commands with JSON output support: - `--proxy-mode direct|default` - Egress mode instead of a selected proxy: `direct` for no proxy regardless of stealth, `default` for the stealth-derived default (Kernel's stealth proxy with `--stealth`, direct egress otherwise). Omit all proxy flags to get the default. - `--name ` - Optional unique name for the session (used to find it later by name; can be changed with `browsers update --name`) - `--tag ` - Set a tag on the session, repeatable; up to 50 pairs + - `--vault ` - Attach a project-owned vault at creation (repeatable, max 20). Requires `--project` or `KERNEL_PROJECT`. Cannot be combined with pool flags, even with `--yes`; vault bindings cannot be added to existing sessions. - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags). `--name`/`--tag` still apply to the acquired session. - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - `--telemetry=all` - Enable telemetry for all categories @@ -266,6 +268,108 @@ Commands with JSON output support: - `-s, --silent` - Suppress progress output - _Note: redirects are followed automatically by Chromium._ +### Vaults + +Vault commands **prepare and observe payment credentials; they do not submit merchant payments**. +Vault names, item keys, and project ownership are immutable. Select the project explicitly with +`--project ` or `KERNEL_PROJECT`; the API assigns ownership from that scope, not a +`project_id` body field. Project-scoped credentials cannot switch projects. + +#### Command reference + +| Command | Purpose / flags | +| --- | --- | +| `kernel vaults create --name ` | Create or retrieve the vault with that immutable name | +| `kernel vaults list` | `--limit 1..100` (default 20), `--offset`; JSON includes `vaults` and optional `next_offset` | +| `kernel vaults get ` | Get by ID or name | +| `kernel vaults delete ` | Invalidate the vault and all its items; `--yes` skips confirmation | +| `kernel vaults wallets create --provider link\|agentcard` | Connect/enroll a wallet; `--open` opens a returned HTTPS action URL; AgentCard optionally accepts `--user-id` for an already enrolled user in this organization | +| `kernel vaults wallets payment-methods ` | Fetch advertised live payment methods; JSON is the item with `expanded.payment_methods` | +| `kernel vaults cards create ` | Create a card request with the typed flags below; never implicitly authorize Link | +| `kernel vaults cards update ` | Replace the full card spec using the same flags; the API enforces state/provider constraints | +| `kernel vaults cards authorize ` | After explicit user approval, GET the requested Link card and POST `authorize` only if advertised; optional `--open` | +| `kernel vaults items list ` | List item keys, types, providers, status, and required actions | +| `kernel vaults items get ` | Inspect state/actions/returned aliases; `--wait 0..60`, `--expand payment_methods`, `--open` | +| `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | +| `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | + +`` accepts an ID or name. Names and keys use letters, digits, dots, underscores, and +hyphens (1–255 characters; not `.` or `..`). All commands except delete support `-o json`. +JSON preserves field presence and API-returned aliases, while omitting unknown fields, +opaque metadata, and unrecognized event data. Human output labels aliases as non-secret +checkout values and distinguishes card readiness from checkout authorization/payment outcomes. + +**Card flags:** `--provider`, `--wallet `, `--amount `, `--currency `, +and `--merchant ` are required. Currency is normalized to lowercase. + +- **Link:** also requires `--payment-method-id`, `--merchant-url`, `--context` (at least 100 + characters describing the purchase), and exactly one of `--test` or `--live`. Amount is + 1–500000 minor units. Choose the payment-method ID from the wallet listing; capability + hints are advisory, and missing hints mean unknown rather than ineligible. +- **AgentCard:** optionally accepts `--card-id` from the wallet listing; otherwise the + cardholder selects a card at approval. Sandbox/live mode is set by the deployment; + there is no per-item test flag. AgentCard authorization happens at checkout, not through + `cards authorize`. A reusable card being `ready` does not mean the last payment succeeded. +- Permitted checkout domains are provider-assigned and displayed when returned. The API + does **not** accept a domain-setting flag. The merchant URL is not a domain allowlist. +- Advanced optional Link line items, totals, metadata, and expiry are not configurable in + this initial CLI surface. `cards update` replaces the entire spec, so omitted optional + details previously set through another client are removed. + +#### Link checkout preparation + +1. Select a project and create/select a vault. Connect the wallet in the provider's UI: + + ```bash + export KERNEL_PROJECT=my-project + kernel vaults create --name checkout + kernel vaults wallets create checkout wallet-1 --provider link --open + kernel vaults items get checkout wallet-1 --wait 60 + ``` + +2. Once connected, list methods and explicitly choose a returned ID: + + ```bash + kernel vaults wallets payment-methods checkout wallet-1 + kernel vaults cards create checkout order-1 \ + --provider link --wallet wallet-1 --payment-method-id \ + --amount 1234 --currency USD --merchant 'Example Shop' \ + --merchant-url https://shop.example \ + --context 'Purchase the selected office supplies from Example Shop for the approved order, with a total spending limit of 1234 minor currency units.' \ + --test + ``` + +3. After explicit user approval, authorize **only if the item advertises it**. Follow the + returned approval action, then observe: + + ```bash + kernel vaults cards authorize checkout order-1 --open + kernel vaults items get checkout order-1 --wait 60 + ``` + +4. When ready, attach the same vault to a new browser. Use only the returned + `state.aliases` values in that browser's checkout and respect returned permitted domains: + + ```bash + kernel browsers create --vault checkout + ``` + +5. Observe outcomes independently of merchant checkout submission: + + ```bash + kernel vaults items get checkout order-1 + kernel vaults items events checkout order-1 + kernel vaults items events checkout order-1 --after --wait 60 + ``` + +Waits are single bounded observations, not readiness guarantees or payment retries. Pending +state is returned as-is. Requests are not automatically retried by the vault commands. +Pending/terminal Link authorizations cannot be resumed by `cards authorize`. +**Never retry failed, timed-out, rejected, or indeterminate payments.** Inspect state/events +and reconcile the outcome instead. Do not pass card data, OAuth codes/tokens, ciphertext, +provider secrets, or sensitive provider responses to the CLI. Complete collection, OAuth, +and approval actions through the provider's returned URL/UI; no callback-code command exists. + ### Browser Pools - `kernel browser-pools list` - List browser pools diff --git a/cmd/browser_vaults.go b/cmd/browser_vaults.go new file mode 100644 index 00000000..a6848dd8 --- /dev/null +++ b/cmd/browser_vaults.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "fmt" + + kernel "github.com/kernel/kernel-go-sdk" +) + +func buildBrowserVaults(values []string) ([]kernel.VaultReferenceParam, error) { + if len(values) > 20 { + return nil, fmt.Errorf("at most 20 --vault references may be attached") + } + var refs []kernel.VaultReferenceParam + seen := make(map[string]bool, len(values)) + for _, value := range values { + if err := validateVaultName(value, "--vault"); err != nil { + return nil, err + } + if seen[value] { + return nil, fmt.Errorf("duplicate --vault reference") + } + seen[value] = true + ref := kernel.VaultReferenceParam{} + if cuidRegex.MatchString(value) { + ref.ID = kernel.Opt(value) + } else { + ref.Name = kernel.Opt(value) + } + refs = append(refs, ref) + } + return refs, nil +} diff --git a/cmd/browser_vaults_test.go b/cmd/browser_vaults_test.go new file mode 100644 index 00000000..93f1cc79 --- /dev/null +++ b/cmd/browser_vaults_test.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildBrowserVaults(t *testing.T) { + const id = "abcdefghijklmnopqrstuvwx" + refs, err := buildBrowserVaults([]string{id, "checkout"}) + require.NoError(t, err) + require.Len(t, refs, 2) + assert.Equal(t, id, refs[0].ID.Value) + assert.False(t, refs[0].Name.Valid()) + assert.Equal(t, "checkout", refs[1].Name.Value) + assert.False(t, refs[1].ID.Valid()) + for _, values := range [][]string{{""}, {" "}, {"../checkout"}, {".."}, {"checkout", "checkout"}, make([]string, 21)} { + _, err := buildBrowserVaults(values) + require.Error(t, err) + } + refs, err = buildBrowserVaults(nil) + require.NoError(t, err) + body, err := json.Marshal(kernel.BrowserNewParams{Vaults: refs}) + require.NoError(t, err) + assert.NotContains(t, string(body), "vaults") + assert.NotNil(t, browsersCreateCmd.Flags().Lookup("vault")) + assert.Nil(t, browsersUpdateCmd.Flags().Lookup("vault")) + assert.False(t, poolLeaseAllowedFlags()["vault"]) +} + +func browserVaultTestCommand(client kernel.Client) *cobra.Command { + cmd := &cobra.Command{Use: "create"} + cmd.Flags().String("project", "", "") + cmd.Flags().StringArray("vault", nil, "") + cmd.Flags().String("pool-id", "", "") + cmd.Flags().String("pool-name", "", "") + cmd.Flags().Bool("yes", false, "") + addJSONOutputFlag(cmd) + cmd.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client)) + return cmd +} + +func TestBrowserVaultPoolAndProjectValidation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid attachment reached API") }) + for _, flags := range [][]string{ + {"--vault", "checkout", "--pool-id", "pool-1", "--yes"}, + {"--vault", "checkout", "--pool-name", "pool", "--yes"}, + {"--vault", "checkout"}, + {"--vault=", "--project", "project-test"}, + } { + cmd := browserVaultTestCommand(client) + require.NoError(t, cmd.ParseFlags(flags)) + err := runBrowsersCreate(cmd, nil) + require.Error(t, err) + } +} + +func TestBrowserCreateVaultRequestAndReturnedAttachments(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + const body = `{"session_id":"browser-1","cdp_ws_url":"ws://example.test/cdp","vaults":[{"id":"vault-1","name":"checkout"}]}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/browsers", r.URL.Path) + payload, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"vaults":[{"name":"checkout"}]}`, string(payload)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + for _, output := range []string{"", "json"} { + cmd := browserVaultTestCommand(client) + require.NoError(t, cmd.Flags().Set("vault", "checkout")) + require.NoError(t, cmd.Flags().Set("output", output)) + buf := capturePtermOutput(t) + out := captureStdout(t, func() { require.NoError(t, runBrowsersCreate(cmd, nil)) }) + if output == "json" { + assert.JSONEq(t, body, out) + } else { + assert.Contains(t, buf.String(), "Attached vault ID") + assert.Contains(t, buf.String(), "vault-1") + assert.Contains(t, buf.String(), "checkout") + } + } +} + +func TestBrowserCreateInvalidVaultNeverCallsSDK(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + t.Fatal("invalid vault reference should not reach SDK") + return nil, nil + }}} + require.Error(t, b.Create(context.Background(), BrowsersCreateInput{Vaults: []string{strings.Repeat("x", 256)}})) +} diff --git a/cmd/browsers.go b/cmd/browsers.go index caf2cfe2..4ae863d5 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -377,6 +377,7 @@ type BrowsersCreateInput struct { PrivateHosts []string StartURL string Extensions []string + Vaults []string Viewport string Telemetry string TelemetryExport string @@ -564,7 +565,11 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if err := validateStartURLFlag(in.StartURL); err != nil { return err } - params := kernel.BrowserNewParams{} + vaults, err := buildBrowserVaults(in.Vaults) + if err != nil { + return err + } + params := kernel.BrowserNewParams{Vaults: vaults} if in.TimeoutSeconds > 0 { params.TimeoutSeconds = kernel.Opt(int64(in.TimeoutSeconds)) } @@ -705,6 +710,13 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags) + if len(browser.Vaults) > 0 { + rows := pterm.TableData{{"Attached vault ID", "Name"}} + for _, vault := range browser.Vaults { + rows = append(rows, []string{vault.ID, vault.Name}) + } + PrintTableNoPad(rows, true) + } if in.Telemetry != "" || in.TelemetryExport != "" { printTelemetrySummary(browser.Telemetry) } @@ -1590,7 +1602,7 @@ func (b BrowsersCmd) ReplaysStop(ctx context.Context, in BrowsersReplaysStopInpu if err != nil { return util.CleanedUpSdkError{Err: err} } - err = b.replays.Stop(ctx, in.ReplayID, kernel.BrowserReplayStopParams{ID: br.SessionID}) + err = b.replays.Stop(ctx, in.ReplayID, kernel.BrowserReplayStopParams{IDOrName: br.SessionID}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1599,7 +1611,7 @@ func (b BrowsersCmd) ReplaysStop(ctx context.Context, in BrowsersReplaysStopInpu } func (b BrowsersCmd) ReplaysDownload(ctx context.Context, in BrowsersReplaysDownloadInput) error { - res, err := b.replays.Download(ctx, in.ReplayID, kernel.BrowserReplayDownloadParams{ID: in.Identifier}) + res, err := b.replays.Download(ctx, in.ReplayID, kernel.BrowserReplayDownloadParams{IDOrName: in.Identifier}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1905,7 +1917,7 @@ func (b BrowsersCmd) ProcessKill(ctx context.Context, in BrowsersProcessKillInpu if err != nil { return util.CleanedUpSdkError{Err: err} } - params := kernel.BrowserProcessKillParams{ID: br.SessionID, Signal: kernel.BrowserProcessKillParamsSignal(in.Signal)} + params := kernel.BrowserProcessKillParams{IDOrName: br.SessionID, Signal: kernel.BrowserProcessKillParamsSignal(in.Signal)} _, err = b.process.Kill(ctx, in.ProcessID, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -1923,7 +1935,7 @@ func (b BrowsersCmd) ProcessStatus(ctx context.Context, in BrowsersProcessStatus if err != nil { return util.CleanedUpSdkError{Err: err} } - res, err := b.process.Status(ctx, in.ProcessID, kernel.BrowserProcessStatusParams{ID: br.SessionID}) + res, err := b.process.Status(ctx, in.ProcessID, kernel.BrowserProcessStatusParams{IDOrName: br.SessionID}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1941,7 +1953,7 @@ func (b BrowsersCmd) ProcessStdin(ctx context.Context, in BrowsersProcessStdinIn if err != nil { return util.CleanedUpSdkError{Err: err} } - _, err = b.process.Stdin(ctx, in.ProcessID, kernel.BrowserProcessStdinParams{ID: br.SessionID, DataB64: in.DataB64}) + _, err = b.process.Stdin(ctx, in.ProcessID, kernel.BrowserProcessStdinParams{IDOrName: br.SessionID, DataB64: in.DataB64}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1958,7 +1970,7 @@ func (b BrowsersCmd) ProcessStdoutStream(ctx context.Context, in BrowsersProcess if err != nil { return util.CleanedUpSdkError{Err: err} } - stream := b.process.StdoutStreamStreaming(ctx, in.ProcessID, kernel.BrowserProcessStdoutStreamParams{ID: br.SessionID}) + stream := b.process.StdoutStreamStreaming(ctx, in.ProcessID, kernel.BrowserProcessStdoutStreamParams{IDOrName: br.SessionID}) if stream == nil { pterm.Error.Println("failed to open stdout stream") return nil @@ -1992,7 +2004,7 @@ func (b BrowsersCmd) ProcessResize(ctx context.Context, in BrowsersProcessResize if err != nil { return util.CleanedUpSdkError{Err: err} } - params := kernel.BrowserProcessResizeParams{ID: br.SessionID, Cols: in.Cols, Rows: in.Rows} + params := kernel.BrowserProcessResizeParams{IDOrName: br.SessionID, Cols: in.Cols, Rows: in.Rows} _, err = b.process.Resize(ctx, in.ProcessID, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -2041,7 +2053,7 @@ func (b BrowsersCmd) FSWatchStop(ctx context.Context, in BrowsersFSWatchStopInpu if err != nil { return util.CleanedUpSdkError{Err: err} } - err = b.fsWatch.Stop(ctx, in.WatchID, kernel.BrowserFWatchStopParams{ID: br.SessionID}) + err = b.fsWatch.Stop(ctx, in.WatchID, kernel.BrowserFWatchStopParams{IDOrName: br.SessionID}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -2058,7 +2070,7 @@ func (b BrowsersCmd) FSWatchEvents(ctx context.Context, in BrowsersFSWatchEvents if err != nil { return util.CleanedUpSdkError{Err: err} } - stream := b.fsWatch.EventsStreaming(ctx, in.WatchID, kernel.BrowserFWatchEventsParams{ID: br.SessionID}) + stream := b.fsWatch.EventsStreaming(ctx, in.WatchID, kernel.BrowserFWatchEventsParams{IDOrName: br.SessionID}) if stream == nil { pterm.Error.Println("failed to open watch events stream") return nil @@ -2960,6 +2972,7 @@ func init() { browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)") browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60") browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") + browsersCreateCmd.Flags().StringArray("vault", nil, "Project-owned vault ID or name to attach at creation (repeatable, max 20; requires --project or KERNEL_PROJECT; incompatible with pools)") browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") browsersCreateCmd.Flags().String("telemetry", "", "Configure telemetry (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") @@ -3089,6 +3102,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { privateHosts, _ := cmd.Flags().GetStringSlice("private-host") startURL, _ := cmd.Flags().GetString("start-url") extensions, _ := cmd.Flags().GetStringSlice("extension") + vaults, _ := cmd.Flags().GetStringArray("vault") viewport, _ := cmd.Flags().GetString("viewport") viewportInteractive, _ := cmd.Flags().GetBool("viewport-interactive") poolID, _ := cmd.Flags().GetString("pool-id") @@ -3102,6 +3116,19 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") skipConfirm, _ := cmd.Flags().GetBool("yes") + if cmd.Flags().Changed("vault") { + if len(vaults) == 0 { + return fmt.Errorf("--vault requires a vault ID or name") + } + if poolID != "" || poolName != "" { + return fmt.Errorf("--vault cannot be used with --pool-id or --pool-name; create a new browser to attach vaults") + } + project, _ := cmd.Flags().GetString("project") + if err := requireVaultProject(resolveProjectSelection(project)); err != nil { + return err + } + } + if poolID != "" && poolName != "" { pterm.Error.Println("must specify at most one of --pool-id or --pool-name") return nil @@ -3219,6 +3246,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { PrivateHosts: privateHosts, StartURL: startURL, Extensions: extensions, + Vaults: vaults, Viewport: viewport, Telemetry: telemetry, TelemetryExport: telemetryExport, diff --git a/cmd/vaults.go b/cmd/vaults.go new file mode 100644 index 00000000..cf251677 --- /dev/null +++ b/cmd/vaults.go @@ -0,0 +1,312 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/kernel/cli/pkg/interactive" + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/pterm/pterm" +) + +var vaultNamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,255}$`) + +type VaultsCmd struct { + vaults *kernel.VaultService + prompter interactive.Prompter + openURL func(string) error +} + +func validateVaultName(value, label string) error { + if !vaultNamePattern.MatchString(value) || value == "." || value == ".." { + return fmt.Errorf("%s must contain 1-255 letters, digits, dots, underscores, or hyphens (not . or ..)", label) + } + return nil +} + +// Do not unwrap provider errors: the root error renderer otherwise prints their bodies. +func vaultRequestError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("vault request interrupted; inspect item state and events before taking further action; do not retry payments") + } + var apiErr *kernel.Error + if errors.As(err, &apiErr) { + return fmt.Errorf("vault request failed (HTTP %d); response body withheld; inspect item state and events; do not retry payments", apiErr.StatusCode) + } + return fmt.Errorf("vault request failed; details withheld; inspect item state and events; do not retry payments") +} + +func (c VaultsCmd) Create(ctx context.Context, name, output string) error { + if err := validateVaultName(name, "--name"); err != nil { + return err + } + v, err := c.vaults.Upsert(ctx, kernel.VaultUpsertParams{Name: name}, option.WithMaxRetries(0)) + if err != nil { + return vaultRequestError(err) + } + return printVault(v, output) +} + +func (c VaultsCmd) Get(ctx context.Context, vault, output string) error { + v, err := c.vaults.Get(ctx, vault, option.WithMaxRetries(0)) + if err != nil { + return vaultRequestError(err) + } + return printVault(v, output) +} + +func (c VaultsCmd) List(ctx context.Context, limit, offset int64, project, output string) error { + if limit < 1 || limit > 100 || offset < 0 { + return fmt.Errorf("--limit must be between 1 and 100; --offset must be non-negative") + } + var response *http.Response + page, err := c.vaults.List(ctx, kernel.VaultListParams{Limit: kernel.Opt(limit), Offset: kernel.Opt(offset)}, option.WithMaxRetries(0), option.WithResponseInto(&response)) + if err != nil { + return vaultRequestError(err) + } + pagination, err := parseProjectListPagination(response) + if err != nil { + return fmt.Errorf("invalid vault pagination metadata") + } + if output == "json" { + items, err := vaultSafeJSONSlice(page.Items, vaultFields) + if err != nil { + return err + } + return printVaultJSON(struct { + Vaults []vaultJSON `json:"vaults"` + NextOffset int `json:"next_offset,omitempty"` + }{items, pagination.NextOffset}) + } + if len(page.Items) == 0 { + pterm.Info.Println("No vaults found") + } else { + rows := pterm.TableData{{"ID", "Name", "Created At"}} + for _, v := range page.Items { + rows = append(rows, []string{v.ID, v.Name, util.FormatLocal(v.CreatedAt)}) + } + PrintTableNoPad(rows, true) + } + if pagination.HasMore { + pterm.Printf("Next: kernel --project %q vaults list --limit %d --offset %d\n", project, limit, pagination.NextOffset) + } + return nil +} + +func (c VaultsCmd) Delete(ctx context.Context, vault, key string, yes bool) error { + label := "vault " + vault + " and all its items" + if key != "" { + label = "vault item " + vault + "/" + key + } + if !yes { + ok, err := c.prompter.Confirm("delete "+label, "Delete "+label+" and invalidate its credentials?") + if err != nil { + return err + } + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + var err error + if key == "" { + err = c.vaults.Delete(ctx, vault, option.WithMaxRetries(0)) + } else { + err = c.vaults.Items.Delete(ctx, key, kernel.VaultItemDeleteParams{IDOrName: vault}, option.WithMaxRetries(0)) + } + if err != nil && !util.IsNotFound(err) { + return vaultRequestError(err) + } + pterm.Success.Println("Deleted (or already absent): " + label) + return nil +} + +func (c VaultsCmd) ListItems(ctx context.Context, vault, output string) error { + items, err := c.vaults.Items.List(ctx, vault, option.WithMaxRetries(0)) + if err != nil { + return vaultRequestError(err) + } + if output == "json" { + data, err := vaultSafeJSONSlice(*items, vaultItemFields) + if err != nil { + return err + } + return printVaultJSON(data) + } + if len(*items) == 0 { + pterm.Info.Println("No vault items found") + return nil + } + rows := pterm.TableData{{"Key", "Type", "Provider", "Status", "Action"}} + for _, item := range *items { + rows = append(rows, []string{item.Key, item.Type, item.Spec.Provider, item.State.Status, util.OrDash(item.Action.Name)}) + } + PrintTableNoPad(rows, true) + return nil +} + +func validateVaultWait(wait int64) error { + if wait < 0 || wait > 60 { + return fmt.Errorf("--wait must be between 0 and 60 seconds") + } + return nil +} + +func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, expand []string, output string, open bool) error { + if err := validateVaultWait(wait); err != nil { + return err + } + for _, field := range expand { + if field != "payment_methods" { + return fmt.Errorf("--expand only supports payment_methods") + } + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(wait)*time.Second+30*time.Second) + defer cancel() + item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault, Wait: kernel.Opt(wait), Expand: expand}, option.WithMaxRetries(0)) + if err != nil { + return vaultRequestError(err) + } + return c.showItem(item, output, open) +} + +func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key, provider, userID, output string, open bool) error { + var spec kernel.WalletVaultItemSpecUnionParam + switch provider { + case "link": + if userID != "" { + return fmt.Errorf("--user-id is only supported by agentcard") + } + spec = kernel.WalletVaultItemSpecParamOfLink(kernel.WalletVaultItemSpecLinkAuthorizationParam{ + Method: "oauth", Client: kernel.WalletVaultItemSpecLinkAuthorizationClientParam{Type: "kernel_managed"}, + }) + case "agentcard": + spec.OfAgentcard = &kernel.WalletVaultItemSpecAgentcardParam{} + if userID != "" { + if !regexp.MustCompile(`^usr_[A-Za-z0-9_]+$`).MatchString(userID) { + return fmt.Errorf("--user-id must be an enrolled AgentCard user ID (usr_...)") + } + spec.OfAgentcard.UserID = kernel.Opt(userID) + } + default: + return fmt.Errorf("--provider must be link or agentcard") + } + item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfWallet: &kernel.VaultItemUpsertParamsBodyWallet{Spec: spec}}, option.WithMaxRetries(0)) + if err != nil { + return vaultRequestError(err) + } + return c.showItem(item, output, open) +} + +func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel.CardVaultItemSpecUnionParam, update bool, output string) error { + var item *kernel.VaultItemUnion + var err error + if update { + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, Spec: spec}, option.WithMaxRetries(0)) + } else { + item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) + } + if err != nil { + return vaultRequestError(err) + } + return c.showItem(item, output, false) +} + +func (c VaultsCmd) Authorize(ctx context.Context, vault, key, output string, open bool) error { + item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) + if err != nil { + return vaultRequestError(err) + } + if item.Type != "card" || item.Spec.Provider != "link" || item.State.Status != "requested" { + return fmt.Errorf("authorization requires a requested Link card; inspect item state and events; do not retry payments or resume indeterminate authorizations") + } + available := false + for _, operation := range item.AsCard().AvailableOperations { + if operation.Type == "authorize" { + available = true + if output != "json" { + pterm.Info.Println(operation.Description) + } + } + } + if !available { + return fmt.Errorf("authorize is not advertised in available_operations; inspect the item and its wallet") + } + item, err = c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, Type: kernel.VaultItemPerformOperationParamsTypeAuthorize}, option.WithMaxRetries(0)) + if err != nil { + return vaultRequestError(err) + } + return c.showItem(item, output, open) +} + +func (c VaultsCmd) Events(ctx context.Context, vault, key, after string, wait int64, output string) error { + if err := validateVaultWait(wait); err != nil { + return err + } + params := kernel.VaultItemEventsParams{IDOrName: vault, Wait: kernel.Opt(wait)} + if after != "" { + params.After = kernel.Opt(after) + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(wait)*time.Second+30*time.Second) + defer cancel() + events, err := c.vaults.Items.Events(ctx, key, params, option.WithMaxRetries(0)) + if err != nil { + return vaultRequestError(err) + } + data, err := vaultSafeJSONSlice(*events, vaultEventFields) + if err != nil { + return err + } + if output == "json" { + return printVaultJSON(data) + } + if len(*events) == 0 { + pterm.Info.Println("No new vault item events") + return nil + } + printVaultEvents(*events, data) + pterm.Printf("For later events, pass --after %s to items events. Observing events does not retry a payment.\n", (*events)[len(*events)-1].ID) + return nil +} + +func (c VaultsCmd) showItem(item *kernel.VaultItemUnion, output string, open bool) error { + if err := printVaultItem(item, output); err != nil { + return err + } + if !open { + return nil + } + actionURL := item.Action.URL + if actionURL == "" { + if output != "json" { + pterm.Info.Println("No action URL returned; no browser opened") + } + return nil + } + u, err := url.Parse(actionURL) + if err != nil || u.Scheme != "https" || !vaultDisplayURL(actionURL) { + return fmt.Errorf("action URL is not a display-safe HTTPS URL; no browser opened") + } + if err := c.openURL(actionURL); err != nil { + return fmt.Errorf("could not open the browser; open the returned action URL manually") + } + return nil +} + +func requireVaultProject(project string) error { + if strings.TrimSpace(project) == "" { + return fmt.Errorf("select the vault's project with --project or KERNEL_PROJECT; vault project ownership is immutable") + } + return nil +} diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go new file mode 100644 index 00000000..79b47cdd --- /dev/null +++ b/cmd/vaults_commands.go @@ -0,0 +1,295 @@ +package cmd + +import ( + "fmt" + "net/url" + "regexp" + "strings" + "unicode/utf8" + + "github.com/kernel/cli/pkg/interactive" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/pkg/browser" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(newVaultsCommand()) +} + +func getVaultsHandler(cmd *cobra.Command) VaultsCmd { + client := getKernelClient(cmd) + return VaultsCmd{vaults: &client.Vaults, prompter: interactive.NewPrompter(), openURL: browser.OpenURL} +} + +func vaultOutput(cmd *cobra.Command) string { + output, _ := cmd.Flags().GetString("output") + return output +} + +func vaultPreRun(cmd *cobra.Command, args []string) error { + project, _ := cmd.Flags().GetString("project") + if err := requireVaultProject(resolveProjectSelection(project)); err != nil { + return err + } + if err := validateJSONOutput(vaultOutput(cmd)); err != nil { + return err + } + for i, arg := range args { + label := "vault ID or name" + if i == 1 { + label = "item key" + } + if err := validateVaultName(arg, label); err != nil { + return err + } + } + return nil +} + +func newVaultsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "vaults", Aliases: []string{"vault"}, Short: "Prepare and observe project-owned payment credentials", + Long: `Prepare and observe payment credentials; vault commands do not submit merchant payments. + +Select the project with --project or KERNEL_PROJECT. The API assigns +immutable project ownership from that scope. Vault names and item keys are immutable. + +1. Create/select a vault, then create a provider wallet and follow its returned action. +2. For Link, list wallet payment methods and select an ID explicitly. +3. Create a card request. Link requires explicit --test or --live intent. +4. For a requested Link card, use cards authorize only when advertised. Follow the + returned approval action. AgentCard authorizes at checkout, not through this command. +5. Attach the vault with browsers create --vault . Use only returned + non-secret aliases in that browser. Inspect items get/events for the outcome. + +Permitted checkout domains are provider-assigned and displayed when returned; +there is no domain-setting API. AgentCard mode is deployment-controlled, not per item. +Never supply card data, OAuth codes/tokens, ciphertext, or provider secrets to the CLI. +Never retry failed, timed-out, rejected, or indeterminate payments. +JSON output preserves returned public fields but omits unknown/opaque provider data.`, + Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, + } + + create := &cobra.Command{Use: "create --name ", Short: "Create or retrieve a vault by immutable name", Args: cobra.NoArgs, PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + return getVaultsHandler(cmd).Create(cmd.Context(), name, vaultOutput(cmd)) + }} + create.Flags().String("name", "", "Immutable vault name (required)") + _ = create.MarkFlagRequired("name") + addJSONOutputFlag(create) + + list := &cobra.Command{Use: "list", Short: "List vaults in the selected project", Args: cobra.NoArgs, PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + limit, _ := cmd.Flags().GetInt64("limit") + offset, _ := cmd.Flags().GetInt64("offset") + project, _ := cmd.Flags().GetString("project") + return getVaultsHandler(cmd).List(cmd.Context(), limit, offset, resolveProjectSelection(project), vaultOutput(cmd)) + }} + list.Flags().Int64("limit", 20, "Maximum vaults to return (1-100)") + list.Flags().Int64("offset", 0, "Number of vaults to skip") + addJSONOutputFlag(list) + + get := &cobra.Command{Use: "get ", Short: "Get a vault by ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + return getVaultsHandler(cmd).Get(cmd.Context(), args[0], vaultOutput(cmd)) + }} + addJSONOutputFlag(get) + cmd.AddCommand(create, list, get, newVaultDeleteCommand(false)) + + items := &cobra.Command{Use: "items", Short: "Inspect vault item state, actions, aliases, and outcomes"} + itemList := &cobra.Command{Use: "list ", Short: "List items by vault ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + return getVaultsHandler(cmd).ListItems(cmd.Context(), args[0], vaultOutput(cmd)) + }} + addJSONOutputFlag(itemList) + itemGet := &cobra.Command{Use: "get ", Short: "Get item state and any required action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "Get item state, available operations, provider actions, and returned checkout aliases.\n--wait is a single bounded server-side observation, not a retry or a guarantee of readiness.\nAn item still pending after the wait is returned as-is; ready does not mean paid.", + RunE: func(cmd *cobra.Command, args []string) error { + wait, _ := cmd.Flags().GetInt64("wait") + expand, _ := cmd.Flags().GetStringSlice("expand") + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], wait, expand, vaultOutput(cmd), open) + }} + itemGet.Flags().Int64("wait", 0, "Hold while pending for up to this many seconds (0-60); observe only") + itemGet.Flags().StringSlice("expand", nil, "Advertised live data to fetch: payment_methods") + itemGet.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") + addJSONOutputFlag(itemGet) + itemEvents := &cobra.Command{Use: "events ", Short: "Read immutable item events without retrying payments", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + after, _ := cmd.Flags().GetString("after") + wait, _ := cmd.Flags().GetInt64("wait") + return getVaultsHandler(cmd).Events(cmd.Context(), args[0], args[1], after, wait, vaultOutput(cmd)) + }} + itemEvents.Flags().String("after", "", "Return events after this event ID (use the last ID from the previous response)") + itemEvents.Flags().Int64("wait", 0, "Long-poll once for new events (0-60 seconds)") + addJSONOutputFlag(itemEvents) + items.AddCommand(itemList, itemGet, itemEvents, newVaultDeleteCommand(true)) + + wallets := &cobra.Command{Use: "wallets", Short: "Connect provider wallets and inspect funding methods"} + walletCreate := &cobra.Command{Use: "create --provider ", Short: "Create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "Create a wallet at an immutable key. Link uses Kernel-managed OAuth; complete the returned URL outside the CLI.\nAgentCard returns a card-enrollment action, or may reference an already enrolled user in this organization.\nAgentCard sandbox/live mode is fixed by the deployment; there is no per-item test flag.", + RunE: func(cmd *cobra.Command, args []string) error { + provider, _ := cmd.Flags().GetString("provider") + userID, _ := cmd.Flags().GetString("user-id") + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).CreateWallet(cmd.Context(), args[0], args[1], provider, userID, vaultOutput(cmd), open) + }} + walletCreate.Flags().String("provider", "", "Wallet provider: link or agentcard (required)") + _ = walletCreate.MarkFlagRequired("provider") + walletCreate.Flags().String("user-id", "", "Already enrolled AgentCard user ID in this organization (optional)") + walletCreate.Flags().Bool("open", false, "Open the returned HTTPS connection/enrollment URL") + addJSONOutputFlag(walletCreate) + methods := &cobra.Command{Use: "payment-methods ", Short: "Fetch advertised live wallet payment methods", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "Fetch payment_methods through the item's GET expansion. The wallet must advertise this expansion.\nDisplays selectable IDs and advisory capabilities; never automatically chooses a funding method.\nJSON returns the item with expanded.payment_methods, like items get --expand payment_methods.", + RunE: func(cmd *cobra.Command, args []string) error { + return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], 0, []string{"payment_methods"}, vaultOutput(cmd), false) + }} + addJSONOutputFlag(methods) + wallets.AddCommand(walletCreate, methods) + + cards := &cobra.Command{Use: "cards", Short: "Configure card requests and explicitly authorize requested Link cards"} + authorize := &cobra.Command{Use: "authorize ", Short: "Invoke advertised authorization for a requested Link card", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "Use only after explicit user approval. Retrieve the item and invoke authorize only if advertised and still requested.\nThis can obtain a payment credential but does not submit a merchant payment.\nPending/terminal authorizations are never resumed or retried by this command.\nFollow any returned approval URL, then observe with items get --wait 60.", + RunE: func(cmd *cobra.Command, args []string) error { + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).Authorize(cmd.Context(), args[0], args[1], vaultOutput(cmd), open) + }} + authorize.Flags().Bool("open", false, "Open the returned HTTPS approval URL") + addJSONOutputFlag(authorize) + cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true), authorize) + cmd.AddCommand(items, wallets, cards) + return cmd +} + +func newVaultDeleteCommand(item bool) *cobra.Command { + use, short, nargs := "delete ", "Delete a vault and invalidate all its items", 1 + if item { + use, short, nargs = "delete ", "Delete an item and invalidate its credential", 2 + } + cmd := &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(nargs), PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + key := "" + if item { + key = args[1] + } + yes, _ := cmd.Flags().GetBool("yes") + return getVaultsHandler(cmd).Delete(cmd.Context(), args[0], key, yes) + }} + cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + return cmd +} + +func newVaultCardCommand(update bool) *cobra.Command { + use, short := "create", "Create a card request without authorizing it" + if update { + use, short = "update", "Replace a card spec when the API permits configuration" + } + cmd := &cobra.Command{Use: use + " ", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: short + `. Supply the complete specification, including all required flags. +Link requires --payment-method-id, --merchant-url, --context (at least 100 characters), +and exactly one of --test or --live. Amount is an integer in minor currency units. +AgentCard uses --merchant as its approval-screen name; --card-id is optional. +AgentCard mode is deployment-controlled; --test/--live are not supported for it. +Permitted domains come from the provider and cannot be configured by this API. +Neither create nor update authorizes a Link card. The API enforces update eligibility, +provider/wallet invariants, and immutable item keys. Update replaces the entire spec; +optional purchase details set outside the CLI are removed when omitted. +Never reconfigure to retry a failed, +timed-out, rejected, or indeterminate payment.`, + RunE: func(cmd *cobra.Command, args []string) error { + spec, err := vaultCardSpecFromFlags(cmd) + if err != nil { + return err + } + return getVaultsHandler(cmd).SaveCard(cmd.Context(), args[0], args[1], spec, update, vaultOutput(cmd)) + }} + cmd.Flags().String("provider", "", "Card provider: link or agentcard (required)") + cmd.Flags().String("wallet", "", "Wallet item key in this vault (required)") + cmd.Flags().Int64("amount", 0, "Amount in minor currency units, not a decimal (required)") + cmd.Flags().String("currency", "", "Three-letter currency code (required)") + cmd.Flags().String("merchant", "", "Merchant name for approval (required)") + for _, flag := range []string{"provider", "wallet", "amount", "currency", "merchant"} { + _ = cmd.MarkFlagRequired(flag) + } + cmd.Flags().String("payment-method-id", "", "Explicit ID from wallets payment-methods (required for Link)") + cmd.Flags().String("merchant-url", "", "Absolute HTTP(S) merchant URL (required for Link)") + cmd.Flags().String("context", "", "Purchase purpose, at least 100 characters (required for Link); no secrets") + cmd.Flags().Bool("test", false, "Request Link test credentials (explicitly choose --test or --live)") + cmd.Flags().Bool("live", false, "Request a live Link payment credential (explicit opt-in)") + cmd.MarkFlagsMutuallyExclusive("test", "live") + cmd.Flags().String("card-id", "", "AgentCard vaulted card ID; omit to let the cardholder select during approval") + addJSONOutputFlag(cmd) + return cmd +} + +func vaultCardSpecFromFlags(cmd *cobra.Command) (kernel.CardVaultItemSpecUnionParam, error) { + var spec kernel.CardVaultItemSpecUnionParam + provider, _ := cmd.Flags().GetString("provider") + wallet, _ := cmd.Flags().GetString("wallet") + amount, _ := cmd.Flags().GetInt64("amount") + currency, _ := cmd.Flags().GetString("currency") + merchant, _ := cmd.Flags().GetString("merchant") + if err := validateVaultName(wallet, "--wallet"); err != nil { + return spec, err + } + if !regexp.MustCompile(`^[A-Za-z]{3}$`).MatchString(currency) { + return spec, fmt.Errorf("--currency must be a three-letter currency code") + } + currency = strings.ToLower(currency) + if amount < 1 { + return spec, fmt.Errorf("--amount must be positive, in minor currency units") + } + if strings.TrimSpace(merchant) == "" { + return spec, fmt.Errorf("--merchant is required") + } + switch provider { + case "link": + if cmd.Flags().Changed("card-id") { + return spec, fmt.Errorf("--card-id is only supported by agentcard") + } + if amount > 500000 || utf8.RuneCountInString(merchant) > 255 { + return spec, fmt.Errorf("link requires --amount <= 500000 and --merchant <= 255 characters") + } + test, _ := cmd.Flags().GetBool("test") + live, _ := cmd.Flags().GetBool("live") + if test == live || (cmd.Flags().Changed("test") && cmd.Flags().Changed("live")) { + return spec, fmt.Errorf("link requires exactly one of --test or --live (set to true)") + } + method, _ := cmd.Flags().GetString("payment-method-id") + merchantURL, _ := cmd.Flags().GetString("merchant-url") + contextText, _ := cmd.Flags().GetString("context") + if strings.TrimSpace(method) == "" { + return spec, fmt.Errorf("--payment-method-id is required; select an ID from wallets payment-methods") + } + u, err := url.Parse(merchantURL) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil { + return spec, fmt.Errorf("--merchant-url must be an absolute HTTP(S) URL without credentials") + } + if utf8.RuneCountInString(strings.TrimSpace(contextText)) < 100 { + return spec, fmt.Errorf("--context must describe the purchase in at least 100 characters") + } + spec.OfLink = &kernel.CardVaultItemSpecLinkParam{Wallet: wallet, Amount: amount, Currency: currency, MerchantName: merchant, MerchantURL: merchantURL, PaymentMethodID: method, Context: contextText, Test: test} + case "agentcard": + for _, flag := range []string{"test", "live", "payment-method-id", "merchant-url", "context"} { + if cmd.Flags().Changed(flag) { + return spec, fmt.Errorf("--%s is only supported by Link; AgentCard mode is deployment-controlled", flag) + } + } + if amount > 9007199254740991 || utf8.RuneCountInString(merchant) > 120 { + return spec, fmt.Errorf("agentcard requires --amount <= 9007199254740991 and --merchant <= 120 characters") + } + spec.OfAgentcard = &kernel.CardVaultItemSpecAgentcardParam{Wallet: wallet, Amount: amount, Currency: currency, Merchant: merchant} + cardID, _ := cmd.Flags().GetString("card-id") + if cmd.Flags().Changed("card-id") { + if !regexp.MustCompile(`^vc_[A-Za-z0-9_]+$`).MatchString(cardID) { + return spec, fmt.Errorf("--card-id must be an AgentCard vaulted card ID (vc_...)") + } + spec.OfAgentcard.CardID = kernel.Opt(cardID) + } + default: + return spec, fmt.Errorf("--provider must be link or agentcard") + } + return spec, nil +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go new file mode 100644 index 00000000..148710ad --- /dev/null +++ b/cmd/vaults_output.go @@ -0,0 +1,296 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/pterm/pterm" +) + +type vaultJSON map[string]json.RawMessage +type vaultOutputFields map[string]vaultOutputFields + +func vaultFieldsOf(names string) vaultOutputFields { + fields := make(vaultOutputFields) + for _, name := range strings.Fields(names) { + fields[name] = nil + } + return fields +} + +var vaultFields = vaultFieldsOf("id name created_at updated_at") +var vaultOperationFields = vaultFieldsOf("type description") +var vaultTotalFields = vaultFieldsOf("type display_text amount") +var vaultMethodFields = vaultOutputFields{ + "id": nil, "provider": nil, "type": nil, "is_default": nil, + "display": vaultFieldsOf("label brand last4"), + "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, +} +var vaultItemFields = vaultOutputFields{ + "id": nil, "key": nil, "type": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, + "available_operations": vaultOperationFields, + "available_expansions": vaultOperationFields, + "action": vaultFieldsOf("name url"), + "expanded": {"payment_methods": vaultMethodFields}, + "spec": { + "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, + "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, + "context": nil, "test": nil, "expires_at": nil, + "authorization": {"method": nil, "client": vaultFieldsOf("type")}, + "totals": vaultTotalFields, + "line_items": { + "name": nil, "quantity": nil, "unit_amount": nil, "description": nil, + "sku": nil, "url": nil, "image_url": nil, "product_url": nil, "totals": vaultTotalFields, + }, + }, + "state": { + "provider": nil, "status": nil, "status_reason": nil, "user_id": nil, "domains": nil, + "masks": vaultFieldsOf("brand last4"), + "aliases": vaultFieldsOf("number cvc exp_month exp_year"), + "authorization": vaultFieldsOf("id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered"), + }, +} +var vaultEventFields = vaultOutputFields{ + "id": nil, "name": nil, "created_at": nil, "browser_id": nil, + "data": vaultFieldsOf("reason status authorization_id vault_session_id request_kind outcome_reason provider_status provider_code provider_request_id provider_payment_status provider_error_type provider_error_code provider_decline_code provider_error_param provider_http_status provider_response_bytes provider_latency_ms payment_intent_id payment_method_id checkout_session_id replay_attempted replay_delivered charged_amount_cents charged_currency charged_kind expected_cents actual_cents currency actual_currency intent_status amount_verified psp_error_code"), +} + +// Vault output is a display-safe projection, not raw provider JSON. Keep presence +// information while dropping unknown fields and opaque event data at every level. +func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMessage, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return nil, fmt.Errorf("empty vault response") + } + if bytes.Equal(raw, []byte("null")) { + return raw, nil + } + if raw[0] == '[' { + var values []json.RawMessage + if err := json.Unmarshal(raw, &values); err != nil { + return nil, err + } + for i, value := range values { + filtered, err := filterVaultJSON(value, fields) + if err != nil { + return nil, err + } + values[i] = filtered + } + return json.Marshal(values) + } + if fields == nil { + if raw[0] == '{' { + return json.RawMessage("null"), nil + } + return raw, nil + } + var object vaultJSON + if err := json.Unmarshal(raw, &object); err != nil { + return nil, fmt.Errorf("invalid vault response shape") + } + result := make(vaultJSON) + for key, children := range fields { + if value, ok := object[key]; ok { + if key == "url" || key == "approval_url" || key == "merchant_url" || key == "image_url" || key == "product_url" { + var address string + if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { + continue + } + } + filtered, err := filterVaultJSON(value, children) + if err != nil { + return nil, err + } + result[key] = filtered + } + } + return json.Marshal(result) +} + +func vaultSafeJSONSlice[T util.RawJSONProvider](items []T, fields vaultOutputFields) ([]vaultJSON, error) { + result := make([]vaultJSON, 0, len(items)) + for _, item := range items { + raw, err := filterVaultJSON(json.RawMessage(item.RawJSON()), fields) + if err != nil { + return nil, err + } + var value vaultJSON + if err := json.Unmarshal(raw, &value); err != nil { + return nil, err + } + result = append(result, value) + } + return result, nil +} + +func printVaultJSON(value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +func printVault(v *kernel.Vault, output string) error { + if output == "json" { + raw, err := filterVaultJSON(json.RawMessage(v.RawJSON()), vaultFields) + if err != nil { + return err + } + return printVaultJSON(raw) + } + PrintTableNoPad(pterm.TableData{ + {"Property", "Value"}, {"ID", v.ID}, {"Name (immutable)", v.Name}, + {"Created At", util.FormatLocal(v.CreatedAt)}, {"Updated At", util.FormatLocal(v.UpdatedAt)}, + }, true) + return nil +} + +func vaultDisplayURL(address string) bool { + u, err := url.Parse(address) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil { + return false + } + query, err := url.ParseQuery(u.RawQuery) + if err != nil { + return false + } + fragment, err := url.ParseQuery(u.Fragment) + if err != nil { + return false + } + for _, values := range []url.Values{query, fragment} { + for key := range values { + switch strings.ToLower(key) { + case "code", "access_token", "refresh_token", "id_token", "client_secret", "password": + return false + } + } + } + return true +} + +func printVaultItem(item *kernel.VaultItemUnion, output string) error { + raw, err := filterVaultJSON(json.RawMessage(item.RawJSON()), vaultItemFields) + if err != nil { + return err + } + if output == "json" { + return printVaultJSON(raw) + } + var safe kernel.VaultItemUnion + if err := json.Unmarshal(raw, &safe); err != nil { + return fmt.Errorf("invalid vault item response") + } + item = &safe + rows := pterm.TableData{ + {"Property", "Value"}, {"Key (immutable)", item.Key}, {"ID", item.ID}, + {"Type", item.Type}, {"Provider", item.Spec.Provider}, {"Status", item.State.Status}, + } + if item.State.StatusReason != "" { + rows = append(rows, []string{"Status reason", item.State.StatusReason}) + } + if item.Type == "card" { + merchant := item.Spec.MerchantName + if item.Spec.Provider == "agentcard" { + merchant = item.Spec.Merchant + } + rows = append(rows, []string{"Wallet key", item.Spec.Wallet}, []string{"Merchant", merchant}, []string{"Amount (minor units)", fmt.Sprintf("%d %s", item.Spec.Amount, item.Spec.Currency)}) + if item.Spec.Provider == "link" { + rows = append(rows, []string{"Test", fmt.Sprint(item.Spec.Test)}, []string{"Payment method ID", item.Spec.PaymentMethodID}) + } else { + rows = append(rows, []string{"Mode", "Deployment-controlled (no per-item test mode)"}) + } + } + if item.State.JSON.Domains.Valid() { + rows = append(rows, []string{"Permitted domains (provider-assigned)", strings.Join(item.State.Domains, ", ")}) + } + if item.Action.Name != "" { + rows = append(rows, []string{"Required action", item.Action.Name}) + if item.Action.URL != "" { + rows = append(rows, []string{"Action URL", item.Action.URL}) + } + } + if !item.ExpiresAt.IsZero() { + rows = append(rows, []string{"Expires At", util.FormatLocal(item.ExpiresAt)}) + } + if item.State.JSON.Aliases.Valid() { + a := item.State.Aliases + rows = append(rows, []string{"Checkout alias: number", a.Number}, []string{"Checkout alias: cvc", a.Cvc}, []string{"Checkout alias: exp_month", a.ExpMonth}, []string{"Checkout alias: exp_year", a.ExpYear}) + } + if item.State.JSON.Authorization.Valid() { + a := item.State.Authorization + rows = append(rows, []string{"Checkout authorization", a.ID}, []string{"Authorization status", string(a.Status)}) + if a.Reason != "" { + rows = append(rows, []string{"Authorization reason", a.Reason}) + } + if a.ApprovalURL != "" { + rows = append(rows, []string{"Approval URL", a.ApprovalURL}) + } + if a.JSON.ChargedKind.Valid() { + rows = append(rows, []string{"Charged kind", string(a.ChargedKind)}, []string{"Charged (minor units)", fmt.Sprintf("%d %s", a.ChargedAmountCents, a.ChargedCurrency)}) + } + if a.JSON.ReplayDelivered.Valid() { + rows = append(rows, []string{"Processor response delivered", fmt.Sprint(a.ReplayDelivered)}) + } + } + PrintTableNoPad(rows, true) + if item.Type == "card" { + card := item.AsCard() + for _, op := range card.AvailableOperations { + pterm.Printf("Available operation: %s — %s\n", op.Type, op.Description) + } + for _, expansion := range card.AvailableExpansions { + pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) + } + if item.State.JSON.Aliases.Valid() { + pterm.Info.Println("Aliases are non-secret checkout values. Use only in a browser created with this vault attached; ready does not mean paid.") + } + pterm.Info.Println("Inspect items events for payment outcomes. Do not retry failed, timed-out, rejected, or indeterminate payments.") + } else { + wallet := item.AsWallet() + for _, expansion := range wallet.AvailableExpansions { + pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) + } + } + if item.Expanded.JSON.PaymentMethods.Valid() { + printVaultPaymentMethods(item.Expanded.PaymentMethods) + } + if item.Action.Name != "" { + pterm.Info.Println("Complete the returned action with the provider; never pass card data or OAuth codes to the CLI. Observe with items get --wait 60.") + } + return nil +} + +func printVaultPaymentMethods(methods []kernel.VaultPaymentMethod) { + if len(methods) == 0 { + pterm.Info.Println("No payment methods returned") + return + } + rows := pterm.TableData{{"Payment method ID", "Provider", "Type", "Label", "Brand", "Last4", "Default", "Single-use eligible", "Reasons"}} + for _, m := range methods { + capability := m.Capabilities.SingleUseCard + eligible := "unknown" + if capability.JSON.Eligible.Valid() { + eligible = fmt.Sprint(capability.Eligible) + } + rows = append(rows, []string{m.ID, m.Provider, m.Type, m.Display.Label, m.Display.Brand, m.Display.Last4, fmt.Sprint(m.IsDefault), eligible, strings.Join(capability.Reasons, ", ")}) + } + PrintTableNoPad(rows, true) + pterm.Info.Println("Select an ID explicitly: Link uses cards create --payment-method-id; AgentCard uses --card-id (or omit it for cardholder selection). Capabilities are advisory; missing means unknown, not ineligible.") +} + +func printVaultEvents(events []kernel.VaultItemEvent, data []vaultJSON) { + rows := pterm.TableData{{"Event ID", "Time", "Name", "Browser ID", "Outcome data"}} + for i, event := range events { + rows = append(rows, []string{event.ID, util.FormatLocal(event.CreatedAt), event.Name, util.OrDash(event.BrowserID), string(data[i]["data"])}) + } + PrintTableNoPad(rows, true) +} diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go new file mode 100644 index 00000000..60a4940d --- /dev/null +++ b/cmd/vaults_output_test.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const readyCardFixture = `{ + "id":"card-id","key":"order-1","type":"card", + "spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","test":true,"provider_secret":"SECRET_SPEC"}, + "state":{"provider":"link","status":"ready","domains":["shop.example"],"aliases":{"number":"9999999999999999","cvc":"999","exp_month":"01","exp_year":"2099","secret":"SECRET_ALIAS"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"}, + "available_operations":[],"available_expansions":[],"oauth_tokens":"SECRET_OAUTH" +}` + +func TestVaultOutputAliasesPresenceAndRedaction(t *testing.T) { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(readyCardFixture), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + human := buf.String() + assert.Contains(t, human, "9999999999999999") + assert.Contains(t, human, "Checkout alias: cvc") + assert.Contains(t, human, "Permitted domains (provider-assigned)") + assert.Contains(t, human, "shop.example") + assert.Contains(t, human, "ready does not mean paid") + assert.Contains(t, human, "Do not retry") + assert.NotContains(t, human, "SECRET") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, "SECRET") + assert.Contains(t, out, "9999999999999999") + assert.NotContains(t, out, "authorization") + assert.NotContains(t, out, "expires_at") + + require.NoError(t, json.Unmarshal([]byte(requestedCardFixture), &item)) + buf.Reset() + require.NoError(t, printVaultItem(&item, "")) + assert.NotContains(t, buf.String(), "Checkout alias") + assert.Contains(t, buf.String(), "Available operation: authorize") + out = captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, "aliases") + + nullAliases := strings.Replace(requestedCardFixture, `"status":"requested"`, `"status":"requested","aliases":null`, 1) + require.NoError(t, json.Unmarshal([]byte(nullAliases), &item)) + buf.Reset() + require.NoError(t, printVaultItem(&item, "")) + assert.NotContains(t, buf.String(), "Checkout alias") + out = captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.Contains(t, out, `"aliases": null`) +} + +func TestVaultOutputAgentCardAuthorizationIsNotPaymentSuccess(t *testing.T) { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(`{ + "id":"card-id","key":"order-1","type":"card", + "spec":{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop"}, + "state":{"provider":"agentcard","status":"ready","authorization":{"id":"cauth_test","status":"declined","psp":"stripe","merchant":"Example Shop","amount_cents":1234,"currency":"usd","reason":"expired","charged_kind":"none","replay_delivered":false,"raw_response":"SECRET_RESPONSE"}}, + "available_operations":[],"available_expansions":[] + }`), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + for _, text := range []string{"Deployment-controlled", "Authorization status", "declined", "expired", "Charged kind", "none", "Processor response delivered", "false"} { + assert.Contains(t, buf.String(), text) + } + assert.NotContains(t, buf.String(), "SECRET_RESPONSE") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, `"test"`) + assert.NotContains(t, out, "SECRET_RESPONSE") +} + +func TestVaultOutputPaymentMethodsAdvisoryUnknownVsFalse(t *testing.T) { + body := strings.TrimSuffix(connectedWalletFixture, "}") + `,"expanded":{"payment_methods":[ + {"id":"pm-unknown","provider":"link","type":"card","is_default":true,"display":{"label":"Personal","brand":"visa","last4":"1234"},"capabilities":{},"provider_secret":"SECRET_METHOD"}, + {"id":"pm-ineligible","provider":"link","type":"card","is_default":false,"display":{},"capabilities":{"single_use_card":{"eligible":false,"reasons":["not_supported"]}}} + ]}}` + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(body), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + for _, text := range []string{"Payment method ID", "pm-unknown", "unknown", "pm-ineligible", "false", "not_supported", "--payment-method-id", "advisory"} { + assert.Contains(t, buf.String(), text) + } + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, "SECRET_METHOD") + assert.Contains(t, out, `"capabilities": {}`) +} + +func TestVaultActionOutputNoInventedURLs(t *testing.T) { + for _, action := range []struct{ name, url string }{ + {"link_oauth", "https://provider.example/auth?state=state&code_challenge=challenge"}, + {"spend_approval", "https://provider.example/approve"}, + {"card_enrollment", "https://provider.example/enroll"}, + {"collect", ""}, {"mfa", ""}, {"push_approval", ""}, {"embedded_ceremony", ""}, + } { + t.Run(action.name, func(t *testing.T) { + var item kernel.VaultItemUnion + body := strings.TrimSuffix(connectedWalletFixture, "}") + fmt.Sprintf(`,"action":{"name":%q`, action.name) + if action.url != "" { + body += fmt.Sprintf(`,"url":%q`, action.url) + } + body += "}}" + require.NoError(t, json.Unmarshal([]byte(body), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + assert.Contains(t, buf.String(), action.name) + if action.url != "" { + assert.Contains(t, buf.String(), action.url) + } else { + assert.NotContains(t, buf.String(), "Action URL") + } + }) + } +} + +func TestVaultURLsWithSecretsAreWithheld(t *testing.T) { + for _, address := range []string{ + "https://user:SECRET@provider.example/", "https://provider.example/?code=SECRET", + "https://provider.example/#access_token=SECRET", "javascript:SECRET", + } { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(strings.TrimSuffix(connectedWalletFixture, "}")+fmt.Sprintf(`,"action":{"name":"link_oauth","url":%q}}`, address)), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + assert.NotContains(t, buf.String(), "SECRET") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, "SECRET") + } +} + +func TestVaultCancellationDoesNotLeakTransportDetails(t *testing.T) { + for _, cause := range []error{context.Canceled, context.DeadlineExceeded, errors.New("SECRET_TRANSPORT")} { + err := vaultRequestError(cause) + require.Error(t, err) + assert.NotContains(t, util.CleanedUpSdkError{Err: err}.Error(), "SECRET") + } + var calls atomic.Int32 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + <-r.Context().Done() + }) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + c := VaultsCmd{vaults: &client.Vaults} + err := c.GetItem(ctx, "checkout", "order-1", 60, nil, "json", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "interrupted") + assert.Equal(t, int32(1), calls.Load()) +} diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go new file mode 100644 index 00000000..dcb60f56 --- /dev/null +++ b/cmd/vaults_test.go @@ -0,0 +1,447 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kernel/cli/pkg/interactive" + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const vaultFixture = `{"id":"vault-1","name":"checkout","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` +const requestedCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","context":"Purchase description","test":true},"state":{"provider":"link","status":"requested"},"available_operations":[{"type":"authorize","description":"Use only after explicit user approval."}],"available_expansions":[]}` +const connectedWalletFixture = `{"id":"wallet-id","key":"wallet-1","type":"wallet","spec":{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}},"state":{"provider":"link","status":"connected"},"available_operations":[],"available_expansions":[{"type":"payment_methods","description":"Select a payment method explicitly."}]}` + +func vaultTestClient(t *testing.T, handler http.HandlerFunc) kernel.Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test")) +} + +func executeVaultCommand(t *testing.T, client kernel.Client, args ...string) (string, string, error) { + t.Helper() + root := &cobra.Command{Use: "kernel", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().String("project", "", "Project") + root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + project, _ := cmd.Flags().GetString("project") + scoped := client + scoped.Vaults = kernel.NewVaultService(append(client.Options, option.WithProject(resolveProjectSelection(project)))...) + cmd.SetContext(context.WithValue(cmd.Context(), util.KernelClientKey, scoped)) + return nil + } + root.AddCommand(newVaultsCommand()) + root.SetArgs(args) + buf := capturePtermOutput(t) + var err error + stdout := captureStdout(t, func() { err = root.Execute() }) + return stdout, buf.String(), err +} + +func TestVaultCommandConstruction(t *testing.T) { + for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "cards update", "cards authorize"} { + t.Run(path, func(t *testing.T) { + cmd, remaining, err := newVaultsCommand().Find(strings.Fields(path)) + require.NoError(t, err) + require.Empty(t, remaining) + assert.NotNil(t, cmd.RunE) + assert.NotNil(t, cmd.PreRunE) + assert.NotNil(t, cmd.Args) + if cmd.Name() == "delete" { + assert.NotNil(t, cmd.Flags().Lookup("yes")) + } else { + assert.NotNil(t, cmd.Flags().Lookup("output")) + } + }) + } + cmd, _, err := rootCmd.Find([]string{"vaults", "cards", "authorize"}) + require.NoError(t, err) + assert.False(t, isAuthExempt(cmd)) + for _, unsupported := range []string{"rename", "update", "items put", "items action", "wallets callback", "cards pay"} { + cmd, remaining, _ := newVaultsCommand().Find(strings.Fields(unsupported)) + assert.True(t, len(remaining) > 0 || cmd.RunE == nil, unsupported) + } +} + +func TestVaultRequiredAndInvalidFlags(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + tests := []struct { + args string + want string + }{ + {"vaults create", "required flag"}, + {"vaults create --name=", "--name"}, + {"vaults create --name=bad/name", "--name"}, + {"vaults create --name=..", "--name"}, + {"vaults get", "accepts 1 arg"}, + {"vaults get bad%2Fname", "vault ID or name"}, + {"vaults items get checkout bad/key", "item key"}, + {"vaults list --limit 0", "--limit"}, + {"vaults list --limit 101", "--limit"}, + {"vaults list --offset -1", "--offset"}, + {"vaults list -o yaml", "output"}, + {"vaults items get checkout wallet-1 --wait -1", "--wait"}, + {"vaults items get checkout wallet-1 --wait 61", "--wait"}, + {"vaults items events checkout wallet-1 --wait 61", "--wait"}, + {"vaults items get checkout wallet-1 --expand secret", "--expand"}, + {"vaults wallets create checkout wallet-1", "required flag"}, + {"vaults wallets create checkout wallet-1 --provider unknown", "--provider"}, + {"vaults wallets create checkout wallet-1 --provider link --user-id usr_123", "--user-id"}, + {"vaults wallets create checkout wallet-1 --provider agentcard --user-id wrong", "--user-id"}, + {"vaults cards create checkout order-1", "required flag"}, + {"vaults delete checkout", "--yes"}, + {"vaults items delete checkout order-1", "--yes"}, + {"vaults cards create checkout order-1 --domain shop.example", "unknown flag"}, + {"vaults create --name checkout --project-id project-test", "unknown flag"}, + } + for _, tt := range tests { + t.Run(tt.args, func(t *testing.T) { + _, _, err := executeVaultCommand(t, client, strings.Fields(tt.args)...) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } + t.Setenv("KERNEL_PROJECT", "") + _, _, err := executeVaultCommand(t, client, "vaults", "list") + require.ErrorContains(t, err, "--project") +} + +func linkCardArgs() []string { + return []string{"--provider", "link", "--wallet", "wallet-1", "--amount", "1234", "--currency", "USD", "--merchant", "Example Shop", "--payment-method-id", "pm-1", "--merchant-url", "https://shop.example", "--context", strings.Repeat("Purchase purpose. ", 7), "--test"} +} + +func TestVaultCardSpecValidation(t *testing.T) { + tests := []struct { + flag, value, want string + }{ + {"provider", "other", "--provider"}, + {"wallet", "", "--wallet"}, + {"amount", "0", "--amount"}, + {"amount", "-1", "--amount"}, + {"amount", "500001", "--amount"}, + {"currency", "US", "--currency"}, + {"currency", "123", "--currency"}, + {"merchant", " ", "--merchant"}, + {"merchant", strings.Repeat("m", 256), "--merchant"}, + {"payment-method-id", "", "--payment-method-id"}, + {"merchant-url", "example.com", "--merchant-url"}, + {"merchant-url", "https://user:secret@example.com", "--merchant-url"}, + {"merchant-url", "javascript:alert(1)", "--merchant-url"}, + {"context", strings.Repeat("x", 99), "--context"}, + {"test", "false", "--test or --live"}, + {"live", "true", "--test or --live"}, + {"card-id", "vc_test", "--card-id"}, + } + for _, tt := range tests { + t.Run(tt.flag+tt.value, func(t *testing.T) { + cmd := newVaultCardCommand(false) + require.NoError(t, cmd.ParseFlags(linkCardArgs())) + require.NoError(t, cmd.Flags().Set(tt.flag, tt.value)) + _, err := vaultCardSpecFromFlags(cmd) + require.ErrorContains(t, err, tt.want) + }) + } + for _, update := range []bool{false, true} { + cmd := newVaultCardCommand(update) + args := linkCardArgs() + args[len(args)-1] = "--live" + require.NoError(t, cmd.ParseFlags(args)) + spec, err := vaultCardSpecFromFlags(cmd) + require.NoError(t, err) + assert.False(t, spec.OfLink.Test) + assert.Equal(t, "usd", spec.OfLink.Currency) + assert.Equal(t, int64(1234), spec.OfLink.Amount) + } + for _, args := range [][]string{ + {"--provider", "agentcard", "--wallet", "wallet-1", "--amount", "9007199254740992", "--currency", "USD", "--merchant", "Shop"}, + {"--provider", "agentcard", "--wallet", "wallet-1", "--amount", "1", "--currency", "USD", "--merchant", "Shop", "--test=false"}, + {"--provider", "agentcard", "--wallet", "wallet-1", "--amount", "1", "--currency", "USD", "--merchant", "Shop", "--card-id", ""}, + } { + cmd := newVaultCardCommand(false) + require.NoError(t, cmd.ParseFlags(args)) + _, err := vaultCardSpecFromFlags(cmd) + require.Error(t, err) + } +} + +func TestVaultCreateScopeAndImmutableName(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "env-project") + project := "env-project" + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vaults", r.URL.Path) + assert.Equal(t, project, r.Header.Get("X-Kernel-Project")) + body, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"name":"checkout"}`, string(body)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, vaultFixture) + }) + out, human, err := executeVaultCommand(t, client, "vaults", "create", "--name", "checkout", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, vaultFixture, out) + assert.Empty(t, human) + project = "flag-project" + _, human, err = executeVaultCommand(t, client, "--project", project, "vaults", "create", "--name", "checkout") + require.NoError(t, err) + assert.Contains(t, human, "Name (immutable)") + assert.Contains(t, human, "checkout") +} + +func TestVaultListPaginationAndEmptyJSON(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + body, hasMore, next := "["+vaultFixture+"]", "true", "21" + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/vaults", r.URL.Path) + assert.Equal(t, "1", r.URL.Query().Get("limit")) + assert.Equal(t, "20", r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Has-More", hasMore) + w.Header().Set("X-Next-Offset", next) + _, _ = io.WriteString(w, body) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "list", "--limit", "1", "--offset", "20", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"vaults":[`+vaultFixture+`],"next_offset":21}`, out) + _, human, err := executeVaultCommand(t, client, "vaults", "list", "--limit", "1", "--offset", "20") + require.NoError(t, err) + assert.Contains(t, human, `kernel --project "project-test" vaults list --limit 1 --offset 21`) + body, hasMore, next = "[]", "false", "0" + out, _, err = executeVaultCommand(t, client, "vaults", "list", "--limit", "1", "--offset", "20", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"vaults":[]}`, out) +} + +func TestVaultWalletRequestMapping(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + for _, tt := range []struct { + provider, userID, spec string + }{ + {"link", "", `{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}`}, + {"agentcard", "", `{"provider":"agentcard"}`}, + {"agentcard", "usr_enrolled", `{"provider":"agentcard","user_id":"usr_enrolled"}`}, + } { + t.Run(tt.provider+tt.userID, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/vaults/checkout/items/wallet-1", r.URL.Path) + body, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"type":"wallet","spec":`+tt.spec+`}`, string(body)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, connectedWalletFixture) + }) + args := []string{"vaults", "wallets", "create", "checkout", "wallet-1", "--provider", tt.provider, "-o", "json"} + if tt.userID != "" { + args = append(args, "--user-id", tt.userID) + } + out, human, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.JSONEq(t, connectedWalletFixture, out) + assert.Empty(t, human) + }) + } +} + +func TestVaultCardRequestMapping(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + for _, operation := range []string{"create", "update"} { + for _, provider := range []string{"link", "agentcard"} { + t.Run(operation+provider, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + expectedMethod := http.MethodPut + if operation == "update" { + expectedMethod = http.MethodPatch + } + assert.Equal(t, expectedMethod, r.Method) + assert.Equal(t, "/vaults/checkout/items/order-1", r.URL.Path) + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + if operation == "create" { + assert.JSONEq(t, `"card"`, string(body["type"])) + assert.Len(t, body, 2) + } else { + assert.Len(t, body, 1) + } + if provider == "link" { + assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","payment_method_id":"pm-1","context":%q,"test":true}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) + } else { + assert.JSONEq(t, `{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen"}`, string(body["spec"])) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) + }) + flags := linkCardArgs() + if provider == "agentcard" { + flags = []string{"--provider", provider, "--wallet", "wallet-1", "--amount", "1234", "--currency", "USD", "--merchant", "Example Shop", "--card-id", "vc_chosen"} + } + args := append([]string{"vaults", "cards", operation, "checkout", "order-1", "-o", "json"}, flags...) + out, human, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.JSONEq(t, requestedCardFixture, out) + assert.Empty(t, human) + assert.Equal(t, 1, calls, "card writes must not authorize implicitly") + }) + } + } +} + +func TestVaultAuthorizeRequiresAdvertisedRequestedLinkCard(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + for _, state := range []string{"requested", "pending_authorization", "ready", "consumed", "expired", "declined"} { + for _, advertised := range []bool{false, true} { + t.Run(fmt.Sprint(state, advertised), func(t *testing.T) { + getCalls, postCalls := 0, 0 + body := strings.ReplaceAll(requestedCardFixture, `"status":"requested"`, `"status":"`+state+`"`) + if !advertised { + body = strings.ReplaceAll(body, `[{"type":"authorize","description":"Use only after explicit user approval."}]`, `[]`) + } + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + getCalls++ + _, _ = io.WriteString(w, body) + return + } + postCalls++ + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vaults/checkout/items/order-1/operations", r.URL.Path) + payload, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"type":"authorize"}`, string(payload)) + _, _ = io.WriteString(w, body) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "cards", "authorize", "checkout", "order-1", "-o", "json") + assert.Equal(t, 1, getCalls) + if state == "requested" && advertised { + require.NoError(t, err) + assert.Equal(t, 1, postCalls) + } else { + require.Error(t, err) + assert.Zero(t, postCalls) + } + }) + } + } +} + +func TestVaultNoSDKRetriesOrSensitiveErrors(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + for _, status := range []int{409, 429, 500} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, requestedCardFixture) + return + } + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"message":"provider-secret; retry shortly","code":"sensitive-provider-code"}`) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "cards", "authorize", "checkout", "order-1", "-o", "json") + require.Error(t, err) + assert.Equal(t, 2, calls) + assert.Empty(t, out) + assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", status)) + rendered := util.CleanedUpSdkError{Err: err}.Error() + assert.NotContains(t, rendered, "provider-secret") + assert.NotContains(t, rendered, "sensitive-provider-code") + assert.NotContains(t, rendered, "retry shortly") + }) + } +} + +func TestVaultGetWaitExpansionAndEvents(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/vaults/checkout/items/wallet-1": + assert.Equal(t, "60", r.URL.Query().Get("wait")) + assert.Equal(t, "payment_methods", r.URL.Query().Get("expand")) + _, _ = io.WriteString(w, connectedWalletFixture) + case "/vaults/checkout/items/order-1/events": + assert.Equal(t, "60", r.URL.Query().Get("wait")) + assert.Equal(t, "event-before", r.URL.Query().Get("after")) + _, _ = io.WriteString(w, `[{"id":"event-next","name":"payment_failed","browser_id":"browser-1","created_at":"2026-09-01T00:00:00Z","data":{"reason":"declined","outcome_reason":"provider_error","provider_http_status":402,"provider_decline_code":"insufficient_funds","actual_currency":"usd","provider_response":{"card_number":"SECRET"}}}]`) + default: + t.Errorf("unexpected request: %s", r.URL) + } + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "wallet-1", "--wait", "60", "--expand", "payment_methods", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, connectedWalletFixture, out) + args := []string{"vaults", "items", "events", "checkout", "order-1", "--wait", "60", "--after", "event-before"} + out, _, err = executeVaultCommand(t, client, append(args, "-o", "json")...) + require.NoError(t, err) + assert.NotContains(t, out, "SECRET") + assert.Contains(t, out, "declined") + assert.Contains(t, out, `"provider_http_status": 402`) + assert.Contains(t, out, `"outcome_reason": "provider_error"`) + assert.Contains(t, out, `"actual_currency": "usd"`) + _, human, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Contains(t, human, "--after event-next") + assert.Contains(t, human, "payment_failed") + assert.Contains(t, human, "insufficient_funds") + assert.NotContains(t, human, "SECRET") +} + +func TestVaultDeleteAndEmptyItemLists(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + for _, path := range []string{"vaults delete checkout --yes", "vaults items delete checkout order-1 --yes"} { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + }) + _, human, err := executeVaultCommand(t, client, strings.Fields(path)...) + require.NoError(t, err) + assert.Contains(t, human, "Deleted") + } + for _, path := range []string{"vaults items list checkout", "vaults items events checkout order-1"} { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[]`) + }) + out, _, err := executeVaultCommand(t, client, append(strings.Fields(path), "-o", "json")...) + require.NoError(t, err) + assert.JSONEq(t, `[]`, out) + } +} + +func TestVaultOpenActionOnlyWhenRequested(t *testing.T) { + capturePtermOutput(t) + for _, actionURL := range []string{"", "https://provider.example/approval", "javascript:alert(1)", "https://user:secret@provider.example"} { + var item kernel.VaultItemUnion + body := strings.TrimSuffix(connectedWalletFixture, "}") + fmt.Sprintf(`,"action":{"name":"link_oauth","url":%q}}`, actionURL) + require.NoError(t, json.Unmarshal([]byte(body), &item)) + opened := "" + c := VaultsCmd{openURL: func(url string) error { opened = url; return nil }, prompter: interactive.NewPrompterWithTerminal(false)} + require.NoError(t, c.showItem(&item, "", false)) + assert.Empty(t, opened) + err := c.showItem(&item, "", true) + if strings.HasPrefix(actionURL, "https://provider.example") { + require.NoError(t, err) + assert.Equal(t, actionURL, opened) + } else if actionURL == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.Empty(t, opened) + } + } +} diff --git a/go.mod b/go.mod index 45ca6fa1..2582dc5f 100644 --- a/go.mod +++ b/go.mod @@ -60,3 +60,5 @@ require ( golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/kernel/kernel-go-sdk => github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260904020633-50f33b1b5cf6 diff --git a/go.sum b/go.sum index 8c92c4ec..443e272d 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.95.0 h1:VoEneqrqqT5i3cO1L6faoHo4PqrndG0pAEs6Abivd5A= -github.com/kernel/kernel-go-sdk v0.95.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260904020633-50f33b1b5cf6 h1:HJ7uNHl0niVt6StzclpoVb6L43ZbCZbKebItLBfYaa0= +github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260904020633-50f33b1b5cf6/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 1dad2c7437f456723df4fe8e35d5b57ec3fee581 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:14:59 +0000 Subject: [PATCH 02/12] Clarify vault JSON help and make cancellation test deterministic --- cmd/vaults_commands.go | 28 ++++++++++++++++------------ cmd/vaults_output_test.go | 5 +++-- cmd/vaults_test.go | 3 ++- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 79b47cdd..b685c40f 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -22,6 +22,11 @@ func getVaultsHandler(cmd *cobra.Command) VaultsCmd { return VaultsCmd{vaults: &client.Vaults, prompter: interactive.NewPrompter(), openURL: browser.OpenURL} } +func addVaultJSONOutputFlag(cmd *cobra.Command) { + addJSONOutputFlag(cmd) + cmd.Flags().Lookup("output").Usage = "Output format: json for display-safe API fields" +} + func vaultOutput(cmd *cobra.Command) string { output, _ := cmd.Flags().GetString("output") return output @@ -78,7 +83,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d }} create.Flags().String("name", "", "Immutable vault name (required)") _ = create.MarkFlagRequired("name") - addJSONOutputFlag(create) + addVaultJSONOutputFlag(create) list := &cobra.Command{Use: "list", Short: "List vaults in the selected project", Args: cobra.NoArgs, PreRunE: vaultPreRun, RunE: func(cmd *cobra.Command, args []string) error { @@ -89,13 +94,13 @@ JSON output preserves returned public fields but omits unknown/opaque provider d }} list.Flags().Int64("limit", 20, "Maximum vaults to return (1-100)") list.Flags().Int64("offset", 0, "Number of vaults to skip") - addJSONOutputFlag(list) + addVaultJSONOutputFlag(list) get := &cobra.Command{Use: "get ", Short: "Get a vault by ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, RunE: func(cmd *cobra.Command, args []string) error { return getVaultsHandler(cmd).Get(cmd.Context(), args[0], vaultOutput(cmd)) }} - addJSONOutputFlag(get) + addVaultJSONOutputFlag(get) cmd.AddCommand(create, list, get, newVaultDeleteCommand(false)) items := &cobra.Command{Use: "items", Short: "Inspect vault item state, actions, aliases, and outcomes"} @@ -103,7 +108,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d RunE: func(cmd *cobra.Command, args []string) error { return getVaultsHandler(cmd).ListItems(cmd.Context(), args[0], vaultOutput(cmd)) }} - addJSONOutputFlag(itemList) + addVaultJSONOutputFlag(itemList) itemGet := &cobra.Command{Use: "get ", Short: "Get item state and any required action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, Long: "Get item state, available operations, provider actions, and returned checkout aliases.\n--wait is a single bounded server-side observation, not a retry or a guarantee of readiness.\nAn item still pending after the wait is returned as-is; ready does not mean paid.", RunE: func(cmd *cobra.Command, args []string) error { @@ -115,7 +120,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d itemGet.Flags().Int64("wait", 0, "Hold while pending for up to this many seconds (0-60); observe only") itemGet.Flags().StringSlice("expand", nil, "Advertised live data to fetch: payment_methods") itemGet.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") - addJSONOutputFlag(itemGet) + addVaultJSONOutputFlag(itemGet) itemEvents := &cobra.Command{Use: "events ", Short: "Read immutable item events without retrying payments", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, RunE: func(cmd *cobra.Command, args []string) error { after, _ := cmd.Flags().GetString("after") @@ -124,7 +129,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d }} itemEvents.Flags().String("after", "", "Return events after this event ID (use the last ID from the previous response)") itemEvents.Flags().Int64("wait", 0, "Long-poll once for new events (0-60 seconds)") - addJSONOutputFlag(itemEvents) + addVaultJSONOutputFlag(itemEvents) items.AddCommand(itemList, itemGet, itemEvents, newVaultDeleteCommand(true)) wallets := &cobra.Command{Use: "wallets", Short: "Connect provider wallets and inspect funding methods"} @@ -140,13 +145,13 @@ JSON output preserves returned public fields but omits unknown/opaque provider d _ = walletCreate.MarkFlagRequired("provider") walletCreate.Flags().String("user-id", "", "Already enrolled AgentCard user ID in this organization (optional)") walletCreate.Flags().Bool("open", false, "Open the returned HTTPS connection/enrollment URL") - addJSONOutputFlag(walletCreate) + addVaultJSONOutputFlag(walletCreate) methods := &cobra.Command{Use: "payment-methods ", Short: "Fetch advertised live wallet payment methods", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, Long: "Fetch payment_methods through the item's GET expansion. The wallet must advertise this expansion.\nDisplays selectable IDs and advisory capabilities; never automatically chooses a funding method.\nJSON returns the item with expanded.payment_methods, like items get --expand payment_methods.", RunE: func(cmd *cobra.Command, args []string) error { return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], 0, []string{"payment_methods"}, vaultOutput(cmd), false) }} - addJSONOutputFlag(methods) + addVaultJSONOutputFlag(methods) wallets.AddCommand(walletCreate, methods) cards := &cobra.Command{Use: "cards", Short: "Configure card requests and explicitly authorize requested Link cards"} @@ -157,7 +162,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d return getVaultsHandler(cmd).Authorize(cmd.Context(), args[0], args[1], vaultOutput(cmd), open) }} authorize.Flags().Bool("open", false, "Open the returned HTTPS approval URL") - addJSONOutputFlag(authorize) + addVaultJSONOutputFlag(authorize) cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true), authorize) cmd.AddCommand(items, wallets, cards) return cmd @@ -196,8 +201,7 @@ Permitted domains come from the provider and cannot be configured by this API. Neither create nor update authorizes a Link card. The API enforces update eligibility, provider/wallet invariants, and immutable item keys. Update replaces the entire spec; optional purchase details set outside the CLI are removed when omitted. -Never reconfigure to retry a failed, -timed-out, rejected, or indeterminate payment.`, +Never reconfigure to retry a failed, timed-out, rejected, or indeterminate payment.`, RunE: func(cmd *cobra.Command, args []string) error { spec, err := vaultCardSpecFromFlags(cmd) if err != nil { @@ -220,7 +224,7 @@ timed-out, rejected, or indeterminate payment.`, cmd.Flags().Bool("live", false, "Request a live Link payment credential (explicit opt-in)") cmd.MarkFlagsMutuallyExclusive("test", "live") cmd.Flags().String("card-id", "", "AgentCard vaulted card ID; omit to let the cardholder select during approval") - addJSONOutputFlag(cmd) + addVaultJSONOutputFlag(cmd) return cmd } diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index 60a4940d..e4f28eed 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -144,13 +144,14 @@ func TestVaultCancellationDoesNotLeakTransportDetails(t *testing.T) { require.Error(t, err) assert.NotContains(t, util.CleanedUpSdkError{Err: err}.Error(), "SECRET") } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() var calls atomic.Int32 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls.Add(1) + cancel() <-r.Context().Done() }) - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() c := VaultsCmd{vaults: &client.Vaults} err := c.GetItem(ctx, "checkout", "order-1", 60, nil, "json", false) require.Error(t, err) diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index dcb60f56..d9dca7a2 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -61,7 +61,8 @@ func TestVaultCommandConstruction(t *testing.T) { if cmd.Name() == "delete" { assert.NotNil(t, cmd.Flags().Lookup("yes")) } else { - assert.NotNil(t, cmd.Flags().Lookup("output")) + require.NotNil(t, cmd.Flags().Lookup("output")) + assert.Contains(t, cmd.Flags().Lookup("output").Usage, "display-safe") } }) } From 06617d60a9231b07c84710fcd708d1274ec30bc8 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:05:42 +0000 Subject: [PATCH 03/12] Allow API-default project scope for vault commands --- README.md | 13 +++++----- cmd/browser_vaults_test.go | 8 +++---- cmd/browsers.go | 6 +---- cmd/vaults.go | 14 ++++------- cmd/vaults_commands.go | 11 ++++----- cmd/vaults_test.go | 49 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 68 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 60fc39a9..4c063f70 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ Commands with JSON output support: - `--proxy-mode direct|default` - Egress mode instead of a selected proxy: `direct` for no proxy regardless of stealth, `default` for the stealth-derived default (Kernel's stealth proxy with `--stealth`, direct egress otherwise). Omit all proxy flags to get the default. - `--name ` - Optional unique name for the session (used to find it later by name; can be changed with `browsers update --name`) - `--tag ` - Set a tag on the session, repeatable; up to 50 pairs - - `--vault ` - Attach a project-owned vault at creation (repeatable, max 20). Requires `--project` or `KERNEL_PROJECT`. Cannot be combined with pool flags, even with `--yes`; vault bindings cannot be added to existing sessions. + - `--vault ` - Attach a project-owned vault at creation (repeatable, max 20). Uses the API's effective project unless `--project` or `KERNEL_PROJECT` selects one. Cannot be combined with pool flags, even with `--yes`; vault bindings cannot be added to existing sessions. - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags). `--name`/`--tag` still apply to the acquired session. - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - `--telemetry=all` - Enable telemetry for all categories @@ -271,9 +271,11 @@ Commands with JSON output support: ### Vaults Vault commands **prepare and observe payment credentials; they do not submit merchant payments**. -Vault names, item keys, and project ownership are immutable. Select the project explicitly with -`--project ` or `KERNEL_PROJECT`; the API assigns ownership from that scope, not a -`project_id` body field. Project-scoped credentials cannot switch projects. +Vault names, item keys, and project ownership are immutable. Optionally select a project with +`--project ` or `KERNEL_PROJECT`; otherwise, the API resolves the project from your +credentials and its defaults (the default project for org-wide credentials, not all projects). +Ownership is assigned from that scope, not a `project_id` body field. Project-scoped credentials +cannot switch projects. #### Command reference @@ -318,10 +320,9 @@ and `--merchant ` are required. Currency is normalized to lowercase. #### Link checkout preparation -1. Select a project and create/select a vault. Connect the wallet in the provider's UI: +1. Create/select a vault in the effective project. Connect the wallet in the provider's UI: ```bash - export KERNEL_PROJECT=my-project kernel vaults create --name checkout kernel vaults wallets create checkout wallet-1 --provider link --open kernel vaults items get checkout wallet-1 --wait 60 diff --git a/cmd/browser_vaults_test.go b/cmd/browser_vaults_test.go index 93f1cc79..2d53d545 100644 --- a/cmd/browser_vaults_test.go +++ b/cmd/browser_vaults_test.go @@ -51,14 +51,13 @@ func browserVaultTestCommand(client kernel.Client) *cobra.Command { return cmd } -func TestBrowserVaultPoolAndProjectValidation(t *testing.T) { +func TestBrowserVaultPoolAndReferenceValidation(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid attachment reached API") }) for _, flags := range [][]string{ {"--vault", "checkout", "--pool-id", "pool-1", "--yes"}, {"--vault", "checkout", "--pool-name", "pool", "--yes"}, - {"--vault", "checkout"}, - {"--vault=", "--project", "project-test"}, + {"--vault="}, } { cmd := browserVaultTestCommand(client) require.NoError(t, cmd.ParseFlags(flags)) @@ -68,11 +67,12 @@ func TestBrowserVaultPoolAndProjectValidation(t *testing.T) { } func TestBrowserCreateVaultRequestAndReturnedAttachments(t *testing.T) { - t.Setenv("KERNEL_PROJECT", "project-test") + t.Setenv("KERNEL_PROJECT", "") const body = `{"session_id":"browser-1","cdp_ws_url":"ws://example.test/cdp","vaults":[{"id":"vault-1","name":"checkout"}]}` client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) assert.Equal(t, "/browsers", r.URL.Path) + assert.Empty(t, r.Header.Get("X-Kernel-Project")) payload, _ := io.ReadAll(r.Body) assert.JSONEq(t, `{"vaults":[{"name":"checkout"}]}`, string(payload)) w.Header().Set("Content-Type", "application/json") diff --git a/cmd/browsers.go b/cmd/browsers.go index 4ae863d5..8096b879 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -2972,7 +2972,7 @@ func init() { browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)") browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60") browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") - browsersCreateCmd.Flags().StringArray("vault", nil, "Project-owned vault ID or name to attach at creation (repeatable, max 20; requires --project or KERNEL_PROJECT; incompatible with pools)") + browsersCreateCmd.Flags().StringArray("vault", nil, "Project-owned vault ID or name to attach at creation (repeatable, max 20; incompatible with pools)") browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") browsersCreateCmd.Flags().String("telemetry", "", "Configure telemetry (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") @@ -3123,10 +3123,6 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if poolID != "" || poolName != "" { return fmt.Errorf("--vault cannot be used with --pool-id or --pool-name; create a new browser to attach vaults") } - project, _ := cmd.Flags().GetString("project") - if err := requireVaultProject(resolveProjectSelection(project)); err != nil { - return err - } } if poolID != "" && poolName != "" { diff --git a/cmd/vaults.go b/cmd/vaults.go index cf251677..86b4c8d0 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -7,7 +7,6 @@ import ( "net/http" "net/url" "regexp" - "strings" "time" "github.com/kernel/cli/pkg/interactive" @@ -99,7 +98,11 @@ func (c VaultsCmd) List(ctx context.Context, limit, offset int64, project, outpu PrintTableNoPad(rows, true) } if pagination.HasMore { - pterm.Printf("Next: kernel --project %q vaults list --limit %d --offset %d\n", project, limit, pagination.NextOffset) + projectFlag := "" + if project != "" { + projectFlag = fmt.Sprintf(" --project %q", project) + } + pterm.Printf("Next: kernel%s vaults list --limit %d --offset %d\n", projectFlag, limit, pagination.NextOffset) } return nil } @@ -303,10 +306,3 @@ func (c VaultsCmd) showItem(item *kernel.VaultItemUnion, output string, open boo } return nil } - -func requireVaultProject(project string) error { - if strings.TrimSpace(project) == "" { - return fmt.Errorf("select the vault's project with --project or KERNEL_PROJECT; vault project ownership is immutable") - } - return nil -} diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index b685c40f..a974ac1a 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -33,10 +33,6 @@ func vaultOutput(cmd *cobra.Command) string { } func vaultPreRun(cmd *cobra.Command, args []string) error { - project, _ := cmd.Flags().GetString("project") - if err := requireVaultProject(resolveProjectSelection(project)); err != nil { - return err - } if err := validateJSONOutput(vaultOutput(cmd)); err != nil { return err } @@ -57,8 +53,9 @@ func newVaultsCommand() *cobra.Command { Use: "vaults", Aliases: []string{"vault"}, Short: "Prepare and observe project-owned payment credentials", Long: `Prepare and observe payment credentials; vault commands do not submit merchant payments. -Select the project with --project or KERNEL_PROJECT. The API assigns -immutable project ownership from that scope. Vault names and item keys are immutable. +Optionally select a project with --project or KERNEL_PROJECT. +Otherwise, the API resolves the project from your credentials and its defaults. +Vault names, item keys, and project ownership are immutable. 1. Create/select a vault, then create a provider wallet and follow its returned action. 2. For Link, list wallet payment methods and select an ID explicitly. @@ -85,7 +82,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d _ = create.MarkFlagRequired("name") addVaultJSONOutputFlag(create) - list := &cobra.Command{Use: "list", Short: "List vaults in the selected project", Args: cobra.NoArgs, PreRunE: vaultPreRun, + list := &cobra.Command{Use: "list", Short: "List vaults in the effective project", Args: cobra.NoArgs, PreRunE: vaultPreRun, RunE: func(cmd *cobra.Command, args []string) error { limit, _ := cmd.Flags().GetInt64("limit") offset, _ := cmd.Flags().GetInt64("offset") diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index d9dca7a2..63c9ee6d 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -114,9 +114,49 @@ func TestVaultRequiredAndInvalidFlags(t *testing.T) { assert.Contains(t, err.Error(), tt.want) }) } +} + +func TestVaultCommandsWithoutProject(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") - _, _, err := executeVaultCommand(t, client, "vaults", "list") - require.ErrorContains(t, err, "--project") + tests := []struct { + args []string + response string + calls int + }{ + {[]string{"create", "--name", "checkout"}, vaultFixture, 1}, + {[]string{"list"}, "[" + vaultFixture + "]", 1}, + {[]string{"get", "checkout"}, vaultFixture, 1}, + {[]string{"delete", "checkout", "--yes"}, "", 1}, + {[]string{"items", "list", "checkout"}, "[" + requestedCardFixture + "]", 1}, + {[]string{"items", "get", "checkout", "order-1"}, requestedCardFixture, 1}, + {[]string{"items", "delete", "checkout", "order-1", "--yes"}, "", 1}, + {[]string{"items", "events", "checkout", "order-1"}, "[]", 1}, + {[]string{"wallets", "create", "checkout", "wallet-1", "--provider", "link"}, connectedWalletFixture, 1}, + {[]string{"wallets", "payment-methods", "checkout", "wallet-1"}, connectedWalletFixture, 1}, + {append([]string{"cards", "create", "checkout", "order-1"}, linkCardArgs()...), requestedCardFixture, 1}, + {append([]string{"cards", "update", "checkout", "order-1"}, linkCardArgs()...), requestedCardFixture, 1}, + {[]string{"cards", "authorize", "checkout", "order-1"}, requestedCardFixture, 2}, + } + for _, tt := range tests { + t.Run(strings.Join(tt.args[:min(2, len(tt.args))], " "), func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Empty(t, r.Header.Get("X-Kernel-Project")) + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Has-More", "false") + w.Header().Set("X-Next-Offset", "0") + _, _ = io.WriteString(w, tt.response) + }) + _, _, err := executeVaultCommand(t, client, append([]string{"vaults"}, tt.args...)...) + require.NoError(t, err) + assert.Equal(t, tt.calls, calls) + }) + } } func linkCardArgs() []string { @@ -218,6 +258,11 @@ func TestVaultListPaginationAndEmptyJSON(t *testing.T) { _, human, err := executeVaultCommand(t, client, "vaults", "list", "--limit", "1", "--offset", "20") require.NoError(t, err) assert.Contains(t, human, `kernel --project "project-test" vaults list --limit 1 --offset 21`) + t.Setenv("KERNEL_PROJECT", "") + _, human, err = executeVaultCommand(t, client, "vaults", "list", "--limit", "1", "--offset", "20") + require.NoError(t, err) + assert.Contains(t, human, "kernel vaults list --limit 1 --offset 21") + assert.NotContains(t, human, "--project") body, hasMore, next = "[]", "false", "0" out, _, err = executeVaultCommand(t, client, "vaults", "list", "--limit", "1", "--offset", "20", "-o", "json") require.NoError(t, err) From ce1af483a98244c9bf937b6ca929b240318b9eda Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:17:01 +0000 Subject: [PATCH 04/12] Surface vault API errors through the standard CLI formatter --- README.md | 3 ++ cmd/vaults.go | 42 +++++++++------------------- cmd/vaults_output_test.go | 11 ++------ cmd/vaults_test.go | 59 ++++++++++++++++++++++++++++++++++----- 4 files changed, 70 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 4c063f70..31916d34 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,9 @@ hyphens (1–255 characters; not `.` or `..`). All commands except delete suppor JSON preserves field presence and API-returned aliases, while omitting unknown fields, opaque metadata, and unrecognized event data. Human output labels aliases as non-secret checkout values and distinguishes card readiness from checkout authorization/payment outcomes. +API failures use the CLI's standard error formatter, preserving the API's code and message. +Delete failures, including invalid-project and missing-resource responses, return an error rather +than claiming success. **Card flags:** `--provider`, `--wallet `, `--amount `, `--currency `, and `--merchant ` are required. Currency is normalized to lowercase. diff --git a/cmd/vaults.go b/cmd/vaults.go index 86b4c8d0..1949a5be 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "errors" "fmt" "net/http" "net/url" @@ -31,28 +30,13 @@ func validateVaultName(value, label string) error { return nil } -// Do not unwrap provider errors: the root error renderer otherwise prints their bodies. -func vaultRequestError(err error) error { - if err == nil { - return nil - } - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return fmt.Errorf("vault request interrupted; inspect item state and events before taking further action; do not retry payments") - } - var apiErr *kernel.Error - if errors.As(err, &apiErr) { - return fmt.Errorf("vault request failed (HTTP %d); response body withheld; inspect item state and events; do not retry payments", apiErr.StatusCode) - } - return fmt.Errorf("vault request failed; details withheld; inspect item state and events; do not retry payments") -} - func (c VaultsCmd) Create(ctx context.Context, name, output string) error { if err := validateVaultName(name, "--name"); err != nil { return err } v, err := c.vaults.Upsert(ctx, kernel.VaultUpsertParams{Name: name}, option.WithMaxRetries(0)) if err != nil { - return vaultRequestError(err) + return util.CleanedUpSdkError{Err: err} } return printVault(v, output) } @@ -60,7 +44,7 @@ func (c VaultsCmd) Create(ctx context.Context, name, output string) error { func (c VaultsCmd) Get(ctx context.Context, vault, output string) error { v, err := c.vaults.Get(ctx, vault, option.WithMaxRetries(0)) if err != nil { - return vaultRequestError(err) + return util.CleanedUpSdkError{Err: err} } return printVault(v, output) } @@ -72,7 +56,7 @@ func (c VaultsCmd) List(ctx context.Context, limit, offset int64, project, outpu var response *http.Response page, err := c.vaults.List(ctx, kernel.VaultListParams{Limit: kernel.Opt(limit), Offset: kernel.Opt(offset)}, option.WithMaxRetries(0), option.WithResponseInto(&response)) if err != nil { - return vaultRequestError(err) + return util.CleanedUpSdkError{Err: err} } pagination, err := parseProjectListPagination(response) if err != nil { @@ -128,17 +112,17 @@ func (c VaultsCmd) Delete(ctx context.Context, vault, key string, yes bool) erro } else { err = c.vaults.Items.Delete(ctx, key, kernel.VaultItemDeleteParams{IDOrName: vault}, option.WithMaxRetries(0)) } - if err != nil && !util.IsNotFound(err) { - return vaultRequestError(err) + if err != nil { + return util.CleanedUpSdkError{Err: err} } - pterm.Success.Println("Deleted (or already absent): " + label) + pterm.Success.Println("Deleted: " + label) return nil } func (c VaultsCmd) ListItems(ctx context.Context, vault, output string) error { items, err := c.vaults.Items.List(ctx, vault, option.WithMaxRetries(0)) if err != nil { - return vaultRequestError(err) + return util.CleanedUpSdkError{Err: err} } if output == "json" { data, err := vaultSafeJSONSlice(*items, vaultItemFields) @@ -179,7 +163,7 @@ func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, e defer cancel() item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault, Wait: kernel.Opt(wait), Expand: expand}, option.WithMaxRetries(0)) if err != nil { - return vaultRequestError(err) + return util.CleanedUpSdkError{Err: err} } return c.showItem(item, output, open) } @@ -207,7 +191,7 @@ func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key, provider, userI } item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfWallet: &kernel.VaultItemUpsertParamsBodyWallet{Spec: spec}}, option.WithMaxRetries(0)) if err != nil { - return vaultRequestError(err) + return util.CleanedUpSdkError{Err: err} } return c.showItem(item, output, open) } @@ -221,7 +205,7 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) } if err != nil { - return vaultRequestError(err) + return util.CleanedUpSdkError{Err: err} } return c.showItem(item, output, false) } @@ -229,7 +213,7 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. func (c VaultsCmd) Authorize(ctx context.Context, vault, key, output string, open bool) error { item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { - return vaultRequestError(err) + return util.CleanedUpSdkError{Err: err} } if item.Type != "card" || item.Spec.Provider != "link" || item.State.Status != "requested" { return fmt.Errorf("authorization requires a requested Link card; inspect item state and events; do not retry payments or resume indeterminate authorizations") @@ -248,7 +232,7 @@ func (c VaultsCmd) Authorize(ctx context.Context, vault, key, output string, ope } item, err = c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, Type: kernel.VaultItemPerformOperationParamsTypeAuthorize}, option.WithMaxRetries(0)) if err != nil { - return vaultRequestError(err) + return util.CleanedUpSdkError{Err: err} } return c.showItem(item, output, open) } @@ -265,7 +249,7 @@ func (c VaultsCmd) Events(ctx context.Context, vault, key, after string, wait in defer cancel() events, err := c.vaults.Items.Events(ctx, key, params, option.WithMaxRetries(0)) if err != nil { - return vaultRequestError(err) + return util.CleanedUpSdkError{Err: err} } data, err := vaultSafeJSONSlice(*events, vaultEventFields) if err != nil { diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index e4f28eed..25142a1c 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -3,7 +3,6 @@ package cmd import ( "context" "encoding/json" - "errors" "fmt" "net/http" "strings" @@ -11,7 +10,6 @@ import ( "testing" "time" - "github.com/kernel/cli/pkg/util" kernel "github.com/kernel/kernel-go-sdk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -138,12 +136,7 @@ func TestVaultURLsWithSecretsAreWithheld(t *testing.T) { } } -func TestVaultCancellationDoesNotLeakTransportDetails(t *testing.T) { - for _, cause := range []error{context.Canceled, context.DeadlineExceeded, errors.New("SECRET_TRANSPORT")} { - err := vaultRequestError(cause) - require.Error(t, err) - assert.NotContains(t, util.CleanedUpSdkError{Err: err}.Error(), "SECRET") - } +func TestVaultGetCancellation(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var calls atomic.Int32 @@ -155,6 +148,6 @@ func TestVaultCancellationDoesNotLeakTransportDetails(t *testing.T) { c := VaultsCmd{vaults: &client.Vaults} err := c.GetItem(ctx, "checkout", "order-1", 60, nil, "json", false) require.Error(t, err) - assert.Contains(t, err.Error(), "interrupted") + assert.ErrorIs(t, err, context.Canceled) assert.Equal(t, int32(1), calls.Load()) } diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 63c9ee6d..0b604d60 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -382,7 +382,7 @@ func TestVaultAuthorizeRequiresAdvertisedRequestedLinkCard(t *testing.T) { } } -func TestVaultNoSDKRetriesOrSensitiveErrors(t *testing.T) { +func TestVaultNoSDKRetriesAndAPIErrorMessages(t *testing.T) { t.Setenv("KERNEL_PROJECT", "project-test") for _, status := range []int{409, 429, 500} { t.Run(fmt.Sprint(status), func(t *testing.T) { @@ -395,21 +395,66 @@ func TestVaultNoSDKRetriesOrSensitiveErrors(t *testing.T) { return } w.WriteHeader(status) - _, _ = io.WriteString(w, `{"message":"provider-secret; retry shortly","code":"sensitive-provider-code"}`) + _, _ = io.WriteString(w, `{"message":"Authorization service unavailable","code":"authorization_failed"}`) }) out, _, err := executeVaultCommand(t, client, "vaults", "cards", "authorize", "checkout", "order-1", "-o", "json") require.Error(t, err) assert.Equal(t, 2, calls) assert.Empty(t, out) - assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", status)) - rendered := util.CleanedUpSdkError{Err: err}.Error() - assert.NotContains(t, rendered, "provider-secret") - assert.NotContains(t, rendered, "sensitive-provider-code") - assert.NotContains(t, rendered, "retry shortly") + var apiErr *kernel.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, status, apiErr.StatusCode) + assert.Equal(t, "authorization_failed: Authorization service unavailable", util.CleanedUpSdkError{Err: err}.Error()) }) } } +func TestVaultInvalidProjectErrors(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + commands := [][]string{ + {"list"}, {"get", "checkout"}, {"create", "--name", "checkout"}, {"delete", "checkout", "--yes"}, + {"items", "list", "checkout"}, {"items", "get", "checkout", "order-1"}, + {"items", "events", "checkout", "order-1"}, {"items", "delete", "checkout", "order-1", "--yes"}, + {"wallets", "create", "checkout", "wallet-1", "--provider", "link"}, + {"wallets", "payment-methods", "checkout", "wallet-1"}, + append([]string{"cards", "create", "checkout", "order-1"}, linkCardArgs()...), + append([]string{"cards", "update", "checkout", "order-1"}, linkCardArgs()...), + {"cards", "authorize", "checkout", "order-1"}, + } + for _, project := range []string{"doesntexist", "abcdefghijklmnopqrstuvwx"} { + for _, args := range commands { + t.Run(project+"/"+strings.Join(args[:min(2, len(args))], " "), func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, project, r.Header.Get("X-Kernel-Project")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"code":"project_not_found","message":"Project not found or inactive"}`) + }) + out, human, err := executeVaultCommand(t, client, append([]string{"--project", project, "vaults"}, args...)...) + require.Error(t, err) + assert.Equal(t, "project_not_found: Project not found or inactive", util.CleanedUpSdkError{Err: err}.Error()) + assert.Equal(t, 1, calls) + assert.Empty(t, out) + assert.NotContains(t, human, "Deleted") + }) + } + } +} + +func TestVaultPlainTextAPIError(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Credential is scoped to a different project", http.StatusForbidden) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "list", "--project", "other-project", "-o", "json") + require.Error(t, err) + assert.Empty(t, out) + assert.Contains(t, util.CleanedUpSdkError{Err: err}.Error(), "Credential is scoped to a different project") + assert.NotContains(t, err.Error(), "withheld") +} + func TestVaultGetWaitExpansionAndEvents(t *testing.T) { t.Setenv("KERNEL_PROJECT", "project-test") client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { From 843e4ea43650bdd38137f48e8d33641a77f81856 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:48:38 +0000 Subject: [PATCH 05/12] Treat vault delete 404s as deleted or not found --- README.md | 5 +++-- cmd/vaults.go | 4 ++-- cmd/vaults_test.go | 48 ++++++++++++++++++++++++++++++++++++---------- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 31916d34..da65936c 100644 --- a/README.md +++ b/README.md @@ -301,8 +301,9 @@ JSON preserves field presence and API-returned aliases, while omitting unknown f opaque metadata, and unrecognized event data. Human output labels aliases as non-secret checkout values and distinguishes card readiness from checkout authorization/payment outcomes. API failures use the CLI's standard error formatter, preserving the API's code and message. -Delete failures, including invalid-project and missing-resource responses, return an error rather -than claiming success. +`vaults delete` and `vaults items delete` treat HTTP 404 as success and print +`Deleted or not found`, whether the missing object is the project, vault, or item. +Other API errors still return a nonzero exit status. **Card flags:** `--provider`, `--wallet `, `--amount `, `--currency `, and `--merchant ` are required. Currency is normalized to lowercase. diff --git a/cmd/vaults.go b/cmd/vaults.go index 1949a5be..af91f777 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -112,10 +112,10 @@ func (c VaultsCmd) Delete(ctx context.Context, vault, key string, yes bool) erro } else { err = c.vaults.Items.Delete(ctx, key, kernel.VaultItemDeleteParams{IDOrName: vault}, option.WithMaxRetries(0)) } - if err != nil { + if err != nil && !util.IsNotFound(err) { return util.CleanedUpSdkError{Err: err} } - pterm.Success.Println("Deleted: " + label) + pterm.Success.Println("Deleted or not found: " + label) return nil } diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 0b604d60..97190688 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -412,9 +412,9 @@ func TestVaultNoSDKRetriesAndAPIErrorMessages(t *testing.T) { func TestVaultInvalidProjectErrors(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") commands := [][]string{ - {"list"}, {"get", "checkout"}, {"create", "--name", "checkout"}, {"delete", "checkout", "--yes"}, + {"list"}, {"get", "checkout"}, {"create", "--name", "checkout"}, {"items", "list", "checkout"}, {"items", "get", "checkout", "order-1"}, - {"items", "events", "checkout", "order-1"}, {"items", "delete", "checkout", "order-1", "--yes"}, + {"items", "events", "checkout", "order-1"}, {"wallets", "create", "checkout", "wallet-1", "--provider", "link"}, {"wallets", "payment-methods", "checkout", "wallet-1"}, append([]string{"cards", "create", "checkout", "order-1"}, linkCardArgs()...), @@ -492,17 +492,45 @@ func TestVaultGetWaitExpansionAndEvents(t *testing.T) { assert.NotContains(t, human, "SECRET") } -func TestVaultDeleteAndEmptyItemLists(t *testing.T) { +func TestVaultDeleteResponses(t *testing.T) { t.Setenv("KERNEL_PROJECT", "project-test") for _, path := range []string{"vaults delete checkout --yes", "vaults items delete checkout order-1 --yes"} { - client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodDelete, r.Method) - w.WriteHeader(http.StatusNoContent) - }) - _, human, err := executeVaultCommand(t, client, strings.Fields(path)...) - require.NoError(t, err) - assert.Contains(t, human, "Deleted") + for _, response := range []struct { + status int + code string + }{ + {204, ""}, {404, "not_found"}, {404, "project_not_found"}, {403, "forbidden"}, {409, "conflict"}, {500, "internal_error"}, + } { + t.Run(fmt.Sprintf("%s/%d/%s", path, response.status, response.code), func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodDelete, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.status) + if response.code != "" { + _, _ = fmt.Fprintf(w, `{"code":%q,"message":"API error details"}`, response.code) + } + }) + out, human, err := executeVaultCommand(t, client, strings.Fields(path)...) + assert.Equal(t, 1, calls) + assert.Empty(t, out) + if response.status == http.StatusNoContent || response.status == http.StatusNotFound { + require.NoError(t, err) + assert.Contains(t, human, "Deleted or not found: vault") + assert.Contains(t, human, "checkout") + } else { + require.Error(t, err) + assert.Equal(t, response.code+": API error details", util.CleanedUpSdkError{Err: err}.Error()) + assert.Empty(t, human) + } + }) + } } +} + +func TestVaultEmptyItemLists(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") for _, path := range []string{"vaults items list checkout", "vaults items events checkout order-1"} { client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") From e9f246a40d0ffb12562ed8b7e5434e0f6db73d1e Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:15:38 +0000 Subject: [PATCH 06/12] Print vault action URLs outside truncating tables --- README.md | 1 + cmd/vaults_output.go | 12 ++++++------ cmd/vaults_output_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index da65936c..d64aa366 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,7 @@ hyphens (1–255 characters; not `.` or `..`). All commands except delete suppor JSON preserves field presence and API-returned aliases, while omitting unknown fields, opaque metadata, and unrecognized event data. Human output labels aliases as non-secret checkout values and distinguishes card readiness from checkout authorization/payment outcomes. +Action and approval URLs print in full on separate lines, without table truncation. API failures use the CLI's standard error formatter, preserving the API's code and message. `vaults delete` and `vaults items delete` treat HTTP 404 as success and print `Deleted or not found`, whether the missing object is the project, vault, or item. diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 148710ad..1c3df13d 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -214,9 +214,6 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { } if item.Action.Name != "" { rows = append(rows, []string{"Required action", item.Action.Name}) - if item.Action.URL != "" { - rows = append(rows, []string{"Action URL", item.Action.URL}) - } } if !item.ExpiresAt.IsZero() { rows = append(rows, []string{"Expires At", util.FormatLocal(item.ExpiresAt)}) @@ -231,9 +228,6 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if a.Reason != "" { rows = append(rows, []string{"Authorization reason", a.Reason}) } - if a.ApprovalURL != "" { - rows = append(rows, []string{"Approval URL", a.ApprovalURL}) - } if a.JSON.ChargedKind.Valid() { rows = append(rows, []string{"Charged kind", string(a.ChargedKind)}, []string{"Charged (minor units)", fmt.Sprintf("%d %s", a.ChargedAmountCents, a.ChargedCurrency)}) } @@ -242,6 +236,12 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { } } PrintTableNoPad(rows, true) + if item.Action.Name != "" && item.Action.URL != "" { + pterm.Printf("Action URL:\n%s\n", item.Action.URL) + } + if item.State.JSON.Authorization.Valid() && item.State.Authorization.ApprovalURL != "" { + pterm.Printf("Approval URL:\n%s\n", item.State.Authorization.ApprovalURL) + } if item.Type == "card" { card := item.AsCard() for _, op := range card.AvailableOperations { diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index 25142a1c..f3c78bdf 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -121,6 +121,34 @@ func TestVaultActionOutputNoInventedURLs(t *testing.T) { } } +func TestVaultLongURLsPrintedOutsideTable(t *testing.T) { + actionURL := "https://provider.example/auth?state=" + strings.Repeat("a", 512) + "&code_challenge=complete-challenge" + approvalURL := "https://provider.example/approve?request=" + strings.Repeat("b", 512) + for _, tt := range []struct { + name, body, label, url string + }{ + {"action", strings.TrimSuffix(connectedWalletFixture, "}") + fmt.Sprintf(`,"action":{"name":"link_oauth","url":%q}}`, actionURL), "Action URL", actionURL}, + {"approval", fmt.Sprintf(`{"key":"order-1","type":"card","spec":{"provider":"agentcard"},"state":{"provider":"agentcard","status":"ready","authorization":{"status":"pending","approval_url":%q}}}`, approvalURL), "Approval URL", approvalURL}, + } { + t.Run(tt.name, func(t *testing.T) { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(tt.body), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + assert.Contains(t, buf.String(), tt.label+":\n"+tt.url+"\n") + assert.Equal(t, 1, strings.Count(buf.String(), tt.url)) + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + var decoded kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(out), &decoded)) + if tt.name == "action" { + assert.Equal(t, tt.url, decoded.Action.URL) + } else { + assert.Equal(t, tt.url, decoded.State.Authorization.ApprovalURL) + } + }) + } +} + func TestVaultURLsWithSecretsAreWithheld(t *testing.T) { for _, address := range []string{ "https://user:SECRET@provider.example/", "https://provider.example/?code=SECRET", From 002c0170f7d42ccc4f30ae285245d97a8e9ade5e Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:50:09 +0000 Subject: [PATCH 07/12] Accept raw provider specs for vault wallets and cards --- README.md | 57 +++++++------- cmd/vaults.go | 22 +----- cmd/vaults_commands.go | 158 +++++++++++++------------------------- cmd/vaults_help.go | 78 +++++++++++++++++++ cmd/vaults_output.go | 2 +- cmd/vaults_output_test.go | 2 +- cmd/vaults_spec_test.go | 99 ++++++++++++++++++++++++ cmd/vaults_test.go | 85 ++++++-------------- 8 files changed, 290 insertions(+), 213 deletions(-) create mode 100644 cmd/vaults_help.go create mode 100644 cmd/vaults_spec_test.go diff --git a/README.md b/README.md index d64aa366..6686cd97 100644 --- a/README.md +++ b/README.md @@ -285,10 +285,10 @@ cannot switch projects. | `kernel vaults list` | `--limit 1..100` (default 20), `--offset`; JSON includes `vaults` and optional `next_offset` | | `kernel vaults get ` | Get by ID or name | | `kernel vaults delete ` | Invalidate the vault and all its items; `--yes` skips confirmation | -| `kernel vaults wallets create --provider link\|agentcard` | Connect/enroll a wallet; `--open` opens a returned HTTPS action URL; AgentCard optionally accepts `--user-id` for an already enrolled user in this organization | +| `kernel vaults wallets create --provider link\|agentcard --spec ''` | Connect/enroll a wallet using its provider's spec; `--open` opens a returned HTTPS action URL | | `kernel vaults wallets payment-methods ` | Fetch advertised live payment methods; JSON is the item with `expanded.payment_methods` | -| `kernel vaults cards create ` | Create a card request with the typed flags below; never implicitly authorize Link | -| `kernel vaults cards update ` | Replace the full card spec using the same flags; the API enforces state/provider constraints | +| `kernel vaults cards create --provider link\|agentcard --spec ''` | Create a card request; never implicitly authorize Link | +| `kernel vaults cards update --provider link\|agentcard --spec ''` | Replace the full card spec; the API enforces state/provider constraints | | `kernel vaults cards authorize ` | After explicit user approval, GET the requested Link card and POST `authorize` only if advertised; optional `--open` | | `kernel vaults items list ` | List item keys, types, providers, status, and required actions | | `kernel vaults items get ` | Inspect state/actions/returned aliases; `--wait 0..60`, `--expand payment_methods`, `--open` | @@ -306,22 +306,22 @@ API failures use the CLI's standard error formatter, preserving the API's code a `Deleted or not found`, whether the missing object is the project, vault, or item. Other API errors still return a nonzero exit status. -**Card flags:** `--provider`, `--wallet `, `--amount `, `--currency `, -and `--merchant ` are required. Currency is normalized to lowercase. - -- **Link:** also requires `--payment-method-id`, `--merchant-url`, `--context` (at least 100 - characters describing the purchase), and exactly one of `--test` or `--live`. Amount is - 1–500000 minor units. Choose the payment-method ID from the wallet listing; capability - hints are advisory, and missing hints mean unknown rather than ineligible. -- **AgentCard:** optionally accepts `--card-id` from the wallet listing; otherwise the - cardholder selects a card at approval. Sandbox/live mode is set by the deployment; - there is no per-item test flag. AgentCard authorization happens at checkout, not through - `cards authorize`. A reusable card being `ready` does not mean the last payment succeeded. -- Permitted checkout domains are provider-assigned and displayed when returned. The API - does **not** accept a domain-setting flag. The merchant URL is not a domain allowlist. -- Advanced optional Link line items, totals, metadata, and expiry are not configurable in - this initial CLI surface. `cards update` replaces the entire spec, so omitted optional - details previously set through another client are removed. +**Provider specifications:** wallet creation and card creation/update require `--provider` +and `--spec ''`. Supply only the spec object, not a `{type, spec}` envelope. The command +sets the item type and injects `provider`; if JSON also contains `provider`, it must match. +Other values are forwarded unchanged, including optional fields, without defaults or normalization. +The API validates the provider-specific schema. Each command's `--help` includes its raw +TypeScript-style types, which must stay in sync with the [API spec](https://api.onkernel.com/spec.yaml). + +- **Link wallet:** supply `authorization: {method: "oauth", client: {type: "kernel_managed"}}`. +- **AgentCard wallet:** use `{}` to enroll, or supply `user_id` for an already enrolled user. +- **Link card:** include an explicit `test: true` or `test: false` and the required fields shown + in help. Optional `line_items`, `totals`, `metadata`, and `expires_at` are supported through JSON. +- **AgentCard card:** uses `merchant`, not Link's `merchant_name`. Its optional `card_id` selects + a vaulted card; otherwise the cardholder selects one at approval. Mode is deployment-controlled. + +`cards update` replaces the entire spec, so omitted optional details are removed. Permitted +checkout domains remain provider-assigned. Neither command submits a merchant payment. #### Link checkout preparation @@ -329,7 +329,8 @@ and `--merchant ` are required. Currency is normalized to lowercase. ```bash kernel vaults create --name checkout - kernel vaults wallets create checkout wallet-1 --provider link --open + kernel vaults wallets create checkout wallet-1 --provider link \ + --spec '{"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}' --open kernel vaults items get checkout wallet-1 --wait 60 ``` @@ -337,12 +338,16 @@ and `--merchant ` are required. Currency is normalized to lowercase. ```bash kernel vaults wallets payment-methods checkout wallet-1 - kernel vaults cards create checkout order-1 \ - --provider link --wallet wallet-1 --payment-method-id \ - --amount 1234 --currency USD --merchant 'Example Shop' \ - --merchant-url https://shop.example \ - --context 'Purchase the selected office supplies from Example Shop for the approved order, with a total spending limit of 1234 minor currency units.' \ - --test + kernel vaults cards create checkout order-1 --provider link --spec '{ + "wallet": "wallet-1", + "payment_method_id": "", + "amount": 1234, + "currency": "usd", + "merchant_name": "Example Shop", + "merchant_url": "https://shop.example", + "context": "Purchase the selected office supplies from Example Shop for the approved order, with a total spending limit of 1234 minor currency units.", + "test": true + }' ``` 3. After explicit user approval, authorize **only if the item advertises it**. Follow the diff --git a/cmd/vaults.go b/cmd/vaults.go index af91f777..f8a4f792 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -168,27 +168,7 @@ func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, e return c.showItem(item, output, open) } -func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key, provider, userID, output string, open bool) error { - var spec kernel.WalletVaultItemSpecUnionParam - switch provider { - case "link": - if userID != "" { - return fmt.Errorf("--user-id is only supported by agentcard") - } - spec = kernel.WalletVaultItemSpecParamOfLink(kernel.WalletVaultItemSpecLinkAuthorizationParam{ - Method: "oauth", Client: kernel.WalletVaultItemSpecLinkAuthorizationClientParam{Type: "kernel_managed"}, - }) - case "agentcard": - spec.OfAgentcard = &kernel.WalletVaultItemSpecAgentcardParam{} - if userID != "" { - if !regexp.MustCompile(`^usr_[A-Za-z0-9_]+$`).MatchString(userID) { - return fmt.Errorf("--user-id must be an enrolled AgentCard user ID (usr_...)") - } - spec.OfAgentcard.UserID = kernel.Opt(userID) - } - default: - return fmt.Errorf("--provider must be link or agentcard") - } +func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key string, spec kernel.WalletVaultItemSpecUnionParam, output string, open bool) error { item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfWallet: &kernel.VaultItemUpsertParamsBodyWallet{Spec: spec}}, option.WithMaxRetries(0)) if err != nil { return util.CleanedUpSdkError{Err: err} diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index a974ac1a..7abd3bf0 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -1,14 +1,12 @@ package cmd import ( + "encoding/json" "fmt" - "net/url" - "regexp" - "strings" - "unicode/utf8" "github.com/kernel/cli/pkg/interactive" kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/packages/param" "github.com/pkg/browser" "github.com/spf13/cobra" ) @@ -59,7 +57,7 @@ Vault names, item keys, and project ownership are immutable. 1. Create/select a vault, then create a provider wallet and follow its returned action. 2. For Link, list wallet payment methods and select an ID explicitly. -3. Create a card request. Link requires explicit --test or --live intent. +3. Create a card request with --provider and --spec JSON. Link requires an explicit test boolean. 4. For a requested Link card, use cards authorize only when advertised. Follow the returned approval action. AgentCard authorizes at checkout, not through this command. 5. Attach the vault with browsers create --vault . Use only returned @@ -130,17 +128,27 @@ JSON output preserves returned public fields but omits unknown/opaque provider d items.AddCommand(itemList, itemGet, itemEvents, newVaultDeleteCommand(true)) wallets := &cobra.Command{Use: "wallets", Short: "Connect provider wallets and inspect funding methods"} - walletCreate := &cobra.Command{Use: "create --provider ", Short: "Create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: "Create a wallet at an immutable key. Link uses Kernel-managed OAuth; complete the returned URL outside the CLI.\nAgentCard returns a card-enrollment action, or may reference an already enrolled user in this organization.\nAgentCard sandbox/live mode is fixed by the deployment; there is no per-item test flag.", + walletCreate := &cobra.Command{Use: "create --provider --spec ''", Short: "Create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "Create a wallet at an immutable key and follow the returned provider action.\n" + vaultSpecHelp + vaultWalletSpecHelp, + Example: ` kernel vaults wallets create checkout wallet-1 \ + --provider link --spec '{ + "authorization": { + "method": "oauth", + "client": {"type": "kernel_managed"} + } + }' --open + + kernel vaults wallets create checkout wallet-1 \ + --provider agentcard --spec '{}'`, RunE: func(cmd *cobra.Command, args []string) error { - provider, _ := cmd.Flags().GetString("provider") - userID, _ := cmd.Flags().GetString("user-id") + spec, err := vaultSpecFromFlags(cmd) + if err != nil { + return err + } open, _ := cmd.Flags().GetBool("open") - return getVaultsHandler(cmd).CreateWallet(cmd.Context(), args[0], args[1], provider, userID, vaultOutput(cmd), open) + return getVaultsHandler(cmd).CreateWallet(cmd.Context(), args[0], args[1], param.Override[kernel.WalletVaultItemSpecUnionParam](spec), vaultOutput(cmd), open) }} - walletCreate.Flags().String("provider", "", "Wallet provider: link or agentcard (required)") - _ = walletCreate.MarkFlagRequired("provider") - walletCreate.Flags().String("user-id", "", "Already enrolled AgentCard user ID in this organization (optional)") + addVaultSpecFlags(walletCreate) walletCreate.Flags().Bool("open", false, "Open the returned HTTPS connection/enrollment URL") addVaultJSONOutputFlag(walletCreate) methods := &cobra.Command{Use: "payment-methods ", Short: "Fetch advertised live wallet payment methods", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, @@ -188,109 +196,53 @@ func newVaultCardCommand(update bool) *cobra.Command { if update { use, short = "update", "Replace a card spec when the API permits configuration" } - cmd := &cobra.Command{Use: use + " ", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: short + `. Supply the complete specification, including all required flags. -Link requires --payment-method-id, --merchant-url, --context (at least 100 characters), -and exactly one of --test or --live. Amount is an integer in minor currency units. -AgentCard uses --merchant as its approval-screen name; --card-id is optional. -AgentCard mode is deployment-controlled; --test/--live are not supported for it. -Permitted domains come from the provider and cannot be configured by this API. -Neither create nor update authorizes a Link card. The API enforces update eligibility, -provider/wallet invariants, and immutable item keys. Update replaces the entire spec; -optional purchase details set outside the CLI are removed when omitted. -Never reconfigure to retry a failed, timed-out, rejected, or indeterminate payment.`, + cmd := &cobra.Command{Use: use + " --provider --spec ''", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: short + `. Neither create nor update authorizes a Link card. +Update replaces the entire spec; omitted optional details are removed. +Never reconfigure to retry a failed, timed-out, rejected, or indeterminate payment. +` + vaultSpecHelp + vaultCardSpecHelp, + Example: " kernel vaults cards " + use + ` checkout order-1 \ + --provider agentcard --spec '{ + "wallet": "wallet-1", + "merchant": "Example Shop", + "amount": 1234, + "currency": "usd" + }'`, RunE: func(cmd *cobra.Command, args []string) error { - spec, err := vaultCardSpecFromFlags(cmd) + spec, err := vaultSpecFromFlags(cmd) if err != nil { return err } - return getVaultsHandler(cmd).SaveCard(cmd.Context(), args[0], args[1], spec, update, vaultOutput(cmd)) + return getVaultsHandler(cmd).SaveCard(cmd.Context(), args[0], args[1], param.Override[kernel.CardVaultItemSpecUnionParam](spec), update, vaultOutput(cmd)) }} - cmd.Flags().String("provider", "", "Card provider: link or agentcard (required)") - cmd.Flags().String("wallet", "", "Wallet item key in this vault (required)") - cmd.Flags().Int64("amount", 0, "Amount in minor currency units, not a decimal (required)") - cmd.Flags().String("currency", "", "Three-letter currency code (required)") - cmd.Flags().String("merchant", "", "Merchant name for approval (required)") - for _, flag := range []string{"provider", "wallet", "amount", "currency", "merchant"} { - _ = cmd.MarkFlagRequired(flag) - } - cmd.Flags().String("payment-method-id", "", "Explicit ID from wallets payment-methods (required for Link)") - cmd.Flags().String("merchant-url", "", "Absolute HTTP(S) merchant URL (required for Link)") - cmd.Flags().String("context", "", "Purchase purpose, at least 100 characters (required for Link); no secrets") - cmd.Flags().Bool("test", false, "Request Link test credentials (explicitly choose --test or --live)") - cmd.Flags().Bool("live", false, "Request a live Link payment credential (explicit opt-in)") - cmd.MarkFlagsMutuallyExclusive("test", "live") - cmd.Flags().String("card-id", "", "AgentCard vaulted card ID; omit to let the cardholder select during approval") + addVaultSpecFlags(cmd) addVaultJSONOutputFlag(cmd) return cmd } -func vaultCardSpecFromFlags(cmd *cobra.Command) (kernel.CardVaultItemSpecUnionParam, error) { - var spec kernel.CardVaultItemSpecUnionParam +func addVaultSpecFlags(cmd *cobra.Command) { + cmd.Flags().String("provider", "", "Provider: link or agentcard (required)") + cmd.Flags().String("spec", "", "Raw JSON specification object (required); see types and examples above") + _ = cmd.MarkFlagRequired("provider") + _ = cmd.MarkFlagRequired("spec") +} + +func vaultSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { provider, _ := cmd.Flags().GetString("provider") - wallet, _ := cmd.Flags().GetString("wallet") - amount, _ := cmd.Flags().GetInt64("amount") - currency, _ := cmd.Flags().GetString("currency") - merchant, _ := cmd.Flags().GetString("merchant") - if err := validateVaultName(wallet, "--wallet"); err != nil { - return spec, err - } - if !regexp.MustCompile(`^[A-Za-z]{3}$`).MatchString(currency) { - return spec, fmt.Errorf("--currency must be a three-letter currency code") + if provider != "link" && provider != "agentcard" { + return nil, fmt.Errorf("--provider must be link or agentcard") } - currency = strings.ToLower(currency) - if amount < 1 { - return spec, fmt.Errorf("--amount must be positive, in minor currency units") + raw, _ := cmd.Flags().GetString("spec") + var spec map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &spec); err != nil || spec == nil { + return nil, fmt.Errorf("--spec must be a JSON object") } - if strings.TrimSpace(merchant) == "" { - return spec, fmt.Errorf("--merchant is required") - } - switch provider { - case "link": - if cmd.Flags().Changed("card-id") { - return spec, fmt.Errorf("--card-id is only supported by agentcard") - } - if amount > 500000 || utf8.RuneCountInString(merchant) > 255 { - return spec, fmt.Errorf("link requires --amount <= 500000 and --merchant <= 255 characters") - } - test, _ := cmd.Flags().GetBool("test") - live, _ := cmd.Flags().GetBool("live") - if test == live || (cmd.Flags().Changed("test") && cmd.Flags().Changed("live")) { - return spec, fmt.Errorf("link requires exactly one of --test or --live (set to true)") - } - method, _ := cmd.Flags().GetString("payment-method-id") - merchantURL, _ := cmd.Flags().GetString("merchant-url") - contextText, _ := cmd.Flags().GetString("context") - if strings.TrimSpace(method) == "" { - return spec, fmt.Errorf("--payment-method-id is required; select an ID from wallets payment-methods") - } - u, err := url.Parse(merchantURL) - if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil { - return spec, fmt.Errorf("--merchant-url must be an absolute HTTP(S) URL without credentials") - } - if utf8.RuneCountInString(strings.TrimSpace(contextText)) < 100 { - return spec, fmt.Errorf("--context must describe the purchase in at least 100 characters") - } - spec.OfLink = &kernel.CardVaultItemSpecLinkParam{Wallet: wallet, Amount: amount, Currency: currency, MerchantName: merchant, MerchantURL: merchantURL, PaymentMethodID: method, Context: contextText, Test: test} - case "agentcard": - for _, flag := range []string{"test", "live", "payment-method-id", "merchant-url", "context"} { - if cmd.Flags().Changed(flag) { - return spec, fmt.Errorf("--%s is only supported by Link; AgentCard mode is deployment-controlled", flag) - } - } - if amount > 9007199254740991 || utf8.RuneCountInString(merchant) > 120 { - return spec, fmt.Errorf("agentcard requires --amount <= 9007199254740991 and --merchant <= 120 characters") - } - spec.OfAgentcard = &kernel.CardVaultItemSpecAgentcardParam{Wallet: wallet, Amount: amount, Currency: currency, Merchant: merchant} - cardID, _ := cmd.Flags().GetString("card-id") - if cmd.Flags().Changed("card-id") { - if !regexp.MustCompile(`^vc_[A-Za-z0-9_]+$`).MatchString(cardID) { - return spec, fmt.Errorf("--card-id must be an AgentCard vaulted card ID (vc_...)") - } - spec.OfAgentcard.CardID = kernel.Opt(cardID) + if value, ok := spec["provider"]; ok { + var embedded string + if err := json.Unmarshal(value, &embedded); err != nil || embedded != provider { + return nil, fmt.Errorf("spec.provider must match --provider") } - default: - return spec, fmt.Errorf("--provider must be link or agentcard") } + spec["provider"], _ = json.Marshal(provider) return spec, nil } diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go new file mode 100644 index 00000000..dbfd149a --- /dev/null +++ b/cmd/vaults_help.go @@ -0,0 +1,78 @@ +package cmd + +// Keep these help types in sync with https://api.onkernel.com/spec.yaml. +const vaultSpecHelp = ` +--spec takes the specification object, not the {type, spec} request envelope. +--provider supplies spec.provider; omit it from JSON or supply the same value. +The API validates provider-specific fields. Values are forwarded without defaults +or normalization. Never include card data, OAuth tokens, or provider secrets. + +// Keep these types in sync with https://api.onkernel.com/spec.yaml. +// TypeScript notation: ? means optional. Other fields are required. +// The provider field below is supplied by --provider. +` + +const vaultWalletSpecHelp = ` +type LinkWalletSpec = { + provider: "link"; + authorization: { + method: "oauth"; + client: { type: "kernel_managed" }; + }; +}; + +type AgentCardWalletSpec = { + provider: "agentcard"; + user_id?: string; // usr_...; already enrolled in this organization +}; + +AgentCard sandbox/live mode is deployment-controlled, not a per-item flag. +` + +const vaultCardSpecHelp = ` +type LinkCardSpec = { + provider: "link"; + wallet: string; // wallet item key + payment_method_id: string; // from wallets payment-methods + amount: number; // integer minor units; 1..500000 + currency: string; // three letters + merchant_name: string; // 1..255 characters + merchant_url: string; // URI + context: string; // at least 100 characters + test: boolean; // true = test; false = live; required + line_items?: LinkLineItem[]; + totals?: LinkTotal[]; + metadata?: Record; + expires_at?: number; // int64 +}; + +type AgentCardCardSpec = { + provider: "agentcard"; + wallet: string; // wallet item key + merchant: string; // approval-screen name; 1..120 characters + amount: number; // integer minor units; 1..9007199254740991 + currency: string; // three letters + card_id?: string; // vc_...; otherwise chosen at approval +}; + +type LinkLineItem = { + name: string; + quantity?: number; // integer >= 1 + unit_amount?: number; // integer minor units + description?: string; + sku?: string; + url?: string; + image_url?: string; + product_url?: string; + totals?: LinkTotal[]; +}; + +type LinkTotal = { + type: string; + display_text: string; + amount: number; // integer minor units +}; + +AgentCard sandbox/live mode is deployment-controlled; its spec has no test field. +Permitted domains are provider-assigned, not configurable in the spec. +` diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 1c3df13d..b0132385 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -284,7 +284,7 @@ func printVaultPaymentMethods(methods []kernel.VaultPaymentMethod) { rows = append(rows, []string{m.ID, m.Provider, m.Type, m.Display.Label, m.Display.Brand, m.Display.Last4, fmt.Sprint(m.IsDefault), eligible, strings.Join(capability.Reasons, ", ")}) } PrintTableNoPad(rows, true) - pterm.Info.Println("Select an ID explicitly: Link uses cards create --payment-method-id; AgentCard uses --card-id (or omit it for cardholder selection). Capabilities are advisory; missing means unknown, not ineligible.") + pterm.Info.Println("Select an ID explicitly in the card --spec JSON: Link uses payment_method_id; AgentCard uses card_id (or omit it for cardholder selection). Capabilities are advisory; missing means unknown, not ineligible.") } func printVaultEvents(events []kernel.VaultItemEvent, data []vaultJSON) { diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index f3c78bdf..2d4d6562 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -86,7 +86,7 @@ func TestVaultOutputPaymentMethodsAdvisoryUnknownVsFalse(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(body), &item)) buf := capturePtermOutput(t) require.NoError(t, printVaultItem(&item, "")) - for _, text := range []string{"Payment method ID", "pm-unknown", "unknown", "pm-ineligible", "false", "not_supported", "--payment-method-id", "advisory"} { + for _, text := range []string{"Payment method ID", "pm-unknown", "unknown", "pm-ineligible", "false", "not_supported", "payment_method_id", "--spec JSON", "advisory"} { assert.Contains(t, buf.String(), text) } out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) diff --git a/cmd/vaults_spec_test.go b/cmd/vaults_spec_test.go new file mode 100644 index 00000000..ee43f08e --- /dev/null +++ b/cmd/vaults_spec_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVaultRawSpecForwarding(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, path := range []string{"wallets create", "cards create", "cards update"} { + for _, provider := range []string{"link", "agentcard"} { + for _, raw := range []string{ + `{}`, + `{"test":false,"amount":0,"currency":"USD","metadata":null}`, + `{"expires_at":9223372036854775807,"line_items":[{"name":"Item","quantity":2,"unit_amount":100,"totals":[{"type":"tax","display_text":"Tax","amount":10}]}],"totals":[],"metadata":{"reference":"order-1"}}`, + } { + t.Run(path+"/"+provider+"/"+raw, func(t *testing.T) { + var expected map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(raw), &expected)) + expected["provider"], _ = json.Marshal(provider) + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + var body struct { + Type string `json:"type"` + Spec map[string]json.RawMessage `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, expected, body.Spec, "preserve exact numbers, false, zero, null, nested fields, and omissions") + assert.Equal(t, "/vaults/checkout/items/item-1", r.URL.Path) + if path == "cards update" { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Empty(t, body.Type) + } else { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, strings.TrimSuffix(strings.Fields(path)[0], "s"), body.Type) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) + }) + args := append([]string{"vaults"}, strings.Fields(path)...) + args = append(args, "checkout", "item-1", "--provider", provider, "--spec", raw, "-o", "json") + _, _, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + }) + } + } + } +} + +func TestVaultRawSpecValidationIsLeftToAPI(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Spec map[string]json.RawMessage `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.NotContains(t, body.Spec, "test", "an omitted test field must not become a live request") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"code":"invalid_request","message":"test is required"}`) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "cards", "create", "checkout", "order-1", "--provider", "link", "--spec", "{}") + require.ErrorContains(t, err, "invalid_request: test is required") +} + +func TestVaultSpecHelpAndFlags(t *testing.T) { + for _, path := range []string{"wallets create", "cards create", "cards update"} { + t.Run(path, func(t *testing.T) { + cmd, _, err := newVaultsCommand().Find(strings.Fields(path)) + require.NoError(t, err) + require.NotNil(t, cmd.Flags().Lookup("provider")) + require.NotNil(t, cmd.Flags().Lookup("spec")) + for _, removed := range []string{"wallet", "amount", "currency", "merchant", "merchant-url", "payment-method-id", "context", "test", "live", "card-id", "user-id"} { + assert.Nil(t, cmd.Flags().Lookup(removed), removed) + } + assert.Contains(t, cmd.Long, "Keep these types in sync with https://api.onkernel.com/spec.yaml") + assert.Contains(t, cmd.Long, "TypeScript notation") + assert.Contains(t, cmd.Long, "provider: \"link\"") + assert.Contains(t, cmd.Long, "provider: \"agentcard\"") + assert.Contains(t, cmd.Example, "--spec '") + if strings.HasPrefix(path, "cards") { + for _, field := range []string{"test: boolean", "merchant_name:", "merchant:", "line_items?:", "metadata?:", "expires_at?:", "type LinkLineItem", "type LinkTotal"} { + assert.Contains(t, cmd.Long, field) + } + } else { + assert.Contains(t, cmd.Long, "authorization:") + assert.Contains(t, cmd.Long, "user_id?:") + } + }) + } +} diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 97190688..9d7cdb80 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -19,6 +19,8 @@ import ( "github.com/stretchr/testify/require" ) +const linkWalletSpecFixture = `{"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}` + const vaultFixture = `{"id":"vault-1","name":"checkout","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` const requestedCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","context":"Purchase description","test":true},"state":{"provider":"link","status":"requested"},"available_operations":[{"type":"authorize","description":"Use only after explicit user approval."}],"available_expansions":[]}` const connectedWalletFixture = `{"id":"wallet-id","key":"wallet-1","type":"wallet","spec":{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}},"state":{"provider":"link","status":"connected"},"available_operations":[],"available_expansions":[{"type":"payment_methods","description":"Select a payment method explicitly."}]}` @@ -98,7 +100,10 @@ func TestVaultRequiredAndInvalidFlags(t *testing.T) { {"vaults items events checkout wallet-1 --wait 61", "--wait"}, {"vaults items get checkout wallet-1 --expand secret", "--expand"}, {"vaults wallets create checkout wallet-1", "required flag"}, - {"vaults wallets create checkout wallet-1 --provider unknown", "--provider"}, + {"vaults wallets create checkout wallet-1 --provider unknown --spec {}", "--provider"}, + {"vaults wallets create checkout wallet-1 --provider link", "required flag"}, + {"vaults cards create checkout order-1 --provider link", "required flag"}, + {"vaults cards update checkout order-1 --spec {}", "required flag"}, {"vaults wallets create checkout wallet-1 --provider link --user-id usr_123", "--user-id"}, {"vaults wallets create checkout wallet-1 --provider agentcard --user-id wrong", "--user-id"}, {"vaults cards create checkout order-1", "required flag"}, @@ -131,7 +136,7 @@ func TestVaultCommandsWithoutProject(t *testing.T) { {[]string{"items", "get", "checkout", "order-1"}, requestedCardFixture, 1}, {[]string{"items", "delete", "checkout", "order-1", "--yes"}, "", 1}, {[]string{"items", "events", "checkout", "order-1"}, "[]", 1}, - {[]string{"wallets", "create", "checkout", "wallet-1", "--provider", "link"}, connectedWalletFixture, 1}, + {[]string{"wallets", "create", "checkout", "wallet-1", "--provider", "link", "--spec", linkWalletSpecFixture}, connectedWalletFixture, 1}, {[]string{"wallets", "payment-methods", "checkout", "wallet-1"}, connectedWalletFixture, 1}, {append([]string{"cards", "create", "checkout", "order-1"}, linkCardArgs()...), requestedCardFixture, 1}, {append([]string{"cards", "update", "checkout", "order-1"}, linkCardArgs()...), requestedCardFixture, 1}, @@ -160,60 +165,21 @@ func TestVaultCommandsWithoutProject(t *testing.T) { } func linkCardArgs() []string { - return []string{"--provider", "link", "--wallet", "wallet-1", "--amount", "1234", "--currency", "USD", "--merchant", "Example Shop", "--payment-method-id", "pm-1", "--merchant-url", "https://shop.example", "--context", strings.Repeat("Purchase purpose. ", 7), "--test"} + return []string{"--provider", "link", "--spec", fmt.Sprintf(`{"wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","payment_method_id":"pm-1","merchant_url":"https://shop.example","context":%q,"test":true}`, strings.Repeat("Purchase purpose. ", 7))} } -func TestVaultCardSpecValidation(t *testing.T) { - tests := []struct { - flag, value, want string - }{ - {"provider", "other", "--provider"}, - {"wallet", "", "--wallet"}, - {"amount", "0", "--amount"}, - {"amount", "-1", "--amount"}, - {"amount", "500001", "--amount"}, - {"currency", "US", "--currency"}, - {"currency", "123", "--currency"}, - {"merchant", " ", "--merchant"}, - {"merchant", strings.Repeat("m", 256), "--merchant"}, - {"payment-method-id", "", "--payment-method-id"}, - {"merchant-url", "example.com", "--merchant-url"}, - {"merchant-url", "https://user:secret@example.com", "--merchant-url"}, - {"merchant-url", "javascript:alert(1)", "--merchant-url"}, - {"context", strings.Repeat("x", 99), "--context"}, - {"test", "false", "--test or --live"}, - {"live", "true", "--test or --live"}, - {"card-id", "vc_test", "--card-id"}, - } - for _, tt := range tests { - t.Run(tt.flag+tt.value, func(t *testing.T) { - cmd := newVaultCardCommand(false) - require.NoError(t, cmd.ParseFlags(linkCardArgs())) - require.NoError(t, cmd.Flags().Set(tt.flag, tt.value)) - _, err := vaultCardSpecFromFlags(cmd) - require.ErrorContains(t, err, tt.want) - }) - } - for _, update := range []bool{false, true} { - cmd := newVaultCardCommand(update) - args := linkCardArgs() - args[len(args)-1] = "--live" - require.NoError(t, cmd.ParseFlags(args)) - spec, err := vaultCardSpecFromFlags(cmd) - require.NoError(t, err) - assert.False(t, spec.OfLink.Test) - assert.Equal(t, "usd", spec.OfLink.Currency) - assert.Equal(t, int64(1234), spec.OfLink.Amount) - } - for _, args := range [][]string{ - {"--provider", "agentcard", "--wallet", "wallet-1", "--amount", "9007199254740992", "--currency", "USD", "--merchant", "Shop"}, - {"--provider", "agentcard", "--wallet", "wallet-1", "--amount", "1", "--currency", "USD", "--merchant", "Shop", "--test=false"}, - {"--provider", "agentcard", "--wallet", "wallet-1", "--amount", "1", "--currency", "USD", "--merchant", "Shop", "--card-id", ""}, - } { - cmd := newVaultCardCommand(false) - require.NoError(t, cmd.ParseFlags(args)) - _, err := vaultCardSpecFromFlags(cmd) - require.Error(t, err) +func TestVaultSpecValidation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid JSON reached API") }) + for _, path := range []string{"wallets create", "cards create", "cards update"} { + for _, spec := range []string{"", "null", "[]", "42", `"text"`, "{", "{} {}", `{"provider":"agentcard"}`, `{"provider":null}`, `{"provider":1}`} { + t.Run(path+"/"+spec, func(t *testing.T) { + args := append([]string{"vaults"}, strings.Fields(path)...) + args = append(args, "checkout", "item-1", "--provider", "link", "--spec", spec) + _, _, err := executeVaultCommand(t, client, args...) + require.Error(t, err) + }) + } } } @@ -287,10 +253,7 @@ func TestVaultWalletRequestMapping(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, connectedWalletFixture) }) - args := []string{"vaults", "wallets", "create", "checkout", "wallet-1", "--provider", tt.provider, "-o", "json"} - if tt.userID != "" { - args = append(args, "--user-id", tt.userID) - } + args := []string{"vaults", "wallets", "create", "checkout", "wallet-1", "--provider", tt.provider, "--spec", tt.spec, "-o", "json"} out, human, err := executeVaultCommand(t, client, args...) require.NoError(t, err) assert.JSONEq(t, connectedWalletFixture, out) @@ -322,7 +285,7 @@ func TestVaultCardRequestMapping(t *testing.T) { assert.Len(t, body, 1) } if provider == "link" { - assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","payment_method_id":"pm-1","context":%q,"test":true}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) + assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","merchant_url":"https://shop.example","payment_method_id":"pm-1","context":%q,"test":true}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) } else { assert.JSONEq(t, `{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen"}`, string(body["spec"])) } @@ -331,7 +294,7 @@ func TestVaultCardRequestMapping(t *testing.T) { }) flags := linkCardArgs() if provider == "agentcard" { - flags = []string{"--provider", provider, "--wallet", "wallet-1", "--amount", "1234", "--currency", "USD", "--merchant", "Example Shop", "--card-id", "vc_chosen"} + flags = []string{"--provider", provider, "--spec", `{"wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen"}`} } args := append([]string{"vaults", "cards", operation, "checkout", "order-1", "-o", "json"}, flags...) out, human, err := executeVaultCommand(t, client, args...) @@ -415,7 +378,7 @@ func TestVaultInvalidProjectErrors(t *testing.T) { {"list"}, {"get", "checkout"}, {"create", "--name", "checkout"}, {"items", "list", "checkout"}, {"items", "get", "checkout", "order-1"}, {"items", "events", "checkout", "order-1"}, - {"wallets", "create", "checkout", "wallet-1", "--provider", "link"}, + {"wallets", "create", "checkout", "wallet-1", "--provider", "link", "--spec", linkWalletSpecFixture}, {"wallets", "payment-methods", "checkout", "wallet-1"}, append([]string{"cards", "create", "checkout", "order-1"}, linkCardArgs()...), append([]string{"cards", "update", "checkout", "order-1"}, linkCardArgs()...), From 6491b787bdf7be91aa631cbfc73fbee943b6a707 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:23:15 +0000 Subject: [PATCH 08/12] Document the current vault CLI and SDK release requirements --- DEVELOPMENT.md | 36 +++++++++++++++++++++++++++++++++++- README.md | 43 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0e18536f..daec0983 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -12,7 +12,7 @@ brew install onkernel/tap/kernel Install the following tools: -- Go 1.22+ ( https://go.dev/doc/install ) +- Go 1.25+ ( https://go.dev/doc/install ); the required version is recorded in `go.mod` - [Goreleaser Pro](https://goreleaser.com/install/#pro) - **IMPORTANT: You must install goreleaser-pro, not the standard version, as this is required for our release process** - [chglog](https://github.com/goreleaser/chglog) @@ -56,6 +56,40 @@ A typical workflow we encounter is updating the API and integrating those change ./scripts/go-mod-replace-kernel.sh ``` +### Maintaining the vault commands + +Wallet creation and card creation/update accept `--provider` and raw `--spec` JSON. +Provider-specific fields are validated by the API, not duplicated as CLI flags or local +schema validators. Preserve JSON values, including large integers, explicit `false`, nulls, +and omitted fields; do not introduce defaults while converting requests for the SDK. + +- Keep the types in `cmd/vaults_help.go` aligned with the + [published API spec](https://api.onkernel.com/spec.yaml), including nested optional types. +- Update the README examples and payment-method selection hints when changing this interface. +- Run `make test`, `make build`, and `go test -race ./cmd -run TestVault -count=1`. + Use local HTTP fixtures for request/output tests; do not create live payment credentials. +- Action and approval URLs must remain outside the terminal-width-limited tables. Check + complete URLs at narrow terminal widths as well as in piped and JSON output. + +#### Preview SDK dependency + +The vault implementation currently uses the `go.mod` replacement for +`github.com/kernel/kernel-go-sdk-staging` at +`v0.86.1-0.20260904020633-50f33b1b5cf6`. Fetching it requires GitHub access to that repository +and `GOPRIVATE=github.com/kernel/kernel-go-sdk-staging`. The test workflow temporarily +obtains a repository-scoped read token and disables Go caching; fork CI cannot access it. + +A cached local build does not prove the preview is fetchable. If a fresh build reports +`unknown revision`, resolve the dependency before merging or releasing. + +Before releasing the vault commands: + +1. Upgrade to the canonical released SDK containing the required vault APIs and remove the + replacement with `go mod edit -dropreplace=github.com/kernel/kernel-go-sdk`. +2. Run `go mod tidy` and address any generated SDK interface changes. +3. Remove the preview-token/GOPRIVATE setup from `.github/workflows/test.yaml` and restore caching. +4. Verify a fresh dependency fetch, tests, build, and lint against the released SDK. + ### Releasing a new version Releases are automated via GitHub Actions. Simply push a version tag and the release workflow will handle the rest. diff --git a/README.md b/README.md index 6686cd97..5374f295 100644 --- a/README.md +++ b/README.md @@ -295,8 +295,9 @@ cannot switch projects. | `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | | `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | -`` accepts an ID or name. Names and keys use letters, digits, dots, underscores, and -hyphens (1–255 characters; not `.` or `..`). All commands except delete support `-o json`. +`` accepts an ID or name. `` is the immutable item key within that vault, not +its generated item ID. Names and keys use letters, digits, dots, underscores, and hyphens +(1–255 characters; not `.` or `..`). All commands except delete support `-o json`. JSON preserves field presence and API-returned aliases, while omitting unknown fields, opaque metadata, and unrecognized event data. Human output labels aliases as non-secret checkout values and distinguishes card readiness from checkout authorization/payment outcomes. @@ -338,6 +339,7 @@ checkout domains remain provider-assigned. Neither command submits a merchant pa ```bash kernel vaults wallets payment-methods checkout wallet-1 + # Equivalent: kernel vaults items get checkout wallet-1 --expand payment_methods kernel vaults cards create checkout order-1 --provider link --spec '{ "wallet": "wallet-1", "payment_method_id": "", @@ -373,6 +375,43 @@ checkout domains remain provider-assigned. Neither command submits a merchant pa kernel vaults items events checkout order-1 --after --wait 60 ``` +#### AgentCard checkout preparation + +For a separate AgentCard flow, create a vault and complete the wallet enrollment action: + +```bash +kernel vaults create --name agentcard-checkout +kernel vaults wallets create agentcard-checkout wallet-1 --provider agentcard --spec '{}' --open +kernel vaults items get agentcard-checkout wallet-1 --wait 60 +``` + +Once the wallet is connected, create the card request: + +```bash +kernel vaults cards create agentcard-checkout order-1 --provider agentcard --spec '{ + "wallet": "wallet-1", + "merchant": "Example Shop", + "amount": 1234, + "currency": "usd" +}' +kernel browsers create --vault agentcard-checkout +``` + +AgentCard authorizes at checkout; do not run `cards authorize` for it. To select a vaulted +card in advance, inspect `wallets payment-methods` and include its ID as `card_id` in the +card spec. Otherwise, the cardholder selects a card at approval. A reusable card being +`ready` does not mean the last payment succeeded. + +#### Expansions, updates, and lifecycle + +`--expand` takes a value, such as `--expand payment_methods`; it is not a boolean switch. +Request only expansions advertised in `available_expansions`. Add `-o json` to read the +returned `expanded.payment_methods` directly. Unavailable expansions return an API error. + +Use `cards update --provider --spec ''` to replace the entire +card spec when the API permits it. Include optional fields you want to retain; the CLI +does not merge the new JSON with the existing spec. + Waits are single bounded observations, not readiness guarantees or payment retries. Pending state is returned as-is. Requests are not automatically retried by the vault commands. Pending/terminal Link authorizations cannot be resumed by `cards authorize`. From 8dc1e1173d08b0d597a44529c42e7f856f80034c Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:48:59 +0000 Subject: [PATCH 09/12] Invoke advertised vault item operations generically --- DEVELOPMENT.md | 6 ++ README.md | 31 +++++-- cmd/vaults.go | 32 ++++--- cmd/vaults_commands.go | 36 ++++---- cmd/vaults_invoke_test.go | 184 ++++++++++++++++++++++++++++++++++++++ cmd/vaults_output.go | 47 +++++++++- cmd/vaults_output_test.go | 2 +- cmd/vaults_test.go | 20 ++--- 8 files changed, 312 insertions(+), 46 deletions(-) create mode 100644 cmd/vaults_invoke_test.go diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index daec0983..f5a24aa1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -63,6 +63,12 @@ Provider-specific fields are validated by the API, not duplicated as CLI flags o schema validators. Preserve JSON values, including large integers, explicit `false`, nulls, and omitted fields; do not introduce defaults while converting requests for the SDK. +Item operations use `items invoke `. Gate invocations on the +server's `available_operations`, not local provider/type/state rules or an operation registry. +The current operation schema only accepts `{"type":"authorize"}` with no extra fields; +add operation parameters only when the API supports them. GET output should retain the +selected project in copyable invocation hints and shell-quote arguments safely. + - Keep the types in `cmd/vaults_help.go` aligned with the [published API spec](https://api.onkernel.com/spec.yaml), including nested optional types. - Update the README examples and payment-method selection hints when changing this interface. diff --git a/README.md b/README.md index 5374f295..dd7d5385 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Commands with JSON output support: - **Proxies**: `create`, `list`, `get`, `update`, `check` - **API Keys**: `create`, `list`, `get`, `update`, `rotate` - **Auth Connections**: `timeline` -- **Vaults**: `create`, `list`, `get`, `items list/get/events`, `wallets create/payment-methods`, `cards create/update/authorize` (display-safe public fields only) +- **Vaults**: `create`, `list`, `get`, `items list/get/events/invoke`, `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) - **Projects**: `update` - **Org**: `limits get/set` - **Apps**: `list`, `history` @@ -289,9 +289,9 @@ cannot switch projects. | `kernel vaults wallets payment-methods ` | Fetch advertised live payment methods; JSON is the item with `expanded.payment_methods` | | `kernel vaults cards create --provider link\|agentcard --spec ''` | Create a card request; never implicitly authorize Link | | `kernel vaults cards update --provider link\|agentcard --spec ''` | Replace the full card spec; the API enforces state/provider constraints | -| `kernel vaults cards authorize ` | After explicit user approval, GET the requested Link card and POST `authorize` only if advertised; optional `--open` | | `kernel vaults items list ` | List item keys, types, providers, status, and required actions | -| `kernel vaults items get ` | Inspect state/actions/returned aliases; `--wait 0..60`, `--expand payment_methods`, `--open` | +| `kernel vaults items get ` | Inspect state/actions/returned aliases and copyable operation commands; `--wait 0..60`, `--expand payment_methods`, `--open` | +| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; optional `--open` opens a returned HTTPS action | | `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | | `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | @@ -356,7 +356,8 @@ checkout domains remain provider-assigned. Neither command submits a merchant pa returned approval action, then observe: ```bash - kernel vaults cards authorize checkout order-1 --open + kernel vaults items get checkout order-1 + kernel vaults items invoke checkout order-1 authorize --open kernel vaults items get checkout order-1 --wait 60 ``` @@ -397,11 +398,28 @@ kernel vaults cards create agentcard-checkout order-1 --provider agentcard --spe kernel browsers create --vault agentcard-checkout ``` -AgentCard authorizes at checkout; do not run `cards authorize` for it. To select a vaulted -card in advance, inspect `wallets payment-methods` and include its ID as `card_id` in the +AgentCard authorizes at checkout and does not currently advertise `authorize`. To select a +vaulted card in advance, inspect `wallets payment-methods` and include its ID as `card_id` in the card spec. Otherwise, the cardholder selects a card at approval. A reusable card being `ready` does not mean the last payment succeeded. +#### Invoking item operations + +`items get` displays every `available_operations` entry's type and description, plus a +copyable `items invoke` command retaining the selected project. Read the description and +follow its approval requirements before invoking. Required user actions (OAuth, enrollment, +MFA, spend approval) appear separately; they are not operations to invoke through this endpoint. + +`items invoke` fetches the item again and calls +`POST /vaults/{id_or_name}/items/{key}/operations` only if the requested operation is still +advertised. The API controls availability; the CLI has no provider/type/state-specific +operation checks. The response is the updated item, possibly with a required user action. + +The current [API spec](https://api.onkernel.com/spec.yaml) accepts only +`{"type":"authorize"}` and forbids extra fields. There is no operation `--spec` flag; +wallet/card `--spec` flags remain unchanged. New parameterless operations can be invoked by +name when the API advertises them, without adding CLI subcommands. + #### Expansions, updates, and lifecycle `--expand` takes a value, such as `--expand payment_methods`; it is not a boolean switch. @@ -414,7 +432,6 @@ does not merge the new JSON with the existing spec. Waits are single bounded observations, not readiness guarantees or payment retries. Pending state is returned as-is. Requests are not automatically retried by the vault commands. -Pending/terminal Link authorizations cannot be resumed by `cards authorize`. **Never retry failed, timed-out, rejected, or indeterminate payments.** Inspect state/events and reconcile the outcome instead. Do not pass card data, OAuth codes/tokens, ciphertext, provider secrets, or sensitive provider responses to the CLI. Complete collection, OAuth, diff --git a/cmd/vaults.go b/cmd/vaults.go index f8a4f792..ebbe121d 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "regexp" + "strings" "time" "github.com/kernel/cli/pkg/interactive" @@ -150,7 +151,7 @@ func validateVaultWait(wait int64) error { return nil } -func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, expand []string, output string, open bool) error { +func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, expand []string, project, output string, open bool) error { if err := validateVaultWait(wait); err != nil { return err } @@ -165,7 +166,13 @@ func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, e if err != nil { return util.CleanedUpSdkError{Err: err} } - return c.showItem(item, output, open) + if err := c.showItem(item, output, open); err != nil { + return err + } + if output != "json" { + return printVaultOperationHints(item, vault, key, project) + } + return nil } func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key string, spec kernel.WalletVaultItemSpecUnionParam, output string, open bool) error { @@ -190,27 +197,32 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. return c.showItem(item, output, false) } -func (c VaultsCmd) Authorize(ctx context.Context, vault, key, output string, open bool) error { +func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation, output string, open bool) error { + if strings.TrimSpace(operation) == "" { + return fmt.Errorf("operation must not be empty") + } item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { return util.CleanedUpSdkError{Err: err} } - if item.Type != "card" || item.Spec.Provider != "link" || item.State.Status != "requested" { - return fmt.Errorf("authorization requires a requested Link card; inspect item state and events; do not retry payments or resume indeterminate authorizations") + operations, err := vaultItemOperations(item) + if err != nil { + return err } available := false - for _, operation := range item.AsCard().AvailableOperations { - if operation.Type == "authorize" { + for _, op := range operations { + if op.Type == operation { available = true if output != "json" { - pterm.Info.Println(operation.Description) + pterm.Info.Println(op.Description) } + break } } if !available { - return fmt.Errorf("authorize is not advertised in available_operations; inspect the item and its wallet") + return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } - item, err = c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, Type: kernel.VaultItemPerformOperationParamsTypeAuthorize}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, Type: kernel.VaultItemPerformOperationParamsType(operation)}, option.WithMaxRetries(0)) if err != nil { return util.CleanedUpSdkError{Err: err} } diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 7abd3bf0..3cff15bc 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -35,6 +35,9 @@ func vaultPreRun(cmd *cobra.Command, args []string) error { return err } for i, arg := range args { + if i > 1 { + break + } label := "vault ID or name" if i == 1 { label = "item key" @@ -58,8 +61,8 @@ Vault names, item keys, and project ownership are immutable. 1. Create/select a vault, then create a provider wallet and follow its returned action. 2. For Link, list wallet payment methods and select an ID explicitly. 3. Create a card request with --provider and --spec JSON. Link requires an explicit test boolean. -4. For a requested Link card, use cards authorize only when advertised. Follow the - returned approval action. AgentCard authorizes at checkout, not through this command. +4. Inspect items get, then use items invoke only when advertised. + Follow the operation description and any returned provider action. 5. Attach the vault with browsers create --vault . Use only returned non-secret aliases in that browser. Inspect items get/events for the outcome. @@ -110,7 +113,8 @@ JSON output preserves returned public fields but omits unknown/opaque provider d wait, _ := cmd.Flags().GetInt64("wait") expand, _ := cmd.Flags().GetStringSlice("expand") open, _ := cmd.Flags().GetBool("open") - return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], wait, expand, vaultOutput(cmd), open) + project, _ := cmd.Flags().GetString("project") + return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], wait, expand, resolveProjectSelection(project), vaultOutput(cmd), open) }} itemGet.Flags().Int64("wait", 0, "Hold while pending for up to this many seconds (0-60); observe only") itemGet.Flags().StringSlice("expand", nil, "Advertised live data to fetch: payment_methods") @@ -125,7 +129,16 @@ JSON output preserves returned public fields but omits unknown/opaque provider d itemEvents.Flags().String("after", "", "Return events after this event ID (use the last ID from the previous response)") itemEvents.Flags().Int64("wait", 0, "Long-poll once for new events (0-60 seconds)") addVaultJSONOutputFlag(itemEvents) - items.AddCommand(itemList, itemGet, itemEvents, newVaultDeleteCommand(true)) + invoke := &cobra.Command{Use: "invoke ", Short: "Invoke an operation advertised by an item", Args: cobra.ExactArgs(3), PreRunE: vaultPreRun, + Long: "Retrieve the item and invoke only an operation listed in available_operations.\nRead its description with items get before invoking; follow any approval requirements.\nThe API determines availability regardless of item type, provider, or state.\nRequests are not automatically retried. The updated item may contain a required user action.\nThe current API accepts only {\"type\":\"authorize\"}; there are no operation parameters or --spec flag.", + Example: " kernel vaults items get checkout order-1\n kernel vaults items invoke checkout order-1 authorize", + RunE: func(cmd *cobra.Command, args []string) error { + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], vaultOutput(cmd), open) + }} + invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") + addVaultJSONOutputFlag(invoke) + items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) wallets := &cobra.Command{Use: "wallets", Short: "Connect provider wallets and inspect funding methods"} walletCreate := &cobra.Command{Use: "create --provider --spec ''", Short: "Create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, @@ -154,21 +167,14 @@ JSON output preserves returned public fields but omits unknown/opaque provider d methods := &cobra.Command{Use: "payment-methods ", Short: "Fetch advertised live wallet payment methods", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, Long: "Fetch payment_methods through the item's GET expansion. The wallet must advertise this expansion.\nDisplays selectable IDs and advisory capabilities; never automatically chooses a funding method.\nJSON returns the item with expanded.payment_methods, like items get --expand payment_methods.", RunE: func(cmd *cobra.Command, args []string) error { - return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], 0, []string{"payment_methods"}, vaultOutput(cmd), false) + project, _ := cmd.Flags().GetString("project") + return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], 0, []string{"payment_methods"}, resolveProjectSelection(project), vaultOutput(cmd), false) }} addVaultJSONOutputFlag(methods) wallets.AddCommand(walletCreate, methods) - cards := &cobra.Command{Use: "cards", Short: "Configure card requests and explicitly authorize requested Link cards"} - authorize := &cobra.Command{Use: "authorize ", Short: "Invoke advertised authorization for a requested Link card", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: "Use only after explicit user approval. Retrieve the item and invoke authorize only if advertised and still requested.\nThis can obtain a payment credential but does not submit a merchant payment.\nPending/terminal authorizations are never resumed or retried by this command.\nFollow any returned approval URL, then observe with items get --wait 60.", - RunE: func(cmd *cobra.Command, args []string) error { - open, _ := cmd.Flags().GetBool("open") - return getVaultsHandler(cmd).Authorize(cmd.Context(), args[0], args[1], vaultOutput(cmd), open) - }} - authorize.Flags().Bool("open", false, "Open the returned HTTPS approval URL") - addVaultJSONOutputFlag(authorize) - cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true), authorize) + cards := &cobra.Command{Use: "cards", Short: "Configure card requests"} + cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) cmd.AddCommand(items, wallets, cards) return cmd } diff --git a/cmd/vaults_invoke_test.go b/cmd/vaults_invoke_test.go new file mode 100644 index 00000000..fa185098 --- /dev/null +++ b/cmd/vaults_invoke_test.go @@ -0,0 +1,184 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVaultInvokeUsesAdvertisedTypeAcrossItems(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + operation := `[{"type":"refresh","description":"Refresh this item explicitly."}]` + for _, fixture := range []string{ + strings.ReplaceAll(requestedCardFixture, `[{"type":"authorize","description":"Use only after explicit user approval."}]`, operation), + strings.ReplaceAll(connectedWalletFixture, `"available_operations":[]`, `"available_operations":`+operation), + strings.ReplaceAll(strings.ReplaceAll(connectedWalletFixture, `"provider":"link"`, `"provider":"agentcard"`), `"available_operations":[]`, `"available_operations":`+operation), + } { + t.Run(fixture, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + assert.Equal(t, http.MethodGet, r.Method) + } else { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vaults/checkout/items/item-1/operations", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"refresh"}`, string(body)) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + out, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "item-1", "refresh", "-o", "json") + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.JSONEq(t, fixture, out) + assert.Empty(t, human) + }) + } +} + +func TestVaultInvokeRejectsUnadvertisedOperation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "refresh") + require.ErrorContains(t, err, `operation "refresh" is not advertised`) + assert.Equal(t, 1, calls) +} + +func TestVaultInvokeGetFailureDoesNotPostOrRetry(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, status := range []int{403, 404, 429, 500} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"code":"item_unavailable","message":"Item unavailable"}`) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize") + require.ErrorContains(t, err, "item_unavailable: Item unavailable") + assert.Equal(t, 1, calls) + }) + } +} + +func TestVaultInvokeArgumentsAndHelp(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + for _, args := range [][]string{ + {"checkout", "order-1"}, + {"checkout", "order-1", ""}, + {"checkout", "order-1", "authorize", "extra"}, + {"checkout", "order-1", "authorize", "--spec", "{}"}, + } { + _, _, err := executeVaultCommand(t, client, append([]string{"vaults", "items", "invoke"}, args...)...) + require.Error(t, err) + } + cmd, _, err := newVaultsCommand().Find([]string{"items", "invoke"}) + require.NoError(t, err) + assert.Nil(t, cmd.Flags().Lookup("spec")) + assert.NotNil(t, cmd.Flags().Lookup("open")) + assert.Contains(t, cmd.Long, `{"type":"authorize"}`) + assert.Contains(t, cmd.Long, "available_operations") +} + +func TestVaultInvokeOpensOnlyReturnedActionExplicitly(t *testing.T) { + for _, open := range []bool{false, true} { + t.Run(fmt.Sprint(open), func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + body := requestedCardFixture + if r.Method == http.MethodPost { + body = strings.ReplaceAll(body, `"status":"requested"`, `"status":"pending_authorization"`) + body = strings.ReplaceAll(body, `"available_operations":`, `"action":{"name":"spend_approval","url":"https://provider.example/approve"},"available_operations":`) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + opened := "" + c := VaultsCmd{vaults: &client.Vaults, openURL: func(url string) error { opened = url; return nil }} + var err error + out := captureStdout(t, func() { + err = c.Invoke(context.Background(), "checkout", "order-1", "authorize", "json", open) + }) + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.Contains(t, out, `"status": "pending_authorization"`) + if open { + assert.Equal(t, "https://provider.example/approve", opened) + } else { + assert.Empty(t, opened) + } + }) + } +} + +func TestVaultGetOperationHintUsesExplicitProject(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "other-project") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "chosen-project", r.Header.Get("X-Kernel-Project")) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1", "--project", "chosen-project") + require.NoError(t, err) + assert.Contains(t, human, "Invoke: kernel vaults items invoke --project=chosen-project -- checkout order-1 authorize") + assert.NotContains(t, human, "other-project") +} + +func TestVaultGetOperationHints(t *testing.T) { + for _, project := range []string{"", "project-1", "team's $(touch /tmp/nope)"} { + for _, wallet := range []bool{false, true} { + t.Run(fmt.Sprint(project, wallet), func(t *testing.T) { + t.Setenv("KERNEL_PROJECT", project) + fixture := requestedCardFixture + if wallet { + fixture = strings.ReplaceAll(connectedWalletFixture, `"available_operations":[]`, `"available_operations":[{"type":"refresh","description":"Refresh this item explicitly."}]`) + } + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "item-1") + require.NoError(t, err) + op := "authorize" + if wallet { + op = "refresh" + } + assert.Contains(t, human, "Available operation: "+op) + assert.Contains(t, human, "Invoke: kernel vaults items invoke") + assert.Contains(t, human, " -- checkout item-1 "+op) + switch project { + case "": + assert.NotContains(t, human, "--project") + case "project-1": + assert.Contains(t, human, "--project=project-1") + default: + assert.Contains(t, human, `--project='team'\''s $(touch /tmp/nope)'`) + } + out, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "item-1", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, fixture, out) + assert.Empty(t, human) + }) + } + } +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index b0132385..c47a98a1 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -177,6 +177,43 @@ func vaultDisplayURL(address string) bool { return true } +type vaultItemOperation struct { + Type string `json:"type"` + Description string `json:"description"` +} + +func vaultItemOperations(item *kernel.VaultItemUnion) ([]vaultItemOperation, error) { + var fields struct { + Operations []vaultItemOperation `json:"available_operations"` + } + if err := json.Unmarshal([]byte(item.RawJSON()), &fields); err != nil { + return nil, fmt.Errorf("invalid vault item operations: %w", err) + } + return fields.Operations, nil +} + +func vaultShellArgument(value string) string { + if vaultNamePattern.MatchString(value) { + return value + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project string) error { + operations, err := vaultItemOperations(item) + if err != nil { + return err + } + prefix := "kernel vaults items invoke" + if project != "" { + prefix += " --project=" + vaultShellArgument(project) + } + for _, op := range operations { + pterm.Printf("Invoke: %s -- %s %s %s\n", prefix, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) + } + return nil +} + func printVaultItem(item *kernel.VaultItemUnion, output string) error { raw, err := filterVaultJSON(json.RawMessage(item.RawJSON()), vaultItemFields) if err != nil { @@ -242,11 +279,15 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.State.JSON.Authorization.Valid() && item.State.Authorization.ApprovalURL != "" { pterm.Printf("Approval URL:\n%s\n", item.State.Authorization.ApprovalURL) } + operations, err := vaultItemOperations(item) + if err != nil { + return err + } + for _, op := range operations { + pterm.Printf("Available operation: %s — %s\n", op.Type, op.Description) + } if item.Type == "card" { card := item.AsCard() - for _, op := range card.AvailableOperations { - pterm.Printf("Available operation: %s — %s\n", op.Type, op.Description) - } for _, expansion := range card.AvailableExpansions { pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) } diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index 2d4d6562..191c8bfb 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -174,7 +174,7 @@ func TestVaultGetCancellation(t *testing.T) { <-r.Context().Done() }) c := VaultsCmd{vaults: &client.Vaults} - err := c.GetItem(ctx, "checkout", "order-1", 60, nil, "json", false) + err := c.GetItem(ctx, "checkout", "order-1", 60, nil, "", "json", false) require.Error(t, err) assert.ErrorIs(t, err, context.Canceled) assert.Equal(t, int32(1), calls.Load()) diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 9d7cdb80..e9a895ae 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -52,7 +52,7 @@ func executeVaultCommand(t *testing.T, client kernel.Client, args ...string) (st } func TestVaultCommandConstruction(t *testing.T) { - for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "cards update", "cards authorize"} { + for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "cards update", "items invoke"} { t.Run(path, func(t *testing.T) { cmd, remaining, err := newVaultsCommand().Find(strings.Fields(path)) require.NoError(t, err) @@ -68,10 +68,10 @@ func TestVaultCommandConstruction(t *testing.T) { } }) } - cmd, _, err := rootCmd.Find([]string{"vaults", "cards", "authorize"}) + cmd, _, err := rootCmd.Find([]string{"vaults", "items", "invoke"}) require.NoError(t, err) assert.False(t, isAuthExempt(cmd)) - for _, unsupported := range []string{"rename", "update", "items put", "items action", "wallets callback", "cards pay"} { + for _, unsupported := range []string{"rename", "update", "items put", "items action", "wallets callback", "cards pay", "cards authorize"} { cmd, remaining, _ := newVaultsCommand().Find(strings.Fields(unsupported)) assert.True(t, len(remaining) > 0 || cmd.RunE == nil, unsupported) } @@ -140,7 +140,7 @@ func TestVaultCommandsWithoutProject(t *testing.T) { {[]string{"wallets", "payment-methods", "checkout", "wallet-1"}, connectedWalletFixture, 1}, {append([]string{"cards", "create", "checkout", "order-1"}, linkCardArgs()...), requestedCardFixture, 1}, {append([]string{"cards", "update", "checkout", "order-1"}, linkCardArgs()...), requestedCardFixture, 1}, - {[]string{"cards", "authorize", "checkout", "order-1"}, requestedCardFixture, 2}, + {[]string{"items", "invoke", "checkout", "order-1", "authorize"}, requestedCardFixture, 2}, } for _, tt := range tests { t.Run(strings.Join(tt.args[:min(2, len(tt.args))], " "), func(t *testing.T) { @@ -307,7 +307,7 @@ func TestVaultCardRequestMapping(t *testing.T) { } } -func TestVaultAuthorizeRequiresAdvertisedRequestedLinkCard(t *testing.T) { +func TestVaultInvokeRequiresAdvertisedOperation(t *testing.T) { t.Setenv("KERNEL_PROJECT", "project-test") for _, state := range []string{"requested", "pending_authorization", "ready", "consumed", "expired", "declined"} { for _, advertised := range []bool{false, true} { @@ -331,13 +331,13 @@ func TestVaultAuthorizeRequiresAdvertisedRequestedLinkCard(t *testing.T) { assert.JSONEq(t, `{"type":"authorize"}`, string(payload)) _, _ = io.WriteString(w, body) }) - _, _, err := executeVaultCommand(t, client, "vaults", "cards", "authorize", "checkout", "order-1", "-o", "json") + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "-o", "json") assert.Equal(t, 1, getCalls) - if state == "requested" && advertised { + if advertised { require.NoError(t, err) assert.Equal(t, 1, postCalls) } else { - require.Error(t, err) + require.ErrorContains(t, err, "not advertised in available_operations") assert.Zero(t, postCalls) } }) @@ -360,7 +360,7 @@ func TestVaultNoSDKRetriesAndAPIErrorMessages(t *testing.T) { w.WriteHeader(status) _, _ = io.WriteString(w, `{"message":"Authorization service unavailable","code":"authorization_failed"}`) }) - out, _, err := executeVaultCommand(t, client, "vaults", "cards", "authorize", "checkout", "order-1", "-o", "json") + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "-o", "json") require.Error(t, err) assert.Equal(t, 2, calls) assert.Empty(t, out) @@ -382,7 +382,7 @@ func TestVaultInvalidProjectErrors(t *testing.T) { {"wallets", "payment-methods", "checkout", "wallet-1"}, append([]string{"cards", "create", "checkout", "order-1"}, linkCardArgs()...), append([]string{"cards", "update", "checkout", "order-1"}, linkCardArgs()...), - {"cards", "authorize", "checkout", "order-1"}, + {"items", "invoke", "checkout", "order-1", "authorize"}, } for _, project := range []string{"doesntexist", "abcdefghijklmnopqrstuvwx"} { for _, args := range commands { From 5861797b2692972efd82cfba8a0d1b3ba65f51dd Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:40:23 +0000 Subject: [PATCH 10/12] Remove card test-mode fields and guidance --- README.md | 9 ++++----- cmd/vaults_commands.go | 4 ++-- cmd/vaults_help.go | 4 ---- cmd/vaults_output.go | 6 ++---- cmd/vaults_output_test.go | 21 +++++++++++++++++++-- cmd/vaults_spec_test.go | 12 +++++++----- cmd/vaults_test.go | 6 +++--- 7 files changed, 37 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index dd7d5385..3b1818c1 100644 --- a/README.md +++ b/README.md @@ -316,10 +316,10 @@ TypeScript-style types, which must stay in sync with the [API spec](https://api. - **Link wallet:** supply `authorization: {method: "oauth", client: {type: "kernel_managed"}}`. - **AgentCard wallet:** use `{}` to enroll, or supply `user_id` for an already enrolled user. -- **Link card:** include an explicit `test: true` or `test: false` and the required fields shown - in help. Optional `line_items`, `totals`, `metadata`, and `expires_at` are supported through JSON. +- **Link card:** include the required fields shown in help. Optional `line_items`, `totals`, + `metadata`, and `expires_at` are supported through JSON. - **AgentCard card:** uses `merchant`, not Link's `merchant_name`. Its optional `card_id` selects - a vaulted card; otherwise the cardholder selects one at approval. Mode is deployment-controlled. + a vaulted card; otherwise the cardholder selects one at approval. `cards update` replaces the entire spec, so omitted optional details are removed. Permitted checkout domains remain provider-assigned. Neither command submits a merchant payment. @@ -347,8 +347,7 @@ checkout domains remain provider-assigned. Neither command submits a merchant pa "currency": "usd", "merchant_name": "Example Shop", "merchant_url": "https://shop.example", - "context": "Purchase the selected office supplies from Example Shop for the approved order, with a total spending limit of 1234 minor currency units.", - "test": true + "context": "Purchase the selected office supplies from Example Shop for the approved order, with a total spending limit of 1234 minor currency units." }' ``` diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 3cff15bc..c7c270da 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -60,14 +60,14 @@ Vault names, item keys, and project ownership are immutable. 1. Create/select a vault, then create a provider wallet and follow its returned action. 2. For Link, list wallet payment methods and select an ID explicitly. -3. Create a card request with --provider and --spec JSON. Link requires an explicit test boolean. +3. Create a card request with --provider and --spec JSON. 4. Inspect items get, then use items invoke only when advertised. Follow the operation description and any returned provider action. 5. Attach the vault with browsers create --vault . Use only returned non-secret aliases in that browser. Inspect items get/events for the outcome. Permitted checkout domains are provider-assigned and displayed when returned; -there is no domain-setting API. AgentCard mode is deployment-controlled, not per item. +there is no domain-setting API. Never supply card data, OAuth codes/tokens, ciphertext, or provider secrets to the CLI. Never retry failed, timed-out, rejected, or indeterminate payments. JSON output preserves returned public fields but omits unknown/opaque provider data.`, diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index dbfd149a..989c97e0 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -25,8 +25,6 @@ type AgentCardWalletSpec = { provider: "agentcard"; user_id?: string; // usr_...; already enrolled in this organization }; - -AgentCard sandbox/live mode is deployment-controlled, not a per-item flag. ` const vaultCardSpecHelp = ` @@ -39,7 +37,6 @@ type LinkCardSpec = { merchant_name: string; // 1..255 characters merchant_url: string; // URI context: string; // at least 100 characters - test: boolean; // true = test; false = live; required line_items?: LinkLineItem[]; totals?: LinkTotal[]; metadata?: Record; @@ -73,6 +70,5 @@ type LinkTotal = { amount: number; // integer minor units }; -AgentCard sandbox/live mode is deployment-controlled; its spec has no test field. Permitted domains are provider-assigned, not configurable in the spec. ` diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index c47a98a1..fba1d132 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -40,7 +40,7 @@ var vaultItemFields = vaultOutputFields{ "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, - "context": nil, "test": nil, "expires_at": nil, + "context": nil, "expires_at": nil, "authorization": {"method": nil, "client": vaultFieldsOf("type")}, "totals": vaultTotalFields, "line_items": { @@ -241,9 +241,7 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { } rows = append(rows, []string{"Wallet key", item.Spec.Wallet}, []string{"Merchant", merchant}, []string{"Amount (minor units)", fmt.Sprintf("%d %s", item.Spec.Amount, item.Spec.Currency)}) if item.Spec.Provider == "link" { - rows = append(rows, []string{"Test", fmt.Sprint(item.Spec.Test)}, []string{"Payment method ID", item.Spec.PaymentMethodID}) - } else { - rows = append(rows, []string{"Mode", "Deployment-controlled (no per-item test mode)"}) + rows = append(rows, []string{"Payment method ID", item.Spec.PaymentMethodID}) } } if item.State.JSON.Domains.Valid() { diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index 191c8bfb..fe757e1a 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -17,7 +17,7 @@ import ( const readyCardFixture = `{ "id":"card-id","key":"order-1","type":"card", - "spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","test":true,"provider_secret":"SECRET_SPEC"}, + "spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","provider_secret":"SECRET_SPEC"}, "state":{"provider":"link","status":"ready","domains":["shop.example"],"aliases":{"number":"9999999999999999","cvc":"999","exp_month":"01","exp_year":"2099","secret":"SECRET_ALIAS"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"}, "available_operations":[],"available_expansions":[],"oauth_tokens":"SECRET_OAUTH" }` @@ -68,7 +68,7 @@ func TestVaultOutputAgentCardAuthorizationIsNotPaymentSuccess(t *testing.T) { }`), &item)) buf := capturePtermOutput(t) require.NoError(t, printVaultItem(&item, "")) - for _, text := range []string{"Deployment-controlled", "Authorization status", "declined", "expired", "Charged kind", "none", "Processor response delivered", "false"} { + for _, text := range []string{"Authorization status", "declined", "expired", "Charged kind", "none", "Processor response delivered", "false"} { assert.Contains(t, buf.String(), text) } assert.NotContains(t, buf.String(), "SECRET_RESPONSE") @@ -77,6 +77,23 @@ func TestVaultOutputAgentCardAuthorizationIsNotPaymentSuccess(t *testing.T) { assert.NotContains(t, out, "SECRET_RESPONSE") } +func TestVaultOutputOmitsCardTestMode(t *testing.T) { + for _, provider := range []string{"link", "agentcard"} { + t.Run(provider, func(t *testing.T) { + body := strings.ReplaceAll(requestedCardFixture, `"provider":"link"`, `"provider":"`+provider+`"`) + body = strings.ReplaceAll(body, `"amount":1234`, `"amount":1234,"test":true`) + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(body), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + assert.NotContains(t, buf.String(), "Test") + assert.NotContains(t, buf.String(), "Mode") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, `"test"`) + }) + } +} + func TestVaultOutputPaymentMethodsAdvisoryUnknownVsFalse(t *testing.T) { body := strings.TrimSuffix(connectedWalletFixture, "}") + `,"expanded":{"payment_methods":[ {"id":"pm-unknown","provider":"link","type":"card","is_default":true,"display":{"label":"Personal","brand":"visa","last4":"1234"},"capabilities":{},"provider_secret":"SECRET_METHOD"}, diff --git a/cmd/vaults_spec_test.go b/cmd/vaults_spec_test.go index ee43f08e..b20f2078 100644 --- a/cmd/vaults_spec_test.go +++ b/cmd/vaults_spec_test.go @@ -17,7 +17,7 @@ func TestVaultRawSpecForwarding(t *testing.T) { for _, provider := range []string{"link", "agentcard"} { for _, raw := range []string{ `{}`, - `{"test":false,"amount":0,"currency":"USD","metadata":null}`, + `{"custom_option":false,"amount":0,"currency":"USD","metadata":null}`, `{"expires_at":9223372036854775807,"line_items":[{"name":"Item","quantity":2,"unit_amount":100,"totals":[{"type":"tax","display_text":"Tax","amount":10}]}],"totals":[],"metadata":{"reference":"order-1"}}`, } { t.Run(path+"/"+provider+"/"+raw, func(t *testing.T) { @@ -62,13 +62,13 @@ func TestVaultRawSpecValidationIsLeftToAPI(t *testing.T) { Spec map[string]json.RawMessage `json:"spec"` } require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.NotContains(t, body.Spec, "test", "an omitted test field must not become a live request") + assert.NotContains(t, body.Spec, "wallet") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _, _ = io.WriteString(w, `{"code":"invalid_request","message":"test is required"}`) + _, _ = io.WriteString(w, `{"code":"invalid_request","message":"wallet is required"}`) }) _, _, err := executeVaultCommand(t, client, "vaults", "cards", "create", "checkout", "order-1", "--provider", "link", "--spec", "{}") - require.ErrorContains(t, err, "invalid_request: test is required") + require.ErrorContains(t, err, "invalid_request: wallet is required") } func TestVaultSpecHelpAndFlags(t *testing.T) { @@ -86,8 +86,10 @@ func TestVaultSpecHelpAndFlags(t *testing.T) { assert.Contains(t, cmd.Long, "provider: \"link\"") assert.Contains(t, cmd.Long, "provider: \"agentcard\"") assert.Contains(t, cmd.Example, "--spec '") + assert.NotContains(t, cmd.Long, "test: boolean") + assert.NotContains(t, cmd.Long, "sandbox/live") if strings.HasPrefix(path, "cards") { - for _, field := range []string{"test: boolean", "merchant_name:", "merchant:", "line_items?:", "metadata?:", "expires_at?:", "type LinkLineItem", "type LinkTotal"} { + for _, field := range []string{"merchant_name:", "merchant:", "line_items?:", "metadata?:", "expires_at?:", "type LinkLineItem", "type LinkTotal"} { assert.Contains(t, cmd.Long, field) } } else { diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index e9a895ae..67afa4d1 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -22,7 +22,7 @@ import ( const linkWalletSpecFixture = `{"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}` const vaultFixture = `{"id":"vault-1","name":"checkout","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` -const requestedCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","context":"Purchase description","test":true},"state":{"provider":"link","status":"requested"},"available_operations":[{"type":"authorize","description":"Use only after explicit user approval."}],"available_expansions":[]}` +const requestedCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","context":"Purchase description"},"state":{"provider":"link","status":"requested"},"available_operations":[{"type":"authorize","description":"Use only after explicit user approval."}],"available_expansions":[]}` const connectedWalletFixture = `{"id":"wallet-id","key":"wallet-1","type":"wallet","spec":{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}},"state":{"provider":"link","status":"connected"},"available_operations":[],"available_expansions":[{"type":"payment_methods","description":"Select a payment method explicitly."}]}` func vaultTestClient(t *testing.T, handler http.HandlerFunc) kernel.Client { @@ -165,7 +165,7 @@ func TestVaultCommandsWithoutProject(t *testing.T) { } func linkCardArgs() []string { - return []string{"--provider", "link", "--spec", fmt.Sprintf(`{"wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","payment_method_id":"pm-1","merchant_url":"https://shop.example","context":%q,"test":true}`, strings.Repeat("Purchase purpose. ", 7))} + return []string{"--provider", "link", "--spec", fmt.Sprintf(`{"wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","payment_method_id":"pm-1","merchant_url":"https://shop.example","context":%q}`, strings.Repeat("Purchase purpose. ", 7))} } func TestVaultSpecValidation(t *testing.T) { @@ -285,7 +285,7 @@ func TestVaultCardRequestMapping(t *testing.T) { assert.Len(t, body, 1) } if provider == "link" { - assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","merchant_url":"https://shop.example","payment_method_id":"pm-1","context":%q,"test":true}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) + assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","merchant_url":"https://shop.example","payment_method_id":"pm-1","context":%q}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) } else { assert.JSONEq(t, `{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen"}`, string(body["spec"])) } From ed60dbb02951a7d66408c94b70daa0fb98203833 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:45:01 +0000 Subject: [PATCH 11/12] Use published Go SDK v0.100.0 --- .github/workflows/test.yaml | 18 ++---------------- DEVELOPMENT.md | 21 ++++----------------- go.mod | 4 +--- go.sum | 4 ++-- 4 files changed, 9 insertions(+), 38 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1d43d529..5f10d664 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,21 +21,7 @@ jobs: uses: actions/setup-go@v6 with: go-version-file: "go.mod" - cache: false - - - name: Create read-only token for the preview SDK - id: sdk-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.ADMIN_APP_ID }} - private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} - repositories: kernel-go-sdk-staging - permission-contents: read + cache: true - name: Run tests - env: - GOPRIVATE: github.com/kernel/kernel-go-sdk-staging - GH_TOKEN: ${{ steps.sdk-token.outputs.token }} - run: | - gh auth setup-git - make test + run: make test diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f5a24aa1..0dc5aaae 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -77,24 +77,11 @@ selected project in copyable invocation hints and shell-quote arguments safely. - Action and approval URLs must remain outside the terminal-width-limited tables. Check complete URLs at narrow terminal widths as well as in piped and JSON output. -#### Preview SDK dependency +#### SDK dependency -The vault implementation currently uses the `go.mod` replacement for -`github.com/kernel/kernel-go-sdk-staging` at -`v0.86.1-0.20260904020633-50f33b1b5cf6`. Fetching it requires GitHub access to that repository -and `GOPRIVATE=github.com/kernel/kernel-go-sdk-staging`. The test workflow temporarily -obtains a repository-scoped read token and disables Go caching; fork CI cannot access it. - -A cached local build does not prove the preview is fetchable. If a fresh build reports -`unknown revision`, resolve the dependency before merging or releasing. - -Before releasing the vault commands: - -1. Upgrade to the canonical released SDK containing the required vault APIs and remove the - replacement with `go mod edit -dropreplace=github.com/kernel/kernel-go-sdk`. -2. Run `go mod tidy` and address any generated SDK interface changes. -3. Remove the preview-token/GOPRIVATE setup from `.github/workflows/test.yaml` and restore caching. -4. Verify a fresh dependency fetch, tests, build, and lint against the released SDK. +Vault commands use the published `github.com/kernel/kernel-go-sdk` v0.100.0 dependency. +No preview replacement, private SDK access, or special CI authentication is required. +When upgrading the SDK, run `go mod tidy`, tests, build, and lint against the released module. ### Releasing a new version diff --git a/go.mod b/go.mod index 2582dc5f..c00426c4 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.95.0 + github.com/kernel/kernel-go-sdk v0.100.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 @@ -60,5 +60,3 @@ require ( golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/kernel/kernel-go-sdk => github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260904020633-50f33b1b5cf6 diff --git a/go.sum b/go.sum index 443e272d..84a83f5a 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260904020633-50f33b1b5cf6 h1:HJ7uNHl0niVt6StzclpoVb6L43ZbCZbKebItLBfYaa0= -github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260904020633-50f33b1b5cf6/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.0 h1:8RKNKk0js3BHdwEkDd0aPX17OEGSHVrI26akNUwttzU= +github.com/kernel/kernel-go-sdk v0.100.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From cbacedce3d2db0c57d34c299c016e3ec4b8f323b Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:49:08 +0000 Subject: [PATCH 12/12] Keep vault feature guidance out of development docs --- DEVELOPMENT.md | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0dc5aaae..a71b0813 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -56,33 +56,6 @@ A typical workflow we encounter is updating the API and integrating those change ./scripts/go-mod-replace-kernel.sh ``` -### Maintaining the vault commands - -Wallet creation and card creation/update accept `--provider` and raw `--spec` JSON. -Provider-specific fields are validated by the API, not duplicated as CLI flags or local -schema validators. Preserve JSON values, including large integers, explicit `false`, nulls, -and omitted fields; do not introduce defaults while converting requests for the SDK. - -Item operations use `items invoke `. Gate invocations on the -server's `available_operations`, not local provider/type/state rules or an operation registry. -The current operation schema only accepts `{"type":"authorize"}` with no extra fields; -add operation parameters only when the API supports them. GET output should retain the -selected project in copyable invocation hints and shell-quote arguments safely. - -- Keep the types in `cmd/vaults_help.go` aligned with the - [published API spec](https://api.onkernel.com/spec.yaml), including nested optional types. -- Update the README examples and payment-method selection hints when changing this interface. -- Run `make test`, `make build`, and `go test -race ./cmd -run TestVault -count=1`. - Use local HTTP fixtures for request/output tests; do not create live payment credentials. -- Action and approval URLs must remain outside the terminal-width-limited tables. Check - complete URLs at narrow terminal widths as well as in piped and JSON output. - -#### SDK dependency - -Vault commands use the published `github.com/kernel/kernel-go-sdk` v0.100.0 dependency. -No preview replacement, private SDK access, or special CI authentication is required. -When upgrading the SDK, run `go mod tidy`, tests, build, and lint against the released module. - ### Releasing a new version Releases are automated via GitHub Actions. Simply push a version tag and the release workflow will handle the rest.