diff --git a/README.md b/README.md index 8d643cc4..912dee83 100644 --- a/README.md +++ b/README.md @@ -289,11 +289,26 @@ cannot switch projects. | `kernel vaults wallets payment-methods ` | Fetch advertised live payment methods; JSON is the item with `expanded.payment_methods` | | `kernel vaults cards create --provider link\|agentcard --spec ''` | Create a card request; never implicitly authorize Link | | `kernel vaults cards update --provider link\|agentcard --spec ''` | Update a card spec; pending issuance preserves omitted optional fields, and the API enforces state/provider constraints | +| `kernel vaults credentials create --spec ''` | Declare a credential item's fields; `--values-file ` seeds values, `--open` opens a returned collection URL | +| `kernel vaults credentials update --version ` | Set or clear values and the description; `--values-file `, `--description`, `--expected-item-id` | | `kernel vaults items list ` | List item keys, types, providers, status, and required actions | | `kernel vaults items get ` | Inspect state/actions/returned aliases and copyable operation commands; `--wait 0..60`, `--expand payment_methods`, `--open` | -| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; `authorize --open` opens a returned HTTPS action; `fill --params ''` fills checkout fields | +| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; `authorize --open` opens a returned HTTPS action; `prepare_checkout --params ''` prepares an unused AgentCard card for Square Pay; `fill --params ''` fills checkout or login fields; `collect --open` opens a credential item's hosted form | | `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | | `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | +| `kernel vaults provider-configs create --name --provider link\|agentcard --client-id ` | Register customer-owned provider credentials; `--client-secret` or `--client-secret-file` (`-` reads stdin) | +| `kernel vaults provider-configs list` | `--page` (default 1), `--per-page 1..100` (default 20) | +| `kernel vaults provider-configs get ` | Get by ID or name; secrets are never returned | +| `kernel vaults provider-configs update ` | `--name` renames, `--client-secret`/`--client-secret-file` rotates; the client ID is immutable | +| `kernel vaults provider-configs delete ` | Refused with 409 while a vault item still references it; `--yes` skips confirmation | + +`provider-configs` commands are organization-scoped: they need an organization-scoped API key +or dashboard login, a project-scoped key receives 403, and `--project` does not apply. +A configuration is shared across the organization's projects and serves many wallets. +Select one for an AgentCard wallet with `provider_config` in `--spec`, or omit it to use +Kernel-managed credentials; the binding is fixed at wallet creation and renaming a +configuration does not rebind existing wallets. Customer-managed Link wallets are created by +importing a grant's OAuth tokens, which this CLI never accepts — create them from your backend. `` accepts an ID or name. `` is the immutable item key within that vault, not its generated item ID. Names and keys use letters, digits, dots, underscores, and hyphens @@ -466,10 +481,11 @@ kernel vaults cards create agentcard-checkout order-1 --provider agentcard --spe kernel browsers create --vault agentcard-checkout ``` -AgentCard authorizes at checkout and does not currently advertise `authorize`. To select a -vaulted card in advance, inspect `wallets payment-methods` and include its ID as `card_id` in the -card spec. Otherwise, the cardholder selects a card at approval. A reusable card being -`ready` does not mean the last payment succeeded. +AgentCard authorizes at checkout and does not advertise `authorize`. Eligible unused AgentCard +cards advertise `prepare_checkout` instead; see [Prepare a Square checkout](#prepare-a-square-checkout). +To select a vaulted card in advance, inspect `wallets payment-methods` and include its ID as +`card_id` in the card spec. Otherwise, the cardholder selects a card at approval. A reusable card +being `ready` does not mean the last payment succeeded. #### Invoking item operations @@ -485,17 +501,53 @@ 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. -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. +possibly with a required user action. `--open` is supported for `authorize` and `prepare_checkout`. +The [API spec](https://api.onkernel.com/spec.yaml) also accepts `prepare_checkout` and `fill`, with +their inputs in `--params`. The positional operation supplies `type`; including `type` in params 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 +##### Prepare a Square checkout -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: +Eligible unused AgentCard cards advertise `prepare_checkout` before the first native Square Pay +action. Preparation obtains cardholder device approval and binds consent to one browser session and +one declared merchant origin: + +```bash +kernel vaults items get agentcard-checkout order-1 +kernel vaults items invoke agentcard-checkout order-1 prepare_checkout --params '{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}' --open +``` + +- `browser_id` is a browser **session ID** of a browser created with this vault attached, not a + reusable browser name. It is sent unchanged; the CLI does not resolve names. +- `merchant_origin` is the canonical origin of the **top-level merchant document**, not the Square + iframe. Only a scheme and host (with an optional port) are accepted; `http` only for localhost. +- `environment` is `production` or `sandbox`. It describes Square, not the AgentCard credential mode. + +The response is the updated item carrying `state.preparation` with its status, approval URL, and +submission deadline. Deliver the approval URL and keep that page open through token handoff; +`--open` opens it for you. Then poll: + +```bash +kernel vaults items get agentcard-checkout order-1 --wait 60 +``` + +Submit native Square Pay only once the item reaches `ready_to_submit`, and before the printed +deadline. Readiness lasts at most 30 seconds, and polling never extends it. The preparation amount +is display-only and does not constrain the merchant's eventual charge. + +Every preparation is single-use, including after failure or expiry. A failed request is not a retry +signal: one may already have been created. Item `consumed` means the prepared attempt settled, not +that an order or charge succeeded; `stopped` cannot be reused; `outcome_unknown` blocks new requests +and requires merchant reconciliation. Inspect `items events` and reconcile uncertain outcomes with +the merchant rather than preparing again. + +##### Fill checkout or login fields + +Fill is supported when advertised by a ready credential item or a ready Link card, not +AgentCard. It writes stored values without returning them or submitting the form: ```bash kernel vaults items get checkout order-1 @@ -506,6 +558,7 @@ kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browse the CLI does not resolve names. - `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. + Cards require it. Credential items may omit it, which then requires exactly one open page. - `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. @@ -514,6 +567,9 @@ kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browse `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`. +- For a credential item, each `field` is a declared field name that has a stored value, and + `format` is rejected. A `totp` field fills a freshly generated code; its seed never enters + the browser. - 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 @@ -819,6 +875,113 @@ kernel browsers webmcp invoke my-browser --tool-ref '' --input-file in - `--to ` - Directory to extract the profile into (required) - `--format ` - Archive format to request: `tar.zst` (compressed, default) or `tar` (decompressed server-side) +### Vaults + +A vault is a named, project-scoped container for payment items. Vault names and +item keys are immutable, so `create` behaves as an upsert: creating with a name +or key that already exists returns what is there rather than failing. Link +vaults to a session with `kernel browsers create --vault `. + +- `kernel vaults list` - List vaults in the current project + - `--page ` - Page number, 1-based (default 1) + - `--per-page ` - Items per page (default 20) + - `--output json`, `-o json` - Output raw JSON array + - When more vaults are available, the CLI prints the exact command to fetch the next page +- `kernel vaults get ` - Get a vault by ID or name + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults create --name ` - Create or retrieve a vault by name + - `--name ` - Immutable vault name (required) + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults delete ` - Delete a vault; every item it holds is invalidated + - `-y, --yes` - Skip confirmation prompt + +#### Vault Items + +An item is a wallet (an authorized funding source), a card (a payment credential +minted from a wallet), or a credential (a login or other non-payment secret with +no wallet or provider). Items advertise the operations valid in their current +state, so run `kernel vaults items get` and read `Available Operations` before +invoking one. + +- `kernel vaults items list ` - List a vault's items; secret values are never returned + - `--output json`, `-o json` - Output raw JSON array +- `kernel vaults items get ` - Get an item and the operations currently valid for it + - `--wait ` - Hold for up to this many seconds while the item is pending authorization or approval (max 60) + - `--expand ` - Request live provider data listed under `Available Expansions`, e.g. `payment_methods` (repeatable or comma-separated). Expanded data is fetched from the provider and is not persisted in the item. + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults items create --type --spec ` - Create or retrieve an item by key + - `--type wallet|card` - Item type (required) + - `--spec ` - Provider-specific spec as a JSON object, discriminated by its `provider` field. Amounts are integers in minor currency units (`1250` = $12.50). + - `--spec-file ` - Read the spec from a file (use `-` for stdin). Mutually exclusive with `--spec`. + - `--output json`, `-o json` - Output raw JSON object + + Examples: + + ```bash + kernel vaults items create my-vault my-wallet --type wallet \ + --spec '{"provider":"agentcard","user_id":"usr_123"}' + + kernel vaults items create my-vault my-wallet --type wallet \ + --spec '{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}' + + kernel vaults items create my-vault my-card --type card \ + --spec '{"provider":"agentcard","wallet":"my-wallet","merchant":"Acme","amount":1250,"currency":"USD"}' + ``` + +- `kernel vaults items update --spec ` - Update a card item's spec before or between authorizations + - `--spec ` / `--spec-file ` - Full replacement card spec (only card items can be updated) + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults items invoke ` - Perform an operation the item advertises + - `` - Operation to perform, e.g. `authorize`, `collect`, `prepare_checkout`, or `fill`. Operations may call an external provider and return the item's updated state. + - `--params ` - Operation inputs for `prepare_checkout` and `fill`; omit `type`. `authorize` and `collect` take none. + - `--open` - Open a returned HTTPS action or approval URL for `authorize`, `collect`, and `prepare_checkout` + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults items events ` - List an item's immutable audit events, oldest first + - `--after ` - Return only events after this event ID + - `--wait ` - Long-poll for new events for up to this many seconds (max 60). Together with `--after`, this follows an item's progress. + - `--output json`, `-o json` - Output raw JSON array +- `kernel vaults items delete ` - Delete an item; its secret value is invalidated + - `-y, --yes` - Skip confirmation prompt + +#### Credential Items + +A credential item stores a login or other non-payment secret with no wallet and no +external provider. Declare its fields once; field names, types, required flags, and +sensitivity are fixed at creation. Never store card numbers, security codes, or +expiration dates in a credential item - use wallet and card items for payments. + +Values are write-only and never appear in shell arguments: pass them through +`--values-file ` (or `-` for stdin) as a JSON object of field names to values. + +- `kernel vaults credentials create --spec ` - Declare a credential item + - `--spec ` - `{"description"?: string, "fields": {"": {"type": "text"|"email"|"password"|"totp", "required"?: bool, "sensitive"?: bool}}}` (required). Field names match `[a-zA-Z][a-zA-Z0-9_]{0,63}`; 1-32 fields. Values are rejected here. + - `--values-file ` - JSON object of declared field names to non-empty string values (`-` reads stdin) + - `--open` - Open a returned HTTPS collection URL + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults credentials update --version ` - Set or clear values and the description + - `--version ` - Expected current item version from the latest read (required). A concurrent edit returns 409 instead of being overwritten. + - `--values-file ` - JSON object of field names to values; `null` or `""` clears one immediately (`-` reads stdin) + - `--description ` - Replacement form title; `""` clears it + - `--expected-item-id ` - Immutable item ID precondition; returns 409 if the key now identifies a different item + - `--output json`, `-o json` - Output raw JSON object + + Example: + + ```bash + kernel vaults credentials create logins hacker-news --spec '{"description":"Hacker News","fields":{"username":{"type":"text","sensitive":false},"password":{"type":"password"}}}' --values-file ./values.json --open + + # Open the hosted form again for the person who holds the credential + kernel vaults items invoke logins hacker-news collect --open + + # Fill the login into a browser created with --vault logins + kernel vaults items invoke logins hacker-news fill -o json --params '{"browser_id":"browser-session-id","fields":[{"field":"username","selector":"#login"},{"field":"password","selector":"#password"}]}' + ``` + +If every required field has a value, the item is `ready` and no collection action is +returned; `collect` still opens its form. Otherwise the item is `pending_collection` +with a time-scoped hosted form URL - treat that URL as a secret. `ready` means the +required values are present, not that a login succeeded. + ### Projects - `kernel projects list` - List projects (up to 100 by default) @@ -910,22 +1073,26 @@ Managed auth connections (`kernel auth connections`). The commands below are new - `--output json`, `-o json` - Output raw JSON array - `kernel auth connections create` - New flags: - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy configuration for this connection's login, reauth, and health-check browser sessions (mutually exclusive). Omit to derive the default from stealth. + - `--region us-east|eu-west|ap-southeast` - Region for this connection's browser sessions (default: `us-east`). Non-default regions require an eligible plan and organization access. - `--stealth` - Whether those browser sessions run in stealth mode (default: true); use `--stealth=false` to disable - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Default telemetry for this connection's browser sessions. Same semantics as `kernel browsers create` - `--telemetry-export-otlp ` - Export this connection's captured telemetry over OTLP to one of the org's configured destinations. Implies `--telemetry=all` when `--telemetry` is not set. Use `=off` to disable export. - `kernel auth connections update ` - New flags: - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy configuration for future browser sessions (mutually exclusive). Use `--proxy-mode=default` to drop a selected proxy rather than passing an empty value. + - `--region us-east|eu-west|ap-southeast` - Region for future browser sessions; omit to keep the current region - `--stealth` - Set whether future browser sessions run in stealth mode; use `--stealth=false` to disable - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Update telemetry for future browser sessions - `--telemetry-export-otlp ` - Update where future sessions export captured telemetry. Naming a destination requires passing `--telemetry` in the same command, since the API validates capture and export together and enabling capture here would replace the connection's current category selection. Use `=off` to disable export. - `kernel auth connections login ` - New flags: - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy override for this login's browser session (mutually exclusive); omitted properties inherit the connection defaults + - `--region us-east|eu-west|ap-southeast` - Region override for this login's browser session; omit to inherit the connection's region. Applies only to this login. - `--stealth` - Stealth override for this login's browser session; use `--stealth=false` to disable - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Telemetry override for this login only, merged onto the connection's config - `--telemetry-export-otlp ` - Export override for this login only. Naming a destination requires passing `--telemetry` in the same command. Use `=off` to disable export. - `kernel auth connections submit ` - New flags: - `--field-value ` - Canonical field-id=value pair from the connection's `fields` list (repeatable); preferred over the legacy `--field` - `--choice-id ` - Canonical choice ID from the connection's `choices` list + - `--interaction-id ` - Canonical interaction the submitted values answer. Only valid with `--field-value` or `--choice-id`; omit it and the CLI reads the connection's current interaction ID for you. Pass it to pin the submission, so the API rejects it if the flow has already moved on. `kernel auth connections get` and `follow` list those IDs alongside the metadata the API captured for them, so you can tell the options apart before submitting. Fields show their type, ref, and any hint (which names the masked destination a one-time code was sent to); choices show their type, semantic MFA method (`sms`, `totp`, `push`, …), and masked destination. diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index dc2cb263..3c8fc592 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -49,6 +49,7 @@ type AuthConnectionCreateInput struct { ProxyID string ProxyName string ProxyMode string + Region string Stealth BoolFlag SaveCredentials bool NoSaveCredentials bool @@ -57,6 +58,7 @@ type AuthConnectionCreateInput struct { NoAutoReauth bool RecordSession BoolFlag Telemetry string + TelemetryCdpExclude string TelemetryExport string Output string } @@ -84,6 +86,7 @@ type AuthConnectionUpdateInput struct { ProxyName string ProxyNameSet bool ProxyMode string + Region string Stealth BoolFlag SaveCredentials BoolFlag HealthCheckInterval int @@ -92,6 +95,7 @@ type AuthConnectionUpdateInput struct { AutoReauth BoolFlag RecordSession BoolFlag Telemetry string + TelemetryCdpExclude string TelemetryExport string Output string } @@ -111,15 +115,17 @@ type AuthConnectionDeleteInput struct { } type AuthConnectionLoginInput struct { - ID string - ProxyID string - ProxyName string - ProxyMode string - Stealth BoolFlag - RecordSession BoolFlag - Telemetry string - TelemetryExport string - Output string + ID string + ProxyID string + ProxyName string + ProxyMode string + Region string + Stealth BoolFlag + RecordSession BoolFlag + Telemetry string + TelemetryCdpExclude string + TelemetryExport string + Output string } type AuthConnectionSubmitInput struct { @@ -130,7 +136,11 @@ type AuthConnectionSubmitInput struct { // canonical `field_values` keyed by the field IDs the API returned. CanonicalFieldValues map[string]string // SelectedChoiceID is the canonical choice ID from the API's `choices` list. - SelectedChoiceID string + SelectedChoiceID string + // InteractionID pins the submission to the canonical interaction the values + // were read from. Left empty, the CLI reads the connection's current + // interaction ID, since the API requires one for canonical submissions. + InteractionID string MfaOptionID string SignInOptionID string SSOButtonSelector string @@ -213,6 +223,14 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.Browser.Proxy = proxy } + region, err := parseRegionFlag(in.Region) + if err != nil { + return err + } + if region != "" { + params.ManagedAuthCreateRequest.Browser.Region = kernel.ManagedAuthBrowserConfigRegion(region) + } + if in.Stealth.Set { params.ManagedAuthCreateRequest.Browser.Stealth = kernel.Opt(in.Stealth.Value) } @@ -233,8 +251,8 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, true) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, true) if err != nil { return err } @@ -288,6 +306,9 @@ func managedAuthBrowserRows(cfg kernel.ManagedAuthBrowserConfig) pterm.TableData if proxy := formatBrowserProxyConfig(cfg.Proxy); proxy != "" { rows = append(rows, []string{"Browser Proxy", proxy}) } + if cfg.Region != "" { + rows = append(rows, []string{"Browser Region", string(cfg.Region)}) + } // Stealth defaults to true when omitted, so only report what the API sent. if cfg.JSON.Stealth.Valid() { rows = append(rows, []string{"Browser Stealth", fmt.Sprintf("%t", cfg.Stealth)}) @@ -374,13 +395,22 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn hasChanges = true } + region, err := parseRegionFlag(in.Region) + if err != nil { + return err + } + if region != "" { + params.ManagedAuthUpdateRequest.Browser.Region = kernel.ManagedAuthBrowserConfigRegion(region) + hasChanges = true + } + if in.Stealth.Set { params.ManagedAuthUpdateRequest.Browser.Stealth = kernel.Opt(in.Stealth.Value) hasChanges = true } - if in.Telemetry != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, false) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) if err != nil { return err } @@ -414,13 +444,14 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn // models the one on `get` and the one on the `follow` event stream as two // identical but distinct types, so both are converted to this before rendering. type managedAuthInputField struct { - ID string - Label string - Type string - Ref string - Hint string - Required bool - ReplaceExisting bool + ID string + Label string + Type string + Ref string + Hint string + InputMode string + Reason string + Required bool } // managedAuthInputChoice is the choice counterpart of managedAuthInputField. @@ -434,22 +465,27 @@ type managedAuthInputChoice struct { } // formatManagedAuthField renders one canonical input field as -// `id (Label) [type, ref=…, required, hint="…"]`. The hint carries the API's -// context for the field, such as the masked destination a one-time code was -// sent to, so it is often what tells the user which value to supply. +// `id (Label) [type, input_mode=…, ref=…, required, hint="…"]`. The hint carries +// the API's context for the field, such as the masked destination a one-time code +// was sent to, so it is often what tells the user which value to supply. The +// input mode is a keyboard hint that is independent of the field type, so a +// numeric one-time code shows as `text, input_mode=numeric`. func formatManagedAuthField(f managedAuthInputField) string { - meta := make([]string, 0, 5) + meta := make([]string, 0, 6) if f.Type != "" { meta = append(meta, f.Type) } + if f.InputMode != "" { + meta = append(meta, "input_mode="+f.InputMode) + } if f.Ref != "" { meta = append(meta, "ref="+f.Ref) } if f.Required { meta = append(meta, "required") } - if f.ReplaceExisting { - meta = append(meta, "replace-existing") + if f.Reason != "" { + meta = append(meta, "reason="+f.Reason) } if f.Hint != "" { meta = append(meta, fmt.Sprintf("hint=%q", f.Hint)) @@ -538,17 +574,23 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e // Canonical fields/choices supersede discovered_fields, mfa_options and // pending_sso_buttons. Show them first so the IDs needed by `submit // --field-value` and `submit --choice-id` are the first thing visible. + // The interaction ID scopes those submissions and only accompanies canonical + // input, so show it alongside them. + if auth.InteractionID != "" { + tableData = append(tableData, []string{"Interaction ID", auth.InteractionID}) + } if len(auth.Fields) > 0 { fields := make([]string, 0, len(auth.Fields)) for _, f := range auth.Fields { fields = append(fields, formatManagedAuthField(managedAuthInputField{ - ID: f.ID, - Label: f.Label, - Type: f.Type, - Ref: f.Ref, - Hint: f.Hint, - Required: f.Required, - ReplaceExisting: f.Reason == "rejected", + ID: f.ID, + Label: f.Label, + Type: f.Type, + Ref: f.Ref, + Hint: f.Hint, + InputMode: f.InputMode, + Required: f.Required, + Reason: string(f.Reason), })) } tableData = append(tableData, []string{"Fields", strings.Join(fields, "; ")}) @@ -764,6 +806,14 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu params.Browser.Proxy = proxy } + region, err := parseRegionFlag(in.Region) + if err != nil { + return err + } + if region != "" { + params.Browser.Region = kernel.ManagedAuthBrowserConfigRegion(region) + } + if in.Stealth.Set { params.Browser.Stealth = kernel.Opt(in.Stealth.Value) } @@ -772,8 +822,8 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu params.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, false) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) if err != nil { return err } @@ -838,6 +888,28 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn return fmt.Errorf("provide exactly one of: %s", submitModeFlags) } + // The API binds canonical submissions to the interaction the values were read + // from, and rejects an interaction ID sent with a legacy submit mode. + isCanonical := hasCanonicalFields || hasChoice + if in.InteractionID != "" && !isCanonical { + return fmt.Errorf("the --interaction-id flag is only valid with --field-value or --choice-id") + } + if isCanonical && in.InteractionID == "" { + // Resolve the current interaction rather than making the user copy it out + // of `get` or `follow` first. The ID changes on every actionable pause, so + // the freshly read one is the only one worth defaulting to; passing + // --interaction-id explicitly pins the submission to an older interaction + // and lets the API reject it as stale. + conn, err := c.svc.Get(ctx, in.ID) + if err != nil { + return util.CleanedUpSdkError{Err: fmt.Errorf("failed to fetch connection for interaction ID resolution: %w", err)} + } + if conn == nil || conn.InteractionID == "" { + return fmt.Errorf("connection %s has no canonical interaction awaiting input; run 'kernel auth connections get %s' to see what the flow is waiting on", in.ID, in.ID) + } + in.InteractionID = conn.InteractionID + } + // Resolve MFA option: the user may pass the label (e.g. "Get a text"), the // type (e.g. "sms"), or the display string ("Get a text (sms)"). The API // expects the type, so look up the connection's available options and map @@ -884,6 +956,9 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn if hasChoice { params.SubmitFieldsRequest.SelectedChoiceID = kernel.Opt(in.SelectedChoiceID) } + if in.InteractionID != "" { + params.SubmitFieldsRequest.InteractionID = kernel.Opt(in.InteractionID) + } if hasMfaOption { params.SubmitFieldsRequest.MfaOptionID = kernel.Opt(in.MfaOptionID) } @@ -1063,17 +1138,21 @@ func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowIn state.Timestamp.Local().Format(time.RFC3339), state.FlowStatus, state.FlowStep) + if state.InteractionID != "" { + pterm.Info.Printf(" Interaction ID: %s\n", state.InteractionID) + } if len(state.Fields) > 0 { fields := make([]string, 0, len(state.Fields)) for _, f := range state.Fields { fields = append(fields, formatManagedAuthField(managedAuthInputField{ - ID: f.ID, - Label: f.Label, - Type: f.Type, - Ref: f.Ref, - Hint: f.Hint, - Required: f.Required, - ReplaceExisting: f.Reason == "rejected", + ID: f.ID, + Label: f.Label, + Type: f.Type, + Ref: f.Ref, + Hint: f.Hint, + InputMode: f.InputMode, + Required: f.Required, + Reason: string(f.Reason), })) } pterm.Info.Printf(" Fields: %s\n", strings.Join(fields, ", ")) @@ -1181,8 +1260,18 @@ var authConnectionsSubmitCmd = &cobra.Command{ Short: "Submit field values to a login flow", Long: `Submit field values for the login form. Poll the managed auth to track progress. +Canonical submissions (--field-value, --choice-id) are bound to the interaction +they answer. The CLI reads the connection's current interaction ID for you; pass +--interaction-id to pin the submission to a specific interaction instead. + Examples: - # Submit field values + # Submit canonical field values from the connection's fields list + kernel auth connections submit --field-value field_email=me@example.com --field-value field_password=secret + + # Answer a specific interaction (rejected if the flow has moved on) + kernel auth connections submit --choice-id mfa_sms --interaction-id mai_abc123xyz + + # Submit legacy field values kernel auth connections submit --field username=myuser --field password=mypass # Select an MFA option @@ -1224,6 +1313,7 @@ func init() { authConnectionsCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for this connection's browser sessions (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsCreateCmd.Flags().String("proxy-name", "", "Proxy name to use for this connection's browser sessions (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsCreateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the stealth-derived default") + authConnectionsCreateCmd.Flags().String("region", "", "Region for this connection's browser sessions (us-east, eu-west, ap-southeast); defaults to us-east. Non-default regions require an eligible plan and organization access") authConnectionsCreateCmd.Flags().Bool("stealth", true, "Run this connection's browser sessions in stealth mode; use --stealth=false to disable") authConnectionsCreateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") authConnectionsCreateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks. Defaults to 3600 or your plan minimum, whichever is larger. The maximum is 86400; the minimum depends on your plan (Enterprise 300, Startup 1200, Hobbyist 3600, Free 21600)") @@ -1232,6 +1322,7 @@ func init() { authConnectionsCreateCmd.Flags().Bool("record-session", false, "Record browser sessions for this connection by default (useful for debugging)") authConnectionsCreateCmd.Flags().String("telemetry", "", "Configure telemetry for this connection's browser sessions (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") authConnectionsCreateCmd.Flags().String("telemetry-export-otlp", "", "Export this connection's captured telemetry over OTLP to one of the org's configured destinations, by ID or name; --telemetry-export-otlp=off disables export. Implies --telemetry=all when --telemetry is not set, since export requires capture") + authConnectionsCreateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") _ = authConnectionsCreateCmd.MarkFlagRequired("domain") _ = authConnectionsCreateCmd.MarkFlagRequired("profile-name") authConnectionsCreateCmd.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") @@ -1250,6 +1341,7 @@ func init() { authConnectionsUpdateCmd.Flags().String("proxy-id", "", "Proxy ID to use for future browser sessions (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsUpdateCmd.Flags().String("proxy-name", "", "Proxy name to use for future browser sessions (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsUpdateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' to drop a selected proxy and use the stealth-derived default") + authConnectionsUpdateCmd.Flags().String("region", "", "Region for future browser sessions (us-east, eu-west, ap-southeast); omit to keep the current region. Non-default regions require an eligible plan and organization access") authConnectionsUpdateCmd.Flags().Bool("stealth", true, "Set whether future browser sessions run in stealth mode; use --stealth=false to disable") authConnectionsUpdateCmd.Flags().Bool("save-credentials", false, "Enable saving credentials after successful login") authConnectionsUpdateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") @@ -1261,6 +1353,7 @@ func init() { authConnectionsUpdateCmd.Flags().Bool("record-session", false, "Set whether browser sessions are recorded by default; use --record-session=false to disable") authConnectionsUpdateCmd.Flags().String("telemetry", "", "Update telemetry for future browser sessions: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection)") authConnectionsUpdateCmd.Flags().String("telemetry-export-otlp", "", "Update where future sessions export captured telemetry over OTLP, by destination ID or name; --telemetry-export-otlp=off disables export. Naming a destination requires passing --telemetry in the same command, since export and capture are validated together") + authConnectionsUpdateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("save-credentials", "no-save-credentials") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("health-checks", "no-health-checks") @@ -1282,15 +1375,18 @@ func init() { authConnectionsLoginCmd.Flags().String("proxy-id", "", "Proxy ID to use for this login (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsLoginCmd.Flags().String("proxy-name", "", "Proxy name to use for this login (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsLoginCmd.Flags().String("proxy-mode", "", "Proxy egress mode for this login instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the stealth-derived default") + authConnectionsLoginCmd.Flags().String("region", "", "Region for this login's browser session (us-east, eu-west, ap-southeast); omit to inherit the connection's region. Applies only to this login") authConnectionsLoginCmd.Flags().Bool("stealth", true, "Override stealth mode for this login's browser session; use --stealth=false to disable") authConnectionsLoginCmd.Flags().Bool("record-session", false, "Override whether this login's browser session is recorded; use --record-session=false to disable") authConnectionsLoginCmd.Flags().String("telemetry", "", "Telemetry override for this login only, merged onto the connection's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") authConnectionsLoginCmd.Flags().String("telemetry-export-otlp", "", "Export override for this login only: an OTLP destination ID or name; --telemetry-export-otlp=off disables export for this login. Naming a destination requires passing --telemetry in the same command, since export and capture are validated together") + authConnectionsLoginCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") // Submit flags addJSONOutputFlag(authConnectionsSubmitCmd) authConnectionsSubmitCmd.Flags().StringArray("field-value", []string{}, "Canonical field-id=value pair from the connection's `fields` list (repeatable)") authConnectionsSubmitCmd.Flags().String("choice-id", "", "Canonical choice ID from the connection's `choices` list") + authConnectionsSubmitCmd.Flags().String("interaction-id", "", "Canonical interaction ID the submitted values belong to; defaults to the connection's current interaction. Only valid with --field-value or --choice-id") authConnectionsSubmitCmd.Flags().StringArray("field", []string{}, "Legacy field name=value pair (repeatable); prefer --field-value") authConnectionsSubmitCmd.Flags().String("mfa-option-id", "", "MFA option ID if user selected an MFA method") authConnectionsSubmitCmd.Flags().String("sign-in-option-id", "", "Sign-in option ID if the flow returned non-MFA choices") @@ -1334,11 +1430,13 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { proxyID, _ := cmd.Flags().GetString("proxy-id") proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") + region, _ := cmd.Flags().GetString("region") noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") noHealthChecks, _ := cmd.Flags().GetBool("no-health-checks") noAutoReauth, _ := cmd.Flags().GetBool("no-auto-reauth") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") svc := client.Auth.Connections @@ -1355,6 +1453,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { ProxyID: proxyID, ProxyName: proxyName, ProxyMode: proxyMode, + Region: region, Stealth: readBoolFlag(cmd.Flags(), "stealth"), NoSaveCredentials: noSaveCredentials, HealthCheckInterval: healthCheckInterval, @@ -1362,6 +1461,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { NoAutoReauth: noAutoReauth, RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, Output: output, }) @@ -1391,10 +1491,12 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { proxyID, _ := cmd.Flags().GetString("proxy-id") proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") + region, _ := cmd.Flags().GetString("region") saveCredentials, _ := cmd.Flags().GetBool("save-credentials") noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") saveCredentialsFlag := BoolFlag{} @@ -1440,6 +1542,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { ProxyName: proxyName, ProxyNameSet: cmd.Flags().Changed("proxy-name"), ProxyMode: proxyMode, + Region: region, Stealth: readBoolFlag(cmd.Flags(), "stealth"), SaveCredentials: saveCredentialsFlag, HealthCheckInterval: healthCheckInterval, @@ -1448,6 +1551,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { AutoReauth: togglePair("auto-reauth", "no-auto-reauth"), RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, Output: output, }) @@ -1492,21 +1596,25 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { proxyID, _ := cmd.Flags().GetString("proxy-id") proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") + region, _ := cmd.Flags().GetString("region") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} return c.Login(cmd.Context(), AuthConnectionLoginInput{ - ID: args[0], - ProxyID: proxyID, - ProxyName: proxyName, - ProxyMode: proxyMode, - Stealth: readBoolFlag(cmd.Flags(), "stealth"), - RecordSession: readBoolFlag(cmd.Flags(), "record-session"), - Telemetry: telemetry, - TelemetryExport: telemetryExport, - Output: output, + ID: args[0], + ProxyID: proxyID, + ProxyName: proxyName, + ProxyMode: proxyMode, + Region: region, + Stealth: readBoolFlag(cmd.Flags(), "stealth"), + RecordSession: readBoolFlag(cmd.Flags(), "record-session"), + Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, + TelemetryExport: telemetryExport, + Output: output, }) } @@ -1516,6 +1624,7 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error { fieldPairs, _ := cmd.Flags().GetStringArray("field") canonicalFieldPairs, _ := cmd.Flags().GetStringArray("field-value") choiceID, _ := cmd.Flags().GetString("choice-id") + interactionID, _ := cmd.Flags().GetString("interaction-id") mfaOptionID, _ := cmd.Flags().GetString("mfa-option-id") signInOptionID, _ := cmd.Flags().GetString("sign-in-option-id") ssoButtonSelector, _ := cmd.Flags().GetString("sso-button-selector") @@ -1543,6 +1652,7 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error { FieldValues: fieldValues, CanonicalFieldValues: canonicalFieldValues, SelectedChoiceID: choiceID, + InteractionID: interactionID, MfaOptionID: mfaOptionID, SignInOptionID: signInOptionID, SSOButtonSelector: ssoButtonSelector, diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index b1c654a9..6ac153fd 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -147,14 +147,21 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) { Status: kernel.ManagedAuthStatusNeedsAuth, FlowStatus: kernel.ManagedAuthFlowStatusInProgress, FlowStep: kernel.ManagedAuthFlowStepAwaitingInput, + // Canonical fields and choices always arrive with the interaction + // they belong to, which `submit` needs. + InteractionID: "mai_abc123xyz", Fields: []kernel.ManagedAuthField{ { - ID: "otp", - Label: "One-time code", - Type: "code", - Ref: "totp_code", - Hint: "Enter the code sent to +1 ••• ••• 1234", - Required: true, + ID: "otp", + Label: "One-time code", + Type: "code", + Ref: "totp_code", + // The keyboard hint is independent of the field type, so + // it is shown even though the type is already "code". + InputMode: "numeric", + Hint: "Enter the code sent to +1 ••• ••• 1234", + Reason: "rejected", + Required: true, }, }, Choices: []kernel.ManagedAuthChoice{ @@ -181,8 +188,11 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) { require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "e0x3vbw4z66kpwny3k5k46tj"})) out := outBuf.String() + assert.Contains(t, out, `mai_abc123xyz`) assert.Contains(t, out, `otp (One-time code)`) - assert.Contains(t, out, `code, ref=totp_code, required`) + // The reason tells the user why the field is being asked for: "rejected" + // means a stored credential was refused, so a new value has to replace it. + assert.Contains(t, out, `code, input_mode=numeric, ref=totp_code, required, reason=rejected`) assert.Contains(t, out, `hint="Enter the code sent to +1 ••• ••• 1234"`) assert.Contains(t, out, `mfa_sms (Text message)`) assert.Contains(t, out, `mfa_method, sms, to=+1 ••• ••• 1234`) @@ -752,6 +762,71 @@ func TestCreate_BrowserConfig(t *testing.T) { assert.False(t, browser.Stealth.Value) } +// Region is part of the connection's browser config: create sets it, update +// moves future sessions, and login overrides it for that login only. +func TestCreate_BrowserRegion(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionNewParams + fake := &FakeAuthConnectionService{ + NewFunc: func(ctx context.Context, body kernel.AuthConnectionNewParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + captured = body + return &kernel.ManagedAuth{ID: "auth_1"}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Create(context.Background(), AuthConnectionCreateInput{ + Domain: "example.com", + ProfileName: "prof", + Region: "eu-west", + })) + + assert.Equal(t, kernel.ManagedAuthBrowserConfigRegionEuWest, captured.ManagedAuthCreateRequest.Browser.Region) +} + +// Region alone is a real change, so it must satisfy update's "at least one +// field" check rather than being dropped. +func TestUpdate_BrowserRegion(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionUpdateParams + fake := &FakeAuthConnectionService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.AuthConnectionUpdateParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + captured = body + return &kernel.ManagedAuth{ID: id}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Update(context.Background(), AuthConnectionUpdateInput{ID: "auth_1", Region: "ap-southeast"})) + + assert.Equal(t, kernel.ManagedAuthBrowserConfigRegionApSoutheast, captured.ManagedAuthUpdateRequest.Browser.Region) +} + +func TestLogin_BrowserRegion(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionLoginParams + fake := &FakeAuthConnectionService{ + LoginFunc: func(ctx context.Context, id string, body kernel.AuthConnectionLoginParams, opts ...option.RequestOption) (*kernel.LoginResponse, error) { + captured = body + return &kernel.LoginResponse{ID: id}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Login(context.Background(), AuthConnectionLoginInput{ID: "auth_1", Region: "us-east"})) + + assert.Equal(t, kernel.ManagedAuthBrowserConfigRegionUsEast, captured.Browser.Region) +} + +func TestCreate_InvalidRegionErrors(t *testing.T) { + capturePtermOutput(t) + c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} + + err := c.Create(context.Background(), AuthConnectionCreateInput{ + Domain: "example.com", ProfileName: "prof", Region: "mars", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --region value") +} + func TestLogin_BrowserProxyMode(t *testing.T) { capturePtermOutput(t) var captured kernel.AuthConnectionLoginParams @@ -820,16 +895,24 @@ func TestLogin_TelemetryOverride(t *testing.T) { assert.True(t, captured.Browser.Telemetry.Browser.Screenshot.Enabled.Value) } -func TestSubmit_CanonicalChoiceID(t *testing.T) { - capturePtermOutput(t) - var captured kernel.AuthConnectionSubmitParams - fake := &FakeAuthConnectionService{ +// canonicalSubmitFake serves the current interaction ID from `get` and captures +// what `submit` sends, which is what every canonical submission needs. +func canonicalSubmitFake(interactionID string, captured *kernel.AuthConnectionSubmitParams) *FakeAuthConnectionService { + return &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + return &kernel.ManagedAuth{ID: id, InteractionID: interactionID}, nil + }, SubmitFunc: func(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (*kernel.SubmitFieldsResponse, error) { - captured = body + *captured = body return &kernel.SubmitFieldsResponse{Accepted: true}, nil }, } - c := AuthConnectionCmd{svc: fake} +} + +func TestSubmit_CanonicalChoiceID(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionSubmitParams + c := AuthConnectionCmd{svc: canonicalSubmitFake("mai_current", &captured)} require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ ID: "auth_1", SelectedChoiceID: "choice_sms", @@ -841,6 +924,53 @@ func TestSubmit_CanonicalChoiceID(t *testing.T) { } func TestSubmit_CanonicalFieldValues(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionSubmitParams + c := AuthConnectionCmd{svc: canonicalSubmitFake("mai_current", &captured)} + require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + CanonicalFieldValues: map[string]string{"field_email": "me@example.com"}, + })) + assert.Equal(t, map[string]string{"field_email": "me@example.com"}, captured.SubmitFieldsRequest.FieldValues) + assert.Nil(t, captured.SubmitFieldsRequest.Fields) +} + +func TestSubmit_CanonicalResolvesCurrentInteractionID(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionSubmitParams + c := AuthConnectionCmd{svc: canonicalSubmitFake("mai_current", &captured)} + require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + CanonicalFieldValues: map[string]string{"field_email": "me@example.com"}, + })) + require.True(t, captured.SubmitFieldsRequest.InteractionID.Valid()) + assert.Equal(t, "mai_current", captured.SubmitFieldsRequest.InteractionID.Value) +} + +func TestSubmit_ExplicitInteractionIDIsNotOverwritten(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionSubmitParams + fake := canonicalSubmitFake("mai_current", &captured) + getCalls := 0 + inner := fake.GetFunc + fake.GetFunc = func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + getCalls++ + return inner(ctx, id, opts...) + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + SelectedChoiceID: "choice_sms", + // Pinning an older interaction is how a caller detects that the flow moved + // on, so the CLI must forward it untouched. + InteractionID: "mai_pinned", + })) + assert.Equal(t, 0, getCalls) + require.True(t, captured.SubmitFieldsRequest.InteractionID.Valid()) + assert.Equal(t, "mai_pinned", captured.SubmitFieldsRequest.InteractionID.Value) +} + +func TestSubmit_LegacyModeOmitsInteractionID(t *testing.T) { capturePtermOutput(t) var captured kernel.AuthConnectionSubmitParams fake := &FakeAuthConnectionService{ @@ -851,11 +981,61 @@ func TestSubmit_CanonicalFieldValues(t *testing.T) { } c := AuthConnectionCmd{svc: fake} require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + FieldValues: map[string]string{"username": "me"}, + })) + // The API rejects an interaction ID paired with a legacy submit mode. + assert.False(t, captured.SubmitFieldsRequest.InteractionID.Valid()) +} + +func TestSubmit_InteractionIDRequiresCanonicalMode(t *testing.T) { + capturePtermOutput(t) + c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} + err := c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + FieldValues: map[string]string{"username": "me"}, + InteractionID: "mai_current", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "the --interaction-id flag is only valid with --field-value or --choice-id") +} + +func TestSubmit_CanonicalWithoutPendingInteractionErrors(t *testing.T) { + capturePtermOutput(t) + submitted := false + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + return &kernel.ManagedAuth{ID: id}, nil + }, + SubmitFunc: func(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (*kernel.SubmitFieldsResponse, error) { + submitted = true + return &kernel.SubmitFieldsResponse{Accepted: true}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + err := c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + SelectedChoiceID: "choice_sms", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no canonical interaction awaiting input") + assert.False(t, submitted) +} + +func TestSubmit_CanonicalGetErrorSurfaced(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + return nil, errors.New("boom") + }, + } + c := AuthConnectionCmd{svc: fake} + err := c.Submit(context.Background(), AuthConnectionSubmitInput{ ID: "auth_1", CanonicalFieldValues: map[string]string{"field_email": "me@example.com"}, - })) - assert.Equal(t, map[string]string{"field_email": "me@example.com"}, captured.SubmitFieldsRequest.FieldValues) - assert.Nil(t, captured.SubmitFieldsRequest.Fields) + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "interaction ID resolution") } func TestSubmit_CanonicalAndLegacyAreMutuallyExclusive(t *testing.T) { diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index d6d23f86..14bc79fa 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -108,21 +108,24 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err return nil } -// buildPoolNewTelemetryParam converts a --telemetry flag value to the pool create param. -func buildPoolNewTelemetryParam(s string) (kernel.BrowserPoolNewParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) +// buildPoolNewTelemetryParam converts --telemetry and --telemetry-cdp-exclude flag +// values to the pool create param. +func buildPoolNewTelemetryParam(s, cdpExclude string) (kernel.BrowserPoolNewParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) return kernel.BrowserPoolNewParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// buildPoolUpdateTelemetryParam converts a --telemetry flag value to the pool update param. -func buildPoolUpdateTelemetryParam(s string) (kernel.BrowserPoolUpdateParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) +// buildPoolUpdateTelemetryParam converts --telemetry and --telemetry-cdp-exclude flag +// values to the pool update param. +func buildPoolUpdateTelemetryParam(s, cdpExclude string) (kernel.BrowserPoolUpdateParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) return kernel.BrowserPoolUpdateParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// buildPoolAcquireTelemetryParam converts a --telemetry flag value to the acquire override param. -func buildPoolAcquireTelemetryParam(s string) (kernel.BrowserPoolAcquireParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) +// buildPoolAcquireTelemetryParam converts --telemetry and --telemetry-cdp-exclude flag +// values to the acquire override param. +func buildPoolAcquireTelemetryParam(s, cdpExclude string) (kernel.BrowserPoolAcquireParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) return kernel.BrowserPoolAcquireParamsTelemetry{Enabled: enabled, Browser: browser}, err } @@ -132,7 +135,11 @@ func formatPoolTelemetry(cfg kernel.BrowserTelemetryConfig) string { if len(on) == 0 { return "disabled" } - return strings.Join(on, ", ") + base := strings.Join(on, ", ") + if ex := formatCdpExcludedMethods(cfg.Browser.Control.Cdp.ExcludedMethods); ex != "" { + return base + " (excluding CDP methods: " + ex + ")" + } + return base } type BrowserPoolsCreateInput struct { @@ -155,6 +162,7 @@ type BrowserPoolsCreateInput struct { ChromePolicy string ChromePolicyFile string Telemetry string + TelemetryCdpExclude string Output string } @@ -247,8 +255,8 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) params.ChromePolicy = chromePolicy } - if in.Telemetry != "" { - t, err := buildPoolNewTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { + t, err := buildPoolNewTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) if err != nil { return err } @@ -269,7 +277,7 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) } else { pterm.Success.Printf("Created browser pool %s\n", pool.ID) } - if in.Telemetry != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { printTelemetrySummary(pool.BrowserPoolConfig.Telemetry) } return nil @@ -350,6 +358,7 @@ type BrowserPoolsUpdateInput struct { ChromePolicyFile string ClearChromePolicy bool Telemetry string + TelemetryCdpExclude string DiscardAllIdle BoolFlag Output string } @@ -488,8 +497,8 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) params.SetExtraFields(extraFields) } - if in.Telemetry != "" { - t, err := buildPoolUpdateTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { + t, err := buildPoolUpdateTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) if err != nil { return err } @@ -510,7 +519,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) } else { pterm.Success.Printf("Updated browser pool %s\n", pool.ID) } - if in.Telemetry != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { printTelemetrySummary(pool.BrowserPoolConfig.Telemetry) } return nil @@ -535,13 +544,14 @@ func (c BrowserPoolsCmd) Delete(ctx context.Context, in BrowserPoolsDeleteInput) } type BrowserPoolsAcquireInput struct { - IDOrName string - TimeoutSeconds int64 - Name string - StartURL string - Tags map[string]string - Telemetry string - Output string + IDOrName string + TimeoutSeconds int64 + Name string + StartURL string + Tags map[string]string + Telemetry string + TelemetryCdpExclude string + Output string } // buildAcquireParams builds the SDK params for acquiring a browser from a pool. @@ -549,7 +559,7 @@ type BrowserPoolsAcquireInput struct { // path so the per-lease name/tags/start-url/telemetry forwarding cannot silently // diverge between them. The telemetry override merges onto the pool's config for // this lease. -func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64, telemetry, startURL string) (kernel.BrowserPoolAcquireParams, error) { +func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64, telemetry, telemetryCdpExclude, startURL string) (kernel.BrowserPoolAcquireParams, error) { params := kernel.BrowserPoolAcquireParams{} if timeoutSeconds > 0 { params.AcquireTimeoutSeconds = kernel.Int(timeoutSeconds) @@ -563,8 +573,8 @@ func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int6 if len(tags) > 0 { params.Tags = kernel.Tags(tags) } - if telemetry != "" { - t, err := buildPoolAcquireTelemetryParam(telemetry) + if telemetry != "" || telemetryCdpExclude != "" { + t, err := buildPoolAcquireTelemetryParam(telemetry, telemetryCdpExclude) if err != nil { return kernel.BrowserPoolAcquireParams{}, err } @@ -578,7 +588,7 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu return err } - params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.StartURL) + params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.TelemetryCdpExclude, in.StartURL) if err != nil { return err } @@ -749,6 +759,7 @@ func init() { browserPoolsCreateCmd.Flags().String("chrome-policy", "", "Custom Chrome enterprise policy as a JSON object") browserPoolsCreateCmd.Flags().String("chrome-policy-file", "", "Read Chrome enterprise policy (JSON object) from a file (use '-' for stdin)") browserPoolsCreateCmd.Flags().String("telemetry", "", "Configure telemetry for browsers warmed into the pool (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") + browserPoolsCreateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") browserPoolsCreateCmd.MarkFlagsMutuallyExclusive("chrome-policy", "chrome-policy-file") addJSONOutputFlag(browserPoolsGetCmd) @@ -779,6 +790,7 @@ func init() { browserPoolsUpdateCmd.MarkFlagsMutuallyExclusive("chrome-policy", "chrome-policy-file") browserPoolsUpdateCmd.MarkFlagsMutuallyExclusive("private-host", "clear-private-hosts") browserPoolsUpdateCmd.Flags().String("telemetry", "", "Update pool telemetry: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection). Applies only to browsers warmed after the update.") + browserPoolsUpdateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") browserPoolsUpdateCmd.Flags().Bool("discard-all-idle", false, "Discard all idle browsers") addJSONOutputFlag(browserPoolsUpdateCmd) @@ -789,6 +801,7 @@ func init() { browserPoolsAcquireCmd.Flags().String("start-url", "", "URL to navigate the acquired browser to, overriding the pool's start URL for this acquire only (best-effort)") browserPoolsAcquireCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the acquired session (repeatable; applies to this lease)") browserPoolsAcquireCmd.Flags().String("telemetry", "", "Telemetry override for this lease only, merged onto the pool's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") + browserPoolsAcquireCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") addJSONOutputFlag(browserPoolsAcquireCmd) browserPoolsReleaseCmd.Flags().String("session-id", "", "Browser session ID to release") @@ -845,6 +858,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { chromePolicy, _ := cmd.Flags().GetString("chrome-policy") chromePolicyFile, _ := cmd.Flags().GetString("chrome-policy-file") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") output, _ := cmd.Flags().GetString("output") in := BrowserPoolsCreateInput{ @@ -867,6 +881,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { ChromePolicy: chromePolicy, ChromePolicyFile: chromePolicyFile, Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, Output: output, } @@ -908,6 +923,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { chromePolicyFile, _ := cmd.Flags().GetString("chrome-policy-file") clearChromePolicy, _ := cmd.Flags().GetBool("clear-chrome-policy") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") discardIdle, _ := cmd.Flags().GetBool("discard-all-idle") output, _ := cmd.Flags().GetString("output") @@ -937,6 +953,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { ChromePolicyFile: chromePolicyFile, ClearChromePolicy: clearChromePolicy, Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, DiscardAllIdle: BoolFlag{Set: cmd.Flags().Changed("discard-all-idle"), Value: discardIdle}, Output: output, } @@ -959,16 +976,18 @@ func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { startURL, _ := cmd.Flags().GetString("start-url") tags, _ := tagsFromFlag(cmd, "tag") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") output, _ := cmd.Flags().GetString("output") c := BrowserPoolsCmd{client: &client.BrowserPools} return c.Acquire(cmd.Context(), BrowserPoolsAcquireInput{ - IDOrName: args[0], - TimeoutSeconds: timeout, - Name: name, - StartURL: startURL, - Tags: tags, - Telemetry: telemetry, - Output: output, + IDOrName: args[0], + TimeoutSeconds: timeout, + Name: name, + StartURL: startURL, + Tags: tags, + Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, + Output: output, }) } diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index 1df6e6ee..c7780a1d 100644 --- a/cmd/browser_pools_test.go +++ b/cmd/browser_pools_test.go @@ -239,7 +239,7 @@ func TestBrowserPoolsCreate_PrivateHostNormalization(t *testing.T) { // forwarding used by both `browser-pools acquire` and the `browsers create // --pool-id` lease path. func TestBuildAcquireParams(t *testing.T) { - p, err := buildAcquireParams("lease", map[string]string{"env": "prod"}, 30, "console,network", "https://example.com") + p, err := buildAcquireParams("lease", map[string]string{"env": "prod"}, 30, "console,network", "", "https://example.com") assert.NoError(t, err) assert.True(t, p.Name.Valid()) assert.Equal(t, "lease", p.Name.Value) @@ -252,7 +252,7 @@ func TestBuildAcquireParams(t *testing.T) { assert.True(t, p.Telemetry.Browser.Network.Enabled.Value) // Unset inputs produce an empty params struct (nothing forwarded). - empty, err := buildAcquireParams("", nil, 0, "", "") + empty, err := buildAcquireParams("", nil, 0, "", "", "") assert.NoError(t, err) assert.False(t, empty.Name.Valid()) assert.Len(t, empty.Tags, 0) @@ -260,7 +260,7 @@ func TestBuildAcquireParams(t *testing.T) { assert.False(t, empty.StartURL.Valid()) // An invalid category surfaces an error rather than a partial param. - _, err = buildAcquireParams("", nil, 0, "bogus", "") + _, err = buildAcquireParams("", nil, 0, "bogus", "", "") assert.Error(t, err) } diff --git a/cmd/browsers.go b/cmd/browsers.go index 4c59108c..51b43164 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -39,39 +39,39 @@ type BrowsersService interface { Update(ctx context.Context, idOrName string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (res *kernel.BrowserUpdateResponse, err error) DeleteByID(ctx context.Context, idOrName string, opts ...option.RequestOption) (err error) HTTPClient(id string, opts ...option.RequestOption) (*http.Client, error) - LoadExtensions(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) (err error) + LoadExtensions(ctx context.Context, idOrName string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) (err error) } // BrowserReplaysService defines the subset we use for browser replays. type BrowserReplaysService interface { - List(ctx context.Context, id string, opts ...option.RequestOption) (res *[]kernel.BrowserReplayListResponse, err error) + List(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *[]kernel.BrowserReplayListResponse, err error) Download(ctx context.Context, replayID string, query kernel.BrowserReplayDownloadParams, opts ...option.RequestOption) (res *http.Response, err error) - Start(ctx context.Context, id string, body kernel.BrowserReplayStartParams, opts ...option.RequestOption) (res *kernel.BrowserReplayStartResponse, err error) + Start(ctx context.Context, idOrName string, body kernel.BrowserReplayStartParams, opts ...option.RequestOption) (res *kernel.BrowserReplayStartResponse, err error) Stop(ctx context.Context, replayID string, body kernel.BrowserReplayStopParams, opts ...option.RequestOption) (err error) } // BrowserFSService defines the subset we use for browser filesystem APIs. type BrowserFSService interface { - NewDirectory(ctx context.Context, id string, body kernel.BrowserFNewDirectoryParams, opts ...option.RequestOption) (err error) - DeleteDirectory(ctx context.Context, id string, body kernel.BrowserFDeleteDirectoryParams, opts ...option.RequestOption) (err error) - DeleteFile(ctx context.Context, id string, body kernel.BrowserFDeleteFileParams, opts ...option.RequestOption) (err error) - DownloadDirZip(ctx context.Context, id string, query kernel.BrowserFDownloadDirZipParams, opts ...option.RequestOption) (res *http.Response, err error) - FileInfo(ctx context.Context, id string, query kernel.BrowserFFileInfoParams, opts ...option.RequestOption) (res *kernel.BrowserFFileInfoResponse, err error) - ListFiles(ctx context.Context, id string, query kernel.BrowserFListFilesParams, opts ...option.RequestOption) (res *[]kernel.BrowserFListFilesResponse, err error) - Move(ctx context.Context, id string, body kernel.BrowserFMoveParams, opts ...option.RequestOption) (err error) - ReadFile(ctx context.Context, id string, query kernel.BrowserFReadFileParams, opts ...option.RequestOption) (res *http.Response, err error) - SetFilePermissions(ctx context.Context, id string, body kernel.BrowserFSetFilePermissionsParams, opts ...option.RequestOption) (err error) - Upload(ctx context.Context, id string, body kernel.BrowserFUploadParams, opts ...option.RequestOption) (err error) - UploadZip(ctx context.Context, id string, body kernel.BrowserFUploadZipParams, opts ...option.RequestOption) (err error) - WriteFile(ctx context.Context, id string, contents io.Reader, body kernel.BrowserFWriteFileParams, opts ...option.RequestOption) (err error) + NewDirectory(ctx context.Context, idOrName string, body kernel.BrowserFNewDirectoryParams, opts ...option.RequestOption) (err error) + DeleteDirectory(ctx context.Context, idOrName string, body kernel.BrowserFDeleteDirectoryParams, opts ...option.RequestOption) (err error) + DeleteFile(ctx context.Context, idOrName string, body kernel.BrowserFDeleteFileParams, opts ...option.RequestOption) (err error) + DownloadDirZip(ctx context.Context, idOrName string, query kernel.BrowserFDownloadDirZipParams, opts ...option.RequestOption) (res *http.Response, err error) + FileInfo(ctx context.Context, idOrName string, query kernel.BrowserFFileInfoParams, opts ...option.RequestOption) (res *kernel.BrowserFFileInfoResponse, err error) + ListFiles(ctx context.Context, idOrName string, query kernel.BrowserFListFilesParams, opts ...option.RequestOption) (res *[]kernel.BrowserFListFilesResponse, err error) + Move(ctx context.Context, idOrName string, body kernel.BrowserFMoveParams, opts ...option.RequestOption) (err error) + ReadFile(ctx context.Context, idOrName string, query kernel.BrowserFReadFileParams, opts ...option.RequestOption) (res *http.Response, err error) + SetFilePermissions(ctx context.Context, idOrName string, body kernel.BrowserFSetFilePermissionsParams, opts ...option.RequestOption) (err error) + Upload(ctx context.Context, idOrName string, body kernel.BrowserFUploadParams, opts ...option.RequestOption) (err error) + UploadZip(ctx context.Context, idOrName string, body kernel.BrowserFUploadZipParams, opts ...option.RequestOption) (err error) + WriteFile(ctx context.Context, idOrName string, contents io.Reader, body kernel.BrowserFWriteFileParams, opts ...option.RequestOption) (err error) } // BrowserProcessService defines the subset we use for browser process APIs. type BrowserProcessService interface { - Exec(ctx context.Context, id string, body kernel.BrowserProcessExecParams, opts ...option.RequestOption) (res *kernel.BrowserProcessExecResponse, err error) + Exec(ctx context.Context, idOrName string, body kernel.BrowserProcessExecParams, opts ...option.RequestOption) (res *kernel.BrowserProcessExecResponse, err error) Kill(ctx context.Context, processID string, params kernel.BrowserProcessKillParams, opts ...option.RequestOption) (res *kernel.BrowserProcessKillResponse, err error) Resize(ctx context.Context, processID string, params kernel.BrowserProcessResizeParams, opts ...option.RequestOption) (res *kernel.BrowserProcessResizeResponse, err error) - Spawn(ctx context.Context, id string, body kernel.BrowserProcessSpawnParams, opts ...option.RequestOption) (res *kernel.BrowserProcessSpawnResponse, err error) + Spawn(ctx context.Context, idOrName string, body kernel.BrowserProcessSpawnParams, opts ...option.RequestOption) (res *kernel.BrowserProcessSpawnResponse, err error) Status(ctx context.Context, processID string, query kernel.BrowserProcessStatusParams, opts ...option.RequestOption) (res *kernel.BrowserProcessStatusResponse, err error) Stdin(ctx context.Context, processID string, params kernel.BrowserProcessStdinParams, opts ...option.RequestOption) (res *kernel.BrowserProcessStdinResponse, err error) StdoutStreamStreaming(ctx context.Context, processID string, query kernel.BrowserProcessStdoutStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserProcessStdoutStreamResponse]) @@ -80,34 +80,34 @@ type BrowserProcessService interface { // BrowserFWatchService defines the subset we use for browser filesystem watch APIs. type BrowserFWatchService interface { EventsStreaming(ctx context.Context, watchID string, query kernel.BrowserFWatchEventsParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserFWatchEventsResponse]) - Start(ctx context.Context, id string, body kernel.BrowserFWatchStartParams, opts ...option.RequestOption) (res *kernel.BrowserFWatchStartResponse, err error) + Start(ctx context.Context, idOrName string, body kernel.BrowserFWatchStartParams, opts ...option.RequestOption) (res *kernel.BrowserFWatchStartResponse, err error) Stop(ctx context.Context, watchID string, body kernel.BrowserFWatchStopParams, opts ...option.RequestOption) (err error) } // BrowserLogService defines the subset we use for browser log APIs. type BrowserLogService interface { - StreamStreaming(ctx context.Context, id string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) + StreamStreaming(ctx context.Context, idOrName string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) } // BrowserPlaywrightService defines the subset we use for Playwright execution. type BrowserPlaywrightService interface { - Execute(ctx context.Context, id string, body kernel.BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *kernel.BrowserPlaywrightExecuteResponse, err error) + Execute(ctx context.Context, idOrName string, body kernel.BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *kernel.BrowserPlaywrightExecuteResponse, err error) } // BrowserComputerService defines the subset we use for OS-level mouse & screen. type BrowserComputerService interface { - Batch(ctx context.Context, id string, body kernel.BrowserComputerBatchParams, opts ...option.RequestOption) (err error) - CaptureScreenshot(ctx context.Context, id string, body kernel.BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (res *http.Response, err error) - ClickMouse(ctx context.Context, id string, body kernel.BrowserComputerClickMouseParams, opts ...option.RequestOption) (err error) - DragMouse(ctx context.Context, id string, body kernel.BrowserComputerDragMouseParams, opts ...option.RequestOption) (err error) - GetMousePosition(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.BrowserComputerGetMousePositionResponse, err error) - MoveMouse(ctx context.Context, id string, body kernel.BrowserComputerMoveMouseParams, opts ...option.RequestOption) (err error) - PressKey(ctx context.Context, id string, body kernel.BrowserComputerPressKeyParams, opts ...option.RequestOption) (err error) - ReadClipboard(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.BrowserComputerReadClipboardResponse, err error) - Scroll(ctx context.Context, id string, body kernel.BrowserComputerScrollParams, opts ...option.RequestOption) (err error) - SetCursorVisibility(ctx context.Context, id string, body kernel.BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (res *kernel.BrowserComputerSetCursorVisibilityResponse, err error) - TypeText(ctx context.Context, id string, body kernel.BrowserComputerTypeTextParams, opts ...option.RequestOption) (err error) - WriteClipboard(ctx context.Context, id string, body kernel.BrowserComputerWriteClipboardParams, opts ...option.RequestOption) (err error) + Batch(ctx context.Context, idOrName string, body kernel.BrowserComputerBatchParams, opts ...option.RequestOption) (err error) + CaptureScreenshot(ctx context.Context, idOrName string, body kernel.BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (res *http.Response, err error) + ClickMouse(ctx context.Context, idOrName string, body kernel.BrowserComputerClickMouseParams, opts ...option.RequestOption) (err error) + DragMouse(ctx context.Context, idOrName string, body kernel.BrowserComputerDragMouseParams, opts ...option.RequestOption) (err error) + GetMousePosition(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.BrowserComputerGetMousePositionResponse, err error) + MoveMouse(ctx context.Context, idOrName string, body kernel.BrowserComputerMoveMouseParams, opts ...option.RequestOption) (err error) + PressKey(ctx context.Context, idOrName string, body kernel.BrowserComputerPressKeyParams, opts ...option.RequestOption) (err error) + ReadClipboard(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.BrowserComputerReadClipboardResponse, err error) + Scroll(ctx context.Context, idOrName string, body kernel.BrowserComputerScrollParams, opts ...option.RequestOption) (err error) + SetCursorVisibility(ctx context.Context, idOrName string, body kernel.BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (res *kernel.BrowserComputerSetCursorVisibilityResponse, err error) + TypeText(ctx context.Context, idOrName string, body kernel.BrowserComputerTypeTextParams, opts ...option.RequestOption) (err error) + WriteClipboard(ctx context.Context, idOrName string, body kernel.BrowserComputerWriteClipboardParams, opts ...option.RequestOption) (err error) } // Regular expression to validate CUID2 identifiers (starts with a letter, 24 lowercase alphanumeric characters). @@ -246,6 +246,24 @@ func formatPrivateHosts(network kernel.BrowserNetworkConfig) string { return strings.Join(network.PrivateHosts, ", ") } +// formatVaultReferences renders the vaults linked to a session for table output, +// preferring each vault's name and falling back to its ID. It returns an empty +// string when no vaults are linked, so the row can be omitted entirely. +func formatVaultReferences(vaults []kernel.VaultReference) string { + if len(vaults) == 0 { + return "" + } + labels := make([]string, 0, len(vaults)) + for _, vault := range vaults { + if vault.Name != "" { + labels = append(labels, vault.Name) + continue + } + labels = append(labels, vault.ID) + } + return strings.Join(labels, ", ") +} + // parseStringMapFlag parses repeated KEY=value flag values into a map. It returns a nil // map when no values were given, so callers can distinguish "flag absent" from "flag set // to an empty map". @@ -360,32 +378,33 @@ func formatTags(tags kernel.Tags) string { // Inputs for each command type BrowsersCreateInput struct { - TimeoutSeconds int - Stealth BoolFlag - Headless BoolFlag - GPU BoolFlag - Memory string - InvocationID string - Kiosk BoolFlag - ProfileID string - ProfileName string - ProfileSaveChanges BoolFlag - ProxyID string - ProxyName string - ProxyMode string - Region string - PrivateHosts []string - StartURL string - Extensions []string - Vaults []string - Viewport string - Telemetry string - TelemetryExport string - ChromePolicy string - ChromePolicyFile string - Name string - Tags map[string]string - Output string + TimeoutSeconds int + Stealth BoolFlag + Headless BoolFlag + GPU BoolFlag + Memory string + InvocationID string + Kiosk BoolFlag + ProfileID string + ProfileName string + ProfileSaveChanges BoolFlag + ProxyID string + ProxyName string + ProxyMode string + Region string + PrivateHosts []string + StartURL string + Extensions []string + Vaults []string + Viewport string + Telemetry string + TelemetryCdpExclude string + TelemetryExport string + ChromePolicy string + ChromePolicyFile string + Name string + Tags map[string]string + Output string } type BrowsersDeleteInput struct { @@ -416,6 +435,7 @@ type BrowsersUpdateInput struct { Viewport string Force bool Telemetry string + TelemetryCdpExclude string Name string SetName bool ClearName bool @@ -675,8 +695,8 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } - if in.Telemetry != "" || in.TelemetryExport != "" { - t, err := buildNewTelemetryParam(in.Telemetry, in.TelemetryExport) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + t, err := buildNewTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport) if err != nil { return err } @@ -718,7 +738,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } PrintTableNoPad(rows, true) } - if in.Telemetry != "" || in.TelemetryExport != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { printTelemetrySummary(browser.Telemetry) } return nil @@ -852,9 +872,17 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { tableData = append(tableData, []string{"Proxy", proxy}) } tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}) + if vaults := formatVaultReferences(browser.Vaults); vaults != "" { + tableData = append(tableData, []string{"Vaults", vaults}) + } if !browser.DeletedAt.IsZero() { tableData = append(tableData, []string{"Deleted At", util.FormatLocal(browser.DeletedAt)}) } + // Only populated for deleted sessions, where "pending" means the usage figures + // above are not yet the final billed ones. + if browser.UsageStatus != "" { + tableData = append(tableData, []string{"Usage Status", string(browser.UsageStatus)}) + } PrintTableNoPad(tableData, true) return nil @@ -954,8 +982,8 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } // Validate that at least one update option is provided - if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && !hasNameChange && !hasTagsChange { - return fmt.Errorf("must specify at least one of: --proxy-id, --proxy-name, --proxy-mode, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, --viewport, --telemetry, --name, --clear-name, --tag, or --clear-tags") + if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && in.TelemetryCdpExclude == "" && !hasNameChange && !hasTagsChange { + return fmt.Errorf("must specify at least one of: --proxy-id, --proxy-name, --proxy-mode, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, --viewport, --telemetry, --telemetry-cdp-exclude, --name, --clear-name, --tag, or --clear-tags") } params := kernel.BrowserUpdateParams{} @@ -998,8 +1026,8 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } // Handle telemetry changes - if in.Telemetry != "" { - t, err := buildUpdateTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { + t, err := buildUpdateTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) if err != nil { return err } @@ -1049,7 +1077,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { if hasProfileChange { pterm.Info.Printf("Profile save changes: %t\n", browser.ProfileSaveChanges) } - if in.Telemetry != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { printTelemetrySummary(browser.Telemetry) } return nil @@ -2695,6 +2723,7 @@ func init() { browsersUpdateCmd.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") browsersUpdateCmd.Flags().Bool("force", false, "Force viewport resize even when a live view or recording/replay is active") browsersUpdateCmd.Flags().String("telemetry", "", "Update telemetry: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection)") + browsersUpdateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") browsersUpdateCmd.Flags().String("name", "", "Set a new unique name for the browser session (mutually exclusive with --clear-name)") browsersUpdateCmd.Flags().Bool("clear-name", false, "Clear the browser session name") browsersUpdateCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE (repeatable; up to 50 pairs). Replaces the entire tag set; mutually exclusive with --clear-tags") @@ -2892,7 +2921,7 @@ func init() { // computer press-key computerPressKey := &cobra.Command{Use: "press-key ", Short: "Press one or more keys", Args: cobra.ExactArgs(1), RunE: runBrowsersComputerPressKey} - computerPressKey.Flags().StringSlice("key", []string{}, "One X11 keysym or chord per value, e.g. Return, Ctrl+t, or Ctrl+minus (repeatable)") + computerPressKey.Flags().StringSlice("key", []string{}, "One X11 keysym or chord per value, e.g. Return, Ctrl+t, or Ctrl+minus. Use X11 names for punctuation in chords; a literal hyphen is accepted as an alias, so Ctrl+- is normalized to Ctrl+minus (repeatable)") _ = computerPressKey.MarkFlagRequired("key") computerPressKey.Flags().Int64("duration", 0, "Duration to hold keys down in ms (0=tap)") computerPressKey.Flags().StringSlice("hold-key", []string{}, "Modifier keys to hold (repeatable)") @@ -2978,6 +3007,7 @@ func init() { 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)") + browsersCreateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") browsersCreateCmd.Flags().String("telemetry-export-otlp", "", "Export captured telemetry over OTLP to one of the org's configured destinations, by ID or name; --telemetry-export-otlp=off disables export. Implies --telemetry=all when --telemetry is not set, since export requires capture") browsersCreateCmd.Flags().String("name", "", "Optional unique name for the browser session (used to find it later; can be changed with 'browsers update --name')") browsersCreateCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the session (repeatable; up to 50 pairs)") @@ -3110,6 +3140,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { poolID, _ := cmd.Flags().GetString("pool-id") poolName, _ := cmd.Flags().GetString("pool-name") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") name, _ := cmd.Flags().GetString("name") tags, _ := tagsFromFlag(cmd, "tag") @@ -3185,7 +3216,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("timeout") && timeout > 0 { acquireTimeout = int64(timeout) } - acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry, startURL) + acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry, telemetryCdpExclude, startURL) if err != nil { return err } @@ -3227,32 +3258,33 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { } in := BrowsersCreateInput{ - TimeoutSeconds: timeout, - Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, - Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, - GPU: BoolFlag{Set: cmd.Flags().Changed("gpu"), Value: gpuVal}, - Memory: memory, - InvocationID: invocationID, - Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kioskVal}, - ProfileID: profileID, - ProfileName: profileName, - ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, - ProxyID: proxyID, - ProxyName: proxyName, - ProxyMode: proxyMode, - Region: region, - PrivateHosts: privateHosts, - StartURL: startURL, - Extensions: extensions, - Vaults: vaults, - Viewport: viewport, - Telemetry: telemetry, - TelemetryExport: telemetryExport, - ChromePolicy: chromePolicy, - ChromePolicyFile: chromePolicyFile, - Name: name, - Tags: tags, - Output: output, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, + GPU: BoolFlag{Set: cmd.Flags().Changed("gpu"), Value: gpuVal}, + Memory: memory, + InvocationID: invocationID, + Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kioskVal}, + ProfileID: profileID, + ProfileName: profileName, + ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, + ProxyID: proxyID, + ProxyName: proxyName, + ProxyMode: proxyMode, + Region: region, + PrivateHosts: privateHosts, + StartURL: startURL, + Extensions: extensions, + Vaults: vaults, + Viewport: viewport, + Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, + TelemetryExport: telemetryExport, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, + Name: name, + Tags: tags, + Output: output, } svc := client.Browsers @@ -3314,6 +3346,7 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { viewport, _ := cmd.Flags().GetString("viewport") force, _ := cmd.Flags().GetBool("force") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") name, _ := cmd.Flags().GetString("name") clearName, _ := cmd.Flags().GetBool("clear-name") tags, tagsProvided := tagsFromFlag(cmd, "tag") @@ -3334,6 +3367,7 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { Viewport: viewport, Force: force, Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, Name: name, SetName: cmd.Flags().Changed("name"), ClearName: clearName, diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 4a51bccd..2d94f99f 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -83,6 +83,8 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig p.System = on() case "screenshot": p.Screenshot = on() + case "platform": + p.Platform = on() case "captcha": p.Captcha = on() default: @@ -92,20 +94,112 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig return p, nil } -// resolveTelemetryFlag interprets a --telemetry flag value shared by every browser -// and browser-pool command: "all" enables the default set, "off" disables capture, -// and a comma-separated list opts into exactly those categories. It returns the -// resolved (enabled, browser) pair so each endpoint can assemble its own param type. -func resolveTelemetryFlag(s string) (param.Opt[bool], kernel.BrowserTelemetryCategoriesConfigParam, error) { +// cdpCommandMethods are the browser-control commands the CDP proxy reports as +// cdp_command events, and so the values --telemetry-cdp-exclude accepts. +var cdpCommandMethods = []string{ + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent", + "Input.insertText", + "Input.imeSetComposition", + "Input.dispatchTouchEvent", + "Input.dispatchDragEvent", + "Input.cancelDragging", + "Input.emulateTouchFromMouseEvent", + "Input.synthesizePinchGesture", + "Input.synthesizeScrollGesture", + "Input.synthesizeTapGesture", + "DOM.setFileInputFiles", + "DOM.focus", + "DOM.scrollIntoViewIfNeeded", + "Page.bringToFront", + "Page.captureScreenshot", + "Page.captureSnapshot", + "Page.handleJavaScriptDialog", + "Page.navigate", + "Page.navigateToHistoryEntry", + "Page.reload", + "Page.printToPDF", + "Page.startScreencast", + "Page.stopScreencast", + "Page.stopLoading", + "Page.close", + "Page.setWebLifecycleState", + "Target.activateTarget", + "Target.closeTarget", + "Target.createTarget", + "Target.createBrowserContext", + "Target.disposeBrowserContext", + "Target.openDevTools", + "Browser.cancelDownload", + "Browser.close", + "Browser.setWindowBounds", + "Browser.setContentsSize", + "Autofill.trigger", +} + +// telemetryCdpExcludeNone is the --telemetry-cdp-exclude value that clears the +// exclusion list rather than naming methods to drop. +const telemetryCdpExcludeNone = "none" + +// parseTelemetryCdpExcludedMethods parses a --telemetry-cdp-exclude value into the +// exclusion list carried by the control category. "none" resolves to an empty list, +// which tells the API to report every supported method again. Method names are +// matched case-insensitively and returned in their canonical CDP spelling. +func parseTelemetryCdpExcludedMethods(s string) ([]kernel.BrowserCdpCommandMethod, error) { + methods := []kernel.BrowserCdpCommandMethod{} + if strings.TrimSpace(s) == telemetryCdpExcludeNone { + return methods, nil + } + for _, part := range strings.Split(s, ",") { + name := strings.TrimSpace(part) + if name == "" { + continue + } + i := slices.IndexFunc(cdpCommandMethods, func(m string) bool { return strings.EqualFold(m, name) }) + if i < 0 { + return nil, fmt.Errorf("unknown CDP method %q: must be one of %s, or %q to clear the exclusion list", name, strings.Join(cdpCommandMethods, ", "), telemetryCdpExcludeNone) + } + methods = append(methods, kernel.BrowserCdpCommandMethod(cdpCommandMethods[i])) + } + return methods, nil +} + +// resolveTelemetryFlag interprets the --telemetry and --telemetry-cdp-exclude flag +// values shared by every browser and browser-pool command: "all" enables the default +// set, "off" disables capture, and a comma-separated list opts into exactly those +// categories. Excluded CDP methods are merged into the control category independently +// of the selection, so they survive a later update that only names categories. It +// returns the resolved (enabled, browser) pair so each endpoint can assemble its own +// param type. +func resolveTelemetryFlag(s, cdpExclude string) (param.Opt[bool], kernel.BrowserTelemetryCategoriesConfigParam, error) { + var enabled param.Opt[bool] + var p kernel.BrowserTelemetryCategoriesConfigParam switch s { case "all": - return kernel.Opt(true), kernel.BrowserTelemetryCategoriesConfigParam{}, nil + enabled = kernel.Opt(true) case "off": - return kernel.Opt(false), kernel.BrowserTelemetryCategoriesConfigParam{}, nil + enabled = kernel.Opt(false) default: - p, err := parseTelemetryCategories(s) - return param.Opt[bool]{}, p, err + var err error + if p, err = parseTelemetryCategories(s); err != nil { + return enabled, p, err + } + } + if cdpExclude == "" { + return enabled, p, nil } + // Exclusion is a control-telemetry setting, so it has no meaning in a request + // that turns capture off. Error messages never lead with a flag token — the + // error style title-cases the first word. + if s == "off" { + return enabled, p, fmt.Errorf("cannot combine --telemetry=off with --telemetry-cdp-exclude: excluding CDP methods only applies while control telemetry is captured") + } + methods, err := parseTelemetryCdpExcludedMethods(cdpExclude) + if err != nil { + return enabled, p, err + } + p.Control.Cdp.ExcludedMethods = methods + return enabled, p, nil } // telemetryExportOff is the --telemetry-export-otlp value that turns export off @@ -167,10 +261,10 @@ func validateTelemetryExportCombo(telemetry, id, name string, canImply bool) err return nil } -// buildNewTelemetryParam converts --telemetry and --telemetry-export-otlp flag -// values to the create API param. -func buildNewTelemetryParam(s, export string) (kernel.BrowserNewParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) +// buildNewTelemetryParam converts --telemetry, --telemetry-cdp-exclude and +// --telemetry-export-otlp flag values to the create API param. +func buildNewTelemetryParam(s, cdpExclude, export string) (kernel.BrowserNewParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) p := kernel.BrowserNewParamsTelemetry{Enabled: enabled, Browser: browser} if err != nil || export == "" { return p, err @@ -207,26 +301,37 @@ func optIfSet(s string) param.Opt[string] { return kernel.Opt(s) } -// buildUpdateTelemetryParam converts a --telemetry flag value to the update API param. -func buildUpdateTelemetryParam(s string) (kernel.BrowserUpdateParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) +// buildUpdateTelemetryParam converts --telemetry and --telemetry-cdp-exclude flag +// values to the update API param. +func buildUpdateTelemetryParam(s, cdpExclude string) (kernel.BrowserUpdateParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) return kernel.BrowserUpdateParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// buildManagedAuthTelemetryParam converts --telemetry and --telemetry-export-otlp -// flag values to the browser telemetry config carried by an auth connection's -// browser settings, shared by create, update, and login. +// buildManagedAuthTelemetryParam converts --telemetry, --telemetry-cdp-exclude and +// --telemetry-export-otlp flag values to the browser telemetry config carried by an +// auth connection's browser settings, shared by create, update, and login. // // canImply is true only on create, where there is no stored selection to clobber // and capture can safely be turned on for the user so a destination works on its // own. On update and login it is false: enabling capture there would replace the // connection's current category selection rather than merge onto it. -func buildManagedAuthTelemetryParam(s, export string, canImply bool) (kernel.ManagedAuthBrowserConfigTelemetryParam, error) { - enabled, browser, err := resolveTelemetryFlag(s) +func buildManagedAuthTelemetryParam(s, cdpExclude, export string, canImply bool) (kernel.ManagedAuthBrowserConfigTelemetryParam, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) p := kernel.ManagedAuthBrowserConfigTelemetryParam{Enabled: enabled, Browser: browser} - if err != nil || export == "" { + if err != nil { return p, err } + // A connection stores the browser config as sent rather than resolving it, so a + // request carrying only CDP exclusions would drop the connection's category + // selection. On update and login the user has to restate what to capture; on + // create there is nothing to lose. + if cdpExclude != "" && s == "" && !canImply { + return p, fmt.Errorf("setting --telemetry-cdp-exclude also requires --telemetry in the same command: the connection stores its browser config as sent, so exclusions on their own would drop its category selection") + } + if export == "" { + return p, nil + } exEnabled, id, name, err := resolveTelemetryExportFlag(export) if err != nil { return p, err @@ -264,6 +369,9 @@ func formatManagedAuthTelemetry(cfg kernel.ManagedAuthBrowserConfigTelemetry) st } return "disabled" }() + if ex := formatCdpExcludedMethods(cfg.Browser.Control.Cdp.ExcludedMethods); ex != "" { + base += " (excluding CDP methods: " + ex + ")" + } if dest := managedAuthExportDestination(cfg.Export); dest != "" { return base + " (exporting to " + dest + ")" } @@ -287,7 +395,7 @@ func managedAuthExportDestination(ex kernel.ManagedAuthBrowserConfigTelemetryExp // flows automatically whenever a CDP category is captured. var settableCategories = []string{ "console", "network", "page", "interaction", - "control", "connection", "system", "screenshot", "captcha", + "control", "connection", "system", "screenshot", "platform", "captcha", } // streamFilterCategories are the categories accepted by `telemetry stream --categories`. @@ -310,6 +418,7 @@ func telemetryEnabledCategories(cfg kernel.BrowserTelemetryConfig) []string { {"connection", b.Connection.Enabled}, {"system", b.System.Enabled}, {"screenshot", b.Screenshot.Enabled}, + {"platform", b.Platform.Enabled}, {"captcha", b.Captcha.Enabled}, } on := make([]string, 0, len(ordered)) @@ -330,6 +439,9 @@ func printTelemetrySummary(cfg kernel.BrowserTelemetryConfig) { return } pterm.Info.Printf("Telemetry capturing: %s\n", strings.Join(on, ", ")) + if ex := formatCdpExcludedMethods(cfg.Browser.Control.Cdp.ExcludedMethods); ex != "" { + pterm.Info.Printf("Telemetry excluding CDP methods: %s\n", ex) + } if cfg.Export.Otlp.Enabled { // The response reports the resolved destination by ID even when the request // selected it by name. @@ -341,6 +453,19 @@ func printTelemetrySummary(cfg kernel.BrowserTelemetryConfig) { } } +// formatCdpExcludedMethods renders the CDP methods left out of control +// telemetry's cdp_command stream, or "" when every supported method is reported. +func formatCdpExcludedMethods(methods []kernel.BrowserCdpCommandMethod) string { + if len(methods) == 0 { + return "" + } + names := make([]string, 0, len(methods)) + for _, m := range methods { + names = append(names, string(m)) + } + return strings.Join(names, ", ") +} + // shouldEmit applies client-side category/type filters to a telemetry event. func shouldEmit(category, eventType string, categories, types []string) bool { if len(categories) > 0 && !slices.Contains(categories, category) { diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index d052a2b0..591c9b88 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -299,7 +299,7 @@ func TestShouldEmit(t *testing.T) { } func TestParseTelemetryCategories_OptInList(t *testing.T) { - p, err := parseTelemetryCategories("network,control,captcha") + p, err := parseTelemetryCategories("network,control,captcha,platform") assert.NoError(t, err) // Listed categories are enabled. @@ -338,21 +338,21 @@ func TestParseTelemetryCategories_WhitespaceTolerance(t *testing.T) { // listed categories enabled (Enabled unset). func TestBuildTelemetryParam_WireEncoding(t *testing.T) { t.Run("all", func(t *testing.T) { - p, err := buildNewTelemetryParam("all", "") + p, err := buildNewTelemetryParam("all", "", "") assert.NoError(t, err) assert.True(t, p.Enabled.Valid()) assert.True(t, p.Enabled.Value) assert.False(t, p.Browser.Network.Enabled.Valid()) }) t.Run("off", func(t *testing.T) { - p, err := buildNewTelemetryParam("off", "") + p, err := buildNewTelemetryParam("off", "", "") assert.NoError(t, err) assert.True(t, p.Enabled.Valid()) assert.False(t, p.Enabled.Value) assert.False(t, p.Browser.Network.Enabled.Valid()) }) t.Run("opt-in list sets only Browser", func(t *testing.T) { - p, err := buildNewTelemetryParam("network,control", "") + p, err := buildNewTelemetryParam("network,control", "", "") assert.NoError(t, err) assert.False(t, p.Enabled.Valid(), "Enabled must be unset for an opt-in selection") assert.True(t, p.Browser.Network.Enabled.Valid()) @@ -368,7 +368,7 @@ func TestBuildTelemetryParam_WireEncoding(t *testing.T) { // enabled=false combined with one. func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { t.Run("destination by CUID sets id", func(t *testing.T) { - p, err := buildNewTelemetryParam("", "abcdefghijklmnopqrstuvwx") + p, err := buildNewTelemetryParam("", "", "abcdefghijklmnopqrstuvwx") assert.NoError(t, err) otlp := p.Export.Otlp assert.True(t, otlp.Destination.ID.Valid()) @@ -377,7 +377,7 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { assert.False(t, otlp.Enabled.Valid(), "a destination implies enabled server-side") }) t.Run("destination by name sets name", func(t *testing.T) { - p, err := buildNewTelemetryParam("", "my-collector") + p, err := buildNewTelemetryParam("", "", "my-collector") assert.NoError(t, err) otlp := p.Export.Otlp assert.True(t, otlp.Destination.Name.Valid()) @@ -385,20 +385,20 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { assert.False(t, otlp.Destination.ID.Valid(), "id must be unset when name is sent") }) t.Run("destination implies capture on create", func(t *testing.T) { - p, err := buildNewTelemetryParam("", "my-collector") + p, err := buildNewTelemetryParam("", "", "my-collector") assert.NoError(t, err) assert.True(t, p.Enabled.Valid(), "export requires capture, so create implies it") assert.True(t, p.Enabled.Value) }) t.Run("explicit --telemetry selection is preserved", func(t *testing.T) { - p, err := buildNewTelemetryParam("network,control", "my-collector") + p, err := buildNewTelemetryParam("network,control", "", "my-collector") assert.NoError(t, err) assert.False(t, p.Enabled.Valid(), "an opt-in selection must not be overridden") assert.True(t, p.Browser.Network.Enabled.Value) assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) }) t.Run("off disables export without a destination", func(t *testing.T) { - p, err := buildNewTelemetryParam("all", "off") + p, err := buildNewTelemetryParam("all", "", "off") assert.NoError(t, err) otlp := p.Export.Otlp assert.True(t, otlp.Enabled.Valid()) @@ -407,7 +407,7 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { assert.False(t, otlp.Destination.Name.Valid()) }) t.Run("off does not imply capture", func(t *testing.T) { - p, err := buildNewTelemetryParam("", "off") + p, err := buildNewTelemetryParam("", "", "off") assert.NoError(t, err) assert.False(t, p.Enabled.Valid(), "disabling export must not turn capture on") }) @@ -416,44 +416,44 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { // same request. Update and login refuse to supply one: doing so would replace // the connection's current category selection. t.Run("update requires an explicit --telemetry alongside a destination", func(t *testing.T) { - _, err := buildManagedAuthTelemetryParam("", "my-collector", false) + _, err := buildManagedAuthTelemetryParam("", "", "my-collector", false) assert.Error(t, err) assert.Contains(t, err.Error(), "also requires --telemetry") }) t.Run("login requires an explicit --telemetry alongside a destination", func(t *testing.T) { - _, err := buildManagedAuthTelemetryParam("", "my-collector", false) + _, err := buildManagedAuthTelemetryParam("", "", "my-collector", false) assert.Error(t, err) assert.Contains(t, err.Error(), "also requires --telemetry") }) t.Run("update never implies capture when --telemetry is given", func(t *testing.T) { - p, err := buildManagedAuthTelemetryParam("console", "my-collector", false) + p, err := buildManagedAuthTelemetryParam("console", "", "my-collector", false) assert.NoError(t, err) assert.False(t, p.Enabled.Valid(), "an opt-in selection must not be widened to the default set") assert.True(t, p.Browser.Console.Enabled.Value) assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) }) t.Run("login never implies capture when --telemetry is given", func(t *testing.T) { - p, err := buildManagedAuthTelemetryParam("console", "my-collector", false) + p, err := buildManagedAuthTelemetryParam("console", "", "my-collector", false) assert.NoError(t, err) assert.False(t, p.Enabled.Valid()) assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) }) t.Run("update and login allow export=off without --telemetry", func(t *testing.T) { - u, err := buildManagedAuthTelemetryParam("", "off", false) + u, err := buildManagedAuthTelemetryParam("", "", "off", false) assert.NoError(t, err) assert.False(t, u.Export.Otlp.Enabled.Value) - l, err := buildManagedAuthTelemetryParam("", "off", false) + l, err := buildManagedAuthTelemetryParam("", "", "off", false) assert.NoError(t, err) assert.False(t, l.Export.Otlp.Enabled.Value) }) t.Run("auth connection create implies capture", func(t *testing.T) { - p, err := buildManagedAuthTelemetryParam("", "my-collector", true) + p, err := buildManagedAuthTelemetryParam("", "", "my-collector", true) assert.NoError(t, err) assert.True(t, p.Enabled.Valid()) assert.True(t, p.Enabled.Value) }) t.Run("invalid category still errors with export set", func(t *testing.T) { - _, err := buildNewTelemetryParam("bogus", "my-collector") + _, err := buildNewTelemetryParam("bogus", "", "my-collector") assert.Error(t, err) }) t.Run("telemetry=off with a destination is rejected", func(t *testing.T) { @@ -461,9 +461,9 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { name string fn func() error }{ - {"create", func() error { _, e := buildNewTelemetryParam("off", "my-collector"); return e }}, - {"auth create", func() error { _, e := buildManagedAuthTelemetryParam("off", "my-collector", true); return e }}, - {"auth update/login", func() error { _, e := buildManagedAuthTelemetryParam("off", "my-collector", false); return e }}, + {"create", func() error { _, e := buildNewTelemetryParam("off", "", "my-collector"); return e }}, + {"auth create", func() error { _, e := buildManagedAuthTelemetryParam("off", "", "my-collector", true); return e }}, + {"auth update/login", func() error { _, e := buildManagedAuthTelemetryParam("off", "", "my-collector", false); return e }}, } { err := tc.fn() assert.Error(t, err, tc.name) @@ -471,13 +471,13 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { } }) t.Run("telemetry=off with export=off is allowed", func(t *testing.T) { - p, err := buildNewTelemetryParam("off", "off") + p, err := buildNewTelemetryParam("off", "", "off") assert.NoError(t, err) assert.False(t, p.Enabled.Value) assert.False(t, p.Export.Otlp.Enabled.Value) }) t.Run("empty export value errors", func(t *testing.T) { - _, err := buildNewTelemetryParam("all", " ") + _, err := buildNewTelemetryParam("all", "", " ") assert.Error(t, err) }) } @@ -720,3 +720,82 @@ func TestTelemetryEvents_FullScanIgnoresOffsetUsesSince(t *testing.T) { assert.Equal(t, "5m", gotQuery.Since.Value, "--all walks the window from --since") _ = buf } + +func TestParseTelemetryCategories_Platform(t *testing.T) { + p, err := parseTelemetryCategories("platform") + + assert.NoError(t, err) + assert.True(t, p.Platform.Enabled.Valid()) + assert.True(t, p.Platform.Enabled.Value) + // platform is opt-in only, so it must be offered by the flag's error message too. + _, err = parseTelemetryCategories("bogus") + assert.ErrorContains(t, err, "platform") +} + +func TestTelemetryEnabledCategories_Platform(t *testing.T) { + cfg := kernel.BrowserTelemetryConfig{Browser: kernel.BrowserTelemetryCategoriesConfig{}} + cfg.Browser.Platform.Enabled = true + + assert.Equal(t, []string{"platform"}, telemetryEnabledCategories(cfg)) +} + +func TestParseTelemetryCdpExcludedMethods(t *testing.T) { + t.Run("canonicalizes and trims", func(t *testing.T) { + got, err := parseTelemetryCdpExcludedMethods(" input.dispatchmouseevent , Page.captureScreenshot ") + assert.NoError(t, err) + assert.Equal(t, []kernel.BrowserCdpCommandMethod{ + kernel.BrowserCdpCommandMethodInputDispatchMouseEvent, + kernel.BrowserCdpCommandMethodPageCaptureScreenshot, + }, got) + }) + t.Run("none clears the list", func(t *testing.T) { + got, err := parseTelemetryCdpExcludedMethods("none") + assert.NoError(t, err) + assert.NotNil(t, got, "an empty list must still be sent, so the API reports every method again") + assert.Empty(t, got) + }) + t.Run("rejects unknown methods", func(t *testing.T) { + _, err := parseTelemetryCdpExcludedMethods("Page.doesNotExist") + assert.ErrorContains(t, err, "unknown CDP method") + }) +} + +func TestBuildTelemetryParam_CdpExclude(t *testing.T) { + t.Run("merges into control without enabling it", func(t *testing.T) { + p, err := buildNewTelemetryParam("", "Input.dispatchMouseEvent", "") + assert.NoError(t, err) + assert.False(t, p.Enabled.Valid()) + assert.False(t, p.Browser.Control.Enabled.Valid(), "exclusions must not silently flip the control category") + assert.Equal(t, []kernel.BrowserCdpCommandMethod{ + kernel.BrowserCdpCommandMethodInputDispatchMouseEvent, + }, p.Browser.Control.Cdp.ExcludedMethods) + }) + t.Run("combines with a category selection", func(t *testing.T) { + p, err := buildUpdateTelemetryParam("control,network", "Page.captureScreenshot") + assert.NoError(t, err) + assert.True(t, p.Browser.Control.Enabled.Value) + assert.Equal(t, []kernel.BrowserCdpCommandMethod{ + kernel.BrowserCdpCommandMethodPageCaptureScreenshot, + }, p.Browser.Control.Cdp.ExcludedMethods) + }) + t.Run("rejects combining with telemetry off", func(t *testing.T) { + _, err := buildNewTelemetryParam("off", "Page.captureScreenshot", "") + assert.ErrorContains(t, err, "cannot combine --telemetry=off with --telemetry-cdp-exclude") + }) +} + +func TestBuildManagedAuthTelemetryParam_CdpExcludeNeedsCategories(t *testing.T) { + // The connection stores the config verbatim, so exclusions on their own would + // replace its category selection — allowed on create, rejected on update/login. + _, err := buildManagedAuthTelemetryParam("", "Page.navigate", "", false) + assert.ErrorContains(t, err, "also requires --telemetry in the same command") + + p, err := buildManagedAuthTelemetryParam("", "Page.navigate", "", true) + assert.NoError(t, err) + assert.Equal(t, []kernel.BrowserCdpCommandMethod{ + kernel.BrowserCdpCommandMethodPageNavigate, + }, p.Browser.Control.Cdp.ExcludedMethods) + + _, err = buildManagedAuthTelemetryParam("control", "Page.navigate", "", false) + assert.NoError(t, err) +} diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index a039f2fd..afa36b70 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -518,6 +518,47 @@ func TestBrowsersCreate_WithNameAndTags(t *testing.T) { assert.Contains(t, out, "env=staging, team=backend") } +func TestBrowsersCreate_WithVaults(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "sess-vaults"}, nil + }, + } + + b := BrowsersCmd{browsers: fake} + err := b.Create(context.Background(), BrowsersCreateInput{ + // A Kernel-shaped identifier is sent as an ID, anything else as a name. + Vaults: []string{"gtw36zdwv9as2etqetxpnspl", "payments"}, + }) + assert.NoError(t, err) + + require.Len(t, captured.Vaults, 2) + assert.Equal(t, "gtw36zdwv9as2etqetxpnspl", captured.Vaults[0].ID.Value) + assert.False(t, captured.Vaults[0].Name.Valid()) + assert.Equal(t, "payments", captured.Vaults[1].Name.Value) + assert.False(t, captured.Vaults[1].ID.Valid()) +} + +func TestBrowsersCreate_WithoutVaults(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "sess-no-vaults"}, nil + }, + } + + b := BrowsersCmd{browsers: fake} + assert.NoError(t, b.Create(context.Background(), BrowsersCreateInput{})) + assert.Empty(t, captured.Vaults) +} + func TestBrowsersCreate_WithPrivateHosts(t *testing.T) { setupStdoutCapture(t) diff --git a/cmd/invoke.go b/cmd/invoke.go index ea89f628..738c9d04 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -185,7 +185,7 @@ func runInvoke(cmd *cobra.Command, args []string) error { return nil } succeeded := resp.Status == kernel.InvocationNewResponseStatusSucceeded - printResult(succeeded, resp.Output) + printResult(succeeded, resp.Output, resp.StatusReason) duration := time.Since(startTime) if succeeded { @@ -268,7 +268,7 @@ func runInvoke(cmd *cobra.Command, args []string) error { if status == string(kernel.InvocationGetResponseStatusSucceeded) || status == string(kernel.InvocationGetResponseStatusFailed) { // Finished – print output and exit accordingly succeeded := status == string(kernel.InvocationGetResponseStatusSucceeded) - printResult(succeeded, stateEv.Invocation.Output) + printResult(succeeded, stateEv.Invocation.Output, stateEv.Invocation.StatusReason) duration := time.Since(startTime) if succeeded { @@ -313,14 +313,20 @@ func handleSdkError(err error) error { return nil } -func printResult(success bool, output string) { +func printResult(success bool, output, statusReason string) { output = formatJSONValue(output) // use pterm.Success if succeeded, pterm.Error if failed if success { pterm.Success.Printf("Result:\n%s\n", output) - } else { - pterm.Error.Printf("Result:\n%s\n", output) + return + } + // The API populates status_reason with a customer-safe summary of the failure + // whenever an invocation fails; show it above the raw output, which may be + // plain text rather than JSON. + if statusReason != "" { + pterm.Error.Printf("Reason: %s\n", statusReason) } + pterm.Error.Printf("Result:\n%s\n", output) } func formatJSONValue(value string) string { diff --git a/cmd/invoke_test.go b/cmd/invoke_test.go new file mode 100644 index 00000000..8eb86ce1 --- /dev/null +++ b/cmd/invoke_test.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPrintResultShowsStatusReasonOnFailure(t *testing.T) { + buf := capturePtermOutput(t) + + printResult(false, `{"error":"domain is required"}`, "Invocation failed. See output for details.") + + out := buf.String() + require.Contains(t, out, "Reason: Invocation failed. See output for details.") + require.Contains(t, out, `"error": "domain is required"`) + require.Less(t, strings.Index(out, "Reason:"), strings.Index(out, "Result:"), + "the customer-safe reason should precede the raw output") +} + +func TestPrintResultOmitsStatusReasonWhenEmpty(t *testing.T) { + buf := capturePtermOutput(t) + + printResult(false, "boom", "") + + out := buf.String() + require.NotContains(t, out, "Reason:") + require.Contains(t, out, "boom") +} + +func TestPrintResultIgnoresStatusReasonOnSuccess(t *testing.T) { + buf := capturePtermOutput(t) + + // status_reason is omitted for non-failed invocations, but a stale value must + // never be presented as a failure summary. + printResult(true, `{"ok":true}`, "should not appear") + + out := buf.String() + require.NotContains(t, out, "should not appear") + require.Contains(t, out, `"ok": true`) +} diff --git a/cmd/org.go b/cmd/org.go index e8ba0d52..2bce469d 100644 --- a/cmd/org.go +++ b/cmd/org.go @@ -149,6 +149,15 @@ func renderOrgLimits(limits *kernel.OrgLimits) { rows = append(rows, []string{"Min Health Check Interval", fmt.Sprintf("%ds", limits.MinHealthCheckIntervalSeconds)}) } + // Vault limits are plan-derived and, like the managed auth rows above, only + // returned by newer API versions. A null max_vaults means unlimited. + if orgLimitFieldPresent(limits.JSON.MaxVaults) { + rows = append(rows, []string{"Max Vaults", formatProjectLimitValue(limits.MaxVaults, limits.JSON.MaxVaults)}) + } + if orgLimitFieldPresent(limits.JSON.VaultsUsed) { + rows = append(rows, []string{"Vaults Used", fmt.Sprintf("%d", limits.VaultsUsed)}) + } + PrintTableNoPad(rows, true) } @@ -199,6 +208,7 @@ func orgEntitlementRows(entitlements *kernel.OrgEntitlements) pterm.TableData { {"Feature", "Health check maximum (seconds)", fmt.Sprintf("%d", features.ManagedAuth.HealthCheckIntervalMaxSeconds)}, {"Feature", "Credentials", fmt.Sprintf("%t", features.Credentials.Enabled)}, {"Feature", "Credential providers", fmt.Sprintf("%t", features.CredentialProviders.Enabled)}, + {"Feature", "Vaults", fmt.Sprintf("%t", features.Vaults.Enabled)}, {"Feature", "Managed proxies", fmt.Sprintf("%t", features.ManagedProxies.Enabled)}, {"Feature", "Custom proxies", fmt.Sprintf("%t", features.CustomProxies.Enabled)}, {"Feature", "Proxy bypass hosts", fmt.Sprintf("%t", features.ProxyBypassHosts.Enabled)}, @@ -206,6 +216,7 @@ func orgEntitlementRows(entitlements *kernel.OrgEntitlements) pterm.TableData { {"Limit", "Max concurrent browsers", fmt.Sprintf("%d", limits.MaxConcurrentBrowsers)}, {"Limit", "Max concurrent invocations", fmt.Sprintf("%d", limits.MaxConcurrentInvocations)}, {"Limit", "Default max concurrent invocations per app", fmt.Sprintf("%d", limits.DefaultMaxConcurrentInvocationsPerApp)}, + {"Limit", "Max vaults", formatEntitlementLimitValue(limits.MaxVaults, limits.JSON.MaxVaults)}, } } @@ -255,7 +266,7 @@ var orgLimitsCmd = &cobra.Command{ var orgLimitsGetCmd = &cobra.Command{ Use: "get", Short: "Get organization limits", - Long: "Show the organization's effective limits: the concurrency limit, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth limits along with current auth connection usage.", + Long: "Show the organization's effective limits: the concurrency limit, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth and vault limits along with current auth connection and vault usage.", Args: cobra.NoArgs, RunE: runOrgLimitsGet, } diff --git a/cmd/org_test.go b/cmd/org_test.go index e55713fd..9118f619 100644 --- a/cmd/org_test.go +++ b/cmd/org_test.go @@ -45,12 +45,13 @@ func testOrgEntitlementsWithUnlimitedValues(t *testing.T) *kernel.OrgEntitlement "managed_auth":{"enabled":true,"max_connections":null,"health_check_interval_min_seconds":1200,"health_check_interval_default_seconds":3600,"health_check_interval_max_seconds":86400}, "credentials":{"enabled":true}, "credential_providers":{"enabled":true}, + "vaults":{"enabled":true}, "managed_proxies":{"enabled":true}, "custom_proxies":{"enabled":true}, "proxy_bypass_hosts":{"enabled":true}, "gpu":{"enabled":false} }, - "limits":{"max_concurrent_browsers":150,"max_concurrent_invocations":150,"default_max_concurrent_invocations_per_app":20} + "limits":{"max_concurrent_browsers":150,"max_concurrent_invocations":150,"default_max_concurrent_invocations_per_app":20,"max_vaults":null} }`), &entitlements) assert.NoError(t, err) return &entitlements @@ -69,12 +70,13 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { "managed_auth":{"enabled":false,"max_connections":29,"health_check_interval_min_seconds":31,"health_check_interval_default_seconds":37,"health_check_interval_max_seconds":41}, "credentials":{"enabled":true}, "credential_providers":{"enabled":false}, + "vaults":{"enabled":true}, "managed_proxies":{"enabled":true}, "custom_proxies":{"enabled":false}, "proxy_bypass_hosts":{"enabled":true}, "gpu":{"enabled":false} }, - "limits":{"max_concurrent_browsers":43,"max_concurrent_invocations":47,"default_max_concurrent_invocations_per_app":53} + "limits":{"max_concurrent_browsers":43,"max_concurrent_invocations":47,"default_max_concurrent_invocations_per_app":53,"max_vaults":59} }`), &entitlements) assert.NoError(t, err) @@ -99,6 +101,7 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { {"Feature", "Health check maximum (seconds)", "41"}, {"Feature", "Credentials", "true"}, {"Feature", "Credential providers", "false"}, + {"Feature", "Vaults", "true"}, {"Feature", "Managed proxies", "true"}, {"Feature", "Custom proxies", "false"}, {"Feature", "Proxy bypass hosts", "true"}, @@ -106,6 +109,7 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { {"Limit", "Max concurrent browsers", "43"}, {"Limit", "Max concurrent invocations", "47"}, {"Limit", "Default max concurrent invocations per app", "53"}, + {"Limit", "Max vaults", "59"}, }, orgEntitlementRows(&entitlements)) } @@ -123,6 +127,7 @@ func TestOrgEntitlementRows_BooleanFieldProvenance(t *testing.T) { {"Managed auth", func(e *kernel.OrgEntitlements) { e.Features.ManagedAuth.Enabled = true }}, {"Credentials", func(e *kernel.OrgEntitlements) { e.Features.Credentials.Enabled = true }}, {"Credential providers", func(e *kernel.OrgEntitlements) { e.Features.CredentialProviders.Enabled = true }}, + {"Vaults", func(e *kernel.OrgEntitlements) { e.Features.Vaults.Enabled = true }}, {"Managed proxies", func(e *kernel.OrgEntitlements) { e.Features.ManagedProxies.Enabled = true }}, {"Custom proxies", func(e *kernel.OrgEntitlements) { e.Features.CustomProxies.Enabled = true }}, {"Proxy bypass hosts", func(e *kernel.OrgEntitlements) { e.Features.ProxyBypassHosts.Enabled = true }}, @@ -347,6 +352,57 @@ func TestOrgLimitsGet_OmitsManagedAuthRowsWhenAbsent(t *testing.T) { assert.NotContains(t, out, "Min Health Check Interval") } +func TestOrgLimitsGet_RendersVaultLimits(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{ + MaxConcurrentSessions: 100, + MaxVaults: 3, + VaultsUsed: 2, + } + limits.JSON.MaxVaults = respjson.NewField("3") + limits.JSON.VaultsUsed = respjson.NewField("2") + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Max Vaults") + assert.Contains(t, out, "Vaults Used") +} + +func TestOrgLimitsGet_NullMaxVaultsShownAsUnlimited(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{MaxConcurrentSessions: 100, DefaultProjectMaxConcurrentSessions: 25} + limits.JSON.DefaultProjectMaxConcurrentSessions = respjson.NewField("25") + // Null (not omitted) means a paid plan or active trial: no vault cap. + limits.JSON.MaxVaults = respjson.NewField(respjson.Null) + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Max Vaults") + assert.Contains(t, out, "unlimited") +} + +func TestOrgLimitsGet_OmitsVaultRowsWhenAbsent(t *testing.T) { + buf := capturePtermOutput(t) + c := OrgCmd{limits: &FakeOrgLimitsService{}} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.NotContains(t, out, "Max Vaults") + assert.NotContains(t, out, "Vaults Used") +} + func TestOrgLimitsGet_SurfacesAPIError(t *testing.T) { capturePtermOutput(t) fake := &FakeOrgLimitsService{ diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index 4cd072ef..12827f70 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -37,10 +37,14 @@ var proxiesCreateCmd = &cobra.Command{ Proxy types (from best to worst for bot detection): - mobile: Mobile carrier proxies - residential: Residential IP proxies -- isp: ISP proxies +- isp: ISP proxies (supported countries: US, GB, FR, DE, SG) - datacenter: Datacenter proxies - custom: Your own proxy server +Country targeting: +- datacenter and isp default to US when --country is omitted +- residential and mobile use the global pool without country targeting when --country is omitted + Examples: # Create a datacenter proxy kernel proxies create --type datacenter --country US --name "US Datacenter" @@ -51,6 +55,9 @@ Examples: # Create a custom TLS-terminating proxy with a CA bundle kernel proxies create --type custom --host proxy.example.com --port 8080 --ca-bundle ./proxy-ca.pem --name "My TLS Proxy" + # Create an ISP proxy in Germany + kernel proxies create --type isp --country DE --name "DE ISP" + # Create a residential proxy with location kernel proxies create --type residential --country US --city sanfrancisco --state CA --name "SF Residential" @@ -108,7 +115,7 @@ func init() { proxiesCreateCmd.Flags().String("protocol", "https", "Protocol to use for the proxy connection (http|https)") // Location flags (datacenter, isp, residential, mobile) - proxiesCreateCmd.Flags().String("country", "", "ISO 3166 country code or EU") + proxiesCreateCmd.Flags().String("country", "", "ISO 3166 country code or EU (isp proxies support US, GB, FR, DE, SG; datacenter and isp default to US, residential and mobile use the global pool without country targeting)") proxiesCreateCmd.Flags().String("city", "", "City name (no spaces, e.g. sanfrancisco)") proxiesCreateCmd.Flags().String("state", "", "Two-letter state code") proxiesCreateCmd.Flags().String("zip", "", "US ZIP code") diff --git a/cmd/telemetry_destinations.go b/cmd/telemetry_destinations.go index f8b4d417..452c3690 100644 --- a/cmd/telemetry_destinations.go +++ b/cmd/telemetry_destinations.go @@ -146,7 +146,7 @@ func (c TelemetryDestinationsCmd) List(ctx context.Context, in TelemetryDestinat return nil } - rows := pterm.TableData{{"ID", "Name", "Endpoint", "Description", "Headers", "Created At"}} + rows := pterm.TableData{{"ID", "Name", "Endpoint", "Description", "Headers", "Delivery", "Created At"}} for _, d := range items { rows = append(rows, []string{ d.ID, @@ -154,6 +154,7 @@ func (c TelemetryDestinationsCmd) List(ctx context.Context, in TelemetryDestinat d.Endpoint, util.OrDash(d.Description), util.OrDash(formatOtlpDestinationHeaders(d.Headers)), + formatOtlpDestinationDelivery(d), util.FormatLocal(d.CreatedAt), }) } @@ -332,6 +333,20 @@ func formatOtlpDestinationHeaders(headers map[string]string) string { return strings.Join(names, ", ") } +// formatOtlpDestinationDelivery summarizes whether exports are currently +// landing. Only ConsecutiveFailures answers that: LastError and LastErrorAt are +// retained after a later success, so a destination can carry both a recorded +// error and a healthy status. +func formatOtlpDestinationDelivery(d kernel.OtlpDestination) string { + if d.ConsecutiveFailures > 0 { + return fmt.Sprintf("failing (%d consecutive)", d.ConsecutiveFailures) + } + if d.LastExportAt.IsZero() && d.LastErrorAt.IsZero() { + return "no deliveries yet" + } + return "ok" +} + func printOtlpDestinationDetail(d *kernel.OtlpDestination) { rows := pterm.TableData{ {"Property", "Value"}, @@ -340,6 +355,13 @@ func printOtlpDestinationDetail(d *kernel.OtlpDestination) { {"Endpoint", d.Endpoint}, {"Description", util.OrDash(d.Description)}, {"Headers", util.OrDash(formatOtlpDestinationHeaders(d.Headers))}, + {"Delivery", formatOtlpDestinationDelivery(*d)}, + {"Consecutive Failures", fmt.Sprintf("%d", d.ConsecutiveFailures)}, + {"Last Export At", util.FormatLocal(d.LastExportAt)}, + // Kept even once exports recover, so it is labelled as the last recorded + // failure rather than as the destination's current state. + {"Last Error", util.OrDash(d.LastError)}, + {"Last Error At", util.FormatLocal(d.LastErrorAt)}, {"Created At", util.FormatLocal(d.CreatedAt)}, {"Updated At", util.FormatLocal(d.UpdatedAt)}, } @@ -379,8 +401,13 @@ var telemetryDestinationsListCmd = &cobra.Command{ var telemetryDestinationsGetCmd = &cobra.Command{ Use: "get ", Short: "Get an OTLP destination by ID or name", - Args: cobra.ExactArgs(1), - RunE: runTelemetryDestinationsGet, + Long: "Get an OTLP destination, including its delivery health.\n\n" + + "Delivery reads Consecutive Failures: zero means the most recently recorded delivery succeeded. " + + "Last Error and Last Error At describe the last failure Kernel recorded and are kept after a later " + + "success, so they can predate Last Export At and do not by themselves mean export is broken. " + + "Response bodies, endpoint URLs and credentials are never returned in Last Error.", + Args: cobra.ExactArgs(1), + RunE: runTelemetryDestinationsGet, } var telemetryDestinationsCreateCmd = &cobra.Command{ diff --git a/cmd/telemetry_destinations_test.go b/cmd/telemetry_destinations_test.go index 47e41020..73d174ec 100644 --- a/cmd/telemetry_destinations_test.go +++ b/cmd/telemetry_destinations_test.go @@ -281,3 +281,65 @@ func TestFormatOtlpDestinationHeaders(t *testing.T) { assert.Equal(t, "", formatOtlpDestinationHeaders(nil)) assert.Equal(t, "Authorization, X-Api-Key", formatOtlpDestinationHeaders(map[string]string{"X-Api-Key": "", "Authorization": ""})) } + +func TestFormatOtlpDestinationDelivery(t *testing.T) { + exported := time.Unix(1_700_000_000, 0) + failed := time.Unix(1_600_000_000, 0) + + assert.Equal(t, "no deliveries yet", formatOtlpDestinationDelivery(kernel.OtlpDestination{})) + assert.Equal(t, "ok", formatOtlpDestinationDelivery(kernel.OtlpDestination{LastExportAt: exported})) + assert.Equal(t, "failing (3 consecutive)", formatOtlpDestinationDelivery(kernel.OtlpDestination{ + ConsecutiveFailures: 3, + LastExportAt: exported, + LastErrorAt: exported, + })) + // A retained error from before the last success must not read as failing. + assert.Equal(t, "ok", formatOtlpDestinationDelivery(kernel.OtlpDestination{ + LastExportAt: exported, + LastError: "http_401", + LastErrorAt: failed, + })) +} + +func TestTelemetryDestinationsGet_ShowsDeliveryHealth(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeTelemetryDestinationsService{GetFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.OtlpDestination, error) { + return &kernel.OtlpDestination{ + ID: "d1", + Name: "honeycomb", + Endpoint: "https://api.honeycomb.io", + ConsecutiveFailures: 2, + LastError: "http_401", + LastErrorAt: time.Unix(1_700_000_000, 0), + LastExportAt: time.Unix(1_600_000_000, 0), + CreatedAt: time.Unix(0, 0), + UpdatedAt: time.Unix(0, 0), + }, nil + }} + c := TelemetryDestinationsCmd{destinations: fake} + require.NoError(t, c.Get(context.Background(), TelemetryDestinationsGetInput{Identifier: "d1"})) + out := buf.String() + assert.Contains(t, out, "failing (2 consecutive)") + assert.Contains(t, out, "http_401") + assert.Contains(t, out, "Last Export At") +} + +func TestTelemetryDestinationsList_ShowsDeliveryColumn(t *testing.T) { + buf := capturePtermOutput(t) + items := []kernel.OtlpDestination{{ + ID: "d1", + Name: "honeycomb", + Endpoint: "https://api.honeycomb.io", + ConsecutiveFailures: 5, + CreatedAt: time.Unix(0, 0), + UpdatedAt: time.Unix(0, 0), + }} + fake := &FakeTelemetryDestinationsService{ListFunc: func(ctx context.Context, query kernel.TelemetryDestinationListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.OtlpDestination], error) { + return &pagination.OffsetPagination[kernel.OtlpDestination]{Items: items}, nil + }} + c := TelemetryDestinationsCmd{destinations: fake} + require.NoError(t, c.List(context.Background(), TelemetryDestinationsListInput{Page: 1, PerPage: 20})) + out := buf.String() + assert.Contains(t, out, "Delivery") + assert.Contains(t, out, "failing (5 consecutive)") +} diff --git a/cmd/vaults.go b/cmd/vaults.go index 6eb2a6ca..48fad96a 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -14,7 +14,6 @@ import ( "github.com/kernel/cli/pkg/util" kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" - "github.com/kernel/kernel-go-sdk/shared/constant" "github.com/pterm/pterm" ) @@ -144,7 +143,7 @@ func (c VaultsCmd) ListItems(ctx context.Context, vault, output string) error { if err != nil { return err } - rows = append(rows, []string{item.Key, item.Type, item.Spec.Provider, item.State.Status, util.OrDash(actions.RequiredAction)}) + rows = append(rows, []string{item.Key, item.Type, util.OrDash(item.Spec.Provider), item.State.Status, util.OrDash(actions.RequiredAction)}) } PrintTableNoPad(rows, true) return nil @@ -193,7 +192,7 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. var item *kernel.VaultItemUnion var err error if update { - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, Spec: spec}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCardVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyCardVaultItemUpdateRequest{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)) } @@ -203,13 +202,16 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. return c.showItem(item, output, false) } -func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultFillParams, output string, open bool) error { +func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultOperationParams, output string, open bool) error { if strings.TrimSpace(operation) == "" { return fmt.Errorf("operation must not be empty") } - if operation == "fill" && (params == nil || open) { + if operation == "fill" && (params == nil || params.Fill == nil || open) { return fmt.Errorf("fill requires --params and does not support --open") } + if operation == "prepare_checkout" && (params == nil || params.Checkout == nil) { + return fmt.Errorf("prepare_checkout requires --params with browser_id, merchant_origin, and environment") + } item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { if operation == "fill" { @@ -225,7 +227,10 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return fmt.Errorf("invalid vault item operations; operation was not invoked") } if actions.RecoveryRequired { - return fmt.Errorf("recovery_required: reconcile the original operation with the provider or support; do not retry, delete, or replace it") + if actions.Abandonable { + return fmt.Errorf("recovery_required: automatic reuse is blocked; no authorization ID was returned, so delete this card explicitly to abandon the attempt and create a replacement") + } + return fmt.Errorf("recovery_required: reconcile the known authorization ID with the provider or support; do not retry, delete, or replace it") } available := false for _, op := range actions.Operations { @@ -241,15 +246,36 @@ 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" { - return c.fill(ctx, vault, key, params, output) + if err := validateVaultFillForItem(item, params.Fill); err != nil { + return err + } + return c.fill(ctx, vault, key, item.Type, params.Fill, output) + } + body := kernel.VaultItemPerformOperationParams{IDOrName: vault} + switch operation { + case "collect": + body.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: kernel.CollectVaultItemOperationRequestTypeCollect} + case "prepare_checkout": + body.OfPrepareCheckout = &kernel.PrepareCheckoutVaultItemOperationRequestParam{ + Type: kernel.PrepareCheckoutVaultItemOperationRequestTypePrepareCheckout, + Checkout: kernel.VaultCheckoutContextParam{ + BrowserID: params.Checkout.BrowserID, + MerchantOrigin: params.Checkout.MerchantOrigin, + Environment: kernel.VaultCheckoutContextEnvironment(params.Checkout.Environment), + }, + } + default: + // Preserve support for other advertised parameterless operations. + body.OfAuthorize = &kernel.AuthorizeVaultItemOperationRequestParam{Type: kernel.AuthorizeVaultItemOperationRequestType(operation)} } - // 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)) + response, err := c.vaults.Items.PerformOperation(ctx, key, body, option.WithMaxRetries(0)) if err != nil { + if operation == "prepare_checkout" { + return vaultPrepareCheckoutRequestError(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 @@ -304,6 +330,10 @@ func (c VaultsCmd) showItem(item *kernel.VaultItemUnion, output string, open boo return nil } actionURL := actions.ActionURL + if actionURL == "" { + // prepare_checkout returns an approval URL rather than a required action. + actionURL = actions.ApprovalURL + } if actionURL == "" { if output != "json" { pterm.Info.Println("No action URL returned; no browser opened") diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 6da600a0..088af7c4 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -52,7 +52,7 @@ 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. + Long: `Prepare and observe payment credentials and stored logins; vault commands do not submit merchant payments. Optionally select a project with --project or KERNEL_PROJECT. Otherwise, the API resolves the project from your credentials and its defaults. @@ -68,6 +68,12 @@ Vault names, item keys, and project ownership are immutable. aliases are an alternative for explicitly chosen egress-substitution integrations, not a fallback after fill. Inspect items get/events for payment outcomes. +For logins and other non-payment credentials, use credentials create instead of a +wallet and card: declare the fields, supply any known values with --values-file, +and hand the returned collection URL to whoever holds the credential. Attach the +vault with browsers create --vault, then use advertised fill to bind its fields. +Credential items must never hold card numbers, security codes, or expiration dates. + Permitted checkout domains are provider-assigned and displayed when returned; there is no domain-setting API. Never supply card data, OAuth codes, ciphertext, or secrets in shell arguments. @@ -79,6 +85,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d } create := &cobra.Command{Use: "create --name ", Short: "Create or retrieve a vault by immutable name", Args: cobra.NoArgs, PreRunE: vaultPreRun, + Long: "Create or retrieve a vault by immutable name.\nFree organizations can store up to 3 non-deleted vaults across all projects; paid plans and active trials have no vault cap.\nRetrieving an existing vault by name succeeds even at the limit.\nSee kernel org limits get for the current cap and usage.", RunE: func(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") return getVaultsHandler(cmd).Create(cmd.Context(), name, vaultOutput(cmd)) @@ -112,7 +119,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d }} 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 does not mean paid.\nrecovery_required stops waiting and means unresolved, not declined or expired.\nA known authorization ID must be reconciled with the provider or support; do not retry, delete, or replace the payment.\nAn AgentCard checkout that returned no authorization ID may be abandoned by deleting that card explicitly, which is not proof that the payment did not occur.", RunE: func(cmd *cobra.Command, args []string) error { wait, _ := cmd.Flags().GetInt64("wait") expand, _ := cmd.Flags().GetStringSlice("expand") @@ -138,13 +145,32 @@ JSON output preserves returned public fields but omits unknown/opaque provider d 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), -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. +Collect is advertised by ready and pending_collection credential items. It takes no +--params and returns the item with a time-scoped hosted form URL, reusing an active +session or renewing an expired one; --open opens it. Opening the form clears no +values and changes neither readiness nor the item version. Treat the URL as a secret. +Prepare_checkout is advertised by eligible unused AgentCard cards before the first +Square Pay action. It requires --params with browser_id (session ID of a browser +created with this vault attached), merchant_origin (canonical origin of the +top-level merchant document, not the Square iframe; http only for localhost), and +environment (production or sandbox, describing Square and not the AgentCard +credential mode). Deliver the returned approval URL and keep that page open; +--open opens it. Then poll with items get --wait 60 until ready_to_submit and +submit native Pay before the preparation deadline; readiness lasts at most 30 +seconds and polling never extends it. Unused preparations expire automatically. +Every preparation is single-use, including after failure or expiry: do not +automatically retry, and reconcile uncertain outcomes with the merchant. +Fill requires --params JSON with browser_id (session ID, not name) and 1-32 fields. +Each binding has field and selector. Optional timeout_ms is 1-30000 (default 10000). +Do not include type, values, or frame IDs in --params. +For cards, page_url is a required exact HTTPS URL and each field is one of number, +cvc, exp_month (MM), exp_year (YYYY), billing_name, billing_line1, billing_line2, +billing_city, billing_state, billing_postal_code, billing_country, or the combined +expiration, which also requires format MM/YY or MM/YYYY. Fill is available only when +advertised by a ready Link card, not AgentCard. +For credentials, each field is a declared field name with a stored value, format is +not accepted, and page_url may be omitted to require exactly one open page. A totp +field fills a freshly generated code; its seed never enters the browser. 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. @@ -153,7 +179,10 @@ prove no writes occurred. No automatic retries, alias fallback, or form submissi 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`, + kernel vaults items invoke checkout order-1 prepare_checkout --params '{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}' --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 + kernel vaults items invoke logins hacker-news collect --open + kernel vaults items invoke logins hacker-news fill --params '{"browser_id":"browser-session-id","fields":[{"field":"username","selector":"#login"},{"field":"password","selector":"#password"}]}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { open, _ := cmd.Flags().GetBool("open") raw, _ := cmd.Flags().GetString("params") @@ -163,8 +192,8 @@ Inspect the browser before deciding what to do next; completed does not mean pai } 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", "", "Operation-specific JSON object for fill and prepare_checkout; omit type (supplied by )") + invoke.Flags().Bool("open", false, "Open a returned HTTPS action or approval URL for authorize, collect, and prepare_checkout") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) @@ -207,16 +236,97 @@ 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) + + credentials := &cobra.Command{Use: "credentials", Aliases: []string{"credential"}, Short: "Store logins and other non-payment credentials"} + credentials.AddCommand(newVaultCredentialCreateCommand(), newVaultCredentialUpdateCommand()) + cmd.AddCommand(items, wallets, cards, credentials) + return cmd +} + +func newVaultCredentialCreateCommand() *cobra.Command { + cmd := &cobra.Command{Use: "create --spec ''", Short: "Declare a credential item and optionally seed its values", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: `Create a credential item at an immutable key, without a wallet or provider. +Repeating the original creation request returns the current item without +overwriting later edits; a different request at the same key returns 409. +Use vaults credentials update for changes. +` + vaultCredentialSpecHelp, + Example: ` kernel vaults credentials create logins hacker-news --spec '{ + "description": "Hacker News", + "fields": { + "username": {"type": "text", "sensitive": false}, + "password": {"type": "password"} + } + }' --values-file ./values.json --open`, + RunE: func(cmd *cobra.Command, args []string) error { + spec, err := vaultCredentialSpecFromFlags(cmd) + if err != nil { + return err + } + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).CreateCredential(cmd.Context(), args[0], args[1], spec, vaultOutput(cmd), open) + }} + cmd.Flags().String("spec", "", "Credential specification JSON with fields and an optional description (required)") + _ = cmd.MarkFlagRequired("spec") + addVaultCredentialValuesFlag(cmd) + cmd.Flags().Bool("open", false, "Open a returned HTTPS collection URL in your browser") + addVaultJSONOutputFlag(cmd) return cmd } +func newVaultCredentialUpdateCommand() *cobra.Command { + cmd := &cobra.Command{Use: "update --version ", Short: "Set or clear credential values and the description", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: `Atomically update the description and selected values; omitted properties are preserved. +--version is the expected current item version from the latest read, so a +concurrent edit returns 409 instead of being overwritten. Read it with items get. +Field names, types, required flags, and sensitivity cannot change, and unknown +field names return 400. A successful update increments the version and invalidates +outstanding hosted collection sessions. + +--values-file sets values; a JSON null or empty string clears one immediately. +Clearing a required field reopens collection and returns a fresh collection action; +clearing a required totp field returns 400 because no form can collect it. +--description "" clears the description.`, + Example: ` kernel vaults credentials update logins hacker-news --version 3 --values-file ./values.json + kernel vaults credentials update logins hacker-news --version 3 --description "Hacker News"`, + RunE: func(cmd *cobra.Command, args []string) error { + version, _ := cmd.Flags().GetInt64("version") + if version < 1 { + return fmt.Errorf("--version must be the expected current item version (1 or greater)") + } + expectedItemID, _ := cmd.Flags().GetString("expected-item-id") + spec, err := vaultCredentialUpdateSpecFromFlags(cmd) + if err != nil { + return err + } + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).UpdateCredential(cmd.Context(), args[0], args[1], version, expectedItemID, spec, vaultOutput(cmd), open) + }} + cmd.Flags().Int64("version", 0, "Expected current item version from the latest read (required)") + _ = cmd.MarkFlagRequired("version") + cmd.Flags().String("description", "", "Replacement form title; an empty string clears it") + cmd.Flags().String("expected-item-id", "", "Immutable item ID precondition; returns 409 if the key now identifies a different item") + addVaultCredentialValuesFlag(cmd) + cmd.Flags().Bool("open", false, "Open a returned HTTPS collection URL in your browser") + addVaultJSONOutputFlag(cmd) + return cmd +} + +func addVaultCredentialValuesFlag(cmd *cobra.Command) { + cmd.Flags().String("values-file", "", "JSON object of field names to values, read from a file (use '-' for stdin); never pass values as shell arguments") +} + func newVaultDeleteCommand(item bool) *cobra.Command { use, short, nargs := "delete ", "Delete a vault and invalidate all its items", 1 + long := short + ".\nUnresolved payment operations block deletion, including operations on child cards of a wallet." if item { use, short, nargs = "delete ", "Delete an item and invalidate its credential", 2 + long = short + `. +Unresolved payment operations normally block deletion. An AgentCard checkout whose +create response returned no authorization ID may be abandoned by deleting that card +directly, so a replacement can be created; deleting its wallet or vault stays blocked. +Deleting or recreating an item is not proof that a payment did not occur.` } - cmd := &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(nargs), PreRunE: vaultPreRun, + cmd := &cobra.Command{Use: use, Short: short, Long: long, Args: cobra.ExactArgs(nargs), PreRunE: vaultPreRun, RunE: func(cmd *cobra.Command, args []string) error { key := "" if item { diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go new file mode 100644 index 00000000..f1f2b52d --- /dev/null +++ b/cmd/vaults_credentials.go @@ -0,0 +1,180 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "sort" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/param" + "github.com/spf13/cobra" +) + +var vaultCredentialFieldTypes = []string{"text", "email", "password", "totp"} + +// CreateCredential stores a credential item without a wallet or provider. Values +// arrive through a file so secrets never appear in shell arguments; omitted +// required values leave the item pending_collection with a hosted form action. +func (c VaultsCmd) CreateCredential(ctx context.Context, vault, key string, spec map[string]json.RawMessage, output string, open bool) error { + item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{ + IDOrName: vault, + OfCredential: &kernel.CredentialVaultItemRequestParam{ + Type: kernel.CredentialVaultItemRequestTypeCredential, + Spec: param.Override[kernel.CredentialVaultItemSpecInputParam](spec), + }, + }, option.WithMaxRetries(0)) + if err != nil { + return vaultCredentialItemError(err) + } + return c.showItem(item, output, open) +} + +// UpdateCredential atomically replaces selected values and the description. +// --version is the expected current item version, so a concurrent edit returns +// 409 instead of silently overwriting it. +func (c VaultsCmd) UpdateCredential(ctx context.Context, vault, key string, version int64, expectedItemID string, spec map[string]json.RawMessage, output string, open bool) error { + request := kernel.CredentialVaultItemUpdateRequestParam{ + Type: kernel.CredentialVaultItemUpdateRequestTypeCredential, + Version: version, + Spec: param.Override[kernel.CredentialVaultItemSpecUpdateParam](spec), + } + if expectedItemID != "" { + request.ExpectedItemID = kernel.Opt(expectedItemID) + } + item, err := c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &request}, option.WithMaxRetries(0)) + if err != nil { + return vaultCredentialItemError(err) + } + return c.showItem(item, output, open) +} + +// The spec is forwarded without defaults or normalization, like card and wallet +// specs. Only the shape the CLI must reason about is checked here. +func vaultCredentialSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { + raw, _ := cmd.Flags().GetString("spec") + var spec map[string]json.RawMessage + if json.Unmarshal([]byte(raw), &spec) != nil || spec == nil { + return nil, fmt.Errorf("--spec must be a JSON object with fields and an optional description") + } + for key := range spec { + if key != "fields" && key != "description" { + return nil, fmt.Errorf("--spec supports only description and fields") + } + } + fields, err := vaultCredentialFieldsFromSpec(spec["fields"]) + if err != nil { + return nil, err + } + if cmd.Flags().Changed("values-file") { + values, err := readVaultFieldValues(cmd, "values-file") + if err != nil { + return nil, err + } + if err := vaultCredentialApplyValues(fields, values); err != nil { + return nil, err + } + } + encoded, err := json.Marshal(fields) + if err != nil { + return nil, err + } + spec["fields"] = encoded + return spec, nil +} + +func vaultCredentialFieldsFromSpec(raw json.RawMessage) (map[string]json.RawMessage, error) { + var fields map[string]json.RawMessage + if raw == nil || json.Unmarshal(raw, &fields) != nil || len(fields) < 1 || len(fields) > 32 { + return nil, fmt.Errorf("--spec.fields must be a JSON object declaring 1-32 fields") + } + for name, raw := range fields { + if !vaultFieldNamePattern.MatchString(name) { + return nil, fmt.Errorf("--spec.fields names must match [a-zA-Z][a-zA-Z0-9_]{0,63}") + } + var field map[string]json.RawMessage + if json.Unmarshal(raw, &field) != nil || field == nil { + return nil, fmt.Errorf("--spec.fields[%q] must be a JSON object", name) + } + if _, ok := field["value"]; ok { + return nil, fmt.Errorf("--spec must not contain field values; supply them with --values-file") + } + var fieldType string + if json.Unmarshal(field["type"], &fieldType) != nil || !slices.Contains(vaultCredentialFieldTypes, fieldType) { + return nil, fmt.Errorf("--spec.fields[%q].type must be text, email, password, or totp", name) + } + } + return fields, nil +} + +// Creation rejects null and empty values, so refuse them before sending a +// request that cannot succeed. +func vaultCredentialApplyValues(fields map[string]json.RawMessage, values map[string]*string) error { + for _, name := range sortedVaultFieldNames(values) { + raw, declared := fields[name] + if !declared { + return fmt.Errorf("--values-file field %q is not declared in --spec.fields", name) + } + if values[name] == nil || *values[name] == "" { + return fmt.Errorf("--values-file value for %q must be a non-empty string; omit the field to leave it unset", name) + } + var field map[string]json.RawMessage + if err := json.Unmarshal(raw, &field); err != nil { + return err + } + encoded, err := json.Marshal(*values[name]) + if err != nil { + return err + } + field["value"] = encoded + if fields[name], err = json.Marshal(field); err != nil { + return err + } + } + return nil +} + +// An update must change something: the API rejects an empty spec. +func vaultCredentialUpdateSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { + spec := make(map[string]json.RawMessage) + if cmd.Flags().Changed("description") { + description, _ := cmd.Flags().GetString("description") + encoded, err := json.Marshal(description) + if err != nil { + return nil, err + } + spec["description"] = encoded + } + if cmd.Flags().Changed("values-file") { + values, err := readVaultFieldValues(cmd, "values-file") + if err != nil { + return nil, err + } + fields := make(map[string]json.RawMessage, len(values)) + for _, name := range sortedVaultFieldNames(values) { + encoded, err := json.Marshal(map[string]*string{"value": values[name]}) + if err != nil { + return nil, err + } + fields[name] = encoded + } + if spec["fields"], err = json.Marshal(fields); err != nil { + return nil, err + } + } + if len(spec) == 0 { + return nil, fmt.Errorf("update requires --description, --values-file, or both") + } + return spec, nil +} + +func sortedVaultFieldNames[T any](values map[string]T) []string { + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go new file mode 100644 index 00000000..c7e4d6c7 --- /dev/null +++ b/cmd/vaults_credentials_test.go @@ -0,0 +1,281 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const credentialSpecFixture = `{"description":"Hacker News","fields":{"username":{"type":"text","sensitive":false},"password":{"type":"password"}}}` + +const pendingCredentialFixture = `{"id":"item-1","key":"hacker-news","type":"credential","version":1,` + + `"spec":{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}},` + + `"state":{"status":"pending_collection","fields":{"username":{"has_value":true,"value":"ada"},"password":{"has_value":false}}},` + + `"action":{"name":"collect","url":"https://vault.kernel.sh/c/session-token","expires_at":"2026-09-14T00:30:00Z"},` + + `"available_operations":[{"type":"collect","description":"Open the collection form."}],"available_expansions":[],` + + `"created_at":"2026-09-14T00:00:00Z","updated_at":"2026-09-14T00:00:00Z"}` + +const readyCredentialFixture = `{"id":"item-1","key":"hacker-news","type":"credential","version":2,` + + `"spec":{"fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}},` + + `"state":{"status":"ready","fields":{"username":{"has_value":true,"value":"ada"},"password":{"has_value":true}}},` + + `"available_operations":[{"type":"collect","description":"Open the collection form."},{"type":"fill","description":"Fill login fields."}],"available_expansions":[],` + + `"created_at":"2026-09-14T00:00:00Z","updated_at":"2026-09-14T00:00:00Z"}` + +func writeVaultValuesFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "values.json") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func TestVaultCredentialCreateRequestMapping(t *testing.T) { + var body []byte + var method, path string + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, pendingCredentialFixture) + }) + values := writeVaultValuesFile(t, `{"username":"ada"}`) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "logins", "hacker-news", "--spec", credentialSpecFixture, "--values-file", values, "-o", "json") + require.NoError(t, err) + assert.Equal(t, http.MethodPut, method) + assert.Equal(t, "/vaults/logins/items/hacker-news", path) + assert.JSONEq(t, `{"type":"credential","spec":{"description":"Hacker News","fields":{"username":{"type":"text","sensitive":false,"value":"ada"},"password":{"type":"password"}}}}`, string(body)) + assert.JSONEq(t, pendingCredentialFixture, out) +} + +func TestVaultCredentialCreateValidation(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + values := writeVaultValuesFile(t, `{"username":"ada"}`) + for name, args := range map[string][]string{ + "spec not an object": {"--spec", `[]`}, + "spec scalar": {"--spec", `"credential-sentinel"`}, + "unsupported key": {"--spec", `{"fields":{"a":{"type":"text"}},"provider":"link"}`}, + "missing fields": {"--spec", `{"description":"x"}`}, + "empty fields": {"--spec", `{"fields":{}}`}, + "bad field name": {"--spec", `{"fields":{"user-name":{"type":"text"}}}`}, + "bad field type": {"--spec", `{"fields":{"username":{"type":"credential-sentinel"}}}`}, + "missing field type": {"--spec", `{"fields":{"username":{}}}`}, + "inline value": {"--spec", `{"fields":{"username":{"type":"text","value":"credential-sentinel"}}}`}, + "undeclared value": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `{"nickname":"ada"}`)}, + "null value": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `{"username":null}`)}, + "empty value": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `{"username":""}`)}, + "values not object": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `["ada"]`)}, + "missing values file": {"--spec", credentialSpecFixture, "--values-file", filepath.Join(t.TempDir(), "absent.json")}, + "empty values": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `{}`)}, + "bad values name": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `{"user-name":"ada"}`)}, + } { + t.Run(name, func(t *testing.T) { + out, human, err := executeVaultCommand(t, client, append([]string{"vaults", "credentials", "create", "logins", "hacker-news"}, args...)...) + require.Error(t, err) + assert.NotContains(t, err.Error(), "credential-sentinel") + assert.Empty(t, out) + assert.Empty(t, human) + }) + } + // --spec is required; values alone cannot declare a schema. + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "logins", "hacker-news", "--values-file", values) + require.Error(t, err) +} + +func TestVaultCredentialUpdateRequestMapping(t *testing.T) { + var body []byte + var method string + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + method = r.Method + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, readyCredentialFixture) + }) + values := writeVaultValuesFile(t, `{"password":"hunter2","username":null}`) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "update", "logins", "hacker-news", + "--version", "1", "--expected-item-id", "item-1", "--description", "Hacker News", "--values-file", values, "-o", "json") + require.NoError(t, err) + assert.Equal(t, http.MethodPatch, method) + assert.JSONEq(t, `{"type":"credential","version":1,"expected_item_id":"item-1","spec":{"description":"Hacker News","fields":{"password":{"value":"hunter2"},"username":{"value":null}}}}`, string(body)) + assert.JSONEq(t, readyCredentialFixture, out) + assert.NotContains(t, out, "hunter2") +} + +func TestVaultCredentialUpdateDescriptionOnlyAndValidation(t *testing.T) { + var body []byte + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, readyCredentialFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "update", "logins", "hacker-news", "--version", "2", "--description", "", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"type":"credential","version":2,"spec":{"description":""}}`, string(body)) + + strict := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + for name, args := range map[string][]string{ + "no changes": {"--version", "2"}, + "zero version": {"--version", "0", "--description", "x"}, + "bad version": {"--version", "-1", "--description", "x"}, + } { + t.Run(name, func(t *testing.T) { + _, _, err := executeVaultCommand(t, strict, append([]string{"vaults", "credentials", "update", "logins", "hacker-news"}, args...)...) + require.Error(t, err) + }) + } + _, _, err = executeVaultCommand(t, strict, "vaults", "credentials", "update", "logins", "hacker-news", "--description", "x") + require.ErrorContains(t, err, "version") +} + +func TestVaultCredentialValuesFromStdin(t *testing.T) { + var body []byte + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, readyCredentialFixture) + }) + stdin := strings.NewReader(`{"password":"hunter2"}`) + _, _, err := executeVaultCommandWithStdin(t, client, stdin, "vaults", "credentials", "update", "logins", "hacker-news", "--version", "1", "--values-file", "-", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"type":"credential","version":1,"spec":{"fields":{"password":{"value":"hunter2"}}}}`, string(body)) +} + +func TestVaultCollectOperation(t *testing.T) { + calls := 0 + var body []byte + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls > 1 { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vaults/logins/items/hacker-news/operations", r.URL.Path) + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, pendingCredentialFixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "logins", "hacker-news", "collect", "-o", "json") + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.JSONEq(t, `{"type":"collect"}`, string(body)) + assert.JSONEq(t, pendingCredentialFixture, out) + + // collect takes no parameters. + _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "logins", "hacker-news", "collect", "--params", `{}`) + require.ErrorContains(t, err, "--params") +} + +func TestVaultCredentialItemHumanOutput(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, pendingCredentialFixture) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "logins", "hacker-news") + require.NoError(t, err) + assert.Contains(t, human, "credential") + assert.Contains(t, human, "Version") + assert.Contains(t, human, "Hacker News") + assert.Contains(t, human, "pending_collection") + // The declared schema and per-field presence are both shown. + assert.Contains(t, human, "username") + assert.Contains(t, human, "ada") + assert.Contains(t, human, "password") + assert.Contains(t, human, "(withheld)") + assert.Contains(t, human, "https://vault.kernel.sh/c/session-token") + assert.Contains(t, human, "Collection action") +} + +func TestVaultCredentialFillBindings(t *testing.T) { + calls := 0 + var body []byte + 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, readyCredentialFixture) + return + } + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + _, _ = io.WriteString(w, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"},{"index":1,"status":"filled"}]}`) + }) + // Credential items may omit page_url to require exactly one open page. + params := `{"browser_id":"browser-session-id","fields":[{"field":"username","selector":"#login"},{"field":"password","selector":"#password"}]}` + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "logins", "hacker-news", "fill", "--params", params, "-o", "json") + require.NoError(t, err) + assert.Equal(t, 2, calls) + var request map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &request)) + _, hasPageURL := request["page_url"] + assert.False(t, hasPageURL) + assert.JSONEq(t, `[{"field":"username","selector":"#login"},{"field":"password","selector":"#password"}]`, string(request["fields"])) + assert.Contains(t, out, "completed") +} + +func TestVaultFillItemTypeValidation(t *testing.T) { + for name, tc := range map[string]struct{ fixture, params, message string }{ + "card without page_url": { + readyFillCardFixture, + `{"browser_id":"b","fields":[{"field":"number","selector":"#n"}]}`, + "page_url", + }, + "credential field not declared": { + readyCredentialFixture, + `{"browser_id":"b","fields":[{"field":"nickname","selector":"#n"}]}`, + "not declared", + }, + "credential expiration format": { + readyCredentialFixture, + `{"browser_id":"b","fields":[{"field":"expiration","format":"MM/YY","selector":"#e"}]}`, + "expiration", + }, + } { + t.Run(name, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, tc.fixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "logins", "hacker-news", "fill", "--params", tc.params, "-o", "json") + require.ErrorContains(t, err, tc.message) + assert.Equal(t, 1, calls, "no operation may be posted after a rejected binding") + }) + } +} + +func TestVaultCredentialAPIErrorsAreSpecificAndSafe(t *testing.T) { + for status, message := range map[int]string{400: "declared", 409: "items get", 500: "inspect existing state"} { + t.Run(http.StatusText(status), func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"error":{"message":"credential-sentinel"}}`) + }) + values := writeVaultValuesFile(t, `{"username":"credential-sentinel"}`) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "logins", "hacker-news", "--spec", credentialSpecFixture, "--values-file", values) + require.ErrorContains(t, err, message) + assert.NotContains(t, err.Error(), "credential-sentinel") + + _, _, err = executeVaultCommand(t, client, "vaults", "credentials", "update", "logins", "hacker-news", "--version", "1", "--values-file", values) + require.ErrorContains(t, err, message) + assert.NotContains(t, err.Error(), "credential-sentinel") + }) + } +} diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 7f63c6c0..58bb251f 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -41,20 +41,23 @@ func vaultFillRequestError(err error) error { return fmt.Errorf("fill result unavailable; %s", vaultFillUncertain) } -func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFillParams, output string) error { +func (c VaultsCmd) fill(ctx context.Context, vault, key, itemType 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)), + } + // Credential items may omit page_url to require exactly one open page. + 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 +84,11 @@ 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.") + if itemType == "credential" { + pterm.Println("Fields filled; this does not confirm that the site accepted the values or that a login succeeded.") + } else { + pterm.Println("Fields filled; this does not confirm payment or merchant acceptance.") + } } else { pterm.Println(vaultFillUncertain) } diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index b0531f63..0d595138 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -47,7 +47,6 @@ func TestVaultFillParamsValidation(t *testing.T) { "bad port": replace(`shop.example`, `shop.example:secret`), "URL whitespace": replace(`checkout?`, `checkout ?`), "URL type": replace(`"https://shop.example/checkout?step=2#payment"`, `123`), - "missing URL": replace(`"page_url":"https://shop.example/checkout?step=2#payment",`, ``), "empty fields": replace(`[{"field":"number","selector":"#card-number"},{"field":"expiration","format":"MM/YY","selector":"#expiry"},{"field":"cvc","selector":"#security-code"}]`, `[]`), "fields object": `{"browser_id":"id","page_url":"https://shop.example/","fields":{}}`, "missing fields": `{"browser_id":"id","page_url":"https://shop.example/"}`, diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 8e3dc275..b209bea8 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -45,6 +45,15 @@ type AgentCardWalletSpec = { provider_config?: ProviderConfigReference; // omit for Kernel-managed credentials user_id?: string; // usr_...; enrolled in this organization under the SAME config }; + +A wallet's configuration and provider binding are fixed at creation: it cannot be +moved to a different configuration later, and renaming one does not rebind it. +Omitting user_id returns a hosted enrollment action for the user to complete. + +Link wallets on your own OAuth client are created by importing an existing grant's +access and refresh tokens. Those tokens must never be passed to the CLI; create +such wallets from your backend instead. Only the kernel_managed client shown above +is supported here. ` const vaultCardSpecHelp = ` @@ -92,3 +101,34 @@ type LinkTotal = { Permitted domains are provider-assigned, not configurable in the spec. ` + +const vaultCredentialSpecHelp = ` +--spec takes the credential specification object: an optional description and a +fields map declaring 1-32 fields. Field names match [a-zA-Z][a-zA-Z0-9_]{0,63}. +Values are never accepted in --spec; supply them with --values-file , +a JSON object mapping declared field names to values. Field names, types, +required flags, and sensitivity are fixed at creation and cannot be changed. + +type CredentialSpec = { + description?: string; // site or service name used verbatim as the form title + fields: Record; +}; + +Set sensitive false for ordinary usernames and email addresses so the form can +display and prefill them; reserve true for passwords, API tokens, and TOTP seeds. +A totp value is an RFC 4648 Base32 generator seed, not an otpauth URI or a current +code; browser fill derives the code and never writes the seed. A required totp +field must be given a seed at creation, because no form can collect it. + +If every required field has a value, the item is ready and no collection action is +returned; invoke collect to open its form anyway. Otherwise the item is +pending_collection with a time-scoped hosted form URL. Treat that URL as a secret. + +Credential items are for logins and other non-payment credentials. Do not store, +collect, or fill credit card numbers, security codes, or expiration dates in them; +use wallet and card items for payments instead. +` diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index e519a6c8..84c24249 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -8,8 +8,25 @@ import ( "regexp" "slices" "strings" + + kernel "github.com/kernel/kernel-go-sdk" ) +// vaultOperationParams holds the parsed --params payload for the one operation +// that accepts parameters; exactly one field is set, or none for authorize. +type vaultOperationParams struct { + Fill *vaultFillParams + Checkout *vaultCheckoutContext +} + +// vaultCheckoutContext binds an AgentCard preparation to a browser session and +// the declared top-level merchant origin, not a tab. +type vaultCheckoutContext struct { + BrowserID string `json:"browser_id"` + MerchantOrigin string `json:"merchant_origin"` + Environment string `json:"environment"` +} + type vaultFillParams struct { BrowserID string `json:"browser_id"` PageURL string `json:"page_url"` @@ -25,6 +42,13 @@ type vaultFillField struct { var vaultFillPageURLPattern = regexp.MustCompile(`^https://[^/?#@*\s]+(?:[/?#][^\s]*)?$`) +// Declared credential field names; card field names also satisfy this pattern. +var vaultFieldNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,63}$`) + +// Card fields the API fills from the decrypted card, excluding the combined +// expiration field, which additionally requires a format. +var vaultCardFillFields = []string{"number", "cvc", "exp_month", "exp_year", "billing_name", "billing_line1", "billing_line2", "billing_city", "billing_state", "billing_postal_code", "billing_country"} + // Reject duplicate and unknown keys without including payloads in diagnostics. func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) { invalid := fmt.Errorf("--params must contain JSON objects with only supported, non-duplicate properties") @@ -65,21 +89,28 @@ func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) return object, nil } -func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) (*vaultFillParams, error) { +func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) (*vaultOperationParams, error) { if strings.TrimSpace(operation) == "" { return nil, fmt.Errorf("operation must not be empty") } - if openSet && operation != "authorize" { - return nil, fmt.Errorf("--open is only supported for authorize") + if openSet && operation != "authorize" && operation != "prepare_checkout" && operation != "collect" { + return nil, fmt.Errorf("--open is only supported for authorize, collect, and prepare_checkout") + } + if operation == "prepare_checkout" { + checkout, err := parseVaultCheckoutContext(raw, paramsSet) + if err != nil { + return nil, err + } + return &vaultOperationParams{Checkout: checkout}, nil } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill; authorize takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill and prepare_checkout; authorize and collect take no parameters") } 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 with browser_id, fields, and page_url for cards") } object, err := vaultParamsObject(raw, "browser_id page_url fields timeout_ms") if err != nil { @@ -89,12 +120,16 @@ 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") + // Cards require page_url; credential items may omit it to require exactly one + // open page. The item type decides, so only validate the value when supplied. + if _, ok := object["page_url"]; ok { + 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 timeout, ok := object["timeout_ms"]; ok { if json.Unmarshal(timeout, ¶ms.TimeoutMS) != nil || params.TimeoutMS == nil || *params.TimeoutMS < 1 || *params.TimeoutMS > 30000 { @@ -115,22 +150,93 @@ 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 || !vaultFieldNamePattern.MatchString(binding.Field) { + return nil, fmt.Errorf("--params.fields[%d].field must be a supported card field or a declared credential field name", i) } - switch binding.Field { - case "expiration": + if binding.Field == "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) } - 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) + } else if _, ok := field["format"]; ok { + return nil, fmt.Errorf("--params.fields[%d].format is only supported for a card's combined expiration field", i) } params.Fields = append(params.Fields, binding) } - return ¶ms, nil + return &vaultOperationParams{Fill: ¶ms}, nil +} + +// Preparations are single-use even after failure or expiry, so reject a +// malformed checkout context before spending one. +func parseVaultCheckoutContext(raw string, paramsSet bool) (*vaultCheckoutContext, error) { + if !paramsSet { + return nil, fmt.Errorf("prepare_checkout requires --params with browser_id, merchant_origin, and environment") + } + object, err := vaultParamsObject(raw, "browser_id merchant_origin environment") + if err != nil { + return nil, err + } + var checkout vaultCheckoutContext + if json.Unmarshal(object["browser_id"], &checkout.BrowserID) != nil || strings.TrimSpace(checkout.BrowserID) == "" { + return nil, fmt.Errorf("--params.browser_id must be a non-empty browser session ID, not a name") + } + if json.Unmarshal(object["environment"], &checkout.Environment) != nil || (checkout.Environment != "production" && checkout.Environment != "sandbox") { + return nil, fmt.Errorf("--params.environment must be production or sandbox; it describes Square, not the AgentCard credential mode") + } + if json.Unmarshal(object["merchant_origin"], &checkout.MerchantOrigin) != nil { + return nil, fmt.Errorf("--params.merchant_origin must be the top-level merchant document's origin, not the Square iframe") + } + origin, err := vaultMerchantOrigin(checkout.MerchantOrigin) + if err != nil { + return nil, err + } + checkout.MerchantOrigin = origin + return &checkout, nil +} + +// A canonical origin carries no path, query, fragment, or credentials. HTTP is +// accepted only for loopback test merchants. +func vaultMerchantOrigin(value string) (string, error) { + invalid := fmt.Errorf("--params.merchant_origin must be a canonical HTTPS origin such as https://shop.example.com (http accepted only for localhost), without a path, query, or fragment") + u, err := url.Parse(strings.TrimSpace(value)) + if err != nil || u.Host == "" || u.User != nil || u.Opaque != "" || u.RawQuery != "" || u.Fragment != "" { + return "", invalid + } + if u.Path != "" && u.Path != "/" { + return "", invalid + } + switch u.Scheme { + case "https": + case "http": + if host := u.Hostname(); host != "localhost" && host != "127.0.0.1" && host != "::1" { + return "", invalid + } + default: + return "", invalid + } + return u.Scheme + "://" + u.Host, nil +} + +// Card and credential items accept different bindings, and the item type is only +// known after the item is read. Reject mismatches before any browser writes. +func validateVaultFillForItem(item *kernel.VaultItemUnion, params *vaultFillParams) error { + if item.Type == "credential" { + for i, field := range params.Fields { + if field.Format != "" { + return fmt.Errorf("--params.fields[%d].format is only supported for a card's combined expiration field", i) + } + if _, declared := item.Spec.Fields[field.Field]; !declared { + return fmt.Errorf("--params.fields[%d].field %q is not declared on this credential item", i, field.Field) + } + } + return nil + } + if params.PageURL == "" { + return fmt.Errorf("--params.page_url is required for card items; only credential items may omit it") + } + for i, field := range params.Fields { + if field.Field != "expiration" && !slices.Contains(vaultCardFillFields, field.Field) { + return fmt.Errorf("--params.fields[%d].field must be a supported card field", i) + } + } + return nil } diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index b3dbb38d..5e61d884 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/url" + "sort" "strings" "github.com/kernel/cli/pkg/util" @@ -15,6 +16,10 @@ import ( type vaultJSON map[string]json.RawMessage type vaultOutputFields map[string]vaultOutputFields +// vaultOutputWildcard applies one schema to every property of an object whose +// keys are not known in advance, such as credential field maps. +const vaultOutputWildcard = "*" + func vaultFieldsOf(names string) vaultOutputFields { fields := make(vaultOutputFields) for _, name := range strings.Fields(names) { @@ -31,16 +36,23 @@ var vaultMethodFields = vaultOutputFields{ "display": vaultFieldsOf("label brand last4"), "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, } + +// Credential field maps are keyed by caller-declared names, so their schema is +// applied to every property instead of a fixed key list. +var vaultCredentialFieldFields = vaultOutputFields{vaultOutputWildcard: vaultFieldsOf("type required sensitive")} +var vaultCredentialFieldStateFields = vaultOutputFields{vaultOutputWildcard: vaultFieldsOf("has_value value")} + 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": vaultCredentialFieldFields, "provider_config": vaultFieldsOf("id name"), "authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultFieldsOf("id name")}}, "totals": vaultTotalFields, @@ -51,9 +63,11 @@ var vaultItemFields = vaultOutputFields{ }, "state": { "provider": nil, "status": nil, "status_reason": nil, "user_id": nil, "domains": nil, + "fields": vaultCredentialFieldStateFields, "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"), + "preparation": vaultFieldsOf("id status browser_id merchant_origin environment approval_url created_at expires_at"), }, } var vaultEventFields = vaultOutputFields{ @@ -96,24 +110,41 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes return nil, fmt.Errorf("invalid vault response shape") } result := make(vaultJSON) + if wildcard, hasWildcard := fields[vaultOutputWildcard]; hasWildcard { + for key, value := range object { + if err := filterVaultProperty(result, key, value, wildcard); err != nil { + return nil, err + } + } + return json.Marshal(result) + } for key, children := range fields { if value, ok := object[key]; ok { - if key == "url" || key == "approval_url" || key == "merchant_url" || key == "image_url" || key == "product_url" { - var address string - if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { - continue - } - } - filtered, err := filterVaultJSON(value, children) - if err != nil { + if err := filterVaultProperty(result, key, value, children); err != nil { return nil, err } - result[key] = filtered } } return json.Marshal(result) } +// Withhold any URL that is not display-safe rather than reporting an error, so a +// credential-bearing link is dropped from output instead of being echoed back. +func filterVaultProperty(result vaultJSON, key string, value json.RawMessage, fields vaultOutputFields) error { + if key == "url" || key == "approval_url" || key == "merchant_url" || key == "image_url" || key == "product_url" { + var address string + if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { + return nil + } + } + filtered, err := filterVaultJSON(value, fields) + if err != nil { + return err + } + result[key] = filtered + return nil +} + func vaultSafeJSONSlice[T util.RawJSONProvider](items []T, fields vaultOutputFields) ([]vaultJSON, error) { result := make([]vaultJSON, 0, len(items)) for _, item := range items { @@ -196,7 +227,7 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s } for _, op := range actions.Operations { command := prefix - if op.Type == "fill" { + if op.Type == "fill" || op.Type == "prepare_checkout" { command += " --params ''" } pterm.Printf("Invoke: %s -- %s %s %s\n", command, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) @@ -222,8 +253,17 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { return err } rows := pterm.TableData{ - {"Property", "Value"}, {"Key (immutable)", item.Key}, {"ID", item.ID}, - {"Type", item.Type}, {"Provider", item.Spec.Provider}, {"Status", item.State.Status}, + {"Property", "Value"}, {"Key (immutable)", item.Key}, {"ID", item.ID}, {"Type", item.Type}, + } + if item.Type != "credential" { + rows = append(rows, []string{"Provider", item.Spec.Provider}) + } + rows = append(rows, []string{"Status", item.State.Status}) + if item.Type == "credential" { + rows = append(rows, []string{"Version", fmt.Sprint(item.Version)}) + if item.Spec.Description != "" { + rows = append(rows, []string{"Description", item.Spec.Description}) + } } if item.Type == "wallet" { configID, configName := item.Spec.ProviderConfig.ID, item.Spec.ProviderConfig.Name @@ -255,7 +295,16 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { rows = append(rows, []string{"Permitted domains (provider-assigned)", strings.Join(item.State.Domains, ", ")}) } if actions.RequiredAction != "" { - rows = append(rows, []string{"Required action", actions.RequiredAction}) + // A credential form is offered whenever a session is active; on a ready + // item it is an invitation to edit, not an outstanding requirement. + label := "Required action" + if item.Type == "credential" { + label = "Collection action" + } + rows = append(rows, []string{label, actions.RequiredAction}) + if item.Type == "credential" && !item.Action.ExpiresAt.IsZero() { + rows = append(rows, []string{"Collection link expires", util.FormatLocal(item.Action.ExpiresAt)}) + } } if !item.ExpiresAt.IsZero() { rows = append(rows, []string{"Expires At", util.FormatLocal(item.ExpiresAt)}) @@ -277,14 +326,61 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { rows = append(rows, []string{"Processor response delivered", fmt.Sprint(a.ReplayDelivered)}) } } + if item.State.JSON.Preparation.Valid() { + p := item.State.Preparation + rows = append(rows, + []string{"Checkout preparation", util.OrDash(p.ID)}, + []string{"Preparation status", string(p.Status)}, + []string{"Preparation environment (Square)", string(p.Environment)}, + []string{"Merchant origin", p.MerchantOrigin}, + []string{"Preparation browser", p.BrowserID}, + ) + if !p.ExpiresAt.IsZero() { + rows = append(rows, []string{"Submit native Pay before", util.FormatLocal(p.ExpiresAt)}) + } + } PrintTableNoPad(rows, true) + if item.Type == "credential" { + printVaultCredentialFields(item) + } printVaultItemGuidance(item, actions) return nil } +// Declared schema and per-field presence answer different questions: the schema +// says what the form collects, the state says what is stored. Sensitive values +// are never returned, so presence is all the API discloses for them. +func printVaultCredentialFields(item *kernel.VaultItemUnion) { + if len(item.Spec.Fields) == 0 { + return + } + names := make([]string, 0, len(item.Spec.Fields)) + for name := range item.Spec.Fields { + names = append(names, name) + } + sort.Strings(names) + rows := pterm.TableData{{"Field", "Type", "Required", "Sensitive", "Has value", "Value"}} + for _, name := range names { + definition := item.Spec.Fields[name] + state := item.State.Fields[name] + value := "-" + if definition.Sensitive { + value = "(withheld)" + } else if state.Value != "" { + value = state.Value + } + rows = append(rows, []string{name, string(definition.Type), fmt.Sprint(definition.Required), fmt.Sprint(definition.Sensitive), fmt.Sprint(state.HasValue), value}) + } + PrintTableNoPad(rows, true) +} + func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemActions) { if actions.RecoveryRequired { - pterm.Warning.Println("recovery_required: the original operation is unresolved, not declined or expired. Do not retry, delete, or replace it. Reconcile with the provider or support; no reset operation exists.") + if actions.Abandonable { + pterm.Warning.Println("recovery_required: the original operation is unresolved, not declined or expired. Automatic reuse is blocked and no reset operation exists. No authorization ID was returned, so deleting this card explicitly abandons the attempt and lets you create a replacement; deletion is not proof that the payment did not occur. Deleting its wallet or vault stays blocked.") + return + } + pterm.Warning.Println("recovery_required: the original operation is unresolved, not declined or expired. Do not retry, delete, or replace it. Reconcile the known authorization ID with the provider or support; no reset operation exists.") return } if item.Type == "wallet" && item.Spec.Provider == "link" && item.Spec.Authorization.Client.Type == "customer_managed" && item.State.Status == "degraded" { @@ -296,10 +392,12 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction if actions.ApprovalURL != "" { pterm.Printf("Approval URL:\n%s\n", actions.ApprovalURL) } + printVaultPreparationGuidance(item) for _, op := range actions.Operations { pterm.Printf("Available operation: %s — %s\n", op.Type, op.Description) } - if item.Type == "card" { + switch item.Type { + case "card": card := item.AsCard() for _, expansion := range card.AvailableExpansions { pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) @@ -308,7 +406,9 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction pterm.Info.Println("Aliases are non-secret checkout values. Use only in a browser created with this vault attached; ready does not mean paid.") } pterm.Info.Println("Inspect items events for payment outcomes. Do not retry failed, timed-out, rejected, or indeterminate payments.") - } else { + case "credential": + printVaultCredentialGuidance(item) + default: wallet := item.AsWallet() for _, expansion := range wallet.AvailableExpansions { pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) @@ -317,11 +417,45 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction if item.Expanded.JSON.PaymentMethods.Valid() { printVaultPaymentMethods(item.Expanded.PaymentMethods) } - if actions.RequiredAction != "" { + if actions.RequiredAction != "" && item.Type != "credential" { pterm.Info.Println("Complete the returned action with the provider; never pass card data or OAuth codes to the CLI. Observe with items get --wait 60.") } } +func printVaultCredentialGuidance(item *kernel.VaultItemUnion) { + if item.State.Status == "pending_collection" { + pterm.Warning.Println("pending_collection: required values are missing. Open the collection URL yourself or hand it to the person who holds the credential; treat it as a secret and keep it out of logs. Observe with items get --wait 60.") + } else { + pterm.Info.Println("ready: every required field has a value. This does not mean a login succeeded.") + } + pterm.Info.Println("Set or clear values with vaults credentials update --version, which requires the version above. Never pass credential values as shell arguments; use --values-file. Do not store card data in credential items.") +} + +// Preparation state and item state answer different questions: the preparation +// says whether egress can still claim it, the item says whether the attempt has +// settled. Neither means an order or charge succeeded. +func printVaultPreparationGuidance(item *kernel.VaultItemUnion) { + switch item.State.Status { + case "preparing": + pterm.Info.Println("preparing: the cardholder has not approved this device yet. Keep the approval page open and observe with items get --wait 60; do not prepare again.") + case "ready_to_submit": + pterm.Warning.Println("ready_to_submit: device readiness lasts at most 30 seconds. Submit native Square Pay before the preparation deadline; polling never extends it. An expired readiness window cannot be reused.") + case "consumed": + pterm.Warning.Println("consumed: the prepared attempt has settled. This does not mean an order or charge succeeded. Inspect items events and reconcile with the merchant; preparations are single-use and this one cannot be reused.") + case "stopped": + pterm.Warning.Println("stopped: this preparation cannot be reused. Do not retry it; create a replacement card only after confirming with the merchant that no payment occurred.") + case "outcome_unknown": + pterm.Warning.Println("outcome_unknown: the checkout outcome is unresolved and new requests are blocked. Reconcile with the merchant; do not retry, delete, or replace the card.") + } + if !item.State.JSON.Preparation.Valid() { + return + } + if item.State.Preparation.Status == kernel.AgentcardCheckoutPreparationStatusConsumed { + pterm.Info.Println("Preparation consumed means egress claimed it and it cannot be reused. Use the item status as the lifecycle indicator.") + } + pterm.Info.Println("The preparation amount is display-only and does not constrain the merchant's eventual charge.") +} + func printVaultPaymentMethods(methods []kernel.VaultPaymentMethod) { if len(methods) == 0 { pterm.Info.Println("No payment methods returned") diff --git a/cmd/vaults_policy.go b/cmd/vaults_policy.go index d235c12a..64822c0c 100644 --- a/cmd/vaults_policy.go +++ b/cmd/vaults_policy.go @@ -14,17 +14,25 @@ type vaultItemOperation struct { type vaultItemActions struct { RecoveryRequired bool - RequiredAction string - ActionURL string - ApprovalURL string - Operations []vaultItemOperation + // Abandonable is true when an unresolved AgentCard checkout returned no + // authorization ID. The API lets that card be deleted explicitly to abandon + // the attempt so a replacement can be created; its wallet and vault stay + // blocked, and deletion is not proof that the payment did not occur. + Abandonable bool + RequiredAction string + ActionURL string + ApprovalURL string + Operations []vaultItemOperation } // Execution and human output use this policy; JSON preserves the API-advertised // fields through the separate display-safe projection. func effectiveVaultItemActions(item *kernel.VaultItemUnion) (vaultItemActions, error) { if item.State.Status == "recovery_required" { - return vaultItemActions{RecoveryRequired: true}, nil + return vaultItemActions{ + RecoveryRequired: true, + Abandonable: item.Type == "card" && item.State.Provider == "agentcard" && item.State.Authorization.ID == "", + }, nil } var fields struct { Operations []vaultItemOperation `json:"available_operations"` @@ -32,10 +40,16 @@ func effectiveVaultItemActions(item *kernel.VaultItemUnion) (vaultItemActions, e if err := json.Unmarshal([]byte(item.RawJSON()), &fields); err != nil { return vaultItemActions{}, fmt.Errorf("invalid vault item operations: %w", err) } + // An AgentCard preparation carries its own approval URL instead of a + // required action; the cardholder must keep that page open through handoff. + approvalURL := item.State.Authorization.ApprovalURL + if approvalURL == "" { + approvalURL = item.State.Preparation.ApprovalURL + } return vaultItemActions{ RequiredAction: item.Action.Name, ActionURL: item.Action.URL, - ApprovalURL: item.State.Authorization.ApprovalURL, + ApprovalURL: approvalURL, Operations: fields.Operations, }, nil } diff --git a/cmd/vaults_policy_test.go b/cmd/vaults_policy_test.go index 2b364fec..d8a1ea54 100644 --- a/cmd/vaults_policy_test.go +++ b/cmd/vaults_policy_test.go @@ -58,3 +58,30 @@ func TestVaultRecoveryActionDisplayPolicy(t *testing.T) { } } } + +// An unresolved AgentCard checkout that returned no authorization ID may be +// abandoned by deleting that card; one with a known authorization ID may not. +func TestVaultRecoveryAbandonmentGuidance(t *testing.T) { + const abandonGuidance = "deleting this card explicitly abandons the attempt" + const reconcileGuidance = "Do not retry, delete, or replace it" + for _, tc := range []struct { + name string + authorization string + wants, avoids string + }{ + {"no-authorization-id", "", abandonGuidance, reconcileGuidance}, + {"known-authorization-id", `,"authorization":{"id":"cauth_test","status":"awaiting_approval","psp":"stripe","merchant":"Example Shop","amount_cents":1234,"currency":"usd"}`, reconcileGuidance, abandonGuidance}, + } { + t.Run(tc.name, func(t *testing.T) { + body := `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop"},"state":{"provider":"agentcard","status":"recovery_required"` + tc.authorization + `},"available_operations":[],"available_expansions":[]}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + _, human, err := executeVaultInputCommand(t, client, "", "vaults", "items", "get", "checkout", "order-1") + require.NoError(t, err) + assert.Contains(t, human, tc.wants) + assert.NotContains(t, human, tc.avoids) + }) + } +} diff --git a/cmd/vaults_prepare_checkout.go b/cmd/vaults_prepare_checkout.go new file mode 100644 index 00000000..969735c2 --- /dev/null +++ b/cmd/vaults_prepare_checkout.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "errors" + "fmt" + + kernel "github.com/kernel/kernel-go-sdk" +) + +// A preparation is single-use even after failure or expiry, so a failed request +// is not a retry signal: the attempt may already have consumed one. +const vaultPrepareCheckoutUncertain = "a single-use preparation may still have been created; inspect the item and its events, and do not automatically retry" + +func vaultPrepareCheckoutRequestError(err error) error { + var apiErr *kernel.Error + if errors.As(err, &apiErr) { + return fmt.Errorf("prepare_checkout failed (HTTP %d); %s", apiErr.StatusCode, vaultPrepareCheckoutUncertain) + } + // Do not wrap SDK/transport errors: they can contain request or response data, + // and the root error handler extracts raw SDK error messages through Unwrap. + return fmt.Errorf("prepare_checkout result unavailable; %s", vaultPrepareCheckoutUncertain) +} diff --git a/cmd/vaults_prepare_checkout_test.go b/cmd/vaults_prepare_checkout_test.go new file mode 100644 index 00000000..7360ddc4 --- /dev/null +++ b/cmd/vaults_prepare_checkout_test.go @@ -0,0 +1,167 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const prepareCheckoutOperation = `[{"type":"prepare_checkout","description":"Prepare this unused AgentCard card before the first Square Pay action."}]` + +// A ready AgentCard card that advertises prepare_checkout. +var unusedAgentCardFixture = strings.ReplaceAll(strings.ReplaceAll( + `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"agentcard","wallet":"wallet-1","merchant":"Example Shop","amount":2599,"currency":"usd"},"state":{"provider":"agentcard","status":"ready"},"available_operations":OPS,"available_expansions":[]}`, + "OPS", prepareCheckoutOperation), "\n", "") + +// The same card after preparation, awaiting cardholder device approval. +var preparingAgentCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"agentcard","wallet":"wallet-1","merchant":"Example Shop","amount":2599,"currency":"usd"},"state":{"provider":"agentcard","status":"preparing","preparation":{"id":"prep-1","status":"awaiting_approval","browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production","approval_url":"https://provider.example/approve","created_at":"2026-01-01T12:00:00Z","expires_at":"2026-01-01T12:00:30Z"}},"available_operations":[],"available_expansions":[]}` + +func TestVaultPrepareCheckoutSendsCheckoutContext(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + body := unusedAgentCardFixture + if r.Method == http.MethodPost { + assert.Equal(t, "/vaults/checkout/items/order-1/operations", r.URL.Path) + raw, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"prepare_checkout","checkout":{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}}`, string(raw)) + body = preparingAgentCardFixture + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "prepare_checkout", + "--params", `{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com/","environment":"production"}`, "-o", "json") + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.JSONEq(t, preparingAgentCardFixture, out) +} + +func TestVaultPrepareCheckoutRendersPreparationAndDeadline(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + body := unusedAgentCardFixture + if r.Method == http.MethodPost { + body = preparingAgentCardFixture + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "prepare_checkout", + "--params", `{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}`) + require.NoError(t, err) + assert.Contains(t, human, "prep-1") + assert.Contains(t, human, "awaiting_approval") + assert.Contains(t, human, "https://shop.example.com") + assert.Contains(t, human, "Submit native Pay before") + assert.Contains(t, human, "https://provider.example/approve") + assert.Contains(t, human, "Keep the approval page open") + assert.Contains(t, human, "display-only") +} + +func TestVaultPrepareCheckoutOpensApprovalURL(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + body := unusedAgentCardFixture + if r.Method == http.MethodPost { + body = preparingAgentCardFixture + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + opened := "" + handler := VaultsCmd{vaults: &client.Vaults, openURL: func(url string) error { opened = url; return nil }} + params, err := parseVaultOperationParams("prepare_checkout", `{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}`, true, true) + require.NoError(t, err) + captureStdout(t, func() { + err = handler.Invoke(t.Context(), "checkout", "order-1", "prepare_checkout", params, "json", true) + }) + require.NoError(t, err) + assert.Equal(t, "https://provider.example/approve", opened) +} + +func TestVaultPrepareCheckoutRejectsInvalidParams(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + for name, params := range map[string]string{ + "missing": "", + "empty browser": `{"browser_id":" ","merchant_origin":"https://shop.example.com","environment":"production"}`, + "bad environment": `{"browser_id":"b","merchant_origin":"https://shop.example.com","environment":"staging"}`, + "origin with path": `{"browser_id":"b","merchant_origin":"https://shop.example.com/checkout","environment":"production"}`, + "origin query": `{"browser_id":"b","merchant_origin":"https://shop.example.com?a=1","environment":"production"}`, + "insecure origin": `{"browser_id":"b","merchant_origin":"http://shop.example.com","environment":"production"}`, + "credentials": `{"browser_id":"b","merchant_origin":"https://user:pass@shop.example.com","environment":"production"}`, + "unknown key": `{"browser_id":"b","merchant_origin":"https://shop.example.com","environment":"production","tab_id":"1"}`, + "explicit type": `{"type":"prepare_checkout","browser_id":"b","merchant_origin":"https://shop.example.com","environment":"production"}`, + } { + t.Run(name, func(t *testing.T) { + args := []string{"vaults", "items", "invoke", "checkout", "order-1", "prepare_checkout"} + if params != "" { + args = append(args, "--params", params) + } + _, _, err := executeVaultCommand(t, client, args...) + require.Error(t, err) + }) + } +} + +func TestVaultMerchantOriginAcceptsLoopbackAndCanonicalizes(t *testing.T) { + for input, want := range map[string]string{ + "https://shop.example.com": "https://shop.example.com", + "https://shop.example.com/": "https://shop.example.com", + "https://shop.example.com:8443": "https://shop.example.com:8443", + "http://localhost:3000": "http://localhost:3000", + "http://127.0.0.1": "http://127.0.0.1", + } { + got, err := vaultMerchantOrigin(input) + require.NoError(t, err, input) + assert.Equal(t, want, got) + } +} + +func TestVaultPrepareCheckoutFailureDoesNotSuggestRetry(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"code":"item_not_ready","message":"Item not ready"}`) + return + } + _, _ = io.WriteString(w, unusedAgentCardFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "prepare_checkout", + "--params", `{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}`) + require.ErrorContains(t, err, "prepare_checkout failed (HTTP 409)") + require.ErrorContains(t, err, "single-use preparation may still have been created") + // One GET plus one POST: a single-use preparation is never retried. + assert.Equal(t, 2, calls) +} + +func TestVaultItemGuidanceForPreparedStatuses(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for status, want := range map[string]string{ + "ready_to_submit": "at most 30 seconds", + "consumed": "does not mean an order or charge succeeded", + "stopped": "cannot be reused", + "outcome_unknown": "new requests are blocked", + } { + t.Run(status, func(t *testing.T) { + body := strings.ReplaceAll(preparingAgentCardFixture, `"status":"preparing"`, `"status":"`+status+`"`) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1") + require.NoError(t, err) + assert.Contains(t, human, want) + }) + } +} diff --git a/cmd/vaults_secrets.go b/cmd/vaults_secrets.go index 24803447..4b4c66f1 100644 --- a/cmd/vaults_secrets.go +++ b/cmd/vaults_secrets.go @@ -33,7 +33,23 @@ func vaultCredentialError(err error) error { return fmt.Errorf("vault request failed; details withheld to protect credentials; inspect existing state before taking further action") } -func readVaultSecrets(cmd *cobra.Command, flag string, fields ...string) (map[string]string, error) { +// Credential item writes fail for reasons a wallet or provider configuration +// cannot, so map their conflicts to what the caller must actually reconcile. +// Details stay withheld: bodies and transport errors can echo submitted values. +func vaultCredentialItemError(err error) error { + var apiErr *kernel.Error + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case 400: + return fmt.Errorf("credential request rejected (HTTP 400); field names must be declared, values must satisfy their declared type, and a required totp field needs a valid Base32 seed that no form can collect") + case 409: + return fmt.Errorf("credential conflict (HTTP 409); the item changed since your last read or is not a credential item. Re-read it with items get and retry with the version it returns") + } + } + return vaultCredentialError(err) +} + +func readVaultSecretFile(cmd *cobra.Command, flag string) ([]byte, error) { path, _ := cmd.Flags().GetString(flag) if path == "" { return nil, fmt.Errorf("--%s requires a file path or '-' for stdin", flag) @@ -52,6 +68,14 @@ func readVaultSecrets(cmd *cobra.Command, flag string, fields ...string) (map[st if err != nil || len(data) > maxBytes { return nil, fmt.Errorf("could not read --%s (maximum 1 MiB)", flag) } + return data, nil +} + +func readVaultSecrets(cmd *cobra.Command, flag string, fields ...string) (map[string]string, error) { + data, err := readVaultSecretFile(cmd, flag) + if err != nil { + return nil, err + } var values map[string]string if json.Unmarshal(data, &values) != nil || len(values) != len(fields) { return nil, fmt.Errorf("--%s must contain only the documented non-empty JSON string fields", flag) @@ -81,3 +105,23 @@ func vaultSpecHasSecrets(value json.RawMessage) bool { } return false } + +// Credential values are write-only secrets, so they arrive through a protected +// file or stdin rather than shell arguments. A null value clears a stored value +// on update; creation rejects null and empty values separately. +func readVaultFieldValues(cmd *cobra.Command, flag string) (map[string]*string, error) { + data, err := readVaultSecretFile(cmd, flag) + if err != nil { + return nil, err + } + var values map[string]*string + if json.Unmarshal(data, &values) != nil || len(values) < 1 || len(values) > 32 { + return nil, fmt.Errorf("--%s must be a JSON object mapping 1-32 field names to string or null values", flag) + } + for name := range values { + if !vaultFieldNamePattern.MatchString(name) { + return nil, fmt.Errorf("--%s field names must match [a-zA-Z][a-zA-Z0-9_]{0,63}", flag) + } + } + return values, nil +} diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 8584a189..1f3ee4b5 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -33,8 +33,16 @@ func vaultTestClient(t *testing.T, handler http.HandlerFunc) kernel.Client { } func executeVaultCommand(t *testing.T, client kernel.Client, args ...string) (string, string, error) { + t.Helper() + return executeVaultCommandWithStdin(t, client, nil, args...) +} + +func executeVaultCommandWithStdin(t *testing.T, client kernel.Client, stdin io.Reader, args ...string) (string, string, error) { t.Helper() root := &cobra.Command{Use: "kernel", SilenceErrors: true, SilenceUsage: true} + if stdin != nil { + root.SetIn(stdin) + } root.PersistentFlags().String("project", "", "Project") root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { project, _ := cmd.Flags().GetString("project") @@ -52,7 +60,7 @@ func executeVaultCommand(t *testing.T, client kernel.Client, args ...string) (st } func TestVaultCommandConstruction(t *testing.T) { - for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "cards update", "items invoke"} { + for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "cards update", "items invoke", "credentials create", "credentials update"} { t.Run(path, func(t *testing.T) { cmd, remaining, err := newVaultsCommand().Find(strings.Fields(path)) require.NoError(t, err) diff --git a/go.mod b/go.mod index fa46bff5..e7169cca 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.102.0 + github.com/kernel/kernel-go-sdk v0.103.1-0.20260915001710-bcf94cc5a1bd 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 6f4ca2b0..fc56e724 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 v0.103.1-0.20260915001710-bcf94cc5a1bd h1:SaXXMQpHFsZfHtdR0uR0XbcGGV0pngJYB6mFZyzJo9w= +github.com/kernel/kernel-go-sdk v0.103.1-0.20260915001710-bcf94cc5a1bd/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=