diff --git a/README.md b/README.md index 9641813d..06ef1a61 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Kernel provides sandboxed, ready-to-use Chrome browsers for browser automations - Invoke app actions (sync or async) and stream logs - Create, list, view, and delete managed browser sessions - Get a live view URL for visual monitoring and remote control +- Search the web across providers and retrieve page content for results ## Installation @@ -156,6 +157,9 @@ kernel search get srch_01jsearchresult # Use portable filters or other advanced request fields kernel search --request '{"query":"browser automation","include_domains":["example.com"],"strict_params":true}' + +# Fetch content for the top results of a retained search +kernel search contents srch_01jsearchresult --limit 3 --content-source browser ``` - `--max-results` accepts 1–100; the API may clamp it to the provider cap. @@ -167,9 +171,18 @@ kernel search --request '{"query":"browser automation","include_domains":["examp - Create requests are not automatically retried, to avoid duplicate billable searches after an ambiguous failure. If a request fails ambiguously, use `search get` only when the API returned a retained search ID. -- Retained searches return 404 when missing, expired, or inaccessible. Deferred - content retrieval is not exposed because it is reserved but unavailable in the - current API contract. +- Retained searches return 404 when missing, expired, or inaccessible. +- `search contents ` retrieves content for selected results of a retained + search. Provide exactly one of `--result-ids` (in desired response order) or + `--limit` (top N results, 1–100). `--timeout-ms` sets the overall deadline + (1000–120000). Content options: `--content-source` (`auto`, `provider`, or + `browser`; default `auto`), `--content-format` (`markdown` or `text`), + `--content-max-chars`, `--content-max-age-hours`, `--content-timeout-ms`, + `--content-browser-id` (reuse an existing browser session), and + `--content-browser-mode` (`curl` or `render`). When no browser ID is given and a + result needs browser retrieval, Kernel creates a temporary browser for the + request; it is billed like any other browser. Requests are not automatically + retried. - To search for a literal query equal to a subcommand name (`get` or `providers`), use `--request '{"query":"providers"}'`. @@ -268,6 +281,7 @@ kernel search --request '{"query":"browser automation","include_domains":["examp - `--telemetry=off` - Disable telemetry - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` - `--telemetry-export-otlp ` - Export captured telemetry over OTLP to one of the org's configured destinations. Implies `--telemetry=all` when `--telemetry` is not set, since export requires capture. Use `--telemetry-export-otlp=off` to disable export. + - `--telemetry-storage on|off` - Whether to persist captured telemetry to Kernel storage (default on). `off` requires `--telemetry-export-otlp ` in the same command, so events are only available on the live stream and through the export. Cannot be changed after the browser is created. - `--chrome-policy ` - Custom Chrome enterprise policy as a JSON object. Kernel-managed policies (extensions, proxy, automation) are rejected server-side. - `--chrome-policy-file ` - Read the Chrome enterprise policy from a file (use `-` for stdin). Mutually exclusive with `--chrome-policy`. - `--output json`, `-o json` - Output raw JSON object @@ -335,8 +349,17 @@ populated, not that login succeeded. `fill` requires an already-open page and ne navigates or submits it. Optional `page_url` selects the exact page; cards require it. Do not automatically retry failed/unknown fills or fall back to aliases. +Create specs list `fields` as an ordered array. Each entry carries a stable `name` +(letters, digits, and underscores, starting with a letter) that keys values, updates, +and fills. Order is preserved: list fields in the same top-to-bottom order as the +website, because the collection form renders that order unchanged. An optional `label` +supplies non-secret display text for that field on the collection form; it never +affects value keys, updates, or fills. Use a single trimmed line of at most 128 UTF-8 +bytes, and it is returned as metadata in `get`/`list` output. + Use `credentials update --version --spec-file changes.json` -with a spec such as `{"fields":{"password":{"value":"replacement"}}}`. Keep actual +with a spec such as `{"fields":{"password":{"value":"replacement"}}}`; update specs key +`fields` by name rather than using the ordered array. Keep actual secrets in protected files or stdin, never shell arguments. Omission preserves values; null or an empty string clears supported fields, including required text/email/password fields (returning them to pending collection). The form still requires nonempty required inputs. Field definitions cannot change. Stale versions fail, without retries. `items invoke collect` reopens the full form without @@ -422,10 +445,10 @@ elevate a project-scoped API key. Existing Kernel-managed wallet commands remain | Command | Purpose | | --- | --- | -| `kernel vault-provider-configs create --name --provider link\|agentcard --credentials-file ` | Register client credentials; file JSON contains `client_id` and `client_secret` strings | +| `kernel vault-provider-configs create --name --provider link\|agentcard --credentials-file ` | Register client credentials; file JSON contains `client_id` and `client_secret` strings. Link: `--publishable-key` sets the Stripe publishable key Kernel needs to refresh and revoke imported wallet grants | | `kernel vault-provider-configs list` | `--limit 1..100`, `--offset`; JSON includes `vault_provider_configs` and optional `next_offset` | | `kernel vault-provider-configs get ` | Show public metadata (`show` is an alias); AgentCard `test_mode` is introspected, not selectable | -| `kernel vault-provider-configs update ` | `--name` renames; `--credentials-file` rotates using a JSON object containing only `client_secret` | +| `kernel vault-provider-configs update ` | `--name` renames; `--credentials-file` rotates using a JSON object containing only `client_secret`; `--publishable-key` sets the Link publishable key | | `kernel vault-provider-configs delete ` | Delete only when no non-deleted items reference it; `--yes` skips confirmation | Config commands support `-o json` except delete. Secrets never appear in list/get/write output. @@ -456,7 +479,7 @@ stop refreshing that grant in your backend: Kernel owns subsequent refresh-token ```bash kernel vault-provider-configs create --name link-client --provider link \ - --credentials-file /secure/link-client.json + --credentials-file /secure/link-client.json --publishable-key pk_live_... kernel vaults wallets create checkout imported-wallet --provider link --spec '{}' \ --provider-config-name link-client --tokens-file /secure/link-grant.json ``` @@ -597,14 +620,21 @@ kernel vaults items get user-123 order-1 --wait 60 -o json this vault attached. `merchant_origin` is the canonical HTTPS origin of the top-level merchant document, not a processor iframe; HTTP localhost is allowed for tests. -Optional `psp` selects the tokenization processor: `square`, `braintree`, `worldpay`, -`bambora`, or `mercado_pago`. Omit it for Square; non-Square processors require +Optional `psp` selects the checkout processor: `square`, `braintree`, `worldpay`, +`bambora`, `mercado_pago`, or `adyen`. Omit it for Square; non-Square processors require multi-processor preparation enablement. `environment` is `production`, `sandbox`, or -`shared`: use `production` or `sandbox` for Square, Braintree and Worldpay, and `shared` -for Bambora and Mercado Pago. Shared endpoints do not establish test mode; merchant +`shared`: use `production` or `sandbox` for Square, Braintree, Worldpay and Adyen, and +`shared` for Bambora and Mercado Pago. Shared endpoints do not establish test mode; merchant credentials and configuration determine processor test mode, independently of the AgentCard credential mode. +`adyen` supports fresh-card Sessions requests on Adyen hosts only. Fill the public dummy +card fields rather than vault aliases, and keep the approval page open through device +handoff, including Adyen encryption. The unique armed preparation is associated with the +next eligible request from the declared browser and merchant origin; competing preparations +are rejected. Adyen device approval and browser `Authorised` responses are not capture or +fulfillment evidence. + Keep the approval page open. Poll until the item's status is `ready_to_submit`, then submit native Pay before `state.preparation.expires_at`. Readiness lasts at most 30 seconds, and polling does not extend it. The CLI displays the preparation ID, status, @@ -698,6 +728,7 @@ exists. - `--fill-rate ` - Percentage of the pool to fill per minute - `--timeout ` - Idle timeout for browsers acquired from the pool - `--stealth`, `--headless`, `--kiosk` - Default pool configuration + - `--memory 8GiB|16GiB` - Memory for headful browsers in the pool (default 8GiB) - `--refresh-on-profile-update` - Flush idle browsers when the pool's profile is updated (requires a profile) - `--profile-id`, `--profile-name`, `--proxy-id`, `--region`, `--start-url`, `--extension`, `--viewport`, `--private-host` - Same semantics as `kernel browsers create` - `--chrome-policy ` / `--chrome-policy-file ` - Custom Chrome enterprise policy applied to every browser in the pool, as a JSON object or from a file (`-` for stdin). Same semantics as `kernel browsers create`. @@ -706,7 +737,7 @@ exists. - `kernel browser-pools get ` - Get pool details - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools update ` - Update pool configuration - - Same flags as create (except `--region`, which is fixed at creation and cannot be updated) plus `--clear-profile`, `--clear-proxy`, `--clear-start-url`, `--clear-extensions`, `--clear-chrome-policy`, and `--clear-private-hosts` for removing durable configuration. `--clear-private-hosts` restores the default private IP ranges. `--fill-rate 0` pauses automatic filling. `--discard-all-idle` discards all idle browsers and refills the pool. `--telemetry` and private-host updates only apply to browsers warmed after the update. + - Same flags as create (except `--region`, which is fixed at creation and cannot be updated) plus `--clear-profile`, `--clear-proxy`, `--clear-start-url`, `--clear-extensions`, `--clear-chrome-policy`, and `--clear-private-hosts` for removing durable configuration. `--clear-private-hosts` restores the default private IP ranges. `--fill-rate 0` pauses automatic filling. `--discard-all-idle` discards all idle browsers and refills the pool. `--telemetry`, `--memory`, and private-host updates only apply to browsers warmed after the update. - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools delete ` - Delete a pool - `--force` - Force delete even if browsers are leased @@ -758,6 +789,7 @@ Captured telemetry can be exported over OTLP to one of the org's configured dest - Capture and export: `kernel browsers create --telemetry-export-otlp my-collector` - Capture without exporting: `kernel browsers create --telemetry=all` - Stop exporting: `--telemetry-export-otlp=off` +- Export only, without persisting to Kernel storage: `kernel browsers create --telemetry-export-otlp my-collector --telemetry-storage off` Export is bound at session creation, so it is available on `browsers create` and on the managed-auth commands that create a browser (`auth connections create`, `update`, and `login`). A browser session keeps the destination it was created with — `browsers update` cannot change it — and browser pools do not support export. @@ -951,6 +983,13 @@ Destinations are the OTLP/HTTP endpoints sessions export to. They belong to the - Prints the tool's `output` as pretty JSON on completion; tool errors and cancellations exit non-zero - `awaiting_submission` is successful but warns that a non-autosubmit declarative form was filled, not submitted. Inspect the form, obtain any required confirmation, then submit through Playwright or computer interaction instead of invoking the tool again - Invocations are never retried automatically. A 504 `outcome_unknown` error prints the code, invocation ID, and message and exits non-zero. The tool may already have had side effects; verify the outcome before invoking it again +- `kernel browsers webmcp custom-tools list ` - List registered custom tools with their generated ID, namespace, kind, URL patterns, and metadata + - `-o json` - Output the raw response +- `kernel browsers webmcp custom-tools add --namespace --source ''` - Atomically add a namespaced batch of page-backed or CDP-backed custom tools + - `--source ` or `--source-file ` - JavaScript expression evaluating to a non-empty array of tool definitions (URL matchers, tool metadata, execute functions); mutually exclusive. Use `--source-file -` to read stdin + - `--force-overwrite-namespace` - Atomically replace every existing tool in the namespace + - `-o json` - Output the raw response +- `kernel browsers webmcp custom-tools remove ` - Remove one custom tool by generated ID (in-progress invocations are not canceled) - `kernel browsers webmcp custom-tools list ` - List all registered custom tools, even when no page currently matches - Displays ID, namespace, kind (`page` or `cdp`), name, and URL patterns @@ -1080,18 +1119,21 @@ Managed auth connections (`kernel auth connections`). The commands below are new - `--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. + - `--telemetry-storage on|off` - Whether this connection's sessions persist captured telemetry to Kernel storage (default on). `off` requires `--telemetry-export-otlp ` in the same command. - `kernel auth connections update ` - New flags: - `--region us-east|eu-west|ap-southeast` - Update the region for browser sessions created after this command. Active sessions don't move. - `--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. - `--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. + - `--telemetry-storage on|off` - Update whether future sessions persist captured telemetry to Kernel storage. Requires `--telemetry` in the same command; `off` also requires an export destination. - `kernel auth connections login ` - New flags: - `--region us-east|eu-west|ap-southeast` - Region override for this login only. Omit it to inherit the connection region. - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy override for this login's browser session (mutually exclusive); omitted properties inherit the connection defaults - `--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. + - `--telemetry-storage on|off` - Storage override for this login only. Requires `--telemetry` in the same command; `off` also requires an export destination. - `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 @@ -1209,6 +1251,55 @@ Automated authentication for web services. The `run` command orchestrates the fu - `--default-project-max-concurrent-sessions ` - Default maximum concurrent browsers for projects without an explicit override (`0` to remove the default) - `--output json`, `-o json` - Output raw JSON object +### Search + +- `kernel search ` - Search the web through Kernel's search providers + - `--country ` - ISO 3166-1 alpha-2 search locale preference + - `--language ` - BCP 47 search language preference + - `--max-results ` - Requested result count, 1-100 (clamped to the serving provider's cap) + - `--recency ` - Relative search window: `hour`, `day`, `week`, `month`, or `year` + - `--safe-search ` - Safety preference: `off`, `moderate`, or `strict` + - `--start-date ` / `--end-date ` - Inclusive publication-date bounds (`--recency` takes precedence) + - `--include-domains ` / `--exclude-domains ` - Hostname preferences, matching a hostname and its subdomains + - `--strict-params` - Require every supplied portable parameter to be honored exactly instead of approximated + - `--include-raw` - Include untouched provider payloads in the response's raw fields + - `--timeout-ms ` - Overall deadline across search attempts and inline retrieval + - `--content` - Retrieve page content for each result using portable defaults + - `--show-content` - Print the extracted content text for each result (implies `--content`) + - `--content-source ` - Retrieval source: `auto`, `provider`, or `browser` + - `--content-format ` - Extracted content format: `markdown` or `text` + - `--content-max-chars ` - Per-result Unicode character limit after extraction + - `--content-max-age-hours ` - Maximum acceptable age of cached page content; `0` forces a live fetch + - `--content-timeout-ms ` - Per-result retrieval deadline + - `--content-browser-id ` - Retrieve through an existing browser session (requires `--content-source browser`) + - `--content-browser-mode ` - Browser retrieval mode: `curl` or `render` + - `--provider ` - Pin a single provider (`brave`, `exa`, `perplexity`, `context`, `parallel`, `valyu`, `octen`, `you`, `tavily`, `serpapi`) + - `--fallback-providers ` - Ordered provider chain to try in turn + - `--fallback-on ` - Outcomes that advance to the next provider: `error`, `timeout`, `empty` + - `--provider-options ` - Provider-native options as a JSON object keyed by provider slug + - `--output json`, `-o json` - Output raw JSON object +- `kernel search get ` - Re-read a retained search without calling a provider or incurring cost + - `--show-content` - Print the extracted content text for each result + - `--output json`, `-o json` - Output raw JSON object +- `kernel search providers` - List providers, result caps, and content capabilities + - `--slug ` - Filter to a single provider; also prints its portable-parameter support matrix and notes + - `--output json`, `-o json` - Output raw JSON array +- `kernel search contents ` - Fetch content for selected results of a retained search + - `--result-ids ` - Result IDs from the retained search, in the desired response order + - `--limit ` - Number of results to fetch starting from rank 1 (mutually exclusive with `--result-ids`) + - `--timeout-ms ` - Overall deadline across all selected results + - `--content-source ` - `auto` (default), `provider`, or `browser` + - `--content-format ` - `markdown` or `text` + - `--content-max-chars ` - Per-result character limit after extraction + - `--content-max-age-hours ` - For `auto`, maximum age of retained provider content; `0` always uses a browser + - `--content-timeout-ms ` - Per-result deadline + - `--content-browser-id ` - Retrieve through an existing browser session + - `--content-browser-mode ` - `curl` or `render` + +Searches are retained for 24 hours. Omitting the strategy flags lets Kernel pick an +eligible provider; portable filters a provider cannot honor are approximated or +dropped and reported as warnings unless `--strict-params` is set. + ## Examples ### Create a new app diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index be139f69..af052b14 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -60,6 +60,7 @@ type AuthConnectionCreateInput struct { Telemetry string TelemetryCdpExclude string TelemetryExport string + TelemetryStorage string Output string } @@ -97,6 +98,7 @@ type AuthConnectionUpdateInput struct { Telemetry string TelemetryCdpExclude string TelemetryExport string + TelemetryStorage string Output string } @@ -122,9 +124,11 @@ type AuthConnectionLoginInput struct { Region string Stealth BoolFlag RecordSession BoolFlag + SkillMode string Telemetry string TelemetryCdpExclude string TelemetryExport string + TelemetryStorage string Output string } @@ -251,11 +255,16 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" || in.TelemetryStorage != "" { t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, true) if err != nil { return err } + storage, err := resolveTelemetryStorageFlag(in.TelemetryStorage, in.Telemetry, in.TelemetryExport, true) + if err != nil { + return err + } + t.Storage.Enabled = storage params.ManagedAuthCreateRequest.Browser.Telemetry = t } @@ -412,11 +421,16 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn hasChanges = true } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" || in.TelemetryStorage != "" { t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) if err != nil { return err } + storage, err := resolveTelemetryStorageFlag(in.TelemetryStorage, in.Telemetry, in.TelemetryExport, false) + if err != nil { + return err + } + t.Storage.Enabled = storage params.ManagedAuthUpdateRequest.Browser.Telemetry = t hasChanges = true } @@ -692,6 +706,9 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e if auth.HealthCheckInterval > 0 { tableData = append(tableData, []string{"Health Check Interval", fmt.Sprintf("%d seconds", auth.HealthCheckInterval)}) } + if auth.HealthCheckUnavailableReason != "" { + tableData = append(tableData, []string{"Health Check Unavailable", string(auth.HealthCheckUnavailableReason)}) + } if auth.BrowserSessionID != "" { tableData = append(tableData, []string{"Browser Session ID", auth.BrowserSessionID}) } @@ -794,6 +811,20 @@ func (c AuthConnectionCmd) Delete(ctx context.Context, in AuthConnectionDeleteIn return nil } +// parseSkillModeFlag validates the --skill-mode value against the modes the API +// accepts for a login, so a typo fails locally instead of starting a flow with +// the wrong skill behavior. +func parseSkillModeFlag(mode string) (kernel.AuthConnectionLoginParamsSkillMode, error) { + switch kernel.AuthConnectionLoginParamsSkillMode(mode) { + case kernel.AuthConnectionLoginParamsSkillModeEnabled: + return kernel.AuthConnectionLoginParamsSkillModeEnabled, nil + case kernel.AuthConnectionLoginParamsSkillModeDisabled: + return kernel.AuthConnectionLoginParamsSkillModeDisabled, nil + default: + return "", fmt.Errorf("invalid --skill-mode value: %s (must be one of enabled, disabled)", mode) + } +} + func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -825,11 +856,24 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu params.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + if in.SkillMode != "" { + mode, err := parseSkillModeFlag(in.SkillMode) + if err != nil { + return err + } + params.SkillMode = mode + } + + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" || in.TelemetryStorage != "" { t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) if err != nil { return err } + storage, err := resolveTelemetryStorageFlag(in.TelemetryStorage, in.Telemetry, in.TelemetryExport, false) + if err != nil { + return err + } + t.Storage.Enabled = storage params.Browser.Telemetry = t } @@ -1069,7 +1113,7 @@ func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimeli return nil } - tableData := pterm.TableData{{"Timestamp", "Type", "Status", "Step", "Browser Session", "Telemetry", "Details"}} + tableData := pterm.TableData{{"Timestamp", "Completed", "Type", "Status", "Step", "Browser Session", "Telemetry", "Details"}} for _, e := range events { details := e.ErrorMessage if details == "" { @@ -1087,6 +1131,9 @@ func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimeli } tableData = append(tableData, []string{ util.FormatLocal(e.Timestamp), + // Absent (dashed out) for in-progress attempts, health checks, and + // older attempts recorded before completion times were persisted. + util.FormatLocal(e.CompletedAt), string(e.Type), string(e.Status), string(e.Step), @@ -1325,6 +1372,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-storage", "", "Whether this connection's browser sessions persist captured telemetry to Kernel storage: on (default) or off. Turning storage off requires --telemetry-export-otlp= in the same command, so events are only available on the live stream and through the export") 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") @@ -1356,6 +1404,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-storage", "", "Update whether future sessions persist captured telemetry to Kernel storage: on or off. Requires --telemetry in the same command; turning storage off also requires --telemetry-export-otlp=") 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") @@ -1381,8 +1430,10 @@ func init() { authConnectionsLoginCmd.Flags().String("region", "", "Geographic region override for this login: 'us-east', 'eu-west', or 'ap-southeast'") 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("skill-mode", "", "Whether this login reads and writes learned domain skills: 'enabled' (default) or 'disabled'. Automatic reauths inherit the selected mode until a later accepted login sets enabled or omits the flag") 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-storage", "", "Storage override for this login only: on or off. Requires --telemetry in the same command; turning storage off also requires --telemetry-export-otlp=") 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 @@ -1441,6 +1492,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { telemetry, _ := cmd.Flags().GetString("telemetry") telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") + telemetryStorage, _ := cmd.Flags().GetString("telemetry-storage") svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} @@ -1466,6 +1518,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { Telemetry: telemetry, TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, + TelemetryStorage: telemetryStorage, Output: output, }) } @@ -1501,6 +1554,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { telemetry, _ := cmd.Flags().GetString("telemetry") telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") + telemetryStorage, _ := cmd.Flags().GetString("telemetry-storage") saveCredentialsFlag := BoolFlag{} @@ -1556,6 +1610,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { Telemetry: telemetry, TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, + TelemetryStorage: telemetryStorage, Output: output, }) } @@ -1600,9 +1655,11 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") region, _ := cmd.Flags().GetString("region") + skillMode, _ := cmd.Flags().GetString("skill-mode") telemetry, _ := cmd.Flags().GetString("telemetry") telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") + telemetryStorage, _ := cmd.Flags().GetString("telemetry-storage") svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} @@ -1614,9 +1671,11 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { Region: region, Stealth: readBoolFlag(cmd.Flags(), "stealth"), RecordSession: readBoolFlag(cmd.Flags(), "record-session"), + SkillMode: skillMode, Telemetry: telemetry, TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, + TelemetryStorage: telemetryStorage, Output: output, }) } diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index f23888c3..8e64f39f 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" @@ -137,6 +138,30 @@ func TestAuthConnectionsGet_PrintsSubmissionHints(t *testing.T) { assert.Contains(t, out, "Continue with Google") } +func TestAuthConnectionsGet_PrintsHealthCheckUnavailableReason(t *testing.T) { + setupStdoutCapture(t) + + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + return &kernel.ManagedAuth{ + ID: id, + Domain: "example.com", + ProfileName: "profile-1", + Status: kernel.ManagedAuthStatusAuthenticated, + HealthCheckUnavailableReason: kernel.ManagedAuthHealthCheckUnavailableReasonNoAuthCheckURL, + }, nil + }, + } + c := AuthConnectionCmd{svc: fake} + + err := c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"}) + require.NoError(t, err) + + out := outBuf.String() + assert.Contains(t, out, "Health Check Unavailable") + assert.Contains(t, out, "no_auth_check_url") +} + // TestAuthConnectionsGet_PrintsCanonicalInputMetadata covers the metadata the // API preserves on canonical fields and choices: the field hint naming a masked // code destination, and the MFA type and masked destination that distinguish @@ -1223,7 +1248,8 @@ func TestTimeline_RendersEventsAndPagination(t *testing.T) { "type": "login", "status": "SUCCESS", "browser_session_id": "browser_1", - "telemetry_captured": true + "telemetry_captured": true, + "completed_at": "2026-09-21T12:00:00Z" }`), &loginEvent)) fake := &FakeAuthConnectionService{ TimelineFunc: func(ctx context.Context, id string, query kernel.AuthConnectionTimelineParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent], error) { @@ -1252,6 +1278,9 @@ func TestTimeline_RendersEventsAndPagination(t *testing.T) { // Telemetry capture is reported for events that have a browser session. assert.Contains(t, out, "Telemetry") assert.Regexp(t, `browser_1.*yes`, out) + // completed_at is shown for terminal attempts and dashed out otherwise. + assert.Contains(t, out, "Completed") + assert.Contains(t, out, util.FormatLocal(time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC))) // The third event is truncated off the page. assert.NotContains(t, out, "health_check") assert.Contains(t, out, "Has more: yes") @@ -1390,3 +1419,43 @@ func TestAuthConnectionsGet_TelemetryRowOmittedWhenOff(t *testing.T) { require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"})) assert.NotContains(t, outBuf.String(), "Browser Telemetry") } + +func TestLogin_SkillMode(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", SkillMode: "disabled"})) + assert.Equal(t, kernel.AuthConnectionLoginParamsSkillModeDisabled, captured.SkillMode) +} + +// Omitting --skill-mode leaves the field unset, so the API keeps its default of +// enabled rather than the CLI pinning a mode the user never asked for. +func TestLogin_SkillModeOmitted(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"})) + assert.Empty(t, string(captured.SkillMode)) +} + +func TestLogin_InvalidSkillModeErrors(t *testing.T) { + capturePtermOutput(t) + c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} + + err := c.Login(context.Background(), AuthConnectionLoginInput{ID: "auth_1", SkillMode: "mars"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --skill-mode value") +} diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 14bc79fa..18ffba7d 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -150,6 +150,7 @@ type BrowserPoolsCreateInput struct { Stealth BoolFlag Headless BoolFlag Kiosk BoolFlag + Memory string RefreshOnProfileUpdate BoolFlag ProfileID string ProfileName string @@ -196,6 +197,13 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) if in.Kiosk.Set { params.KioskMode = kernel.Bool(in.Kiosk.Value) } + memory, err := parseMemoryFlag(in.Memory) + if err != nil { + return err + } + if memory != "" { + params.Memory = memory + } if in.RefreshOnProfileUpdate.Set { params.RefreshOnProfileUpdate = kernel.Bool(in.RefreshOnProfileUpdate.Value) } @@ -318,6 +326,7 @@ func (c BrowserPoolsCmd) Get(ctx context.Context, in BrowserPoolsGetInput) error {"Headless", fmt.Sprintf("%t", cfg.Headless)}, {"Stealth", fmt.Sprintf("%t", cfg.Stealth)}, {"Kiosk Mode", fmt.Sprintf("%t", cfg.KioskMode)}, + {"Memory", util.OrDash(string(cfg.Memory))}, {"Refresh On Profile Update", fmt.Sprintf("%t", cfg.RefreshOnProfileUpdate)}, {"Profile", formatProfile(cfg.Profile)}, {"Proxy ID", util.OrDash(cfg.ProxyID)}, @@ -341,6 +350,7 @@ type BrowserPoolsUpdateInput struct { Stealth BoolFlag Headless BoolFlag Kiosk BoolFlag + Memory string RefreshOnProfileUpdate BoolFlag ProfileID string ProfileName string @@ -422,6 +432,13 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) if in.Kiosk.Set { params.KioskMode = kernel.Bool(in.Kiosk.Value) } + memory, err := parseMemoryFlag(in.Memory) + if err != nil { + return err + } + if memory != "" { + params.Memory = kernel.BrowserPoolUpdateParamsMemory(memory) + } if in.DiscardAllIdle.Set { params.DiscardAllIdle = kernel.Bool(in.DiscardAllIdle.Value) } @@ -551,16 +568,42 @@ type BrowserPoolsAcquireInput struct { Tags map[string]string Telemetry string TelemetryCdpExclude string + ProfileID string + ProfileName string + ProfileSaveChanges bool Output string } +// buildAcquireProfileParam validates the --profile-id/--profile-name/--save-changes +// flags for a pool acquire and converts them to the per-lease profile param. +// Browsers loaded with an acquire-time profile are destroyed on release rather +// than returned to the pool. +func buildAcquireProfileParam(profileID, profileName string, saveChanges bool) (kernel.BrowserProfileParam, error) { + if profileID != "" && profileName != "" { + return kernel.BrowserProfileParam{}, fmt.Errorf("must specify at most one of --profile-id or --profile-name") + } + if profileID == "" && profileName == "" { + if saveChanges { + return kernel.BrowserProfileParam{}, fmt.Errorf("--save-changes requires --profile-id or --profile-name") + } + return kernel.BrowserProfileParam{}, nil + } + profile := kernel.BrowserProfileParam{SaveChanges: kernel.Opt(saveChanges)} + if profileID != "" { + profile.ID = kernel.Opt(profileID) + } else { + profile.Name = kernel.Opt(profileName) + } + return profile, nil +} + // buildAcquireParams builds the SDK params for acquiring a browser from a pool. // Shared by `browser-pools acquire` and the `browsers create --pool-id/--pool-name` -// path so the per-lease name/tags/start-url/telemetry forwarding cannot silently +// path so the per-lease name/tags/start-url/telemetry/profile 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, telemetryCdpExclude, startURL string) (kernel.BrowserPoolAcquireParams, error) { - params := kernel.BrowserPoolAcquireParams{} +func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64, telemetry, telemetryCdpExclude, startURL string, profile kernel.BrowserProfileParam) (kernel.BrowserPoolAcquireParams, error) { + params := kernel.BrowserPoolAcquireParams{Profile: profile} if timeoutSeconds > 0 { params.AcquireTimeoutSeconds = kernel.Int(timeoutSeconds) } @@ -588,7 +631,11 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu return err } - params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.TelemetryCdpExclude, in.StartURL) + profile, err := buildAcquireProfileParam(in.ProfileID, in.ProfileName, in.ProfileSaveChanges) + if err != nil { + return err + } + params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.TelemetryCdpExclude, in.StartURL, profile) if err != nil { return err } @@ -623,6 +670,16 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu if resp.StartURL != "" { tableData = append(tableData, []string{"Start URL", resp.StartURL}) } + if resp.Profile.ID != "" || resp.Profile.Name != "" { + profVal := resp.Profile.Name + if profVal == "" { + profVal = resp.Profile.ID + } + tableData = append(tableData, + []string{"Profile", profVal}, + []string{"Profile Save Changes", fmt.Sprintf("%t", resp.ProfileSaveChanges)}, + ) + } if len(resp.Tags) > 0 { tableData = append(tableData, []string{"Tags", formatTags(resp.Tags)}) } @@ -747,6 +804,7 @@ func init() { browserPoolsCreateCmd.Flags().Bool("stealth", false, "Enable stealth mode") browserPoolsCreateCmd.Flags().Bool("headless", false, "Enable headless mode") browserPoolsCreateCmd.Flags().Bool("kiosk", false, "Enable kiosk mode") + browserPoolsCreateCmd.Flags().String("memory", "", "Memory for headful browsers in the pool: '8GiB' (default) or '16GiB'") browserPoolsCreateCmd.Flags().Bool("refresh-on-profile-update", false, "Flush idle browsers when the pool's profile is updated") browserPoolsCreateCmd.Flags().String("profile-id", "", "Profile ID") browserPoolsCreateCmd.Flags().String("profile-name", "", "Profile name") @@ -771,6 +829,7 @@ func init() { browserPoolsUpdateCmd.Flags().Bool("stealth", false, "Enable stealth mode") browserPoolsUpdateCmd.Flags().Bool("headless", false, "Enable headless mode") browserPoolsUpdateCmd.Flags().Bool("kiosk", false, "Enable kiosk mode") + browserPoolsUpdateCmd.Flags().String("memory", "", "Memory for newly-warmed headful browsers in the pool: '8GiB' or '16GiB'. Existing browsers keep their allocation; use --discard-all-idle to replace idle browsers") browserPoolsUpdateCmd.Flags().Bool("refresh-on-profile-update", false, "Flush idle browsers when the pool's profile is updated") browserPoolsUpdateCmd.Flags().String("profile-id", "", "Profile ID") browserPoolsUpdateCmd.Flags().String("profile-name", "", "Profile name") @@ -801,6 +860,9 @@ 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("profile-id", "", "Profile ID to load into the acquired browser for this lease (mutually exclusive with --profile-name; the browser is destroyed and replaced on release)") + browserPoolsAcquireCmd.Flags().String("profile-name", "", "Profile name to load into the acquired browser for this lease (mutually exclusive with --profile-id; the browser is destroyed and replaced on release)") + browserPoolsAcquireCmd.Flags().Bool("save-changes", false, "If set, save changes back to the acquire-time profile when the session ends") 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) @@ -846,6 +908,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { stealth, _ := cmd.Flags().GetBool("stealth") headless, _ := cmd.Flags().GetBool("headless") kiosk, _ := cmd.Flags().GetBool("kiosk") + memory, _ := cmd.Flags().GetString("memory") refreshOnProfileUpdate, _ := cmd.Flags().GetBool("refresh-on-profile-update") profileID, _ := cmd.Flags().GetString("profile-id") profileName, _ := cmd.Flags().GetString("profile-name") @@ -869,6 +932,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, + Memory: memory, RefreshOnProfileUpdate: BoolFlag{Set: cmd.Flags().Changed("refresh-on-profile-update"), Value: refreshOnProfileUpdate}, ProfileID: profileID, ProfileName: profileName, @@ -906,6 +970,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { stealth, _ := cmd.Flags().GetBool("stealth") headless, _ := cmd.Flags().GetBool("headless") kiosk, _ := cmd.Flags().GetBool("kiosk") + memory, _ := cmd.Flags().GetString("memory") refreshOnProfileUpdate, _ := cmd.Flags().GetBool("refresh-on-profile-update") profileID, _ := cmd.Flags().GetString("profile-id") profileName, _ := cmd.Flags().GetString("profile-name") @@ -936,6 +1001,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, + Memory: memory, RefreshOnProfileUpdate: BoolFlag{Set: cmd.Flags().Changed("refresh-on-profile-update"), Value: refreshOnProfileUpdate}, ProfileID: profileID, ProfileName: profileName, @@ -977,6 +1043,9 @@ func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { tags, _ := tagsFromFlag(cmd, "tag") telemetry, _ := cmd.Flags().GetString("telemetry") telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") + profileID, _ := cmd.Flags().GetString("profile-id") + profileName, _ := cmd.Flags().GetString("profile-name") + saveChanges, _ := cmd.Flags().GetBool("save-changes") output, _ := cmd.Flags().GetString("output") c := BrowserPoolsCmd{client: &client.BrowserPools} return c.Acquire(cmd.Context(), BrowserPoolsAcquireInput{ @@ -987,6 +1056,9 @@ func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { Tags: tags, Telemetry: telemetry, TelemetryCdpExclude: telemetryCdpExclude, + ProfileID: profileID, + ProfileName: profileName, + ProfileSaveChanges: saveChanges, Output: output, }) } diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index c7780a1d..ea3b65bd 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", kernel.BrowserProfileParam{Name: kernel.Opt("my-profile")}) assert.NoError(t, err) assert.True(t, p.Name.Valid()) assert.Equal(t, "lease", p.Name.Value) @@ -250,20 +250,78 @@ func TestBuildAcquireParams(t *testing.T) { assert.Equal(t, "https://example.com", p.StartURL.Value) assert.True(t, p.Telemetry.Browser.Console.Enabled.Value) assert.True(t, p.Telemetry.Browser.Network.Enabled.Value) + assert.Equal(t, "my-profile", p.Profile.Name.Value) // Unset inputs produce an empty params struct (nothing forwarded). - empty, err := buildAcquireParams("", nil, 0, "", "", "") + empty, err := buildAcquireParams("", nil, 0, "", "", "", kernel.BrowserProfileParam{}) assert.NoError(t, err) assert.False(t, empty.Name.Valid()) assert.Len(t, empty.Tags, 0) assert.False(t, empty.AcquireTimeoutSeconds.Valid()) assert.False(t, empty.StartURL.Valid()) + assert.False(t, empty.Profile.ID.Valid()) + assert.False(t, empty.Profile.Name.Valid()) // An invalid category surfaces an error rather than a partial param. - _, err = buildAcquireParams("", nil, 0, "bogus", "", "") + _, err = buildAcquireParams("", nil, 0, "bogus", "", "", kernel.BrowserProfileParam{}) assert.Error(t, err) } +func TestBuildAcquireProfileParam(t *testing.T) { + p, err := buildAcquireProfileParam("prof-1", "", true) + assert.NoError(t, err) + assert.Equal(t, "prof-1", p.ID.Value) + assert.False(t, p.Name.Valid()) + assert.True(t, p.SaveChanges.Value) + + p, err = buildAcquireProfileParam("", "my-profile", false) + assert.NoError(t, err) + assert.Equal(t, "my-profile", p.Name.Value) + assert.True(t, p.SaveChanges.Valid()) + assert.False(t, p.SaveChanges.Value) + + empty, err := buildAcquireProfileParam("", "", false) + assert.NoError(t, err) + assert.False(t, empty.ID.Valid()) + assert.False(t, empty.Name.Valid()) + assert.False(t, empty.SaveChanges.Valid()) + + _, err = buildAcquireProfileParam("prof-1", "my-profile", false) + assert.Error(t, err) + _, err = buildAcquireProfileParam("", "", true) + assert.Error(t, err) +} + +func TestBrowserPoolsAcquire_WithProfile(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserPoolAcquireParams + fake := &FakeBrowserPoolsService{ + AcquireFunc: func(ctx context.Context, id string, body kernel.BrowserPoolAcquireParams, opts ...option.RequestOption) (*kernel.BrowserPoolAcquireResponse, error) { + captured = body + return &kernel.BrowserPoolAcquireResponse{ + SessionID: "sess-1", + Profile: kernel.Profile{ID: "prof-1", Name: "my-profile"}, + ProfileSaveChanges: true, + }, nil + }, + } + + c := BrowserPoolsCmd{client: fake} + err := c.Acquire(context.Background(), BrowserPoolsAcquireInput{ + IDOrName: "pool-1", + ProfileName: "my-profile", + ProfileSaveChanges: true, + }) + assert.NoError(t, err) + assert.Equal(t, "my-profile", captured.Profile.Name.Value) + assert.True(t, captured.Profile.SaveChanges.Value) + + out := outBuf.String() + assert.Contains(t, out, "Profile") + assert.Contains(t, out, "my-profile") +} + func TestBrowserPoolsCreate_WithRefreshOnProfileUpdate(t *testing.T) { setupStdoutCapture(t) diff --git a/cmd/browsers.go b/cmd/browsers.go index 13ed46a6..1e79b836 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -468,6 +468,7 @@ type BrowsersCreateInput struct { Telemetry string TelemetryCdpExclude string TelemetryExport string + TelemetryStorage string ChromePolicy string ChromePolicyFile string Name string @@ -769,11 +770,16 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" || in.TelemetryStorage != "" { t, err := buildNewTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport) if err != nil { return err } + storage, err := resolveTelemetryStorageFlag(in.TelemetryStorage, in.Telemetry, in.TelemetryExport, true) + if err != nil { + return err + } + t.Storage.Enabled = storage params.Telemetry = t } @@ -814,7 +820,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } PrintTableNoPad(rows, true) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" || in.TelemetryStorage != "" { printTelemetrySummary(browser.Telemetry) } return nil @@ -3258,6 +3264,7 @@ unrestricted code execution inside the browser VM and is not sandboxed.`, 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("telemetry-storage", "", "Whether to persist captured telemetry to Kernel storage: on (default) or off. Turning storage off requires --telemetry-export-otlp= in the same command, so events are only available on the live stream and through the export; it cannot be changed after the browser is created") 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)") browsersCreateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompts") @@ -3292,7 +3299,7 @@ followed automatically by Chromium.`, telemetryRoot := &cobra.Command{Use: "telemetry", Short: "Browser telemetry operations"} telemetryStream := &cobra.Command{Use: "stream ", Short: "Stream live telemetry events", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStream} - telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by event category (console,network,page,interaction,control,connection,system,screenshot,captcha,monitor)") + telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by event category (console,network,page,interaction,control,platform,connection,system,screenshot,captcha,monitor)") telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") telemetryStream.Flags().Int64("seq", -1, "Resume after sequence number N (Last-Event-ID); replays events with seq > N. Default -1 streams from now") telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") @@ -3306,8 +3313,8 @@ followed automatically by Chromium.`, telemetryEvents.Flags().String("order", "", "Read direction: asc (default) reads oldest first, desc reads newest first (cannot be combined with --since)") telemetryEvents.Flags().String("since", "", "Window start: RFC-3339 timestamp or a duration like 5m (default 5m). Ignored when --offset is set") telemetryEvents.Flags().String("until", "", "Window end (exclusive): RFC-3339 timestamp or a duration like 5m") - telemetryEvents.Flags().StringSlice("categories", []string{}, "Filter by event category (console,network,page,interaction,control,connection,system,screenshot,captcha,monitor)") - telemetryEvents.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error); walks every page in the window") + telemetryEvents.Flags().StringSlice("categories", []string{}, "Filter by event category (console,network,page,interaction,control,platform,connection,system,screenshot,captcha,monitor)") + telemetryEvents.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. page_crashed,captcha_challenge_result); combines with --categories, an event must match both") telemetryEvents.Flags().Bool("all", false, "Walk every page in the window instead of just the first (ignores --offset)") addJSONOutputFlag(telemetryEvents) telemetryRoot.AddCommand(telemetryEvents) @@ -3347,15 +3354,19 @@ func runBrowsersList(cmd *cobra.Command, args []string) error { // this set so they correctly surface that warning rather than being silently ignored. func poolLeaseAllowedFlags() map[string]bool { return map[string]bool{ - "pool-id": true, - "pool-name": true, - "timeout": true, - "name": true, - "start-url": true, - "tag": true, - "telemetry": true, - "output": true, - "yes": true, + "pool-id": true, + "pool-name": true, + "timeout": true, + "name": true, + "start-url": true, + "tag": true, + "telemetry": true, + "telemetry-cdp-exclude": true, + "profile-id": true, + "profile-name": true, + "save-changes": true, + "output": true, + "yes": true, // Global persistent flags that don't configure browsers "no-color": true, "log-level": true, @@ -3392,6 +3403,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { telemetry, _ := cmd.Flags().GetString("telemetry") telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") + telemetryStorage, _ := cmd.Flags().GetString("telemetry-storage") name, _ := cmd.Flags().GetString("name") tags, _ := tagsFromFlag(cmd, "tag") chromePolicy, _ := cmd.Flags().GetString("chrome-policy") @@ -3419,7 +3431,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if poolID != "" || poolName != "" { // When using a pool, configuration comes from the pool itself, but - // name, start URL, tags, and telemetry apply per-lease to the acquired + // name, start URL, tags, telemetry, and profile apply per-lease to the acquired // session — they mirror the fields BrowserPoolAcquireParams accepts. allowedFlags := poolLeaseAllowedFlags() @@ -3470,7 +3482,11 @@ 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, telemetryCdpExclude, startURL) + acquireProfile, err := buildAcquireProfileParam(profileID, profileName, saveChanges) + if err != nil { + return err + } + acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry, telemetryCdpExclude, startURL, acquireProfile) if err != nil { return err } @@ -3535,6 +3551,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { Telemetry: telemetry, TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, + TelemetryStorage: telemetryStorage, ChromePolicy: chromePolicy, ChromePolicyFile: chromePolicyFile, Name: name, diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 2d94f99f..9893052a 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -261,6 +261,37 @@ func validateTelemetryExportCombo(telemetry, id, name string, canImply bool) err return nil } +// resolveTelemetryStorageFlag interprets a --telemetry-storage flag value: "on" +// persists captured telemetry to Kernel storage (the server default) and "off" +// leaves events only on the live stream and the OTLP export. Storage can only be +// turned off alongside an export destination in the same command, since the API +// validates the request payload on its own and the setting cannot be changed once +// a browser exists. +// +// canImply mirrors buildManagedAuthTelemetryParam: on update and login a +// connection stores the browser config as sent, so a storage setting on its own +// would drop the connection's category selection. +func resolveTelemetryStorageFlag(storage, telemetry, export string, canImply bool) (param.Opt[bool], error) { + var enabled param.Opt[bool] + switch strings.TrimSpace(storage) { + case "": + return enabled, nil + case "on": + enabled = kernel.Opt(true) + case "off": + enabled = kernel.Opt(false) + if v := strings.TrimSpace(export); v == "" || v == telemetryExportOff { + return enabled, fmt.Errorf("turning telemetry storage off requires an export destination in the same command: pass --telemetry-export-otlp= so captured events have somewhere to go") + } + default: + return enabled, fmt.Errorf("invalid telemetry storage value %q: must be on or off", storage) + } + if telemetry == "" && !canImply { + return enabled, fmt.Errorf("setting --telemetry-storage also requires --telemetry in the same command: the connection stores its browser config as sent, so a storage setting on its own would drop its category selection") + } + return enabled, nil +} + // 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) { @@ -451,6 +482,11 @@ func printTelemetrySummary(cfg kernel.BrowserTelemetryConfig) { pterm.Info.Println("Telemetry exporting over OTLP") } } + // Storage defaults on and is omitted for browsers created before the setting + // existed, so only call it out when the response reports it off. + if cfg.Storage.JSON.Enabled.Valid() && !cfg.Storage.Enabled { + pterm.Info.Println("Telemetry storage: off (events are only available on the live stream and through any configured export)") + } } // formatCdpExcludedMethods renders the CDP methods left out of control @@ -587,11 +623,9 @@ func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEv return util.CleanedUpSdkError{Err: gerr} } - // A --types filter is client-side (the archive endpoint filters only by - // category), so it must see every page to be complete. Walk the whole window - // whenever --all or a --types filter is set; otherwise read a single page and + // Walk the whole window when --all is set; otherwise read a single page and // surface the X-Next-Offset cursor for manual --offset paging. - fullScan := in.All || len(in.Types) > 0 + fullScan := in.All params := kernel.BrowserTelemetryEventsParams{} if in.Limit > 0 { @@ -612,13 +646,16 @@ func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEv if in.Until != "" { params.Until = kernel.Opt(in.Until) } - // Send each category as a repeated query param. The SDK serializes a []string - // field as a single comma-joined value, but the endpoint expects the parameter - // repeated, so a comma-joined value matches no category. - opts := make([]option.RequestOption, 0, len(in.Categories)+1) + // Send each category and type as a repeated query param. The SDK serializes a + // []string field as a single comma-joined value, but the endpoint expects the + // parameter repeated, so a comma-joined value matches nothing. + opts := make([]option.RequestOption, 0, len(in.Categories)+len(in.Types)+1) for _, c := range in.Categories { opts = append(opts, option.WithQueryAdd("category", c)) } + for _, t := range in.Types { + opts = append(opts, option.WithQueryAdd("type", t)) + } var items []kernel.BrowserTelemetryEventsResponse nextOffset := "" @@ -626,10 +663,7 @@ func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEv if fullScan { pager := b.telemetry.EventsAutoPaging(ctx, sessionID, params, opts...) for pager.Next() { - it := pager.Current() - if shouldEmit(it.Event.Category, it.Event.Type, nil, in.Types) { - items = append(items, it) - } + items = append(items, pager.Current()) } if err := pager.Err(); err != nil { return util.CleanedUpSdkError{Err: err} @@ -674,6 +708,11 @@ func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEv if len(items) == 0 { pterm.Info.Println("No telemetry events found") + // Filters apply within each page, so an empty filtered page can still be + // followed by pages with matches. + if nextOffset != "" { + pterm.Info.Printf("More events available — re-run with --offset %s\n", nextOffset) + } return nil } diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index 591c9b88..df6e6c9e 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -503,6 +503,17 @@ func TestPrintTelemetrySummary_Export(t *testing.T) { printTelemetrySummary(parse(`{"browser":{"control":{"enabled":true}},"export":{"otlp":{"enabled":false}}}`)) assert.NotContains(t, outBuf.String(), "OTLP") }) + t.Run("reports storage when off", func(t *testing.T) { + setupStdoutCapture(t) + printTelemetrySummary(parse(`{"browser":{"control":{"enabled":true}},"storage":{"enabled":false}}`)) + assert.Contains(t, outBuf.String(), "Telemetry storage: off") + }) + t.Run("stays quiet when storage is on or omitted", func(t *testing.T) { + setupStdoutCapture(t) + printTelemetrySummary(parse(`{"browser":{"control":{"enabled":true}},"storage":{"enabled":true}}`)) + printTelemetrySummary(parse(`{"browser":{"control":{"enabled":true}}}`)) + assert.NotContains(t, outBuf.String(), "storage") + }) } func TestTelemetryEnabledCategories(t *testing.T) { @@ -668,35 +679,37 @@ func TestTelemetryEvents_SurfacesNonNotFoundGetError(t *testing.T) { assert.Error(t, err) } -// A --types filter is client-side, so it must scan every page in the window to be -// complete. Setting --types (without --all) must therefore route through the -// auto-pager, not the single-page fetch that could drop matches on later pages. -func TestTelemetryEvents_TypesFilterWalksAllPages(t *testing.T) { +// Types are filtered server-side like categories: they go out as repeated +// "type" query params on a single-page read, not a client-side full scan. +func TestTelemetryEvents_TypesSentAsRepeatedQueryParams(t *testing.T) { buf := capturePtermOutput(t) fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { return &kernel.BrowserGetResponse{SessionID: "sess-1"}, nil }} - autoPaged := false + var gotQuery kernel.BrowserTelemetryEventsParams + var gotOpts []option.RequestOption fakeTelemetry := &FakeBrowserTelemetryService{ EventsFunc: func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) { - t.Fatalf("single-page Events must not be called when --types is set") - return nil, nil + gotQuery, gotOpts = query, opts + return &pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil }, EventsAutoPagingFunc: func(id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse] { - autoPaged = true - return pagination.NewOffsetPaginationAutoPager(&pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil) + t.Fatalf("--types alone must not trigger a full-window scan") + return nil }, } b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry} - err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", Types: []string{"network_response"}}) + err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", Categories: []string{"page"}, Types: []string{"page_crashed", "page_load"}}) assert.NoError(t, err) - assert.True(t, autoPaged, "--types must walk every page so the client-side filter is complete") + assert.Empty(t, gotQuery.Type, "types must not use the comma-joined typed field") + // One category, two type query params, plus the response-capture option. + assert.Len(t, gotOpts, 4) _ = buf } -// A full-window scan (--all/--types) must ignore the manual --offset cursor and +// A full-window scan (--all) must ignore the manual --offset cursor and // walk from --since; forwarding the offset would start mid-window and drop // earlier pages, contradicting the documented behavior. func TestTelemetryEvents_FullScanIgnoresOffsetUsesSince(t *testing.T) { @@ -799,3 +812,46 @@ func TestBuildManagedAuthTelemetryParam_CdpExcludeNeedsCategories(t *testing.T) _, err = buildManagedAuthTelemetryParam("control", "Page.navigate", "", false) assert.NoError(t, err) } + +// TestResolveTelemetryStorageFlag covers the --telemetry-storage values and the +// rules the API enforces on the request payload: storage can only go off with an +// export destination in the same request, and update/login must restate the +// category selection because the connection stores its browser config as sent. +func TestResolveTelemetryStorageFlag(t *testing.T) { + t.Run("unset leaves storage omitted", func(t *testing.T) { + v, err := resolveTelemetryStorageFlag("", "", "", true) + assert.NoError(t, err) + assert.False(t, v.Valid()) + }) + t.Run("on sends enabled=true", func(t *testing.T) { + v, err := resolveTelemetryStorageFlag("on", "", "", true) + assert.NoError(t, err) + assert.True(t, v.Valid()) + assert.True(t, v.Value) + }) + t.Run("off with a destination sends enabled=false", func(t *testing.T) { + v, err := resolveTelemetryStorageFlag("off", "", "my-collector", true) + assert.NoError(t, err) + assert.True(t, v.Valid()) + assert.False(t, v.Value) + }) + t.Run("off requires a destination", func(t *testing.T) { + for _, export := range []string{"", "off"} { + _, err := resolveTelemetryStorageFlag("off", "all", export, true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "requires an export destination") + } + }) + t.Run("invalid value is rejected", func(t *testing.T) { + _, err := resolveTelemetryStorageFlag("maybe", "", "", true) + assert.Error(t, err) + }) + t.Run("update and login require --telemetry", func(t *testing.T) { + _, err := resolveTelemetryStorageFlag("off", "", "my-collector", false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "also requires --telemetry") + v, err := resolveTelemetryStorageFlag("off", "all", "my-collector", false) + assert.NoError(t, err) + assert.False(t, v.Value) + }) +} diff --git a/cmd/org.go b/cmd/org.go index 2bce469d..41fca9cd 100644 --- a/cmd/org.go +++ b/cmd/org.go @@ -135,6 +135,16 @@ func renderOrgLimits(limits *kernel.OrgLimits) { {"Default Project Max Concurrent Sessions", formatProjectLimitValue(limits.DefaultProjectMaxConcurrentSessions, limits.JSON.DefaultProjectMaxConcurrentSessions)}, } + // Concurrency usage is measured live and only returned by newer API + // versions. Unlike the limit rows, a null here means usage could not be + // read rather than "unlimited", so render it as unknown. + if orgLimitFieldPresent(limits.JSON.ConcurrentSessionsUsed) { + rows = append(rows, []string{"Concurrent Sessions Used", formatOrgUsageValue(limits.ConcurrentSessionsUsed, limits.JSON.ConcurrentSessionsUsed)}) + } + if orgLimitFieldPresent(limits.JSON.ConcurrentSessionsAvailable) { + rows = append(rows, []string{"Concurrent Sessions Available", formatOrgUsageValue(limits.ConcurrentSessionsAvailable, limits.JSON.ConcurrentSessionsAvailable)}) + } + // Managed auth limits are plan-derived and only returned by newer API // versions, so render each row only when the field is present. A null // max_auth_connections means unlimited, so presence — not validity — is the @@ -167,6 +177,15 @@ func orgLimitFieldPresent(field respjson.Field) bool { return field.Raw() != respjson.Omitted } +// formatOrgUsageValue renders a live usage counter, where a null means the API +// could not read current usage rather than "unlimited". +func formatOrgUsageValue(value int64, field respjson.Field) string { + if !field.Valid() { + return "unknown" + } + return fmt.Sprintf("%d", value) +} + func renderOrgEntitlements(entitlements *kernel.OrgEntitlements) { if entitlements == nil { pterm.Info.Println("No organization entitlements found") @@ -212,6 +231,7 @@ func orgEntitlementRows(entitlements *kernel.OrgEntitlements) pterm.TableData { {"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)}, + {"Feature", "Search", fmt.Sprintf("%t", features.Search.Enabled)}, {"Feature", "GPU", fmt.Sprintf("%t", features.GPU.Enabled)}, {"Limit", "Max concurrent browsers", fmt.Sprintf("%d", limits.MaxConcurrentBrowsers)}, {"Limit", "Max concurrent invocations", fmt.Sprintf("%d", limits.MaxConcurrentInvocations)}, @@ -266,7 +286,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 and vault limits along with current auth connection and vault usage.", + Long: "Show the organization's effective limits: the concurrency limit, current organization-wide concurrent browser usage and remaining capacity, 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 9118f619..d3eec5f2 100644 --- a/cmd/org_test.go +++ b/cmd/org_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" "time" @@ -49,6 +50,7 @@ func testOrgEntitlementsWithUnlimitedValues(t *testing.T) *kernel.OrgEntitlement "managed_proxies":{"enabled":true}, "custom_proxies":{"enabled":true}, "proxy_bypass_hosts":{"enabled":true}, + "search":{"enabled":true}, "gpu":{"enabled":false} }, "limits":{"max_concurrent_browsers":150,"max_concurrent_invocations":150,"default_max_concurrent_invocations_per_app":20,"max_vaults":null} @@ -74,6 +76,7 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { "managed_proxies":{"enabled":true}, "custom_proxies":{"enabled":false}, "proxy_bypass_hosts":{"enabled":true}, + "search":{"enabled":true}, "gpu":{"enabled":false} }, "limits":{"max_concurrent_browsers":43,"max_concurrent_invocations":47,"default_max_concurrent_invocations_per_app":53,"max_vaults":59} @@ -105,6 +108,7 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { {"Feature", "Managed proxies", "true"}, {"Feature", "Custom proxies", "false"}, {"Feature", "Proxy bypass hosts", "true"}, + {"Feature", "Search", "true"}, {"Feature", "GPU", "false"}, {"Limit", "Max concurrent browsers", "43"}, {"Limit", "Max concurrent invocations", "47"}, @@ -131,6 +135,7 @@ func TestOrgEntitlementRows_BooleanFieldProvenance(t *testing.T) { {"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 }}, + {"Search", func(e *kernel.OrgEntitlements) { e.Features.Search.Enabled = true }}, {"GPU", func(e *kernel.OrgEntitlements) { e.Features.GPU.Enabled = true }}, } @@ -296,6 +301,63 @@ func TestOrgLimitsGet_NullDefaultShownAsUnlimited(t *testing.T) { assert.Contains(t, buf.String(), "unlimited") } +func TestOrgLimitsGet_RendersConcurrencyUsage(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{ + MaxConcurrentSessions: 100, + ConcurrentSessionsUsed: 12, + ConcurrentSessionsAvailable: 88, + } + limits.JSON.ConcurrentSessionsUsed = respjson.NewField("12") + limits.JSON.ConcurrentSessionsAvailable = respjson.NewField("88") + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Concurrent Sessions Used") + assert.Contains(t, out, "12") + assert.Contains(t, out, "Concurrent Sessions Available") + assert.Contains(t, out, "88") +} + +func TestOrgLimitsGet_NullConcurrencyUsageShownAsUnknown(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{MaxConcurrentSessions: 100} + // Null (not omitted) means usage could not be read, which is not + // the same as unlimited. + limits.JSON.ConcurrentSessionsUsed = respjson.NewField(respjson.Null) + limits.JSON.ConcurrentSessionsAvailable = respjson.NewField(respjson.Null) + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + // Both usage rows render as unknown rather than borrowing the "unlimited" + // meaning a null limit would have. + assert.Contains(t, out, "Concurrent Sessions Used") + assert.Contains(t, out, "Concurrent Sessions Available") + assert.Equal(t, 2, strings.Count(out, "unknown")) +} + +func TestOrgLimitsGet_OmitsConcurrencyUsageRowsWhenAbsent(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, "Concurrent Sessions Used") + assert.NotContains(t, out, "Concurrent Sessions Available") +} + func TestOrgLimitsGet_RendersManagedAuthLimits(t *testing.T) { buf := capturePtermOutput(t) fake := &FakeOrgLimitsService{ diff --git a/cmd/search.go b/cmd/search.go index 3981670e..6184282b 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -5,11 +5,14 @@ import ( "fmt" "net/http" "net/url" + "slices" "strings" "unicode/utf8" "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/packages/param" "github.com/spf13/cobra" ) @@ -20,7 +23,7 @@ func newSearchCommand() *cobra.Command { Long: "Search the web using automatic routing or a pinned provider. Returns the full JSON resource, including warnings, attempts, usage, and expiry.\n\n" + "Use --request with a JSON object for advanced searches. The request schema includes query (required), max_results, strategy (auto, pinned, or fallback), content, include_raw, include_domains, exclude_domains, date and locale filters, strict_params, and provider-specific options. Strategy objects accept provider configuration; fallback strategies also accept fallback_on. The API validates provider-specific and advanced fields.\n\n" + "Requires Search API access for your organization.", - Example: " kernel search 'browser automation' --provider exa --max-results 5\n kernel search --request '{\"query\":\"browser automation\",\"include_domains\":[\"example.com\"],\"strict_params\":true}'\n kernel search get srch_123\n kernel search providers --slug exa", + Example: " kernel search 'browser automation' --provider exa --max-results 5\n kernel search --request '{\"query\":\"browser automation\",\"include_domains\":[\"example.com\"],\"strict_params\":true}'\n kernel search get srch_123\n kernel search providers --slug exa\n kernel search contents srch_123 --limit 3 --content-source browser", Args: cobra.MaximumNArgs(1), RunE: runSearch, } @@ -44,10 +47,109 @@ func newSearchCommand() *cobra.Command { return executeSearchRequest(cmd, http.MethodGet, path, nil) }} providers.Flags().String("slug", "", "Filter by provider slug") - cmd.AddCommand(get, providers) + contents := &cobra.Command{ + Use: "contents ", + Short: "Fetch content for selected results of a retained search as JSON", + Long: "Retrieves selected results from a retained search. Provide exactly one of --result-ids or --limit; the latter fetches the top results. Content defaults to source auto. " + + "Responses preserve --result-ids order and include one outcome per selected result, including timeout entries for work unfinished at the overall deadline. " + + "Browser retrievals run sequentially in result order and are billed like any other browser.", + Example: " kernel search contents srch_123 --limit 3\n kernel search contents srch_123 --result-ids res_1,res_2 --content-source browser --content-browser-mode render", + Args: cobra.ExactArgs(1), + RunE: runSearchContents, + } + contents.Flags().StringSlice("result-ids", nil, "Result IDs from the retained search, in desired response order (mutually exclusive with --limit)") + contents.Flags().Int("limit", 0, "Number of results to fetch starting from rank 1 (1–100; mutually exclusive with --result-ids)") + contents.Flags().Int("timeout-ms", 0, "Overall deadline across all selected results in milliseconds (1000–120000; default 60000)") + contents.Flags().String("content-source", "", "Content source: auto, provider, or browser (default auto)") + contents.Flags().String("content-format", "", "Content format: markdown or text") + contents.Flags().Int("content-max-chars", 0, "Per-result Unicode character limit after extraction") + contents.Flags().Int("content-max-age-hours", 0, "For source auto, maximum age of retained provider content; 0 fetches every result through a browser") + contents.Flags().Int("content-timeout-ms", 0, "Per-result deadline in milliseconds, including capacity acquisition, retrieval, and extraction") + contents.Flags().String("content-browser-id", "", "Existing browser session to retrieve through (requires source auto or browser)") + contents.Flags().String("content-browser-mode", "", "Browser retrieval mode: curl or render") + contents.MarkFlagsMutuallyExclusive("result-ids", "limit") + contents.MarkFlagsOneRequired("result-ids", "limit") + cmd.AddCommand(get, providers, contents) return cmd } +func runSearchContents(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(args[0]) == "" { + return fmt.Errorf("search ID must not be empty") + } + flags := cmd.Flags() + var request kernel.FetchRequestParam + if flags.Changed("result-ids") { + ids, _ := flags.GetStringSlice("result-ids") + if len(ids) == 0 || len(ids) > 100 { + return fmt.Errorf("--result-ids must contain 1–100 IDs") + } + for _, id := range ids { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("--result-ids must not contain empty IDs") + } + } + request.ResultIDs = ids + } + if flags.Changed("limit") { + limit, _ := flags.GetInt("limit") + if limit < 1 || limit > 100 { + return fmt.Errorf("--limit must be between 1 and 100") + } + request.Limit = kernel.Int(int64(limit)) + } + if flags.Changed("timeout-ms") { + timeout, _ := flags.GetInt("timeout-ms") + if timeout < 1000 || timeout > 120000 { + return fmt.Errorf("--timeout-ms must be between 1000 and 120000") + } + request.TimeoutMs = kernel.Int(int64(timeout)) + } + enumFlag := func(name string, allowed ...string) (string, error) { + value, _ := flags.GetString(name) + if flags.Changed(name) && !slices.Contains(allowed, value) { + return "", fmt.Errorf("--%s must be one of: %s", name, strings.Join(allowed, ", ")) + } + return value, nil + } + var err error + content := &request.Content + if content.Source, err = enumFlag("content-source", "auto", "provider", "browser"); err != nil { + return err + } + if content.Format, err = enumFlag("content-format", "markdown", "text"); err != nil { + return err + } + if content.Browser.Mode, err = enumFlag("content-browser-mode", "curl", "render"); err != nil { + return err + } + for name, target := range map[string]*param.Opt[int64]{ + "content-max-chars": &content.MaxChars, + "content-max-age-hours": &content.MaxAgeHours, + "content-timeout-ms": &content.TimeoutMs, + } { + if flags.Changed(name) { + value, _ := flags.GetInt(name) + if value < 0 { + return fmt.Errorf("--%s must not be negative", name) + } + *target = kernel.Int(int64(value)) + } + } + if flags.Changed("content-browser-id") { + browserID, _ := flags.GetString("content-browser-id") + if strings.TrimSpace(browserID) == "" { + return fmt.Errorf("--content-browser-id must not be empty") + } + content.Browser.BrowserID = kernel.String(browserID) + } + body, err := json.Marshal(request) + if err != nil { + return err + } + return executeSearchRequest(cmd, http.MethodPost, "search/"+url.PathEscape(args[0])+"/contents", body) +} + func runSearch(cmd *cobra.Command, args []string) error { var body json.RawMessage if cmd.Flags().Changed("request") { diff --git a/cmd/search_test.go b/cmd/search_test.go index ff039b5b..c6dc7dd9 100644 --- a/cmd/search_test.go +++ b/cmd/search_test.go @@ -92,6 +92,37 @@ func TestSearchReadCommands(t *testing.T) { } } +func TestSearchContents(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want string + }{ + {"limit", []string{"--limit", "3"}, `{"limit":3}`}, + {"result ids", []string{"--result-ids", "res_2,res_1", "--timeout-ms", "30000"}, `{"result_ids":["res_2","res_1"],"timeout_ms":30000}`}, + {"content options", []string{"--limit", "1", "--content-source", "browser", "--content-format", "text", "--content-max-chars", "500", "--content-max-age-hours", "0", "--content-timeout-ms", "5000", "--content-browser-id", "br_1", "--content-browser-mode", "render"}, + `{"limit":1,"content":{"source":"browser","format":"text","max_chars":500,"max_age_hours":0,"timeout_ms":5000,"browser":{"browser_id":"br_1","mode":"render"}}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + const response = `{"search_id":"srch_test","contents":[],"usage":{"content_fetches":0},"warnings":[],"future_field":9007199254740993}` + stdout, err := executeSearchCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/search/srch_test/contents", r.URL.Path) + data, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, tc.want, string(data)) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, response) + }, "", append([]string{"contents", "srch_test"}, tc.args...)...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.JSONEq(t, response, stdout) + }) + } +} + func TestSearchInvalidInput(t *testing.T) { for _, args := range [][]string{ {}, {" "}, {strings.Repeat("x", 2049)}, {"a", "b"}, {"test", "--provider", ""}, @@ -100,6 +131,16 @@ func TestSearchInvalidInput(t *testing.T) { {"test", "--request", `{"query":"test"}`}, {"--request", `{}`, "--provider", "exa"}, {"--request", `{}`, "--max-results", "5"}, {"--request-file", "request.json"}, {"get"}, {"providers", "extra"}, + {"contents"}, {"contents", "srch_test"}, {"contents", " ", "--limit", "1"}, + {"contents", "srch_test", "--limit", "1", "--result-ids", "res_1"}, + {"contents", "srch_test", "--limit", "0"}, {"contents", "srch_test", "--limit", "101"}, + {"contents", "srch_test", "--limit", "1", "--timeout-ms", "999"}, + {"contents", "srch_test", "--limit", "1", "--content-source", "cache"}, + {"contents", "srch_test", "--limit", "1", "--content-format", "html"}, + {"contents", "srch_test", "--limit", "1", "--content-browser-mode", "headless"}, + {"contents", "srch_test", "--limit", "1", "--content-max-chars", "-1"}, + {"contents", "srch_test", "--limit", "1", "--content-browser-id", " "}, + {"contents", "srch_test", "--result-ids", "res_1,"}, } { t.Run(strings.Join(args, " "), func(t *testing.T) { _, err := executeSearchCommand(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected API call") }, "", args...) @@ -126,7 +167,7 @@ func TestSearchErrorsDoNotRetryCreate(t *testing.T) { } func TestSearchWiring(t *testing.T) { - for _, path := range [][]string{{"search"}, {"search", "get"}, {"search", "providers"}} { + for _, path := range [][]string{{"search"}, {"search", "get"}, {"search", "providers"}, {"search", "contents"}} { cmd, remaining, err := rootCmd.Find(path) require.NoError(t, err) require.Empty(t, remaining) diff --git a/cmd/vault_provider_configs.go b/cmd/vault_provider_configs.go index fe7672ea..ca18d59d 100644 --- a/cmd/vault_provider_configs.go +++ b/cmd/vault_provider_configs.go @@ -14,7 +14,7 @@ import ( "github.com/spf13/cobra" ) -var vaultProviderConfigFields = vaultFieldsOf("id name provider client_id test_mode created_at updated_at") +var vaultProviderConfigFields = vaultFieldsOf("id name provider client_id publishable_key test_mode created_at updated_at") type VaultProviderConfigsCmd struct { configs *kernel.VaultProviderConfigService @@ -50,7 +50,7 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect return nil } create := &cobra.Command{Use: "create --name --provider --credentials-file ", Short: "Register a provider configuration", Args: cobra.NoArgs, PreRunE: preRun, - Long: "Register a configuration; duplicate names return a conflict without replacing credentials.\n--credentials-file must contain a JSON object with client_id and client_secret strings.\nUse a protected file or pipe from a secret manager; never put credentials in shell arguments.", + Long: "Register a configuration; duplicate names return a conflict without replacing credentials.\n--credentials-file must contain a JSON object with client_id and client_secret strings.\nUse a protected file or pipe from a secret manager; never put credentials in shell arguments.\nFor Link, pass --publishable-key so imported wallet grants keep working after their access token expires.", RunE: func(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") if err := validateVaultName(name, "--name"); err != nil { @@ -60,13 +60,21 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect if provider != "link" && provider != "agentcard" { return fmt.Errorf("--provider must be link or agentcard") } + publishableKey, _ := cmd.Flags().GetString("publishable-key") + if cmd.Flags().Changed("publishable-key") && (provider != "link" || publishableKey == "") { + return fmt.Errorf("--publishable-key requires --provider link and a non-empty value") + } credentials, err := readVaultSecrets(cmd, "credentials-file", "client_id", "client_secret") if err != nil { return err } params := kernel.VaultProviderConfigNewParams{} if provider == "link" { - params.OfLink = &kernel.VaultProviderConfigNewParamsBodyLink{Name: name, Credentials: kernel.VaultProviderConfigNewParamsBodyLinkCredentials{ClientID: credentials["client_id"], ClientSecret: credentials["client_secret"]}} + linkCredentials := kernel.VaultProviderConfigNewParamsBodyLinkCredentials{ClientID: credentials["client_id"], ClientSecret: credentials["client_secret"]} + if publishableKey != "" { + linkCredentials.PublishableKey = kernel.Opt(publishableKey) + } + params.OfLink = &kernel.VaultProviderConfigNewParamsBodyLink{Name: name, Credentials: linkCredentials} } else { params.OfAgentcard = &kernel.VaultProviderConfigNewParamsBodyAgentcard{Name: name, Credentials: kernel.VaultProviderConfigNewParamsBodyAgentcardCredentials{ClientID: credentials["client_id"], ClientSecret: credentials["client_secret"]}} } @@ -80,6 +88,7 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect create.Flags().String("name", "", "Organization-unique configuration name (required)") create.Flags().String("provider", "", "Provider: link or agentcard (required)") create.Flags().String("credentials-file", "", "Read client_id and client_secret JSON from a file (use '-' for stdin)") + create.Flags().String("publishable-key", "", "Link only. Stripe publishable key for the account that owns the Link OAuth client; required for Kernel to refresh or revoke imported wallet grants") for _, flag := range []string{"name", "provider", "credentials-file"} { _ = create.MarkFlagRequired(flag) } @@ -107,10 +116,10 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect addVaultJSONOutputFlag(list) update := &cobra.Command{Use: "update ", Short: "Rename a configuration or rotate its secret", Args: cobra.ExactArgs(1), PreRunE: preRun, - Long: "Omitted fields remain unchanged. --credentials-file accepts only a client_secret JSON string field.\nProvider, client ID, and mode cannot change. Rotation affects all wallets using this configuration.", + Long: "Omitted fields remain unchanged. --credentials-file accepts only a client_secret JSON string field.\n--publishable-key sets the Stripe publishable key on Link configurations.\nProvider, client ID, and mode cannot change. Rotation affects all wallets using this configuration.", RunE: func(cmd *cobra.Command, args []string) error { - if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("credentials-file") { - return fmt.Errorf("provide --name or --credentials-file") + if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("credentials-file") && !cmd.Flags().Changed("publishable-key") { + return fmt.Errorf("provide --name, --credentials-file, or --publishable-key") } params := kernel.VaultProviderConfigUpdateParams{} if cmd.Flags().Changed("name") { @@ -127,6 +136,13 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect } params.Credentials.ClientSecret = kernel.Opt(credentials["client_secret"]) } + if cmd.Flags().Changed("publishable-key") { + publishableKey, _ := cmd.Flags().GetString("publishable-key") + if publishableKey == "" { + return fmt.Errorf("--publishable-key must be non-empty") + } + params.Credentials.PublishableKey = kernel.Opt(publishableKey) + } c := getVaultProviderConfigsHandler(cmd) config, err := c.configs.Update(cmd.Context(), args[0], params, option.WithMaxRetries(0)) if err != nil { @@ -136,6 +152,7 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect }} update.Flags().String("name", "", "New organization-unique name; existing wallet bindings are preserved") update.Flags().String("credentials-file", "", "Read client_secret JSON from a file (use '-' for stdin)") + update.Flags().String("publishable-key", "", "Link configurations only. Stripe publishable key sent to Link when refreshing and revoking wallet grants") addVaultJSONOutputFlag(update) delete := &cobra.Command{Use: "delete ", Short: "Delete an unused configuration", Args: cobra.ExactArgs(1), PreRunE: preRun, @@ -216,6 +233,9 @@ func printVaultProviderConfig(config *kernel.VaultProviderConfigUnion, output st return printVaultJSON(raw) } rows := pterm.TableData{{"Property", "Value"}, {"ID", config.ID}, {"Name", config.Name}, {"Provider (immutable)", config.Provider}, {"Client ID (immutable)", config.ClientID}} + if config.PublishableKey != "" { + rows = append(rows, []string{"Publishable Key", config.PublishableKey}) + } if config.JSON.TestMode.Valid() { rows = append(rows, []string{"Test mode (introspected)", fmt.Sprint(config.TestMode)}) } diff --git a/cmd/vault_provider_configs_test.go b/cmd/vault_provider_configs_test.go index 1d5e5932..1d4adce0 100644 --- a/cmd/vault_provider_configs_test.go +++ b/cmd/vault_provider_configs_test.go @@ -299,3 +299,42 @@ func TestVaultProviderConfigEmptyAndInvalidPagination(t *testing.T) { } } } + +func TestVaultProviderConfigPublishableKey(t *testing.T) { + secret := vaultTestSecret(t) + t.Run("create link", func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Credentials map[string]string `json:"credentials"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "pk_test_123", body.Credentials["publishable_key"]) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + response := strings.ReplaceAll(providerConfigFixture, `"provider":"agentcard"`, `"provider":"link","publishable_key":"pk_test_123"`) + _, _ = io.WriteString(w, strings.ReplaceAll(response, `,"test_mode":false`, "")) + }) + out, _, err := executeVaultInputCommand(t, client, fmt.Sprintf(`{"client_id":"client-1","client_secret":%q}`, secret), "vault-provider-configs", "create", "--name", "checkout-client", "--provider", "link", "--credentials-file", "-", "--publishable-key", "pk_test_123", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"publishable_key": "pk_test_123"`) + assert.False(t, strings.Contains(out, secret)) + }) + t.Run("create agentcard rejected", func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("unexpected request") }) + _, _, err := executeVaultInputCommand(t, client, "", "vault-provider-configs", "create", "--name", "checkout-client", "--provider", "agentcard", "--credentials-file", "-", "--publishable-key", "pk_test_123") + require.ErrorContains(t, err, "--publishable-key requires --provider link") + }) + t.Run("update", func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, `{"publishable_key":"pk_test_456"}`, string(body["credentials"])) + _, hasName := body["name"] + assert.False(t, hasName) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, providerConfigFixture) + }) + _, _, err := executeVaultInputCommand(t, client, "", "vault-provider-configs", "update", "config-1", "--publishable-key", "pk_test_456", "-o", "json") + require.NoError(t, err) + }) +} diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 398dce1a..5b2afb6f 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -183,11 +183,13 @@ and corrective guidance; no fields were written by that request. Inspect and cor the cause before deciding on a new fill. Transport loss remains an uncertain outcome. prepare_checkout requires checkout.browser_id, checkout.merchant_origin (canonical HTTPS origin of the top-level merchant page, not a processor iframe), and checkout.environment -(production, sandbox, or shared). Optional checkout.psp selects the tokenization processor: -square, braintree, worldpay, bambora, or mercado_pago. Omit psp for Square; non-Square +(production, sandbox, or shared). Optional checkout.psp selects the checkout processor: +square, braintree, worldpay, bambora, mercado_pago, or adyen. Omit psp for Square; non-Square processors require multi-processor preparation enablement. Use production or sandbox for -square, braintree and worldpay; shared for bambora and mercado_pago. Shared endpoints do not -establish test mode; merchant credentials determine it. +square, braintree, worldpay and adyen; shared for bambora and mercado_pago. Shared endpoints do +not establish test mode; merchant credentials determine it. adyen prepares fresh-card Sessions +requests on Adyen hosts only: fill public dummy card fields, not vault aliases. Adyen device +approval and browser Authorised responses are not capture or fulfillment evidence. Use only when advertised for an AgentCard card. Keep the returned approval page open, poll until ready_to_submit, then submit native Pay before preparation.expires_at. Preparations are single-use, including after failure or expiry; never retry automatically. diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 211c2788..555521fd 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "context" "encoding/json" "fmt" @@ -133,7 +134,7 @@ func newVaultCredentialsCommand() *cobra.Command { }, } if update { - cmd.Long += "\nUpdate applies to Kernel-hosted credentials only. It preserves omitted fields, replaces nonempty string values, and clears supported values with null or an empty string. Clearing a required text/email/password field returns pending_collection; form submissions still require a nonempty value.\nField definitions are immutable. Do not automatically retry version conflicts." + cmd.Long += "\nUpdate applies to Kernel-hosted credentials only. Update spec fields are an object keyed by field name, not the ordered array used on create.\nUpdate preserves omitted fields, replaces nonempty string values, and clears supported values with null or an empty string. Clearing a required text/email/password field returns pending_collection; form submissions still require a nonempty value.\nField definitions are immutable. Do not automatically retry version conflicts." cmd.Flags().Int64("version", 0, "Expected version from items get (required; never auto-refreshed)") _ = cmd.MarkFlagRequired("version") cmd.Flags().String("expected-item-id", "", "Immutable item ID from the original read; reject an update if the key now refers to a replacement item") @@ -254,9 +255,18 @@ func credentialSpecInput(data []byte) (kernel.CredentialVaultItemSpecInputUnionP case "kernel": var spec kernel.KernelCredentialVaultItemSpecInputParam if json.Unmarshal(data, &spec) != nil || len(spec.Fields) == 0 { + if credentialSpecUsesKeyedFields(data) { + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("credential spec fields must be an ordered array of definitions carrying a name, not an object keyed by name") + } return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("credential spec requires fields") } spec.Provider = kernel.KernelCredentialVaultItemSpecInputProviderKernel + // Names key values, updates, and fills; reject specs the form cannot address. + for _, field := range spec.Fields { + if strings.TrimSpace(field.Name) == "" { + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("every credential spec field requires a name") + } + } return kernel.CredentialVaultItemSpecInputUnionParam{OfKernel: &spec}, nil case "1password": var spec kernel.OnePasswordCredentialVaultItemSpecInputParam @@ -294,3 +304,16 @@ func (c VaultsCmd) connectCredentialAccount(ctx context.Context, vault, key, out } return c.showItem(item, output, open) } + +// The create spec moved from fields keyed by name to an ordered array; point +// callers still sending the object form at the replacement shape. +func credentialSpecUsesKeyedFields(data []byte) bool { + var object struct { + Fields json.RawMessage `json:"fields"` + } + if json.Unmarshal(data, &object) != nil { + return false + } + fields := bytes.TrimSpace(object.Fields) + return len(fields) > 0 && fields[0] == '{' +} diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go index b9f40f9d..008ea306 100644 --- a/cmd/vaults_credentials_test.go +++ b/cmd/vaults_credentials_test.go @@ -210,3 +210,13 @@ func TestCredentialDiscoveryAndInvalidInput(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, `{"fields":{}}`, string(data)) } + +func TestCredentialSpecInputProvider(t *testing.T) { + spec, err := credentialSpecInput([]byte(`{"provider":"kernel","fields":[{"name":"password","type":"password"}]}`)) + require.NoError(t, err) + require.NotNil(t, spec.OfKernel) + assert.EqualValues(t, "kernel", spec.OfKernel.Provider) + + _, err = credentialSpecInput([]byte(`{"provider":"bitwarden","fields":[{"name":"password","type":"password"}]}`)) + assert.EqualError(t, err, "credential spec provider must be kernel or 1password") +} diff --git a/cmd/vaults_fill_credentials_test.go b/cmd/vaults_fill_credentials_test.go index c351e70c..29d7b7f6 100644 --- a/cmd/vaults_fill_credentials_test.go +++ b/cmd/vaults_fill_credentials_test.go @@ -19,7 +19,7 @@ func TestVaultFillBothItemTypesAndInputs(t *testing.T) { for _, input := range []string{"params", "spec-file"} { for _, test := range []struct{ name, item, params, result string }{ {"card", readyFillCardFixture, fillParamsFixture, completedFillFixture}, - {"credential", readyFillCredentialFixture, `{"browser_id":"browser-id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, completedFillFixture}, + {"credential", readyFillCredentialFixture, `{"browser_id":"browser-id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom_field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, completedFillFixture}, {"credential URL", readyFillCredentialFixture, `{"browser_id":"browser-id","page_url":"http://localhost/login","fields":[{"field":"expiration","selector":"#password"}]}`, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"}]}`}, } { t.Run(input+"/"+test.name, func(t *testing.T) { @@ -56,7 +56,7 @@ func TestVaultCredentialFillValidation(t *testing.T) { for _, params := range []string{ `{"browser_id":"id","fields":[{"field":"unknown","selector":"#field"}]}`, `{"browser_id":"id","fields":[{"field":"expiration","selector":"#field","format":"MM/YY"}]}`, - `{"browser_id":"id","fields":[{"field":"custom field","selector":"#field","format":"MM/YYYY"}]}`, + `{"browser_id":"id","fields":[{"field":"custom_field","selector":"#field","format":"MM/YYYY"}]}`, `{"browser_id":"id","fields":[{"field":"expiration","selector":"#field","value":"secret-sentinel"}]}`, `{"browser_id":"id","browser_id":"secret-sentinel","fields":[{"field":"expiration","selector":"#field"}]}`, } { @@ -106,7 +106,7 @@ func TestCredentialFillCLIOutcomes(t *testing.T) { io.WriteString(w, result) })) defer server.Close() - out, stderr, exit := runVaultFillCLI(t, server.URL, "fill", "--params", `{"browser_id":"id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, "-o", "json") + out, stderr, exit := runVaultFillCLI(t, server.URL, "fill", "--params", `{"browser_id":"id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom_field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, "-o", "json") assert.True(t, json.Valid([]byte(out))) assert.JSONEq(t, result, out) assert.Empty(t, stderr) diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 8e3dc275..406f93de 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -69,7 +69,8 @@ type AgentCardCardSpec = { merchant: string; // approval-screen name; 1..120 characters amount: number; // integer minor units; 1..9007199254740991 currency: string; // three letters - card_id?: string; // vc_...; otherwise chosen at approval + card_id?: string; // opaque AgentCard ID, pass through unchanged; else chosen at approval + checkout_origin?: string; // top-level checkout page origin (https://host[:port], no path) for autopilot matching; omitted asks for approval; updates that omit it remove it }; type LinkLineItem = { diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index a1414860..aa5ecff8 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -43,7 +43,7 @@ var vaultItemFields = vaultOutputFields{ "action": vaultFieldsOf("name url expires_at instructions"), "expanded": {"payment_methods": vaultMethodFields}, "spec": { - "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, + "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "checkout_origin": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "context": nil, "expires_at": nil, "description": nil, "account": nil, "requests": onePasswordRequestFields, @@ -368,6 +368,9 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.Spec.Provider == "link" { rows = append(rows, []string{"Payment method ID", item.Spec.PaymentMethodID}) } + if item.Spec.Provider == "agentcard" && item.Spec.CheckoutOrigin != "" { + rows = append(rows, []string{"Checkout origin", item.Spec.CheckoutOrigin}) + } } if item.State.JSON.Domains.Valid() { rows = append(rows, []string{"Permitted domains (provider-assigned)", strings.Join(item.State.Domains, ", ")}) diff --git a/cmd/vaults_prepare_checkout.go b/cmd/vaults_prepare_checkout.go index b58ca4d8..74967c6e 100644 --- a/cmd/vaults_prepare_checkout.go +++ b/cmd/vaults_prepare_checkout.go @@ -10,9 +10,9 @@ import ( kernel "github.com/kernel/kernel-go-sdk" ) -// Environments and tokenization processors accepted by prepare_checkout. Square, -// Braintree and Worldpay use production or sandbox; Bambora and Mercado Pago use -// shared. Pairing is enforced by the API, which owns processor enablement. +// Environments and checkout processors accepted by prepare_checkout. Square, +// Braintree, Worldpay and Adyen use production or sandbox; Bambora and Mercado Pago +// use shared. Pairing is enforced by the API, which owns processor enablement. var vaultCheckoutEnvironments = []kernel.VaultCheckoutContextEnvironment{ kernel.VaultCheckoutContextEnvironmentProduction, kernel.VaultCheckoutContextEnvironmentSandbox, @@ -25,6 +25,7 @@ var vaultCheckoutProcessors = []kernel.AgentcardPreparedProcessor{ kernel.AgentcardPreparedProcessorWorldpay, kernel.AgentcardPreparedProcessorBambora, kernel.AgentcardPreparedProcessorMercadoPago, + kernel.AgentcardPreparedProcessorAdyen, } func vaultCheckoutProcessorNames() []string { diff --git a/cmd/vaults_prepare_checkout_test.go b/cmd/vaults_prepare_checkout_test.go index 5cb43950..cadecc72 100644 --- a/cmd/vaults_prepare_checkout_test.go +++ b/cmd/vaults_prepare_checkout_test.go @@ -108,7 +108,7 @@ func TestVaultPrepareCheckoutInvalidParams(t *testing.T) { } _, err := parseVaultCheckoutParams(strings.Replace(checkoutParamsFixture, "https://shop.example", "http://localhost:3000", 1)) require.NoError(t, err) - for _, psp := range []string{"square", "braintree", "worldpay", "bambora", "mercado_pago"} { + for _, psp := range []string{"square", "braintree", "worldpay", "bambora", "mercado_pago", "adyen"} { params, err := parseVaultCheckoutParams(strings.Replace(checkoutParamsFixture, `"environment":`, `"psp":"`+psp+`","environment":`, 1)) require.NoError(t, err, psp) assert.Equal(t, psp, string(params.Psp)) diff --git a/cmd/vaults_public_values_test.go b/cmd/vaults_public_values_test.go index b88bc680..ca6b98c6 100644 --- a/cmd/vaults_public_values_test.go +++ b/cmd/vaults_public_values_test.go @@ -121,3 +121,41 @@ func TestVaultFillActionableErrors(t *testing.T) { }) } } + +// label is non-secret display metadata: it must reach the API unchanged on create +// and survive the display-safe output projection on every read. +func TestVaultCredentialLabelsRoundTrip(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"description":"Hacker News","fields":[{"name":"username","label":"Username or email","type":"text","required":true,"sensitive":false},{"name":"password","label":"Password","type":"password","required":true,"sensitive":true}]}` + fixture := fmt.Sprintf(`{"id":"credential-1","key":"login","type":"credential","version":1,"spec":%s,"state":{"status":"pending_collection","fields":{"username":{"has_value":false},"password":{"has_value":false}}},"available_operations":[],"available_expansions":[]}`, spec) + sent := "" + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Spec struct { + Fields json.RawMessage `json:"fields"` + } `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + sent = string(body.Spec.Fields) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", "--spec-file", credentialSpecFile(t, spec), "-o", "json") + require.NoError(t, err) + assert.Contains(t, sent, `"label":"Username or email"`) + assert.Contains(t, sent, `"label":"Password"`) + assert.Contains(t, out, `"label": "Username or email"`) + assert.Contains(t, out, `"label": "Password"`) +} + +// A label is metadata only; it must never carry a value into the output. +func TestVaultCredentialLabelDoesNotExposeValues(t *testing.T) { + fixture := strings.Replace(publicCredentialFixture, + `{"name":"password","type":"password"}`, + `{"name":"password","label":"Password","type":"password"}`, 1) + require.NotEqual(t, publicCredentialFixture, fixture) + out, err := filterVaultJSON(json.RawMessage(fixture), vaultItemFields) + require.NoError(t, err) + assert.Contains(t, string(out), `"label":"Password"`) + assert.NotContains(t, string(out), "private-password") +} diff --git a/cmd/vaults_sdk_contract_test.go b/cmd/vaults_sdk_contract_test.go index 00f19b59..a94cc17d 100644 --- a/cmd/vaults_sdk_contract_test.go +++ b/cmd/vaults_sdk_contract_test.go @@ -124,3 +124,51 @@ func TestVaultPreparationEventsAreProjected(t *testing.T) { assert.Contains(t, out, `"preparation_id": "prep-1"`) assert.NotContains(t, out, "never-print") } + +func TestCredentialFieldOrderIsPreserved(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"description":"Example","fields":[{"name":"email","type":"email","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true},{"name":"otp","type":"totp","required":false,"sensitive":true}]}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Spec struct { + Fields json.RawMessage `json:"fields"` + } `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + // The website's top-to-bottom order must reach the API unchanged. + assert.Equal(t, `[{"name":"email","type":"email","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true},{"name":"otp","type":"totp","required":false,"sensitive":true}]`, string(body.Spec.Fields)) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"credential-1","key":"login","type":"credential","version":1,"spec":%s,"state":{"status":"pending_collection","fields":{"email":{"has_value":false},"password":{"has_value":false},"otp":{"has_value":false}}},"available_operations":[],"available_expansions":[]}`, spec) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", "--spec-file", credentialSpecFile(t, spec), "-o", "json") + require.NoError(t, err) + assert.Less(t, strings.Index(out, `"email"`), strings.Index(out, `"password"`)) + assert.Less(t, strings.Index(out, `"password"`), strings.Index(out, `"otp"`)) + for _, name := range []string{"email", "password", "otp"} { + assert.Contains(t, out, fmt.Sprintf(`"name": %q`, name)) + } +} + +func TestCredentialKeyedFieldsAreRejectedWithGuidance(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("a keyed create spec must not reach the API") + }) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", + "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password","value":"secret-echo"}}}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "ordered array") + assert.NotContains(t, err.Error(), "secret-echo") +} + +func TestCredentialFieldsRequireNames(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("an unnamed field must not reach the API") + }) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", + "--spec-file", credentialSpecFile(t, `{"fields":[{"type":"password","value":"secret-echo"}]}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "name") + assert.NotContains(t, err.Error(), "secret-echo") +} diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 14942156..88d8fddf 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -283,14 +283,14 @@ func TestVaultCardRequestMapping(t *testing.T) { if provider == "link" { assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","merchant_url":"https://shop.example","payment_method_id":"pm-1","context":%q}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) } else { - assert.JSONEq(t, `{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen"}`, string(body["spec"])) + assert.JSONEq(t, `{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen","checkout_origin":"https://shop.example.com"}`, string(body["spec"])) } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, requestedCardFixture) }) flags := linkCardArgs() if provider == "agentcard" { - flags = []string{"--provider", provider, "--spec", `{"wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen"}`} + flags = []string{"--provider", provider, "--spec", `{"wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen","checkout_origin":"https://shop.example.com"}`} } args := append([]string{"vaults", "cards", operation, "checkout", "order-1", "-o", "json"}, flags...) out, human, err := executeVaultCommand(t, client, args...) @@ -303,6 +303,45 @@ func TestVaultCardRequestMapping(t *testing.T) { } } +func TestVaultCardAgentcardCardIDIsOpaque(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + // AgentCard card IDs are opaque: the CLI must forward whatever the caller + // supplies without assuming a prefix or format. + for _, cardID := range []string{"vc_chosen", "chosen", "card-123", "AGC/9f2e::7"} { + t.Run(cardID, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + var spec struct { + CardID string `json:"card_id"` + } + require.NoError(t, json.Unmarshal(body["spec"], &spec)) + assert.Equal(t, cardID, spec.CardID) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) + }) + spec := fmt.Sprintf(`{"wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":%q}`, cardID) + _, _, err := executeVaultCommand(t, client, + "vaults", "cards", "create", "checkout", "order-1", "-o", "json", + "--provider", "agentcard", "--spec", spec) + require.NoError(t, err) + }) + } +} + +func TestVaultCardAgentcardCheckoutOriginIsPublic(t *testing.T) { + raw := `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"agentcard","wallet":"wallet-1","merchant":"Example Shop","amount":1234,"currency":"usd","checkout_origin":"https://shop.example.com"}}` + out, err := filterVaultJSON(json.RawMessage(raw), vaultItemFields) + require.NoError(t, err) + var item struct { + Spec struct { + CheckoutOrigin string `json:"checkout_origin"` + } `json:"spec"` + } + require.NoError(t, json.Unmarshal(out, &item)) + assert.Equal(t, "https://shop.example.com", item.Spec.CheckoutOrigin) +} + func TestVaultInvokeRequiresAdvertisedOperation(t *testing.T) { t.Setenv("KERNEL_PROJECT", "project-test") for _, state := range []string{"requested", "pending_authorization", "ready", "consumed", "expired", "declined"} { diff --git a/go.mod b/go.mod index e8e8cd58..2b6c3ff2 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.114.0 + github.com/kernel/kernel-go-sdk v0.114.1-0.20260930182635-e746d9980b83 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 76da961a..f361cd73 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.114.0 h1:JX8qu4XUIYQ6dvrzGpGty3KCC7GlyB1olXOBeJxBZL0= -github.com/kernel/kernel-go-sdk v0.114.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.114.1-0.20260930182635-e746d9980b83 h1:S0qtghU55P043VAtKzRzoPM9Ix8OcsEbTQNGsnoWN3U= +github.com/kernel/kernel-go-sdk v0.114.1-0.20260930182635-e746d9980b83/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=