From 322acab598f50749aeff560ca3510aea3e648bf2 Mon Sep 17 00:00:00 2001 From: hiroTamada <88675973+hiroTamada@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:00:06 +0000 Subject: [PATCH 1/4] Support Kernel-managed vault wallets and cards --- README.md | 60 +++++++++-- cmd/vaults_commands.go | 62 ++++++++---- cmd/vaults_help.go | 26 ++++- cmd/vaults_kernel_payment_test.go | 161 ++++++++++++++++++++++++++++++ cmd/vaults_output.go | 32 ++++-- cmd/vaults_payment_help_test.go | 8 +- cmd/vaults_secrets.go | 24 +++++ cmd/vaults_wallet_spec.go | 9 +- go.mod | 2 +- go.sum | 4 +- 10 files changed, 346 insertions(+), 42 deletions(-) create mode 100644 cmd/vaults_kernel_payment_test.go diff --git a/README.md b/README.md index eb863c84..41141d33 100644 --- a/README.md +++ b/README.md @@ -397,9 +397,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 --spec ''` | Connect/enroll a wallet using its provider's spec; `--open` opens a returned HTTPS action URL | +| `kernel vaults wallets create --provider link\|agentcard\|kernel --spec ''` | Connect/enroll a wallet using its provider's spec; `--open` opens a returned HTTPS action URL | +| `kernel vaults wallets get ` | Observe wallet state and its hosted action; `--wait 0..60`, `--open` | | `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 create --provider link\|agentcard\|kernel --spec ''` | Create a card request without authorizing it | | `kernel vaults cards update --provider link\|agentcard --spec ''` | Update a card spec; pending issuance preserves omitted optional fields, and the API enforces state/provider constraints | | `kernel vaults items list ` | List item keys, types, providers, status, and required actions | | `kernel vaults items get ` | Inspect state/actions/returned AgentCard aliases and copyable operation commands; `--wait 0..60`, `--expand payment_methods`, `--open` | @@ -414,7 +415,8 @@ its generated item ID. Names and keys use letters, digits, dots, underscores, an JSON preserves field presence and API-returned AgentCard 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. -Link cards do not expose aliases or support egress substitution; browser checkout uses only `fill`. +Link and Kernel cards do not expose aliases or support egress substitution; browser checkout +uses only advertised `fill`. Action and approval URLs print in full on separate lines, without table truncation. Most API failures use the CLI's standard error formatter. Wallet creation and provider config commands withhold response/transport details to prevent credential echoes; HTTP status remains visible. @@ -425,23 +427,29 @@ Other API errors still return a nonzero exit status. **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 +Other non-secret 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). +- **Kernel wallet:** use `{}`; no provider configuration or token file is accepted. The hosted + `card_enrollment` action collects card details from the cardholder, never from the CLI. - **Link wallet:** supply `authorization: {method: "oauth", client: {type: "kernel_managed"}}`. - **AgentCard wallet:** use `{}` to enroll, or supply `user_id` for a user enrolled in the same organization and provider configuration. +- **Kernel card:** supply `wallet`, `amount` (minor units, at most 50000), `currency`, + `merchant_name`, and HTTPS `merchant_url`. Supply two-letter `merchant_country` for Visa. + Kernel card updates are unsupported. - **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. -`cards update` replaces the spec for requested cards. Pending issuance updates preserve omitted -optional fields; explicit empty lists clear them. Wallet/provider bindings and unsupported fields +`cards update` replaces the spec for supported providers' requested cards; Kernel cards +cannot be updated. Pending issuance updates preserve omitted optional fields; explicit empty lists clear them. Wallet/provider bindings and unsupported fields cannot change after authorization starts. Checkout cards can be edited between authorizations. An uncertain update enters `recovery_required` and must not be retried. Identical card creation -returns its existing state without polling, reauthorizing, or resetting recovery. Permitted -checkout domains remain provider-assigned. Neither command submits a merchant payment. +returns its existing state without polling, reauthorizing, or resetting recovery. Link and +AgentCard checkout domains remain provider-assigned; Kernel fill is locked to the exact origin +of `merchant_url`. Neither command submits a merchant payment. #### Provider configurations and imported grants @@ -507,6 +515,40 @@ Do not retry, delete, or replace the original operation. Reconcile with the prov there is no reset or caller-asserted reconciliation endpoint. Unresolved child cards can block wallet and vault deletion. Time passing or deletion is not evidence of non-execution. +#### Kernel-managed card checkout + +```bash +kernel vaults create --name checkout +kernel vaults wallets create checkout cardholder --provider kernel --spec '{}' --open +kernel vaults wallets get checkout cardholder --wait 60 +kernel vaults wallets payment-methods checkout cardholder +``` + +Share the returned `card_enrollment` URL with the cardholder. A connected wallet may still +report `single_use_card.eligible: false` with a `network_token_*` reason while network token +enrollment is pending or unsupported. Inspect the expansion before requesting a card; this +wallet uses its enrolled card, not a `payment_method_id` in the card spec. + +```bash +kernel vaults cards create checkout order-1 --provider kernel --spec '{ + "wallet":"cardholder", "amount":1200, "currency":"usd", + "merchant_name":"Example Shop", "merchant_url":"https://shop.example/checkout", + "merchant_country":"US" +}' +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 +``` + +Only invoke `authorize` when advertised. Visa may return a `spend_approval` URL for the +cardholder; Mastercard may become ready without one. An unresolved `recovery_required` item +must not be retried, deleted, or replaced. When ready, create a browser with `--vault checkout` +and invoke the advertised `fill` operation with the browser ID, an HTTPS `page_url` on the +exact origin of `merchant_url`, and the checkout field selectors (see `items invoke --help`). +Fill types the one-time network token and code but does not submit the merchant form. +Submit before `expires_at`; neither ready nor fill proves that the merchant charged the card. +Never put PAN, CVC, or enrollment credentials in CLI arguments or logs. + #### Link checkout preparation Link OAuth, user approval, and provider-issued single-use card issuance precede browser checkout. diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 43c99312..df62d3aa 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -83,18 +83,22 @@ 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. + For Kernel, share the hosted card_enrollment URL; card details stay on that page. +2. Inspect wallets payment-methods for eligibility and reasons before creating a card. + For Link, select a payment method ID explicitly. 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 ; attachment is required for fill. - Ready Link cards use only advertised fill with --params for browser checkout. - Link cards do not expose aliases or support egress substitution. + Ready Link and Kernel cards use only advertised fill with --params for browser checkout. + Neither exposes aliases or supports egress substitution. Kernel fill is locked to + merchant_url's origin; submit the merchant checkout before the card expires. AgentCard-only checkout aliases support egress substitution with checkout hold, approval, and replay; they are not a fallback after fill. - Inspect items get/events for payment outcomes. + Inspect items get/events for vault outcomes; Kernel does not observe merchant charges. -Permitted checkout domains are provider-assigned and displayed when returned; +Permitted checkout domains are provider-assigned and displayed when returned; Kernel +card fill instead uses the exact origin of merchant_url. there is no domain-setting API. Never supply card data, OAuth codes, ciphertext, or secrets in shell arguments. Use vault-provider-configs for client credentials and wallets create --tokens-file @@ -175,8 +179,9 @@ exp_year (YYYY), billing_name, billing_line1, billing_line2, billing_city, billing_state, billing_postal_code, billing_country. expiration requires format MM/YY or MM/YYYY. Optional timeout_ms is 1-30000 (default 10000). The API searches the page and descendant frames, including payment iframes. -Fill is available for credential items and ready Link cards when advertised, not AgentCard. -Link cards do not expose aliases or support egress substitution. +Fill is available for credential items and ready Link or Kernel cards when advertised, not AgentCard. +Link and Kernel cards do not expose aliases or support egress substitution. Kernel card fill +is locked to merchant_url's origin and its one-time code expires; submit checkout before expiry. Fill writes real values into the browser; unrestricted browser/CDP access can read them. Fill never explicitly submits forms or clicks buttons, but input/change events may trigger site behavior. completed means fields were filled, not website acceptance, login, or payment success. @@ -237,6 +242,7 @@ JSON kernel vaults items invoke user-vault login webmcp_invoke --spec-file - <<'JSON' {"browser_id":"","tool_ref":"","page_url":"https://example.com/login","input":{"email":null,"password":null},"bindings":[{"field":"email","input_path":"/email"},{"field":"password","input_path":"/password"}]} JSON + kernel vaults items invoke checkout order-1 authorize --open kernel vaults items invoke user-vault github 1pw_create_access_request --params '{"browser_id":"","reason":"Sign in to GitHub"}' kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"browser_id":"","timeout_seconds":60}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login"}' @@ -273,7 +279,7 @@ JSON items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultWebMCPCommand(), 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, + 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 '{ @@ -284,7 +290,9 @@ JSON }' --open kernel vaults wallets create checkout wallet-1 \ - --provider agentcard --spec '{}'`, + --provider agentcard --spec '{}' + + kernel vaults wallets create checkout cardholder --provider kernel --spec '{}' --open`, RunE: func(cmd *cobra.Command, args []string) error { spec, err := vaultWalletSpecFromFlags(cmd) if err != nil { @@ -307,7 +315,17 @@ JSON 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) + walletGet := &cobra.Command{Use: "get ", Short: "Get a wallet's state and hosted enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + project, _ := cmd.Flags().GetString("project") + wait, _ := cmd.Flags().GetInt64("wait") + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], wait, nil, resolveProjectSelection(project), vaultOutput(cmd), open) + }} + walletGet.Flags().Int64("wait", 0, "Hold while pending for up to this many seconds (0-60); observe only") + walletGet.Flags().Bool("open", false, "Open a returned HTTPS enrollment URL") + addVaultJSONOutputFlag(walletGet) + wallets.AddCommand(walletCreate, walletGet, methods) cards := &cobra.Command{Use: "cards", Short: "Configure card requests"} cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) @@ -338,10 +356,14 @@ func newVaultCardCommand(update bool) *cobra.Command { if update { use, short = "update", "Update a card spec when the API permits configuration" } - 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. -Requested cards accept a replacement spec. Pending issuance updates preserve omitted -optional fields; explicit empty lists clear them. The API restricts fields after + providers := "link|agentcard|kernel" + if update { + providers = "link|agentcard" + } + cmd := &cobra.Command{Use: use + " --provider <" + providers + "> --spec ''", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: short + `. Neither create nor update authorizes a card. +Kernel cards cannot be updated. Other requested cards accept a replacement spec. +Pending issuance updates preserve omitted optional fields; explicit empty lists clear them. The API restricts fields after authorization starts; wallet/provider bindings cannot change. An uncertain update enters recovery_required and must not be retried. Checkout cards can be edited between authorizations. Identical creates return existing state without resetting it. @@ -360,6 +382,9 @@ A recovery item that permits abandonment must be deleted after explicit user con if err != nil { return err } + if update && string(spec["provider"]) == `"kernel"` { + return fmt.Errorf("Kernel card updates are not supported; create a new card item for a new purchase") + } return getVaultsHandler(cmd).SaveCard(cmd.Context(), args[0], args[1], param.Override[kernel.CardVaultItemSpecUnionParam](spec), update, vaultOutput(cmd)) }} addVaultSpecFlags(cmd) @@ -368,7 +393,7 @@ A recovery item that permits abandonment must be deleted after explicit user con } func addVaultSpecFlags(cmd *cobra.Command) { - cmd.Flags().String("provider", "", "Provider: link or agentcard (required)") + cmd.Flags().String("provider", "", "Provider: link, agentcard, or kernel (required)") cmd.Flags().String("spec", "", "Raw JSON specification object (required); see types and examples above") _ = cmd.MarkFlagRequired("provider") _ = cmd.MarkFlagRequired("spec") @@ -376,8 +401,8 @@ func addVaultSpecFlags(cmd *cobra.Command) { func vaultSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { provider, _ := cmd.Flags().GetString("provider") - if provider != "link" && provider != "agentcard" { - return nil, fmt.Errorf("--provider must be link or agentcard") + if provider != "link" && provider != "agentcard" && provider != "kernel" { + return nil, fmt.Errorf("--provider must be link, agentcard, or kernel") } raw, _ := cmd.Flags().GetString("spec") var spec map[string]json.RawMessage @@ -393,6 +418,9 @@ func vaultSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) if vaultSpecHasSecrets(json.RawMessage(raw)) { return nil, fmt.Errorf("--spec must not contain credentials or tokens; use the dedicated file/stdin inputs") } + if provider == "kernel" && vaultSpecHasCardData(json.RawMessage(raw)) { + return nil, fmt.Errorf("--spec must not contain card details; use hosted enrollment") + } spec["provider"], _ = json.Marshal(provider) return spec, nil } diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index ea948f44..417be09c 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -13,7 +13,12 @@ or normalization. Never include card data, OAuth tokens, or provider secrets in ` const vaultWalletSpecHelp = ` -Omit config selection to preserve Kernel-managed defaults. +For --provider kernel, use --spec '{}'. Share the returned card_enrollment URL +with the cardholder; never enter card details in the CLI. Use wallets get --wait 60 +to observe connection, then wallets payment-methods to inspect token eligibility. +A connected wallet may still be ineligible while network token enrollment runs. +Kernel wallets accept neither provider configuration flags nor --tokens-file. +For Link and AgentCard, omit config selection to preserve managed defaults. --provider-config-id and --provider-config-name are mutually exclusive. For Link with selection flags, use --spec '{}' and --tokens-file . Alternatively, set the customer_managed client and provider_config in --spec, @@ -31,6 +36,8 @@ an uncertain payment through the new wallet. There is no in-place reauthorizatio type ProviderConfigReference = { id: string } | { name: string }; +type KernelWalletSpec = { provider: "kernel" }; + type LinkWalletSpec = { provider: "link"; authorization: { @@ -48,6 +55,16 @@ type AgentCardWalletSpec = { ` const vaultCardSpecHelp = ` +type KernelCardSpec = { + provider: "kernel"; + wallet: string; // enrolled Kernel wallet item key + amount: number; // integer minor units; 1..50000 + currency: string; // ISO 4217 three-letter code + merchant_name: string; // 1..255 characters + merchant_url: string; // HTTPS checkout URL; fill locked to its exact origin + merchant_country?: string; // ISO 3166-1 alpha-2; required for Visa +}; + type LinkCardSpec = { provider: "link"; wallet: string; // wallet item key @@ -91,6 +108,13 @@ type LinkTotal = { amount: number; // integer minor units }; +Kernel cards cannot be updated. Create a new card item for a new purchase, but +never retry an uncertain authorization or replace an item in recovery_required. +Invoke authorize only when advertised. For Visa, present the returned +spend_approval URL to the cardholder and poll items get --wait 60 until ready. +Mastercard may become ready without hosted approval. Neither ready nor fill +confirms merchant payment. Fill and submit before expires_at; never pass PAN to CLI. + Card updates replace the whole spec, so omitting checkout_origin from an update removes its existing value. For non-prepared authorization, checkout_origin is forwarded to AgentCard for eligible autopilot rule matching. Use a canonical HTTPS origin (lowercase diff --git a/cmd/vaults_kernel_payment_test.go b/cmd/vaults_kernel_payment_test.go new file mode 100644 index 00000000..f60e354b --- /dev/null +++ b/cmd/vaults_kernel_payment_test.go @@ -0,0 +1,161 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const kernelWalletFixture = `{"id":"wallet-id","key":"cardholder","type":"wallet","spec":{"provider":"kernel"},"state":{"provider":"kernel","status":"pending_authorization"},"action":{"name":"card_enrollment","url":"https://vault.example/enroll?state=opaque"},"available_operations":[],"available_expansions":[{"type":"payment_methods","description":"Check token eligibility"}],"pan":"4111111111111111"}` +const kernelCardFixture = `{"id":"card-id","key":"order-1","type":"card","spec":{"provider":"kernel","wallet":"cardholder","amount":1200,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"},"state":{"provider":"kernel","status":"requested","masks":{"brand":"visa","last4":"1234","token_last4":"5678","pan":"4111111111111111"}},"available_operations":[{"type":"authorize","description":"Authorize this purchase"}],"available_expansions":[]}` + +func TestKernelWalletCreateAndGet(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/vaults/checkout/items/cardholder", r.URL.Path) + if r.Method == http.MethodPut { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"wallet","spec":{"provider":"kernel"}}`, string(body)) + } else { + assert.Equal(t, http.MethodGet, r.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, kernelWalletFixture) + }) + for _, args := range [][]string{ + {"wallets", "create", "checkout", "cardholder", "--provider", "kernel", "--spec", "{}"}, + {"wallets", "get", "checkout", "cardholder", "--wait", "0"}, + } { + out, human, err := executeVaultCommand(t, client, append([]string{"vaults"}, args...)...) + require.NoError(t, err) + assert.Contains(t, human, "https://vault.example/enroll?state=opaque") + assert.Contains(t, human, "card_enrollment") + assert.NotContains(t, out+human, "4111111111111111") + jsonArgs := append(append([]string{"vaults"}, args...), "-o", "json") + out, _, err = executeVaultCommand(t, client, jsonArgs...) + require.NoError(t, err) + assert.Contains(t, out, `"card_enrollment"`) + assert.NotContains(t, out, "4111111111111111") + } +} + +func TestKernelWalletRejectsExternalCredentials(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("invalid wallet reached API") }) + for _, flag := range [][]string{{"--tokens-file", "-"}, {"--provider-config-id", "config-1"}, {"--provider-config-name", "config-1"}} { + args := append([]string{"vaults", "wallets", "create", "checkout", "cardholder", "--provider", "kernel", "--spec", "{}"}, flag...) + _, _, err := executeVaultCommand(t, client, args...) + require.ErrorContains(t, err, "Kernel wallets do not use provider configurations or tokens") + } +} + +func TestKernelSpecRejectsCardDetails(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("card details reached API") }) + for _, spec := range []string{ + `{"pan":"4111111111111111"}`, + `{"metadata":{"card_number":"4111111111111111"}}`, + `{"cards":[{"cvc":"123"}]}`, + } { + for _, path := range [][]string{{"wallets", "create"}, {"cards", "create"}} { + args := append([]string{"vaults"}, path...) + args = append(args, "checkout", "item-1", "--provider", "kernel", "--spec", spec) + _, _, err := executeVaultCommand(t, client, args...) + require.ErrorContains(t, err, "card details") + assert.NotContains(t, err.Error(), "4111111111111111") + } + } +} + +func TestKernelPaymentMethodsEligibility(t *testing.T) { + wallet := strings.TrimSuffix(kernelWalletFixture, "}") + `,"expanded":{"payment_methods":[{"id":"card-1","provider":"kernel","type":"card","display":{"brand":"visa","last4":"1234","pan":"4111111111111111"},"capabilities":{"single_use_card":{"eligible":false,"reasons":["network_token_pending"]}}}]}}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "payment_methods", r.URL.Query().Get("expand")) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, wallet) + }) + out, human, err := executeVaultCommand(t, client, "vaults", "wallets", "payment-methods", "checkout", "cardholder") + require.NoError(t, err) + assert.Contains(t, human, "network_token_pending") + assert.Contains(t, human, "false") + assert.NotContains(t, human+out, "4111111111111111") + out, _, err = executeVaultCommand(t, client, "vaults", "wallets", "payment-methods", "checkout", "cardholder", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"network_token_pending"`) + assert.NotContains(t, out, "4111111111111111") +} + +func TestKernelCardCreateAuthorizeAndApproval(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + response := kernelCardFixture + switch calls { + case 1: + assert.Equal(t, "/vaults/checkout/items/order-1", r.URL.Path) + assert.Equal(t, http.MethodPut, r.Method) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"card","spec":{"provider":"kernel","wallet":"cardholder","amount":1200,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"}}`, string(body)) + case 2: + assert.Equal(t, "/vaults/checkout/items/order-1", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + case 3: + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vaults/checkout/items/order-1/operations", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"authorize"}`, string(body)) + response = strings.TrimSuffix(kernelCardFixture, "}") + `,"action":{"name":"spend_approval","url":"https://vault.example/approve?state=opaque"}}` + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, response) + }) + spec := `{"wallet":"cardholder","amount":1200,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"}` + out, human, err := executeVaultCommand(t, client, "vaults", "cards", "create", "checkout", "order-1", "--provider", "kernel", "--spec", spec, "-o", "json") + require.NoError(t, err) + var created map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &created)) + assert.Equal(t, "US", created["spec"].(map[string]any)["merchant_country"]) + assert.Contains(t, out, `"token_last4": "5678"`) + assert.NotContains(t, out+human, "4111111111111111") + assert.Equal(t, 1, calls, "creation must not authorize") + out, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"spend_approval"`) + assert.Contains(t, out, "https://vault.example/approve?state=opaque") + assert.NotContains(t, out, "4111111111111111") + assert.Equal(t, 3, calls) +} + +func TestKernelReadyCardUsesAdvertisedFill(t *testing.T) { + ready := strings.Replace(kernelCardFixture, `"status":"requested"`, `"status":"ready"`, 1) + ready = strings.Replace(ready, `"type":"authorize","description":"Authorize this purchase"`, `"type":"fill","description":"Fill checkout fields"`, 1) + 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, ready) + return + } + assert.Equal(t, http.MethodPost, r.Method) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"fill","browser_id":"browser-1","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"}]}`, string(body)) + _, _ = io.WriteString(w, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"}]}`) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", `{"browser_id":"browser-1","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"}]}`, "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"completed"`) + assert.Equal(t, 2, calls) +} + +func TestKernelCardUpdateRejectedLocally(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("Kernel update reached API") }) + _, _, err := executeVaultCommand(t, client, "vaults", "cards", "update", "checkout", "order-1", "--provider", "kernel", "--spec", `{}`) + require.ErrorContains(t, err, "Kernel card updates are not supported") +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index aa5ecff8..2eb5ce91 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -44,7 +44,7 @@ var vaultItemFields = vaultOutputFields{ "expanded": {"payment_methods": vaultMethodFields}, "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "checkout_origin": nil, - "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, + "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "merchant_country": nil, "context": nil, "expires_at": nil, "description": nil, "account": nil, "requests": onePasswordRequestFields, "fields": vaultFieldsOf("name label type required sensitive"), @@ -63,7 +63,7 @@ var vaultItemFields = vaultOutputFields{ "request": onePasswordRequestFields, "entries": onePasswordRequestEntryFields, }, "fields": {"*": vaultFieldsOf("has_value")}, - "masks": vaultFieldsOf("brand last4"), + "masks": vaultFieldsOf("brand last4 token_last4"), "aliases": vaultFieldsOf("number cvc exp_month exp_year"), "preparation": vaultFieldsOf("id status browser_id merchant_origin environment psp created_at expires_at approval_url"), "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"), @@ -368,10 +368,16 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.Spec.Provider == "link" { rows = append(rows, []string{"Payment method ID", item.Spec.PaymentMethodID}) } + if item.Spec.Provider == "kernel" { + rows = append(rows, []string{"Merchant URL", item.Spec.MerchantURL}) + } if item.Spec.Provider == "agentcard" && item.Spec.CheckoutOrigin != "" { rows = append(rows, []string{"Checkout origin", item.Spec.CheckoutOrigin}) } } + if item.Type == "card" && item.Spec.Provider == "kernel" && item.State.JSON.Masks.Valid() { + rows = append(rows, []string{"Enrolled card last4", item.State.Masks.Last4}, []string{"Network token last4", item.State.Masks.TokenLast4}) + } if item.State.JSON.Domains.Valid() { rows = append(rows, []string{"Permitted domains (provider-assigned)", strings.Join(item.State.Domains, ", ")}) } @@ -456,6 +462,9 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction return } if item.Type == "card" { + if item.Spec.Provider == "kernel" { + pterm.Info.Println("Visa may require the returned spend_approval action; observe readiness with items get --wait 60. Ready means a one-time code is available, not that the merchant charged the card. Fill and submit checkout before expires_at; do not retry uncertain authorizations.") + } if item.State.JSON.Preparation.Valid() { switch item.State.Status { case "preparing": @@ -472,22 +481,29 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction 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. Never retry automatically; if recovery permits abandonment, delete the card only after explicit user confirmation before creating a replacement.") + if item.Spec.Provider == "kernel" { + pterm.Info.Println("Kernel does not observe merchant charges; confirm the checkout outcome independently. Never retry an uncertain authorization or replace an item in recovery_required.") + } else { + pterm.Info.Println("Inspect items events for payment outcomes. Never retry automatically; if recovery permits abandonment, delete the card only after explicit user confirmation before creating a replacement.") + } } else { wallet := item.AsWallet() + if item.Spec.Provider == "kernel" { + pterm.Info.Println("Share the hosted card_enrollment URL with the cardholder. Connected does not imply token eligibility; inspect wallets payment-methods for eligible and reasons before requesting a card.") + } 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) + printVaultPaymentMethods(item.Spec.Provider, item.Expanded.PaymentMethods) } if actions.RequiredAction != "" { 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.") } } -func printVaultPaymentMethods(methods []kernel.VaultPaymentMethod) { +func printVaultPaymentMethods(provider string, methods []kernel.VaultPaymentMethod) { if len(methods) == 0 { pterm.Info.Println("No payment methods returned") return @@ -502,7 +518,11 @@ 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 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.") + if provider == "kernel" { + pterm.Info.Println("Kernel cards use the wallet key, not a payment method ID. Wait for single_use_card.eligible before requesting a card; reasons explain ineligibility. Missing capability means unknown, not eligible.") + } else { + 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_payment_help_test.go b/cmd/vaults_payment_help_test.go index cb7978a9..95fd518e 100644 --- a/cmd/vaults_payment_help_test.go +++ b/cmd/vaults_payment_help_test.go @@ -18,8 +18,8 @@ func TestVaultPaymentHelpProviderBoundaries(t *testing.T) { name: "vaults", want: []string{ "attachment is required for fill", - "Ready Link cards use only advertised fill", - "Link cards do not expose aliases or support egress substitution", + "Ready Link and Kernel cards use only advertised fill", + "Neither exposes aliases or supports egress substitution", "AgentCard-only checkout aliases support egress substitution with checkout hold, approval, and replay", "not a fallback after fill", }, @@ -34,8 +34,8 @@ func TestVaultPaymentHelpProviderBoundaries(t *testing.T) { path: []string{"items", "invoke"}, want: []string{ "The vault must already be attached to the browser", - "Fill is available for credential items and ready Link cards when advertised, not AgentCard", - "Link cards do not expose aliases or support egress substitution", + "Fill is available for credential items and ready Link or Kernel cards when advertised, not AgentCard", + "Link and Kernel cards do not expose aliases or support egress substitution", "Fill writes real values into the browser; unrestricted browser/CDP access can read them", "Fill never explicitly submits forms or clicks buttons", "input/change events may trigger site behavior", diff --git a/cmd/vaults_secrets.go b/cmd/vaults_secrets.go index 04d307a5..532c4127 100644 --- a/cmd/vaults_secrets.go +++ b/cmd/vaults_secrets.go @@ -98,6 +98,30 @@ func readVaultSecrets(cmd *cobra.Command, flag string, fields ...string) (map[st return values, nil } +func vaultSpecHasCardData(value json.RawMessage) bool { + var object map[string]json.RawMessage + if json.Unmarshal(value, &object) == nil { + for key, child := range object { + switch strings.ToLower(key) { + case "pan", "card_number", "cardnumber", "number", "cvc", "cvv", "security_code", "expiry", "expiration": + return true + } + if vaultSpecHasCardData(child) { + return true + } + } + } + var array []json.RawMessage + if json.Unmarshal(value, &array) == nil { + for _, child := range array { + if vaultSpecHasCardData(child) { + return true + } + } + } + return false +} + func vaultSpecHasSecrets(value json.RawMessage) bool { var object map[string]json.RawMessage if json.Unmarshal(value, &object) != nil { diff --git a/cmd/vaults_wallet_spec.go b/cmd/vaults_wallet_spec.go index 8136b3a1..415b2bb3 100644 --- a/cmd/vaults_wallet_spec.go +++ b/cmd/vaults_wallet_spec.go @@ -41,12 +41,17 @@ func vaultWalletSpecFromFlags(cmd *cobra.Command) (kernel.VaultItemUpsertParamsB return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, err } provider, _ := cmd.Flags().GetString("provider") - if provider == "agentcard" { + switch provider { + case "kernel": + if reference != nil || cmd.Flags().Changed("tokens-file") { + return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, fmt.Errorf("Kernel wallets do not use provider configurations or tokens") + } + case "agentcard": if cmd.Flags().Changed("tokens-file") { return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, fmt.Errorf("--tokens-file is only for imported Link wallet grants") } err = configureAgentCardWallet(spec, reference) - } else { + case "link": err = configureLinkWallet(cmd, spec, reference) } if err != nil { diff --git a/go.mod b/go.mod index 2696f88c..2e424b9d 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.117.0 + github.com/kernel/kernel-go-sdk v0.119.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 080d8770..75816424 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,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.117.0 h1:b6/am7RkJyhadi/98pMHAyuiwyEoDD6smZ4V92pUJ40= -github.com/kernel/kernel-go-sdk v0.117.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.119.0 h1:BF0VowtcKTot27DdK3dT5GngardUuUYkbn0GH9QLBCc= +github.com/kernel/kernel-go-sdk v0.119.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 a3876cdb384439506381efb4c5aa62f3c42e4069 Mon Sep 17 00:00:00 2001 From: hiroTamada <88675973+hiroTamada@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:56:35 +0000 Subject: [PATCH 2/4] Cover all Kernel card fill fields and reject expiry input --- cmd/vaults_kernel_payment_test.go | 5 ++++- cmd/vaults_secrets.go | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/vaults_kernel_payment_test.go b/cmd/vaults_kernel_payment_test.go index db8ec025..c3f94ec7 100644 --- a/cmd/vaults_kernel_payment_test.go +++ b/cmd/vaults_kernel_payment_test.go @@ -59,6 +59,9 @@ func TestKernelSpecRejectsCardDetails(t *testing.T) { `{"pan":"4111111111111111"}`, `{"metadata":{"card_number":"4111111111111111"}}`, `{"cards":[{"cvc":"123"}]}`, + `{"expiry":"12/30"}`, + `{"exp_month":"12","exp_year":"2030"}`, + `{"expiration_month":"12","expiration_year":"2030"}`, } { for _, path := range [][]string{{"wallets", "create"}, {"cards", "create"}} { args := append([]string{"vaults"}, path...) @@ -146,7 +149,7 @@ func TestKernelReadyCardUsesAdvertisedFill(t *testing.T) { body, err := io.ReadAll(r.Body) require.NoError(t, err) assert.JSONEq(t, `{"type":"fill","browser_id":"browser-1","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#cardNumber"},{"field":"expiration","format":"MM/YY","selector":"#cardExpiry"},{"field":"cvc","selector":"#cardCvc"}]}`, string(body)) - _, _ = io.WriteString(w, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"}]}`) + _, _ = io.WriteString(w, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"},{"index":1,"status":"filled"},{"index":2,"status":"filled"}]}`) }) out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", `{"browser_id":"browser-1","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#cardNumber"},{"field":"expiration","format":"MM/YY","selector":"#cardExpiry"},{"field":"cvc","selector":"#cardCvc"}]}`, "-o", "json") require.NoError(t, err) diff --git a/cmd/vaults_secrets.go b/cmd/vaults_secrets.go index a0c5148a..2a52afc8 100644 --- a/cmd/vaults_secrets.go +++ b/cmd/vaults_secrets.go @@ -103,7 +103,7 @@ func vaultSpecHasCardData(value json.RawMessage) bool { if json.Unmarshal(value, &object) == nil { for key, child := range object { switch strings.ToLower(key) { - case "pan", "card_number", "cardnumber", "number", "cvc", "cvv", "security_code", "expiry", "expiration": + case "pan", "card_number", "cardnumber", "number", "cvc", "cvv", "security_code", "expiry", "expiration", "exp_month", "exp_year", "expiration_month", "expiration_year": return true } if vaultSpecHasCardData(child) { From a18d8a8bc9e7865079fc3cdec9c5e02780976fc2 Mon Sep 17 00:00:00 2001 From: hiroTamada <88675973+hiroTamada@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:02:03 +0000 Subject: [PATCH 3/4] Limit recovery operations to advertised verification restart --- README.md | 7 +++-- cmd/vaults.go | 6 +++- cmd/vaults_fill_test.go | 10 +++--- cmd/vaults_output.go | 6 +++- cmd/vaults_policy.go | 8 ++++- cmd/vaults_policy_test.go | 9 +++--- cmd/vaults_prepare_checkout_test.go | 2 +- cmd/vaults_restart_verification_test.go | 42 +++++++++++++++++++++++-- cmd/vaults_wallet_config_test.go | 6 ++-- 9 files changed, 74 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 8b245463..7f10eccf 100644 --- a/README.md +++ b/README.md @@ -712,9 +712,10 @@ 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, including in `recovery_required`. The API controls availability. In recovery, -`restart_verification` may be invoked once if advertised; otherwise do not retry, delete, -or replace the item. Stale action URLs in recovery are not opened; a successful restart +advertised. In `recovery_required`, the CLI hides and refuses every operation except +an advertised `restart_verification`, which may be invoked once. Even other advertised +operations remain blocked; otherwise do not retry, delete, or replace the item. +Stale action URLs in recovery are not opened; a successful restart returns a fresh approval action. `authorize` sends `{"type":"authorize"}` without `--params` and returns the updated item, diff --git a/cmd/vaults.go b/cmd/vaults.go index d57aff34..51dc31e1 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -287,7 +287,11 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } if !available { if actions.RecoveryRequired { - return fmt.Errorf("recovery_required: reconcile the original operation with the provider or support; do not retry, delete, or replace it. invoke restart_verification once only if advertised in available_operations") + message := "recovery_required: reconcile the original operation with the provider or support; do not retry, delete, or replace it" + if len(actions.Operations) > 0 { + message += ". invoke the advertised restart_verification once to get a fresh approval link" + } + return fmt.Errorf("%s", message) } if isOnePasswordOperation(operation) { return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item with items get -o json. For 1Password credentials, check that the linked credential_account is connected and the credential state; do not delete or recreate the item to reset it", operation) diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index 61847e81..ff3f76c6 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -156,11 +156,11 @@ func TestVaultFillBindingsSerialization(t *testing.T) { func TestVaultFillAdvertisedAvailability(t *testing.T) { for name, fixture := range map[string]string{ - "requested": requestedCardFixture, - "wallet": connectedWalletFixture, - "agentcard": strings.ReplaceAll(strings.ReplaceAll(readyFillCardFixture, "link", "agentcard"), `[{"type":"fill","description":"Fill checkout fields."}]`, `[]`), - "ready unadvertised": strings.ReplaceAll(readyFillCardFixture, `[{"type":"fill","description":"Fill checkout fields."}]`, `[]`), - "recovery unadvertised": strings.ReplaceAll(strings.ReplaceAll(readyFillCardFixture, `"ready"`, `"recovery_required"`), `[{"type":"fill","description":"Fill checkout fields."}]`, `[]`), + "requested": requestedCardFixture, + "wallet": connectedWalletFixture, + "agentcard": strings.ReplaceAll(strings.ReplaceAll(readyFillCardFixture, "link", "agentcard"), `[{"type":"fill","description":"Fill checkout fields."}]`, `[]`), + "ready unadvertised": strings.ReplaceAll(readyFillCardFixture, `[{"type":"fill","description":"Fill checkout fields."}]`, `[]`), + "recovery stale operation": strings.ReplaceAll(readyFillCardFixture, `"ready"`, `"recovery_required"`), } { t.Run(name, func(t *testing.T) { calls := 0 diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 1b47d38d..8b2f6129 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -448,7 +448,11 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemActions) { if actions.RecoveryRequired { - pterm.Warning.Println("recovery_required: the original operation is unresolved, not declined or expired. " + vaultRecoveryHelp) + guidance := "do not retry, delete, or replace it. reconcile with the provider or support; no reset operation exists." + if len(actions.Operations) > 0 { + guidance = vaultRecoveryHelp + } + pterm.Warning.Println("recovery_required: the original operation is unresolved, not declined or expired. " + guidance) for _, op := range actions.Operations { pterm.Printf("available operation: %s — %s\n", op.Type, op.Description) } diff --git a/cmd/vaults_policy.go b/cmd/vaults_policy.go index 56f42dc5..c21f71eb 100644 --- a/cmd/vaults_policy.go +++ b/cmd/vaults_policy.go @@ -30,7 +30,13 @@ func effectiveVaultItemActions(item *kernel.VaultItemUnion) (vaultItemActions, e return vaultItemActions{}, fmt.Errorf("invalid vault item operations: %w", err) } if item.State.Status == "recovery_required" { - return vaultItemActions{RecoveryRequired: true, Operations: fields.Operations}, nil + actions := vaultItemActions{RecoveryRequired: true} + for _, op := range fields.Operations { + if op.Type == "restart_verification" { + actions.Operations = append(actions.Operations, op) + } + } + return actions, nil } approvalURL := item.State.Authorization.ApprovalURL if item.State.Status == "preparing" && item.State.Preparation.ApprovalURL != "" { diff --git a/cmd/vaults_policy_test.go b/cmd/vaults_policy_test.go index 566ef5a7..4310d4b5 100644 --- a/cmd/vaults_policy_test.go +++ b/cmd/vaults_policy_test.go @@ -45,13 +45,12 @@ func TestVaultRecoveryActionDisplayPolicy(t *testing.T) { assert.Contains(t, out, `"authorize"`) assert.Empty(t, human) } else if status == "recovery_required" { + assert.NotContains(t, human, "spend_approval") assert.NotContains(t, human, "required action") assert.NotContains(t, human, "https://example.test/approve") - if command == "get" { - assert.Contains(t, human, "restart_verification") - assert.Contains(t, human, "available operation: authorize") - assert.Contains(t, human, "invoke:") - } + assert.NotContains(t, human, "restart_verification") + assert.NotContains(t, human, "available operation:") + assert.NotContains(t, human, "invoke:") } else { assert.Contains(t, human, "spend_approval") } diff --git a/cmd/vaults_prepare_checkout_test.go b/cmd/vaults_prepare_checkout_test.go index e3e90a61..b9bbf1fe 100644 --- a/cmd/vaults_prepare_checkout_test.go +++ b/cmd/vaults_prepare_checkout_test.go @@ -155,7 +155,7 @@ func TestVaultPrepareCheckoutChecksAvailabilityAndNeverRetries(t *testing.T) { } for _, fixture := range []string{ strings.Replace(preparationCardFixture, `"type":"prepare_checkout"`, `"type":"other"`, 1), - strings.Replace(strings.Replace(preparationCardFixture, `"status":"preparing"`, `"status":"recovery_required"`, 1), `"type":"prepare_checkout"`, `"type":"other"`, 1), + strings.Replace(preparationCardFixture, `"status":"preparing"`, `"status":"recovery_required"`, 1), strings.ReplaceAll(preparationCardFixture, `"agentcard"`, `"link"`), } { posts := 0 diff --git a/cmd/vaults_restart_verification_test.go b/cmd/vaults_restart_verification_test.go index adf35ce8..c6dc9c32 100644 --- a/cmd/vaults_restart_verification_test.go +++ b/cmd/vaults_restart_verification_test.go @@ -52,7 +52,7 @@ func TestVaultRestartVerificationRecovery(t *testing.T) { }) if !advertised { require.ErrorContains(t, err, "recovery_required") - assert.Contains(t, err.Error(), "do not retry, delete, or replace") + assert.EqualError(t, err, "recovery_required: reconcile the original operation with the provider or support; do not retry, delete, or replace it") assert.Zero(t, posts) assert.Empty(t, opened) assert.Empty(t, out) @@ -71,6 +71,41 @@ func TestVaultRestartVerificationRecovery(t *testing.T) { } } +func TestVaultRecoveryRejectsOtherAdvertisedOperations(t *testing.T) { + for _, operation := range []string{"authorize", "fill"} { + for _, restartAdvertised := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/restart=%t", operation, restartAdvertised), func(t *testing.T) { + operations := fmt.Sprintf(`[{"type":%q,"description":"must stay hidden"}]`, operation) + if restartAdvertised { + operations = strings.TrimSuffix(operations, "]") + `,{"type":"restart_verification","description":"restart verification"}]` + } + fixture := strings.Replace(kernelCardFixture, `"status":"requested"`, `"status":"recovery_required"`, 1) + fixture = strings.Replace(fixture, `[{"type":"authorize","description":"Authorize this purchase"}]`, operations, 1) + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method, "recovery must not dispatch this operation") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + args := []string{"vaults", "items", "invoke", "checkout", "order-1", operation} + if operation == "fill" { + args = append(args, "--params", fillParamsFixture) + } + out, human, err := executeVaultCommand(t, client, args...) + message := "recovery_required: reconcile the original operation with the provider or support; do not retry, delete, or replace it" + if restartAdvertised { + message += ". invoke the advertised restart_verification once to get a fresh approval link" + } + assert.EqualError(t, err, message) + assert.Equal(t, 1, calls) + assert.Empty(t, out) + assert.NotContains(t, human, "must stay hidden") + }) + } + } +} + func TestVaultRestartVerificationRejectsParams(t *testing.T) { client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("parameterized restart reached API") }) for _, input := range []string{"--params", "--spec-file"} { @@ -81,7 +116,7 @@ func TestVaultRestartVerificationRejectsParams(t *testing.T) { func TestVaultRecoveryDisplaysAdvertisedRestart(t *testing.T) { fixture := strings.Replace(kernelCardFixture, `"status":"requested"`, `"status":"recovery_required"`, 1) - fixture = strings.Replace(fixture, `"type":"authorize"`, `"type":"restart_verification"`, 1) + fixture = strings.Replace(fixture, `[{"type":"authorize","description":"Authorize this purchase"}]`, `[{"type":"authorize","description":"must stay hidden"},{"type":"fill","description":"must stay hidden"},{"type":"restart_verification","description":"restart verification"}]`, 1) fixture = strings.TrimSuffix(fixture, "}") + `,"action":{"name":"spend_approval","url":"https://vault.example/stale"}}` client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -92,5 +127,8 @@ func TestVaultRecoveryDisplaysAdvertisedRestart(t *testing.T) { assert.Contains(t, human, "available operation: restart_verification") assert.Contains(t, human, "invoke: kernel vaults items invoke -- checkout order-1 restart_verification") assert.Contains(t, human, "invoke it once") + assert.NotContains(t, human, "available operation: authorize") + assert.NotContains(t, human, "available operation: fill") + assert.NotContains(t, human, "must stay hidden") assert.NotContains(t, human, "https://vault.example/stale") } diff --git a/cmd/vaults_wallet_config_test.go b/cmd/vaults_wallet_config_test.go index 812bcbb7..04626b32 100644 --- a/cmd/vaults_wallet_config_test.go +++ b/cmd/vaults_wallet_config_test.go @@ -173,13 +173,12 @@ func TestVaultRecoveryRequired(t *testing.T) { for _, provider := range []string{"link", "agentcard"} { body := strings.ReplaceAll(requestedCardFixture, `"status":"requested"`, `"status":"recovery_required"`) body = strings.ReplaceAll(body, `"provider":"link"`, `"provider":"`+provider+`"`) - body = strings.ReplaceAll(body, `[{"type":"authorize","description":"Use only after explicit user approval."}]`, `[]`) for _, operation := range []string{"get", "invoke"} { t.Run(provider+"/"+operation, func(t *testing.T) { calls := 0 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ - assert.Equal(t, http.MethodGet, r.Method, "must not dispatch an unadvertised operation") + assert.Equal(t, http.MethodGet, r.Method, "must not dispatch even if a stale operation is advertised") w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, body) }) @@ -214,7 +213,8 @@ func TestVaultRecoveryDoesNotOpenStaleAction(t *testing.T) { require.NoError(t, c.showItem(&item, "", true)) assert.NotContains(t, buf.String(), "https://example.test/approve") assert.NotContains(t, buf.String(), "required action") - assert.Contains(t, buf.String(), "restart_verification") + assert.NotContains(t, buf.String(), "spend_approval") + assert.NotContains(t, buf.String(), "restart_verification") } func TestVaultImportedWalletDegradedOutput(t *testing.T) { From e9f17111716fa66dea0dd840bc51c0081db8858f Mon Sep 17 00:00:00 2001 From: hiroTamada <88675973+hiroTamada@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:44:51 +0000 Subject: [PATCH 4/4] Give Kernel payment recovery a next step for every stop --- README.md | 74 ++++++-- cmd/vaults.go | 28 ++- cmd/vaults_commands.go | 24 +-- cmd/vaults_confirm_transaction.go | 10 +- cmd/vaults_fill.go | 12 +- cmd/vaults_help.go | 11 +- cmd/vaults_kernel_errors.go | 117 ++++++++++++ cmd/vaults_kernel_payment_test.go | 2 +- cmd/vaults_kernel_recovery_test.go | 242 ++++++++++++++++++++++++ cmd/vaults_output.go | 56 +++++- cmd/vaults_payment_help_test.go | 4 +- cmd/vaults_policy.go | 12 +- cmd/vaults_policy_test.go | 9 +- cmd/vaults_restart_verification_test.go | 8 +- 14 files changed, 549 insertions(+), 60 deletions(-) create mode 100644 cmd/vaults_kernel_errors.go create mode 100644 cmd/vaults_kernel_recovery_test.go diff --git a/README.md b/README.md index 7f10eccf..4f1c8d4a 100644 --- a/README.md +++ b/README.md @@ -526,9 +526,15 @@ payments; do not repeat an uncertain payment through the new wallet. There is no imported-wallet reauthorization. **`recovery_required` means unresolved, not declined or expired.** It stops server-side waiting. -If `restart_verification` is advertised in `available_operations`, invoke it once to get a -fresh `spend_approval` link. Otherwise do not retry, delete, or replace the item; reconcile -with the provider or support. Unresolved child cards can block wallet and vault deletion. +If `restart_verification` is advertised in `available_operations`, invoke with explicit +user approval each time to get a fresh `spend_approval` link. The cardholder must verify +and approve spending again; restart grants no spend consent. If the response is unclear, +re-get and share any new action; invoke once more only if still advertised with renewed +user approval. Otherwise do not retry payment, delete or replace the item. Contact +support@kernel.sh privately with project, vault, key, status_reason and relevant event +timestamps (never card data, API keys or capability URLs) to reconcile the original +operation and establish whether a new purchase is safe; never assume a new card is safe. +Unresolved child cards can block wallet and vault deletion. Time passing or deletion is not evidence of non-execution. #### Kernel-managed card checkout @@ -542,12 +548,18 @@ kernel vaults wallets payment-methods checkout cardholder Share the returned `card_enrollment` URL with the cardholder. A connected wallet may still report `single_use_card.eligible: false` with a `network_token_*` reason while network token -enrollment is pending or unsupported. Inspect the expansion before requesting a card; this -wallet uses its enrolled card, not a `payment_method_id` in the card spec. +enrollment is pending or unsupported. Re-check `network_token_pending` later at most once +in this attempt; `network_token_unsupported`, `network_token_failed`, or persistent +ineligibility requires a different card, enrolled under a new wallet key after user +confirmation. Missing eligibility is unknown. Never poll indefinitely. +For `reconnect_required`, share the new enrollment action. For `degraded`, obtain user +confirmation and delete only if the API permits it before a new wallet/enrollment; +reconcile unresolved child cards first. This wallet uses its enrolled card, not a +`payment_method_id` in the card spec. ```bash kernel vaults cards create checkout order-1 --provider kernel --spec '{ - "wallet":"cardholder", "amount":1200, "currency":"usd", + "wallet":"cardholder", "amount":1200, "currency":"USD", "merchant_name":"Example Shop", "merchant_url":"https://shop.example/checkout", "merchant_country":"US" }' @@ -558,7 +570,8 @@ kernel vaults items get checkout order-1 --wait 60 Only invoke `authorize` when advertised. Visa returns a `spend_approval` URL for the cardholder to verify and approve; Mastercard may become ready without one. In -`recovery_required`, invoke `restart_verification` once **only if advertised**: +`recovery_required`, invoke `restart_verification` **only if advertised**, with explicit +user approval each time: ```bash kernel vaults items invoke checkout order-1 restart_verification --open @@ -567,7 +580,17 @@ kernel vaults items invoke checkout order-1 restart_verification --open This rotates the approval link locally without a provider call, only after a classified initial attestation failure with proof no intent was dispatched. Otherwise do not retry, delete, or replace the item. Always check `available_operations` rather than assuming -the API supports this operation. +the API supports this operation. Human output, JSON output and execution expose only +advertised restart_verification during recovery; other operations and stale action URLs +are suppressed. + +`--open` on enrollment or spend-approval URLs is only for a human cardholder using their +own machine. Agents must omit it and share the capability URL privately; never open it +for the cardholder. The examples with `--open` are for human use. +Optional `merchant_category` and `merchant_category_code` (actual four-digit MCC other +than `0000`) are accepted; omit rather than invent them. Kernel cards cannot be updated: +with user confirmation, delete only if permitted and create a new item under a new key; +reconcile unresolved outcomes first. When ready, create a browser with the vault attached, navigate to checkout, then fill: @@ -585,8 +608,9 @@ kernel vaults items invoke checkout order-1 fill --params '{ ``` Use the **exact current HTTPS top-level URL** on `merchant_url`'s origin and selectors -matching that page. Kernel cards have **no stored billing fields**: requesting `billing_*` -returns 400 `field_unavailable` before any write. Enter billing details (such as name and +matching that page. Supported fields are `number`, `expiration` (format `MM/YY` or +`MM/YYYY`), `cvc`, `exp_month` and `exp_year`. Kernel cards have **no stored billing +fields**: requesting `billing_*` returns 400 `field_unavailable` before any write. Enter billing details (such as name and ZIP) directly on the merchant page. Fill types the one-time network token and code but never submits checkout. Submit before `expires_at`; neither ready nor fill proves payment. @@ -604,10 +628,27 @@ kernel vaults items invoke checkout order-1 confirm_transaction --params '{ `type` or any extra properties. `status` must be `APPROVED`, `DECLINED`, `PENDING`, `ERROR`, or `CANCELLED`. `transaction_type` must be `PURCHASE`, `AUTHORIZATION`, `CAPTURE`, `REFUND`, `REVERSAL`, `VERIFICATION`, `CHARGEBACK`, or `FRAUD`. `amount` is an integer in minor units, -0..50000 and no greater than the approved amount; `currency` is three uppercase letters; +0..50000 and no greater than the approved amount; `currency` is three uppercase letters +matching the approved purchase currency; `occurred_at` is the observed outcome's RFC3339 timestamp. The operation is no longer -advertised after reporting. Never infer success from fill. Unknown outcomes, including -transport loss while reporting, must not be reported or retried. +advertised after reporting. Never infer success from fill. `PENDING` means explicit +merchant evidence of an accepted pending transaction; `ERROR` means an explicit merchant +transaction error, not transport loss or an ambiguous processing page. These are final +reports, not polling states. Unknown outcomes need the user to check merchant orders, +email or statements, then report the confirmed outcome even after card expiry. +If a report response is lost, re-get and send the identical observed report once more +only if freshly advertised; otherwise observe state/events (recorded, in flight or recovery). +Never repeat checkout. After a confirmed merchant decline, report DECLINED when advertised; +if the user wants another purchase, ask for a different card or merchant, delete only if +permitted and create one new card under a new key. Expired approval/code before checkout: +do not submit; with user confirmation delete only if permitted and create one new card. +If checkout was submitted, reconcile and report the observed outcome first. + +Kernel request errors retain actionable API messages while scrubbing HTTP(S) URLs. +After create transport/5xx failure, retry once with the identical key and spec, never a +new key to recover a lost response. After authorize/report failures re-get and invoke +once more only if freshly advertised. Authorize 409 requires state inspection, not input +changes. For 429 wait for Retry-After before re-checking availability. No automatic retries. Never put PAN, CVC, expiry, or enrollment credentials in CLI arguments or logs. #### Link checkout preparation @@ -713,8 +754,11 @@ 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. In `recovery_required`, the CLI hides and refuses every operation except -an advertised `restart_verification`, which may be invoked once. Even other advertised -operations remain blocked; otherwise do not retry, delete, or replace the item. +an advertised `restart_verification`, invoked with explicit user approval each time and +new cardholder spend consent. Human and JSON output use the same recovery filtering. +Other operations remain blocked; otherwise contact support@kernel.sh with project, +vault, key, status_reason and event timestamps (no card data, API keys or capability URLs). +Do not delete or replace unresolved items. Stale action URLs in recovery are not opened; a successful restart returns a fresh approval action. diff --git a/cmd/vaults.go b/cmd/vaults.go index 51dc31e1..dc75a9ec 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -223,6 +223,9 @@ func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, e func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key string, spec kernel.VaultItemUpsertParamsBodyWalletSpecUnion, 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 { + if vaultSpecProvider(spec) == "kernel" { + return kernelVaultRequestError(err, "create") + } return vaultCredentialError(err) } return c.showItem(item, output, open) @@ -237,6 +240,9 @@ 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 { + if vaultSpecProvider(spec) == "kernel" { + return kernelVaultRequestError(err, "create") + } return util.CleanedUpSdkError{Err: err} } return c.showItem(item, output, false) @@ -287,9 +293,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } if !available { if actions.RecoveryRequired { - message := "recovery_required: reconcile the original operation with the provider or support; do not retry, delete, or replace it" - if len(actions.Operations) > 0 { - message += ". invoke the advertised restart_verification once to get a fresh approval link" + message := "recovery_required: " + kernelSupportGuidance + if actions.advertises("restart_verification") { + message = "recovery_required: " + vaultRecoveryHelp } return fmt.Errorf("%s", message) } @@ -298,8 +304,13 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } + if operation == "confirm_transaction" && item.Spec.Provider == "kernel" { + if params.ConfirmTransaction.Currency != strings.ToUpper(item.Spec.Currency) || params.ConfirmTransaction.Amount > item.Spec.Amount { + return fmt.Errorf("confirm_transaction currency must match the approved purchase currency and amount must not exceed the approved purchase amount; operation was not invoked") + } + } if operation == "fill" { - return c.fill(ctx, vault, key, params.Fill, output) + return c.fill(ctx, vault, key, params.Fill, output, item.Spec.Provider) } if operation == "1pw_fill" { return c.onePasswordFill(ctx, vault, key, params.OnePassword, output) @@ -338,13 +349,22 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if item.Type == "credential" || item.Type == "credential_account" { return vaultCredentialError(err) } + if item.Spec.Provider == "kernel" { + return kernelVaultRequestError(err, operation) + } return util.CleanedUpSdkError{Err: err} } if response == nil || (response.Type != "card" && response.Type != "wallet" && response.Type != "credential" && response.Type != "credential_account") { + if item.Spec.Provider == "kernel" { + return fmt.Errorf("unexpected vault operation response; %s", kernelVaultRetryGuidance(operation)) + } return fmt.Errorf("unexpected vault operation response; inspect the item and do not retry") } var updated kernel.VaultItemUnion if err := json.Unmarshal([]byte(response.RawJSON()), &updated); err != nil { + if item.Spec.Provider == "kernel" { + return fmt.Errorf("invalid vault item response; %s", kernelVaultRetryGuidance(operation)) + } return fmt.Errorf("invalid vault item response; inspect the item and do not retry") } return c.showItem(&updated, output, open) diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index e688b672..c1246288 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -98,8 +98,8 @@ vault names, item keys, and project ownership are immutable. AgentCard-only checkout aliases support egress substitution with checkout hold, approval, and replay; they are not a fallback after fill. 6. after the user submits kernel card checkout, report the observed outcome once with - confirm_transaction when advertised. never infer success from fill; do not report or - retry unknown outcomes. in recovery_required, ` + vaultRecoveryHelp + ` + confirm_transaction when advertised. ` + kernelTransactionHelp + `. + in recovery_required, ` + vaultRecoveryHelp + ` permitted checkout domains are provider-assigned and displayed when returned; kernel card fill instead uses the exact origin of merchant_url. @@ -175,7 +175,7 @@ each vault has its own key; it is created on first request and may be cached.`, }} 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") + itemGet.Flags().Bool("open", false, "open a returned https action url; cardholder links are human-only, agents must share privately") 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 { @@ -232,13 +232,15 @@ confirm_transaction requires --params or --spec-file without type, with all of: status (APPROVED, DECLINED, PENDING, ERROR, CANCELLED), transaction_type (PURCHASE, AUTHORIZATION, CAPTURE, REFUND, REVERSAL, VERIFICATION, CHARGEBACK, FRAUD), amount (integer minor units 0..50000, no greater than the approved amount), currency (three -uppercase letters), and occurred_at (RFC3339). no other fields are accepted. -after the user submits checkout, report the observed outcome once, only when advertised. -never infer success from fill; do not report or retry unknown outcomes, including transport loss. +uppercase letters matching the approved purchase currency), and occurred_at (RFC3339). +no other fields are accepted. ` + kernelTransactionHelp + `. restart_verification takes no parameters. in recovery_required, ` + vaultRecoveryHelp + ` it rotates the approval link without a provider call, only after a classified initial attestation failure with proof no intent was dispatched. other recovery states must not be retried. -collect/authorize/prepare_checkout/1pw_recover/restart_verification may use --open. fill returns value-free per-field outcomes; +collect/authorize/prepare_checkout/1pw_recover/restart_verification may use --open. +--open on card_enrollment or spend_approval is for a human cardholder on their own machine. +agents must omit --open and share capability links privately, never open them for cardholders. +fill returns value-free per-field outcomes; completed exits 0, failed/unknown exit nonzero with valid json retained on stdout in -o json. webmcp_invoke (credential items and ready Link cards when advertised; see items webmcp invoke --help @@ -313,7 +315,7 @@ JSON invoke.Flags().String("params", "", "operation parameters json for fill, webmcp_invoke, prepare_checkout, confirm_transaction, or 1pw_* (maximum 128 KiB); omit type and credential values; 1pw_update_access_token requires --spec-file") invoke.Flags().String("spec-file", "", "operation parameters json file (use '-' for stdin; maximum 128 KiB)") invoke.MarkFlagsMutuallyExclusive("params", "spec-file") - invoke.Flags().Bool("open", false, "open a returned https action url in your browser") + invoke.Flags().Bool("open", false, "open a returned https action url; cardholder links are human-only, agents must share privately") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultWebMCPCommand(), newVaultDeleteCommand(true)) @@ -345,7 +347,7 @@ JSON walletCreate.Flags().String("provider-config-name", "", "bind the new wallet to this provider configuration name (immutable)") walletCreate.MarkFlagsMutuallyExclusive("provider-config-id", "provider-config-name") walletCreate.Flags().String("tokens-file", "", "import Link access_token and refresh_token json from a file (use '-' for stdin)") - walletCreate.Flags().Bool("open", false, "open the returned https connection/enrollment url") + walletCreate.Flags().Bool("open", false, "open a returned https connection/enrollment url; cardholder use only, agents must share privately") 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.", @@ -362,7 +364,7 @@ JSON return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], wait, nil, resolveProjectSelection(project), vaultOutput(cmd), open) }} walletGet.Flags().Int64("wait", 0, "hold while pending for up to this many seconds (0-60); observe only") - walletGet.Flags().Bool("open", false, "open a returned https enrollment url") + walletGet.Flags().Bool("open", false, "open a returned https enrollment url; cardholder use only, agents must share privately") addVaultJSONOutputFlag(walletGet) wallets.AddCommand(walletCreate, walletGet, methods) @@ -422,7 +424,7 @@ an AgentCard recovery item that permits abandonment must be deleted after explic return err } if update && string(spec["provider"]) == `"kernel"` { - return fmt.Errorf("kernel card updates are not supported; create a new card item for a new purchase") + return fmt.Errorf("kernel card updates are not supported; " + kernelReplacementHelp) } return getVaultsHandler(cmd).SaveCard(cmd.Context(), args[0], args[1], param.Override[kernel.CardVaultItemSpecUnionParam](spec), update, vaultOutput(cmd)) }} diff --git a/cmd/vaults_confirm_transaction.go b/cmd/vaults_confirm_transaction.go index fbc14078..4eb74ad8 100644 --- a/cmd/vaults_confirm_transaction.go +++ b/cmd/vaults_confirm_transaction.go @@ -3,10 +3,13 @@ package cmd import ( "encoding/json" "fmt" + "regexp" "slices" "time" ) +var vaultCurrencyPattern = regexp.MustCompile(`^[A-Z]{3}$`) + type vaultConfirmTransactionParams struct { Type string `json:"type"` Status string `json:"status"` @@ -33,14 +36,9 @@ func parseVaultConfirmTransactionParams(raw string) (*vaultConfirmTransactionPar return nil, fmt.Errorf("amount must be an integer between 0 and 50000 minor units, no greater than the approved amount") } params.Amount = *amount - if json.Unmarshal(object["currency"], ¶ms.Currency) != nil || len(params.Currency) != 3 { + if json.Unmarshal(object["currency"], ¶ms.Currency) != nil || !vaultCurrencyPattern.MatchString(params.Currency) { return nil, fmt.Errorf("currency must be a three-letter uppercase code") } - for _, letter := range params.Currency { - if letter < 'A' || letter > 'Z' { - return nil, fmt.Errorf("currency must be a three-letter uppercase code") - } - } if json.Unmarshal(object["occurred_at"], ¶ms.OccurredAt) != nil { return nil, fmt.Errorf("occurred_at must be an RFC3339 timestamp") } diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index f5a1dfd8..086e914b 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -96,7 +96,7 @@ const vaultFillNotWritten = "no fields were written by this request; correct the var vaultFillRejected = map[int]string{400: vaultFillNotWritten, 403: vaultFillNotWritten, 404: vaultFillNotWritten, 409: vaultFillNotWritten} -func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFillParams, output string) error { +func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFillParams, output string, provider string) error { request := kernel.FillVaultItemOperationRequestParam{ BrowserID: params.BrowserID, Type: kernel.FillVaultItemOperationRequestTypeFill, @@ -117,7 +117,15 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil } response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfFill: &request}, option.WithMaxRetries(0)) if err != nil { - return vaultOperationRequestError(err, "fill", vaultFillErrorMessages, vaultFillRejected, vaultFillUncertain) + messages := vaultFillErrorMessages + if provider == "kernel" { + messages = make(map[string]string, len(vaultFillErrorMessages)) + for code, message := range vaultFillErrorMessages { + messages[code] = message + } + messages["field_unavailable"] = "kernel cards have no stored billing fields; remove unsupported bindings and enter billing details on the merchant page" + } + return vaultOperationRequestError(err, "fill", messages, vaultFillRejected, vaultFillUncertain) } if response == nil { return fmt.Errorf("empty fill result; %s", vaultFillUncertain) diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 955e832d..fb880b3e 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -12,13 +12,14 @@ or normalization. never include card data, OAuth tokens, or provider secrets in // the provider field below is supplied by --provider. ` -const vaultRecoveryHelp = "if restart_verification is advertised in available_operations, invoke it once to get a fresh spend_approval link. otherwise do not retry, delete, or replace the item; reconcile with the provider or support." +const vaultRecoveryHelp = "only if restart_verification is advertised in available_operations, invoke it with explicit user approval each time to get a fresh spend_approval link. restart grants no spend consent: the cardholder must verify and explicitly approve spending again on the new link. if the result is unclear, re-get and share the new action if present; invoke once more only if restart_verification is still advertised and the user approves again. otherwise " + kernelSupportGuidance const vaultWalletSpecHelp = ` for --provider kernel, use --spec '{}'. share the returned card_enrollment url with the cardholder; never enter card details in the cli. use wallets get --wait 60 to observe connection, then wallets payment-methods to inspect token eligibility. a connected wallet may still be ineligible while network token enrollment runs. +` + kernelWalletHelp + `. ` + kernelEligibilityHelp + `. kernel wallets accept neither provider configuration flags nor --tokens-file. for Link and AgentCard, omit config selection to preserve managed defaults. --provider-config-id and --provider-config-name are mutually exclusive. @@ -103,6 +104,8 @@ type KernelCardSpec = { merchant_name: string; // 1..255 characters merchant_url: string; // https merchant checkout url; fill is locked to its origin merchant_country?: string; // iso 3166-1 alpha-2; required for Visa cards + merchant_category?: string; // actual category, 1..255 characters; omit rather than invent + merchant_category_code?: string; // actual four-digit MCC, not 0000; omit rather than invent }; type LinkLineItem = { @@ -123,14 +126,14 @@ type LinkTotal = { amount: number; // integer minor units }; -kernel cards cannot be updated. create a new card item for a new purchase, but -never retry an uncertain authorization. in recovery_required, ` + vaultRecoveryHelp + ` +kernel cards cannot be updated. ` + kernelReplacementHelp + `. +in recovery_required, ` + vaultRecoveryHelp + ` invoke authorize only when advertised. for Visa, present the returned spend_approval url to the cardholder and poll items get --wait 60 until ready. Mastercard may become ready without hosted approval. neither ready nor fill confirms merchant payment. fill and submit before expires_at; never pass PAN to cli. after the user submits checkout, report the observed outcome once with confirm_transaction -when advertised; never infer success from fill. do not report or retry unknown outcomes. +when advertised. ` + kernelTransactionHelp + `. ` + kernelTerminalHelp + `. kernel cards have no stored billing fields; billing_* returns field_unavailable before any write. enter billing details on the merchant page. diff --git a/cmd/vaults_kernel_errors.go b/cmd/vaults_kernel_errors.go new file mode 100644 index 00000000..20d4553f --- /dev/null +++ b/cmd/vaults_kernel_errors.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "strconv" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" +) + +var kernelVaultErrorURL = regexp.MustCompile(`(?i)https?://\S+`) +var kernelVaultErrorCode = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`) + +type kernelVaultTransportError struct { + message string + cause error +} + +func (e kernelVaultTransportError) Error() string { return e.message } +func (e kernelVaultTransportError) Is(target error) bool { return errors.Is(e.cause, target) } + +func safeKernelAPIError(apiErr *kernel.Error, code, message string) error { + if !kernelVaultErrorCode.MatchString(code) { + code = "" + } + raw, err := json.Marshal(map[string]string{"code": code, "message": message}) + if err != nil { + return fmt.Errorf("request failed (http %d)", apiErr.StatusCode) + } + var safe kernel.Error + if err := json.Unmarshal(raw, &safe); err != nil { + return fmt.Errorf("request failed (http %d)", apiErr.StatusCode) + } + safe.StatusCode = apiErr.StatusCode + return util.CleanedUpSdkError{Err: &safe} +} + +func vaultSpecProvider(spec json.Marshaler) string { + raw, err := spec.MarshalJSON() + if err != nil { + return "" + } + var value struct { + Provider string `json:"provider"` + } + if json.Unmarshal(raw, &value) != nil { + return "" + } + return value.Provider +} + +func kernelVaultRetryGuidance(operation string) string { + switch operation { + case "create": + return "retry create once with the identical key and identical spec; it retrieves the existing item if creation succeeded. do not use a new key to recover a lost create response" + case "authorize", "confirm_transaction", "restart_verification": + guidance := "re-get the item and inspect state/events; invoke " + operation + " once more only if freshly advertised in available_operations, never in an automatic loop. otherwise follow the current state. never replay merchant checkout" + if operation == "restart_verification" { + guidance += ". share any new spend_approval privately; obtain user approval each time and new cardholder spend consent" + } + return guidance + default: + return "inspect item state and events before taking further action; no automatic retries" + } +} + +// Preserve SDK classification with a sanitized copy; the root handler prints SDK bodies. +func kernelVaultRequestError(err error, operation string) error { + guidance := kernelVaultRetryGuidance(operation) + var apiErr *kernel.Error + if !errors.As(err, &apiErr) { + return kernelVaultTransportError{message: fmt.Sprintf("%s result unavailable; %s", operation, guidance), cause: err} + } + var body struct { + Code string `json:"code"` + Message string `json:"message"` + } + _ = json.Unmarshal([]byte(apiErr.RawJSON()), &body) + detail := kernelVaultErrorURL.ReplaceAllString(body.Message, "[redacted url]") + switch { + case apiErr.StatusCode == 429: + wait := "wait for the rate limit to clear" + if apiErr.Response != nil { + after := apiErr.Response.Header.Get("Retry-After") + _, secondsErr := strconv.ParseUint(after, 10, 64) + _, dateErr := http.ParseTime(after) + if secondsErr == nil || dateErr == nil { + wait = "wait until Retry-After (" + after + ") has elapsed" + } + } + guidance = wait + "; " + guidance + case apiErr.StatusCode == 409 && operation == "authorize": + guidance = "authorization may be in flight, unresolved or unavailable in this state; there are no authorize inputs to fix. " + guidance + case apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 && apiErr.StatusCode != 408: + if operation == "create" { + guidance = "inspect current state and correct the reported inputs or access before another request; never replay merchant checkout" + } + guidance = "the request was rejected; fix the reported inputs or access. " + guidance + } + return safeKernelAPIError(apiErr, body.Code, fmt.Sprintf("%s failed (http %d): %s; %s", operation, apiErr.StatusCode, detail, guidance)) +} + +const kernelSupportGuidance = "do not retry payment, delete, or replace the item. contact support@kernel.sh privately with project, vault, item key, status_reason and relevant event timestamps (never card data, api keys or capability urls) to reconcile the original operation and establish whether a new purchase is safe; do not assume a new card is safe or promise a resulting state" + +const kernelTransactionHelp = "report one observed merchant outcome only when confirm_transaction is advertised. PENDING means the merchant explicitly confirms an accepted transaction is pending; ERROR means an explicit merchant transaction error, not transport loss or an ambiguous processing page. these are final reports, not polling states. unknown means no reliable merchant outcome: ask the user to check order confirmation, merchant account or statement, then report the confirmed outcome even after card expiry. never infer success from fill. if reporting fails or the response is lost, re-get and send the identical observed report once more only if confirm_transaction is freshly advertised; otherwise observe state/events (recorded, in flight or recovery). never repeat merchant checkout" + +const kernelEligibilityHelp = "eligible=false means do not authorize. for network_token_pending, re-check later at most once in this attempt; network_token_unsupported or network_token_failed needs a different card. if still ineligible or unknown, ask the cardholder before enrolling a different card under a new wallet key; do not poll indefinitely or replay an uncertain purchase" + +const kernelWalletHelp = "share card_enrollment only with the intended cardholder privately, never open it for them. reconnect_required: share the new card_enrollment action. degraded: with user confirmation, delete the wallet only if the api permits it before creating a new wallet under a new key for one enrollment; reconcile unresolved child cards first. connected does not imply token eligibility; inspect wallets payment-methods" + +const kernelReplacementHelp = "after user confirmation, delete only if the api permits it and create one new card under a new key for a new purchase; reconcile unresolved outcomes first" + +const kernelTerminalHelp = "merchant decline: report the observed DECLINED outcome when confirm_transaction is advertised. expired approval link or code before checkout: do not submit. if checkout was submitted, reconcile and report the observed outcome first (reporting may remain available after expiry). if never submitted, or after a confirmed decline, " + kernelReplacementHelp + "; ask for a different card or merchant after decline. unknown outcome: ask the user to check the merchant order page, email or statement and report only after they confirm; never guess or repeat checkout" diff --git a/cmd/vaults_kernel_payment_test.go b/cmd/vaults_kernel_payment_test.go index c3f94ec7..31b8f441 100644 --- a/cmd/vaults_kernel_payment_test.go +++ b/cmd/vaults_kernel_payment_test.go @@ -164,7 +164,7 @@ func TestKernelCardGuidance(t *testing.T) { }) _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1") require.NoError(t, err) - for _, want := range []string{"report the observed outcome once with confirm_transaction", "do not report or retry unknown outcomes", "no stored billing fields", "billing_* returns field_unavailable", "enter billing details on the merchant page"} { + for _, want := range []string{"report one observed merchant outcome only when confirm_transaction is advertised", "unknown means no reliable merchant outcome", "no stored billing fields", "billing_* returns field_unavailable", "enter billing details on the merchant page"} { assert.Contains(t, human, want) } assert.NotContains(t, human, "does not observe merchant charges") diff --git a/cmd/vaults_kernel_recovery_test.go b/cmd/vaults_kernel_recovery_test.go new file mode 100644 index 00000000..12448afe --- /dev/null +++ b/cmd/vaults_kernel_recovery_test.go @@ -0,0 +1,242 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKernelFillExpirationBindings(t *testing.T) { + for _, fields := range []string{`[{"field":"exp_month","selector":"#month"},{"field":"exp_year","selector":"#year"}]`, `[{"field":"expiration","selector":"#expiry","format":"MM/YYYY"}]`} { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, strings.Replace(kernelCardFixture, `"type":"authorize"`, `"type":"fill"`, 1)) + return + } + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + var request struct { + Fields []json.RawMessage `json:"fields"` + } + require.NoError(t, json.Unmarshal(body, &request)) + actual, err := json.Marshal(request.Fields) + require.NoError(t, err) + assert.JSONEq(t, fields, string(actual)) + results := make([]map[string]any, 0, len(request.Fields)) + for i := range request.Fields { + results = append(results, map[string]any{"index": i, "status": "filled"}) + } + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"type": "fill", "status": "completed", "fields": results})) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", `{"browser_id":"browser-1","page_url":"https://shop.example/checkout","fields":`+fields+`}`, "-o", "json") + require.NoError(t, err) + } +} + +func TestKernelCardholderOpenHelp(t *testing.T) { + for _, command := range [][]string{{"wallets", "create"}, {"wallets", "get"}, {"items", "invoke"}} { + cmd, _, err := newVaultsCommand().Find(command) + require.NoError(t, err) + assert.Contains(t, cmd.Flags().Lookup("open").Usage, "agents must share privately") + } +} + +func TestKernelOperationRecoveryRechecksAdvertisement(t *testing.T) { + for _, operation := range []string{"authorize", "confirm_transaction", "restart_verification"} { + t.Run(operation, func(t *testing.T) { + fixture := strings.Replace(kernelCardFixture, `"type":"authorize"`, fmt.Sprintf(`"type":%q`, operation), 1) + if operation == "restart_verification" { + fixture = strings.Replace(fixture, `"status":"requested"`, `"status":"recovery_required"`, 1) + } + posts, gets := 0, 0 + var bodies []string + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + gets++ + if posts >= 2 { + _, _ = io.WriteString(w, strings.Replace(fixture, fmt.Sprintf(`[{"type":%q,"description":"Authorize this purchase"}]`, operation), `[]`, 1)) + } else { + _, _ = io.WriteString(w, fixture) + } + return + } + posts++ + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + bodies = append(bodies, string(body)) + if posts == 1 { + w.WriteHeader(500) + _, _ = io.WriteString(w, `{"code":"provider_error","message":"reconcile before retrying https://vault.example/approval#secret HTTPS://vault.example/enroll?secret=x"}`) + } else { + _, _ = io.WriteString(w, kernelCardFixture) + } + }) + args := []string{"vaults", "items", "invoke", "checkout", "order-1", operation, "-o", "json"} + if operation == "confirm_transaction" { + args = append(args, "--params", confirmTransactionFixture) + } + _, _, err := executeVaultCommand(t, client, args...) + require.Error(t, err) + message := util.CleanedUpSdkError{Err: err}.Error() + assert.Contains(t, message, "reconcile before retrying") + assert.Contains(t, message, "invoke "+operation+" once more only if freshly advertised") + assert.NotContains(t, message, "https://vault.example") + assert.NotContains(t, message, "HTTPS://vault.example") + var apiErr *kernel.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 500, apiErr.StatusCode) + assert.NotContains(t, apiErr.RawJSON(), "secret") + assert.Nil(t, apiErr.Request) + assert.Nil(t, apiErr.Response) + assert.Equal(t, 1, posts, "no automatic retry") + _, _, err = executeVaultCommand(t, client, args...) + require.NoError(t, err) + _, _, err = executeVaultCommand(t, client, args...) + require.Error(t, err) + assert.Equal(t, 2, posts) + assert.Equal(t, 3, gets) + assert.Equal(t, bodies[0], bodies[1]) + }) + } +} + +func TestKernelCreateRetryKeepsKeyAndSpec(t *testing.T) { + for _, itemType := range []string{"wallet", "card"} { + t.Run(itemType, func(t *testing.T) { + var requests, paths []string + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + requests = append(requests, string(body)) + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + if len(requests) == 1 { + w.WriteHeader(500) + _, _ = io.WriteString(w, `{"code":"provider_error","message":"provider unavailable"}`) + } else if itemType == "wallet" { + _, _ = io.WriteString(w, kernelWalletFixture) + } else { + _, _ = io.WriteString(w, kernelCardFixture) + } + }) + spec := `{}` + if itemType == "card" { + spec = `{"wallet":"cardholder","amount":1200,"currency":"USD","merchant_name":"Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US","merchant_category":"Books","merchant_category_code":"5942"}` + } + args := []string{"vaults", itemType + "s", "create", "checkout", "item-1", "--provider", "kernel", "--spec", spec, "-o", "json"} + _, _, err := executeVaultCommand(t, client, args...) + require.ErrorContains(t, err, "identical key and identical spec") + assert.Len(t, requests, 1) + _, _, err = executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, requests[0], requests[1]) + assert.Equal(t, paths[0], paths[1]) + if itemType == "card" { + assert.Contains(t, requests[0], `"merchant_category_code":"5942"`) + } + }) + } +} + +func TestKernelRateLimitAndAuthorizeConflictGuidance(t *testing.T) { + for _, after := range []string{"30", "Wed, 14 Oct 2026 12:00:00 GMT"} { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, kernelCardFixture) + return + } + w.Header().Set("Retry-After", after) + w.WriteHeader(429) + _, _ = io.WriteString(w, `{"code":"rate_limited","message":"provider limit"}`) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize") + require.ErrorContains(t, err, "Retry-After ("+after+")") + assert.Contains(t, err.Error(), "only if freshly advertised") + } + var apiErr kernel.Error + require.NoError(t, json.Unmarshal([]byte(`{"code":"conflict","message":"card authorization outcome is unknown"}`), &apiErr)) + apiErr.StatusCode = 409 + err := kernelVaultRequestError(&apiErr, "authorize") + assert.Contains(t, err.Error(), "no authorize inputs to fix") + assert.Contains(t, err.Error(), "card authorization outcome is unknown") +} + +func TestKernelConfirmationMatchesApprovedPurchase(t *testing.T) { + for _, raw := range []string{strings.Replace(confirmTransactionFixture, `"USD"`, `"EUR"`, 1), strings.Replace(confirmTransactionFixture, `"amount":100`, `"amount":1201`, 1)} { + 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, strings.Replace(kernelCardFixture, `"type":"authorize"`, `"type":"confirm_transaction"`, 1)) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "confirm_transaction", "--params", raw) + require.ErrorContains(t, err, "operation was not invoked") + assert.Equal(t, 1, calls) + } +} + +func TestKernelFillFieldUnavailableDoesNotAskForCardValues(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, strings.Replace(kernelCardFixture, `"type":"authorize"`, `"type":"fill"`, 1)) + return + } + w.WriteHeader(400) + _, _ = io.WriteString(w, `{"code":"field_unavailable","message":"unsupported billing field"}`) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", fillParamsFixture) + require.Error(t, err) + assert.Contains(t, err.Error(), "enter billing details on the merchant page") + assert.Contains(t, err.Error(), "no fields were written") + assert.NotContains(t, err.Error(), "collect missing values") +} + +func TestKernelRecoveryJSONAndStateGuidance(t *testing.T) { + for _, status := range []string{"requested", "recovery_required", "expired", "declined", "degraded", "reconnect_required"} { + fixture := strings.Replace(kernelCardFixture, `"status":"requested"`, fmt.Sprintf(`"status":%q`, status), 1) + if status == "degraded" || status == "reconnect_required" { + fixture = strings.Replace(kernelWalletFixture, `"status":"pending_authorization"`, fmt.Sprintf(`"status":%q`, status), 1) + } + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1") + require.NoError(t, err) + switch status { + case "degraded", "reconnect_required": + assert.Contains(t, human, "delete the wallet only if the api permits it") + assert.Contains(t, human, "share the new card_enrollment") + case "requested": + assert.Contains(t, human, "Visa may require") + assert.NotContains(t, human, "merchant decline") + case "recovery_required": + assert.Contains(t, human, "support@kernel.sh") + assert.Contains(t, human, "project, vault, item key") + assert.Contains(t, human, "do not assume a new card is safe") + out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1", "-o", "json") + require.NoError(t, err) + assert.NotContains(t, out, `"authorize"`) + default: + assert.Contains(t, human, "merchant decline") + assert.Contains(t, human, "if never submitted") + assert.Contains(t, human, "PENDING means") + assert.Contains(t, human, "ERROR means") + assert.NotContains(t, human, "Visa may require") + } + } +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 8b2f6129..a3e7f5ef 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -44,7 +44,7 @@ var vaultItemFields = vaultOutputFields{ "expanded": {"payment_methods": vaultMethodFields}, "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "checkout_origin": nil, - "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "merchant_country": nil, + "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "merchant_country": nil, "merchant_category": nil, "merchant_category_code": nil, "context": nil, "expires_at": nil, "description": nil, "account": nil, "connection_id": nil, "requests": onePasswordRequestFields, "fields": vaultFieldsOf("name label type required sensitive"), @@ -139,6 +139,41 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes result[key] = filtered } } + if _, item := fields["available_operations"]; item { + var state struct { + Status string `json:"status"` + } + if json.Unmarshal(result["state"], &state) == nil && state.Status == "recovery_required" { + var operations []vaultItemOperation + if err := json.Unmarshal(result["available_operations"], &operations); err != nil { + return nil, err + } + allowed := make([]vaultItemOperation, 0) + for _, op := range operations { + if op.Type == "restart_verification" { + allowed = append(allowed, op) + } + } + result["available_operations"], _ = json.Marshal(allowed) + delete(result, "action") + var stateFields vaultJSON + if err := json.Unmarshal(result["state"], &stateFields); err != nil { + return nil, err + } + for _, field := range []string{"authorization", "preparation"} { + if len(stateFields[field]) == 0 || string(stateFields[field]) == "null" { + continue + } + var detail vaultJSON + if err := json.Unmarshal(stateFields[field], &detail); err != nil { + return nil, err + } + delete(detail, "approval_url") + stateFields[field], _ = json.Marshal(detail) + } + result["state"], _ = json.Marshal(stateFields) + } + } var itemType string if result["spec"] != nil && result["state"] != nil && json.Unmarshal(result["type"], &itemType) == nil && itemType == "credential" { if err := preservePublicCredentialValues(object, result); err != nil { @@ -448,8 +483,8 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemActions) { if actions.RecoveryRequired { - guidance := "do not retry, delete, or replace it. reconcile with the provider or support; no reset operation exists." - if len(actions.Operations) > 0 { + guidance := kernelSupportGuidance + if actions.advertises("restart_verification") { guidance = vaultRecoveryHelp } pterm.Warning.Println("recovery_required: the original operation is unresolved, not declined or expired. " + guidance) @@ -499,7 +534,14 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction } if item.Type == "card" { if item.Spec.Provider == "kernel" { - pterm.Info.Println("Visa may require the returned spend_approval action; observe readiness with items get --wait 60. ready means a one-time code is available, not that the merchant charged the card. fill and submit checkout before expires_at; do not retry uncertain authorizations.") + switch item.State.Status { + case "requested", "pending_authorization", "ready": + pterm.Info.Println("Visa may require the returned spend_approval action; share it privately, never open it for the cardholder. ready means a one-time code is available, not that the merchant charged the card. fill and submit checkout before expires_at.") + } + switch item.State.Status { + case "ready", "declined", "expired", "consumed": + pterm.Info.Println(kernelTerminalHelp) + } } if item.State.JSON.Preparation.Valid() { switch item.State.Status { @@ -518,14 +560,14 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction pterm.Info.Println("aliases are non-secret checkout values. use only in a browser created with this vault attached; ready does not mean paid.") } if item.Spec.Provider == "kernel" { - pterm.Info.Println("after the user submits checkout, report the observed outcome once with confirm_transaction when advertised. never infer payment success from fill; do not report or retry unknown outcomes. kernel cards have no stored billing fields; billing_* returns field_unavailable. enter billing details on the merchant page.") + pterm.Info.Println(kernelTransactionHelp + ". kernel cards have no stored billing fields; billing_* returns field_unavailable. enter billing details on the merchant page.") } else { pterm.Info.Println("inspect items events for payment outcomes. never retry automatically; if recovery permits abandonment, delete the card only after explicit user confirmation before creating a replacement.") } } else { wallet := item.AsWallet() if item.Spec.Provider == "kernel" { - pterm.Info.Println("share the hosted card_enrollment url with the cardholder. connected does not imply token eligibility; inspect wallets payment-methods for eligible and reasons before requesting a card.") + pterm.Info.Println(kernelWalletHelp) } for _, expansion := range wallet.AvailableExpansions { pterm.Printf("available expansion: %s — %s\n", expansion.Type, expansion.Description) @@ -555,7 +597,7 @@ func printVaultPaymentMethods(provider string, methods []kernel.VaultPaymentMeth } PrintTableNoPad(rows, true) if provider == "kernel" { - pterm.Info.Println("kernel cards use the wallet key, not a payment method id. wait for single_use_card.eligible before requesting a card; reasons explain ineligibility. missing capability means unknown, not eligible.") + pterm.Info.Println("kernel cards use the wallet key, not a payment method id. " + kernelEligibilityHelp) } else { 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.") } diff --git a/cmd/vaults_payment_help_test.go b/cmd/vaults_payment_help_test.go index 0913a776..831aff42 100644 --- a/cmd/vaults_payment_help_test.go +++ b/cmd/vaults_payment_help_test.go @@ -44,8 +44,8 @@ func TestVaultPaymentHelpProviderBoundaries(t *testing.T) { "never in an automatic loop. never fall back to aliases", "requests are not automatically retried", "use only when advertised for an AgentCard card", - "report the observed outcome once", - "do not report or retry unknown outcomes", + "report one observed merchant outcome", + "unknown means no reliable merchant outcome", "restart_verification takes no parameters", "kernel cards have no stored billing fields", "billing_* returns field_unavailable (400) before any write", diff --git a/cmd/vaults_policy.go b/cmd/vaults_policy.go index c21f71eb..151b573e 100644 --- a/cmd/vaults_policy.go +++ b/cmd/vaults_policy.go @@ -20,8 +20,16 @@ type vaultItemActions struct { Operations []vaultItemOperation } -// Execution and human output use this policy; JSON preserves the API-advertised -// fields through the separate display-safe projection. +func (a vaultItemActions) advertises(operation string) bool { + for _, op := range a.Operations { + if op.Type == operation { + return true + } + } + return false +} + +// Recovery exposes only the explicitly supported restart operation on every surface. func effectiveVaultItemActions(item *kernel.VaultItemUnion) (vaultItemActions, error) { var fields struct { Operations []vaultItemOperation `json:"available_operations"` diff --git a/cmd/vaults_policy_test.go b/cmd/vaults_policy_test.go index 4310d4b5..3f58b0f8 100644 --- a/cmd/vaults_policy_test.go +++ b/cmd/vaults_policy_test.go @@ -40,9 +40,14 @@ func TestVaultRecoveryActionDisplayPolicy(t *testing.T) { out, human, err := executeVaultInputCommand(t, client, "", args...) require.NoError(t, err) if output == "json" { - assert.Contains(t, out, `"spend_approval"`) assert.Contains(t, out, `"available_operations"`) - assert.Contains(t, out, `"authorize"`) + if status == "recovery_required" { + assert.NotContains(t, out, "spend_approval") + assert.NotContains(t, out, `"authorize"`) + } else { + assert.Contains(t, out, `"spend_approval"`) + assert.Contains(t, out, `"authorize"`) + } assert.Empty(t, human) } else if status == "recovery_required" { assert.NotContains(t, human, "spend_approval") diff --git a/cmd/vaults_restart_verification_test.go b/cmd/vaults_restart_verification_test.go index c6dc9c32..f8deaa02 100644 --- a/cmd/vaults_restart_verification_test.go +++ b/cmd/vaults_restart_verification_test.go @@ -52,7 +52,7 @@ func TestVaultRestartVerificationRecovery(t *testing.T) { }) if !advertised { require.ErrorContains(t, err, "recovery_required") - assert.EqualError(t, err, "recovery_required: reconcile the original operation with the provider or support; do not retry, delete, or replace it") + assert.EqualError(t, err, "recovery_required: "+kernelSupportGuidance) assert.Zero(t, posts) assert.Empty(t, opened) assert.Empty(t, out) @@ -93,9 +93,9 @@ func TestVaultRecoveryRejectsOtherAdvertisedOperations(t *testing.T) { args = append(args, "--params", fillParamsFixture) } out, human, err := executeVaultCommand(t, client, args...) - message := "recovery_required: reconcile the original operation with the provider or support; do not retry, delete, or replace it" + message := "recovery_required: " + kernelSupportGuidance if restartAdvertised { - message += ". invoke the advertised restart_verification once to get a fresh approval link" + message = "recovery_required: " + vaultRecoveryHelp } assert.EqualError(t, err, message) assert.Equal(t, 1, calls) @@ -126,7 +126,7 @@ func TestVaultRecoveryDisplaysAdvertisedRestart(t *testing.T) { require.NoError(t, err) assert.Contains(t, human, "available operation: restart_verification") assert.Contains(t, human, "invoke: kernel vaults items invoke -- checkout order-1 restart_verification") - assert.Contains(t, human, "invoke it once") + assert.Contains(t, human, "explicit user approval each time") assert.NotContains(t, human, "available operation: authorize") assert.NotContains(t, human, "available operation: fill") assert.NotContains(t, human, "must stay hidden")