diff --git a/README.md b/README.md index 973a5c91..bc29f037 100644 --- a/README.md +++ b/README.md @@ -369,7 +369,7 @@ Use `credentials update --version --spec-file changes.js with a spec such as `{"fields":{"password":{"value":"replacement"}}}`; update specs key `fields` by name rather than using the ordered array. 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, +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. Add fields with `add_fields` (an array of definitions with the same shape and defaults as create; set their values in `fields` in the same update) and delete fields and their values with `remove_fields` (an array of names). Names must be unique after removals, at most 32 fields may result, and at least one must remain. Stale versions fail, without retries. `items invoke collect` reopens the full form without clearing values; compare versions to observe edits to already-ready items. When an update is bound to an earlier read, also pass `--expected-item-id ` to reject a replacement @@ -379,11 +379,13 @@ Do not use credential items to store, collect, or fill credit card data, includi 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 +Types are `text`, `email`, `password`, `totp`, and `passkey`. TOTP seeds and passkeys must be provided through +create/update, never the form; only generated TOTP codes enter the browser. A passkey value is a JSON-encoded +FIDO Credential Exchange Format passkey object; passkeys are never filled into pages. Password, TOTP, and passkey +fields must be sensitive. Unrestricted browser access can read filled values. CLI JSON output retains explicitly non-sensitive -text/email values, definitions, version, and `has_value`. Sensitive values and TOTP -seeds are omitted. +text/email values, definitions, version, and `has_value`. Sensitive values, TOTP +seeds, and passkeys are omitted. Credential spec input is capped at 128 KiB; write errors are redacted. To reuse a managed auth connection's saved credential, create a credential with @@ -450,11 +452,11 @@ TypeScript-style types, which must stay in sync with the [API spec](https://api. `card_enrollment` action collects card details from the cardholder, never from the CLI. - **Link wallet:** supply `authorization: {method: "oauth", client: {type: "kernel_managed"}}`. - **AgentCard wallet:** use `{}` to enroll, or supply `user_id` for a user enrolled in the same organization and provider configuration. -- **Kernel card:** supply `wallet`, `amount` (minor units, at most 50000), `currency`, +- **Kernel card:** supply `wallet`, `amount` (minor units, at most 1000000), `currency`, `merchant_name`, and HTTPS `merchant_url`. Supply two-letter `merchant_country` for Visa. Kernel card updates are unsupported. - **Link card:** include the required fields shown in help. Optional `line_items`, `totals`, - `metadata`, and `expires_at` are supported through JSON. `amount` is at most 500000 minor + and `metadata` are supported through JSON. `amount` is at most 500000 minor units; a virtual card allows at most 50000. - **AgentCard card:** uses `merchant`, not Link's `merchant_name`. Its optional `card_id` selects a vaulted card; otherwise the cardholder selects one at approval. @@ -463,7 +465,8 @@ TypeScript-style types, which must stay in sync with the [API spec](https://api. cannot be updated. Pending issuance updates preserve omitted optional fields; explicit empty lists clear them. Wallet/provider bindings and unsupported fields cannot change after authorization starts. Checkout cards can be edited between authorizations, but a Link card authorized at a checkout cannot be updated (the API returns 409): delete it and -create a new one. An uncertain update enters `recovery_required` and must not be retried. +create a new one. An uncertain update enters `recovery_required`; delete the card and create a +new one rather than retrying the update. Identical card creation returns its existing state without polling, reauthorizing, or resetting recovery. Link fill is limited to the origin of the checkout the card was authorized at; AgentCard checkout domains remain provider-assigned; Kernel fill is locked to the exact origin @@ -533,10 +536,14 @@ If `restart_verification` is advertised in `available_operations`, invoke with e user approval each time to get a fresh `spend_approval` link. The cardholder must verify and approve spending again; restart grants no spend consent. If the response is unclear, re-get and share any new action; invoke once more only if still advertised with renewed -user approval. Otherwise do not retry payment, delete or replace the item. Contact -support@kernel.sh privately with project, vault, key, status_reason and relevant event -timestamps (never card data, API keys or capability URLs) to reconcile the original -operation and establish whether a new purchase is safe; never assume a new card is safe. +user approval. Otherwise do not retry payment or the uncertain update. A Link or Kernel +card in recovery for issuance, or an AgentCard card whose checkout create returned no +authorization ID, may be deleted after explicit user confirmation and replaced by one new +card under a new key. The cardholder may see an approval request for the original card and +should approve only the new one. If the API refuses deletion (for example, Kernel recovery +while reporting a merchant transaction), contact support@kernel.sh privately with project, +vault, key, status_reason and relevant event timestamps (never card data, API keys or +capability URLs) to reconcile the original operation; never assume a new card is safe. Unresolved child cards can block wallet and vault deletion. Time passing or deletion is not evidence of non-execution. @@ -581,8 +588,10 @@ kernel vaults items invoke checkout order-1 restart_verification --open ``` This rotates the approval link locally without a provider call, only after a classified -initial attestation failure with proof no intent was dispatched. Otherwise do not retry, -delete, or replace the item. Always check `available_operations` rather than assuming +initial attestation failure with proof no intent was dispatched. Otherwise do not retry; +with user confirmation, delete the card and create a new purchase under a new key (the +cardholder approves it separately). Recovery while reporting a merchant transaction cannot +be deleted and must be reconciled with support. Always check `available_operations` rather than assuming the API supports this operation. Human output, JSON output and execution expose only advertised restart_verification during recovery; other operations and stale action URLs are suppressed. @@ -631,7 +640,7 @@ kernel vaults items invoke checkout order-1 confirm_transaction --params '{ `type` or any extra properties. `status` must be `APPROVED`, `DECLINED`, `PENDING`, `ERROR`, or `CANCELLED`. `transaction_type` must be `PURCHASE`, `AUTHORIZATION`, `CAPTURE`, `REFUND`, `REVERSAL`, `VERIFICATION`, `CHARGEBACK`, or `FRAUD`. `amount` is an integer in minor units, -0..50000 and no greater than the approved amount; `currency` is three uppercase letters +0..1000000 and no greater than the approved amount; `currency` is three uppercase letters matching the approved purchase currency; `occurred_at` is the observed outcome's RFC3339 timestamp. The operation is no longer advertised after reporting. Never infer success from fill. `PENDING` means explicit @@ -789,9 +798,9 @@ through this endpoint. advertised. In `recovery_required`, the CLI hides and refuses every operation except an advertised `restart_verification`, invoked with explicit user approval each time and new cardholder spend consent. Human and JSON output use the same recovery filtering. -Other operations remain blocked; otherwise contact support@kernel.sh with project, +Other operations remain blocked. Where the API permits it, delete the card after user +confirmation and create a new one; otherwise contact support@kernel.sh with project, vault, key, status_reason and event timestamps (no card data, API keys or capability URLs). -Do not delete or replace unresolved items. Stale action URLs in recovery are not opened; a successful restart returns a fresh approval action. @@ -954,8 +963,9 @@ does not merge the new JSON with the existing spec. Waits are single bounded observations, not readiness guarantees or payment retries. Pending state is returned as-is. Requests are not automatically retried by the vault commands. **Never automatically retry failed, timed-out, rejected, or indeterminate payments.** Inspect -state/events and reconcile the outcome instead. If an AgentCard recovery state explicitly -permits user-confirmed abandonment, delete that card before creating a replacement; deletion +state/events and reconcile the outcome instead. If a recovery state permits user-confirmed +deletion (Link or Kernel issuance, or AgentCard without an authorization ID), delete that +card before creating a replacement; deletion does not prove the original attempt failed. Do not pass card data, OAuth codes/tokens, ciphertext, provider secrets, or sensitive provider responses to the CLI. Complete collection, OAuth, and approval actions through the provider's returned URL/UI; no callback-code command @@ -982,6 +992,7 @@ exists. - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools update ` - Update pool configuration - Same flags as create (except `--region`, which is fixed at creation and cannot be updated) plus `--clear-profile`, `--clear-proxy`, `--clear-start-url`, `--clear-extensions`, `--clear-chrome-policy`, `--clear-private-hosts`, `--clear-proxy-routes`, and `--clear-allowed-hosts` for removing durable configuration. `--clear-private-hosts` removes the whole network configuration (restoring the default private IP ranges and dropping proxy routes and the allowlist). `--private-host`, `--proxy-route`, `--allowed-host`, `--clear-proxy-routes`, and `--clear-allowed-hosts` replace the pool's whole network configuration, so pass the other network flags to keep them; any network update without `--allowed-host` removes the pool's allowlist. Any change to the pool's allowlist, including removing it, automatically replaces idle browsers; leased browsers keep their allowlist until release, when it is reset to the new list or the browser is replaced. `--fill-rate 0` pauses automatic filling. `--discard-all-idle` discards all idle browsers and refills the pool. `--telemetry`, `--memory`, and other network updates only apply to browsers warmed after the update. + - `--expected-network ` - Optimistic concurrency precondition: pass the pool's network configuration from the last pool response (`browser-pools get -o json`, the `browser_pool_config.network` object), or `'{}'` when it has none. The whole update is rejected with 409 `stale_network` if the saved network differs, checked atomically with the write. Explicit empty lists differ from omitted lists, and proxy route targets must match the saved references (resolved IDs, not equivalent proxy names). Omit for unconditional updates - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools delete ` - Delete a pool - `--force` - Force delete even if browsers are leased @@ -1463,8 +1474,10 @@ Automated authentication for web services. The `run` command orchestrates the fu - `kernel api-keys create` - Create a new API key - `--name ` - API key name (required) - - `--days-to-expire ` - Number of days until expiry (1-3650); omit for never + - `--days-to-expire ` - Number of days until expiry (1-3650); omit for never. Cannot be combined with `--expires-at` + - `--expires-at ` - Exact expiry time as an RFC 3339 timestamp (e.g. `2026-01-15T09:00:00Z`); must be in the future. Cannot be combined with `--days-to-expire` - `--project-id ` - Create a project-scoped API key for this project ID; omit for org-wide. This is different from global `--project`, which scopes the CLI request to a project ID. + - `--policy-statement ` - Issue a scoped key allowed only the listed actions on the listed resources (repeatable), e.g. `browsers:use=projects/proj_abc123/*`. Everything not listed is denied. - `--output json`, `-o json` - Output raw JSON object, including the one-time plaintext key - `kernel api-keys list` - List API keys diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 33a3344a..f530ec96 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -3,6 +3,8 @@ package cmd import ( "context" "fmt" + "strings" + "time" "github.com/kernel/cli/pkg/interactive" "github.com/kernel/cli/pkg/util" @@ -28,10 +30,12 @@ type APIKeysCmd struct { } type APIKeysCreateInput struct { - Name string - DaysToExpire Int64Flag - ProjectID string - Output string + Name string + DaysToExpire Int64Flag + ExpiresAt string + ProjectID string + PolicyStatements []string + Output string } type APIKeysListInput struct { @@ -86,9 +90,26 @@ func (c APIKeysCmd) Create(ctx context.Context, in APIKeysCreateInput) error { } params.DaysToExpire = kernel.Int(in.DaysToExpire.Value) } + if in.ExpiresAt != "" { + if in.DaysToExpire.Set { + return fmt.Errorf("--expires-at cannot be combined with --days-to-expire") + } + expiresAt, err := time.Parse(time.RFC3339, in.ExpiresAt) + if err != nil { + return fmt.Errorf("invalid --expires-at value %q: must be an RFC 3339 timestamp (e.g. 2026-01-15T09:00:00Z)", in.ExpiresAt) + } + params.ExpiresAt = kernel.Time(expiresAt) + } if in.ProjectID != "" { params.ProjectID = kernel.String(in.ProjectID) } + if len(in.PolicyStatements) > 0 { + statements, err := parseAPIKeyPolicyStatements(in.PolicyStatements) + if err != nil { + return err + } + params.Policy = kernel.APIKeyNewParamsPolicy{Statements: statements} + } key, err := c.apiKeys.New(ctx, params) if err != nil { @@ -332,6 +353,25 @@ func (c APIKeysCmd) Delete(ctx context.Context, in APIKeysDeleteInput) error { return nil } +// parseAPIKeyPolicyStatements parses ACTION=RESOURCE pairs into policy allow +// statements. Actions never contain '=', so the first '=' is the separator. +func parseAPIKeyPolicyStatements(values []string) ([]kernel.APIKeyNewParamsPolicyStatement, error) { + if len(values) > 256 { + return nil, fmt.Errorf("--policy-statement may be specified at most 256 times") + } + statements := make([]kernel.APIKeyNewParamsPolicyStatement, 0, len(values)) + for _, value := range values { + action, resource, ok := strings.Cut(value, "=") + action = strings.TrimSpace(action) + resource = strings.TrimSpace(resource) + if !ok || action == "" || resource == "" { + return nil, fmt.Errorf("invalid --policy-statement value %q: expected ACTION=RESOURCE (e.g. browsers:use=projects/proj_abc123/*)", value) + } + statements = append(statements, kernel.APIKeyNewParamsPolicyStatement{Action: action, Resource: resource}) + } + return statements, nil +} + func renderCreatedAPIKey(key *kernel.CreatedAPIKey) { rows := pterm.TableData{ {"Field", "Value"}, @@ -342,6 +382,7 @@ func renderCreatedAPIKey(key *kernel.CreatedAPIKey) { {"Project", formatAPIKeyProject(key.APIKey)}, {"Masked Key", key.MaskedKey}, {"Expires At", formatAPIKeyExpiresAt(key.APIKey)}, + {"Policy", formatAPIKeyPolicy(key.APIKey)}, } PrintTableNoPad(rows, true) } @@ -357,6 +398,7 @@ func renderAPIKeyDetails(key *kernel.APIKey) { {"Created By", formatAPIKeyCreator(*key)}, {"Expires At", formatAPIKeyExpiresAt(*key)}, {"Created At", util.FormatLocal(key.CreatedAt)}, + {"Policy", formatAPIKeyPolicy(*key)}, } PrintTableNoPad(rows, true) } @@ -378,6 +420,17 @@ func formatAPIKeyScope(key kernel.APIKey) string { return "Org" } +func formatAPIKeyPolicy(key kernel.APIKey) string { + if !key.JSON.Policy.Valid() || len(key.Policy.Statements) == 0 { + return "Unrestricted" + } + lines := make([]string, 0, len(key.Policy.Statements)) + for _, statement := range key.Policy.Statements { + lines = append(lines, statement.Action+" on "+statement.Resource) + } + return strings.Join(lines, "\n") +} + func formatAPIKeyCreator(key kernel.APIKey) string { if key.CreatedBy.JSON.Name.Valid() && key.CreatedBy.Name != "" { return key.CreatedBy.Name @@ -404,7 +457,9 @@ func runAPIKeysCreate(cmd *cobra.Command, args []string) error { c := getAPIKeysHandler(cmd) name, _ := cmd.Flags().GetString("name") daysToExpire, _ := cmd.Flags().GetInt64("days-to-expire") + expiresAt, _ := cmd.Flags().GetString("expires-at") projectID, _ := cmd.Flags().GetString("project-id") + policyStatements, _ := cmd.Flags().GetStringArray("policy-statement") output, _ := cmd.Flags().GetString("output") return c.Create(cmd.Context(), APIKeysCreateInput{ @@ -413,8 +468,10 @@ func runAPIKeysCreate(cmd *cobra.Command, args []string) error { Set: cmd.Flags().Changed("days-to-expire"), Value: daysToExpire, }, - ProjectID: projectID, - Output: output, + ExpiresAt: expiresAt, + ProjectID: projectID, + PolicyStatements: policyStatements, + Output: output, }) } @@ -489,9 +546,14 @@ var apiKeysCmd = &cobra.Command{ var apiKeysCreateCmd = &cobra.Command{ Use: "create", Short: "Create an API key", - Long: "Create an API key.\n\nBy default the new key is org-wide. Use --project-id to create a key whose own access is scoped to that project. The global --project flag only scopes this CLI request.", - Args: cobra.NoArgs, - RunE: runAPIKeysCreate, + Long: "Create an API key.\n\nBy default the new key is org-wide. Use --project-id to create a key whose own access is scoped to that project. The global --project flag only scopes this CLI request.\n\nUse --policy-statement (repeatable) to issue a scoped key that may only perform the listed actions on the listed resources; every action and resource not listed is denied.", + Example: strings.Join([]string{ + "create --name ci --days-to-expire 30", + "create --name ci --expires-at 2026-01-15T09:00:00Z", + "create --name browser-only --project-id proj_abc123 --policy-statement 'browsers:use=projects/proj_abc123/*'", + }, "\n"), + Args: cobra.NoArgs, + RunE: runAPIKeysCreate, } var apiKeysListCmd = &cobra.Command{ @@ -533,8 +595,10 @@ var apiKeysDeleteCmd = &cobra.Command{ func init() { addJSONOutputFlag(apiKeysCreateCmd) apiKeysCreateCmd.Flags().String("name", "", "API key name (required)") - apiKeysCreateCmd.Flags().Int64("days-to-expire", 0, "Number of days until expiry (1-3650); omit for never") + apiKeysCreateCmd.Flags().Int64("days-to-expire", 0, "Number of days until expiry (1-3650); omit for never. Cannot be combined with --expires-at") + apiKeysCreateCmd.Flags().String("expires-at", "", "Exact expiry time as an RFC 3339 timestamp (e.g. 2026-01-15T09:00:00Z); must be in the future. Cannot be combined with --days-to-expire") apiKeysCreateCmd.Flags().String("project-id", "", "Create a project-scoped API key for this project ID; omit for org-wide") + apiKeysCreateCmd.Flags().StringArray("policy-statement", nil, "Allow statement for a scoped key as ACTION=RESOURCE, e.g. browsers:use=projects/proj_abc123/* (repeatable). Resources are projects/{id}/{kind}/{id}; use projects/{id}/* for everything in a project") _ = apiKeysCreateCmd.MarkFlagRequired("name") addJSONOutputFlag(apiKeysListCmd) diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 23d12167..3abddcef 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "testing" + "time" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" @@ -127,6 +128,52 @@ func TestAPIKeysCreateRejectsInvalidDaysToExpire(t *testing.T) { assert.Contains(t, err.Error(), "--days-to-expire must be between 1 and 3650") } +func TestAPIKeysCreateBuildsExpiresAtAndPolicy(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAPIKeysService{ + NewFunc: func(ctx context.Context, body kernel.APIKeyNewParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) { + assert.False(t, body.DaysToExpire.Valid()) + assert.True(t, body.ExpiresAt.Valid()) + assert.Equal(t, time.Date(2030, 1, 15, 9, 0, 0, 0, time.UTC), body.ExpiresAt.Value.UTC()) + require.Len(t, body.Policy.Statements, 2) + assert.Equal(t, "browsers:use", body.Policy.Statements[0].Action) + assert.Equal(t, "projects/proj_123/browsers/*", body.Policy.Statements[0].Resource) + assert.Equal(t, "vaults:read", body.Policy.Statements[1].Action) + assert.Equal(t, "projects/proj_123/vaults/v=1", body.Policy.Statements[1].Resource) + return createdAPIKeyFromJSON(`{"id":"key_123","name":"ci","key":"sk_live_123","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123"},"expires_at":"2030-01-15T09:00:00Z","project_id":"proj_123","project_name":"Prod","policy":{"statements":[{"action":"browsers:use","resource":"projects/proj_123/browsers/*"}]}}`), nil + }, + } + c := APIKeysCmd{apiKeys: fake} + + err := c.Create(context.Background(), APIKeysCreateInput{ + Name: "ci", + ExpiresAt: "2030-01-15T09:00:00Z", + PolicyStatements: []string{"browsers:use=projects/proj_123/browsers/*", "vaults:read=projects/proj_123/vaults/v=1"}, + }) + require.NoError(t, err) + assert.Contains(t, buf.String(), "browsers:use on projects/proj_123/browsers/*") +} + +func TestAPIKeysCreateRejectsInvalidExpiresAtAndPolicy(t *testing.T) { + c := APIKeysCmd{apiKeys: &FakeAPIKeysService{}} + + err := c.Create(context.Background(), APIKeysCreateInput{Name: "ci", ExpiresAt: "tomorrow"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --expires-at") + + err = c.Create(context.Background(), APIKeysCreateInput{ + Name: "ci", + ExpiresAt: "2030-01-15T09:00:00Z", + DaysToExpire: Int64Flag{Set: true, Value: 30}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be combined") + + err = c.Create(context.Background(), APIKeysCreateInput{Name: "ci", PolicyStatements: []string{"browsers:use"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected ACTION=RESOURCE") +} + func TestAPIKeysRejectInvalidOutputBeforeCallingAPI(t *testing.T) { fake := &FakeAPIKeysService{ NewFunc: func(ctx context.Context, body kernel.APIKeyNewParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) { diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index e8881004..e77da019 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -3,7 +3,9 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" + "net/http" "strings" "github.com/kernel/cli/pkg/util" @@ -387,6 +389,7 @@ type BrowserPoolsUpdateInput struct { AllowedHosts []string AllowedHostsProvided bool ClearAllowedHosts bool + ExpectedNetwork string Viewport string ChromePolicy string ChromePolicyFile string @@ -397,6 +400,24 @@ type BrowserPoolsUpdateInput struct { Output string } +// parseExpectedNetwork decodes --expected-network into a raw JSON object. It is +// kept untyped so explicit empty lists, which the API treats differently from +// omitted ones, survive the SDK's omitzero encoding. +func parseExpectedNetwork(data string) (map[string]any, error) { + data = strings.TrimSpace(data) + if data == "" { + return nil, nil + } + network := map[string]any{} + if err := json.Unmarshal([]byte(data), &network); err != nil { + return nil, fmt.Errorf("invalid JSON in --expected-network (must be a JSON object, e.g. '{}'): %w", err) + } + if network == nil { + return nil, fmt.Errorf("invalid --expected-network: must be a JSON object, not null; use '{}' for a pool with no network configuration") + } + return network, nil +} + func validateBrowserPoolUpdateInput(in BrowserPoolsUpdateInput) error { if in.StartURL != "" && in.ClearStartURL { return fmt.Errorf("cannot specify both --start-url and --clear-start-url") @@ -546,9 +567,17 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) params.Network = network } + expectedNetwork, err := parseExpectedNetwork(in.ExpectedNetwork) + if err != nil { + return err + } + extraFields := map[string]any{} // The SDK's omitzero encoder drops empty collections, so explicit clears use // its extra-fields escape hatch to preserve {} and [] on the wire. + if expectedNetwork != nil { + extraFields["expected_network"] = expectedNetwork + } if in.ClearExtensions { extraFields["extensions"] = []kernel.BrowserExtensionParam{} } @@ -589,6 +618,10 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) pool, err := c.client.Update(ctx, in.IDOrName, params) if err != nil { + var apiErr *kernel.Error + if expectedNetwork != nil && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict { + pterm.Warning.Println("The pool's network configuration no longer matches --expected-network; nothing was updated. Re-read it with 'kernel browser-pools get -o json' and retry.") + } return util.CleanedUpSdkError{Err: err} } @@ -869,7 +902,7 @@ func init() { browserPoolsCreateCmd.Flags().Bool("stealth", false, "Enable stealth mode") browserPoolsCreateCmd.Flags().Bool("headless", false, "Enable headless mode") browserPoolsCreateCmd.Flags().Bool("kiosk", false, "Enable kiosk mode") - browserPoolsCreateCmd.Flags().String("memory", "", "Memory for headful browsers in the pool: '8GiB' (default) or '16GiB'") + browserPoolsCreateCmd.Flags().String("memory", "", "Memory for non-GPU browsers in the pool: headless supports '1GiB' (default), '2GiB', or '4GiB'; headful supports '8GiB' (default) or '16GiB'") browserPoolsCreateCmd.Flags().Bool("refresh-on-profile-update", false, "Flush idle browsers when the pool's profile is updated") browserPoolsCreateCmd.Flags().String("profile-id", "", "Profile ID") browserPoolsCreateCmd.Flags().String("profile-name", "", "Profile name") @@ -896,7 +929,7 @@ func init() { browserPoolsUpdateCmd.Flags().Bool("stealth", false, "Enable stealth mode") browserPoolsUpdateCmd.Flags().Bool("headless", false, "Enable headless mode") browserPoolsUpdateCmd.Flags().Bool("kiosk", false, "Enable kiosk mode") - browserPoolsUpdateCmd.Flags().String("memory", "", "Memory for newly-warmed headful browsers in the pool: '8GiB' or '16GiB'. Existing browsers keep their allocation; use --discard-all-idle to replace idle browsers") + browserPoolsUpdateCmd.Flags().String("memory", "", "Memory for newly-warmed non-GPU browsers: headless supports '1GiB', '2GiB', or '4GiB'; headful supports '8GiB' or '16GiB'. Existing browsers keep their allocation; use --discard-all-idle to replace idle browsers") browserPoolsUpdateCmd.Flags().Bool("refresh-on-profile-update", false, "Flush idle browsers when the pool's profile is updated") browserPoolsUpdateCmd.Flags().String("profile-id", "", "Profile ID") browserPoolsUpdateCmd.Flags().String("profile-name", "", "Profile name") @@ -913,6 +946,7 @@ func init() { browserPoolsUpdateCmd.Flags().Bool("clear-proxy-routes", false, "Remove the pool's proxy routes (combine with --private-host or --allowed-host to keep those settings; an omitted --allowed-host removes the pool's allowlist)") browserPoolsUpdateCmd.Flags().StringSlice("allowed-host", nil, "Set or replace the pool's egress allowlist (repeat or comma-separated, max 100), using the same entry rules as 'browsers create --allowed-host'. An empty list is invalid. Changing the allowlist automatically replaces idle browsers; leased browsers keep theirs until release, when it is reset to the new list or the browser is replaced. Network flags replace the pool's whole network configuration, so pass --private-host and --proxy-route too to keep them. Requires proxy v3") browserPoolsUpdateCmd.Flags().Bool("clear-allowed-hosts", false, "Remove the pool's egress allowlist and replace idle browsers (combine with --private-host or --proxy-route to keep those settings)") + browserPoolsUpdateCmd.Flags().String("expected-network", "", "Optimistic concurrency precondition: the pool's network configuration as a JSON object, copied from the last pool response ('browser-pools get -o json'), or '{}' when it has none. The whole update is rejected with 409 stale_network if the saved network differs, checked atomically with the write. Explicit empty lists differ from omitted lists; proxy route targets must match the saved references (resolved IDs, not equivalent proxy names)") browserPoolsUpdateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") browserPoolsUpdateCmd.Flags().String("chrome-policy", "", "Custom Chrome enterprise policy as a JSON object") browserPoolsUpdateCmd.Flags().String("chrome-policy-file", "", "Read Chrome enterprise policy (JSON object) from a file (use '-' for stdin)") @@ -1068,6 +1102,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { clearProxyRoutes, _ := cmd.Flags().GetBool("clear-proxy-routes") allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host") clearAllowedHosts, _ := cmd.Flags().GetBool("clear-allowed-hosts") + expectedNetwork, _ := cmd.Flags().GetString("expected-network") viewport, _ := cmd.Flags().GetString("viewport") chromePolicy, _ := cmd.Flags().GetString("chrome-policy") chromePolicyFile, _ := cmd.Flags().GetString("chrome-policy-file") @@ -1104,6 +1139,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { AllowedHosts: allowedHosts, AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), ClearAllowedHosts: clearAllowedHosts, + ExpectedNetwork: expectedNetwork, Viewport: viewport, ChromePolicy: chromePolicy, ChromePolicyFile: chromePolicyFile, diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index 2b48c059..26c186b9 100644 --- a/cmd/browser_pools_test.go +++ b/cmd/browser_pools_test.go @@ -189,6 +189,44 @@ func TestBrowserPoolsCreate_WithRegion(t *testing.T) { assert.Error(t, c.Create(context.Background(), BrowserPoolsCreateInput{Size: 1, Region: "emea"})) } +func TestBrowserPoolsMemorySizes(t *testing.T) { + setupStdoutCapture(t) + + var created kernel.BrowserPoolNewParams + var updated kernel.BrowserPoolUpdateParams + fake := &FakeBrowserPoolsService{ + NewFunc: func(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + created = body + return &kernel.BrowserPool{ID: "pool-memory"}, nil + }, + UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + updated = body + return &kernel.BrowserPool{ID: id}, nil + }, + } + c := BrowserPoolsCmd{client: fake} + + for _, tc := range []struct { + input string + want kernel.BrowserMemoryRequest + }{ + {input: "1gib", want: kernel.BrowserMemoryRequest1GiB}, + {input: "2GiB", want: kernel.BrowserMemoryRequest2GiB}, + {input: "4GIB", want: kernel.BrowserMemoryRequest4GiB}, + {input: "8GiB", want: kernel.BrowserMemoryRequest8GiB}, + {input: "16GiB", want: kernel.BrowserMemoryRequest16GiB}, + } { + require.NoError(t, c.Create(context.Background(), BrowserPoolsCreateInput{Size: 1, Memory: tc.input})) + assert.Equal(t, tc.want, created.Memory) + + require.NoError(t, c.Update(context.Background(), BrowserPoolsUpdateInput{IDOrName: "pool-memory", Memory: tc.input})) + assert.Equal(t, kernel.BrowserPoolUpdateParamsMemory(tc.want), updated.Memory) + } + + assert.Error(t, c.Create(context.Background(), BrowserPoolsCreateInput{Size: 1, Memory: "6GiB"})) + assert.Error(t, c.Update(context.Background(), BrowserPoolsUpdateInput{IDOrName: "pool-memory", Memory: "6GiB"})) +} + func TestBrowserPoolsGet_ShowsRegion(t *testing.T) { setupStdoutCapture(t) @@ -797,6 +835,59 @@ func TestBrowserPoolsUpdate_PrivateHostModes(t *testing.T) { } } +func TestBrowserPoolsUpdate_ExpectedNetwork(t *testing.T) { + tests := []struct { + name string + input BrowserPoolsUpdateInput + wantJSON string + }{ + { + name: "no network configuration", + input: BrowserPoolsUpdateInput{Size: 2, ExpectedNetwork: "{}"}, + wantJSON: `"expected_network":{}`, + }, + { + name: "preserves explicit empty lists", + input: BrowserPoolsUpdateInput{AllowedHosts: []string{"example.com"}, ExpectedNetwork: `{"private_hosts":[],"proxy_routes":[{"hosts":["a.example"],"proxy":{"id":"proxy-1"}}]}`}, + wantJSON: `"expected_network":{"private_hosts":[],"proxy_routes":[{"hosts":["a.example"],"proxy":{"id":"proxy-1"}}]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserPoolUpdateParams + fake := &FakeBrowserPoolsService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + captured = body + return &kernel.BrowserPool{ID: id}, nil + }, + } + tt.input.IDOrName = "pool-network" + require.NoError(t, (BrowserPoolsCmd{client: fake}).Update(context.Background(), tt.input)) + raw, err := captured.MarshalJSON() + require.NoError(t, err) + assert.Contains(t, string(raw), tt.wantJSON) + }) + } +} + +func TestBrowserPoolsUpdate_RejectsInvalidExpectedNetwork(t *testing.T) { + for _, value := range []string{"null", "[]", "{bad"} { + t.Run(value, func(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowserPoolsService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + t.Fatal("Update should not be called when --expected-network is invalid") + return nil, nil + }, + } + err := (BrowserPoolsCmd{client: fake}).Update(context.Background(), BrowserPoolsUpdateInput{IDOrName: "pool-1", ExpectedNetwork: value}) + assert.ErrorContains(t, err, "--expected-network") + }) + } +} + func TestBrowserPoolsCreate_WithTelemetry(t *testing.T) { setupStdoutCapture(t) diff --git a/cmd/browsers.go b/cmd/browsers.go index 47f2e98d..82d42068 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -180,15 +180,15 @@ func parseRegionFlag(region string) (string, error) { return "", fmt.Errorf("invalid --region value: %s (must be one of %s)", region, strings.Join(availableRegions(), ", ")) } -// availableMemorySizes returns the memory sizes the API accepts when creating a -// browser session. Only headful, non-GPU sessions can request memory; every -// other configuration gets a fixed allocation. +// availableMemorySizes returns the memory sizes the API accepts for non-GPU +// browser sessions. Headless and headful sessions support different subsets. func availableMemorySizes() []string { - return []string{"8GiB", "16GiB"} + return []string{"1GiB", "2GiB", "4GiB", "8GiB", "16GiB"} } // parseMemoryFlag validates a --memory value. An empty value means the flag was -// not set, which lets the API apply its default (8GiB). +// not set, which lets the API apply the image default (1GiB for headless or 8GiB +// for headful). func parseMemoryFlag(memory string) (kernel.BrowserMemoryRequest, error) { if memory == "" { return "", nil @@ -3160,7 +3160,13 @@ func init() { // extensions extensionsRoot := &cobra.Command{Use: "extensions", Short: "Add browser extensions to a running instance"} - extensionsUpload := &cobra.Command{Use: "upload ...", Short: "Upload one or more unpacked extensions and restart Chromium", Args: cobra.MinimumNArgs(2), RunE: runBrowsersExtensionsUpload} + extensionsUpload := &cobra.Command{ + Use: "upload ...", + Short: "Load one or more unpacked extensions into a running browser", + Long: "Load one or more unpacked extensions into a running browser. Eligible extensions are activated live; Chromium restarts only when enterprise policy requires it or live activation fails.", + Args: cobra.MinimumNArgs(2), + RunE: runBrowsersExtensionsUpload, + } extensionsRoot.AddCommand(extensionsUpload) browsersCmd.AddCommand(extensionsRoot) @@ -3286,7 +3292,7 @@ unrestricted code execution inside the browser VM and is not sandboxed.`, browsersCreateCmd.Flags().BoolP("stealth", "s", false, "Launch browser in stealth mode to avoid detection") browsersCreateCmd.Flags().BoolP("headless", "H", false, "Launch browser without GUI access") browsersCreateCmd.Flags().Bool("gpu", false, "Launch browser with hardware-accelerated GPU rendering") - browsersCreateCmd.Flags().String("memory", "", "Memory for a headful, non-GPU browser session: '8GiB' (default) or '16GiB'") + browsersCreateCmd.Flags().String("memory", "", "Memory for a non-GPU browser: headless supports '1GiB' (default), '2GiB', or '4GiB'; headful supports '8GiB' (default) or '16GiB'") browsersCreateCmd.Flags().String("video-memory", "", "Video memory (VRAM) for a GPU browser session (requires --gpu): '2GiB' (default, 4 vCPU/6GiB memory) or '4GiB' (8 vCPU/12GiB memory)") browsersCreateCmd.Flags().String("invocation-id", "", "Associate the browser session with an invocation") browsersCreateCmd.Flags().Bool("kiosk", false, "Launch browser in kiosk mode") diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index a3def713..28da45c0 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -937,12 +937,24 @@ func TestBrowsersCreate_WithMemory(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(raw), `"memory":"16GiB"`) - // Values are normalized to the casing the API expects. - err = b.Create(context.Background(), BrowsersCreateInput{Memory: "8gib"}) - require.NoError(t, err) - assert.Equal(t, kernel.BrowserMemoryRequest8GiB, captured.Memory) + // Values are normalized to the casing the API expects, including the smaller + // allocations available to headless browsers. + for _, tc := range []struct { + input string + want kernel.BrowserMemoryRequest + }{ + {input: "1gib", want: kernel.BrowserMemoryRequest1GiB}, + {input: "2GiB", want: kernel.BrowserMemoryRequest2GiB}, + {input: "4GIB", want: kernel.BrowserMemoryRequest4GiB}, + {input: "8gib", want: kernel.BrowserMemoryRequest8GiB}, + } { + err = b.Create(context.Background(), BrowsersCreateInput{Memory: tc.input}) + require.NoError(t, err) + assert.Equal(t, tc.want, captured.Memory) + } - // Omitting the flag sends nothing; the server defaults to 8GiB. + // Omitting the flag sends nothing; the server selects the image default (1GiB + // for headless or 8GiB for headful). err = b.Create(context.Background(), BrowsersCreateInput{}) require.NoError(t, err) assert.Empty(t, captured.Memory) @@ -952,7 +964,7 @@ func TestBrowsersCreate_WithMemory(t *testing.T) { assert.NotContains(t, string(raw), "memory") // An unsupported size is rejected before the request is made. - assert.Error(t, b.Create(context.Background(), BrowsersCreateInput{Memory: "4GiB"})) + assert.Error(t, b.Create(context.Background(), BrowsersCreateInput{Memory: "6GiB"})) } func TestBrowsersCreate_WithVideoMemory(t *testing.T) { diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 3e0b9c9c..3c5b23b3 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -216,7 +216,8 @@ any write. enter billing details on the merchant page. the api searches the page and descendant frames, including payment iframes. fill is available for credential items and ready Link or kernel cards when advertised, not AgentCard. Link and kernel cards do not expose aliases or support egress substitution. kernel card fill -is locked to merchant_url's origin and its one-time code expires; submit checkout before expiry. +is locked to merchant_url's origin and delivers card details for a few minutes; submit checkout +promptly after filling. a repeated kernel card fill is safe. fill writes real values into the browser; unrestricted browser/cdp access can read them. fill never explicitly submits forms or clicks buttons, but input/change events may trigger site behavior. completed means fields were filled, not website acceptance, login, or payment success. @@ -241,7 +242,7 @@ preparations are single-use, including after failure or expiry; never retry auto confirm_transaction requires --params or --spec-file without type, with all of: status (APPROVED, DECLINED, PENDING, ERROR, CANCELLED), transaction_type (PURCHASE, AUTHORIZATION, CAPTURE, REFUND, REVERSAL, VERIFICATION, CHARGEBACK, FRAUD), amount -(integer minor units 0..50000, no greater than the approved amount), currency (three +(integer minor units 0..1000000, no greater than the approved amount), currency (three uppercase letters matching the approved purchase currency), and occurred_at (RFC3339). no other fields are accepted. ` + kernelTransactionHelp + `. restart_verification takes no parameters. in recovery_required, ` + vaultRecoveryHelp + ` @@ -429,11 +430,12 @@ func newVaultCardCommand(update bool) *cobra.Command { kernel cards cannot be updated. other requested cards accept a replacement spec. pending issuance updates preserve omitted optional fields; explicit empty lists clear them. the api restricts fields after authorization starts; wallet/provider bindings cannot change. an uncertain update -enters recovery_required and must not be retried. checkout cards can be edited +enters recovery_required; delete the card and create a new one rather than retrying +the update. checkout cards can be edited between authorizations, but a Link card authorized at a checkout cannot be updated: delete it and create a new one. identical creates return existing state without resetting it. never reconfigure the same item to retry a failed, timed-out, rejected, or indeterminate payment. -an AgentCard recovery item that permits abandonment must be deleted after explicit user confirmation before creating a replacement. +a recovery item that permits deletion (Link or kernel issuance, or AgentCard without an authorization id) must be deleted after explicit user confirmation before creating a replacement. ` + linkCardFlowHelp + "\n" + vaultSpecHelp + vaultCardSpecHelp, Example: " kernel vaults cards " + use + ` checkout order-1 \ diff --git a/cmd/vaults_confirm_transaction.go b/cmd/vaults_confirm_transaction.go index 4eb74ad8..e21778af 100644 --- a/cmd/vaults_confirm_transaction.go +++ b/cmd/vaults_confirm_transaction.go @@ -32,8 +32,8 @@ func parseVaultConfirmTransactionParams(raw string) (*vaultConfirmTransactionPar return nil, fmt.Errorf("transaction_type must be PURCHASE, AUTHORIZATION, CAPTURE, REFUND, REVERSAL, VERIFICATION, CHARGEBACK, or FRAUD") } var amount *int64 - if json.Unmarshal(object["amount"], &amount) != nil || amount == nil || *amount < 0 || *amount > 50000 { - return nil, fmt.Errorf("amount must be an integer between 0 and 50000 minor units, no greater than the approved amount") + if json.Unmarshal(object["amount"], &amount) != nil || amount == nil || *amount < 0 || *amount > 1000000 { + return nil, fmt.Errorf("amount must be an integer between 0 and 1000000 minor units, no greater than the approved amount") } params.Amount = *amount if json.Unmarshal(object["currency"], ¶ms.Currency) != nil || !vaultCurrencyPattern.MatchString(params.Currency) { diff --git a/cmd/vaults_confirm_transaction_test.go b/cmd/vaults_confirm_transaction_test.go index 5e726a94..cc352945 100644 --- a/cmd/vaults_confirm_transaction_test.go +++ b/cmd/vaults_confirm_transaction_test.go @@ -78,7 +78,7 @@ func TestVaultConfirmTransactionInvalidParamsDoNotRequest(t *testing.T) { for field, values := range map[string][]string{ "status": {`"approved"`, `"UNKNOWN"`, `42`}, "transaction_type": {`"purchase"`, `"SALE"`, `false`}, - "amount": {`-1`, `50001`, `100.5`, `100.0`, `"100"`, `9223372036854775808`}, + "amount": {`-1`, `1000001`, `100.5`, `100.0`, `"100"`, `9223372036854775808`}, "currency": {`"usd"`, `"US"`, `"USDD"`, `"U1D"`, `123`}, "occurred_at": {`"2026-09-01"`, `"2026-09-01T12:00:00"`, `"not-a-date"`, `42`}, } { @@ -110,7 +110,7 @@ func TestVaultConfirmTransactionInvalidParamsDoNotRequest(t *testing.T) { func TestVaultConfirmTransactionValidValues(t *testing.T) { for _, status := range []string{"APPROVED", "DECLINED", "PENDING", "ERROR", "CANCELLED"} { for _, transactionType := range []string{"PURCHASE", "AUTHORIZATION", "CAPTURE", "REFUND", "REVERSAL", "VERIFICATION", "CHARGEBACK", "FRAUD"} { - for _, amount := range []int{0, 50000} { + for _, amount := range []int{0, 1000000} { raw := fmt.Sprintf(`{"status":%q,"transaction_type":%q,"amount":%d,"currency":"USD","occurred_at":"2026-09-01T12:00:00.123+01:00"}`, status, transactionType, amount) params, err := parseVaultOperationParams("confirm_transaction", raw, true, false) require.NoError(t, err) diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index f67f1c40..85a709ee 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -106,15 +106,16 @@ fields as an ordered array of named definitions. inspect the website and list fields in its natural top-to-bottom order because the user-facing collection form renders that order unchanged. definitions accept a stable name and an optional non-secret human-readable label; forms fall back to name. updates, -state, and browser fills always use name. field types are text, email, password, and -totp; definitions also accept required, sensitive, and value. +state, and browser fills always use name. field types are text, email, password, totp, +and passkey; definitions also accept required, sensitive, and value. a passkey value is +a json-encoded FIDO CXF passkey object; passkeys are never filled into pages. custom collection web apps that encrypt in the browser may send encrypted_value (a compact jwe to the key from vaults get-encryption-key) instead of value; never both. 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. +password, totp, and passkey 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 choose an advertised operation: - ordinary web forms: items invoke fill writes field values without submitting. @@ -124,9 +125,9 @@ poll items get --wait 60 until state.status is ready. then choose an advertised side effects, and its output may include the supplied values. fill never submits, so a retry is safe. retry only after checking the page, and never in an automatic loop. 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. +can read filled values. totp seeds and passkeys must not be collected through the hosted form. get/list output includes definitions, has_value, and explicitly non-sensitive text/email values. -sensitive values and totp seeds are omitted. +sensitive values, totp seeds, and passkeys are omitted. collection urls are bearer credentials: share only with the intended user. ` + vaultOnePasswordCredentialHelp + ` @@ -140,7 +141,7 @@ func newVaultCredentialsCommand() *cobra.Command { 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" + name, short = "update", "update credential values, fields, 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 { @@ -158,11 +159,16 @@ func newVaultCredentialsCommand() *cobra.Command { }, } if update { - cmd.Long += "\nupdate applies to KERNEL-hosted credentials only. update spec fields are an object keyed by field name, not the ordered array used on create.\nupdate preserves omitted fields, replaces nonempty string values (value, or encrypted_value from a browser-encrypting collection app), 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.Long += "\nupdate applies to KERNEL-hosted credentials only. update spec fields are an object keyed by field name, not the ordered array used on create.\nupdate preserves omitted fields, replaces nonempty string values (value, or encrypted_value from a browser-encrypting collection app), 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.\nadd_fields appends field definitions (same shape and defaults as create; set their values in fields in the same update) and remove_fields deletes fields by name with their values. names must be unique after removals, at most 32 fields may result, and at least one must remain. a required totp or passkey field needs a value. do not automatically retry version conflicts." cmd.Flags().Int64("version", 0, "expected version from items get (required; never auto-refreshed)") _ = cmd.MarkFlagRequired("version") cmd.Flags().String("expected-item-id", "", "immutable item id from the original read; reject an update if the key now refers to a replacement item") - cmd.Example = " kernel vaults credentials update user-vault login --version 2 --spec-file changes.json" + cmd.Example = ` kernel vaults credentials update user-vault login --version 2 --spec-file changes.json + + # Add a TOTP field and remove an obsolete one + kernel vaults credentials update user-vault login --version 3 --spec-file - <<'JSON' +{"add_fields":[{"name":"otp","type":"totp"}],"fields":{"otp":{"value":""}},"remove_fields":["pin"]} +JSON` } else { cmd.Example = ` # Kernel-hosted collection kernel vaults credentials create user-vault login --spec-file - <<'JSON' diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 31b3d125..685c16ed 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -90,15 +90,18 @@ type AgentCardCardSpec = { checkout_origin?: string; // top-level checkout origin for autopilot matching; update omission removes it }; -// one live purchase with a KERNEL-enrolled card. authorize obtains a network token and -// one-time code for fill on merchant_url's origin until expires_at. Mastercard purchases -// need no hosted approval. a Visa purchase returns a spend_approval action: the cardholder -// approves it with a Visa passkey (the link expires after 30 minutes) before the code is -// issued. updates are not supported. +// one live purchase with a KERNEL-enrolled card. authorize, then fill on merchant_url's +// origin, then submit checkout before expires_at (one hour after authorization). fill +// delivers card details for a few minutes; submit promptly. repeated fill is safe and never +// submits. supported cards without an agentic network token are authorized without a +// one-time code. Mastercard purchases need no hosted approval. a Visa purchase returns a +// spend_approval action: the cardholder approves it with a Visa passkey (the link expires +// after 30 minutes) before details are issued. amount, merchant_url and merchant_country +// are recorded but do not restrict browser payment requests. updates are not supported. type KernelCardSpec = { provider: "kernel"; wallet: string; // KERNEL wallet item key - amount: number; // integer minor units; 1..50000 + amount: number; // integer minor units; 1..1000000 currency: string; // iso 4217 three letters merchant_name: string; // 1..255 characters merchant_url: string; // https merchant checkout url; fill is locked to its origin diff --git a/cmd/vaults_kernel_errors.go b/cmd/vaults_kernel_errors.go index fe9d12d2..07a4896c 100644 --- a/cmd/vaults_kernel_errors.go +++ b/cmd/vaults_kernel_errors.go @@ -108,7 +108,7 @@ func retryAfterWait(apiErr *kernel.Error) string { return "wait for the rate limit to clear" } -const kernelSupportGuidance = "do not retry payment, delete, or replace the item. contact support@kernel.sh privately with project, vault, item key, status_reason and relevant event timestamps (never card data, api keys or capability urls) to reconcile the original operation and establish whether a new purchase is safe; do not assume a new card is safe or promise a resulting state" +const kernelSupportGuidance = "do not retry payment or the uncertain update. a Link or kernel card in recovery for issuance, or an AgentCard card whose checkout create returned no authorization id, may be deleted after explicit user confirmation and replaced by one new card under a new key; the cardholder may see an approval request for the original card and should approve only the new one. deletion is not proof the original operation did not occur. if the api refuses deletion (for example, kernel recovery while reporting a merchant transaction), contact support@kernel.sh privately with project, vault, item key, status_reason and relevant event timestamps (never card data, api keys or capability urls) to reconcile the original operation and establish whether a new purchase is safe; do not assume a new card is safe or promise a resulting state" const kernelTransactionHelp = "report one observed merchant outcome only when confirm_transaction is advertised. PENDING means the merchant explicitly confirms an accepted transaction is pending; ERROR means an explicit merchant transaction error, not transport loss or an ambiguous processing page. these are final reports, not polling states. unknown means no reliable merchant outcome: ask the user to check order confirmation, merchant account or statement, then report the confirmed outcome even after card expiry. never infer success from fill. if reporting fails or the response is lost, re-get and send the identical observed report once more only if confirm_transaction is freshly advertised; otherwise observe state/events (recorded, in flight or recovery). never repeat merchant checkout" diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 18f3eff6..b965d4ef 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -539,7 +539,7 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction if item.Spec.Provider == "kernel" { switch item.State.Status { case "requested", "pending_authorization", "ready": - pterm.Info.Println("Visa may require the returned spend_approval action; share it privately, never open it for the cardholder. ready means a one-time code is available, not that the merchant charged the card. fill and submit checkout before expires_at.") + pterm.Info.Println("Visa may require the returned spend_approval action; share it privately, never open it for the cardholder. ready means card details can be filled, not that the merchant charged the card. fill delivers details for a few minutes; submit checkout promptly after filling and before expires_at.") } switch item.State.Status { case "ready", "declined", "expired", "consumed": diff --git a/cmd/vaults_sdk_contract_test.go b/cmd/vaults_sdk_contract_test.go index cee81edf..7230f6c3 100644 --- a/cmd/vaults_sdk_contract_test.go +++ b/cmd/vaults_sdk_contract_test.go @@ -64,6 +64,23 @@ func TestCredentialUpdateIdentityPrecondition(t *testing.T) { } } +func TestCredentialUpdateFieldDefinitionsWithGeneratedSDK(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"add_fields":[{"name":"site_passkey","type":"passkey","required":true,"sensitive":true}],"fields":{"site_passkey":{"value":"synthetic-cxf-passkey"}},"remove_fields":["password"]}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PATCH", r.Method) + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, spec, string(body["spec"])) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, credentialFixture) + }) + + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "update", "user-123", "login", "--version", "7", "--spec-file", credentialSpecFile(t, spec), "-o", "json") + require.NoError(t, err) + assert.NotContains(t, out, "synthetic-cxf-passkey") +} + func TestCredentialInitialValuesWithGeneratedSDK(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") spec := `{"description":"Example","fields":[{"name":"username","label":"Membership Number or Username","type":"text","sensitive":false,"value":"synthetic-user"},{"name":"email","type":"email","sensitive":false,"value":"test@example.com"},{"name":"password","type":"password","sensitive":true,"value":"synthetic-password"},{"name":"otp","type":"totp","sensitive":true,"value":"JBSWY3DPEHPK3PXP"}]}` diff --git a/go.mod b/go.mod index 899b3c29..59945477 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.126.0 + github.com/kernel/kernel-go-sdk v0.127.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index a8ea4ec3..59fd599c 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,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.126.0 h1:uv3jdi4DooDGwZ9oF8WrS0YcakKv3Op/k6B8AlgUgXM= -github.com/kernel/kernel-go-sdk v0.126.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.127.0 h1:q/lOm5JcuG7jhJpDq37DS5IhcDJBnV35btgqUb8LThM= +github.com/kernel/kernel-go-sdk v0.127.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=