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..61a1dc2a --- /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 --name 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 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: + +```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 8d643cc4..8179e2d8 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,46 @@ 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' +{"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' +{"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 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, +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). @@ -485,17 +524,22 @@ advertised. The API controls availability. The CLI additionally refuses invocati 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. `--open` is supported only for authorize. +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 -`--params`. The positional operation supplies `type`; including `type` in params is rejected. +`--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. -##### Fill checkout fields +##### Fill browser fields -Fill is supported only when advertised by a ready Link card, not AgentCard. It writes stored -card data without returning the values or submitting checkout: +Fill supports credential items and ready Link cards when advertised by the API, not AgentCard. +Both use the same execution and outcome handling. Credential bindings use declared field names, +including TOTP fields, and must omit `format`. Credentials may omit `page_url` only when the API +can resolve a unique page. Card bindings require an exact HTTPS `page_url` and the card fields +listed below. It writes stored values without returning them or submitting the website form: ```bash kernel vaults items get checkout order-1 @@ -504,16 +548,16 @@ kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browse - `browser_id` is a browser **session ID**, not a reusable browser name. It is sent unchanged; the CLI does not resolve names. -- `page_url` is the exact current top-level HTTPS URL, including path, query, and fragment, +- For cards, `page_url` is the exact current top-level HTTPS URL, including path, query, and fragment, without embedded credentials. It must match exactly one open page; no prefix/glob matching. - `fields` contains 1-32 bindings in write order. Each has `field` and a nonempty CSS `selector` targeting an editable input/select or its container. The API searches the selected page and descendants, including payment iframes. Do not supply frame IDs or literal values. -- Stored fields: `number`, `cvc`, `exp_month` (MM), `exp_year` (YYYY), `billing_name`, +- Stored card fields: `number`, `cvc`, `exp_month` (MM), `exp_year` (YYYY), `billing_name`, `billing_line1`, `billing_line2`, `billing_city`, `billing_state`, `billing_postal_code`, `billing_country`. Billing fields use the stored address without reformatting; request only needed fields. Missing requested billing data fails validation before browser writes. -- Combined `expiration` requires `format: "MM/YY"` or `"MM/YYYY"`. Other fields reject `format`. +- Combined card `expiration` requires `format: "MM/YY"` or `"MM/YYYY"`. Other fields reject `format`. - Optional `timeout_ms` is an integer from 1 to 30000 (default 10000), for the whole operation. Fill returns an execution result, **not an updated item**. Normal output shows zero-based @@ -530,8 +574,8 @@ errors are printed in fill results. Fill is non-atomic: execution stops at the first failed/unknown field and earlier writes are not rolled back. `filled` does not mean the site retained or accepted the value; `completed` -does not mean paid. Transport errors do not prove no writes occurred. Inspect the browser -before deciding what to do next. The CLI never retries, submits checkout, or falls back to +does not mean logged in or paid. Transport errors do not prove no writes occurred. Inspect the browser +before deciding what to do next. The CLI never retries, submits website forms, or falls back to aliases. Returned `state.aliases` remain an alternative for explicitly chosen egress-substitution integrations, not a recovery path after a failed or indeterminate fill. 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 6eb2a6ca..7a484ff7 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -193,7 +193,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)) } @@ -241,15 +241,26 @@ 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 { + return err + } return c.fill(ctx, vault, key, params, output) } - // Preserve support for other advertised parameterless operations. - authorize := kernel.VaultItemPerformOperationParamsBodyAuthorize{Type: constant.Authorize(operation)} - response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfAuthorize: &authorize}, option.WithMaxRetries(0)) + request := kernel.VaultItemPerformOperationParams{IDOrName: vault} + if operation == "collect" { + request.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: "collect"} + } else { + // Preserve support for other advertised parameterless operations. + request.OfAuthorize = &kernel.VaultItemPerformOperationParamsBodyAuthorize{Type: constant.Authorize(operation)} + } + response, err := c.vaults.Items.PerformOperation(ctx, key, request, option.WithMaxRetries(0)) if err != nil { + if item.Type == "credential" { + return vaultCredentialError(err) + } return util.CleanedUpSdkError{Err: err} } - if response == nil || (response.Type != "card" && response.Type != "wallet") { + if response == nil || (response.Type != "card" && response.Type != "wallet" && response.Type != "credential") { return fmt.Errorf("unexpected vault operation response; inspect the item and do not retry") } var updated kernel.VaultItemUnion diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 6da600a0..02f51cfa 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. @@ -105,14 +116,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") @@ -135,36 +146,54 @@ JSON output preserves returned public fields but omits unknown/opaque provider d 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. -Read its description with items get before invoking; follow any approval requirements. -Authorize sends {"type":"authorize"} without --params and returns an updated item; ---open opens its returned HTTPS action URL. -Fill requires --params JSON with browser_id (session ID, not name), exact HTTPS -page_url, and 1-32 fields. Each binding has field and selector; expiration also -requires format MM/YY or MM/YYYY. Stored fields: number, cvc, exp_month (MM), +collect returns a time-scoped URL for the full credential form without clearing values. +authorize sends {"type":"authorize"} for payment authorization. +Read the operation description and follow any approval requirements before invoking. +fill requires --params JSON or --spec-file with browser_id (session ID, not name) +and 1-32 ordered fields (field, selector). Do not include type, values, or frame IDs. +The vault must already be attached to the browser. page_url selects an existing page; +fill never navigates. Credentials use declared field names, must omit format, and may +omit page_url only when the API can resolve a unique page. TOTP codes stay server-generated. +Cards require an exact HTTPS page_url. Stored fields: number, cvc, exp_month (MM), exp_year (YYYY), billing_name, billing_line1, billing_line2, billing_city, -billing_state, billing_postal_code, billing_country. Optional timeout_ms is 1-30000 -(default 10000). Do not include type, values, or frame IDs in --params. -Fill is available only when advertised by a ready Link card, not AgentCard. -The API searches the selected page and descendant frames, including payment iframes. -Fill returns value-free per-field outcomes, not an updated item. Completed exits 0; -failed/unknown exit nonzero while preserving the result in -o json. -Fill is not atomic: earlier writes are not rolled back. Transport errors do not -prove no writes occurred. No automatic retries, alias fallback, or form submission. -Inspect the browser before deciding what to do next; completed does not mean paid.`, - Example: ` kernel vaults items get checkout order-1 - kernel vaults items invoke checkout order-1 authorize --open - kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"},{"field":"expiration","format":"MM/YY","selector":"#expiry"},{"field":"cvc","selector":"#security-code"}],"timeout_ms":10000}' -o json`, +billing_state, billing_postal_code, billing_country. expiration requires format MM/YY +or MM/YYYY. Optional timeout_ms is 1-30000 (default 10000). +The API searches the page and descendant frames, including payment iframes. +Fill is available for credential items and ready Link cards when advertised, not AgentCard. +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; +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' +{"browser_id":"","fields":[{"field":"username","selector":"#username"},{"field":"password","selector":"#password"}]} +JSON + kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"}]}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { open, _ := cmd.Flags().GetBool("open") raw, _ := cmd.Flags().GetString("params") - params, err := parseVaultOperationParams(args[2], raw, cmd.Flags().Changed("params"), cmd.Flags().Changed("open")) + 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") + } + data, err := readVaultSpecFile(cmd) + if err != nil { + return err + } + raw, paramsSet = string(data), true + } + params, err := parseVaultOperationParams(args[2], raw, paramsSet, cmd.Flags().Changed("open")) if err != nil { return err } return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} - invoke.Flags().String("params", "", "Operation-specific JSON object for fill; omit type (supplied by )") - invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL for authorize") + 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.MarkFlagsMutuallyExclusive("params", "spec-file") + invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) @@ -207,7 +236,7 @@ Inspect the browser before deciding what to do next; completed does not mean pai 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_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_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 new file mode 100644 index 00000000..6f87cd11 --- /dev/null +++ b/cmd/vaults_credentials.go @@ -0,0 +1,118 @@ +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 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 +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 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' +{"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)") + _ = 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) +} diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go new file mode 100644 index 00000000..40a13cd2 --- /dev/null +++ b/cmd/vaults_credentials_test.go @@ -0,0 +1,210 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "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)) + fieldStatus := status + if status == "completed" { + fieldStatus = "filled" + } + io.WriteString(w, fmt.Sprintf(`{"type":"fill","status":%q,"fields":[{"index":0,"status":%q}],"secret":"never-print"}`, status, fieldStatus)) + }) + 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, "fill "+status) + assert.True(t, json.Valid([]byte(out))) + } else { + require.NoError(t, err) + } + assert.Equal(t, 2, calls) + assert.NotContains(t, out, "never-print") + }) + } +} + +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"} { + 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":[]}`, + `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled","error_code":"secret-echo"}]}`, + `{"type":"fill","status":"completed","fields":[]}`, + } { + result, err := parseVaultFillResult(json.RawMessage(raw), 1) + require.Error(t, err) + assert.Nil(t, result) + 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_fill.go b/cmd/vaults_fill.go index 7f63c6c0..a5a7b4ee 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -34,6 +34,15 @@ const vaultFillUncertain = "browser fields may have been written; inspect the br func vaultFillRequestError(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); %s", body.Code, apiErr.StatusCode, vaultFillUncertain) + } + } return fmt.Errorf("fill request failed (HTTP %d); %s", apiErr.StatusCode, vaultFillUncertain) } // Do not wrap SDK/transport errors: they can contain request or response data, @@ -44,17 +53,19 @@ func vaultFillRequestError(err error) error { func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFillParams, output string) error { request := kernel.FillVaultItemOperationRequestParam{ BrowserID: params.BrowserID, - PageURL: params.PageURL, Type: kernel.FillVaultItemOperationRequestTypeFill, - Fields: make([]kernel.VaultCardFillFieldUnionParam, 0, len(params.Fields)), + Fields: make([]kernel.VaultFillFieldParam, 0, len(params.Fields)), + } + if params.PageURL != "" { + request.PageURL = kernel.Opt(params.PageURL) } if params.TimeoutMS != nil { request.TimeoutMs = kernel.Opt(int64(*params.TimeoutMS)) } for _, field := range params.Fields { - binding := kernel.VaultCardFillFieldParamOfVaultCardFillFieldVaultCardStoredFillField(field.Field, field.Selector) - if field.Field == "expiration" { - binding = kernel.VaultCardFillFieldParamOfVaultCardFillFieldVaultCardExpirationFillField(field.Field, field.Format, field.Selector) + binding := kernel.VaultFillFieldParam{Field: field.Field, Selector: field.Selector} + if field.Format != "" { + binding.Format = kernel.VaultFillFieldFormat(field.Format) } request.Fields = append(request.Fields, binding) } @@ -81,7 +92,7 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil } PrintTableNoPad(rows, true) if result.Status == "completed" { - pterm.Println("Fields filled; this does not confirm payment or merchant acceptance.") + pterm.Println("Fields filled; this does not confirm website acceptance or form submission.") } else { pterm.Println(vaultFillUncertain) } diff --git a/cmd/vaults_fill_credentials_test.go b/cmd/vaults_fill_credentials_test.go new file mode 100644 index 00000000..0450a70f --- /dev/null +++ b/cmd/vaults_fill_credentials_test.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const readyFillCredentialFixture = `{"id":"credential-1","type":"credential","spec":{"fields":{"expiration":{"type":"password"},"custom field":{"type":"text"},"otp":{"type":"totp"}}},"state":{"status":"ready"},"available_operations":[{"type":"fill","description":"Fill credential fields."}]}` + +func TestVaultFillBothItemTypesAndInputs(t *testing.T) { + for _, input := range []string{"params", "spec-file"} { + for _, test := range []struct{ name, item, params, result string }{ + {"card", readyFillCardFixture, fillParamsFixture, completedFillFixture}, + {"credential", readyFillCredentialFixture, `{"browser_id":"browser-id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, completedFillFixture}, + {"credential URL", readyFillCredentialFixture, `{"browser_id":"browser-id","page_url":"http://localhost/login","fields":[{"field":"expiration","selector":"#password"}]}`, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"}]}`}, + } { + t.Run(input+"/"+test.name, 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 r.Method == http.MethodGet { + io.WriteString(w, test.item) + return + } + require.Equal(t, http.MethodPost, r.Method) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"fill",`+test.params[1:], string(body)) + io.WriteString(w, test.result) + }) + value := test.params + if input == "spec-file" { + value = credentialSpecFile(t, value) + } + out, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "fill", "--"+input, value, "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, test.result, out) + assert.Empty(t, human) + assert.Equal(t, 2, calls) + }) + } + } +} + +func TestVaultCredentialFillValidation(t *testing.T) { + for _, input := range []string{"params", "spec-file"} { + for _, params := range []string{ + `{"browser_id":"id","fields":[{"field":"unknown","selector":"#field"}]}`, + `{"browser_id":"id","fields":[{"field":"expiration","selector":"#field","format":"MM/YY"}]}`, + `{"browser_id":"id","fields":[{"field":"custom field","selector":"#field","format":"MM/YYYY"}]}`, + `{"browser_id":"id","fields":[{"field":"expiration","selector":"#field","value":"secret-sentinel"}]}`, + `{"browser_id":"id","browser_id":"secret-sentinel","fields":[{"field":"expiration","selector":"#field"}]}`, + } { + t.Run(input+"/"+params, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, readyFillCredentialFixture) + }) + value := params + if input == "spec-file" { + value = credentialSpecFile(t, params) + } + out, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "fill", "--"+input, value, "-o", "json") + require.Error(t, err) + assert.NotContains(t, err.Error(), "secret-sentinel") + assert.Empty(t, out+human) + }) + } + } +} + +func TestVaultFillInputLimits(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "fill", "--params", fillParamsFixture, "--spec-file", credentialSpecFile(t, fillParamsFixture)) + require.Error(t, err) + _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "fill", "--params", strings.Repeat(" ", 128*1024+1)) + require.Error(t, err) +} + +func TestCredentialFillCLIOutcomes(t *testing.T) { + for _, result := range []string{completedFillFixture, failedFillFixture, unknownFillFixture} { + t.Run(result, func(t *testing.T) { + var posts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + io.WriteString(w, readyFillCredentialFixture) + return + } + posts.Add(1) + io.WriteString(w, result) + })) + defer server.Close() + out, stderr, exit := runVaultFillCLI(t, server.URL, "fill", "--params", `{"browser_id":"id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, "-o", "json") + assert.True(t, json.Valid([]byte(out))) + assert.JSONEq(t, result, out) + assert.Empty(t, stderr) + expectedExit := 1 + if result == completedFillFixture { + expectedExit = 0 + } + assert.Equal(t, expectedExit, exit) + assert.EqualValues(t, 1, posts.Load()) + }) + } +} diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index b0531f63..f9ca6cc7 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -26,7 +26,12 @@ const failedFillFixture = `{"type":"fill","status":"failed","fields":[{"index":0 const unknownFillFixture = `{"type":"fill","status":"unknown","fields":[{"index":0,"status":"filled"},{"index":1,"status":"unknown","error_code":"timeout"},{"index":2,"status":"not_attempted"}]}` func TestVaultFillParamsValidation(t *testing.T) { - client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid params reached API") }) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Type-specific checks require the current item, but must never invoke fill. + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, readyFillCardFixture) + }) replace := func(old, value string) string { return strings.Replace(fillParamsFixture, old, value, 1) } for name, raw := range map[string]string{ "empty": "", "null": "null", "array": "[]", "scalar": `"credential-sentinel"`, diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index e519a6c8..d4cee001 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -8,11 +8,13 @@ import ( "regexp" "slices" "strings" + + kernel "github.com/kernel/kernel-go-sdk" ) type vaultFillParams struct { BrowserID string `json:"browser_id"` - PageURL string `json:"page_url"` + PageURL string `json:"page_url,omitempty"` Fields []vaultFillField `json:"fields"` TimeoutMS *int `json:"timeout_ms,omitempty"` } @@ -23,7 +25,7 @@ type vaultFillField struct { Format string `json:"format,omitempty"` } -var vaultFillPageURLPattern = regexp.MustCompile(`^https://[^/?#@*\s]+(?:[/?#][^\s]*)?$`) +var vaultFillPageURLPattern = regexp.MustCompile(`^https?://[^/?#@*\s]+(?:[/?#][^\s]*)?$`) // Reject duplicate and unknown keys without including payloads in diagnostics. func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) { @@ -69,8 +71,8 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( if strings.TrimSpace(operation) == "" { return nil, fmt.Errorf("operation must not be empty") } - if openSet && operation != "authorize" { - return nil, fmt.Errorf("--open is only supported for authorize") + if openSet && operation != "authorize" && operation != "collect" { + return nil, fmt.Errorf("--open is only supported for authorize and collect") } if operation != "fill" { if paramsSet { @@ -79,7 +81,10 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( return nil, nil } if !paramsSet { - return nil, fmt.Errorf("fill requires --params with browser_id, page_url, and fields") + 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") } object, err := vaultParamsObject(raw, "browser_id page_url fields timeout_ms") if err != nil { @@ -89,12 +94,14 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( if json.Unmarshal(object["browser_id"], ¶ms.BrowserID) != nil || strings.TrimSpace(params.BrowserID) == "" { return nil, fmt.Errorf("--params.browser_id must be a non-empty browser session ID, not a name") } - if json.Unmarshal(object["page_url"], ¶ms.PageURL) != nil || !vaultFillPageURLPattern.MatchString(params.PageURL) { - return nil, fmt.Errorf("--params.page_url must be an exact HTTPS URL without credentials or a wildcard host") - } - u, err := url.Parse(params.PageURL) - if err != nil || u.Hostname() == "" || u.User != nil || u.Opaque != "" { - return nil, fmt.Errorf("--params.page_url must be an exact HTTPS URL without credentials") + if 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") + } + 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") + } } if timeout, ok := object["timeout_ms"]; ok { if json.Unmarshal(timeout, ¶ms.TimeoutMS) != nil || params.TimeoutMS == nil || *params.TimeoutMS < 1 || *params.TimeoutMS > 30000 { @@ -115,22 +122,61 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( if json.Unmarshal(field["selector"], &binding.Selector) != nil || strings.TrimSpace(binding.Selector) == "" { return nil, fmt.Errorf("--params.fields[%d].selector must be a non-empty CSS selector", i) } - if json.Unmarshal(field["field"], &binding.Field) != nil { - return nil, fmt.Errorf("--params.fields[%d].field must be a supported card field", i) + 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) } - switch binding.Field { - case "expiration": - if json.Unmarshal(field["format"], &binding.Format) != nil || (binding.Format != "MM/YY" && binding.Format != "MM/YYYY") { - return nil, fmt.Errorf("--params.fields[%d].format must be MM/YY or MM/YYYY for expiration", i) + 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) } - case "number", "cvc", "exp_month", "exp_year", "billing_name", "billing_line1", "billing_line2", "billing_city", "billing_state", "billing_postal_code", "billing_country": - if _, ok := field["format"]; ok { - return nil, fmt.Errorf("--params.fields[%d].format is only supported for expiration", i) - } - default: - return nil, fmt.Errorf("--params.fields[%d].field must be a supported card field", i) } params.Fields = append(params.Fields, binding) } return ¶ms, nil } + +func validateVaultFillItem(params *vaultFillParams, item *kernel.VaultItemUnion) error { + switch item.Type { + case "credential": + var definition struct { + Spec struct { + Fields map[string]json.RawMessage `json:"fields"` + } `json:"spec"` + } + if json.Unmarshal([]byte(item.RawJSON()), &definition) != nil || len(definition.Spec.Fields) == 0 { + return fmt.Errorf("credential field definitions unavailable; fill was not invoked") + } + for i, field := range params.Fields { + if len(field.Selector) > 2048 { + return fmt.Errorf("fill binding %d: credential selectors must not exceed 2048 bytes", i) + } + if _, exists := definition.Spec.Fields[field.Field]; !exists { + return fmt.Errorf("fill binding %d must reference a declared credential field", i) + } + if field.Format != "" { + return fmt.Errorf("fill binding %d: format is not supported for credentials", i) + } + } + case "card": + if !strings.HasPrefix(params.PageURL, "https://") { + return fmt.Errorf("card fill requires an exact HTTPS page_url") + } + for i, field := range params.Fields { + switch field.Field { + case "expiration": + if field.Format != "MM/YY" && field.Format != "MM/YYYY" { + return fmt.Errorf("fill binding %d: expiration requires format MM/YY or MM/YYYY", i) + } + case "number", "cvc", "exp_month", "exp_year", "billing_name", "billing_line1", "billing_line2", "billing_city", "billing_state", "billing_postal_code", "billing_country": + if field.Format != "" { + return fmt.Errorf("fill binding %d: format is only supported for expiration", i) + } + default: + return fmt.Errorf("fill binding %d must reference a supported card field", i) + } + } + default: + return fmt.Errorf("fill is not supported for this item type") + } + return nil +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index b3dbb38d..49d07840 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 @@ -225,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" { @@ -299,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 { 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 fa46bff5..5ad10822 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 6f4ca2b0..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.102.0 h1:ZGumOc/Bub48B8zRye44BSLNCgqM/Z6K7XcKX0DCjH0= -github.com/kernel/kernel-go-sdk v0.102.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"