From f418e91f551d88e2c26ad2937346ac1644630a62 Mon Sep 17 00:00:00 2001 From: hiroTamada <88675973+hiroTamada@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:18:32 +0000 Subject: [PATCH 1/2] Add vault item fill parameters and safe operation outcomes --- README.md | 72 +++++-- cmd/vaults.go | 21 +- cmd/vaults_commands.go | 38 +++- cmd/vaults_fill.go | 135 ++++++++++++ cmd/vaults_fill_test.go | 383 +++++++++++++++++++++++++++++++++ cmd/vaults_invoke_test.go | 4 +- cmd/vaults_operation_params.go | 136 ++++++++++++ cmd/vaults_output.go | 6 +- 8 files changed, 771 insertions(+), 24 deletions(-) create mode 100644 cmd/vaults_fill.go create mode 100644 cmd/vaults_fill_test.go create mode 100644 cmd/vaults_operation_params.go diff --git a/README.md b/README.md index af479680..8d643cc4 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ cannot switch projects. | `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 aliases and copyable operation commands; `--wait 0..60`, `--expand payment_methods`, `--open` | -| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; optional `--open` opens a returned HTTPS action | +| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; `authorize --open` opens a returned HTTPS action; `fill --params ''` fills checkout fields | | `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | | `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | @@ -429,8 +429,8 @@ wallet and vault deletion. Time passing or deletion is not evidence of non-execu kernel vaults items get checkout order-1 --wait 60 ``` -4. When ready, attach the same vault to a new browser. Use only the returned - `state.aliases` values in that browser's checkout and respect returned permitted domains: +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: ```bash kernel browsers create --vault checkout @@ -473,21 +473,67 @@ card spec. Otherwise, the cardholder selects a card at approval. A reusable card #### Invoking item operations -`items get` displays every `available_operations` entry's type and description, plus a -copyable `items invoke` command retaining the selected project. Read the description and -follow its approval requirements before invoking. Required user actions (OAuth, enrollment, -MFA, spend approval) appear separately; they are not operations to invoke through this endpoint. +`items get` displays every `available_operations` entry's type and description, plus +an `items invoke` command retaining the selected project (replace `` for fill). +Read the description and follow its approval requirements before invoking. Required user actions +(OAuth, enrollment, MFA, spend approval) appear separately; they are not operations to invoke +through this endpoint. `items invoke` fetches the item again and calls `POST /vaults/{id_or_name}/items/{key}/operations` only if the requested operation is still advertised. The API controls availability. The CLI additionally refuses invocation and opening -actions in `recovery_required`, even if a stale action or operation was returned. The response -is the updated item, possibly with a required user action. +actions in `recovery_required`, even if a stale action or operation was returned. -The current [API spec](https://api.onkernel.com/spec.yaml) accepts only -`{"type":"authorize"}` and forbids extra fields. There is no operation `--spec` flag; -wallet/card `--spec` flags remain unchanged. New parameterless operations can be invoked by -name when the API advertises them, without adding CLI subcommands. +`authorize` sends `{"type":"authorize"}` without `--params` and returns the updated item, +possibly with a required user action. `--open` is supported only for authorize. +The [API spec](https://api.onkernel.com/spec.yaml) also accepts `fill`, with its inputs in +`--params`. The positional operation supplies `type`; including `type` in params is rejected. +Parameters must be a JSON object without unknown or duplicate properties. There is no +operation `--spec` flag; wallet/card `--spec` flags remain unchanged. New parameterless +operations can still be invoked by name when advertised. + +##### Fill checkout fields + +Fill is supported only when advertised by a ready Link card, not AgentCard. It writes stored +card data without returning the values or submitting checkout: + +```bash +kernel vaults items get checkout order-1 +kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"},{"field":"expiration","format":"MM/YY","selector":"#expiry"},{"field":"cvc","selector":"#security-code"}],"timeout_ms":10000}' -o json +``` + +- `browser_id` is a browser **session ID**, not a reusable browser name. It is sent unchanged; + the CLI does not resolve names. +- `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 + `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. +- Stored 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. +- Combined `expiration` requires `format: "MM/YY"` or `"MM/YYYY"`. Other fields reject `format`. +- Optional `timeout_ms` is an integer from 1 to 30000 (default 10000), for the whole operation. + +Fill returns an execution result, **not an updated item**. Normal output shows zero-based +field indices, statuses, and error codes. `-o json` preserves the display-safe result shape: + +```json +{"type":"fill","status":"unknown","fields":[{"index":0,"status":"filled"},{"index":1,"status":"unknown","error_code":"timeout"},{"index":2,"status":"not_attempted"}]} +``` + +`completed` exits 0; `failed` and `unknown` exit nonzero **with the result still on stdout**, +without appended error text. API/transport errors exit nonzero with a sanitized diagnostic on +stderr, not a fabricated execution result. No values, selectors, DOM content, or raw browser +errors are printed in fill results. + +Fill is non-atomic: execution stops at the first failed/unknown field and earlier writes are +not rolled back. `filled` does not mean the site retained or accepted the value; `completed` +does not mean paid. Transport errors do not prove no writes occurred. Inspect the browser +before deciding what to do next. The CLI never retries, submits checkout, or falls back to +aliases. Returned `state.aliases` remain an alternative for explicitly chosen egress-substitution +integrations, not a recovery path after a failed or indeterminate fill. #### Expansions, updates, and lifecycle diff --git a/cmd/vaults.go b/cmd/vaults.go index 6250948c..495e7332 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -201,17 +201,26 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. return c.showItem(item, output, false) } -func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation, output string, open bool) error { +func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultFillParams, output string, open bool) error { if strings.TrimSpace(operation) == "" { return fmt.Errorf("operation must not be empty") } + if operation == "fill" && (params == nil || open) { + return fmt.Errorf("fill requires --params and does not support --open") + } item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { + if operation == "fill" { + return fmt.Errorf("could not retrieve vault item; fill was not invoked") + } return util.CleanedUpSdkError{Err: err} } + if item == nil { + return fmt.Errorf("empty vault item response; operation was not invoked") + } actions, err := effectiveVaultItemActions(item) if err != nil { - return err + return fmt.Errorf("invalid vault item operations; operation was not invoked") } if actions.RecoveryRequired { return fmt.Errorf("recovery_required: reconcile the original operation with the provider or support; do not retry, delete, or replace it") @@ -220,7 +229,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation, output str for _, op := range actions.Operations { if op.Type == operation { available = true - if output != "json" { + if output != "json" && operation != "fill" { pterm.Info.Println(op.Description) } break @@ -229,10 +238,16 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation, output str if !available { return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } + if operation == "fill" { + return c.fill(ctx, vault, key, params, output) + } item, err = c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, Type: kernel.VaultItemPerformOperationParamsType(operation)}, option.WithMaxRetries(0)) if err != nil { return util.CleanedUpSdkError{Err: err} } + if item == nil || (item.Type != "card" && item.Type != "wallet") { + return fmt.Errorf("unexpected vault operation response; inspect the item and do not retry") + } return c.showItem(item, output, open) } diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index b68c0497..6da600a0 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -63,8 +63,10 @@ Vault names, item keys, and project ownership are immutable. 3. Create a card request with --provider and --spec JSON. 4. Inspect items get, then use items invoke only when advertised. Follow the operation description and any returned provider action. -5. Attach the vault with browsers create --vault . Use only returned - non-secret aliases in that browser. Inspect items get/events for the outcome. +5. Attach the vault with browsers create --vault . For ready Link cards, + use advertised fill with --params to bind checkout fields. Returned non-secret + aliases are an alternative for explicitly chosen egress-substitution integrations, + not a fallback after fill. Inspect items get/events for payment outcomes. Permitted checkout domains are provider-assigned and displayed when returned; there is no domain-setting API. @@ -132,13 +134,37 @@ JSON output preserves returned public fields but omits unknown/opaque provider d itemEvents.Flags().Int64("wait", 0, "Long-poll once for new events (0-60 seconds)") addVaultJSONOutputFlag(itemEvents) invoke := &cobra.Command{Use: "invoke ", Short: "Invoke an operation advertised by an item", Args: cobra.ExactArgs(3), PreRunE: vaultPreRun, - Long: "Retrieve the item and invoke only an operation listed in available_operations.\nRead its description with items get before invoking; follow any approval requirements.\nThe API determines availability regardless of item type, provider, or state.\nRequests are not automatically retried. The updated item may contain a required user action.\nThe current API accepts only {\"type\":\"authorize\"}; there are no operation parameters or --spec flag.", - Example: " kernel vaults items get checkout order-1\n kernel vaults items invoke checkout order-1 authorize", + Long: `Retrieve the item and invoke only an operation listed in available_operations. +Read its description with items get before invoking; follow any approval requirements. +Authorize sends {"type":"authorize"} without --params and returns an updated item; +--open opens its returned HTTPS action URL. +Fill requires --params JSON with browser_id (session ID, not name), exact HTTPS +page_url, and 1-32 fields. Each binding has field and selector; expiration also +requires format MM/YY or MM/YYYY. Stored fields: number, cvc, exp_month (MM), +exp_year (YYYY), billing_name, billing_line1, billing_line2, billing_city, +billing_state, billing_postal_code, billing_country. Optional timeout_ms is 1-30000 +(default 10000). Do not include type, values, or frame IDs in --params. +Fill is available only when advertised by a ready Link card, not AgentCard. +The API searches the selected page and descendant frames, including payment iframes. +Fill returns value-free per-field outcomes, not an updated item. Completed exits 0; +failed/unknown exit nonzero while preserving the result in -o json. +Fill is not atomic: earlier writes are not rolled back. Transport errors do not +prove no writes occurred. No automatic retries, alias fallback, or form submission. +Inspect the browser before deciding what to do next; completed does not mean paid.`, + Example: ` kernel vaults items get checkout order-1 + kernel vaults items invoke checkout order-1 authorize --open + kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"},{"field":"expiration","format":"MM/YY","selector":"#expiry"},{"field":"cvc","selector":"#security-code"}],"timeout_ms":10000}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { open, _ := cmd.Flags().GetBool("open") - return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], vaultOutput(cmd), open) + raw, _ := cmd.Flags().GetString("params") + params, err := parseVaultOperationParams(args[2], raw, cmd.Flags().Changed("params"), cmd.Flags().Changed("open")) + if err != nil { + return err + } + return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} - invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") + invoke.Flags().String("params", "", "Operation-specific JSON object for fill; omit type (supplied by )") + invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL for authorize") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go new file mode 100644 index 00000000..e93c2ca4 --- /dev/null +++ b/cmd/vaults_fill.go @@ -0,0 +1,135 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strconv" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/pterm/pterm" +) + +type vaultFillResult struct { + Type string `json:"type"` + Status string `json:"status"` + Fields []vaultFillFieldResult `json:"fields"` +} + +type vaultFillFieldResult struct { + Index *int `json:"index"` + Status string `json:"status"` + ErrorCode string `json:"error_code,omitempty"` +} + +var vaultFillResultFields = vaultOutputFields{ + "type": nil, "status": nil, + "fields": vaultFieldsOf("index status error_code"), +} + +const vaultFillUncertain = "browser fields may have been written; inspect the browser and do not retry or fall back to aliases" + +func vaultFillRequestError(err error) error { + var apiErr *kernel.Error + if errors.As(err, &apiErr) { + return fmt.Errorf("fill request failed (HTTP %d); %s", apiErr.StatusCode, vaultFillUncertain) + } + // Do not wrap SDK/transport errors: they can contain request or response data, + // and the root error handler extracts raw SDK error messages through Unwrap. + return fmt.Errorf("fill result unavailable; %s", vaultFillUncertain) +} + +func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFillParams, output string) error { + request := struct { + Type string `json:"type"` + *vaultFillParams + }{Type: "fill", vaultFillParams: params} + var raw json.RawMessage + // The released SDK's PerformOperation only decodes item responses. Use its + // authenticated request path with the same service options until it supports fill. + client := kernel.Client{Options: c.vaults.Items.Options} + path := fmt.Sprintf("vaults/%s/items/%s/operations", url.PathEscape(vault), url.PathEscape(key)) + if err := client.Post(ctx, path, request, &raw, option.WithMaxRetries(0)); err != nil { + return vaultFillRequestError(err) + } + result, err := parseVaultFillResult(raw, len(params.Fields)) + if err != nil { + return err + } + if output == "json" { + if err := printVaultJSON(result); err != nil { + return err + } + } 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}) + } + PrintTableNoPad(rows, true) + if result.Status == "completed" { + pterm.Println("Fields filled; this does not confirm payment or merchant acceptance.") + } else { + pterm.Println(vaultFillUncertain) + } + } + if result.Status != "completed" { + return vaultFillOutcomeError{status: result.Status} + } + return nil +} + +// The result has already been printed; retain a nonzero exit without diagnostics. +type vaultFillOutcomeError struct{ status string } + +func (e vaultFillOutcomeError) Error() string { return "fill " + e.status } +func (e vaultFillOutcomeError) Silent() bool { return true } + +func parseVaultFillResult(raw json.RawMessage, count int) (*vaultFillResult, error) { + invalid := fmt.Errorf("invalid fill result; %s", vaultFillUncertain) + safe, err := filterVaultJSON(raw, vaultFillResultFields) + if err != nil { + return nil, invalid + } + var result vaultFillResult + if json.Unmarshal(safe, &result) != nil || result.Type != "fill" || len(result.Fields) != count { + return nil, invalid + } + status := "completed" + stopped := false + for i, field := range result.Fields { + if field.Index == nil || *field.Index != i { + return nil, invalid + } + if stopped { + if field.Status != "not_attempted" { + return nil, invalid + } + } else { + switch field.Status { + case "filled": + case "failed", "unknown": + status, stopped = field.Status, true + default: + return nil, invalid + } + } + if field.ErrorCode != "" { + if field.Status != "failed" && field.Status != "unknown" { + return nil, invalid + } + switch field.ErrorCode { + case "target_changed", "element_not_found", "ambiguous_selector", "element_not_editable", "option_not_found", "timeout", "execution_failed": + default: + return nil, invalid + } + } + } + if result.Status != status { + return nil, invalid + } + return &result, nil +} diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go new file mode 100644 index 00000000..b0531f63 --- /dev/null +++ b/cmd/vaults_fill_test.go @@ -0,0 +1,383 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const fillParamsFixture = `{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout?step=2#payment","fields":[{"field":"number","selector":"#card-number"},{"field":"expiration","format":"MM/YY","selector":"#expiry"},{"field":"cvc","selector":"#security-code"}],"timeout_ms":10000}` +const readyFillCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link"},"state":{"provider":"link","status":"ready"},"available_operations":[{"type":"fill","description":"Fill checkout fields."}]}` +const completedFillFixture = `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"},{"index":1,"status":"filled"},{"index":2,"status":"filled"}]}` +const failedFillFixture = `{"type":"fill","status":"failed","fields":[{"index":0,"status":"filled"},{"index":1,"status":"failed","error_code":"element_not_found"},{"index":2,"status":"not_attempted"}]}` +const unknownFillFixture = `{"type":"fill","status":"unknown","fields":[{"index":0,"status":"filled"},{"index":1,"status":"unknown","error_code":"timeout"},{"index":2,"status":"not_attempted"}]}` + +func TestVaultFillParamsValidation(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid params reached API") }) + replace := func(old, value string) string { return strings.Replace(fillParamsFixture, old, value, 1) } + for name, raw := range map[string]string{ + "empty": "", "null": "null", "array": "[]", "scalar": `"credential-sentinel"`, + "malformed": `{"credential-sentinel":`, "trailing": fillParamsFixture + ` {}`, + "type override": replace(`"browser_id":`, `"type":"authorize","browser_id":`), + "same type": replace(`"browser_id":`, `"type":"fill","browser_id":`), + "case variant": replace(`"browser_id":`, `"Type":"authorize","browser_id":`), + "duplicate": replace(`"browser_id":`, `"browser_id":"credential-sentinel","browser_id":`), + "unknown key": replace(`"browser_id":`, `"credential-sentinel":"secret","browser_id":`), + "missing browser": replace(`"browser_id":"browser-session-id",`, ""), + "null browser": replace(`"browser-session-id"`, `null`), + "numeric browser": replace(`"browser-session-id"`, `123`), + "blank browser": replace(`"browser-session-id"`, `" "`), + "http URL": replace(`https://`, `http://`), + "credentials URL": replace(`https://`, `https://credential-sentinel:secret@`), + "glob host": replace(`shop.example`, `*.example`), + "missing host": replace(`shop.example`, ``), + "bad port": replace(`shop.example`, `shop.example:secret`), + "URL whitespace": replace(`checkout?`, `checkout ?`), + "URL type": replace(`"https://shop.example/checkout?step=2#payment"`, `123`), + "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":[[]]}`, + "too many": `{"browser_id":"id","page_url":"https://shop.example/","fields":[` + strings.Repeat(`{"field":"number","selector":"#n"},`, 32) + `{"field":"cvc","selector":"#c"}]}`, + "missing field": replace(`"field":"number",`, ``), + "unknown field": replace(`"number"`, `"credential-sentinel"`), + "field type": replace(`"number"`, `{}`), + "missing selector": replace(`,"selector":"#card-number"`, ``), + "null selector": replace(`"#card-number"`, `null`), + "empty selector": replace(`"#card-number"`, `" "`), + "selector type": replace(`"#card-number"`, `123`), + "duplicate field": replace(`"field":"number"`, `"field":"cvc","field":"number"`), + "frame ID": replace(`"selector":"#card-number"`, `"selector":"#card-number","frame_id":"secret"`), + "literal value": replace(`"selector":"#card-number"`, `"selector":"#card-number","value":"credential-sentinel"`), + "format on stored": replace(`"field":"number"`, `"field":"number","format":"MM/YY"`), + "null stored format": replace(`"field":"number"`, `"field":"number","format":null`), + "missing expiration format": replace(`,"format":"MM/YY"`, ``), + "bad format": replace(`"MM/YY"`, `"credential-sentinel"`), + "null format": replace(`"MM/YY"`, `null`), + "timeout zero": replace(`10000`, `0`), "timeout high": replace(`10000`, `30001`), + "negative timeout": replace(`10000`, `-1`), "fractional timeout": replace(`10000`, `1.5`), + "timeout string": replace(`10000`, `"credential-sentinel"`), "timeout null": replace(`10000`, `null`), + } { + t.Run(name, func(t *testing.T) { + out, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", raw, "-o", "json") + require.Error(t, err) + assert.NotContains(t, err.Error(), "credential-sentinel") + assert.NotContains(t, err.Error(), "#card-number") + assert.Empty(t, out) + assert.Empty(t, human) + }) + } + for _, args := range [][]string{ + {"fill"}, {"fill", "--params", fillParamsFixture, "--open"}, + {"fill", "--params", fillParamsFixture, "--open=false"}, + {"authorize", "--params", `{}`}, {"authorize", "--params", ``}, + {"refresh", "--params", `{}`}, {"refresh", "--open"}, + } { + _, _, err := executeVaultCommand(t, client, append([]string{"vaults", "items", "invoke", "checkout", "order-1"}, args...)...) + require.Error(t, err) + } +} + +func TestVaultFillBindingsSerialization(t *testing.T) { + fields := make([]vaultFillField, 0) + for _, field := range strings.Fields("number cvc exp_month exp_year billing_name billing_line1 billing_line2 billing_city billing_state billing_postal_code billing_country") { + fields = append(fields, vaultFillField{Field: field, Selector: "#" + field}) + } + for _, format := range []string{"MM/YY", "MM/YYYY"} { + fields = append(fields, vaultFillField{Field: "expiration", Format: format, Selector: "#expiry"}) + } + for len(fields) < 32 { + fields = append(fields, vaultFillField{Field: "billing_name", Selector: fmt.Sprintf("#billing-name-%d", len(fields))}) + } + for _, timeout := range []int{0, 1, 30000} { + t.Run(fmt.Sprint(timeout), func(t *testing.T) { + params := vaultFillParams{BrowserID: "Session-ID", PageURL: "https://shop.example/checkout?step=2#payment", Fields: fields} + if timeout != 0 { + params.TimeoutMS = &timeout + } + raw, err := json.Marshal(params) + require.NoError(t, err) + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, "Bearer test", r.Header.Get("Authorization")) + assert.Equal(t, "chosen-project", r.Header.Get("X-Kernel-Project")) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, readyFillCardFixture) + return + } + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vaults/checkout/items/order-1/operations", r.URL.Path) + assert.Contains(t, r.Header.Get("Content-Type"), "application/json") + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"fill",`+string(raw[1:]), string(body)) + results := make([]map[string]any, 0, len(fields)) + for i := range fields { + results = append(results, map[string]any{"index": i, "status": "filled"}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"type": "fill", "status": "completed", "fields": results}) + }) + t.Setenv("KERNEL_PROJECT", "other-project") + out, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", string(raw), "--project", "chosen-project", "-o", "json") + require.NoError(t, err) + assert.True(t, json.Valid([]byte(out))) + assert.Empty(t, human) + assert.Equal(t, 2, calls) + }) + } +} + +func TestVaultFillAdvertisedAvailability(t *testing.T) { + for name, fixture := range map[string]string{ + "requested": requestedCardFixture, + "wallet": connectedWalletFixture, + "agentcard": strings.ReplaceAll(strings.ReplaceAll(readyFillCardFixture, "link", "agentcard"), `[{"type":"fill","description":"Fill checkout fields."}]`, `[]`), + "ready unadvertised": strings.ReplaceAll(readyFillCardFixture, `[{"type":"fill","description":"Fill checkout fields."}]`, `[]`), + "recovery stale operation": strings.ReplaceAll(readyFillCardFixture, `"ready"`, `"recovery_required"`), + } { + t.Run(name, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", fillParamsFixture, "-o", "json") + require.Error(t, err) + assert.Empty(t, out) + assert.Equal(t, 1, calls) + }) + } +} + +func TestVaultFillHumanOutputAndRedaction(t *testing.T) { + for _, result := range []string{completedFillFixture, failedFillFixture, unknownFillFixture} { + t.Run(result, func(t *testing.T) { + response := strings.ReplaceAll(result, `"index":`, `"value":"credential-sentinel","selector":"#secret-selector","dom":{"text":"secret"},"index":`) + response = strings.Replace(response, `"type":`, `"values":["credential-sentinel"],"type":`, 1) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := response + if r.Method == http.MethodGet { + body = readyFillCardFixture + } + _, _ = io.WriteString(w, body) + }) + for _, output := range []string{"", "json"} { + out, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", fillParamsFixture, "-o", output) + if result == completedFillFixture { + require.NoError(t, err) + } else { + require.Error(t, err) + } + assert.NotContains(t, out+human, "credential-sentinel") + assert.NotContains(t, out+human, "#secret-selector") + assert.NotContains(t, out+human, "#card-number") + if output == "json" { + assert.JSONEq(t, result, out) + assert.Empty(t, human) + } else { + assert.Contains(t, human, "Field index") + assert.Contains(t, human, "filled") + if result != completedFillFixture { + assert.Contains(t, human, "not_attempted") + assert.Contains(t, human, "do not retry") + } + } + } + }) + } +} + +func runVaultFillCLI(t *testing.T, serverURL string, args ...string) (string, string, int) { + t.Helper() + exe, err := os.Executable() + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + command := exec.CommandContext(ctx, exe, append([]string{"vaults", "items", "invoke", "checkout", "order-1"}, args...)...) + command.Env = append(os.Environ(), "KERNEL_CLI_E2E_EXEC=1", "KERNEL_NO_UPDATE_CHECK=1", "KERNEL_API_KEY=test", "KERNEL_BASE_URL="+serverURL, "KERNEL_PROJECT=test-project", "NO_COLOR=1") + var stdout, stderr bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stderr + err = command.Run() + require.NoError(t, ctx.Err(), "CLI hung: %s", stderr.String()) + code := 0 + if err != nil { + var exit *exec.ExitError + require.ErrorAs(t, err, &exit) + code = exit.ExitCode() + } + return stdout.String(), ansiEscapes.ReplaceAllString(stderr.String(), ""), code +} + +func TestVaultFillCLIOutcomesAndFailures(t *testing.T) { + type testCase struct { + name, response string + status, exit int + getFailure bool + } + tests := []testCase{ + {"completed", completedFillFixture, 200, 0, false}, + {"failed", failedFillFixture, 200, 1, false}, + {"unknown", unknownFillFixture, 200, 1, false}, + {"wrong union", requestedCardFixture, 200, 1, false}, + {"malformed response", `{"credential-sentinel":`, 200, 1, false}, + {"empty response", `null`, 200, 1, false}, + {"empty item", `null`, 200, 1, true}, + {"malformed item", `{"credential-sentinel":`, 200, 1, true}, + {"malformed operations", strings.Replace(readyFillCardFixture, `"type":"fill"`, `"type":{"credential-sentinel":true}`, 1), 200, 1, true}, + {"empty fields", `{"type":"fill","status":"completed","fields":[]}`, 200, 1, false}, + {"invalid status", strings.ReplaceAll(completedFillFixture, "completed", "credential-sentinel"), 200, 1, false}, + {"missing index", strings.Replace(completedFillFixture, `"index":0,`, ``, 1), 200, 1, false}, + {"wrong index", strings.Replace(completedFillFixture, `"index":0`, `"index":2`, 1), 200, 1, false}, + {"unsafe error code", strings.Replace(failedFillFixture, "element_not_found", "credential-sentinel", 1), 200, 1, false}, + {"invalid completion", strings.Replace(failedFillFixture, `"status":"failed"`, `"status":"completed"`, 1), 200, 1, false}, + {"SDK upgrade", `{"code":"sdk_upgrade_required","message":"credential-sentinel"}`, 400, 1, false}, + {"dropped connection", "", 0, 1, false}, + {"truncated response", "", -1, 1, false}, + } + for _, status := range []int{400, 401, 403, 404, 409, 429, 500, 503} { + for _, get := range []bool{false, true} { + tests = append(tests, testCase{fmt.Sprintf("HTTP %d get=%t", status, get), `{"code":"credential-sentinel","message":"credential-sentinel #secret-selector"}`, status, 1, get}) + } + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + assert.Equal(t, "test-project", r.Header.Get("X-Kernel-Project")) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && !tt.getFailure { + _, _ = io.WriteString(w, readyFillCardFixture) + return + } + if tt.status <= 0 { + conn, buf, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + if tt.status == -1 { + _, _ = buf.WriteString("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 1000\r\n\r\n{\"secret\":") + _ = buf.Flush() + } + _ = conn.Close() + return + } + w.WriteHeader(tt.status) + _, _ = io.WriteString(w, tt.response) + })) + defer server.Close() + out, stderr, exit := runVaultFillCLI(t, server.URL, "fill", "--params", fillParamsFixture, "-o", "json") + assert.Equal(t, tt.exit, exit, "stderr=%s", stderr) + wantCalls := int32(2) + if tt.getFailure { + wantCalls = 1 + } + assert.Equal(t, wantCalls, calls.Load(), "must not retry or perform other actions") + assert.NotContains(t, out+stderr, "credential-sentinel") + assert.NotContains(t, out+stderr, "#secret-selector") + switch tt.name { + case "completed", "failed", "unknown": + assert.JSONEq(t, tt.response, out) + assert.Empty(t, stderr) + default: + assert.Empty(t, out) + assert.NotEmpty(t, stderr) + if !tt.getFailure { + assert.Contains(t, stderr, "may have been written") + } + } + }) + } +} + +func TestVaultFillCLIValidationAndAuthorizeCompatibility(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.JSONEq(t, `{"type":"authorize"}`, string(body)) + } + _, _ = io.WriteString(w, requestedCardFixture) + })) + defer server.Close() + for _, raw := range []string{`{"credential-sentinel":`, `{"type":"credential-sentinel"}`, `[]`, ``} { + out, stderr, exit := runVaultFillCLI(t, server.URL, "fill", "--params", raw, "-o", "json") + assert.Equal(t, 1, exit) + assert.Empty(t, out) + assert.NotContains(t, stderr, "credential-sentinel") + assert.NotEmpty(t, stderr) + } + assert.Zero(t, calls.Load()) + out, stderr, exit := runVaultFillCLI(t, server.URL, "authorize", "--open", "-o", "json") + assert.Zero(t, exit) + assert.Empty(t, stderr) + assert.JSONEq(t, requestedCardFixture, out) + assert.Equal(t, int32(2), calls.Load()) +} + +func TestVaultFillSingleFieldAndHint(t *testing.T) { + const params = `{"browser_id":"session-id","page_url":"https://shop.example/","fields":[{"field":"billing_country","selector":"select[name=\"country\"]"}]}` + const result = `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"}]}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"fill",`+params[1:], string(body)) + _, _ = io.WriteString(w, result) + return + } + _, _ = io.WriteString(w, readyFillCardFixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", params, "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, result, out) + _, 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 --params '' -- checkout order-1 fill") +} + +func TestVaultAuthorizeRejectsFillResponse(t *testing.T) { + for _, response := range []string{completedFillFixture, "null"} { + t.Run(response, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, requestedCardFixture) + return + } + _, _ = io.WriteString(w, response) + })) + defer server.Close() + out, stderr, exit := runVaultFillCLI(t, server.URL, "authorize", "-o", "json") + assert.Equal(t, 1, exit) + assert.Empty(t, out) + assert.Contains(t, strings.ToLower(stderr), "unexpected vault operation response") + }) + } +} diff --git a/cmd/vaults_invoke_test.go b/cmd/vaults_invoke_test.go index fa185098..82adf78a 100644 --- a/cmd/vaults_invoke_test.go +++ b/cmd/vaults_invoke_test.go @@ -94,6 +94,8 @@ func TestVaultInvokeArgumentsAndHelp(t *testing.T) { require.NoError(t, err) assert.Nil(t, cmd.Flags().Lookup("spec")) assert.NotNil(t, cmd.Flags().Lookup("open")) + assert.NotNil(t, cmd.Flags().Lookup("params")) + assert.Contains(t, cmd.Long, "failed/unknown exit nonzero") assert.Contains(t, cmd.Long, `{"type":"authorize"}`) assert.Contains(t, cmd.Long, "available_operations") } @@ -116,7 +118,7 @@ func TestVaultInvokeOpensOnlyReturnedActionExplicitly(t *testing.T) { c := VaultsCmd{vaults: &client.Vaults, openURL: func(url string) error { opened = url; return nil }} var err error out := captureStdout(t, func() { - err = c.Invoke(context.Background(), "checkout", "order-1", "authorize", "json", open) + err = c.Invoke(context.Background(), "checkout", "order-1", "authorize", nil, "json", open) }) require.NoError(t, err) assert.Equal(t, 2, calls) diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go new file mode 100644 index 00000000..e519a6c8 --- /dev/null +++ b/cmd/vaults_operation_params.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "regexp" + "slices" + "strings" +) + +type vaultFillParams struct { + BrowserID string `json:"browser_id"` + PageURL string `json:"page_url"` + Fields []vaultFillField `json:"fields"` + TimeoutMS *int `json:"timeout_ms,omitempty"` +} + +type vaultFillField struct { + Field string `json:"field"` + Selector string `json:"selector"` + Format string `json:"format,omitempty"` +} + +var vaultFillPageURLPattern = regexp.MustCompile(`^https://[^/?#@*\s]+(?:[/?#][^\s]*)?$`) + +// Reject duplicate and unknown keys without including payloads in diagnostics. +func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) { + invalid := fmt.Errorf("--params must contain JSON objects with only supported, non-duplicate properties") + dec := json.NewDecoder(strings.NewReader(raw)) + token, err := dec.Token() + if err != nil || token != json.Delim('{') { + return nil, invalid + } + keys := strings.Fields(allowed) + object := make(map[string]json.RawMessage) + for dec.More() { + token, err := dec.Token() + if err != nil { + return nil, invalid + } + key, ok := token.(string) + if !ok { + return nil, invalid + } + if key == "type" { + return nil, fmt.Errorf("--params must not contain type; the positional operation supplies it") + } + if _, duplicate := object[key]; duplicate || !slices.Contains(keys, key) { + return nil, invalid + } + var value json.RawMessage + if err := dec.Decode(&value); err != nil { + return nil, invalid + } + object[key] = value + } + if _, err := dec.Token(); err != nil { + return nil, invalid + } + if _, err := dec.Token(); err != io.EOF { + return nil, invalid + } + return object, nil +} + +func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) (*vaultFillParams, error) { + if strings.TrimSpace(operation) == "" { + return nil, fmt.Errorf("operation must not be empty") + } + if openSet && operation != "authorize" { + return nil, fmt.Errorf("--open is only supported for authorize") + } + if operation != "fill" { + if paramsSet { + return nil, fmt.Errorf("--params is only supported for fill; authorize takes no parameters") + } + return nil, nil + } + if !paramsSet { + return nil, fmt.Errorf("fill requires --params with browser_id, page_url, and fields") + } + object, err := vaultParamsObject(raw, "browser_id page_url fields timeout_ms") + if err != nil { + return nil, err + } + var params vaultFillParams + if json.Unmarshal(object["browser_id"], ¶ms.BrowserID) != nil || strings.TrimSpace(params.BrowserID) == "" { + return nil, fmt.Errorf("--params.browser_id must be a non-empty browser session ID, not a name") + } + if json.Unmarshal(object["page_url"], ¶ms.PageURL) != nil || !vaultFillPageURLPattern.MatchString(params.PageURL) { + return nil, fmt.Errorf("--params.page_url must be an exact HTTPS URL without credentials or a wildcard host") + } + u, err := url.Parse(params.PageURL) + if err != nil || u.Hostname() == "" || u.User != nil || u.Opaque != "" { + return nil, fmt.Errorf("--params.page_url must be an exact HTTPS URL without credentials") + } + if timeout, ok := object["timeout_ms"]; ok { + if json.Unmarshal(timeout, ¶ms.TimeoutMS) != nil || params.TimeoutMS == nil || *params.TimeoutMS < 1 || *params.TimeoutMS > 30000 { + return nil, fmt.Errorf("--params.timeout_ms must be an integer between 1 and 30000") + } + } + var fields []json.RawMessage + if json.Unmarshal(object["fields"], &fields) != nil || len(fields) < 1 || len(fields) > 32 { + return nil, fmt.Errorf("--params.fields must be an array of 1-32 field bindings") + } + params.Fields = make([]vaultFillField, 0, len(fields)) + for i, rawField := range fields { + field, err := vaultParamsObject(string(rawField), "field selector format") + if err != nil { + return nil, fmt.Errorf("--params.fields[%d]: %w", i, err) + } + var binding vaultFillField + if json.Unmarshal(field["selector"], &binding.Selector) != nil || strings.TrimSpace(binding.Selector) == "" { + return nil, fmt.Errorf("--params.fields[%d].selector must be a non-empty CSS selector", i) + } + if json.Unmarshal(field["field"], &binding.Field) != nil { + return nil, fmt.Errorf("--params.fields[%d].field must be a supported card field", i) + } + switch binding.Field { + case "expiration": + if json.Unmarshal(field["format"], &binding.Format) != nil || (binding.Format != "MM/YY" && binding.Format != "MM/YYYY") { + return nil, fmt.Errorf("--params.fields[%d].format must be MM/YY or MM/YYYY for expiration", i) + } + case "number", "cvc", "exp_month", "exp_year", "billing_name", "billing_line1", "billing_line2", "billing_city", "billing_state", "billing_postal_code", "billing_country": + if _, ok := field["format"]; ok { + return nil, fmt.Errorf("--params.fields[%d].format is only supported for expiration", i) + } + default: + return nil, fmt.Errorf("--params.fields[%d].field must be a supported card field", i) + } + params.Fields = append(params.Fields, binding) + } + return ¶ms, nil +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 3262007c..b3dbb38d 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -195,7 +195,11 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s prefix += " --project=" + vaultShellArgument(project) } for _, op := range actions.Operations { - pterm.Printf("Invoke: %s -- %s %s %s\n", prefix, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) + command := prefix + if op.Type == "fill" { + command += " --params ''" + } + pterm.Printf("Invoke: %s -- %s %s %s\n", command, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) } return nil } From b157880e4bbf0850dd2e8bee8248a01a8017ab45 Mon Sep 17 00:00:00 2001 From: hiroTamada <88675973+hiroTamada@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:55:26 +0000 Subject: [PATCH 2/2] Use released SDK types for vault item operations --- cmd/vaults.go | 14 +++++++++++--- cmd/vaults_fill.go | 34 ++++++++++++++++++++++------------ go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/cmd/vaults.go b/cmd/vaults.go index 495e7332..6eb2a6ca 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -13,6 +14,7 @@ import ( "github.com/kernel/cli/pkg/util" kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/shared/constant" "github.com/pterm/pterm" ) @@ -241,14 +243,20 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "fill" { return c.fill(ctx, vault, key, params, output) } - item, err = c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, Type: kernel.VaultItemPerformOperationParamsType(operation)}, option.WithMaxRetries(0)) + // Preserve support for other advertised parameterless operations. + authorize := kernel.VaultItemPerformOperationParamsBodyAuthorize{Type: constant.Authorize(operation)} + response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfAuthorize: &authorize}, option.WithMaxRetries(0)) if err != nil { return util.CleanedUpSdkError{Err: err} } - if item == nil || (item.Type != "card" && item.Type != "wallet") { + if response == nil || (response.Type != "card" && response.Type != "wallet") { return fmt.Errorf("unexpected vault operation response; inspect the item and do not retry") } - return c.showItem(item, output, open) + var updated kernel.VaultItemUnion + if err := json.Unmarshal([]byte(response.RawJSON()), &updated); err != nil { + return fmt.Errorf("invalid vault item response; inspect the item and do not retry") + } + return c.showItem(&updated, output, open) } func (c VaultsCmd) Events(ctx context.Context, vault, key, after string, wait int64, output string) error { diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index e93c2ca4..7f63c6c0 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "net/url" "strconv" kernel "github.com/kernel/kernel-go-sdk" @@ -43,19 +42,30 @@ func vaultFillRequestError(err error) error { } func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFillParams, output string) error { - request := struct { - Type string `json:"type"` - *vaultFillParams - }{Type: "fill", vaultFillParams: params} - var raw json.RawMessage - // The released SDK's PerformOperation only decodes item responses. Use its - // authenticated request path with the same service options until it supports fill. - client := kernel.Client{Options: c.vaults.Items.Options} - path := fmt.Sprintf("vaults/%s/items/%s/operations", url.PathEscape(vault), url.PathEscape(key)) - if err := client.Post(ctx, path, request, &raw, option.WithMaxRetries(0)); err != nil { + request := kernel.FillVaultItemOperationRequestParam{ + BrowserID: params.BrowserID, + PageURL: params.PageURL, + Type: kernel.FillVaultItemOperationRequestTypeFill, + Fields: make([]kernel.VaultCardFillFieldUnionParam, 0, len(params.Fields)), + } + if params.TimeoutMS != nil { + request.TimeoutMs = kernel.Opt(int64(*params.TimeoutMS)) + } + for _, field := range params.Fields { + binding := kernel.VaultCardFillFieldParamOfVaultCardFillFieldVaultCardStoredFillField(field.Field, field.Selector) + if field.Field == "expiration" { + binding = kernel.VaultCardFillFieldParamOfVaultCardFillFieldVaultCardExpirationFillField(field.Field, field.Format, field.Selector) + } + request.Fields = append(request.Fields, binding) + } + response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfFill: &request}, option.WithMaxRetries(0)) + if err != nil { return vaultFillRequestError(err) } - result, err := parseVaultFillResult(raw, len(params.Fields)) + if response == nil { + return fmt.Errorf("empty fill result; %s", vaultFillUncertain) + } + result, err := parseVaultFillResult(json.RawMessage(response.RawJSON()), len(params.Fields)) if err != nil { return err } diff --git a/go.mod b/go.mod index 16efd6b8..fa46bff5 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.101.0 + github.com/kernel/kernel-go-sdk v0.102.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 3661f19f..6f4ca2b0 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.101.0 h1:1Fj1kWosiWOH2C+P1/DspUl+2i0nR4tlvr37HRnndFY= -github.com/kernel/kernel-go-sdk v0.101.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.102.0 h1:ZGumOc/Bub48B8zRye44BSLNCgqM/Z6K7XcKX0DCjH0= +github.com/kernel/kernel-go-sdk v0.102.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=