From 5b8db58f42e89e1f85860e89d8e52b09f120e7c6 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:00:08 +0000 Subject: [PATCH 01/11] Add credential vault commands and preview CLI builds --- .github/workflows/preview.yaml | 47 +++++++++ .github/workflows/release.yaml | 7 ++ .github/workflows/test.yaml | 20 +++- PREVIEW.md | 52 ++++++++++ README.md | 50 ++++++++-- cmd/browsers.go | 2 +- cmd/vaults.go | 32 +++++- cmd/vaults_commands.go | 64 ++++++++++-- cmd/vaults_credentials.go | 151 ++++++++++++++++++++++++++++ cmd/vaults_credentials_test.go | 164 +++++++++++++++++++++++++++++++ cmd/vaults_output.go | 28 +++++- cmd/vaults_secrets.go | 4 +- cmd/vaults_spec_test.go | 2 +- cmd/vaults_test.go | 8 +- cmd/vaults_wallet_config_test.go | 2 +- go.mod | 2 + go.sum | 4 +- scripts/build-preview.sh | 23 +++++ 18 files changed, 626 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/preview.yaml create mode 100644 PREVIEW.md create mode 100644 cmd/vaults_credentials.go create mode 100644 cmd/vaults_credentials_test.go create mode 100644 scripts/build-preview.sh diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml new file mode 100644 index 00000000..c7a8485b --- /dev/null +++ b/.github/workflows/preview.yaml @@ -0,0 +1,47 @@ +name: Preview CLI binaries + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: false + - name: Preview SDK read token + id: sdk-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.ADMIN_APP_ID }} + private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} + repositories: kernel-go-sdk-staging + permission-contents: read + - name: Download dependencies + env: + GOPRIVATE: github.com/kernel/kernel-go-sdk-staging + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: url.https://x-access-token:${{ steps.sdk-token.outputs.token }}@github.com/.insteadOf + GIT_CONFIG_VALUE_0: https://github.com/ + run: go mod download + - name: Build preview archives + run: bash scripts/build-preview.sh + - uses: actions/upload-artifact@v4 + with: + name: kernel-preview-${{ github.event.pull_request.head.sha || github.sha }} + path: dist/preview/* + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f8baf9eb..53e8cdd0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -26,6 +26,13 @@ jobs: with: fetch-depth: 0 + - name: Reject preview SDK dependencies + run: | + if grep -q 'kernel-go-sdk-staging' go.mod; then + echo 'Replace the preview SDK pin with a released SDK before publishing.' >&2 + exit 1 + fi + - name: Set up Go uses: actions/setup-go@v6 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5f10d664..fcb76091 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,6 +9,7 @@ on: jobs: test: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: contents: read @@ -21,7 +22,24 @@ jobs: uses: actions/setup-go@v6 with: go-version-file: "go.mod" - cache: true + cache: false + + - name: Preview SDK read token + id: sdk-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.ADMIN_APP_ID }} + private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} + repositories: kernel-go-sdk-staging + permission-contents: read + + - name: Download dependencies + env: + GOPRIVATE: github.com/kernel/kernel-go-sdk-staging + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: url.https://x-access-token:${{ steps.sdk-token.outputs.token }}@github.com/.insteadOf + GIT_CONFIG_VALUE_0: https://github.com/ + run: go mod download - name: Run tests run: make test diff --git a/PREVIEW.md b/PREVIEW.md new file mode 100644 index 00000000..38cb71ff --- /dev/null +++ b/PREVIEW.md @@ -0,0 +1,52 @@ +# Preview binaries + +The `Preview CLI binaries` workflow builds same-repository pull requests automatically. +It also supports `workflow_dispatch` after the workflow exists on the default branch. +It builds the PR head, not GitHub's synthetic merge commit. Archives cover Linux, +macOS, and Windows on amd64 and arm64, with `SHA256SUMS`. The embedded version is +`0.0.0-preview.g`; the full commit is embedded as well. + +Download a run's artifact (GitHub authentication required): + +```sh +gh run list --repo kernel/cli --workflow preview.yaml --branch +gh run download --repo kernel/cli --pattern 'kernel-preview-*' --dir preview +cd preview +sha256sum -c SHA256SUMS +# macOS: shasum -a 256 -c SHA256SUMS +# Extract the archive matching your operating system and architecture. +tar -xzf kernel_0.0.0-preview.g_linux_amd64.tar.gz +./kernel --version +./kernel vaults credentials --help +./kernel vaults items invoke --help +``` + +Use the extracted binary explicitly rather than replacing the stable installation. +Set `KERNEL_BASE_URL` and `KERNEL_API_KEY` for your local/test environment before API +calls; do not assume the production API supports preview features. + +Artifacts expire after 14 days. No GitHub release, stable tag, npm package, Homebrew +formula, or production deployment is created. macOS binaries are unsigned; Windows +archives contain `kernel.exe`. Download on trusted machines and verify checksums. + +## Temporary SDK pin + +`go.mod` replaces the normal Go SDK with the immutable STLC preview revision from +`kernel/kernel` PR #3898. The staging SDK repository requires GitHub authentication. +Tests and preview builds use the existing GitHub App credentials to obtain a +contents-read token scoped to `kernel-go-sdk-staging`, solely for `go mod download`. +No token is passed to compilation/tests, no credential config is persisted, and Go +caching is disabled so private module sources are not exported into Actions caches. +Fork PR jobs are skipped while this private dependency is required. No +`pull_request_target` workflow executes untrusted PR code. + +Replace the preview pin with the released `github.com/kernel/kernel-go-sdk` version +before a stable release, then remove the temporary SDK-auth steps and restore fork +testing/cache behavior. The stable release workflow rejects the staging SDK pin. + +For a local cross-platform build, authenticate Git for the SDK repository, then run: + +```sh +GOPRIVATE=github.com/kernel/kernel-go-sdk-staging go mod download +bash scripts/build-preview.sh +``` diff --git a/README.md b/README.md index af479680..804c1853 100644 --- a/README.md +++ b/README.md @@ -128,7 +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) +- **Vaults**: `create`, `list`, `get`, `credentials create/update`, `items list/get/events/invoke` (including `collect` and `fill`), `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) - **Projects**: `update` - **Org**: `limits get/set` - **Apps**: `list`, `history` @@ -270,7 +270,44 @@ Commands with JSON output support: ### Vaults -Vault commands **prepare and observe payment credentials; they do not submit merchant payments**. +Vault commands **collect user credentials and manage payment credentials; fill does not submit website forms**. + +#### User credentials + +Create a vault for the end user, attach it when creating a browser, then navigate to the +sensitive form. Define the observed fields without supplying values: + +```sh +kernel vaults create --name user-vault +kernel browsers create --vault user-vault +kernel vaults credentials create user-vault login --spec-file - <<'JSON' +{"fields":{"username":{"type":"email","required":true},"password":{"type":"password","required":true}}} +JSON +kernel vaults items get user-vault login --wait 60 -o json +kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' +{"browser_id":"","fields":[{"field":"username","selector":"#username"},{"field":"password","selector":"#password"}]} +JSON +``` + +Present the returned collection URL to the user before waiting for `ready`. It is a +bearer credential: share it only with that user. Readiness means required values are +populated, not that login succeeded. `fill` requires an already-open page and never +navigates or submits it. Optional `page_url` selects the exact page; cards require it. +Do not automatically retry failed/unknown fills or fall back to aliases. + +Use `credentials update --version --spec-file changes.json` +with a spec such as `{"fields":{"password":{"value":"replacement"}}}`. Keep actual +secrets in protected files or stdin, never shell arguments. Omission preserves values; +null clears supported fields. Field definitions cannot change. Stale versions fail, +without retries. `items invoke collect` reopens the full form without +clearing values; compare versions to observe edits to already-ready items. + +Types are `text`, `email`, `password`, and `totp`. TOTP seeds must be provided through +create/update, never the form; only generated codes enter the browser. Unrestricted +browser access can read filled values. CLI output omits all stored credential values, +including non-sensitive values, and retains definitions, version, and `has_value`. +Credential spec input is capped at 128 KiB; write errors are redacted. + Vault names, item keys, and project ownership are immutable. Optionally select a project with `--project ` 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). @@ -484,10 +521,11 @@ advertised. The API controls availability. The CLI additionally refuses invocati 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. -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` and `collect` are parameterless. `fill` takes `--spec-file` with `browser_id`, +ordered `fields` bindings, optional `page_url`, and optional `timeout_ms`. It prints +outcome-only JSON (`completed`, `failed`, or `unknown` plus field outcomes); a non-completed +result exits nonzero. Other operations return an item. Wallet/card `--spec` flags remain +unchanged. New parameterless operations can be invoked when advertised. #### Expansions, updates, and lifecycle diff --git a/cmd/browsers.go b/cmd/browsers.go index 4c59108c..e33c8a2d 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -2974,7 +2974,7 @@ func init() { browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)") browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60") browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") - browsersCreateCmd.Flags().StringArray("vault", nil, "Project-owned vault ID or name to attach at creation (repeatable, max 20; incompatible with pools)") + browsersCreateCmd.Flags().StringArray("vault", nil, "Vault ID or name to attach for credential/card fill at creation (repeatable, max 20; incompatible with pools; see vaults --help)") browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") browsersCreateCmd.Flags().String("telemetry", "", "Configure telemetry (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") diff --git a/cmd/vaults.go b/cmd/vaults.go index 6250948c..0b70fe2d 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -191,7 +192,7 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. var item *kernel.VaultItemUnion var err error if update { - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, Spec: spec}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCard: &kernel.VaultItemUpdateParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) } else { item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) } @@ -202,6 +203,10 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. } func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation, output string, open bool) error { + return c.invoke(ctx, vault, key, operation, output, open, nil) +} + +func (c VaultsCmd) invoke(ctx context.Context, vault, key, operation, output string, open bool, fill *kernel.FillVaultItemOperationRequestParam) error { if strings.TrimSpace(operation) == "" { return fmt.Errorf("operation must not be empty") } @@ -229,10 +234,33 @@ 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)) + params := kernel.VaultItemPerformOperationParams{IDOrName: vault} + opts := []option.RequestOption{option.WithMaxRetries(0)} + switch operation { + case "fill": + if fill == nil { + return fmt.Errorf("fill requires --spec-file with browser_id and ordered fields; see items invoke --help") + } + params.OfFill = fill + case "collect": + params.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: "collect"} + default: + params.OfAuthorize = &kernel.VaultItemPerformOperationParamsBodyAuthorize{} + opts = append(opts, option.WithJSONSet("type", operation)) + } + result, err := c.vaults.Items.PerformOperation(ctx, key, params, opts...) if err != nil { + if item.Type == "credential" || operation == "fill" { + return vaultCredentialError(err) + } return util.CleanedUpSdkError{Err: err} } + if operation == "fill" { + return printVaultFill(result, len(fill.Fields)) + } + if err := json.Unmarshal([]byte(result.RawJSON()), &item); err != nil { + return fmt.Errorf("invalid vault item response") + } return c.showItem(item, output, open) } diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index b68c0497..55c48912 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -51,8 +51,19 @@ func vaultPreRun(cmd *cobra.Command, args []string) error { func newVaultsCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "vaults", Aliases: []string{"vault"}, Short: "Prepare and observe project-owned payment credentials", - Long: `Prepare and observe payment credentials; vault commands do not submit merchant payments. + Use: "vaults", Aliases: []string{"vault"}, Short: "Collect user credentials and manage payment credentials", + Long: `Collect user credentials and manage payment credentials; fill never submits website forms. + +User credential flow: +1. Create a vault per end user and create a browser with --vault . +2. Navigate to a sensitive form and define its fields with credentials create --spec-file. +3. Present the returned collection URL to the user. Poll items get --wait 60 for ready. +4. Use items invoke fill --spec-file with browser_id and field selectors. +Use credentials update --version for edits, or items invoke collect to reopen the form. +Credential values belong in protected files/stdin, never command-line arguments. +See credentials --help and items invoke --help for examples. + +Payment credential flow: Optionally select a project with --project or KERNEL_PROJECT. Otherwise, the API resolves the project from your credentials and its defaults. @@ -63,8 +74,9 @@ 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 . Prefer advertised fill; + non-secret aliases are an alternative for explicit egress-substitution integrations. + Inspect items get/events for the outcome. Permitted checkout domains are provider-assigned and displayed when returned; there is no domain-setting API. @@ -103,14 +115,14 @@ JSON output preserves returned public fields but omits unknown/opaque provider d addVaultJSONOutputFlag(get) cmd.AddCommand(create, list, get, newVaultDeleteCommand(false)) - items := &cobra.Command{Use: "items", Short: "Inspect vault item state, actions, aliases, and outcomes"} + items := &cobra.Command{Use: "items", Short: "Inspect readiness and collection URLs, or invoke collect/fill", Long: "Use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nUse invoke collect to obtain a collection URL, or invoke fill --spec-file to fill a browser.\nCreate and edit credentials with vaults credentials; payment items use wallets/cards."} itemList := &cobra.Command{Use: "list ", Short: "List items by vault ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, RunE: func(cmd *cobra.Command, args []string) error { return getVaultsHandler(cmd).ListItems(cmd.Context(), args[0], vaultOutput(cmd)) }} addVaultJSONOutputFlag(itemList) itemGet := &cobra.Command{Use: "get ", Short: "Get item state and any required action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: "Get item state, available operations, provider actions, and returned checkout aliases.\n--wait is a single bounded server-side observation, not a retry or a guarantee of readiness.\nAn item still pending after the wait is returned as-is; ready does not mean paid.\nrecovery_required stops waiting and means unresolved, not declined or expired.\nReconcile with the provider or support; do not retry, delete, or replace the payment.", + Long: "Get item state, available operations, provider actions, and returned checkout aliases.\n--wait is a single bounded server-side observation, not a retry or a guarantee of readiness.\nAn item still pending after the wait is returned as-is; ready means populated for credentials, not logged in or paid.\nFor credential edits on an already-ready item, compare versions without --wait. Stored field values are omitted from CLI output.\nrecovery_required stops waiting and means unresolved, not declined or expired.\nReconcile with the provider or support; do not retry, delete, or replace the payment.", RunE: func(cmd *cobra.Command, args []string) error { wait, _ := cmd.Flags().GetInt64("wait") expand, _ := cmd.Flags().GetStringSlice("expand") @@ -132,12 +144,44 @@ 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. +collect returns a time-scoped URL for the full credential form without clearing values. +authorize sends {"type":"authorize"} for payment authorization. +fill requires --spec-file JSON with browser_id and ordered fields (field, selector), +plus optional page_url and timeout_ms. The vault must already be attached to the browser. +Navigate first: page_url selects an existing page, never navigates. Cards require HTTPS +page_url; credentials may omit it only when exactly one page is open. +Fill never submits forms. TOTP codes are generated in the control plane. +completed means fields were filled, not website acceptance. failed may leave partial +writes; unknown quarantines the browser. Never automatically retry or fall back. +Requests are not automatically retried. Only collect/authorize may use --open. +Fill output is outcome-only JSON; non-completed results return a nonzero exit status.`, + Example: ` kernel vaults items invoke user-vault login collect + kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' +{"browser_id":"","fields":[{"field":"username","selector":"#username"},{"field":"password","selector":"#password"}]} +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) + var fill *kernel.FillVaultItemOperationRequestParam + if args[2] == "fill" { + if open { + return fmt.Errorf("--open does not apply to fill") + } + data, err := readVaultSpecFile(cmd) + if err != nil { + return err + } + fill = &kernel.FillVaultItemOperationRequestParam{} + if json.Unmarshal(data, fill) != nil || fill.BrowserID == "" || len(fill.Fields) == 0 { + return fmt.Errorf("fill spec requires browser_id and fields") + } + fill.Type = "fill" + } else if cmd.Flags().Changed("spec-file") { + return fmt.Errorf("--spec-file is only supported for fill") + } + return getVaultsHandler(cmd).invoke(cmd.Context(), args[0], args[1], args[2], vaultOutput(cmd), open, fill) }} + invoke.Flags().String("spec-file", "", "Fill request JSON file (use '-' for stdin; no credential values)") invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) @@ -181,7 +225,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d cards := &cobra.Command{Use: "cards", Short: "Configure card requests"} cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) - cmd.AddCommand(items, wallets, cards) + cmd.AddCommand(items, wallets, cards, newVaultCredentialsCommand()) return cmd } diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go new file mode 100644 index 00000000..c7c1c9ab --- /dev/null +++ b/cmd/vaults_credentials.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/spf13/cobra" +) + +const vaultCredentialHelp = `Create credentials from the sensitive fields observed on a website. +First create a vault for the end user and attach it with browsers create --vault. +Use a protected JSON file or stdin, never secret values in shell arguments. +The spec contains description and fields keyed by name. Field types are text, +email, password, and totp; definitions accept required, sensitive, and value. +Omit required values to receive a collection URL to present to the user. +Poll items get --wait 60 until state.status is ready, then use items invoke fill. +Ready means populated, not a successful login. An agent controlling the browser +can read filled values. TOTP seeds must not be collected through the hosted form. +Get/list output includes definitions and has_value, not stored field values. +Collection URLs are bearer credentials: share only with the intended user.` + +func newVaultCredentialsCommand() *cobra.Command { + group := &cobra.Command{Use: "credentials", Short: "Collect, update, and fill user credentials", Long: vaultCredentialHelp} + for _, update := range []bool{false, true} { + name, short := "create", "Create a credential and return its collection URL" + if update { + name, short = "update", "Update credential values or description using an expected version" + } + cmd := &cobra.Command{Use: name + " --spec-file ", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, Long: vaultCredentialHelp, + RunE: func(cmd *cobra.Command, args []string) error { + data, err := readVaultSpecFile(cmd) + if err != nil { + return err + } + version, _ := cmd.Flags().GetInt64("version") + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).saveCredential(cmd.Context(), args[0], args[1], data, update, version, vaultOutput(cmd), open) + }, + } + if update { + cmd.Long += "\nUpdate preserves omitted fields, replaces string values, and clears supported values with null.\nField definitions are immutable. Do not automatically retry version conflicts." + cmd.Flags().Int64("version", 0, "Expected version from items get (required; never auto-refreshed)") + _ = cmd.MarkFlagRequired("version") + cmd.Example = " kernel vaults credentials update user-vault login --version 2 --spec-file changes.json" + } else { + cmd.Example = ` kernel vaults credentials create user-vault login --spec-file - <<'JSON' +{"fields":{"username":{"type":"email","required":true},"password":{"type":"password","required":true}}} +JSON` + } + cmd.Flags().String("spec-file", "", "Credential spec JSON file (use '-' for stdin; maximum 128 KiB)") + _ = cmd.MarkFlagRequired("spec-file") + cmd.Flags().Bool("open", false, "Open the returned HTTPS collection URL") + addVaultJSONOutputFlag(cmd) + group.AddCommand(cmd) + } + return group +} + +func readVaultSpecFile(cmd *cobra.Command) ([]byte, error) { + path, _ := cmd.Flags().GetString("spec-file") + if path == "" { + return nil, fmt.Errorf("--spec-file is required (use '-' for stdin)") + } + var reader io.Reader = cmd.InOrStdin() + if path != "-" { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("could not open --spec-file") + } + defer f.Close() + reader = f + } + const limit = 128 * 1024 + data, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil || len(data) > limit { + return nil, fmt.Errorf("could not read --spec-file (maximum 128 KiB)") + } + var object map[string]json.RawMessage + if json.Unmarshal(data, &object) != nil || object == nil { + return nil, fmt.Errorf("--spec-file must contain a JSON object") + } + return data, nil +} + +func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data []byte, update bool, version int64, output string, open bool) error { + var item *kernel.VaultItemUnion + var err error + if update { + if version < 1 { + return fmt.Errorf("--version must be positive") + } + var spec kernel.CredentialVaultItemSpecUpdateParam + if json.Unmarshal(data, &spec) != nil { + return fmt.Errorf("invalid credential update spec") + } + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredential: &kernel.CredentialVaultItemUpdateRequestParam{Type: "credential", Version: version, Spec: spec}}, option.WithMaxRetries(0)) + } else { + var spec kernel.CredentialVaultItemSpecInputParam + if json.Unmarshal(data, &spec) != nil || len(spec.Fields) == 0 { + return fmt.Errorf("credential spec requires fields") + } + item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredential: &kernel.CredentialVaultItemRequestParam{Type: "credential", Spec: spec}}, option.WithMaxRetries(0)) + } + if err != nil { + return vaultCredentialError(err) + } + return c.showItem(item, output, open) +} + +func printVaultFill(result *kernel.VaultItemOperationResponseUnion, expectedFields int) error { + raw, err := filterVaultJSON(json.RawMessage(result.RawJSON()), vaultOutputFields{"type": nil, "status": nil, "fields": vaultFieldsOf("index status error_code")}) + if err != nil { + return fmt.Errorf("invalid fill response") + } + var outcome kernel.FillVaultItemOperationResult + if json.Unmarshal(raw, &outcome) != nil || outcome.Type != "fill" || len(outcome.Fields) != expectedFields { + return fmt.Errorf("invalid fill response") + } + switch outcome.Status { + case "completed", "failed", "unknown": + default: + return fmt.Errorf("invalid fill response") + } + for i, field := range outcome.Fields { + if field.Index != int64(i) || (outcome.Status == "completed" && field.Status != "filled") { + return fmt.Errorf("invalid fill response") + } + switch field.Status { + case "filled", "failed", "not_attempted", "unknown": + default: + return fmt.Errorf("invalid fill response") + } + switch field.ErrorCode { + case "", "target_changed", "element_not_found", "ambiguous_selector", "element_not_editable", "option_not_found", "timeout", "execution_failed": + default: + return fmt.Errorf("invalid fill response") + } + } + if err := printVaultJSON(raw); err != nil { + return err + } + if outcome.Status != "completed" { + return fmt.Errorf("fill did not complete; inspect field outcomes and do not automatically retry or fall back") + } + return nil +} diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go new file mode 100644 index 00000000..17ec5511 --- /dev/null +++ b/cmd/vaults_credentials_test.go @@ -0,0 +1,164 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + kernel "github.com/kernel/kernel-go-sdk" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const credentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Website login","fields":{"password":{"type":"password","required":true,"sensitive":true,"value":"never-print"}}},"state":{"status":"pending_collection","fields":{"password":{"has_value":false,"value":"never-print"}}},"action":{"name":"collect","url":"https://vault.kernel.sh/collect#token=item.random","expires_at":"2026-10-01T00:00:00Z"},"available_operations":[{"type":"collect","description":"Open the form"},{"type":"fill","description":"Fill the form"}],"available_expansions":[]}` + +func credentialSpecFile(t *testing.T, data string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "spec.json") + require.NoError(t, os.WriteFile(path, []byte(data), 0600)) + return path +} + +func TestCredentialCreateAndUpdate(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, update := range []bool{false, true} { + t.Run(fmt.Sprint(update), func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, "/vaults/user/items/login", r.URL.Path) + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, `"credential"`, string(body["type"])) + if update { + assert.Equal(t, "PATCH", r.Method) + assert.JSONEq(t, `2`, string(body["version"])) + assert.JSONEq(t, `{"fields":{"password":{"value":null}}}`, string(body["spec"])) + } else { + assert.Equal(t, "PUT", r.Method) + assert.JSONEq(t, `{"fields":{"password":{"type":"password","required":true}}}`, string(body["spec"])) + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, credentialFixture) + }) + args := []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password","required":true}}}`), "-o", "json"} + if update { + args[2] = "update" + args[6] = credentialSpecFile(t, `{"fields":{"password":{"value":null}}}`) + args = append(args, "--version", "2") + } + out, _, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.NotContains(t, out, "never-print") + assert.Contains(t, out, `"has_value": false`) + assert.Contains(t, out, `"version": 2`) + assert.Contains(t, out, "#token=item.random") + }) + } +} + +func TestCredentialWriteErrorsAreRedactedAndNotRetried(t *testing.T) { + for _, status := range []int{400, 409, 429, 500} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + io.WriteString(w, `{"message":"secret-echo"}`) + }) + c := VaultsCmd{vaults: &client.Vaults} + err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":{"password":{"type":"password","value":"secret-echo"}}}`), false, 0, "json", false) + require.Error(t, err) + assert.NotContains(t, err.Error(), "secret-echo") + assert.Equal(t, 1, calls) + }) + } +} + +func TestCredentialCollectAndFill(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, status := range []string{"collect", "completed", "failed", "unknown"} { + t.Run(status, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + io.WriteString(w, credentialFixture) + return + } + assert.Equal(t, "POST", r.Method) + data, err := io.ReadAll(r.Body) + require.NoError(t, err) + if status == "collect" { + assert.JSONEq(t, `{"type":"collect"}`, string(data)) + io.WriteString(w, credentialFixture) + return + } + assert.JSONEq(t, `{"type":"fill","browser_id":"browser-1","fields":[{"field":"password","selector":"#password"}]}`, string(data)) + io.WriteString(w, fmt.Sprintf(`{"type":"fill","status":%q,"fields":[{"index":0,"status":"filled"}],"secret":"never-print"}`, status)) + }) + op := "fill" + if status == "collect" { + op = "collect" + } + args := []string{"vaults", "items", "invoke", "user", "login", op, "-o", "json"} + if op == "fill" { + args = append(args, "--spec-file", credentialSpecFile(t, `{"browser_id":"browser-1","fields":[{"field":"password","selector":"#password"}]}`)) + } + out, _, err := executeVaultCommand(t, client, args...) + if status == "failed" || status == "unknown" { + require.ErrorContains(t, err, "do not automatically retry") + } else { + require.NoError(t, err) + } + assert.Equal(t, 2, calls) + assert.NotContains(t, out, "never-print") + }) + } +} + +func TestCredentialFillRejectsUnsafeOutcomes(t *testing.T) { + for _, raw := range []string{ + `{"type":"fill","status":"secret-echo","fields":[]}`, + `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled","error_code":"secret-echo"}]}`, + `{"type":"fill","status":"completed","fields":[]}`, + } { + var result kernel.VaultItemOperationResponseUnion + require.NoError(t, json.Unmarshal([]byte(raw), &result)) + var err error + out := captureStdout(t, func() { err = printVaultFill(&result, 1) }) + require.Error(t, err) + assert.Empty(t, out) + assert.NotContains(t, err.Error(), "secret-echo") + } +} + +func TestCredentialDiscoveryAndInvalidInput(t *testing.T) { + for _, path := range []string{"credentials", "credentials create", "credentials update", "items", "items invoke"} { + cmd, _, err := newVaultsCommand().Find(strings.Fields(path)) + require.NoError(t, err) + assert.NotEmpty(t, cmd.Long) + } + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("invalid input reached API") }) + for _, body := range []string{"null", "[]", "{} {}", strings.Repeat("x", 128*1024+1)} { + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, body)) + require.Error(t, err) + } + cmd, _, err := newVaultsCommand().Find([]string{"credentials", "create"}) + require.NoError(t, err) + cmd.Flags().Set("spec-file", "-") + cmd.SetIn(strings.NewReader(`{"fields":{}}`)) + data, err := readVaultSpecFile(cmd) + require.NoError(t, err) + assert.JSONEq(t, `{"fields":{}}`, string(data)) +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 3262007c..026ca690 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -32,15 +32,16 @@ var vaultMethodFields = vaultOutputFields{ "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, } var vaultItemFields = vaultOutputFields{ - "id": nil, "key": nil, "type": 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, "available_operations": vaultOperationFields, "available_expansions": vaultOperationFields, - "action": vaultFieldsOf("name url"), + "action": vaultFieldsOf("name url expires_at"), "expanded": {"payment_methods": vaultMethodFields}, "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, - "context": nil, "expires_at": nil, + "context": nil, "expires_at": nil, "description": nil, + "fields": {"*": vaultFieldsOf("type required sensitive")}, "provider_config": vaultFieldsOf("id name"), "authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultFieldsOf("id name")}}, "totals": vaultTotalFields, @@ -51,6 +52,7 @@ var vaultItemFields = vaultOutputFields{ }, "state": { "provider": nil, "status": nil, "status_reason": nil, "user_id": nil, "domains": nil, + "fields": {"*": vaultFieldsOf("has_value")}, "masks": vaultFieldsOf("brand last4"), "aliases": vaultFieldsOf("number cvc exp_month exp_year"), "authorization": vaultFieldsOf("id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered"), @@ -97,6 +99,16 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes } result := make(vaultJSON) for key, children := range fields { + if key == "*" { + for name, value := range object { + filtered, err := filterVaultJSON(value, children) + if err != nil { + return nil, err + } + result[name] = filtered + } + continue + } if value, ok := object[key]; ok { if key == "url" || key == "approval_url" || key == "merchant_url" || key == "image_url" || key == "product_url" { var address string @@ -195,7 +207,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 += " --spec-file fill.json" + } + pterm.Printf("Invoke: %s -- %s %s %s\n", command, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) } return nil } @@ -221,6 +237,10 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { {"Property", "Value"}, {"Key (immutable)", item.Key}, {"ID", item.ID}, {"Type", item.Type}, {"Provider", item.Spec.Provider}, {"Status", item.State.Status}, } + if item.Type == "credential" { + rows = append(rows, []string{"Version", fmt.Sprint(item.Version)}) + pterm.Info.Println("Use -o json for field definitions and presence; stored values are omitted") + } if item.Type == "wallet" { configID, configName := item.Spec.ProviderConfig.ID, item.Spec.ProviderConfig.Name if item.Spec.Provider == "link" { diff --git a/cmd/vaults_secrets.go b/cmd/vaults_secrets.go index 24803447..3616ad0f 100644 --- a/cmd/vaults_secrets.go +++ b/cmd/vaults_secrets.go @@ -21,11 +21,11 @@ func vaultCredentialError(err error) error { case 400: return fmt.Errorf("vault request rejected (HTTP 400); check the input and credential validity") case 403: - return fmt.Errorf("vault request forbidden (HTTP 403); configuration writes require organization-scoped authentication") + return fmt.Errorf("vault request forbidden (HTTP 403); check authentication scope and permissions") case 404: return fmt.Errorf("vault resource not found (HTTP 404)") case 409: - return fmt.Errorf("vault conflict (HTTP 409); names and bindings must match, grants cannot be replaced, and referenced configurations cannot be deleted") + return fmt.Errorf("vault conflict (HTTP 409); inspect current version, state, and immutable bindings before retrying") default: return fmt.Errorf("vault request failed (HTTP %d); outcome may be unresolved, inspect existing state before taking further action", apiErr.StatusCode) } diff --git a/cmd/vaults_spec_test.go b/cmd/vaults_spec_test.go index b20f2078..b5b31c99 100644 --- a/cmd/vaults_spec_test.go +++ b/cmd/vaults_spec_test.go @@ -36,7 +36,7 @@ func TestVaultRawSpecForwarding(t *testing.T) { assert.Equal(t, "/vaults/checkout/items/item-1", r.URL.Path) if path == "cards update" { assert.Equal(t, http.MethodPatch, r.Method) - assert.Empty(t, body.Type) + assert.Equal(t, "card", body.Type) } else { assert.Equal(t, http.MethodPut, r.Method) assert.Equal(t, strings.TrimSuffix(strings.Fields(path)[0], "s"), body.Type) diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 8584a189..14942156 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -278,12 +278,8 @@ func TestVaultCardRequestMapping(t *testing.T) { assert.Equal(t, "/vaults/checkout/items/order-1", r.URL.Path) var body map[string]json.RawMessage require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - if operation == "create" { - assert.JSONEq(t, `"card"`, string(body["type"])) - assert.Len(t, body, 2) - } else { - assert.Len(t, body, 1) - } + assert.JSONEq(t, `"card"`, string(body["type"])) + assert.Len(t, body, 2) if provider == "link" { assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","merchant_url":"https://shop.example","payment_method_id":"pm-1","context":%q}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) } else { diff --git a/cmd/vaults_wallet_config_test.go b/cmd/vaults_wallet_config_test.go index cd0f1f63..540ef44c 100644 --- a/cmd/vaults_wallet_config_test.go +++ b/cmd/vaults_wallet_config_test.go @@ -264,7 +264,7 @@ func TestVaultPendingUpdatePreservesOmissionsAndEmptyLists(t *testing.T) { client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPatch, r.Method) body, _ := io.ReadAll(r.Body) - assert.JSONEq(t, `{"spec":{"provider":"link","wallet":"wallet-1","amount":2000`+fields+`}}`, string(body)) + assert.JSONEq(t, `{"type":"card","spec":{"provider":"link","wallet":"wallet-1","amount":2000`+fields+`}}`, string(body)) w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, strings.ReplaceAll(requestedCardFixture, "requested", "recovery_required")) }) diff --git a/go.mod b/go.mod index 16efd6b8..86b3c256 100644 --- a/go.mod +++ b/go.mod @@ -60,3 +60,5 @@ require ( golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/kernel/kernel-go-sdk => github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260913234358-ea40d26657db diff --git a/go.sum b/go.sum index 3661f19f..fca69520 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-staging v0.86.1-0.20260913234358-ea40d26657db h1:dxlk9L3oXPZq82nK0P41uNziUVT8euSITiWC2ha+NUU= +github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260913234358-ea40d26657db/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= diff --git a/scripts/build-preview.sh b/scripts/build-preview.sh new file mode 100644 index 00000000..14dd04d6 --- /dev/null +++ b/scripts/build-preview.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +sha=$(git rev-parse HEAD) +version="0.0.0-preview.g${sha:0:12}" +date=$(date -u +%Y-%m-%dT%H:%M:%SZ) +mkdir -p dist/preview +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +for os in linux darwin windows; do + for arch in amd64 arm64; do + binary=kernel + if [ "$os" = windows ]; then binary=kernel.exe; fi + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \ + -ldflags "-s -w -X main.version=$version -X main.commit=$sha -X main.date=$date" \ + -o "$work/$binary" ./cmd/kernel + tar -czf "dist/preview/kernel_${version}_${os}_${arch}.tar.gz" -C "$work" "$binary" + done +done +(cd dist/preview && sha256sum kernel_*.tar.gz > SHA256SUMS) +echo "Preview $version ($sha) built in dist/preview" From 62f73a0d99fb3efe72a915469c906b5af3905b5b Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:05:36 +0000 Subject: [PATCH 02/11] Preserve bounded fill error codes for agent diagnostics --- cmd/vaults.go | 5 ++++- cmd/vaults_credentials.go | 17 +++++++++++++++++ cmd/vaults_credentials_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/cmd/vaults.go b/cmd/vaults.go index 0b70fe2d..16737c6c 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -250,7 +250,10 @@ func (c VaultsCmd) invoke(ctx context.Context, vault, key, operation, output str } result, err := c.vaults.Items.PerformOperation(ctx, key, params, opts...) if err != nil { - if item.Type == "credential" || operation == "fill" { + if operation == "fill" { + return vaultFillError(err) + } + if item.Type == "credential" { return vaultCredentialError(err) } return util.CleanedUpSdkError{Err: err} diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index c7c1c9ab..ea849676 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -112,6 +113,22 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ return c.showItem(item, output, open) } +func vaultFillError(err error) error { + var apiErr *kernel.Error + if errors.As(err, &apiErr) { + var body struct { + Code string `json:"code"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil { + switch body.Code { + case "invalid_request", "invalid_selector", "duplicate_target", "timeout", "target_changed", "page_not_found", "ambiguous_page", "element_not_found", "ambiguous_selector", "element_not_editable", "option_not_found", "field_unavailable", "conflict", "destination_denied", "execution_failed": + return fmt.Errorf("fill failed: %s (HTTP %d); inspect the browser and item before further action; do not automatically retry", body.Code, apiErr.StatusCode) + } + } + } + return vaultCredentialError(err) +} + func printVaultFill(result *kernel.VaultItemOperationResponseUnion, expectedFields int) error { raw, err := filterVaultJSON(json.RawMessage(result.RawJSON()), vaultOutputFields{"type": nil, "status": nil, "fields": vaultFieldsOf("index status error_code")}) if err != nil { diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go index 17ec5511..4e978bc6 100644 --- a/cmd/vaults_credentials_test.go +++ b/cmd/vaults_credentials_test.go @@ -127,6 +127,30 @@ func TestCredentialCollectAndFill(t *testing.T) { } } +func TestCredentialFillErrorCodes(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, code := range []string{"ambiguous_selector", "secret-echo"} { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + io.WriteString(w, credentialFixture) + return + } + w.WriteHeader(400) + fmt.Fprintf(w, `{"code":%q,"message":"secret-echo"}`, code) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "login", "fill", "--spec-file", credentialSpecFile(t, `{"browser_id":"browser-1","fields":[{"field":"password","selector":"#password"}]}`)) + require.Error(t, err) + assert.NotContains(t, err.Error(), "secret-echo") + assert.Equal(t, 2, calls) + if code == "ambiguous_selector" { + assert.Contains(t, err.Error(), code) + } + } +} + func TestCredentialFillRejectsUnsafeOutcomes(t *testing.T) { for _, raw := range []string{ `{"type":"fill","status":"secret-echo","fields":[]}`, From 07c6c184ce66dc7f0eb5ca18a449c5d7459c166e Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:10:18 +0000 Subject: [PATCH 03/11] Decode operation responses without retaining prior item state --- cmd/vaults.go | 5 +++-- cmd/vaults_credentials_test.go | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/cmd/vaults.go b/cmd/vaults.go index 16737c6c..a9e4fb68 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -261,10 +261,11 @@ func (c VaultsCmd) invoke(ctx context.Context, vault, key, operation, output str if operation == "fill" { return printVaultFill(result, len(fill.Fields)) } - if err := json.Unmarshal([]byte(result.RawJSON()), &item); err != nil { + var updated kernel.VaultItemUnion + if err := json.Unmarshal([]byte(result.RawJSON()), &updated); err != nil { return fmt.Errorf("invalid vault item response") } - return c.showItem(item, output, open) + 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_credentials_test.go b/cmd/vaults_credentials_test.go index 4e978bc6..f1657619 100644 --- a/cmd/vaults_credentials_test.go +++ b/cmd/vaults_credentials_test.go @@ -127,6 +127,28 @@ func TestCredentialCollectAndFill(t *testing.T) { } } +func TestCredentialOperationDoesNotRetainOldAction(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + var response map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(credentialFixture), &response)) + delete(response, "action") + fresh, err := json.Marshal(response) + require.NoError(t, err) + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + io.WriteString(w, credentialFixture) + } else { + w.Write(fresh) + } + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "login", "collect", "-o", "json") + require.NoError(t, err) + assert.NotContains(t, out, "#token=") +} + func TestCredentialFillErrorCodes(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") for _, code := range []string{"ambiguous_selector", "secret-echo"} { From 33e792bbcc851a401816222aca85998aecc63e64 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:14:52 +0000 Subject: [PATCH 04/11] Download preview artifacts directly into the documented directory --- PREVIEW.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PREVIEW.md b/PREVIEW.md index 38cb71ff..cae962c4 100644 --- a/PREVIEW.md +++ b/PREVIEW.md @@ -10,7 +10,7 @@ Download a run's artifact (GitHub authentication required): ```sh gh run list --repo kernel/cli --workflow preview.yaml --branch -gh run download --repo kernel/cli --pattern 'kernel-preview-*' --dir preview +gh run download --repo kernel/cli --name kernel-preview- --dir preview cd preview sha256sum -c SHA256SUMS # macOS: shasum -a 256 -c SHA256SUMS From c265642cd08770dccdeffb5324e93bebca2893a3 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:20:05 +0000 Subject: [PATCH 05/11] Clarify preview-only SDK dependency before merge --- PREVIEW.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PREVIEW.md b/PREVIEW.md index cae962c4..61a1dc2a 100644 --- a/PREVIEW.md +++ b/PREVIEW.md @@ -31,8 +31,8 @@ archives contain `kernel.exe`. Download on trusted machines and verify checksums ## Temporary SDK pin -`go.mod` replaces the normal Go SDK with the immutable STLC preview revision from -`kernel/kernel` PR #3898. The staging SDK repository requires GitHub authentication. +`go.mod` replaces the normal Go SDK with an immutable STLC preview revision. +The staging SDK repository requires GitHub authentication. Tests and preview builds use the existing GitHub App credentials to obtain a contents-read token scoped to `kernel-go-sdk-staging`, solely for `go mod download`. No token is passed to compilation/tests, no credential config is persisted, and Go @@ -41,7 +41,7 @@ Fork PR jobs are skipped while this private dependency is required. No `pull_request_target` workflow executes untrusted PR code. Replace the preview pin with the released `github.com/kernel/kernel-go-sdk` version -before a stable release, then remove the temporary SDK-auth steps and restore fork +before merging this preview branch, then remove the temporary SDK-auth steps and restore fork testing/cache behavior. The stable release workflow rejects the staging SDK pin. For a local cross-platform build, authenticate Git for the SDK repository, then run: From 10828c2a2bdf102089f714bf3684c3555ebd129a Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:53:23 +0000 Subject: [PATCH 06/11] Show credential-specific collection guidance --- cmd/vaults_credential_guidance_test.go | 43 ++++++++++++++++++++++++++ cmd/vaults_output.go | 7 +++++ 2 files changed, 50 insertions(+) create mode 100644 cmd/vaults_credential_guidance_test.go diff --git a/cmd/vaults_credential_guidance_test.go b/cmd/vaults_credential_guidance_test.go new file mode 100644 index 00000000..80a9887a --- /dev/null +++ b/cmd/vaults_credential_guidance_test.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCredentialHumanGuidance(t *testing.T) { + for _, operation := range []string{"create", "get", "collect"} { + for _, status := range []string{"pending_collection", "ready"} { + t.Run(operation+"/"+status, func(t *testing.T) { + fixture := strings.Replace(credentialFixture, "pending_collection", status, 1) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, fixture) + }) + args := []string{"vaults", "items", "get", "user", "login"} + switch operation { + case "create": + args = []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password"}}}`)} + case "collect": + args = []string{"vaults", "items", "invoke", "user", "login", "collect"} + } + stdout, human, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + output := stdout + human + assert.Contains(t, output, "Share the collection URL with the user") + assert.Contains(t, output, "items get --wait 60") + assert.Contains(t, output, "compare versions without --wait") + assert.Contains(t, output, "not that login succeeded") + assert.Contains(t, output, "Available operation: fill") + assert.NotContains(t, output, "with the provider") + assert.NotContains(t, output, "OAuth codes") + assert.NotContains(t, output, "never-print") + }) + } + } +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 9a57e6e1..49d07840 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -315,6 +315,13 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction for _, op := range actions.Operations { pterm.Printf("Available operation: %s — %s\n", op.Type, op.Description) } + if item.Type == "credential" { + if actions.RequiredAction != "" { + pterm.Info.Println("Share the collection URL with the user to complete the credential form. Observe readiness with items get --wait 60; for edits to an already-ready item, compare versions without --wait.") + } + pterm.Info.Println("Ready means required fields are populated, not that login succeeded. Fill only when advertised; fill does not submit the form.") + return + } if item.Type == "card" { card := item.AsCard() for _, expansion := range card.AvailableExpansions { From b4a66ce5dc1a71291638e4ccdfae139fd9719255 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:23:12 +0000 Subject: [PATCH 07/11] Clarify credential naming and sensitivity guidance --- README.md | 6 +++-- cmd/vaults_credential_steering_test.go | 32 ++++++++++++++++++++++++++ cmd/vaults_credentials.go | 11 ++++++--- 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 cmd/vaults_credential_steering_test.go diff --git a/README.md b/README.md index fe536a08..8179e2d8 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ sensitive form. Define the observed fields without supplying values: kernel vaults create --name user-vault kernel browsers create --vault user-vault kernel vaults credentials create user-vault login --spec-file - <<'JSON' -{"fields":{"username":{"type":"email","required":true},"password":{"type":"password","required":true}}} +{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}} JSON kernel vaults items get user-vault login --wait 60 -o json kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' @@ -298,10 +298,12 @@ Do not automatically retry failed/unknown fills or fall back to aliases. Use `credentials update --version --spec-file changes.json` with a spec such as `{"fields":{"password":{"value":"replacement"}}}`. Keep actual secrets in protected files or stdin, never shell arguments. Omission preserves values; -null clears supported fields. Field definitions cannot change. Stale versions fail, +null or an empty string clears supported fields, including required text/email/password fields (returning them to pending collection). The form still requires nonempty required inputs. Field definitions cannot change. Stale versions fail, without retries. `items invoke collect` reopens the full form without clearing values; compare versions to observe edits to already-ready items. +Set `description` to the recognizable site name only, such as `Hacker News`, not `Hacker News sign-in credentials`. Set `sensitive: false` explicitly for ordinary usernames and email addresses. Reserve `sensitive: true` for passwords, API tokens, and TOTP seeds; the omitted default remains true for safety. + Types are `text`, `email`, `password`, and `totp`. TOTP seeds must be provided through create/update, never the form; only generated codes enter the browser. Unrestricted browser access can read filled values. CLI output omits all stored credential values, diff --git a/cmd/vaults_credential_steering_test.go b/cmd/vaults_credential_steering_test.go new file mode 100644 index 00000000..482dfdcc --- /dev/null +++ b/cmd/vaults_credential_steering_test.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCredentialEmptyStringUpdate(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"credential","version":2,"spec":{"fields":{"password":{"value":""}}}}`, string(body)) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, credentialFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "update", "user", "login", "--version", "2", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"value":""}}}`), "-o", "json") + require.NoError(t, err) +} + +func TestCredentialHelpSteersDisplayNameAndSensitivity(t *testing.T) { + cmd, _, err := newVaultsCommand().Find([]string{"credentials", "create"}) + require.NoError(t, err) + assert.Contains(t, cmd.Long, "recognizable site name only") + assert.Contains(t, cmd.Long, "sensitive:false explicitly for ordinary usernames and email addresses") + assert.Contains(t, cmd.Example, `"description":"Hacker News"`) + assert.Contains(t, cmd.Example, `"sensitive":false`) +} diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 51d5e456..6f87cd11 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -12,11 +12,16 @@ import ( "github.com/spf13/cobra" ) -const vaultCredentialHelp = `Create credentials from the sensitive fields observed on a website. +const vaultCredentialHelp = `Create credentials from the fields observed on a website. First create a vault for the end user and attach it with browsers create --vault. Use a protected JSON file or stdin, never secret values in shell arguments. The spec contains description and fields keyed by name. Field types are text, email, password, and totp; definitions accept required, sensitive, and value. +Set description to the recognizable site name only, e.g. "Hacker News", not +"Hacker News sign-in credentials". This text is the user-facing form title. +Set sensitive:false explicitly for ordinary usernames and email addresses. +Reserve sensitive:true for secrets such as passwords, API tokens, and TOTP seeds. +Password and totp must be sensitive. Omitted sensitive defaults to true for safety. Omit required values to receive a collection URL to present to the user. Poll items get --wait 60 until state.status is ready, then use items invoke fill. Ready means populated, not a successful login. An agent controlling the browser @@ -43,13 +48,13 @@ func newVaultCredentialsCommand() *cobra.Command { }, } if update { - cmd.Long += "\nUpdate preserves omitted fields, replaces string values, and clears supported values with null.\nField definitions are immutable. Do not automatically retry version conflicts." + cmd.Long += "\nUpdate preserves omitted fields, replaces nonempty string values, and clears supported values with null or an empty string. Clearing a required text/email/password field returns pending_collection; form submissions still require a nonempty value.\nField definitions are immutable. Do not automatically retry version conflicts." cmd.Flags().Int64("version", 0, "Expected version from items get (required; never auto-refreshed)") _ = cmd.MarkFlagRequired("version") cmd.Example = " kernel vaults credentials update user-vault login --version 2 --spec-file changes.json" } else { cmd.Example = ` kernel vaults credentials create user-vault login --spec-file - <<'JSON' -{"fields":{"username":{"type":"email","required":true},"password":{"type":"password","required":true}}} +{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}} JSON` } cmd.Flags().String("spec-file", "", "Credential spec JSON file (use '-' for stdin; maximum 128 KiB)") From fd5a2aed7bae89c37f7ab9a6f9e89b0c7711a1ac Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:12:15 +0000 Subject: [PATCH 08/11] Steer credit card workflows away from credential items --- README.md | 2 ++ cmd/vaults_commands.go | 3 +++ cmd/vaults_credential_guidance_test.go | 2 ++ cmd/vaults_credential_steering_test.go | 6 +++++- cmd/vaults_credentials.go | 5 +++++ cmd/vaults_output.go | 1 + 6 files changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8179e2d8..54a3d553 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,8 @@ null or an empty string clears supported fields, including required text/email/p without retries. `items invoke collect` reopens the full form without clearing values; compare versions to observe edits to already-ready items. +Do not use credential items to store, collect, or fill credit card data, including card numbers (PANs), security codes (CVV/CVC), or expiration dates. Use wallet and card item types for credit cards and payment checkout instead. + Set `description` to the recognizable site name only, such as `Hacker News`, not `Hacker News sign-in credentials`. Set `sensitive: false` explicitly for ordinary usernames and email addresses. Reserve `sensitive: true` for passwords, API tokens, and TOTP seeds; the omitted default remains true for safety. Types are `text`, `email`, `password`, and `totp`. TOTP seeds must be provided through diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 02f51cfa..2d242802 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -54,6 +54,9 @@ func newVaultsCommand() *cobra.Command { Use: "vaults", Aliases: []string{"vault"}, Short: "Collect user credentials and manage payment credentials", Long: `Collect user credentials and manage payment credentials; fill never submits website forms. +Do not use credential items to store, collect, or fill credit card data. +Use wallet and card item types for credit cards and payment checkout instead. + User credential flow: 1. Create a vault per end user and create a browser with --vault . 2. Navigate to a sensitive form and define its fields with credentials create --spec-file. diff --git a/cmd/vaults_credential_guidance_test.go b/cmd/vaults_credential_guidance_test.go index 80a9887a..00142808 100644 --- a/cmd/vaults_credential_guidance_test.go +++ b/cmd/vaults_credential_guidance_test.go @@ -33,6 +33,8 @@ func TestCredentialHumanGuidance(t *testing.T) { assert.Contains(t, output, "items get --wait 60") assert.Contains(t, output, "compare versions without --wait") assert.Contains(t, output, "not that login succeeded") + assert.Contains(t, output, "Do not use credential items for credit card data") + assert.Contains(t, output, "Use wallet and card item types") assert.Contains(t, output, "Available operation: fill") assert.NotContains(t, output, "with the provider") assert.NotContains(t, output, "OAuth codes") diff --git a/cmd/vaults_credential_steering_test.go b/cmd/vaults_credential_steering_test.go index 482dfdcc..e6613eaf 100644 --- a/cmd/vaults_credential_steering_test.go +++ b/cmd/vaults_credential_steering_test.go @@ -3,6 +3,7 @@ package cmd import ( "io" "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -22,9 +23,12 @@ func TestCredentialEmptyStringUpdate(t *testing.T) { require.NoError(t, err) } -func TestCredentialHelpSteersDisplayNameAndSensitivity(t *testing.T) { +func TestCredentialHelpSteering(t *testing.T) { cmd, _, err := newVaultsCommand().Find([]string{"credentials", "create"}) require.NoError(t, err) + assert.Contains(t, cmd.Long, "Do not use credential items to store, collect, or fill credit card data") + assert.Contains(t, strings.Join(strings.Fields(cmd.Long), " "), "Use wallet and card item types for credit cards and payment checkout instead") + assert.Contains(t, newVaultsCommand().Long, "Use wallet and card item types") assert.Contains(t, cmd.Long, "recognizable site name only") assert.Contains(t, cmd.Long, "sensitive:false explicitly for ordinary usernames and email addresses") assert.Contains(t, cmd.Example, `"description":"Hacker News"`) diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 6f87cd11..5360baa9 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -13,6 +13,11 @@ import ( ) const vaultCredentialHelp = `Create credentials from the fields observed on a website. + +Do not use credential items to store, collect, or fill credit card data, including +card numbers (PANs), security codes (CVV/CVC), or expiration dates. Use wallet and +card item types for credit cards and payment checkout instead. + First create a vault for the end user and attach it with browsers create --vault. Use a protected JSON file or stdin, never secret values in shell arguments. The spec contains description and fields keyed by name. Field types are text, diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 49d07840..fde826ee 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -319,6 +319,7 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction if actions.RequiredAction != "" { pterm.Info.Println("Share the collection URL with the user to complete the credential form. Observe readiness with items get --wait 60; for edits to an already-ready item, compare versions without --wait.") } + pterm.Info.Println("Do not use credential items for credit card data. Use wallet and card item types for credit cards and payment checkout instead.") pterm.Info.Println("Ready means required fields are populated, not that login succeeded. Fill only when advertised; fill does not submit the form.") return } From 889ca87bd2a1e8f6c37ef4233928793400698b73 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:35:05 +0000 Subject: [PATCH 09/11] Use the released Go SDK for credential vault commands --- .github/workflows/preview.yaml | 16 +--------------- .github/workflows/release.yaml | 7 ------- .github/workflows/test.yaml | 20 +------------------- PREVIEW.md | 22 +++++----------------- cmd/vaults.go | 5 ++--- cmd/vaults_credentials.go | 2 +- go.mod | 4 +--- go.sum | 4 ++-- 8 files changed, 13 insertions(+), 67 deletions(-) diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml index c7a8485b..fd952919 100644 --- a/.github/workflows/preview.yaml +++ b/.github/workflows/preview.yaml @@ -10,7 +10,6 @@ permissions: jobs: build: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -21,21 +20,8 @@ jobs: - uses: actions/setup-go@v6 with: go-version-file: go.mod - cache: false - - name: Preview SDK read token - id: sdk-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.ADMIN_APP_ID }} - private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} - repositories: kernel-go-sdk-staging - permission-contents: read + cache: true - name: Download dependencies - env: - GOPRIVATE: github.com/kernel/kernel-go-sdk-staging - GIT_CONFIG_COUNT: '1' - GIT_CONFIG_KEY_0: url.https://x-access-token:${{ steps.sdk-token.outputs.token }}@github.com/.insteadOf - GIT_CONFIG_VALUE_0: https://github.com/ run: go mod download - name: Build preview archives run: bash scripts/build-preview.sh diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 53e8cdd0..f8baf9eb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -26,13 +26,6 @@ jobs: with: fetch-depth: 0 - - name: Reject preview SDK dependencies - run: | - if grep -q 'kernel-go-sdk-staging' go.mod; then - echo 'Replace the preview SDK pin with a released SDK before publishing.' >&2 - exit 1 - fi - - name: Set up Go uses: actions/setup-go@v6 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fcb76091..5f10d664 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,7 +9,6 @@ on: jobs: test: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: contents: read @@ -22,24 +21,7 @@ jobs: uses: actions/setup-go@v6 with: go-version-file: "go.mod" - cache: false - - - name: Preview SDK read token - id: sdk-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.ADMIN_APP_ID }} - private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} - repositories: kernel-go-sdk-staging - permission-contents: read - - - name: Download dependencies - env: - GOPRIVATE: github.com/kernel/kernel-go-sdk-staging - GIT_CONFIG_COUNT: '1' - GIT_CONFIG_KEY_0: url.https://x-access-token:${{ steps.sdk-token.outputs.token }}@github.com/.insteadOf - GIT_CONFIG_VALUE_0: https://github.com/ - run: go mod download + cache: true - name: Run tests run: make test diff --git a/PREVIEW.md b/PREVIEW.md index 61a1dc2a..4688621e 100644 --- a/PREVIEW.md +++ b/PREVIEW.md @@ -1,6 +1,6 @@ # Preview binaries -The `Preview CLI binaries` workflow builds same-repository pull requests automatically. +The `Preview CLI binaries` workflow builds pull requests automatically. It also supports `workflow_dispatch` after the workflow exists on the default branch. It builds the PR head, not GitHub's synthetic merge commit. Archives cover Linux, macOS, and Windows on amd64 and arm64, with `SHA256SUMS`. The embedded version is @@ -29,24 +29,12 @@ Artifacts expire after 14 days. No GitHub release, stable tag, npm package, Home formula, or production deployment is created. macOS binaries are unsigned; Windows archives contain `kernel.exe`. Download on trusted machines and verify checksums. -## Temporary SDK pin +## Local builds -`go.mod` replaces the normal Go SDK with an immutable STLC preview revision. -The staging SDK repository requires GitHub authentication. -Tests and preview builds use the existing GitHub App credentials to obtain a -contents-read token scoped to `kernel-go-sdk-staging`, solely for `go mod download`. -No token is passed to compilation/tests, no credential config is persisted, and Go -caching is disabled so private module sources are not exported into Actions caches. -Fork PR jobs are skipped while this private dependency is required. No -`pull_request_target` workflow executes untrusted PR code. - -Replace the preview pin with the released `github.com/kernel/kernel-go-sdk` version -before merging this preview branch, then remove the temporary SDK-auth steps and restore fork -testing/cache behavior. The stable release workflow rejects the staging SDK pin. - -For a local cross-platform build, authenticate Git for the SDK repository, then run: +Preview builds use the public Go SDK release pinned in `go.mod`; private SDK +repository access is not required. For a local cross-platform build, run: ```sh -GOPRIVATE=github.com/kernel/kernel-go-sdk-staging go mod download +go mod download bash scripts/build-preview.sh ``` diff --git a/cmd/vaults.go b/cmd/vaults.go index 7a484ff7..5a14fa50 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -14,7 +14,6 @@ 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" ) @@ -193,7 +192,7 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. var item *kernel.VaultItemUnion var err error if update { - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCard: &kernel.VaultItemUpdateParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCardVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyCardVaultItemUpdateRequest{Type: "card", Spec: spec}}, option.WithMaxRetries(0)) } else { item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) } @@ -251,7 +250,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par request.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: "collect"} } else { // Preserve support for other advertised parameterless operations. - request.OfAuthorize = &kernel.VaultItemPerformOperationParamsBodyAuthorize{Type: constant.Authorize(operation)} + request.OfAuthorize = &kernel.AuthorizeVaultItemOperationRequestParam{Type: kernel.AuthorizeVaultItemOperationRequestType(operation)} } response, err := c.vaults.Items.PerformOperation(ctx, key, request, option.WithMaxRetries(0)) if err != nil { diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 5360baa9..558cbff5 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -108,7 +108,7 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ if json.Unmarshal(data, &spec) != nil { return fmt.Errorf("invalid credential update spec") } - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredential: &kernel.CredentialVaultItemUpdateRequestParam{Type: "credential", Version: version, Spec: spec}}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &kernel.CredentialVaultItemUpdateRequestParam{Type: "credential", Version: version, Spec: spec}}, option.WithMaxRetries(0)) } else { var spec kernel.CredentialVaultItemSpecInputParam if json.Unmarshal(data, &spec) != nil || len(spec.Fields) == 0 { diff --git a/go.mod b/go.mod index 5ad10822..ef47300b 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.102.0 + github.com/kernel/kernel-go-sdk v0.104.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 @@ -60,5 +60,3 @@ require ( golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/kernel/kernel-go-sdk => github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260913234358-ea40d26657db diff --git a/go.sum b/go.sum index fca69520..236da6df 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-staging v0.86.1-0.20260913234358-ea40d26657db h1:dxlk9L3oXPZq82nK0P41uNziUVT8euSITiWC2ha+NUU= -github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260913234358-ea40d26657db/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.104.0 h1:HN2MSVroI5O/5hHw3nNzhHmNSGAiX++829srMpPcFpc= +github.com/kernel/kernel-go-sdk v0.104.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 6758ec99f7fe3b6bafa579ab23ab40745bb08366 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:56:18 +0000 Subject: [PATCH 10/11] Cover released vault checkout and credential preconditions --- README.md | 35 ++++++- cmd/vaults.go | 18 +++- cmd/vaults_commands.go | 15 ++- cmd/vaults_credentials.go | 16 ++- cmd/vaults_credentials_test.go | 5 +- cmd/vaults_operation_params.go | 35 +++++-- cmd/vaults_output.go | 28 ++++- cmd/vaults_prepare_checkout.go | 39 +++++++ cmd/vaults_prepare_checkout_test.go | 157 ++++++++++++++++++++++++++++ cmd/vaults_sdk_contract_test.go | 98 +++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 12 files changed, 420 insertions(+), 32 deletions(-) create mode 100644 cmd/vaults_prepare_checkout.go create mode 100644 cmd/vaults_prepare_checkout_test.go create mode 100644 cmd/vaults_sdk_contract_test.go diff --git a/README.md b/README.md index d7574f79..25f9021a 100644 --- a/README.md +++ b/README.md @@ -128,7 +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`, `credentials create/update`, `items list/get/events/invoke` (including `collect` and `fill`), `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) +- **Vaults**: `create`, `list`, `get`, `credentials create/update`, `items list/get/events/invoke` (including `collect`, `fill`, and `prepare_checkout`), `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) - **Projects**: `update` - **Org**: `limits get/set` - **Apps**: `list`, `history` @@ -300,7 +300,9 @@ with a spec such as `{"fields":{"password":{"value":"replacement"}}}`. Keep actu secrets in protected files or stdin, never shell arguments. Omission preserves values; null or an empty string clears supported fields, including required text/email/password fields (returning them to pending collection). The form still requires nonempty required inputs. Field definitions cannot change. Stale versions fail, without retries. `items invoke collect` reopens the full form without -clearing values; compare versions to observe edits to already-ready items. +clearing values; compare versions to observe edits to already-ready items. When an update +is bound to an earlier read, also pass `--expected-item-id ` to reject a replacement +item at the same key. Neither precondition is refreshed automatically. Do not use credential items to store, collect, or fill credit card data, including card numbers (PANs), security codes (CVV/CVC), or expiration dates. Use wallet and card item types for credit cards and payment checkout instead. @@ -515,7 +517,7 @@ 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 -an `items invoke` command retaining the selected project (replace `` for fill). +an `items invoke` command retaining the selected project (replace `` for fill or prepare_checkout). 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. @@ -527,14 +529,37 @@ actions in `recovery_required`, even if a stale action or operation was returned `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 -collection URL. `--open` is supported for authorize and collect. -The [API spec](https://api.onkernel.com/spec.yaml) also accepts `fill`, with its inputs in +collection URL. `--open` is supported for authorize, collect, and prepare_checkout. +The [API spec](https://api.onkernel.com/spec.yaml) also accepts `fill` and `prepare_checkout`, with their inputs in `--params` or `--spec-file ` (mutually exclusive, maximum 128 KiB). The positional operation supplies `type`; including `type` in either input 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. +##### Prepare an AgentCard checkout + +For an unused AgentCard card, invoke `prepare_checkout` only when advertised: + +```bash +kernel vaults items invoke user-123 order-1 prepare_checkout --params '{"checkout":{"browser_id":"browser-session-id","merchant_origin":"https://shop.example","environment":"production"}}' --open +kernel vaults items get user-123 order-1 --wait 60 -o json +``` + +`--spec-file ` accepts the same JSON. `browser_id` is the active session with +this vault attached. `merchant_origin` is the canonical HTTPS origin of the top-level +merchant document, not the Square iframe; HTTP localhost is allowed for tests. +`environment` is `production` or `sandbox` and refers to Square, not the credential mode. + +Keep the approval page open. Poll until the item's status is `ready_to_submit`, then +submit native Pay before `state.preparation.expires_at`. Readiness lasts at most 30 +seconds, and polling does not extend it. The CLI displays the preparation ID, status, +browser, origin, environment, approval URL, and submission deadline. + +Each preparation is single-use, including after failure or expiry. A preparation +marked `consumed` has been claimed; it does not prove the payment settled or succeeded. +Do not automatically retry or switch checkout paths after an uncertain result. + ##### Fill browser fields Fill supports credential items and ready Link cards when advertised by the API, not AgentCard. diff --git a/cmd/vaults.go b/cmd/vaults.go index 5a14fa50..652d2577 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -202,13 +202,16 @@ 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 string, params *vaultFillParams, output string, open bool) error { +func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultOperationParams, output string, open bool) error { if strings.TrimSpace(operation) == "" { return fmt.Errorf("operation must not be empty") } - if operation == "fill" && (params == nil || open) { + if operation == "fill" && (params == nil || params.Fill == nil || open) { return fmt.Errorf("fill requires --params and does not support --open") } + if operation == "prepare_checkout" && (params == nil || params.Checkout == nil) { + return fmt.Errorf("prepare_checkout requires checkout parameters") + } item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { if operation == "fill" { @@ -240,13 +243,18 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } if operation == "fill" { - if err := validateVaultFillItem(params, item); err != nil { + if err := validateVaultFillItem(params.Fill, item); err != nil { return err } - return c.fill(ctx, vault, key, params, output) + return c.fill(ctx, vault, key, params.Fill, output) } request := kernel.VaultItemPerformOperationParams{IDOrName: vault} - if operation == "collect" { + if operation == "prepare_checkout" { + if item.Type != "card" || item.Spec.Provider != "agentcard" { + return fmt.Errorf("prepare_checkout requires an AgentCard card") + } + request.OfPrepareCheckout = &kernel.PrepareCheckoutVaultItemOperationRequestParam{Type: "prepare_checkout", Checkout: *params.Checkout} + } else if operation == "collect" { request.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: "collect"} } else { // Preserve support for other advertised parameterless operations. diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 5cb730ba..48b6ff3b 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -167,7 +167,12 @@ Fill is available for credential items and ready Link cards when advertised, not Fill never submits forms. completed means fields were filled, not website acceptance. failed may leave partial writes; unknown quarantines the browser. Never automatically retry or fall back to aliases. Requests are not automatically retried. -Only collect/authorize may use --open. Fill returns value-free per-field outcomes; +prepare_checkout requires checkout.browser_id, checkout.merchant_origin (canonical HTTPS +origin of the top-level merchant page), and checkout.environment (production or sandbox). +Use only when advertised for an AgentCard card. Keep the returned approval page open, +poll until ready_to_submit, then submit native Pay before preparation.expires_at. +Preparations are single-use, including after failure or expiry; never retry automatically. +collect/authorize/prepare_checkout may use --open. Fill returns value-free per-field outcomes; completed exits 0, failed/unknown exit nonzero with valid JSON retained on stdout in -o json.`, Example: ` kernel vaults items invoke user-vault login collect kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' @@ -179,8 +184,8 @@ JSON raw, _ := cmd.Flags().GetString("params") paramsSet := cmd.Flags().Changed("params") if cmd.Flags().Changed("spec-file") { - if args[2] != "fill" { - return fmt.Errorf("--spec-file is only supported for fill") + if args[2] != "fill" && args[2] != "prepare_checkout" { + return fmt.Errorf("--spec-file is only supported for fill and prepare_checkout") } data, err := readVaultSpecFile(cmd) if err != nil { @@ -194,8 +199,8 @@ JSON } return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} - invoke.Flags().String("params", "", "Fill parameters JSON (maximum 128 KiB); omit type and credential values") - invoke.Flags().String("spec-file", "", "Fill parameters JSON file (use '-' for stdin; maximum 128 KiB)") + invoke.Flags().String("params", "", "Fill or prepare_checkout parameters JSON (maximum 128 KiB); omit type and credential values") + invoke.Flags().String("spec-file", "", "Fill or prepare_checkout 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 in your browser") addVaultJSONOutputFlag(invoke) diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 558cbff5..5c215cdc 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "strings" kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" @@ -49,13 +50,18 @@ func newVaultCredentialsCommand() *cobra.Command { } version, _ := cmd.Flags().GetInt64("version") open, _ := cmd.Flags().GetBool("open") - return getVaultsHandler(cmd).saveCredential(cmd.Context(), args[0], args[1], data, update, version, vaultOutput(cmd), open) + expectedID, _ := cmd.Flags().GetString("expected-item-id") + if cmd.Flags().Changed("expected-item-id") && strings.TrimSpace(expectedID) == "" { + return fmt.Errorf("--expected-item-id must not be empty") + } + return getVaultsHandler(cmd).saveCredential(cmd.Context(), args[0], args[1], data, update, version, expectedID, vaultOutput(cmd), open) }, } if update { cmd.Long += "\nUpdate preserves omitted fields, replaces nonempty string values, and clears supported values with null or an empty string. Clearing a required text/email/password field returns pending_collection; form submissions still require a nonempty value.\nField definitions are immutable. Do not automatically retry version conflicts." cmd.Flags().Int64("version", 0, "Expected version from items get (required; never auto-refreshed)") _ = cmd.MarkFlagRequired("version") + cmd.Flags().String("expected-item-id", "", "Immutable item ID from the original read; reject an update if the key now refers to a replacement item") cmd.Example = " kernel vaults credentials update user-vault login --version 2 --spec-file changes.json" } else { cmd.Example = ` kernel vaults credentials create user-vault login --spec-file - <<'JSON' @@ -97,7 +103,7 @@ func readVaultSpecFile(cmd *cobra.Command) ([]byte, error) { return data, nil } -func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data []byte, update bool, version int64, output string, open bool) error { +func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data []byte, update bool, version int64, expectedID, output string, open bool) error { var item *kernel.VaultItemUnion var err error if update { @@ -108,7 +114,11 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ if json.Unmarshal(data, &spec) != nil { return fmt.Errorf("invalid credential update spec") } - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &kernel.CredentialVaultItemUpdateRequestParam{Type: "credential", Version: version, Spec: spec}}, option.WithMaxRetries(0)) + request := kernel.CredentialVaultItemUpdateRequestParam{Type: "credential", Version: version, Spec: spec} + if expectedID != "" { + request.ExpectedItemID = kernel.String(expectedID) + } + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &request}, option.WithMaxRetries(0)) } else { var spec kernel.CredentialVaultItemSpecInputParam if json.Unmarshal(data, &spec) != nil || len(spec.Fields) == 0 { diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go index 40a13cd2..8dc6ef2e 100644 --- a/cmd/vaults_credentials_test.go +++ b/cmd/vaults_credentials_test.go @@ -38,6 +38,7 @@ func TestCredentialCreateAndUpdate(t *testing.T) { if update { assert.Equal(t, "PATCH", r.Method) assert.JSONEq(t, `2`, string(body["version"])) + assert.JSONEq(t, `"credential-1"`, string(body["expected_item_id"])) assert.JSONEq(t, `{"fields":{"password":{"value":null}}}`, string(body["spec"])) } else { assert.Equal(t, "PUT", r.Method) @@ -50,7 +51,7 @@ func TestCredentialCreateAndUpdate(t *testing.T) { if update { args[2] = "update" args[6] = credentialSpecFile(t, `{"fields":{"password":{"value":null}}}`) - args = append(args, "--version", "2") + args = append(args, "--version", "2", "--expected-item-id", "credential-1") } out, _, err := executeVaultCommand(t, client, args...) require.NoError(t, err) @@ -74,7 +75,7 @@ func TestCredentialWriteErrorsAreRedactedAndNotRetried(t *testing.T) { io.WriteString(w, `{"message":"secret-echo"}`) }) c := VaultsCmd{vaults: &client.Vaults} - err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":{"password":{"type":"password","value":"secret-echo"}}}`), false, 0, "json", false) + err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":{"password":{"type":"password","value":"secret-echo"}}}`), false, 0, "", "json", false) require.Error(t, err) assert.NotContains(t, err.Error(), "secret-echo") assert.Equal(t, 1, calls) diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index d4cee001..248ec768 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -12,6 +12,11 @@ import ( kernel "github.com/kernel/kernel-go-sdk" ) +type vaultOperationParams struct { + Fill *vaultFillParams + Checkout *kernel.VaultCheckoutContextParam +} + type vaultFillParams struct { BrowserID string `json:"browser_id"` PageURL string `json:"page_url,omitempty"` @@ -67,25 +72,43 @@ func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) return object, nil } -func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) (*vaultFillParams, error) { +func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) (*vaultOperationParams, error) { if strings.TrimSpace(operation) == "" { return nil, fmt.Errorf("operation must not be empty") } - if openSet && operation != "authorize" && operation != "collect" { - return nil, fmt.Errorf("--open is only supported for authorize and collect") + if openSet && operation != "authorize" && operation != "collect" && operation != "prepare_checkout" { + return nil, fmt.Errorf("--open is only supported for authorize, collect, and prepare_checkout") + } + if len(raw) > 128*1024 { + return nil, fmt.Errorf("operation parameters exceed 128 KiB") + } + if operation == "prepare_checkout" { + if !paramsSet { + return nil, fmt.Errorf("prepare_checkout requires --params or --spec-file with checkout") + } + checkout, err := parseVaultCheckoutParams(raw) + if err != nil { + return nil, err + } + return &vaultOperationParams{Checkout: checkout}, nil } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill; authorize takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill and prepare_checkout; authorize takes no parameters") } return nil, nil } if !paramsSet { return nil, fmt.Errorf("fill requires --params or --spec-file with browser_id and fields") } - if len(raw) > 128*1024 { - return nil, fmt.Errorf("fill parameters exceed 128 KiB") + fill, err := parseVaultFillParams(raw) + if err != nil { + return nil, err } + return &vaultOperationParams{Fill: fill}, nil +} + +func parseVaultFillParams(raw string) (*vaultFillParams, error) { object, err := vaultParamsObject(raw, "browser_id page_url fields timeout_ms") if err != nil { return nil, err diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 33b5cb75..ad83ee06 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -55,12 +55,13 @@ var vaultItemFields = vaultOutputFields{ "fields": {"*": vaultFieldsOf("has_value")}, "masks": vaultFieldsOf("brand last4"), "aliases": vaultFieldsOf("number cvc exp_month exp_year"), + "preparation": vaultFieldsOf("id status browser_id merchant_origin environment created_at expires_at approval_url"), "authorization": vaultFieldsOf("id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered"), }, } var vaultEventFields = vaultOutputFields{ "id": nil, "name": nil, "created_at": nil, "browser_id": nil, - "data": vaultFieldsOf("reason operation status authorization_id vault_session_id request_kind outcome_reason provider_status provider_code provider_request_id provider_payment_status provider_error_type provider_error_code provider_decline_code provider_error_param provider_http_status provider_response_bytes provider_latency_ms payment_intent_id payment_method_id checkout_session_id replay_attempted replay_delivered charged_amount_cents charged_currency charged_kind expected_cents actual_cents currency actual_currency intent_status amount_verified psp_error_code"), + "data": vaultFieldsOf("reason operation status authorization_id preparation_id vault_session_id request_kind outcome_reason provider_status provider_code provider_request_id provider_payment_status provider_error_type provider_error_code provider_decline_code provider_error_param provider_http_status provider_response_bytes provider_latency_ms payment_intent_id payment_method_id checkout_session_id replay_attempted replay_delivered charged_amount_cents charged_currency charged_kind expected_cents actual_cents currency actual_currency intent_status amount_verified psp_error_code"), } // Vault output is a display-safe projection, not raw provider JSON. Keep presence @@ -110,7 +111,7 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes continue } if value, ok := object[key]; ok { - if key == "url" || key == "approval_url" || key == "merchant_url" || key == "image_url" || key == "product_url" { + if key == "url" || key == "approval_url" || key == "merchant_url" || key == "merchant_origin" || key == "image_url" || key == "product_url" { var address string if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { continue @@ -208,7 +209,7 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s } for _, op := range actions.Operations { command := prefix - if op.Type == "fill" { + if op.Type == "fill" || op.Type == "prepare_checkout" { command += " --params ''" } pterm.Printf("Invoke: %s -- %s %s %s\n", command, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) @@ -280,6 +281,18 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { a := item.State.Aliases rows = append(rows, []string{"Checkout alias: number", a.Number}, []string{"Checkout alias: cvc", a.Cvc}, []string{"Checkout alias: exp_month", a.ExpMonth}, []string{"Checkout alias: exp_year", a.ExpYear}) } + if item.State.JSON.Preparation.Valid() { + p := item.State.Preparation + rows = append(rows, []string{"Preparation ID", p.ID}, []string{"Preparation status", string(p.Status)}, + []string{"Preparation browser", p.BrowserID}, []string{"Merchant origin", p.MerchantOrigin}, + []string{"Environment", string(p.Environment)}) + if !p.ExpiresAt.IsZero() { + rows = append(rows, []string{"Submit before", util.FormatLocal(p.ExpiresAt)}) + } + if p.ApprovalURL != "" { + rows = append(rows, []string{"Preparation approval URL", p.ApprovalURL}) + } + } if item.State.JSON.Authorization.Valid() { a := item.State.Authorization rows = append(rows, []string{"Checkout authorization", a.ID}, []string{"Authorization status", string(a.Status)}) @@ -324,6 +337,15 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction return } if item.Type == "card" { + if item.State.JSON.Preparation.Valid() { + switch item.State.Status { + case "preparing": + pterm.Info.Println("Keep the approval page open and poll items get --wait 60 until ready_to_submit before submitting native Pay.") + case "ready_to_submit": + pterm.Info.Println("Submit native Pay before preparation.expires_at; polling does not extend the deadline.") + } + pterm.Info.Println("Preparations are single-use, including after failure or expiry. Preparation consumed means claimed, not payment success. Do not retry automatically.") + } card := item.AsCard() for _, expansion := range card.AvailableExpansions { pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) diff --git a/cmd/vaults_prepare_checkout.go b/cmd/vaults_prepare_checkout.go new file mode 100644 index 00000000..d0e0c87c --- /dev/null +++ b/cmd/vaults_prepare_checkout.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + kernel "github.com/kernel/kernel-go-sdk" +) + +func parseVaultCheckoutParams(raw string) (*kernel.VaultCheckoutContextParam, error) { + object, err := vaultParamsObject(raw, "checkout") + if err != nil { + return nil, err + } + checkout, err := vaultParamsObject(string(object["checkout"]), "browser_id merchant_origin environment") + if err != nil { + return nil, fmt.Errorf("invalid checkout: %w", err) + } + var params kernel.VaultCheckoutContextParam + if json.Unmarshal(checkout["browser_id"], ¶ms.BrowserID) != nil || strings.TrimSpace(params.BrowserID) == "" { + return nil, fmt.Errorf("checkout.browser_id must be a non-empty browser session ID") + } + if json.Unmarshal(checkout["environment"], ¶ms.Environment) != nil || (params.Environment != "production" && params.Environment != "sandbox") { + return nil, fmt.Errorf("checkout.environment must be production or sandbox") + } + if json.Unmarshal(checkout["merchant_origin"], ¶ms.MerchantOrigin) != nil { + return nil, fmt.Errorf("checkout.merchant_origin must be a canonical HTTPS origin (HTTP localhost is allowed for tests)") + } + u, err := url.Parse(params.MerchantOrigin) + if err != nil || u.Hostname() == "" || u.User != nil || u.Opaque != "" || + u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || + params.MerchantOrigin != u.Scheme+"://"+u.Host || strings.ContainsAny(u.Host, "*\\") || + (u.Scheme != "https" && !(u.Scheme == "http" && u.Hostname() == "localhost")) { + return nil, fmt.Errorf("checkout.merchant_origin must be a canonical HTTPS origin (HTTP localhost is allowed for tests)") + } + return ¶ms, nil +} diff --git a/cmd/vaults_prepare_checkout_test.go b/cmd/vaults_prepare_checkout_test.go new file mode 100644 index 00000000..2130fd5c --- /dev/null +++ b/cmd/vaults_prepare_checkout_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const checkoutParamsFixture = `{"checkout":{"browser_id":"browser-1","merchant_origin":"https://shop.example","environment":"production"}}` +const preparationCardFixture = `{"id":"card-1","key":"order-1","type":"card","spec":{"provider":"agentcard","wallet":"wallet-1","merchant":"Shop","amount":1234,"currency":"usd"},"state":{"provider":"agentcard","status":"preparing","status_reason":"Awaiting approval","preparation":{"id":"prep-1","status":"awaiting_approval","browser_id":"browser-1","merchant_origin":"https://shop.example","environment":"production","created_at":"2026-09-15T00:00:00Z","expires_at":"2026-09-15T00:00:30Z","approval_url":"https://approve.example/prepare","secret":"never-print"}},"action":{"name":"spend_approval","url":"https://approve.example/prepare"},"available_operations":[{"type":"prepare_checkout","description":"Prepare after approval"}],"available_expansions":[]}` + +func TestVaultPrepareCheckout(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, file := range []bool{false, true} { + t.Run(fmt.Sprint(file), func(t *testing.T) { + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "POST" { + posts++ + assert.Equal(t, "/vaults/user-123/items/order-1/operations", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"prepare_checkout","checkout":{"browser_id":"browser-1","merchant_origin":"https://shop.example","environment":"production"}}`, string(body)) + } + fmt.Fprint(w, preparationCardFixture) + }) + args := []string{"vaults", "items", "invoke", "user-123", "order-1", "prepare_checkout", "-o", "json"} + if file { + args = append(args, "--spec-file", credentialSpecFile(t, checkoutParamsFixture)) + } else { + args = append(args, "--params", checkoutParamsFixture) + } + out, _, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, 1, posts) + assert.True(t, json.Valid([]byte(out))) + for _, value := range []string{"prep-1", "browser-1", "https://shop.example", "production", "2026-09-15T00:00:30Z", "https://approve.example/prepare", "Awaiting approval"} { + assert.Contains(t, out, value) + } + assert.NotContains(t, out, "never-print") + }) + } +} + +func TestVaultPrepareCheckoutInvalidParams(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, raw := range []string{ + `{}`, `null`, `{"checkout":null}`, `{"checkout":{}}`, + `{"type":"prepare_checkout","checkout":{}}`, `{"checkout":{},"checkout":{}}`, + strings.Replace(checkoutParamsFixture, `"browser-1"`, `null`, 1), + strings.Replace(checkoutParamsFixture, `"browser-1"`, `""`, 1), + strings.Replace(checkoutParamsFixture, `"production"`, `"invalid"`, 1), + strings.Replace(checkoutParamsFixture, `"environment":`, `"extra":"never-print","environment":`, 1), + strings.Replace(checkoutParamsFixture, `"browser_id":`, `"browser_id":"duplicate","browser_id":`, 1), + strings.Repeat("x", 128*1024+1), + } { + t.Run(fmt.Sprint(len(raw))+raw[:min(len(raw), 45)], func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user-123", "order-1", "prepare_checkout", "--params", raw) + require.Error(t, err) + assert.NotContains(t, err.Error(), "never-print") + assert.Zero(t, calls) + }) + } + for _, origin := range []string{"http://shop.example", "https://user:pass@shop.example", "https://shop.example/checkout", "https://shop.example/", "https://shop.example?x=1", "https://shop.example#", "https://shop.example?", "https://*.example"} { + _, err := parseVaultCheckoutParams(strings.Replace(checkoutParamsFixture, "https://shop.example", origin, 1)) + require.Error(t, err, origin) + } + _, err := parseVaultCheckoutParams(strings.Replace(checkoutParamsFixture, "https://shop.example", "http://localhost:3000", 1)) + require.NoError(t, err) + _, err = parseVaultOperationParams("prepare_checkout", "", false, false) + require.Error(t, err) +} + +func TestVaultPrepareCheckoutChecksAvailabilityAndNeverRetries(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, status := range []int{0, 409, 429, 500} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" { + fmt.Fprint(w, preparationCardFixture) + return + } + posts++ + if status == 0 { + conn, _, err := w.(http.Hijacker).Hijack() + require.NoError(t, err) + conn.Close() + return + } + w.WriteHeader(status) + fmt.Fprint(w, `{"message":"operation failed"}`) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user-123", "order-1", "prepare_checkout", "--params", checkoutParamsFixture) + require.Error(t, err) + assert.Equal(t, 1, posts) + }) + } + for _, fixture := range []string{ + strings.Replace(preparationCardFixture, `"type":"prepare_checkout"`, `"type":"other"`, 1), + strings.Replace(preparationCardFixture, `"status":"preparing"`, `"status":"recovery_required"`, 1), + strings.ReplaceAll(preparationCardFixture, `"agentcard"`, `"link"`), + } { + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + posts++ + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, fixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user-123", "order-1", "prepare_checkout", "--params", checkoutParamsFixture) + require.Error(t, err) + assert.Zero(t, posts) + } +} + +func TestVaultPreparationOpenAndGuidance(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, preparationCardFixture) + }) + opened := "" + c := VaultsCmd{vaults: &client.Vaults, openURL: func(url string) error { opened = url; return nil }} + params, err := parseVaultOperationParams("prepare_checkout", checkoutParamsFixture, true, true) + require.NoError(t, err) + captureStdout(t, func() { + require.NoError(t, c.Invoke(context.Background(), "user-123", "order-1", "prepare_checkout", params, "json", true)) + }) + assert.Equal(t, "https://approve.example/prepare", opened) + for _, status := range []string{"preparing", "ready_to_submit", "consumed"} { + fixture := strings.Replace(preparationCardFixture, `"status":"preparing"`, `"status":"`+status+`"`, 1) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, fixture) + }) + _, text, err := executeVaultCommand(t, client, "vaults", "items", "get", "user-123", "order-1") + require.NoError(t, err) + assert.Contains(t, text, "Preparation ID") + assert.Contains(t, text, "single-use") + assert.NotContains(t, text, "never-print") + if status == "ready_to_submit" { + assert.Contains(t, text, "polling does not extend") + } + } +} diff --git a/cmd/vaults_sdk_contract_test.go b/cmd/vaults_sdk_contract_test.go new file mode 100644 index 00000000..5c5413de --- /dev/null +++ b/cmd/vaults_sdk_contract_test.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCredentialUpdateIdentityPrecondition(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + name, expectedID string + setID bool + status int + }{ + {name: "omitted", status: 200}, + {name: "bound", expectedID: "credential-original", setID: true, status: 200}, + {name: "replacement conflict", expectedID: "credential-original", setID: true, status: 409}, + {name: "empty", setID: true, status: 200}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, "PATCH", r.Method) + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, `7`, string(body["version"])) + if tc.setID { + assert.JSONEq(t, `"credential-original"`, string(body["expected_item_id"])) + } else { + assert.NotContains(t, body, "expected_item_id") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + if tc.status == 200 { + fmt.Fprint(w, credentialFixture) + } else { + fmt.Fprint(w, `{"message":"conflict"}`) + } + }) + args := []string{"vaults", "credentials", "update", "user-123", "login", "--version", "7", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"value":""}}}`), "-o", "json"} + if tc.setID { + args = append(args, "--expected-item-id", tc.expectedID) + } + _, _, err := executeVaultCommand(t, client, args...) + if tc.name == "empty" { + require.Error(t, err) + assert.Zero(t, calls) + } else { + assert.Equal(t, 1, calls) + if tc.status == 409 { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } + }) + } +} + +func TestCredentialInitialValuesWithReleasedSDK(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"description":"Example","fields":{"username":{"type":"text","sensitive":false,"value":"synthetic-user"},"email":{"type":"email","sensitive":false,"value":"test@example.com"},"password":{"type":"password","sensitive":true,"value":"synthetic-password"},"otp":{"type":"totp","sensitive":true,"value":"JBSWY3DPEHPK3PXP"}}}` + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, "PUT", r.Method) + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, spec, string(body["spec"])) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"credential-1","key":"login","type":"credential","version":1,"spec":%s,"state":{"status":"ready","fields":{"otp":{"has_value":true,"value":"JBSWY3DPEHPK3PXP"}}},"available_operations":[],"available_expansions":[]}`, spec) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", "--spec-file", credentialSpecFile(t, spec), "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, calls) + for _, value := range []string{"synthetic-user", "test@example.com", "synthetic-password", "JBSWY3DPEHPK3PXP"} { + assert.NotContains(t, out, value) + } + assert.Contains(t, out, `"has_value": true`) +} + +func TestVaultPreparationEventsAreProjected(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[{"id":"event-1","name":"preparation_ready","created_at":"2026-09-15T00:00:00Z","browser_id":"browser-1","data":{"preparation_id":"prep-1","reason":"ready","provider_secret":"never-print"}}]`) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "events", "user-123", "order-1", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"preparation_id": "prep-1"`) + assert.NotContains(t, out, "never-print") +} diff --git a/go.mod b/go.mod index ef47300b..82826e7f 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.104.0 + github.com/kernel/kernel-go-sdk v0.105.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 236da6df..24f396d5 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.104.0 h1:HN2MSVroI5O/5hHw3nNzhHmNSGAiX++829srMpPcFpc= -github.com/kernel/kernel-go-sdk v0.104.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.105.0 h1:xKCFrduHuKTD6ceytLkMHUDRafnZaJcc8WUPSXGGILw= +github.com/kernel/kernel-go-sdk v0.105.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 c17fb9d63706b79f791cf195ddf91d4a4ffa853e Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:02:43 +0000 Subject: [PATCH 11/11] Keep preparation URLs and input diagnostics usable --- cmd/vaults_operation_params.go | 22 +++++++++++----------- cmd/vaults_output.go | 3 --- cmd/vaults_policy.go | 6 +++++- cmd/vaults_sdk_contract_test.go | 27 +++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 248ec768..8dbfe706 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -34,7 +34,7 @@ var vaultFillPageURLPattern = regexp.MustCompile(`^https?://[^/?#@*\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") + invalid := fmt.Errorf("operation parameters 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('{') { @@ -52,7 +52,7 @@ func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) return nil, invalid } if key == "type" { - return nil, fmt.Errorf("--params must not contain type; the positional operation supplies it") + return nil, fmt.Errorf("operation parameters must not contain type; the positional operation supplies it") } if _, duplicate := object[key]; duplicate || !slices.Contains(keys, key) { return nil, invalid @@ -115,42 +115,42 @@ func parseVaultFillParams(raw string) (*vaultFillParams, error) { } 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") + return nil, fmt.Errorf("browser_id must be a non-empty browser session ID, not a name") } if rawURL, present := object["page_url"]; present { if json.Unmarshal(rawURL, ¶ms.PageURL) != nil || !vaultFillPageURLPattern.MatchString(params.PageURL) { - return nil, fmt.Errorf("--params.page_url must be an exact HTTP or HTTPS URL without credentials or a wildcard host") + return nil, fmt.Errorf("page_url must be an exact HTTP or 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 HTTP or HTTPS URL without credentials") + return nil, fmt.Errorf("page_url must be an exact HTTP or 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") + return nil, fmt.Errorf("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") + return nil, fmt.Errorf("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) + return nil, fmt.Errorf("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) + return nil, fmt.Errorf("fields[%d].selector must be a non-empty CSS selector", i) } if json.Unmarshal(field["field"], &binding.Field) != nil || strings.TrimSpace(binding.Field) == "" || len(binding.Field) > 64 { - return nil, fmt.Errorf("--params.fields[%d].field must be a non-empty field name of at most 64 bytes", i) + return nil, fmt.Errorf("fields[%d].field must be a non-empty field name of at most 64 bytes", i) } if format, present := field["format"]; present { if json.Unmarshal(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", i) + return nil, fmt.Errorf("fields[%d].format must be MM/YY or MM/YYYY", i) } } params.Fields = append(params.Fields, binding) diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index ad83ee06..2cd8ade1 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -289,9 +289,6 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if !p.ExpiresAt.IsZero() { rows = append(rows, []string{"Submit before", util.FormatLocal(p.ExpiresAt)}) } - if p.ApprovalURL != "" { - rows = append(rows, []string{"Preparation approval URL", p.ApprovalURL}) - } } if item.State.JSON.Authorization.Valid() { a := item.State.Authorization diff --git a/cmd/vaults_policy.go b/cmd/vaults_policy.go index d235c12a..38b91f25 100644 --- a/cmd/vaults_policy.go +++ b/cmd/vaults_policy.go @@ -32,10 +32,14 @@ func effectiveVaultItemActions(item *kernel.VaultItemUnion) (vaultItemActions, e if err := json.Unmarshal([]byte(item.RawJSON()), &fields); err != nil { return vaultItemActions{}, fmt.Errorf("invalid vault item operations: %w", err) } + approvalURL := item.State.Authorization.ApprovalURL + if item.State.Status == "preparing" && item.State.Preparation.ApprovalURL != "" { + approvalURL = item.State.Preparation.ApprovalURL + } return vaultItemActions{ RequiredAction: item.Action.Name, ActionURL: item.Action.URL, - ApprovalURL: item.State.Authorization.ApprovalURL, + ApprovalURL: approvalURL, Operations: fields.Operations, }, nil } diff --git a/cmd/vaults_sdk_contract_test.go b/cmd/vaults_sdk_contract_test.go index 5c5413de..29fb41eb 100644 --- a/cmd/vaults_sdk_contract_test.go +++ b/cmd/vaults_sdk_contract_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -85,6 +86,32 @@ func TestCredentialInitialValuesWithReleasedSDK(t *testing.T) { assert.Contains(t, out, `"has_value": true`) } +func TestVaultPreparationApprovalURLIsPrintedInFull(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + approvalURL := "https://approve.example/" + strings.Repeat("long-token", 40) + fixture := strings.Replace(preparationCardFixture, `"action":{"name":"spend_approval","url":"https://approve.example/prepare"},`, "", 1) + fixture = strings.Replace(fixture, "https://approve.example/prepare", approvalURL, 1) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, fixture) + }) + _, text, err := executeVaultCommand(t, client, "vaults", "items", "get", "user-123", "order-1") + require.NoError(t, err) + assert.Contains(t, text, "Approval URL:\n"+approvalURL+"\n") + assert.NotContains(t, text, "Preparation approval URL") +} + +func TestVaultFileValidationUsesFieldNames(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("invalid input must not reach the API") + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user-123", "login", "fill", "--spec-file", credentialSpecFile(t, `{"browser_id":"","fields":[]}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "browser_id") + assert.NotContains(t, err.Error(), "--params") +} + func TestVaultPreparationEventsAreProjected(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) {