Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 59 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ cannot switch projects.
| `kernel vaults cards update <vault> <key> --provider link\|agentcard --spec '<json>'` | Update a card spec; pending issuance preserves omitted optional fields, and the API enforces state/provider constraints |
| `kernel vaults items list <vault>` | List item keys, types, providers, status, and required actions |
| `kernel vaults items get <vault> <key>` | Inspect state/actions/returned aliases and copyable operation commands; `--wait 0..60`, `--expand payment_methods`, `--open` |
| `kernel vaults items invoke <vault> <key> <operation>` | GET the item, then POST an advertised operation; optional `--open` opens a returned HTTPS action |
| `kernel vaults items invoke <vault> <key> <operation>` | GET the item, then POST an advertised operation; `authorize --open` opens a returned HTTPS action; `fill --params '<json>'` fills checkout fields |
| `kernel vaults items events <vault> <key>` | Read ordered audit events; `--after <event-id>`, `--wait 0..60` |
| `kernel vaults items delete <vault> <key>` | Invalidate an item; `--yes` skips confirmation |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<json>` 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

Expand Down
33 changes: 28 additions & 5 deletions cmd/vaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
Expand All @@ -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"
)

Expand Down Expand Up @@ -201,17 +203,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")
Expand All @@ -220,7 +231,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
Expand All @@ -229,11 +240,23 @@ 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)
}
item, err = c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, Type: kernel.VaultItemPerformOperationParamsType(operation)}, option.WithMaxRetries(0))
if operation == "fill" {
return c.fill(ctx, vault, key, params, output)
}
// 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}
}
return c.showItem(item, output, open)
if response == nil || (response.Type != "card" && response.Type != "wallet") {
return fmt.Errorf("unexpected vault operation response; inspect the item and do not retry")
}
var updated kernel.VaultItemUnion
if err := json.Unmarshal([]byte(response.RawJSON()), &updated); err != nil {
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 {
Expand Down
38 changes: 32 additions & 6 deletions cmd/vaults_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <vault> <key> <operation> only when advertised.
Follow the operation description and any returned provider action.
5. Attach the vault with browsers create --vault <id-or-name>. Use only returned
non-secret aliases in that browser. Inspect items get/events for the outcome.
5. Attach the vault with browsers create --vault <id-or-name>. 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.
Expand Down Expand Up @@ -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 <vault> <key> <operation>", 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 <operation>)")
invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL for authorize")
addVaultJSONOutputFlag(invoke)
items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true))

Expand Down
145 changes: 145 additions & 0 deletions cmd/vaults_fill.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package cmd

import (
"context"
"encoding/json"
"errors"
"fmt"
"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 := 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)
}
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
}
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
}
Loading
Loading