From 06ea83e0b27f704a28357299d3b6fd3210e4d5e6 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:11:14 +0000 Subject: [PATCH 1/6] Support Link Pay Token checkout in vault card commands --- README.md | 90 +++++++---- cmd/vaults.go | 8 + cmd/vaults_commands.go | 57 +++++-- cmd/vaults_fill.go | 26 ++-- cmd/vaults_fill_test.go | 1 - cmd/vaults_help.go | 7 +- cmd/vaults_invoke_test.go | 2 +- cmd/vaults_link.go | 106 +++++++++++++ cmd/vaults_link_test.go | 266 ++++++++++++++++++++++++++++++++ cmd/vaults_operation_params.go | 14 +- cmd/vaults_output.go | 15 +- cmd/vaults_payment_help_test.go | 2 +- cmd/vaults_test.go | 2 +- go.mod | 2 +- go.sum | 4 +- 15 files changed, 532 insertions(+), 70 deletions(-) create mode 100644 cmd/vaults_link.go create mode 100644 cmd/vaults_link_test.go diff --git a/README.md b/README.md index 8bf3edc5..35bae208 100644 --- a/README.md +++ b/README.md @@ -416,10 +416,10 @@ cannot switch projects. | `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\|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 cards update --provider link\|agentcard --spec ''` | Update a card spec before it is authorized at a checkout; 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` | -| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; `authorize --open` opens a returned HTTPS action; `prepare_checkout --params ''` prepares an unused AgentCard card for Square Pay; `fill --params ''` fills checkout or login fields; `collect --open` opens a credential item's hosted form | +| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; `authorize --browser-id --page-url ` authorizes a Link card at checkout; `authorize --open` opens a returned HTTPS action; `prepare_checkout --params ''` prepares an unused AgentCard card for Square Pay; `fill --params ''` fills checkout or login fields; `collect --open` opens a credential item's hosted form | | `kernel vaults items webmcp invoke ` | Invoke a live WebMCP tool with item fields bound to null input slots; `--browser-id`, `--tool-ref`, `--page-url`, `--input`/`--input-file`, repeatable `--bind =`, `--timeout-sec 1..120`. Same as `items invoke webmcp_invoke --params ''` | | `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | | `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | @@ -454,15 +454,18 @@ TypeScript-style types, which must stay in sync with the [API spec](https://api. `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. + `metadata`, and `expires_at` are supported through JSON. `amount` is at most 500000 minor + units; a virtual card allows at most 50000. - **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 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. Link and +cannot change after authorization starts. Checkout cards can be edited between authorizations, +but a Link card authorized at a checkout cannot be updated (the API returns 409): delete it and +create a new one. An uncertain update enters `recovery_required` and must not be retried. +Identical card creation returns its existing state without polling, reauthorizing, or resetting +recovery. Link fill is limited to the origin of the checkout the card was authorized at; AgentCard checkout domains remain provider-assigned; Kernel fill is locked to the exact origin of `merchant_url`. Neither command submits a merchant payment. @@ -651,12 +654,12 @@ once more only if freshly advertised. Authorize 409 requires state inspection, n 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 +#### Link card checkout -Link OAuth, user approval, and provider-issued single-use card issuance precede browser checkout. -Issued Link cards use only the advertised vault-item `fill` operation for checkout fields. +Each item's `available_operations` descriptions and the Link wallet's `description` are +authoritative; `items get` prints them. The flow: -1. Create/select a vault in the effective project. Connect the wallet in the provider's UI: +1. Connect the wallet once, in the provider's UI: ```bash kernel vaults create --name checkout @@ -665,11 +668,18 @@ Issued Link cards use only the advertised vault-item `fill` operation for checko kernel vaults items get checkout wallet-1 --wait 60 ``` -2. Once connected, list methods and explicitly choose a returned ID: +2. Reach the final checkout page in a browser created with the vault attached. Note the amount, + currency, merchant name and URL, and choose a funding method: ```bash + kernel browsers create --vault checkout kernel vaults wallets payment-methods checkout wallet-1 - # Equivalent: kernel vaults items get checkout wallet-1 --expand payment_methods + ``` + +3. Create the card. This does not contact Link; the card is `requested` and advertises + `authorize`. To change the amount or merchant before authorizing, use `cards update`. + + ```bash kernel vaults cards create checkout order-1 --provider link --spec '{ "wallet": "wallet-1", "payment_method_id": "", @@ -681,30 +691,52 @@ Issued Link cards use only the advertised vault-item `fill` operation for checko }' ``` -3. After explicit user approval, authorize **only if the item advertises it**. Follow the - returned approval action, then observe: +4. Authorize at the checkout with the browser session ID and the exact current top-level + checkout URL. Pass both flags or neither. KERNEL inspects the checkout, chooses how the + credential is delivered (a Link Pay Token or a virtual card), limits fill to that checkout's + origin, and sends the user an approval request. Do not inspect or invoke WebMCP yourself. ```bash - 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 + kernel vaults items invoke checkout order-1 authorize \ + --browser-id --page-url https://shop.example/checkout ``` -4. When ready, attach the same vault to a new browser, navigate to checkout, and use - the advertised `fill` operation below. Respect returned permitted domains: +5. Share the approval URL from the item's action with the user, then wait until `fill` is + advertised. To re-check sooner, invoke `authorize` again; checkout context is not needed, + and re-checking never creates another payment request. ```bash - kernel browsers create --vault checkout + kernel vaults items get checkout order-1 --wait 60 ``` -5. Observe outcomes independently of merchant checkout submission: +6. Fill as the item's `fill` description says, in any browser with the vault attached. A Link + Pay Token takes `browser_id` and `page_url` and omits `fields`; a virtual card also takes + field/selector bindings (see [Fill browser fields](#fill-browser-fields)). ```bash - kernel vaults items get checkout order-1 - kernel vaults items events checkout order-1 - kernel vaults items events checkout order-1 --after --wait 60 + kernel vaults items invoke checkout order-1 fill \ + --params '{"browser_id":"","page_url":"https://shop.example/checkout"}' -o json ``` +7. Submit the checkout yourself; fill never submits payment. Failed or unknown fills are safe + to retry. + +Recovery: + +- Authorize errors `page_not_found`, `ambiguous_page`, `browser_not_found`, `browser_unavailable`, + `destination_denied`, `timeout`, and `browser_error` happen before any payment request. The CLI + prints the code and message. Make sure the checkout is open in exactly one tab at `page_url`, + then authorize again. If checkout inspection keeps failing, authorize without `--browser-id` + and `--page-url` to issue a virtual card that fills any page on the `merchant_url` origin. +- Once a card is authorized at a checkout, `cards update` returns 409: delete the card and + create a new one. +- If authorization is declined or expires, read `status_reason`, then delete the card and + create a new one. +- If the amount exceeds the 50000 minor-unit virtual card limit on a checkout without Link Pay + Token support, delete the card and tell the user. +- If the result of submitting payment is unclear, check the merchant's order confirmation or + order history before paying again. `items events` shows the card's recorded events. + #### AgentCard checkout preparation For a separate AgentCard flow, create a vault and complete the wallet enrollment action: @@ -763,7 +795,8 @@ 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, -possibly with a required user action. `collect` is also parameterless and returns a credential +possibly with a required user action. For a Link card at checkout, `--browser-id` and +`--page-url` (both or neither) add `browser_id` and `page_url`. `collect` is also parameterless and returns a credential collection URL. `restart_verification` is parameterless and sends `{"type":"restart_verification"}`. `--open` is supported for authorize, collect, prepare_checkout, 1pw_recover, and restart_verification. `fill`, `prepare_checkout`, and `confirm_transaction` accept inputs in @@ -815,7 +848,8 @@ Do not automatically retry or switch checkout paths after an uncertain result. Fill supports credential items and ready Link or Kernel cards when advertised by the API, not AgentCard. All use the same execution and outcome handling. Credential bindings use declared field names, including TOTP fields, and must omit `format`. Credentials may omit `page_url` only when the API -can resolve a unique page. Card bindings require an exact HTTPS `page_url` and the card fields +can resolve a unique page. Cards require an exact HTTPS `page_url`. Follow the item's advertised +`fill` description for the rest: a Link Pay Token omits `fields`; other cards use the card fields listed below. The vault must already be attached to the browser for fill authorization. Fill writes real stored values into the browser without returning them in the result; unrestricted browser/CDP access can read those values. It does not explicitly submit forms @@ -830,10 +864,10 @@ kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browse the CLI does not resolve names. - For cards, `page_url` is the exact current top-level HTTPS URL, including path, query, and fragment, without embedded credentials. It must match exactly one open page; no prefix/glob matching. -- `fields` contains 1-32 bindings in write order. Each has `field` and a nonempty CSS +- `fields`, when the item's fill description asks for it, contains 1-32 bindings in write order. Each has `field` and a nonempty CSS `selector` targeting an editable input/select or its container. The API searches the selected page and descendants, including payment iframes. Do not supply frame IDs or literal values. -- Link stored card fields: `number`, `cvc`, `exp_month` (MM), `exp_year` (YYYY), `billing_name`, +- Link virtual card fields: `number`, `cvc`, `exp_month` (MM), `exp_year` (YYYY), `billing_name`, `billing_line1`, `billing_line2`, `billing_city`, `billing_state`, `billing_postal_code`, `billing_country`. Billing fields use the stored address without reformatting; request only needed fields. Missing requested billing data fails validation before browser writes. diff --git a/cmd/vaults.go b/cmd/vaults.go index dc75a9ec..232cb112 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -310,6 +310,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } } if operation == "fill" { + if item.Type != "card" && len(params.Fill.Fields) == 0 { + return fmt.Errorf("fill for %s items requires 1-32 fields binding declared field names to selectors; fill was not invoked", item.Type) + } return c.fill(ctx, vault, key, params.Fill, output, item.Spec.Provider) } if operation == "1pw_fill" { @@ -337,6 +340,8 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par request.OfPrepareCheckout = &kernel.PrepareCheckoutVaultItemOperationRequestParam{Type: "prepare_checkout", Checkout: *params.Checkout} } else if operation == "collect" { request.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: "collect"} + } else if params != nil && params.Authorize != nil { + request.OfAuthorize = params.Authorize } else { // Preserve support for other advertised parameterless operations. request.OfAuthorize = &kernel.AuthorizeVaultItemOperationRequestParam{Type: kernel.AuthorizeVaultItemOperationRequestType(operation)} @@ -352,6 +357,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if item.Spec.Provider == "kernel" { return kernelVaultRequestError(err, operation) } + if operation == "authorize" && item.Type == "card" && item.Spec.Provider == "link" { + return linkAuthorizeError(err) + } return util.CleanedUpSdkError{Err: err} } if response == nil || (response.Type != "card" && response.Type != "wallet" && response.Type != "credential" && response.Type != "credential_account") { diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index c1246288..3e0b9c9c 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -87,27 +87,33 @@ vault names, item keys, and project ownership are immutable. 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. +3. attach the vault with browsers create --vault ; attachment is required for + authorizing a Link card at a checkout and for fill. for Link, reach the final checkout + page before creating the card. +4. create a card request with --provider and --spec json. +5. 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 and kernel cards use only advertised fill with --params for browser checkout. +6. 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. kernel cards have no stored billing fields; enter billing details on the merchant page. 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 +7. after the user submits kernel card checkout, report the observed outcome once with 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. -there is no domain-setting api. +` + linkCardFlowHelp + ` + +permitted checkout domains are displayed when returned. Link card fill is limited to the +origin of the checkout it was authorized at (or merchant_url's origin for a virtual card +authorized without a checkout); kernel card fill uses the exact origin of merchant_url; +AgentCard domains are provider-assigned. 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 for imported Link grants; both accept protected files or stdin. -never automatically retry failed, timed-out, rejected, or indeterminate payments. +do not automatically repeat failed, timed-out, rejected, or indeterminate payments: inspect +the item, its events, and the merchant's order history, then follow the advertised operations. when an item explicitly permits user-confirmed abandonment, delete that card before creating a replacement. json output preserves returned public fields but omits unknown/opaque provider data.`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, @@ -189,14 +195,18 @@ each vault has its own key; it is created on first request and may be cached.`, invoke := &cobra.Command{Use: "invoke ", Short: "invoke an operation advertised by an item", Args: cobra.ExactArgs(3), PreRunE: vaultPreRun, Long: `retrieve the item and invoke only an operation listed in available_operations. collect returns a time-scoped url for the full credential form without clearing values. -authorize sends {"type":"authorize"} for payment authorization. +authorize sends {"type":"authorize"} for payment authorization. for a Link card at the final +checkout page, add --browser-id and --page-url (both or neither) to send browser_id and +page_url; see Link card checkout below. read the operation description and follow any approval requirements before invoking. fill requires --params json or --spec-file with browser_id (session id, not name) -and 1-32 ordered fields (field, selector). do not include type, values, or frame ids. +and the inputs the item's advertised fill description asks for: 1-32 ordered fields +(field, selector) for credentials, kernel cards, and Link virtual cards; no fields for a +Link Pay Token. do not include type, values, or frame ids. the vault must already be attached to the browser. page_url selects an existing page; fill never navigates. credentials use declared field names, must omit format, and may omit page_url only when the api can resolve a unique page. totp codes stay server-generated. -cards require an exact https page_url. Link stored fields: number, cvc, exp_month (MM), +cards require an exact https page_url. Link virtual card fields: number, cvc, exp_month (MM), 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). @@ -254,6 +264,8 @@ completed/awaiting_submission exit 0 without confirming site acceptance; cancele exit nonzero with json retained on stdout. unknown means the tool may have run: inspect the browser and never retry automatically. 400/403/404/409 rejections mean the tool was not invoked. +` + linkCardFlowHelp + ` + 1Password credentials (see credentials --help) use --params or --spec-file without type: 1pw_create_access_request: no browser needed; optional goal (<=140), reason (<=100), keywords (1-5 strings); reason and keywords only for single-entry credentials. @@ -282,6 +294,8 @@ 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 checkout order-1 authorize --browser-id --page-url https://shop.example/checkout + kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"","page_url":"https://shop.example/checkout"}' -o json kernel vaults items invoke user-vault github 1pw_create_access_request --params '{"reason":"Sign in to GitHub"}' kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"timeout_seconds":60}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login"}' @@ -310,18 +324,27 @@ JSON if err != nil { return err } + authorize, err := parseVaultAuthorizeCheckout(cmd, args[2]) + if err != nil { + return err + } + if authorize != nil { + params = &vaultOperationParams{Authorize: authorize} + } return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} 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; cardholder links are human-only, agents must share privately") + invoke.Flags().String("browser-id", "", "authorize a Link card at checkout: browser session id with this vault attached (requires --page-url)") + invoke.Flags().String("page-url", "", "authorize a Link card at checkout: exact current top-level checkout url open in --browser-id (requires --browser-id)") addVaultJSONOutputFlag(invoke) 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, - Long: "create a wallet at an immutable key and follow the returned provider action.\n" + vaultSpecHelp + vaultWalletSpecHelp, + Long: "create a wallet at an immutable key and follow the returned provider action.\na connected Link wallet pays at checkout through cards; its returned description and the flow below describe how.\n" + vaultSpecHelp + vaultWalletSpecHelp + "\n" + linkCardFlowHelp, Example: ` kernel vaults wallets create checkout wallet-1 \ --provider link --spec '{ "authorization": { @@ -395,7 +418,7 @@ func newVaultDeleteCommand(item bool) *cobra.Command { func newVaultCardCommand(update bool) *cobra.Command { use, short := "create", "create a card request without authorizing it" if update { - use, short = "update", "update a card spec when the api permits configuration" + use, short = "update", "update a card spec before it is authorized at a checkout" } providers := "link|agentcard|kernel" if update { @@ -407,10 +430,12 @@ 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. +between authorizations, but a Link card authorized at a checkout cannot be updated: +delete it and create a new one. identical creates return existing state without resetting it. never reconfigure the same item to retry a failed, timed-out, rejected, or indeterminate payment. an AgentCard recovery item that permits abandonment must be deleted after explicit user confirmation before creating a replacement. -` + vaultSpecHelp + vaultCardSpecHelp, + +` + linkCardFlowHelp + "\n" + vaultSpecHelp + vaultCardSpecHelp, Example: " kernel vaults cards " + use + ` checkout order-1 \ --provider agentcard --spec '{ "wallet": "wallet-1", diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 086e914b..14b0b73a 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -32,7 +32,7 @@ var vaultFillResultFields = vaultOutputFields{ const vaultFillUncertain = "browser fields may have been written; fill never submits, so a retry is safe. retry only after checking the page, and never in an automatic loop. do not fall back to aliases." var vaultFillErrorMessages = map[string]string{ - "invalid_request": "check field names, formats, and browser parameters", + "invalid_request": "check field names, formats, and browser parameters; follow the item's fill description, which says whether to omit fields", "invalid_selector": "inspect the page and correct the selector", "duplicate_target": "multiple bindings resolve to the same element; use distinct targets", "timeout": "the fill deadline elapsed", @@ -100,7 +100,6 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil request := kernel.FillVaultItemOperationRequestParam{ BrowserID: params.BrowserID, Type: kernel.FillVaultItemOperationRequestTypeFill, - Fields: make([]kernel.VaultFillFieldParam, 0, len(params.Fields)), } if params.PageURL != "" { request.PageURL = kernel.Opt(params.PageURL) @@ -140,15 +139,20 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil } } else { pterm.Printf("fill: %s\n", result.Status) - rows := pterm.TableData{{"field index", "status", "error code"}} - for _, field := range result.Fields { - rows = append(rows, []string{strconv.Itoa(*field.Index), field.Status, field.ErrorCode}) + if len(result.Fields) > 0 { + rows := pterm.TableData{{"field index", "status", "error code"}} + for _, field := range result.Fields { + rows = append(rows, []string{strconv.Itoa(*field.Index), field.Status, field.ErrorCode}) + } + PrintTableNoPad(rows, true) } - PrintTableNoPad(rows, true) - if result.Status == "completed" { - pterm.Println("fields filled; this does not confirm website acceptance or form submission.") - } else { + switch { + case result.Status != "completed": pterm.Println(vaultFillUncertain) + case len(result.Fields) == 0: + pterm.Println("the checkout received the payment credential; fill never submits payment. submit the checkout yourself.") + default: + pterm.Println("fields filled; this does not confirm website acceptance or form submission.") } } if result.Status != "completed" { @@ -203,6 +207,10 @@ func parseVaultFillResult(raw json.RawMessage, count int) (*vaultFillResult, err } } } + // A fill without field bindings reports only an overall status. + if count == 0 && (result.Status == "failed" || result.Status == "unknown") { + status = result.Status + } if result.Status != status { return nil, invalid } diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index ff3f76c6..539ee57c 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -59,7 +59,6 @@ func TestVaultFillParamsValidation(t *testing.T) { "missing URL": replace(`"page_url":"https://shop.example/checkout?step=2#payment",`, ``), "empty fields": replace(`[{"field":"number","selector":"#card-number"},{"field":"expiration","format":"MM/YY","selector":"#expiry"},{"field":"cvc","selector":"#security-code"}]`, `[]`), "fields object": `{"browser_id":"id","page_url":"https://shop.example/","fields":{}}`, - "missing fields": `{"browser_id":"id","page_url":"https://shop.example/"}`, "null fields": `{"browser_id":"id","page_url":"https://shop.example/","fields":null}`, "null binding": `{"browser_id":"id","page_url":"https://shop.example/","fields":[null]}`, "array binding": `{"browser_id":"id","page_url":"https://shop.example/","fields":[[]]}`, diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index fb880b3e..9150a019 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -70,10 +70,10 @@ type LinkCardSpec = { provider: "link"; wallet: string; // wallet item key payment_method_id: string; // from wallets payment-methods - amount: number; // integer minor units; 1..500000 + amount: number; // integer minor units; 1..500000; a virtual card allows at most 50000 currency: string; // three letters merchant_name: string; // 1..255 characters - merchant_url: string; // uri + merchant_url: string; // uri; a virtual card authorized without a checkout fills only this origin context: string; // at least 100 characters line_items?: LinkLineItem[]; totals?: LinkTotal[]; @@ -146,5 +146,6 @@ it retains the current approval flow; even when supplied, autopilot may not appl user approval may still be required. this field does not guarantee payment success. prepared checkout uses preparation merchant_origin instead. -permitted domains are provider-assigned, not configurable in the spec. +permitted domains are not configurable in the spec. Link card fill is limited to the origin +of the checkout the card was authorized at; AgentCard domains are provider-assigned. ` diff --git a/cmd/vaults_invoke_test.go b/cmd/vaults_invoke_test.go index 1e4bac41..979475a3 100644 --- a/cmd/vaults_invoke_test.go +++ b/cmd/vaults_invoke_test.go @@ -141,7 +141,7 @@ func TestVaultGetOperationHintUsesExplicitProject(t *testing.T) { }) _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1", "--project", "chosen-project") require.NoError(t, err) - assert.Contains(t, human, "invoke: kernel vaults items invoke --project=chosen-project -- checkout order-1 authorize") + assert.Contains(t, human, "invoke: kernel vaults items invoke --project=chosen-project --browser-id '' --page-url '' -- checkout order-1 authorize") assert.NotContains(t, human, "other-project") } diff --git a/cmd/vaults_link.go b/cmd/vaults_link.go new file mode 100644 index 00000000..df097370 --- /dev/null +++ b/cmd/vaults_link.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/spf13/cobra" +) + +// linkCardFlowHelp follows the API's Link wallet and card operation descriptions. +// Every command that documents Link checkout includes it, so the flow reads the same everywhere. +const linkCardFlowHelp = `Link card checkout (each item's available_operations descriptions are authoritative): +1. reach the final checkout page in a browser created with --vault. note the amount, + currency, merchant name and url, and a payment_method_id from wallets payment-methods. +2. create the card with cards create --provider link. this does not contact Link; the + card is requested and advertises authorize. to change the amount or merchant before + authorizing, use cards update. +3. authorize with items invoke authorize --browser-id + --page-url . KERNEL inspects the checkout, chooses + how the credential is delivered (a Link Pay Token or a virtual card), limits fill to that + checkout's origin, and sends the user an approval request. do not inspect or invoke + WebMCP yourself. +4. share the approval url from the item's action with the user, then use items get --wait 60 + until fill is advertised. to re-check sooner, invoke authorize again; checkout context is + not needed, and re-checking never creates another payment request. +5. fill as the item's fill description says, in any browser with this vault attached: a Link + Pay Token takes browser_id and page_url and omits fields; a virtual card also takes + field/selector bindings. +6. submit the checkout yourself; fill never submits payment. failed or unknown fills are safe + to retry. +if authorize returns page_not_found, ambiguous_page, browser_not_found, browser_unavailable, +destination_denied, timeout, or browser_error, no payment request was made: make sure the +checkout is open in exactly one tab at page_url, then authorize again. if checkout inspection +keeps failing, authorize without --browser-id and --page-url to issue a virtual card that fills +any page on the merchant_url origin. once a card is authorized at a checkout, cards update +returns 409: delete the card and create a new one. if authorization is declined or expires, +read status_reason, then delete the card and create a new one. if the amount exceeds the +50000 minor-unit virtual card limit on a checkout without Link Pay Token support, delete the +card and tell the user. if the result of submitting payment is unclear, check the merchant's +order confirmation or order history before paying again.` + +const linkAuthorizeBrowserGuidance = "no payment request was made. make sure the checkout is open in exactly one tab at page_url in a browser with this vault attached, then authorize again. if checkout inspection keeps failing, authorize without --browser-id and --page-url to issue a virtual card that fills any page on the merchant_url origin" + +// Authorize returns these codes when checkout inspection fails, before any provider request. +var linkAuthorizeBrowserCodes = map[string]bool{ + "page_not_found": true, "ambiguous_page": true, "browser_not_found": true, "browser_unavailable": true, + "destination_denied": true, "timeout": true, "browser_error": true, +} + +// parseVaultAuthorizeCheckout returns the authorize request for --browser-id and --page-url, +// or nil when neither flag is set. +func parseVaultAuthorizeCheckout(cmd *cobra.Command, operation string) (*kernel.AuthorizeVaultItemOperationRequestParam, error) { + browserSet, pageSet := cmd.Flags().Changed("browser-id"), cmd.Flags().Changed("page-url") + if !browserSet && !pageSet { + return nil, nil + } + if operation != "authorize" { + return nil, fmt.Errorf("--browser-id and --page-url are only supported for authorize; pass %s inputs with --params or --spec-file", operation) + } + if browserSet != pageSet { + return nil, fmt.Errorf("--browser-id and --page-url must be passed together; authorize was not invoked") + } + browserID, _ := cmd.Flags().GetString("browser-id") + pageURL, _ := cmd.Flags().GetString("page-url") + if strings.TrimSpace(browserID) == "" { + return nil, fmt.Errorf("--browser-id must be a non-empty browser session ID, not a name") + } + if u, err := url.Parse(pageURL); err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + return nil, fmt.Errorf("--page-url must be the exact current top-level https checkout URL") + } + return &kernel.AuthorizeVaultItemOperationRequestParam{ + Type: kernel.AuthorizeVaultItemOperationRequestTypeAuthorize, + BrowserID: kernel.Opt(browserID), + PageURL: kernel.Opt(pageURL), + }, nil +} + +// linkAuthorizeError keeps the API's error code and message and adds the recovery step. +func linkAuthorizeError(err error) error { + var apiErr *kernel.Error + if !errors.As(err, &apiErr) { + return kernelVaultTransportError{message: "authorize result unavailable; re-get the item and follow its advertised operations. once approval is requested, authorize only re-checks it and never creates another payment request", cause: err} + } + var body struct { + Code string `json:"code"` + Message string `json:"message"` + } + _ = json.Unmarshal([]byte(apiErr.RawJSON()), &body) + var guidance string + switch { + case linkAuthorizeBrowserCodes[body.Code]: + guidance = linkAuthorizeBrowserGuidance + case apiErr.StatusCode == 429: + guidance = "wait, then authorize again" + case body.Code == "invalid_request": + guidance = "fix the reported input, then authorize again. if it reports that the amount exceeds the Link virtual card limit, delete this card and tell the user this checkout cannot be paid with a Link virtual card" + default: + guidance = "re-get the item and follow its advertised operations" + } + message := kernelVaultErrorURL.ReplaceAllString(body.Message, "[redacted url]") + return safeKernelAPIError(apiErr, body.Code, fmt.Sprintf("authorize failed (http %d): %s; %s", apiErr.StatusCode, message, guidance)) +} diff --git a/cmd/vaults_link_test.go b/cmd/vaults_link_test.go new file mode 100644 index 00000000..c6e8a821 --- /dev/null +++ b/cmd/vaults_link_test.go @@ -0,0 +1,266 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const linkPayTokenFillParams = `{"browser_id":"browser-1","page_url":"https://shop.example/checkout"}` + +func TestLinkCardFlowHelpIsShared(t *testing.T) { + flow := strings.Join(strings.Fields(linkCardFlowHelp), " ") + for _, path := range [][]string{nil, {"wallets", "create"}, {"cards", "create"}, {"cards", "update"}, {"items", "invoke"}} { + t.Run(strings.Join(path, " "), func(t *testing.T) { + cmd, _, err := newVaultsCommand().Find(path) + require.NoError(t, err) + assert.Contains(t, strings.Join(strings.Fields(cmd.Long), " "), flow) + }) + } +} + +func TestLinkCardFlowHelpOrder(t *testing.T) { + flow := strings.Join(strings.Fields(linkCardFlowHelp), " ") + last := -1 + for _, step := range []string{ + "1. reach the final checkout page", + "2. create the card with cards create --provider link", + "3. authorize with items invoke authorize --browser-id --page-url", + "4. share the approval url from the item's action", + "5. fill as the item's fill description says", + "6. submit the checkout yourself; fill never submits payment", + } { + index := strings.Index(flow, step) + require.Greater(t, index, last, step) + last = index + } + for _, want := range []string{ + "a Link Pay Token or a virtual card", + "a Link Pay Token takes browser_id and page_url and omits fields", + "page_not_found, ambiguous_page, browser_not_found, browser_unavailable, destination_denied, timeout, or browser_error", + "make sure the checkout is open in exactly one tab at page_url, then authorize again", + "authorize without --browser-id and --page-url to issue a virtual card", + "cards update returns 409: delete the card and create a new one", + "50000 minor-unit virtual card limit", + "check the merchant's order confirmation or order history before paying again", + } { + assert.Contains(t, flow, want) + } +} + +func TestVaultHelpAvoidsRetiredLinkWording(t *testing.T) { + var visit func(*cobra.Command) + visit = func(cmd *cobra.Command) { + text := strings.ToLower(strings.Join([]string{cmd.Short, cmd.Long, cmd.Example, cmd.Flags().FlagUsages()}, " ")) + for _, banned := range []string{"execution method", "execution mode", "credential type", "merchant_account", "authorize and restart_verification take no parameters"} { + assert.NotContains(t, text, banned, cmd.CommandPath()) + } + for _, child := range cmd.Commands() { + visit(child) + } + } + visit(newVaultsCommand()) + assert.NotContains(t, strings.ToLower(linkCardFlowHelp), "never retry") + + update, _, err := newVaultsCommand().Find([]string{"cards", "update"}) + require.NoError(t, err) + assert.Equal(t, "update a card spec before it is authorized at a checkout", update.Short) + assert.Contains(t, strings.Join(strings.Fields(update.Long), " "), "a Link card authorized at a checkout cannot be updated: delete it and create a new one") +} + +func TestVaultLinkAuthorizeSendsCheckout(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + name string + args []string + body string + }{ + {"checkout", []string{"--browser-id", "browser-1", "--page-url", "https://shop.example/checkout?step=pay#card"}, `{"type":"authorize","browser_id":"browser-1","page_url":"https://shop.example/checkout?step=pay#card"}`}, + {"without checkout", nil, `{"type":"authorize"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method == http.MethodPost { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, tc.body, string(body)) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) + }) + args := append([]string{"vaults", "items", "invoke", "checkout", "order-1", "authorize", "-o", "json"}, tc.args...) + _, _, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, 2, calls) + }) + } +} + +func TestVaultLinkAuthorizeCheckoutValidation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid authorize reached the API") }) + for name, tc := range map[string]struct { + args []string + want string + }{ + "browser only": {[]string{"authorize", "--browser-id", "browser-1"}, "must be passed together"}, + "page only": {[]string{"authorize", "--page-url", "https://shop.example/checkout"}, "must be passed together"}, + "blank browser": {[]string{"authorize", "--browser-id", " ", "--page-url", "https://shop.example/checkout"}, "--browser-id must be"}, + "http page": {[]string{"authorize", "--browser-id", "browser-1", "--page-url", "http://shop.example/checkout"}, "--page-url must be"}, + "relative page": {[]string{"authorize", "--browser-id", "browser-1", "--page-url", "/checkout"}, "--page-url must be"}, + "credentials page": {[]string{"authorize", "--browser-id", "browser-1", "--page-url", "https://user:pass@shop.example/"}, "--page-url must be"}, + "fill": {[]string{"fill", "--params", linkPayTokenFillParams, "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout"}, "only supported for authorize"}, + "params": {[]string{"authorize", "--params", linkPayTokenFillParams}, "authorize takes --browser-id and --page-url"}, + } { + t.Run(name, func(t *testing.T) { + _, _, err := executeVaultCommand(t, client, append([]string{"vaults", "items", "invoke", "checkout", "order-1"}, tc.args...)...) + require.ErrorContains(t, err, tc.want) + }) + } +} + +func TestVaultLinkAuthorizeErrorGuidance(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + status int + code, message string + want string + }{ + {400, "page_not_found", "no open tab is at page_url; open the checkout page in the browser and authorize again", linkAuthorizeBrowserGuidance}, + {400, "ambiguous_page", "more than one tab is at page_url; close the extra tabs and authorize again", linkAuthorizeBrowserGuidance}, + {404, "browser_not_found", "browser not found", linkAuthorizeBrowserGuidance}, + {409, "browser_unavailable", "browser is unavailable for checkout inspection", linkAuthorizeBrowserGuidance}, + {403, "destination_denied", "checkout destination is not authorized", linkAuthorizeBrowserGuidance}, + {400, "timeout", "checkout inspection failed", linkAuthorizeBrowserGuidance}, + {500, "browser_error", "checkout inspection failed", linkAuthorizeBrowserGuidance}, + {400, "invalid_request", "amount exceeds the Link virtual card limit", "fix the reported input, then authorize again. if it reports that the amount exceeds the Link virtual card limit, delete this card and tell the user this checkout cannot be paid with a Link virtual card"}, + {429, "rate_limited", "too many requests", "wait, then authorize again"}, + } { + t.Run(tc.code, func(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, requestedCardFixture) + return + } + w.WriteHeader(tc.status) + _, _ = fmt.Fprintf(w, `{"code":%q,"message":%q}`, tc.code, tc.message) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout") + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("%s: authorize failed (http %d): %s; %s", tc.code, tc.status, tc.message, tc.want)) + }) + } +} + +func TestVaultLinkPayTokenFillOmitsFields(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + status string + human string + fails bool + }{ + {"completed", "the checkout received the payment credential; fill never submits payment. submit the checkout yourself.", false}, + {"failed", vaultFillUncertain, true}, + {"unknown", vaultFillUncertain, true}, + } { + t.Run(tc.status, func(t *testing.T) { + result := fmt.Sprintf(`{"type":"fill","status":%q,"fields":[]}`, tc.status) + 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, readyFillCardFixture) + return + } + 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"}`, string(body)) + _, _ = io.WriteString(w, result) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", linkPayTokenFillParams, "-o", "json") + assert.JSONEq(t, result, out) + _, human, humanErr := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", linkPayTokenFillParams) + assert.Contains(t, human, "fill: "+tc.status) + assert.Contains(t, human, tc.human) + assert.NotContains(t, human, "field index") + if tc.fails { + require.Error(t, err) + require.Error(t, humanErr) + } else { + require.NoError(t, err) + require.NoError(t, humanErr) + } + }) + } +} + +func TestVaultFillWithoutFieldsRequiresFieldsForCredentials(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, readyFillCredentialFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user-vault", "login", "fill", "--params", `{"browser_id":"browser-1"}`) + require.ErrorContains(t, err, "fill for credential items requires 1-32 fields") + assert.Equal(t, 1, calls) + + _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", `{"browser_id":"browser-1","page_url":"https://shop.example/checkout","fields":[]}`) + require.ErrorContains(t, err, "omit fields when the item's fill description says to") + assert.Equal(t, 1, calls) +} + +func TestVaultLinkCardUpdate(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, status := range []int{200, 409} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/vaults/checkout/items/order-1", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.Contains(t, string(body), `"type":"card"`) + assert.Contains(t, string(body), `"provider":"link"`) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if status == 200 { + _, _ = io.WriteString(w, requestedCardFixture) + } else { + _, _ = io.WriteString(w, `{"code":"conflict","message":"this card was authorized at a checkout and cannot be changed; delete this card and create a new one with the updated details"}`) + } + }) + _, _, err := executeVaultCommand(t, client, append([]string{"vaults", "cards", "update", "checkout", "order-1"}, linkCardArgs()...)...) + if status == 200 { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, "conflict: this card was authorized at a checkout and cannot be changed; delete this card and create a new one") + }) + } +} + +func TestVaultLinkWalletDescription(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + description := "Use this wallet to pay at checkout." + fixture := strings.Replace(connectedWalletFixture, `"type":"wallet",`, `"type":"wallet","description":"`+description+`",`, 1) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "wallets", "get", "checkout", "wallet-1", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, fixture, out) + _, human, err := executeVaultCommand(t, client, "vaults", "wallets", "get", "checkout", "wallet-1") + require.NoError(t, err) + assert.Contains(t, human, description) +} diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 3f71ca57..9650e261 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -12,6 +12,7 @@ import ( ) type vaultOperationParams struct { + Authorize *kernel.AuthorizeVaultItemOperationRequestParam Fill *vaultFillParams WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam Checkout *kernel.VaultCheckoutContextParam @@ -144,12 +145,12 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations; authorize and restart_verification take no parameters") + return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations; authorize takes --browser-id and --page-url, and restart_verification takes no parameters") } return nil, nil } if !paramsSet { - return nil, fmt.Errorf("fill requires --params or --spec-file with browser_id and fields") + return nil, fmt.Errorf("fill requires --params or --spec-file with browser_id and the inputs the item's fill description asks for") } fill, err := parseVaultFillParams(raw) if err != nil { @@ -181,9 +182,14 @@ func parseVaultFillParams(raw string) (*vaultFillParams, error) { return nil, fmt.Errorf("timeout_ms must be an integer between 1 and 30000") } } + rawFields, present := object["fields"] + if !present { + // A Link Pay Token fill takes no fields; the API validates what each item requires. + return ¶ms, nil + } var fields []json.RawMessage - if json.Unmarshal(object["fields"], &fields) != nil || len(fields) < 1 || len(fields) > 32 { - return nil, fmt.Errorf("fields must be an array of 1-32 field bindings") + if json.Unmarshal(rawFields, &fields) != nil || len(fields) < 1 || len(fields) > 32 { + return nil, fmt.Errorf("fields must be an array of 1-32 field bindings; omit fields when the item's fill description says to") } params.Fields = make([]vaultFillField, 0, len(fields)) for i, rawField := range fields { diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index a3e7f5ef..18f3eff6 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -37,7 +37,7 @@ var vaultMethodFields = vaultOutputFields{ "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, } var vaultItemFields = vaultOutputFields{ - "id": nil, "key": nil, "type": nil, "version": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, + "id": nil, "key": nil, "type": nil, "version": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, "description": nil, "available_operations": vaultOperationFields, "available_expansions": vaultOperationFields, "action": vaultFieldsOf("name url expires_at instructions"), @@ -338,6 +338,9 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s if vaultOperationTakesParams(op.Type) { command += " --params ''" } + if op.Type == "authorize" && item.Type == "card" && item.Spec.Provider == "link" && item.State.Status == "requested" { + command += " --browser-id '' --page-url ''" + } pterm.Printf("invoke: %s -- %s %s %s\n", command, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) } return nil @@ -559,13 +562,19 @@ 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.") } - if item.Spec.Provider == "kernel" { + switch item.Spec.Provider { + case "kernel": pterm.Info.Println(kernelTransactionHelp + ". kernel cards have no stored billing fields; billing_* returns field_unavailable. enter billing details on the merchant page.") - } else { + case "link": + pterm.Info.Println("fill never submits payment; submit the checkout yourself. if the result of submitting payment is unclear, check the merchant's order confirmation or order history before paying again. if authorization is declined or expires, delete this card and create a new one.") + default: 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.Description != "" { + pterm.Info.Println(item.Description) + } if item.Spec.Provider == "kernel" { pterm.Info.Println(kernelWalletHelp) } diff --git a/cmd/vaults_payment_help_test.go b/cmd/vaults_payment_help_test.go index 831aff42..13e35d25 100644 --- a/cmd/vaults_payment_help_test.go +++ b/cmd/vaults_payment_help_test.go @@ -17,7 +17,7 @@ func TestVaultPaymentHelpProviderBoundaries(t *testing.T) { { name: "vaults", want: []string{ - "attachment is required for fill", + "attachment is required for authorizing a Link card at a checkout and for fill", "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", diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 77ebfdc8..8b94f0c3 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -402,7 +402,7 @@ func TestVaultNoSDKRetriesAndAPIErrorMessages(t *testing.T) { var apiErr *kernel.Error require.ErrorAs(t, err, &apiErr) assert.Equal(t, status, apiErr.StatusCode) - assert.Equal(t, "authorization_failed: Authorization service unavailable", util.CleanedUpSdkError{Err: err}.Error()) + assert.Contains(t, util.CleanedUpSdkError{Err: err}.Error(), fmt.Sprintf("authorization_failed: authorize failed (http %d): Authorization service unavailable; ", status)) }) } } diff --git a/go.mod b/go.mod index c29ae9c7..0b4e291e 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.123.1-0.20261012183001-8e8343ff3131 + github.com/kernel/kernel-go-sdk v0.125.1-0.20261014011053-e7ab5d320b3e 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 a4fdfc2c..168bad8b 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.123.1-0.20261012183001-8e8343ff3131 h1:ArVew3GmkyvVSd9yW3gBOktEeejeMc4HRmRWoSSffxY= -github.com/kernel/kernel-go-sdk v0.123.1-0.20261012183001-8e8343ff3131/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.125.1-0.20261014011053-e7ab5d320b3e h1:jWgFyHpVyhJYALQk4SpY/QlZab/ZzTv9x4By+3KanSI= +github.com/kernel/kernel-go-sdk v0.125.1-0.20261014011053-e7ab5d320b3e/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 ffa92ff195e504856656eba6ac0a1eb7660025d8 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:18:46 +0000 Subject: [PATCH 2/6] Keep the page check in Link fill retry guidance --- README.md | 2 +- cmd/vaults_link.go | 2 +- cmd/vaults_link_test.go | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 35bae208..6db33075 100644 --- a/README.md +++ b/README.md @@ -719,7 +719,7 @@ authoritative; `items get` prints them. The flow: ``` 7. Submit the checkout yourself; fill never submits payment. Failed or unknown fills are safe - to retry. + to retry: check the page first, and never retry in an automatic loop. Recovery: diff --git a/cmd/vaults_link.go b/cmd/vaults_link.go index df097370..79194ad1 100644 --- a/cmd/vaults_link.go +++ b/cmd/vaults_link.go @@ -31,7 +31,7 @@ const linkCardFlowHelp = `Link card checkout (each item's available_operations d Pay Token takes browser_id and page_url and omits fields; a virtual card also takes field/selector bindings. 6. submit the checkout yourself; fill never submits payment. failed or unknown fills are safe - to retry. + to retry: check the page first, and never retry in an automatic loop. if authorize returns page_not_found, ambiguous_page, browser_not_found, browser_unavailable, destination_denied, timeout, or browser_error, no payment request was made: make sure the checkout is open in exactly one tab at page_url, then authorize again. if checkout inspection diff --git a/cmd/vaults_link_test.go b/cmd/vaults_link_test.go index c6e8a821..bf3b30d4 100644 --- a/cmd/vaults_link_test.go +++ b/cmd/vaults_link_test.go @@ -49,6 +49,7 @@ func TestLinkCardFlowHelpOrder(t *testing.T) { "cards update returns 409: delete the card and create a new one", "50000 minor-unit virtual card limit", "check the merchant's order confirmation or order history before paying again", + "failed or unknown fills are safe to retry: check the page first, and never retry in an automatic loop", } { assert.Contains(t, flow, want) } From b979a5d77761f497944bf4322e0b0402bb5eae39 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:19:15 +0000 Subject: [PATCH 3/6] Reword Link fill retry guidance without a dead-end --- README.md | 2 +- cmd/vaults_link.go | 2 +- cmd/vaults_link_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6db33075..1bbf3a3c 100644 --- a/README.md +++ b/README.md @@ -719,7 +719,7 @@ authoritative; `items get` prints them. The flow: ``` 7. Submit the checkout yourself; fill never submits payment. Failed or unknown fills are safe - to retry: check the page first, and never retry in an automatic loop. + to retry, but only after checking the page and not in an automatic loop. Recovery: diff --git a/cmd/vaults_link.go b/cmd/vaults_link.go index 79194ad1..37384a63 100644 --- a/cmd/vaults_link.go +++ b/cmd/vaults_link.go @@ -31,7 +31,7 @@ const linkCardFlowHelp = `Link card checkout (each item's available_operations d Pay Token takes browser_id and page_url and omits fields; a virtual card also takes field/selector bindings. 6. submit the checkout yourself; fill never submits payment. failed or unknown fills are safe - to retry: check the page first, and never retry in an automatic loop. + to retry, but only after checking the page and not in an automatic loop. if authorize returns page_not_found, ambiguous_page, browser_not_found, browser_unavailable, destination_denied, timeout, or browser_error, no payment request was made: make sure the checkout is open in exactly one tab at page_url, then authorize again. if checkout inspection diff --git a/cmd/vaults_link_test.go b/cmd/vaults_link_test.go index bf3b30d4..a72de1cc 100644 --- a/cmd/vaults_link_test.go +++ b/cmd/vaults_link_test.go @@ -49,7 +49,7 @@ func TestLinkCardFlowHelpOrder(t *testing.T) { "cards update returns 409: delete the card and create a new one", "50000 minor-unit virtual card limit", "check the merchant's order confirmation or order history before paying again", - "failed or unknown fills are safe to retry: check the page first, and never retry in an automatic loop", + "failed or unknown fills are safe to retry, but only after checking the page and not in an automatic loop", } { assert.Contains(t, flow, want) } From d6a68939addf15b80b23d7a2ba9fb1d16fb31994 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:23:07 +0000 Subject: [PATCH 4/6] Bump kernel-go-sdk to v0.126.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0b4e291e..899b3c29 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.125.1-0.20261014011053-e7ab5d320b3e + github.com/kernel/kernel-go-sdk v0.126.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 168bad8b..a8ea4ec3 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.125.1-0.20261014011053-e7ab5d320b3e h1:jWgFyHpVyhJYALQk4SpY/QlZab/ZzTv9x4By+3KanSI= -github.com/kernel/kernel-go-sdk v0.125.1-0.20261014011053-e7ab5d320b3e/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.126.0 h1:uv3jdi4DooDGwZ9oF8WrS0YcakKv3Op/k6B8AlgUgXM= +github.com/kernel/kernel-go-sdk v0.126.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 3e75f4c8b7c3b87235526eefa4c26b6fd1f9e7f9 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:20:38 +0000 Subject: [PATCH 5/6] Address review: approval step, checkout errors, Link-only inputs --- README.md | 3 +- cmd/vaults.go | 12 ++++-- cmd/vaults_kernel_errors.go | 24 ++++++----- cmd/vaults_link.go | 32 ++++++++++----- cmd/vaults_link_test.go | 81 ++++++++++++++++++++++++++++++------- 5 files changed, 113 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 1bbf3a3c..973a5c91 100644 --- a/README.md +++ b/README.md @@ -691,7 +691,8 @@ authoritative; `items get` prints them. The flow: }' ``` -4. Authorize at the checkout with the browser session ID and the exact current top-level +4. Once the user has approved this purchase, and only when the item advertises `authorize`, + authorize at the checkout with the browser session ID and the exact current top-level checkout URL. Pass both flags or neither. KERNEL inspects the checkout, chooses how the credential is delivered (a Link Pay Token or a virtual card), limits fill to that checkout's origin, and sends the user an approval request. Do not inspect or invoke WebMCP yourself. diff --git a/cmd/vaults.go b/cmd/vaults.go index 232cb112..79218f87 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -309,9 +309,13 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par 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") } } + linkCard := item.Type == "card" && item.Spec.Provider == "link" + if params != nil && params.Authorize != nil && !linkCard { + return fmt.Errorf("--browser-id and --page-url only apply to Link cards; authorize was not invoked") + } if operation == "fill" { - if item.Type != "card" && len(params.Fill.Fields) == 0 { - return fmt.Errorf("fill for %s items requires 1-32 fields binding declared field names to selectors; fill was not invoked", item.Type) + if !linkCard && len(params.Fill.Fields) == 0 { + return fmt.Errorf("fill for this item requires 1-32 fields binding field names to selectors; only a Link card's fill may omit them. fill was not invoked") } return c.fill(ctx, vault, key, params.Fill, output, item.Spec.Provider) } @@ -357,8 +361,8 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if item.Spec.Provider == "kernel" { return kernelVaultRequestError(err, operation) } - if operation == "authorize" && item.Type == "card" && item.Spec.Provider == "link" { - return linkAuthorizeError(err) + if operation == "authorize" && linkCard { + return linkAuthorizeError(err, params != nil && params.Authorize != nil) } return util.CleanedUpSdkError{Err: err} } diff --git a/cmd/vaults_kernel_errors.go b/cmd/vaults_kernel_errors.go index 20d4553f..fe9d12d2 100644 --- a/cmd/vaults_kernel_errors.go +++ b/cmd/vaults_kernel_errors.go @@ -83,16 +83,7 @@ func kernelVaultRequestError(err error, operation string) error { 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 + guidance = retryAfterWait(apiErr) + "; " + 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: @@ -104,6 +95,19 @@ func kernelVaultRequestError(err error, operation string) error { return safeKernelAPIError(apiErr, body.Code, fmt.Sprintf("%s failed (http %d): %s; %s", operation, apiErr.StatusCode, detail, guidance)) } +// retryAfterWait says how long to wait after a 429, using a valid Retry-After header when present. +func retryAfterWait(apiErr *kernel.Error) string { + 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 { + return "wait until Retry-After (" + after + ") has elapsed" + } + } + return "wait for the rate limit to clear" +} + 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" diff --git a/cmd/vaults_link.go b/cmd/vaults_link.go index 37384a63..fb76dc12 100644 --- a/cmd/vaults_link.go +++ b/cmd/vaults_link.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "regexp" "strings" kernel "github.com/kernel/kernel-go-sdk" @@ -19,11 +20,11 @@ const linkCardFlowHelp = `Link card checkout (each item's available_operations d 2. create the card with cards create --provider link. this does not contact Link; the card is requested and advertises authorize. to change the amount or merchant before authorizing, use cards update. -3. authorize with items invoke authorize --browser-id - --page-url . KERNEL inspects the checkout, chooses - how the credential is delivered (a Link Pay Token or a virtual card), limits fill to that - checkout's origin, and sends the user an approval request. do not inspect or invoke - WebMCP yourself. +3. once the user has approved this purchase, authorize with items invoke + authorize --browser-id --page-url . KERNEL inspects the checkout, chooses how the credential is delivered (a Link Pay + Token or a virtual card), limits fill to that checkout's origin, and sends the user an + approval request. do not inspect or invoke WebMCP yourself. 4. share the approval url from the item's action with the user, then use items get --wait 60 until fill is advertised. to re-check sooner, invoke authorize again; checkout context is not needed, and re-checking never creates another payment request. @@ -51,6 +52,11 @@ var linkAuthorizeBrowserCodes = map[string]bool{ "destination_denied": true, "timeout": true, "browser_error": true, } +// linkCheckoutPageURL matches the API's page_url pattern for authorize. +var linkCheckoutPageURL = regexp.MustCompile(`^https://[^/?#@*\s]+(?:[/?#][^\s]*)?$`) + +const linkAuthorizeServerErrorGuidance = "if the browser was not created with --vault for this vault, was deleted, or is a pool browser that is not leased, checkout inspection cannot run: create a browser with --vault, open the checkout in it, and authorize again with that browser's session id. otherwise re-get the item and follow its advertised operations" + // parseVaultAuthorizeCheckout returns the authorize request for --browser-id and --page-url, // or nil when neither flag is set. func parseVaultAuthorizeCheckout(cmd *cobra.Command, operation string) (*kernel.AuthorizeVaultItemOperationRequestParam, error) { @@ -59,7 +65,10 @@ func parseVaultAuthorizeCheckout(cmd *cobra.Command, operation string) (*kernel. return nil, nil } if operation != "authorize" { - return nil, fmt.Errorf("--browser-id and --page-url are only supported for authorize; pass %s inputs with --params or --spec-file", operation) + if vaultOperationTakesParams(operation) { + return nil, fmt.Errorf("--browser-id and --page-url are only supported for authorize; pass %s inputs with --params or --spec-file", operation) + } + return nil, fmt.Errorf("--browser-id and --page-url are only supported for authorize") } if browserSet != pageSet { return nil, fmt.Errorf("--browser-id and --page-url must be passed together; authorize was not invoked") @@ -69,7 +78,7 @@ func parseVaultAuthorizeCheckout(cmd *cobra.Command, operation string) (*kernel. if strings.TrimSpace(browserID) == "" { return nil, fmt.Errorf("--browser-id must be a non-empty browser session ID, not a name") } - if u, err := url.Parse(pageURL); err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + if _, err := url.Parse(pageURL); err != nil || !linkCheckoutPageURL.MatchString(pageURL) { return nil, fmt.Errorf("--page-url must be the exact current top-level https checkout URL") } return &kernel.AuthorizeVaultItemOperationRequestParam{ @@ -80,10 +89,11 @@ func parseVaultAuthorizeCheckout(cmd *cobra.Command, operation string) (*kernel. } // linkAuthorizeError keeps the API's error code and message and adds the recovery step. -func linkAuthorizeError(err error) error { +// checkout reports whether the request carried browser_id and page_url. +func linkAuthorizeError(err error, checkout bool) error { var apiErr *kernel.Error if !errors.As(err, &apiErr) { - return kernelVaultTransportError{message: "authorize result unavailable; re-get the item and follow its advertised operations. once approval is requested, authorize only re-checks it and never creates another payment request", cause: err} + return fmt.Errorf("authorize result unavailable (%w); re-get the item and follow its advertised operations. once approval is requested, authorize only re-checks it and never creates another payment request", err) } var body struct { Code string `json:"code"` @@ -95,7 +105,9 @@ func linkAuthorizeError(err error) error { case linkAuthorizeBrowserCodes[body.Code]: guidance = linkAuthorizeBrowserGuidance case apiErr.StatusCode == 429: - guidance = "wait, then authorize again" + guidance = retryAfterWait(apiErr) + ", then authorize again" + case checkout && apiErr.StatusCode >= 500: + guidance = linkAuthorizeServerErrorGuidance case body.Code == "invalid_request": guidance = "fix the reported input, then authorize again. if it reports that the amount exceeds the Link virtual card limit, delete this card and tell the user this checkout cannot be paid with a Link virtual card" default: diff --git a/cmd/vaults_link_test.go b/cmd/vaults_link_test.go index a72de1cc..b891eefc 100644 --- a/cmd/vaults_link_test.go +++ b/cmd/vaults_link_test.go @@ -31,7 +31,7 @@ func TestLinkCardFlowHelpOrder(t *testing.T) { for _, step := range []string{ "1. reach the final checkout page", "2. create the card with cards create --provider link", - "3. authorize with items invoke authorize --browser-id --page-url", + "3. once the user has approved this purchase, authorize with items invoke authorize --browser-id --page-url", "4. share the approval url from the item's action", "5. fill as the item's fill description says", "6. submit the checkout yourself; fill never submits payment", @@ -118,12 +118,18 @@ func TestVaultLinkAuthorizeCheckoutValidation(t *testing.T) { "http page": {[]string{"authorize", "--browser-id", "browser-1", "--page-url", "http://shop.example/checkout"}, "--page-url must be"}, "relative page": {[]string{"authorize", "--browser-id", "browser-1", "--page-url", "/checkout"}, "--page-url must be"}, "credentials page": {[]string{"authorize", "--browser-id", "browser-1", "--page-url", "https://user:pass@shop.example/"}, "--page-url must be"}, - "fill": {[]string{"fill", "--params", linkPayTokenFillParams, "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout"}, "only supported for authorize"}, + "space in path": {[]string{"authorize", "--browser-id", "browser-1", "--page-url", "https://shop.example/check out"}, "--page-url must be"}, + "glob host": {[]string{"authorize", "--browser-id", "browser-1", "--page-url", "https://*.shop.example/checkout"}, "--page-url must be"}, + "fill": {[]string{"fill", "--params", linkPayTokenFillParams, "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout"}, "only supported for authorize; pass fill inputs with --params"}, + "restart": {[]string{"restart_verification", "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout"}, "--browser-id and --page-url are only supported for authorize"}, "params": {[]string{"authorize", "--params", linkPayTokenFillParams}, "authorize takes --browser-id and --page-url"}, } { t.Run(name, func(t *testing.T) { _, _, err := executeVaultCommand(t, client, append([]string{"vaults", "items", "invoke", "checkout", "order-1"}, tc.args...)...) require.ErrorContains(t, err, tc.want) + if name == "restart" { + assert.NotContains(t, err.Error(), "--params") + } }) } } @@ -134,34 +140,81 @@ func TestVaultLinkAuthorizeErrorGuidance(t *testing.T) { status int code, message string want string + retryAfter string + noCheckout bool }{ - {400, "page_not_found", "no open tab is at page_url; open the checkout page in the browser and authorize again", linkAuthorizeBrowserGuidance}, - {400, "ambiguous_page", "more than one tab is at page_url; close the extra tabs and authorize again", linkAuthorizeBrowserGuidance}, - {404, "browser_not_found", "browser not found", linkAuthorizeBrowserGuidance}, - {409, "browser_unavailable", "browser is unavailable for checkout inspection", linkAuthorizeBrowserGuidance}, - {403, "destination_denied", "checkout destination is not authorized", linkAuthorizeBrowserGuidance}, - {400, "timeout", "checkout inspection failed", linkAuthorizeBrowserGuidance}, - {500, "browser_error", "checkout inspection failed", linkAuthorizeBrowserGuidance}, - {400, "invalid_request", "amount exceeds the Link virtual card limit", "fix the reported input, then authorize again. if it reports that the amount exceeds the Link virtual card limit, delete this card and tell the user this checkout cannot be paid with a Link virtual card"}, - {429, "rate_limited", "too many requests", "wait, then authorize again"}, + {400, "page_not_found", "no open tab is at page_url; open the checkout page in the browser and authorize again", linkAuthorizeBrowserGuidance, "", false}, + {400, "ambiguous_page", "more than one tab is at page_url; close the extra tabs and authorize again", linkAuthorizeBrowserGuidance, "", false}, + {404, "browser_not_found", "browser not found", linkAuthorizeBrowserGuidance, "", false}, + {409, "browser_unavailable", "browser is unavailable for checkout inspection", linkAuthorizeBrowserGuidance, "", false}, + {403, "destination_denied", "checkout destination is not authorized", linkAuthorizeBrowserGuidance, "", false}, + {400, "timeout", "checkout inspection failed", linkAuthorizeBrowserGuidance, "", false}, + {500, "browser_error", "checkout inspection failed", linkAuthorizeBrowserGuidance, "", false}, + {400, "invalid_request", "amount exceeds the Link virtual card limit", "fix the reported input, then authorize again. if it reports that the amount exceeds the Link virtual card limit, delete this card and tell the user this checkout cannot be paid with a Link virtual card", "", false}, + {429, "rate_limited", "too many requests", "wait for the rate limit to clear, then authorize again", "", false}, + {429, "rate_limited", "too many requests", "wait until Retry-After (30) has elapsed, then authorize again", "30", false}, + {500, "provider_error", "failed to perform item operation", linkAuthorizeServerErrorGuidance, "", false}, + {500, "provider_error", "failed to perform item operation", "re-get the item and follow its advertised operations", "", true}, } { - t.Run(tc.code, func(t *testing.T) { + t.Run(fmt.Sprint(tc.code, tc.retryAfter, tc.noCheckout), func(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, requestedCardFixture) return } + if tc.retryAfter != "" { + w.Header().Set("Retry-After", tc.retryAfter) + } w.WriteHeader(tc.status) _, _ = fmt.Fprintf(w, `{"code":%q,"message":%q}`, tc.code, tc.message) }) - _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout") + args := []string{"vaults", "items", "invoke", "checkout", "order-1", "authorize"} + if !tc.noCheckout { + args = append(args, "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout") + } + _, _, err := executeVaultCommand(t, client, args...) require.Error(t, err) assert.Contains(t, err.Error(), fmt.Sprintf("%s: authorize failed (http %d): %s; %s", tc.code, tc.status, tc.message, tc.want)) }) } } +func TestVaultLinkAuthorizeTransportErrorKeepsCause(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) + return + } + conn, _, err := w.(http.Hijacker).Hijack() + require.NoError(t, err) + _ = conn.Close() + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout") + require.ErrorContains(t, err, "authorize result unavailable (") + assert.Contains(t, err.Error(), "EOF") + assert.Contains(t, err.Error(), "never creates another payment request") +} + +func TestVaultCheckoutInputsRequireLinkCard(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + readyKernelCard := strings.Replace(strings.Replace(kernelCardFixture, `"status":"requested"`, `"status":"ready"`, 1), `{"type":"authorize","description":"Authorize this purchase"}`, `{"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++ + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, readyKernelCard) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout") + require.ErrorContains(t, err, "--browser-id and --page-url only apply to Link cards") + _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", linkPayTokenFillParams) + require.ErrorContains(t, err, "only a Link card's fill may omit them") + assert.Equal(t, 2, calls) +} + func TestVaultLinkPayTokenFillOmitsFields(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") for _, tc := range []struct { @@ -213,7 +266,7 @@ func TestVaultFillWithoutFieldsRequiresFieldsForCredentials(t *testing.T) { _, _ = io.WriteString(w, readyFillCredentialFixture) }) _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user-vault", "login", "fill", "--params", `{"browser_id":"browser-1"}`) - require.ErrorContains(t, err, "fill for credential items requires 1-32 fields") + require.ErrorContains(t, err, "fill for this item requires 1-32 fields") assert.Equal(t, 1, calls) _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", `{"browser_id":"browser-1","page_url":"https://shop.example/checkout","fields":[]}`) From cd82fc93cb1489f9640ad4c275f0e1534d4cd3a0 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:27:26 +0000 Subject: [PATCH 6/6] Redact URLs from Link authorize transport errors --- cmd/vaults_link.go | 3 ++- cmd/vaults_link_test.go | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/vaults_link.go b/cmd/vaults_link.go index fb76dc12..6d7f65ef 100644 --- a/cmd/vaults_link.go +++ b/cmd/vaults_link.go @@ -93,7 +93,8 @@ func parseVaultAuthorizeCheckout(cmd *cobra.Command, operation string) (*kernel. func linkAuthorizeError(err error, checkout bool) error { var apiErr *kernel.Error if !errors.As(err, &apiErr) { - return fmt.Errorf("authorize result unavailable (%w); re-get the item and follow its advertised operations. once approval is requested, authorize only re-checks it and never creates another payment request", err) + cause := kernelVaultErrorURL.ReplaceAllString(err.Error(), "[redacted url]") + return kernelVaultTransportError{message: fmt.Sprintf("authorize result unavailable (%s); re-get the item and follow its advertised operations. once approval is requested, authorize only re-checks it and never creates another payment request", cause), cause: err} } var body struct { Code string `json:"code"` diff --git a/cmd/vaults_link_test.go b/cmd/vaults_link_test.go index b891eefc..91390f67 100644 --- a/cmd/vaults_link_test.go +++ b/cmd/vaults_link_test.go @@ -195,6 +195,8 @@ func TestVaultLinkAuthorizeTransportErrorKeepsCause(t *testing.T) { _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout") require.ErrorContains(t, err, "authorize result unavailable (") assert.Contains(t, err.Error(), "EOF") + assert.Contains(t, err.Error(), "[redacted url]") + assert.NotContains(t, err.Error(), "http://") assert.Contains(t, err.Error(), "never creates another payment request") }