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
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ brew install onkernel/tap/kernel

Install the following tools:

- Go 1.22+ ( https://go.dev/doc/install )
- Go 1.25+ ( https://go.dev/doc/install ); the required version is recorded in `go.mod`
- [Goreleaser Pro](https://goreleaser.com/install/#pro) - **IMPORTANT: You must install goreleaser-pro, not the standard version, as this is required for our release process**
- [chglog](https://github.com/goreleaser/chglog)

Expand Down
170 changes: 170 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ Commands with JSON output support:
- **Proxies**: `create`, `list`, `get`, `update`, `check`
- **API Keys**: `create`, `list`, `get`, `update`, `rotate`
- **Auth Connections**: `timeline`
- **Vaults**: `create`, `list`, `get`, `items list/get/events/invoke`, `wallets create/payment-methods`, `cards create/update` (display-safe public fields only)
- **Projects**: `update`
- **Org**: `limits get/set`
- **Apps**: `list`, `history`
Expand Down Expand Up @@ -223,6 +224,7 @@ Commands with JSON output support:
- `--proxy-mode direct|default` - Egress mode instead of a selected proxy: `direct` for no proxy regardless of stealth, `default` for the stealth-derived default (Kernel's stealth proxy with `--stealth`, direct egress otherwise). Omit all proxy flags to get the default.
- `--name <name>` - Optional unique name for the session (used to find it later by name; can be changed with `browsers update --name`)
- `--tag <KEY=VALUE>` - Set a tag on the session, repeatable; up to 50 pairs
- `--vault <id-or-name>` - Attach a project-owned vault at creation (repeatable, max 20). Uses the API's effective project unless `--project` or `KERNEL_PROJECT` selects one. Cannot be combined with pool flags, even with `--yes`; vault bindings cannot be added to existing sessions.
- `--pool-id <id>` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags). `--name`/`--tag` still apply to the acquired session.
- `--pool-name <name>` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags)
- `--telemetry=all` - Enable telemetry for all categories
Expand Down Expand Up @@ -266,6 +268,174 @@ Commands with JSON output support:
- `-s, --silent` - Suppress progress output
- _Note: redirects are followed automatically by Chromium._

### Vaults

Vault commands **prepare and observe payment credentials; they do not submit merchant payments**.
Vault names, item keys, and project ownership are immutable. Optionally select a project with
`--project <id-or-name>` or `KERNEL_PROJECT`; otherwise, the API resolves the project from your
credentials and its defaults (the default project for org-wide credentials, not all projects).
Ownership is assigned from that scope, not a `project_id` body field. Project-scoped credentials
cannot switch projects.

#### Command reference

| Command | Purpose / flags |
| --- | --- |
| `kernel vaults create --name <name>` | Create or retrieve the vault with that immutable name |
| `kernel vaults list` | `--limit 1..100` (default 20), `--offset`; JSON includes `vaults` and optional `next_offset` |
| `kernel vaults get <vault>` | Get by ID or name |
| `kernel vaults delete <vault>` | Invalidate the vault and all its items; `--yes` skips confirmation |
| `kernel vaults wallets create <vault> <key> --provider link\|agentcard --spec '<json>'` | Connect/enroll a wallet using its provider's spec; `--open` opens a returned HTTPS action URL |
| `kernel vaults wallets payment-methods <vault> <key>` | Fetch advertised live payment methods; JSON is the item with `expanded.payment_methods` |
| `kernel vaults cards create <vault> <key> --provider link\|agentcard --spec '<json>'` | Create a card request; never implicitly authorize Link |
| `kernel vaults cards update <vault> <key> --provider link\|agentcard --spec '<json>'` | Replace the full card spec; 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 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 |

`<vault>` accepts an ID or name. `<key>` is the immutable item key within that vault, not
its generated item ID. Names and keys use letters, digits, dots, underscores, and hyphens
(1–255 characters; not `.` or `..`). All commands except delete support `-o json`.
JSON preserves field presence and API-returned aliases, while omitting unknown fields,
opaque metadata, and unrecognized event data. Human output labels aliases as non-secret
checkout values and distinguishes card readiness from checkout authorization/payment outcomes.
Action and approval URLs print in full on separate lines, without table truncation.
API failures use the CLI's standard error formatter, preserving the API's code and message.
`vaults delete` and `vaults items delete` treat HTTP 404 as success and print
`Deleted or not found`, whether the missing object is the project, vault, or item.
Other API errors still return a nonzero exit status.

**Provider specifications:** wallet creation and card creation/update require `--provider`
and `--spec '<json>'`. Supply only the spec object, not a `{type, spec}` envelope. The command
sets the item type and injects `provider`; if JSON also contains `provider`, it must match.
Other values are forwarded unchanged, including optional fields, without defaults or normalization.
The API validates the provider-specific schema. Each command's `--help` includes its raw
TypeScript-style types, which must stay in sync with the [API spec](https://api.onkernel.com/spec.yaml).

- **Link wallet:** supply `authorization: {method: "oauth", client: {type: "kernel_managed"}}`.
- **AgentCard wallet:** use `{}` to enroll, or supply `user_id` for an already enrolled user.
- **Link card:** include the required fields shown in help. Optional `line_items`, `totals`,
`metadata`, and `expires_at` are supported through JSON.
- **AgentCard card:** uses `merchant`, not Link's `merchant_name`. Its optional `card_id` selects
a vaulted card; otherwise the cardholder selects one at approval.

`cards update` replaces the entire spec, so omitted optional details are removed. Permitted
checkout domains remain provider-assigned. Neither command submits a merchant payment.

#### Link checkout preparation

1. Create/select a vault in the effective project. Connect the wallet in the provider's UI:

```bash
kernel vaults create --name checkout
kernel vaults wallets create checkout wallet-1 --provider link \
--spec '{"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}' --open
kernel vaults items get checkout wallet-1 --wait 60
```

2. Once connected, list methods and explicitly choose a returned ID:

```bash
kernel vaults wallets payment-methods checkout wallet-1
# Equivalent: kernel vaults items get checkout wallet-1 --expand payment_methods
kernel vaults cards create checkout order-1 --provider link --spec '{
"wallet": "wallet-1",
"payment_method_id": "<returned-id>",
"amount": 1234,
"currency": "usd",
"merchant_name": "Example Shop",
"merchant_url": "https://shop.example",
"context": "Purchase the selected office supplies from Example Shop for the approved order, with a total spending limit of 1234 minor currency units."
}'
```

3. After explicit user approval, authorize **only if the item advertises it**. Follow the
returned approval action, then observe:

```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
```

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:

```bash
kernel browsers create --vault checkout
```

5. Observe outcomes independently of merchant checkout submission:

```bash
kernel vaults items get checkout order-1
kernel vaults items events checkout order-1
kernel vaults items events checkout order-1 --after <last-event-id> --wait 60
```

#### AgentCard checkout preparation

For a separate AgentCard flow, create a vault and complete the wallet enrollment action:

```bash
kernel vaults create --name agentcard-checkout
kernel vaults wallets create agentcard-checkout wallet-1 --provider agentcard --spec '{}' --open
kernel vaults items get agentcard-checkout wallet-1 --wait 60
```

Once the wallet is connected, create the card request:

```bash
kernel vaults cards create agentcard-checkout order-1 --provider agentcard --spec '{
"wallet": "wallet-1",
"merchant": "Example Shop",
"amount": 1234,
"currency": "usd"
}'
kernel browsers create --vault agentcard-checkout
```

AgentCard authorizes at checkout and does not currently advertise `authorize`. To select a
vaulted card in advance, inspect `wallets payment-methods` and include its ID as `card_id` in the
card spec. Otherwise, the cardholder selects a card at approval. A reusable card being
`ready` does not mean the last payment succeeded.

#### 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 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 has no provider/type/state-specific
operation checks. The response is the updated item, possibly with a required user action.

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.

#### Expansions, updates, and lifecycle

`--expand` takes a value, such as `--expand payment_methods`; it is not a boolean switch.
Request only expansions advertised in `available_expansions`. Add `-o json` to read the
returned `expanded.payment_methods` directly. Unavailable expansions return an API error.

Use `cards update <vault> <key> --provider <provider> --spec '<json>'` to replace the entire
card spec when the API permits it. Include optional fields you want to retain; the CLI
does not merge the new JSON with the existing spec.

Waits are single bounded observations, not readiness guarantees or payment retries. Pending
state is returned as-is. Requests are not automatically retried by the vault commands.
**Never retry failed, timed-out, rejected, or indeterminate payments.** Inspect state/events
and reconcile the outcome instead. Do not pass card data, OAuth codes/tokens, ciphertext,
provider secrets, or sensitive provider responses to the CLI. Complete collection, OAuth,
and approval actions through the provider's returned URL/UI; no callback-code command exists.

### Browser Pools

- `kernel browser-pools list` - List browser pools
Expand Down
32 changes: 32 additions & 0 deletions cmd/browser_vaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package cmd

import (
"fmt"

kernel "github.com/kernel/kernel-go-sdk"
)

func buildBrowserVaults(values []string) ([]kernel.VaultReferenceParam, error) {
if len(values) > 20 {
return nil, fmt.Errorf("at most 20 --vault references may be attached")
}
var refs []kernel.VaultReferenceParam
seen := make(map[string]bool, len(values))
for _, value := range values {
if err := validateVaultName(value, "--vault"); err != nil {
return nil, err
}
if seen[value] {
return nil, fmt.Errorf("duplicate --vault reference")
}
seen[value] = true
ref := kernel.VaultReferenceParam{}
if cuidRegex.MatchString(value) {
ref.ID = kernel.Opt(value)
} else {
ref.Name = kernel.Opt(value)
}
refs = append(refs, ref)
}
return refs, nil
}
103 changes: 103 additions & 0 deletions cmd/browser_vaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package cmd

import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"

"github.com/kernel/cli/pkg/util"
kernel "github.com/kernel/kernel-go-sdk"
"github.com/kernel/kernel-go-sdk/option"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBuildBrowserVaults(t *testing.T) {
const id = "abcdefghijklmnopqrstuvwx"
refs, err := buildBrowserVaults([]string{id, "checkout"})
require.NoError(t, err)
require.Len(t, refs, 2)
assert.Equal(t, id, refs[0].ID.Value)
assert.False(t, refs[0].Name.Valid())
assert.Equal(t, "checkout", refs[1].Name.Value)
assert.False(t, refs[1].ID.Valid())
for _, values := range [][]string{{""}, {" "}, {"../checkout"}, {".."}, {"checkout", "checkout"}, make([]string, 21)} {
_, err := buildBrowserVaults(values)
require.Error(t, err)
}
refs, err = buildBrowserVaults(nil)
require.NoError(t, err)
body, err := json.Marshal(kernel.BrowserNewParams{Vaults: refs})
require.NoError(t, err)
assert.NotContains(t, string(body), "vaults")
assert.NotNil(t, browsersCreateCmd.Flags().Lookup("vault"))
assert.Nil(t, browsersUpdateCmd.Flags().Lookup("vault"))
assert.False(t, poolLeaseAllowedFlags()["vault"])
}

func browserVaultTestCommand(client kernel.Client) *cobra.Command {
cmd := &cobra.Command{Use: "create"}
cmd.Flags().String("project", "", "")
cmd.Flags().StringArray("vault", nil, "")
cmd.Flags().String("pool-id", "", "")
cmd.Flags().String("pool-name", "", "")
cmd.Flags().Bool("yes", false, "")
addJSONOutputFlag(cmd)
cmd.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client))
return cmd
}

func TestBrowserVaultPoolAndReferenceValidation(t *testing.T) {
t.Setenv("KERNEL_PROJECT", "")
client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid attachment reached API") })
for _, flags := range [][]string{
{"--vault", "checkout", "--pool-id", "pool-1", "--yes"},
{"--vault", "checkout", "--pool-name", "pool", "--yes"},
{"--vault="},
} {
cmd := browserVaultTestCommand(client)
require.NoError(t, cmd.ParseFlags(flags))
err := runBrowsersCreate(cmd, nil)
require.Error(t, err)
}
}

func TestBrowserCreateVaultRequestAndReturnedAttachments(t *testing.T) {
t.Setenv("KERNEL_PROJECT", "")
const body = `{"session_id":"browser-1","cdp_ws_url":"ws://example.test/cdp","vaults":[{"id":"vault-1","name":"checkout"}]}`
client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/browsers", r.URL.Path)
assert.Empty(t, r.Header.Get("X-Kernel-Project"))
payload, _ := io.ReadAll(r.Body)
assert.JSONEq(t, `{"vaults":[{"name":"checkout"}]}`, string(payload))
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, body)
})
for _, output := range []string{"", "json"} {
cmd := browserVaultTestCommand(client)
require.NoError(t, cmd.Flags().Set("vault", "checkout"))
require.NoError(t, cmd.Flags().Set("output", output))
buf := capturePtermOutput(t)
out := captureStdout(t, func() { require.NoError(t, runBrowsersCreate(cmd, nil)) })
if output == "json" {
assert.JSONEq(t, body, out)
} else {
assert.Contains(t, buf.String(), "Attached vault ID")
assert.Contains(t, buf.String(), "vault-1")
assert.Contains(t, buf.String(), "checkout")
}
}
}

func TestBrowserCreateInvalidVaultNeverCallsSDK(t *testing.T) {
b := BrowsersCmd{browsers: &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) {
t.Fatal("invalid vault reference should not reach SDK")
return nil, nil
}}}
require.Error(t, b.Create(context.Background(), BrowsersCreateInput{Vaults: []string{strings.Repeat("x", 256)}}))
}
Loading
Loading