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=