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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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)")