diff --git a/README.md b/README.md index eb863c84..a2c29af7 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Commands with JSON output support: - **Proxies**: `create`, `list`, `get`, `update`, `check` - **API Keys**: `create`, `list`, `get`, `update`, `rotate` - **Auth Connections**: `timeline` -- **Vaults**: `create`, `list`, `get`, `credentials create/update`, `items list/get/events/invoke` (including `collect`, `fill`, and `prepare_checkout`), `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) +- **Vaults**: `create`, `list`, `get`, `credentials create/update`, `items list/get/events/invoke` (including `collect`, `fill`, `webmcp_invoke`, and `prepare_checkout`), `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) - **Projects**: `update` - **Org**: `limits get/set` - **Apps**: `list`, `history` @@ -259,15 +259,16 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser - `kernel browsers list` - List running browsers - `--query ` - Search by name, session ID, profile ID, proxy ID, or pool name - - `--region us-east|eu-west|ap-southeast` - Filter by geographic region; omit to list sessions in all regions + - `--region us-east|us-west|eu-west|ap-southeast` - Filter by geographic region; omit to list sessions in all regions - `--tag ` - Filter by tag, repeatable; a session must match every pair - `--output json`, `-o json` - Output raw JSON array - `kernel browsers create` - Create a new browser session - `-s, --stealth` - Launch browser in stealth mode to avoid detection - `-H, --headless` - Launch browser without GUI access - `--kiosk` - Launch browser in kiosk mode - - `--region us-east|eu-west|ap-southeast` - Geographic region for the session. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to `us-east`. + - `--region us-east|us-west|eu-west|ap-southeast` - Geographic region for the session. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to `us-east`. - `--private-host ` - Destination the browser reaches directly through the session's own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeatable or comma-separated, max 32). Accepts hostname patterns (`*.example.ts.net`), IPs (`10.1.30.63`, `[fd00::1]`), and private CIDRs (`100.64.0.0/10`). Replaces the default private ranges (RFC1918, `100.64.0.0/10`, `fc00::/7`); omit to keep them. Fixed once the session is created. Unrelated to a proxy's `--bypass-host`, which only chooses between upstream proxy and Kernel-managed direct egress. + - `--allowed-host ` - Egress allowlist: the only destinations the browser may reach through Kernel-managed egress (repeatable or comma-separated, max 100). Other destinations are refused with a 403 whose `X-Kernel-Proxy-Error` header is `network_policy_denied`. Accepts exact hostnames (`example.com`), a leading wildcard that matches subdomains but not the domain itself (`*.example.com`), public IPs (`8.8.8.8`, `[2001:4860:4860::8888]`), and public CIDRs (`8.8.4.0/24`); no ports, paths, or schemes. `--start-url` must be allowed. Omit for unfiltered egress. Requires proxy v3; not supported with pools. Create-only. - `--proxy-route '[,...]='` - Route matching browser requests through a selected proxy (repeatable, max 10 routes with 1–50 hosts each). Example: `--proxy-route 'api.ipify.org,*.ipify.org=name:my-dc-proxy'`. The proxy is an ID by default; use `id:` or `name:` explicitly. Exact hostnames beat wildcards; longer wildcard suffixes beat shorter ones. `*.example.com` matches subdomains, not `example.com`. Matching ignores case and ports. Unmatched hosts use `--proxy-*` or default egress, while `--start-url` uses the top-level proxy during setup. Routes are create-only and are not available on pool sessions. - `--start-url ` - Initial page to open on launch - `--proxy-id ` / `--proxy-name ` - Use that proxy for the session regardless of stealth (mutually exclusive with each other and with `--proxy-mode`) @@ -383,6 +384,17 @@ text/email values, definitions, version, and `has_value`. Sensitive values and T seeds are omitted. Credential spec input is capped at 128 KiB; write errors are redacted. +To reuse a managed auth connection's saved credential, create a credential with +provider `managed_auth` and the connection ID from `kernel auth connections list`. +The item stores no values and reads the connection's credential at fill time; it is +created `ready`, `state.fields` lists fill binding names, and `update` returns 409: + +```sh +kernel vaults credentials create user-vault amazon --spec-file - <<'JSON' +{"provider":"managed_auth","connection_id":"","description":"Amazon"} +JSON +``` + Vault names, item keys, and project ownership are immutable. Optionally select a project with `--project ` or `KERNEL_PROJECT`; otherwise, the API resolves the project from your credentials and its defaults (the default project for org-wide credentials, not all projects). @@ -394,7 +406,7 @@ cannot switch projects. | Command | Purpose / flags | | --- | --- | | `kernel vaults create --name ` | Create or retrieve the vault with that immutable name | -| `kernel vaults list` | `--limit 1..100` (default 20), `--offset`; JSON includes `vaults` and optional `next_offset` | +| `kernel vaults list` | `--limit 1..100` (default 20), `--offset`, `--query` (name substring or exact ID); JSON includes `vaults` and optional `next_offset` | | `kernel vaults get ` | Get by ID or name | | `kernel vaults delete ` | Invalidate the vault and all its items; `--yes` skips confirmation | | `kernel vaults wallets create --provider link\|agentcard --spec ''` | Connect/enroll a wallet using its provider's spec; `--open` opens a returned HTTPS action URL | @@ -775,7 +787,7 @@ exists. ### Browser Pools - `kernel browser-pools list` - List browser pools - - `--region us-east|eu-west|ap-southeast` - Filter by geographic region; omit to list pools in all regions + - `--region us-east|us-west|eu-west|ap-southeast` - Filter by geographic region; omit to list pools in all regions - `--output json`, `-o json` - Output raw JSON array - `kernel browser-pools create` - Create a browser pool - `--name ` - Optional unique name for the pool @@ -1009,8 +1021,12 @@ Destinations are the OTLP/HTTP endpoints sessions export to. They belong to the ### Browser Playwright - `kernel browsers playwright execute [code]` - Execute Playwright/TypeScript code against the browser + - `--executor ` - Executor to run the call in. Calls on different executors run concurrently in separate tabs of the same browser; calls on one executor run one at a time. Omit to use the always-present `default` executor bound to the active tab. Any other name creates a named executor on first use that owns a background tab `page` is bound to. At most 8 named executors per browser (409 when exceeded) - `--timeout ` - Maximum execution time in seconds (defaults server-side) - If `[code]` is omitted, code is read from stdin +- `kernel browsers playwright executors list ` - List the browser's Playwright executors (default first) with busy state, timestamps, and the tab each named executor owns +- `kernel browsers playwright executors delete ` - Delete a Playwright executor. Deleting `default` restarts it instead of removing it + - `--close-tab` - Close the tab owned by the executor (default: true) ### Browser REPL @@ -1169,21 +1185,21 @@ Managed auth connections (`kernel auth connections`). The commands below are new - `--per-page ` - Items per page (default: 20) - `--output json`, `-o json` - Output raw JSON array - `kernel auth connections create` - New flags: - - `--region us-east|eu-west|ap-southeast` - Region for this connection's login, reauth, and health-check browser sessions. Defaults to `us-east`. + - `--region us-east|us-west|eu-west|ap-southeast` - Region for this connection's login, reauth, and health-check browser sessions. Defaults to `us-east`. - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy configuration for this connection's login, reauth, and health-check browser sessions (mutually exclusive). Omit to derive the default from stealth. - `--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. + - `--region us-east|us-west|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. + - `--region us-east|us-west|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 @@ -1524,6 +1540,15 @@ TS # With a timeout in seconds kernel browsers playwright execute my-browser --timeout 30 'await (await context.newPage()).goto("https://example.com")' +# Drive two tabs in parallel with named executors (each owns its own tab) +kernel browsers playwright execute my-browser --executor docs 'await page.goto("https://example.com/docs"); return await page.title();' & +kernel browsers playwright execute my-browser --executor pricing 'await page.goto("https://example.com/pricing"); return await page.title();' & +wait + +# List executors and delete one (closes its tab unless --close-tab=false) +kernel browsers playwright executors list my-browser +kernel browsers playwright executors delete my-browser docs + # Mini CDP connection load test (10s) cat <<'TS' | kernel browsers playwright execute my-browser const start = Date.now(); diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index af052b14..1b1dc6d0 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -1363,7 +1363,7 @@ func init() { authConnectionsCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for this connection's browser sessions (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsCreateCmd.Flags().String("proxy-name", "", "Proxy name to use for this connection's browser sessions (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsCreateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the stealth-derived default") - authConnectionsCreateCmd.Flags().String("region", "", "Geographic region for browser sessions: 'us-east', 'eu-west', or 'ap-southeast'. Defaults to us-east") + authConnectionsCreateCmd.Flags().String("region", "", "Geographic region for browser sessions: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'. Defaults to us-east") authConnectionsCreateCmd.Flags().Bool("stealth", true, "Run this connection's browser sessions in stealth mode; use --stealth=false to disable") authConnectionsCreateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") authConnectionsCreateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks. Defaults to 3600 or your plan minimum, whichever is larger. The maximum is 86400; the minimum depends on your plan (Enterprise 300, Startup 1200, Hobbyist 3600, Free 21600)") @@ -1392,7 +1392,7 @@ func init() { authConnectionsUpdateCmd.Flags().String("proxy-id", "", "Proxy ID to use for future browser sessions (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsUpdateCmd.Flags().String("proxy-name", "", "Proxy name to use for future browser sessions (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsUpdateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' to drop a selected proxy and use the stealth-derived default") - authConnectionsUpdateCmd.Flags().String("region", "", "Geographic region for future browser sessions: 'us-east', 'eu-west', or 'ap-southeast'") + authConnectionsUpdateCmd.Flags().String("region", "", "Geographic region for future browser sessions: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'") authConnectionsUpdateCmd.Flags().Bool("stealth", true, "Set whether future browser sessions run in stealth mode; use --stealth=false to disable") authConnectionsUpdateCmd.Flags().Bool("save-credentials", false, "Enable saving credentials after successful login") authConnectionsUpdateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") @@ -1427,7 +1427,7 @@ func init() { authConnectionsLoginCmd.Flags().String("proxy-id", "", "Proxy ID to use for this login (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsLoginCmd.Flags().String("proxy-name", "", "Proxy name to use for this login (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsLoginCmd.Flags().String("proxy-mode", "", "Proxy egress mode for this login instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the stealth-derived default") - authConnectionsLoginCmd.Flags().String("region", "", "Geographic region override for this login: 'us-east', 'eu-west', or 'ap-southeast'") + authConnectionsLoginCmd.Flags().String("region", "", "Geographic region override for this login: 'us-east', 'us-west', '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") diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 18ffba7d..05b9b7e5 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -793,7 +793,7 @@ func init() { browserPoolsListCmd.Flags().String("query", "", "Search browser pools by name (IDs match by exact value)") browserPoolsListCmd.Flags().Int("limit", 0, "Maximum number of pools to return") browserPoolsListCmd.Flags().Int("offset", 0, "Number of pools to skip (for pagination)") - browserPoolsListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east', 'eu-west', or 'ap-southeast' (omit to list pools in all regions)") + browserPoolsListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east', 'us-west', 'eu-west', or 'ap-southeast' (omit to list pools in all regions)") addJSONOutputFlag(browserPoolsCreateCmd) browserPoolsCreateCmd.Flags().String("name", "", "Optional unique name for the pool") @@ -809,7 +809,7 @@ func init() { browserPoolsCreateCmd.Flags().String("profile-id", "", "Profile ID") browserPoolsCreateCmd.Flags().String("profile-name", "", "Profile name") browserPoolsCreateCmd.Flags().String("proxy-id", "", "Proxy ID") - browserPoolsCreateCmd.Flags().String("region", "", "Geographic region for the pool: 'us-east', 'eu-west', or 'ap-southeast'. Fixed once the pool is created; requires a Start-Up or Enterprise plan and defaults to us-east") + browserPoolsCreateCmd.Flags().String("region", "", "Geographic region for the pool: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'. Fixed once the pool is created; requires a Start-Up or Enterprise plan and defaults to us-east") browserPoolsCreateCmd.Flags().StringSlice("private-host", nil, "Destinations browsers in the pool reach directly through their own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel they join (repeat or comma-separated, max 32). Accepts hostname patterns ('*.example.ts.net'), IPs ('10.1.30.63', '[fd00::1]'), and private CIDRs ('100.64.0.0/10'). Replaces the default private ranges (RFC1918, 100.64.0.0/10, fc00::/7); omit to keep them") browserPoolsCreateCmd.Flags().String("start-url", "", "Initial page to open for new browsers") browserPoolsCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") diff --git a/cmd/browsers.go b/cmd/browsers.go index 1e79b836..a44c399d 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -166,7 +166,7 @@ func parseViewport(viewport string) (width, height, refreshRate int64, err error // availableRegions returns the geographic regions the API accepts for browser // sessions and pools. func availableRegions() []string { - return []string{"us-east", "eu-west", "ap-southeast"} + return []string{"us-east", "us-west", "eu-west", "ap-southeast"} } // parseRegionFlag validates a --region value. An empty value means the flag was @@ -236,6 +236,19 @@ func buildNetworkParam(privateHosts []string) (kernel.BrowserNetworkConfigParam, return network, nil } +// maxAllowedHosts mirrors the API's cap on network.allowed_hosts entries. +const maxAllowedHosts = 100 + +// normalizeAllowedHosts trims --allowed-host values, drops empty ones, and +// enforces the API's entry cap. Entry syntax is validated by the API. +func normalizeAllowedHosts(hosts []string) ([]string, error) { + out := normalizePrivateHosts(hosts) + if len(out) > maxAllowedHosts { + return nil, fmt.Errorf("too many --allowed-host entries: %d (maximum %d)", len(out), maxAllowedHosts) + } + return out, nil +} + const ( maxProxyRoutes = 10 maxProxyRouteHosts = 50 @@ -300,6 +313,15 @@ func formatProxyRoutes(network kernel.BrowserNetworkConfig) string { return strings.Join(routes, "; ") } +// formatAllowedHosts renders a session's egress allowlist for table output. A +// missing allowed_hosts list means egress is unfiltered. +func formatAllowedHosts(network kernel.BrowserNetworkConfig) string { + if len(network.AllowedHosts) == 0 { + return "-" + } + return strings.Join(network.AllowedHosts, ", ") +} + // formatPrivateHosts renders a network configuration for table output. A missing // private_hosts list means the API's default private ranges apply; an explicit // empty list means nothing routes around Kernel-managed egress. @@ -460,6 +482,7 @@ type BrowsersCreateInput struct { ProxyMode string Region string PrivateHosts []string + AllowedHosts []string ProxyRoutes []string StartURL string Extensions []string @@ -525,6 +548,7 @@ type BrowsersCmd struct { logs BrowserLogService computer BrowserComputerService playwright BrowserPlaywrightService + executors BrowserPlaywrightExecutorService telemetry BrowserTelemetryService webmcp BrowserWebMCPService } @@ -733,7 +757,12 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { return err } network.ProxyRoutes = routes - if len(network.PrivateHosts) > 0 || len(network.ProxyRoutes) > 0 { + allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts) + if err != nil { + return err + } + network.AllowedHosts = allowedHosts + if len(network.PrivateHosts) > 0 || len(network.ProxyRoutes) > 0 || len(network.AllowedHosts) > 0 { params.Network = network } @@ -811,7 +840,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } tableData := buildBrowserTableData(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags) - tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}, []string{"Proxy Routes", formatProxyRoutes(browser.Network)}) + tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}, []string{"Allowed Hosts", formatAllowedHosts(browser.Network)}, []string{"Proxy Routes", formatProxyRoutes(browser.Network)}) PrintTableNoPad(tableData, true) if len(browser.Vaults) > 0 { rows := pterm.TableData{{"Attached vault ID", "Name"}} @@ -954,6 +983,7 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { tableData = append(tableData, []string{"Proxy", proxy}) } tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}) + tableData = append(tableData, []string{"Allowed Hosts", formatAllowedHosts(browser.Network)}) tableData = append(tableData, []string{"Proxy Routes", formatProxyRoutes(browser.Network)}) if vaults := formatVaultReferences(browser.Vaults); vaults != "" { tableData = append(tableData, []string{"Vaults", vaults}) @@ -1840,6 +1870,7 @@ type BrowsersFSWatchEventsInput struct { type BrowsersPlaywrightExecuteInput struct { Identifier string Code string + Executor string Timeout int64 Output string } @@ -1858,12 +1889,15 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh return util.CleanedUpSdkError{Err: err} } params := kernel.BrowserPlaywrightExecuteParams{Code: in.Code} + if in.Executor != "" { + params.Executor = kernel.Opt(in.Executor) + } if in.Timeout > 0 { params.TimeoutSec = kernel.Opt(in.Timeout) } res, err := b.playwright.Execute(ctx, br.SessionID, params) if err != nil { - return util.CleanedUpSdkError{Err: err} + return playwrightExecuteError(err) } if in.Output == "json" { @@ -1871,6 +1905,9 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh } rows := pterm.TableData{{"Property", "Value"}, {"Success", fmt.Sprintf("%t", res.Success)}} + if res.JSON.Tab.Valid() { + rows = append(rows, []string{"Tab Target ID", res.Tab.TargetID}, []string{"Tab Created", fmt.Sprintf("%t", res.Tab.Created)}) + } PrintTableNoPad(rows, true) if res.Stdout != "" { @@ -2927,7 +2964,7 @@ func init() { browsersListCmd.Flags().Int("limit", 0, "Maximum number of results to return (default 20, max 100)") browsersListCmd.Flags().Int("offset", 0, "Number of results to skip (for pagination)") browsersListCmd.Flags().String("query", "", "Search browsers by name, session ID, profile ID, proxy ID, or pool name") - browsersListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east', 'eu-west', or 'ap-southeast' (omit to list sessions in all regions)") + browsersListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east', 'us-west', 'eu-west', or 'ap-southeast' (omit to list sessions in all regions)") browsersListCmd.Flags().StringArray("tag", nil, "Filter by tag KEY=VALUE (repeatable; a session must match every pair)") // get flags @@ -3201,13 +3238,7 @@ func init() { computerRoot.AddCommand(computerClick, computerMove, computerScreenshot, computerType, computerPressKey, computerScroll, computerDrag, computerSetCursor, computerGetMousePosition, computerBatch, computerReadClipboard, computerWriteClipboard) browsersCmd.AddCommand(computerRoot) - // playwright - playwrightRoot := &cobra.Command{Use: "playwright", Short: "Playwright operations"} - playwrightExecute := &cobra.Command{Use: "execute [code]", Short: "Execute Playwright/TypeScript code against the browser", Args: cobra.MinimumNArgs(1), RunE: runBrowsersPlaywrightExecute} - playwrightExecute.Flags().Int64("timeout", 0, "Maximum execution time in seconds (default per server)") - addJSONOutputFlag(playwrightExecute) - playwrightRoot.AddCommand(playwrightExecute) - browsersCmd.AddCommand(playwrightRoot) + browsersCmd.AddCommand(newBrowsersPlaywrightCommand()) // repl replCmd := &cobra.Command{ @@ -3251,8 +3282,9 @@ unrestricted code execution inside the browser VM and is not sandboxed.`, browsersCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for the browser session (mutually exclusive with --proxy-name and --proxy-mode)") browsersCreateCmd.Flags().String("proxy-name", "", "Proxy name to use for the browser session; must match exactly one active proxy in the project (mutually exclusive with --proxy-id and --proxy-mode)") browsersCreateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the browser default (Kernel's stealth proxy when --stealth is set, direct egress otherwise)") - browsersCreateCmd.Flags().String("region", "", "Geographic region for the session: 'us-east', 'eu-west', or 'ap-southeast'. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to us-east") + browsersCreateCmd.Flags().String("region", "", "Geographic region for the session: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to us-east") browsersCreateCmd.Flags().StringSlice("private-host", nil, "Destinations the browser reaches directly through its own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeat or comma-separated, max 32). Accepts hostname patterns ('*.example.ts.net'), IPs ('10.1.30.63', '[fd00::1]'), and private CIDRs ('100.64.0.0/10'). Replaces the default private ranges (RFC1918, 100.64.0.0/10, fc00::/7); omit to keep them. Fixed once the session is created") + browsersCreateCmd.Flags().StringSlice("allowed-host", nil, "Egress allowlist: the only destinations the browser may reach through Kernel-managed egress (repeat or comma-separated, max 100); anything else is refused with a 403 (network_policy_denied). Accepts exact hostnames ('example.com'), a leading wildcard matching subdomains only ('*.example.com'), public IPs ('8.8.8.8', '[2001:4860:4860::8888]'), and public CIDRs ('8.8.4.0/24'). No ports, paths, or schemes. --start-url must be allowed. Omit for unfiltered egress. Requires proxy v3; not supported with pools. Create-only") browsersCreateCmd.Flags().StringArray("proxy-route", nil, "Route HOST[,HOST...]=PROXY through a proxy (repeatable, max 10 routes and 50 hosts per route). PROXY is an ID by default; use id:ID or name:NAME explicitly. Exact hosts beat wildcards (longer suffixes win); *.example.com excludes example.com. Unmatched hosts use --proxy-* or default egress; start_url uses the top-level proxy. Create-only") browsersCreateCmd.Flags().String("start-url", "", "Initial page to open on launch") browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)") @@ -3392,6 +3424,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { proxyMode, _ := cmd.Flags().GetString("proxy-mode") region, _ := cmd.Flags().GetString("region") privateHosts, _ := cmd.Flags().GetStringSlice("private-host") + allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host") proxyRoutes, _ := cmd.Flags().GetStringArray("proxy-route") startURL, _ := cmd.Flags().GetString("start-url") extensions, _ := cmd.Flags().GetStringSlice("extension") @@ -3543,6 +3576,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { ProxyMode: proxyMode, Region: region, PrivateHosts: privateHosts, + AllowedHosts: allowedHosts, ProxyRoutes: proxyRoutes, StartURL: startURL, Extensions: extensions, @@ -3849,10 +3883,11 @@ func runBrowsersPlaywrightExecute(cmd *cobra.Command, args []string) error { } code = string(data) } + executor, _ := cmd.Flags().GetString("executor") timeout, _ := cmd.Flags().GetInt64("timeout") output, _ := cmd.Flags().GetString("output") b := BrowsersCmd{browsers: &svc, playwright: &svc.Playwright} - return b.PlaywrightExecute(cmd.Context(), BrowsersPlaywrightExecuteInput{Identifier: args[0], Code: strings.TrimSpace(code), Timeout: timeout, Output: output}) + return b.PlaywrightExecute(cmd.Context(), BrowsersPlaywrightExecuteInput{Identifier: args[0], Code: strings.TrimSpace(code), Executor: executor, Timeout: timeout, Output: output}) } func runBrowsersRepl(cmd *cobra.Command, args []string) error { diff --git a/cmd/browsers_playwright_executors.go b/cmd/browsers_playwright_executors.go new file mode 100644 index 00000000..ae44f6a8 --- /dev/null +++ b/cmd/browsers_playwright_executors.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "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/pterm/pterm" + "github.com/spf13/cobra" +) + +// BrowserPlaywrightExecutorService defines the subset we use for Playwright executors. +type BrowserPlaywrightExecutorService interface { + List(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.ExecutorList, error) + Delete(ctx context.Context, name string, params kernel.BrowserPlaywrightExecutorDeleteParams, opts ...option.RequestOption) error +} + +const playwrightExecutorsLong = `Every Playwright call runs in an executor: a dedicated process with its own +browser connection. Calls on different executors run concurrently; calls on +one executor run one at a time. A timeout or crash in one executor does not +affect the others. + +The 'default' executor always exists and binds 'page' to the active tab. Any +other name is a named executor: the first call with a new name creates it, and +it owns a background tab in the default browser context that 'page' is bound +to on every later call. Executor code can still reach other tabs through +'context' and 'browser'. Use named executors to drive several tabs of one +browser in parallel. + +A browser can have at most 8 named executors (the default executor does not +count); a call that would create another fails with HTTP 409. Named executors +are not removed automatically while the browser runs, so delete the ones you no +longer need.` + +type BrowsersPlaywrightExecutorsListInput struct { + Identifier string + Output string +} + +type BrowsersPlaywrightExecutorsDeleteInput struct { + Identifier string + Name string + CloseTab param.Opt[bool] +} + +func (b BrowsersCmd) PlaywrightExecutorsList(ctx context.Context, in BrowsersPlaywrightExecutorsListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + res, err := b.executors.List(ctx, in.Identifier) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + if len(res.Executors) == 0 { + pterm.Info.Println("No Playwright executors found") + return nil + } + rows := pterm.TableData{{"Name", "Busy", "Created At", "Last Used At", "Target ID", "URL"}} + for _, e := range res.Executors { + rows = append(rows, []string{e.Name, strconv.FormatBool(e.Busy), util.FormatLocal(e.CreatedAt), util.FormatLocal(e.LastUsedAt), util.OrDash(e.TargetID), util.OrDash(e.URL)}) + } + PrintTableNoPad(rows, true) + return nil +} + +func (b BrowsersCmd) PlaywrightExecutorsDelete(ctx context.Context, in BrowsersPlaywrightExecutorsDeleteInput) error { + params := kernel.BrowserPlaywrightExecutorDeleteParams{IDOrName: in.Identifier, CloseTab: in.CloseTab} + if err := b.executors.Delete(ctx, in.Name, params); err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Name == "default" { + pterm.Success.Println("Restarted the default Playwright executor") + } else { + pterm.Success.Printf("Deleted Playwright executor %q\n", in.Name) + } + return nil +} + +// playwrightExecuteError turns the 409 returned when a call would exceed the +// named executor limit into an error that names the current executors. +func playwrightExecuteError(err error) error { + var apiErr *kernel.Error + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusConflict { + return util.CleanedUpSdkError{Err: err} + } + var body struct { + Message string `json:"message"` + Executors []kernel.Executor `json:"executors"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) != nil || body.Message == "" { + return util.CleanedUpSdkError{Err: err} + } + var sb strings.Builder + sb.WriteString(body.Message) + if len(body.Executors) > 0 { + sb.WriteString("\nCurrent executors:") + for _, e := range body.Executors { + fmt.Fprintf(&sb, "\n %s", e.Name) + if e.Busy { + sb.WriteString(" (busy)") + } + if e.URL != "" { + fmt.Fprintf(&sb, " %s", e.URL) + } + } + } + sb.WriteString("\nDelete one with 'kernel browsers playwright executors delete '") + return errors.New(sb.String()) +} + +func newBrowsersPlaywrightCommand() *cobra.Command { + root := &cobra.Command{Use: "playwright", Short: "Playwright operations"} + execute := &cobra.Command{ + Use: "execute [code]", + Short: "Execute Playwright/TypeScript code against the browser", + Long: "Execute Playwright/TypeScript code against the browser.\n\n" + + "Code may be passed as an argument or piped via stdin. It has access to 'page', " + + "'context', and 'browser', and may return a value.\n\n" + playwrightExecutorsLong, + Args: cobra.MinimumNArgs(1), + RunE: runBrowsersPlaywrightExecute, + } + execute.Flags().String("executor", "", "Executor to run the call in. Calls on different executors run concurrently in separate tabs of the same browser; calls on one executor run one at a time. Omit to use the always-present 'default' executor bound to the active tab. Any other name creates a named executor on first use that owns a background tab 'page' is bound to. At most 8 named executors per browser (409 when exceeded)") + execute.Flags().Int64("timeout", 0, "Maximum execution time in seconds (default per server)") + addJSONOutputFlag(execute) + root.AddCommand(execute, newBrowsersPlaywrightExecutorsCommand()) + return root +} + +func newBrowsersPlaywrightExecutorsCommand() *cobra.Command { + root := &cobra.Command{Use: "executors", Short: "List and delete the Playwright executors of a browser", Long: playwrightExecutorsLong} + list := &cobra.Command{ + Use: "list ", + Short: "List the browser's Playwright executors", + Long: "List the browser's Playwright executors, the default executor first. Each entry reports " + + "whether a call is running on it and, for named executors, the target ID and URL of the tab it owns.", + Args: cobra.ExactArgs(1), + RunE: runBrowsersPlaywrightExecutorsList, + } + addJSONOutputFlag(list) + del := &cobra.Command{ + Use: "delete ", + Short: "Delete a Playwright executor and, by default, close its tab", + Long: "Stop a Playwright executor's process and, by default, close the tab it owns. A call running " + + "on it fails with an error saying the executor was deleted; the name can be reused afterwards.\n\n" + + "Deleting 'default' restarts it instead of removing it: queued and later calls run on a new " + + "process. It owns no tab, so --close-tab has no effect on it.", + Args: cobra.ExactArgs(2), + RunE: runBrowsersPlaywrightExecutorsDelete, + } + del.Flags().Bool("close-tab", true, "Close the tab owned by the executor") + root.AddCommand(list, del) + return root +} + +func runBrowsersPlaywrightExecutorsList(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + client := getKernelClient(cmd) + b := BrowsersCmd{executors: &client.Browsers.Playwright.Executors} + return b.PlaywrightExecutorsList(cmd.Context(), BrowsersPlaywrightExecutorsListInput{Identifier: args[0], Output: output}) +} + +func runBrowsersPlaywrightExecutorsDelete(cmd *cobra.Command, args []string) error { + var closeTab param.Opt[bool] + if cmd.Flags().Changed("close-tab") { + value, _ := cmd.Flags().GetBool("close-tab") + closeTab = kernel.Opt(value) + } + client := getKernelClient(cmd) + b := BrowsersCmd{executors: &client.Browsers.Playwright.Executors} + return b.PlaywrightExecutorsDelete(cmd.Context(), BrowsersPlaywrightExecutorsDeleteInput{Identifier: args[0], Name: args[1], CloseTab: closeTab}) +} diff --git a/cmd/browsers_playwright_executors_test.go b/cmd/browsers_playwright_executors_test.go new file mode 100644 index 00000000..9e37772a --- /dev/null +++ b/cmd/browsers_playwright_executors_test.go @@ -0,0 +1,316 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func executePlaywrightCommand(t *testing.T, handler http.HandlerFunc, args ...string) (string, string, error) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test"), option.WithMaxRetries(0)) + root := &cobra.Command{Use: "kernel", SilenceErrors: true, SilenceUsage: true} + root.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client)) + root.AddCommand(newBrowsersPlaywrightCommand()) + root.SetArgs(append([]string{"playwright"}, args...)) + buf := capturePtermOutput(t) + var err error + stdout := captureStdout(t, func() { err = root.Execute() }) + return stdout, buf.String(), err +} + +const playwrightExecutorsFixture = `{"executors":[{"name":"default","busy":false,"created_at":"2026-01-02T03:04:05Z","last_used_at":"2026-01-02T03:05:05Z"},{"name":"checkout","busy":true,"created_at":"2026-01-02T03:06:05Z","last_used_at":"2026-01-02T03:07:05Z","target_id":"ABCDEF0123456789","url":"https://example.com/cart"}]}` + +func TestPlaywrightCommandWiring(t *testing.T) { + for _, path := range [][]string{{"execute"}, {"executors", "list"}, {"executors", "delete"}} { + name := path[len(path)-1] + cmd, remaining, err := rootCmd.Find(append([]string{"browsers", "playwright"}, path...)) + require.NoError(t, err) + require.Empty(t, remaining) + assert.Equal(t, name, cmd.Name()) + assert.NotNil(t, cmd.RunE) + assert.False(t, isAuthExempt(cmd)) + } + + execute, _, err := rootCmd.Find([]string{"browsers", "playwright", "execute"}) + require.NoError(t, err) + executor := execute.Flags().Lookup("executor") + require.NotNil(t, executor) + assert.Equal(t, "", executor.DefValue) + assert.Contains(t, executor.Usage, "default") + assert.Contains(t, executor.Usage, "8 named executors") + assert.NotNil(t, execute.Flags().Lookup("timeout")) + assert.NotNil(t, execute.Flags().Lookup("output")) + + del, _, err := rootCmd.Find([]string{"browsers", "playwright", "executors", "delete"}) + require.NoError(t, err) + closeTab := del.Flags().Lookup("close-tab") + require.NotNil(t, closeTab) + assert.Equal(t, "true", closeTab.DefValue) + assert.Contains(t, del.Long, "default") + + list, _, err := rootCmd.Find([]string{"browsers", "playwright", "executors", "list"}) + require.NoError(t, err) + assert.NotNil(t, list.Flags().Lookup("output")) +} + +func TestPlaywrightExecuteExecutorParam(t *testing.T) { + for _, tc := range []struct { + name string + flags []string + executor string + }{ + {"omitted", nil, ""}, + {"named", []string{"--executor", "checkout"}, "checkout"}, + } { + t.Run(tc.name, func(t *testing.T) { + var body struct { + Code string `json:"code"` + Executor *string `json:"executor"` + Timeout *int64 `json:"timeout_sec"` + } + calls := 0 + stdout, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/browsers/my-browser": + fmt.Fprint(w, `{"session_id":"session123"}`) + case r.Method == http.MethodPost && r.URL.Path == "/browsers/session123/playwright/execute": + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + fmt.Fprint(w, `{"success":true,"result":{"title":"Example"},"tab":{"target_id":"ABCDEF0123456789","created":true}}`) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + }, append([]string{"execute", "my-browser", "return await page.title()", "--timeout", "30"}, tc.flags...)...) + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.Equal(t, "return await page.title()", body.Code) + if tc.executor == "" { + assert.Nil(t, body.Executor) + } else { + require.NotNil(t, body.Executor) + assert.Equal(t, tc.executor, *body.Executor) + } + require.NotNil(t, body.Timeout) + assert.Equal(t, int64(30), *body.Timeout) + for _, value := range []string{"Success", "true", "Tab Target ID", "ABCDEF0123456789", "Tab Created"} { + assert.Contains(t, table, value) + } + assert.Contains(t, stdout, `"title": "Example"`) + }) + } +} + +func TestPlaywrightExecuteOutput(t *testing.T) { + for _, tc := range []struct { + name string + response string + json bool + }{ + {"without tab", `{"success":false,"error":"boom"}`, false}, + {"with tab", `{"success":true,"tab":{"target_id":"ABCDEF0123456789","created":false}}`, false}, + {"json", `{"success":true,"result":42,"tab":{"target_id":"ABCDEF0123456789","created":true}}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + args := []string{"execute", "session123", "return 1"} + if tc.json { + args = append(args, "-o", "json") + } + stdout, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"session_id":"session123"}`) + return + } + fmt.Fprint(w, tc.response) + }, args...) + require.NoError(t, err) + if tc.json { + assert.JSONEq(t, tc.response, stdout) + assert.Empty(t, table) + return + } + if strings.Contains(tc.response, `"tab"`) { + assert.Contains(t, table, "Tab Target ID") + assert.Contains(t, table, "ABCDEF0123456789") + assert.Contains(t, table, "Tab Created") + } else { + assert.NotContains(t, table, "Tab Target ID") + assert.Contains(t, table, "boom") + } + }) + } +} + +func TestPlaywrightExecuteExecutorLimit(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"session_id":"session123"}`) + return + } + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"message":"Browser already has 8 named Playwright executors",`+ + `"executors":[{"name":"default","busy":false,"created_at":"2026-01-02T03:04:05Z","last_used_at":"2026-01-02T03:04:05Z"},`+ + `{"name":"checkout","busy":true,"created_at":"2026-01-02T03:04:05Z","last_used_at":"2026-01-02T03:04:05Z","target_id":"T1","url":"https://example.com/cart"}]}`) + }, "execute", "session123", "return 1", "--executor", "ninth") + require.Error(t, err) + msg := err.Error() + assert.Contains(t, msg, "Browser already has 8 named Playwright executors") + assert.Contains(t, msg, "Current executors:") + assert.Contains(t, msg, "default") + assert.Contains(t, msg, "checkout (busy) https://example.com/cart") + assert.Contains(t, msg, "kernel browsers playwright executors delete") + // The root error handler wraps command errors again before printing them. + assert.Equal(t, msg, util.CleanedUpSdkError{Err: err}.Error()) +} + +func TestPlaywrightExecuteOtherErrors(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + want string + }{ + {"not conflict", http.StatusBadRequest, `{"code":"invalid_request","message":"Invalid executor name"}`, "invalid_request: Invalid executor name"}, + {"conflict without message", http.StatusConflict, `{"code":"conflict","message":""}`, "conflict: "}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"session_id":"session123"}`) + return + } + w.WriteHeader(tc.status) + fmt.Fprint(w, tc.body) + }, "execute", "session123", "return 1", "--executor", "bad name") + require.EqualError(t, err, tc.want) + }) + } +} + +func TestPlaywrightExecutorsList(t *testing.T) { + for _, identifier := range []string{"my-browser", "session123"} { + for _, flags := range [][]string{nil, {"-o", "json"}, {"--output", "json"}} { + t.Run(identifier+strings.Join(flags, ""), func(t *testing.T) { + calls := 0 + stdout, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/browsers/"+identifier+"/playwright/executors", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, playwrightExecutorsFixture) + }, append([]string{"executors", "list", identifier}, flags...)...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + if len(flags) > 0 { + assert.JSONEq(t, playwrightExecutorsFixture, stdout) + assert.Empty(t, table) + return + } + for _, value := range []string{"Name", "Busy", "Created At", "Last Used At", "Target ID", "URL", "default", "false", "checkout", "true", "ABCDEF0123456789", "https://example.com/cart"} { + assert.Contains(t, table, value) + } + rows := strings.Split(strings.TrimSpace(table), "\n") + require.Len(t, rows, 3) + assert.Contains(t, rows[1], "default") + assert.Equal(t, 2, strings.Count(rows[1], " - "), rows[1]) + }) + } + } +} + +func TestPlaywrightExecutorsListEmpty(t *testing.T) { + _, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"executors":[]}`) + }, "executors", "list", "my-browser") + require.NoError(t, err) + assert.Contains(t, table, "No Playwright executors found") +} + +func TestPlaywrightExecutorsDelete(t *testing.T) { + for _, tc := range []struct { + name string + executor string + flags []string + closeTab string + want string + }{ + {"default close", "checkout", nil, "", `Deleted Playwright executor "checkout"`}, + {"keep tab", "checkout", []string{"--close-tab=false"}, "false", `Deleted Playwright executor "checkout"`}, + {"explicit close", "checkout", []string{"--close-tab"}, "true", `Deleted Playwright executor "checkout"`}, + {"default executor", "default", nil, "", "Restarted the default Playwright executor"}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + stdout, out, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/browsers/my-browser/playwright/executors/"+tc.executor, r.URL.Path) + assert.Equal(t, tc.closeTab, r.URL.Query().Get("close_tab")) + w.WriteHeader(http.StatusNoContent) + }, append([]string{"executors", "delete", "my-browser", tc.executor}, tc.flags...)...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.Empty(t, stdout) + assert.Contains(t, out, tc.want) + }) + } +} + +func TestPlaywrightExecutorsErrors(t *testing.T) { + for _, tc := range []struct { + args []string + status int + body string + want string + }{ + {[]string{"executors", "list", "missing"}, http.StatusNotFound, `{"code":"not_found","message":"Browser not found"}`, "not_found: Browser not found"}, + {[]string{"executors", "delete", "my-browser", "missing"}, http.StatusNotFound, `{"code":"not_found","message":"Executor not found"}`, "not_found: Executor not found"}, + {[]string{"executors", "delete", "my-browser", "bad name"}, http.StatusBadRequest, `{"code":"invalid_request","message":"Invalid executor name"}`, "invalid_request: Invalid executor name"}, + } { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + fmt.Fprint(w, tc.body) + }, tc.args...) + require.EqualError(t, err, tc.want) + }) + } +} + +func TestPlaywrightExecutorsInvalidInput(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"executors", "list"}, "accepts 1 arg"}, + {[]string{"executors", "list", "browser", "-o", "yaml"}, "unsupported --output"}, + {[]string{"executors", "delete", "browser"}, "accepts 2 arg"}, + {[]string{"executors", "delete", "browser", "a", "b"}, "accepts 2 arg"}, + } { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("invalid input reached API") + }, tc.args...) + require.ErrorContains(t, err, tc.want) + }) + } +} diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 801fe04c..76b8adbc 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -610,6 +610,38 @@ func TestBrowsersCreate_WithPrivateHosts(t *testing.T) { })) } +func TestBrowsersCreate_WithAllowedHosts(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "sess-allowlist"}, nil + }, + } + + err := (BrowsersCmd{browsers: fake}).Create(context.Background(), BrowsersCreateInput{ + AllowedHosts: []string{" example.com ", "*.example.com", ""}, + }) + require.NoError(t, err) + assert.Equal(t, []string{"example.com", "*.example.com"}, captured.Network.AllowedHosts) + + raw, err := captured.MarshalJSON() + require.NoError(t, err) + assert.Contains(t, string(raw), `"allowed_hosts":["example.com","*.example.com"]`) + assert.NotContains(t, string(raw), "private_hosts") + + // The API's 100-entry cap is enforced client-side. + tooMany := make([]string, maxAllowedHosts+1) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("host-%d.example.com", i) + } + assert.Error(t, (BrowsersCmd{browsers: fake}).Create(context.Background(), BrowsersCreateInput{ + AllowedHosts: tooMany, + })) +} + func TestParseProxyRoutes(t *testing.T) { routes, err := parseProxyRoutes([]string{" api.ipify.org , *.ipify.org =name:my-dc-proxy", "other.example=id:proxy-123", "fallback.example=proxy-456"}) require.NoError(t, err) @@ -745,6 +777,10 @@ func TestBrowsersCreate_WithRegion(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(raw), `"region":"ap-southeast"`) + err = b.Create(context.Background(), BrowsersCreateInput{Region: "us-west"}) + require.NoError(t, err) + assert.Equal(t, kernel.BrowserNewParamsRegionUsWest, captured.Region) + // Omitting the flag sends nothing; the server defaults to us-east. err = b.Create(context.Background(), BrowsersCreateInput{}) require.NoError(t, err) diff --git a/cmd/credentials.go b/cmd/credentials.go index 3c7d1564..bf982534 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -173,10 +173,13 @@ func (c CredentialsCmd) Get(ctx context.Context, in CredentialsGetInput) error { {"Name", cred.Name}, {"Domain", cred.Domain}, {"Has TOTP Secret", hasTOTP}, + } + tableData = append(tableData, credentialTotpRows(cred)...) + tableData = append(tableData, pterm.TableData{ {"SSO Provider", ssoProvider}, {"Created At", util.FormatLocal(cred.CreatedAt)}, {"Updated At", util.FormatLocal(cred.UpdatedAt)}, - } + }...) PrintTableNoPad(tableData, true) return nil @@ -276,8 +279,9 @@ func (c CredentialsCmd) Create(ctx context.Context, in CredentialsCreateInput) e {"Name", cred.Name}, {"Domain", cred.Domain}, {"Has TOTP Secret", hasTOTP}, - {"SSO Provider", ssoProvider}, } + tableData = append(tableData, credentialTotpRows(cred)...) + tableData = append(tableData, []string{"SSO Provider", ssoProvider}) PrintTableNoPad(tableData, true) @@ -289,6 +293,36 @@ func (c CredentialsCmd) Create(ctx context.Context, in CredentialsCreateInput) e return nil } +// normalizeTotpAlgorithm validates a TOTP HMAC algorithm and returns its +// canonical upper-case form (SHA1, SHA256, or SHA512). +func normalizeTotpAlgorithm(algorithm string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(algorithm)) + switch normalized { + case "SHA1", "SHA256", "SHA512": + return normalized, nil + default: + return "", fmt.Errorf("invalid --totp-algorithm %q (must be one of SHA1, SHA256, SHA512)", algorithm) + } +} + +// credentialTotpRows returns TOTP metadata rows for credentials with a TOTP secret. +func credentialTotpRows(cred *kernel.Credential) pterm.TableData { + if !cred.HasTotpSecret { + return nil + } + rows := pterm.TableData{} + if cred.TotpAlgorithm != "" { + rows = append(rows, []string{"TOTP Algorithm", string(cred.TotpAlgorithm)}) + } + if cred.TotpDigits > 0 { + rows = append(rows, []string{"TOTP Digits", fmt.Sprintf("%d", cred.TotpDigits)}) + } + if cred.TotpPeriod > 0 { + rows = append(rows, []string{"TOTP Period", fmt.Sprintf("%ds", cred.TotpPeriod)}) + } + return rows +} + func (c CredentialsCmd) Update(ctx context.Context, in CredentialsUpdateInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -427,6 +461,9 @@ Examples: # Create a credential with TOTP for 2FA kernel credentials create --name "my-2fa-site" --domain "example.com" --value "username=myuser" --value "password=mypass" --totp-secret "JBSWY3DPEHPK3PXP" + # Create a credential with custom TOTP parameters + kernel credentials create --name "my-8digit-site" --domain "example.com" --value "username=myuser" --totp-secret "JBSWY3DPEHPK3PXP" --totp-algorithm SHA256 --totp-digits 8 --totp-period 60 + # Create a credential with SSO provider kernel credentials create --name "google-sso" --domain "example.com" --value "email=user@gmail.com" --value "password=mypass" --sso-provider google`, Args: cobra.NoArgs, diff --git a/cmd/credentials_test.go b/cmd/credentials_test.go new file mode 100644 index 00000000..a38477b8 --- /dev/null +++ b/cmd/credentials_test.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeTotpAlgorithm(t *testing.T) { + for input, want := range map[string]string{"SHA1": "SHA1", "sha256": "SHA256", " Sha512 ": "SHA512"} { + got, err := normalizeTotpAlgorithm(input) + require.NoError(t, err) + assert.Equal(t, want, got) + } + + _, err := normalizeTotpAlgorithm("md5") + assert.Error(t, err) +} diff --git a/cmd/logs.go b/cmd/logs.go index 4715eeb1..13fc92bf 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -65,7 +65,12 @@ func runLogs(cmd *cobra.Command, args []string) error { pterm.Info.Println("Showing recent logs (timeout after 3s with no events)") } - stream := client.Invocations.FollowStreaming(cmd.Context(), inv.ID, kernel.InvocationFollowParams{}, option.WithMaxRetries(0)) + // Only forward --since when explicitly set so older invocations still show their full logs + invParams := kernel.InvocationFollowParams{} + if cmd.Flags().Changed("since") { + invParams.Since = kernel.Opt(since) + } + stream := client.Invocations.FollowStreaming(cmd.Context(), inv.ID, invParams, option.WithMaxRetries(0)) if stream.Err() != nil { return fmt.Errorf("failed to follow streaming: %w", stream.Err()) } diff --git a/cmd/offset_pagination_test.go b/cmd/offset_pagination_test.go index fed03c9c..2256aa45 100644 --- a/cmd/offset_pagination_test.go +++ b/cmd/offset_pagination_test.go @@ -72,7 +72,7 @@ func TestOffsetPaginationListCommands(t *testing.T) { case "projects": err = (ProjectsCmd{projects: &client.Projects}).List(context.Background(), ProjectsListInput{Limit: 20, Offset: 20, Output: "json"}) case "vaults": - err = (VaultsCmd{vaults: &client.Vaults}).List(context.Background(), 20, 20, "", "json") + err = (VaultsCmd{vaults: &client.Vaults}).List(context.Background(), 20, 20, "", "", "json") case "vault-provider-configs": err = (VaultProviderConfigsCmd{configs: &client.VaultProviderConfigs}).List(context.Background(), 20, 20, "json") } @@ -87,3 +87,16 @@ func TestOffsetPaginationListCommands(t *testing.T) { } } } + +func TestVaultsListQuery(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "my vault", r.URL.Query().Get("query")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Has-More", "true") + w.Header().Set("X-Next-Offset", "20") + _, _ = io.WriteString(w, "[]") + }) + setupStdoutCapture(t) + require.NoError(t, (VaultsCmd{vaults: &client.Vaults}).List(context.Background(), 20, 0, "my vault", "", "table")) + assert.Contains(t, outBuf.String(), `--query "my vault"`) +} diff --git a/cmd/vaults.go b/cmd/vaults.go index 59c4e35f..dbd7968d 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -51,12 +51,16 @@ func (c VaultsCmd) Get(ctx context.Context, vault, output string) error { return printVault(v, output) } -func (c VaultsCmd) List(ctx context.Context, limit, offset int64, project, output string) error { +func (c VaultsCmd) List(ctx context.Context, limit, offset int64, query, project, output string) error { if limit < 1 || limit > 100 || offset < 0 { return fmt.Errorf("--limit must be between 1 and 100; --offset must be non-negative") } var response *http.Response - page, err := c.vaults.List(ctx, kernel.VaultListParams{Limit: kernel.Opt(limit), Offset: kernel.Opt(offset)}, option.WithMaxRetries(0), option.WithResponseInto(&response)) + params := kernel.VaultListParams{Limit: kernel.Opt(limit), Offset: kernel.Opt(offset)} + if query != "" { + params.Query = kernel.Opt(query) + } + page, err := c.vaults.List(ctx, params, option.WithMaxRetries(0), option.WithResponseInto(&response)) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -88,7 +92,11 @@ func (c VaultsCmd) List(ctx context.Context, limit, offset int64, project, outpu if project != "" { projectFlag = fmt.Sprintf(" --project %q", project) } - pterm.Printf("Next: kernel%s vaults list --limit %d --offset %d\n", projectFlag, limit, pagination.NextOffset) + queryFlag := "" + if query != "" { + queryFlag = fmt.Sprintf(" --query %q", query) + } + pterm.Printf("Next: kernel%s vaults list --limit %d --offset %d%s\n", projectFlag, limit, pagination.NextOffset, queryFlag) } return nil } diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 43c99312..0402b6a1 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -118,11 +118,13 @@ JSON output preserves returned public fields but omits unknown/opaque provider d RunE: func(cmd *cobra.Command, args []string) error { limit, _ := cmd.Flags().GetInt64("limit") offset, _ := cmd.Flags().GetInt64("offset") + query, _ := cmd.Flags().GetString("query") project, _ := cmd.Flags().GetString("project") - return getVaultsHandler(cmd).List(cmd.Context(), limit, offset, resolveProjectSelection(project), vaultOutput(cmd)) + return getVaultsHandler(cmd).List(cmd.Context(), limit, offset, query, resolveProjectSelection(project), vaultOutput(cmd)) }} list.Flags().Int64("limit", 20, "Maximum vaults to return (1-100)") list.Flags().Int64("offset", 0, "Number of vaults to skip") + list.Flags().String("query", "", "Case-insensitive substring match against vault name; IDs match by exact value") addVaultJSONOutputFlag(list) get := &cobra.Command{Use: "get ", Short: "Get a vault by ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, @@ -273,7 +275,7 @@ JSON items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultWebMCPCommand(), newVaultDeleteCommand(true)) wallets := &cobra.Command{Use: "wallets", Short: "Connect provider wallets and inspect funding methods"} - walletCreate := &cobra.Command{Use: "create --provider --spec ''", Short: "Create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + walletCreate := &cobra.Command{Use: "create --provider --spec ''", Short: "Create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, Long: "Create a wallet at an immutable key and follow the returned provider action.\n" + vaultSpecHelp + vaultWalletSpecHelp, Example: ` kernel vaults wallets create checkout wallet-1 \ --provider link --spec '{ @@ -284,7 +286,10 @@ JSON }' --open kernel vaults wallets create checkout wallet-1 \ - --provider agentcard --spec '{}'`, + --provider agentcard --spec '{}' + + kernel vaults wallets create checkout wallet-2 \ + --provider kernel --spec '{}' --open`, RunE: func(cmd *cobra.Command, args []string) error { spec, err := vaultWalletSpecFromFlags(cmd) if err != nil { @@ -338,13 +343,14 @@ func newVaultCardCommand(update bool) *cobra.Command { if update { use, short = "update", "Update a card spec when the API permits configuration" } - cmd := &cobra.Command{Use: use + " --provider --spec ''", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: short + `. Neither create nor update authorizes a Link card. + cmd := &cobra.Command{Use: use + " --provider --spec ''", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: short + `. Neither create nor update authorizes a Link or Kernel card. Requested cards accept a replacement spec. Pending issuance updates preserve omitted optional fields; explicit empty lists clear them. The API restricts fields after authorization starts; wallet/provider bindings cannot change. An uncertain update enters recovery_required and must not be retried. Checkout cards can be edited -between authorizations. Identical creates return existing state without resetting it. +between authorizations. Kernel cards cannot be updated; delete and create a new item. +Identical creates return existing state without resetting it. Never reconfigure the same item to retry a failed, timed-out, rejected, or indeterminate payment. A recovery item that permits abandonment must be deleted after explicit user confirmation before creating a replacement. ` + vaultSpecHelp + vaultCardSpecHelp, @@ -360,6 +366,9 @@ A recovery item that permits abandonment must be deleted after explicit user con if err != nil { return err } + if provider, _ := cmd.Flags().GetString("provider"); update && provider == "kernel" { + return fmt.Errorf("Kernel cards cannot be updated; delete the item and create a new one") + } return getVaultsHandler(cmd).SaveCard(cmd.Context(), args[0], args[1], param.Override[kernel.CardVaultItemSpecUnionParam](spec), update, vaultOutput(cmd)) }} addVaultSpecFlags(cmd) @@ -368,7 +377,7 @@ A recovery item that permits abandonment must be deleted after explicit user con } func addVaultSpecFlags(cmd *cobra.Command) { - cmd.Flags().String("provider", "", "Provider: link or agentcard (required)") + cmd.Flags().String("provider", "", "Provider: link, agentcard, or kernel (required)") cmd.Flags().String("spec", "", "Raw JSON specification object (required); see types and examples above") _ = cmd.MarkFlagRequired("provider") _ = cmd.MarkFlagRequired("spec") @@ -376,8 +385,8 @@ func addVaultSpecFlags(cmd *cobra.Command) { func vaultSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { provider, _ := cmd.Flags().GetString("provider") - if provider != "link" && provider != "agentcard" { - return nil, fmt.Errorf("--provider must be link or agentcard") + if provider != "link" && provider != "agentcard" && provider != "kernel" { + return nil, fmt.Errorf("--provider must be link, agentcard, or kernel") } raw, _ := cmd.Flags().GetString("spec") var spec map[string]json.RawMessage diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index c4a30e51..1ffeaade 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -79,6 +79,14 @@ and requests instead of account. Supply either account or both secrets, never bo or stdin; they are write-only and never displayed. Never ask an end user for them. Replace the token with items invoke 1pw_update_access_token --spec-file.` +const vaultManagedAuthCredentialHelp = `Managed auth credentials (spec provider "managed_auth"): reference a managed auth +connection in the vault's project that already has a saved Kernel credential, with +connection_id (from auth connections list) and an optional description. The item +stores no values; fill reads the connection's saved credential at fill time, so +managed auth updates apply immediately. Items are created ready; state.fields lists +fill binding names without values. No collection form is offered and update returns +409. Deleting the item leaves the connection and its credential unchanged.` + const vaultCredentialHelp = `Create credentials for a website. ` + vaultCredentialPathsHelp + ` @@ -119,7 +127,9 @@ Collection URLs are bearer credentials: share only with the intended user. ` + vaultOnePasswordCredentialHelp + ` -` + vaultOnePasswordStoredTokenHelp +` + vaultOnePasswordStoredTokenHelp + ` + +` + vaultManagedAuthCredentialHelp func newVaultCredentialsCommand() *cobra.Command { group := &cobra.Command{Use: "credentials", Short: "Collect, update, and fill user credentials", Long: vaultCredentialHelp} @@ -158,6 +168,11 @@ JSON # 1Password brokered approval (account is the connected credential_account key) kernel vaults credentials create user-vault github --spec-file - <<'JSON' {"provider":"1password","account":"onepassword","requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"}}]}} +JSON + + # Managed auth connection with a saved credential + kernel vaults credentials create user-vault amazon --spec-file - <<'JSON' +{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"} JSON` } cmd.Flags().String("spec-file", "", "Credential spec JSON file (use '-' for stdin; maximum 128 KiB)") @@ -293,8 +308,15 @@ func credentialSpecInput(data []byte) (kernel.CredentialVaultItemSpecInputUnionP return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("1Password credential spec requires requests with 1-5 login entries") } return kernel.CredentialVaultItemSpecInputUnionParam{Of1password: &spec}, nil + case "managed_auth": + var spec kernel.ManagedAuthCredentialVaultItemSpecInputParam + if json.Unmarshal(data, &spec) != nil || strings.TrimSpace(spec.ConnectionID) == "" { + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("managed auth credential spec requires connection_id") + } + spec.Provider = kernel.ManagedAuthCredentialVaultItemSpecInputProviderManagedAuth + return kernel.CredentialVaultItemSpecInputUnionParam{OfManagedAuth: &spec}, nil default: - return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("credential spec provider must be kernel or 1password") + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("credential spec provider must be kernel, 1password, or managed_auth") } } diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go index 0fcaf9a0..e0c26857 100644 --- a/cmd/vaults_credentials_test.go +++ b/cmd/vaults_credentials_test.go @@ -218,5 +218,5 @@ func TestCredentialSpecInputProvider(t *testing.T) { 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") + assert.EqualError(t, err, "credential spec provider must be kernel, 1password, or managed_auth") } diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index ea948f44..61231032 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -45,6 +45,16 @@ type AgentCardWalletSpec = { provider_config?: ProviderConfigReference; // omit for Kernel-managed credentials user_id?: string; // usr_...; enrolled in this organization under the SAME config }; + +// One card stored with Kernel-managed credentials. Creation returns a card_enrollment +// action: the cardholder enters the card on a Kernel-hosted page, and the wallet +// connects once the card is stored. Kernel then enrolls it for an agentic network +// token when the issuer supports it; until then payment-methods reports +// capabilities.single_use_card.eligible=false and authorize returns 400. +// The card number never reaches Kernel or the CLI. No provider config or tokens. +type KernelWalletSpec = { + provider: "kernel"; +}; ` const vaultCardSpecHelp = ` @@ -73,6 +83,21 @@ type AgentCardCardSpec = { checkout_origin?: string; // top-level checkout origin for autopilot matching; update omission removes it }; +// One live purchase with a Kernel-enrolled card. Authorize obtains a network token and +// one-time code for fill on merchant_url's origin until expires_at. Mastercard purchases +// need no hosted approval. A Visa purchase returns a spend_approval action: the cardholder +// approves it with a Visa passkey (the link expires after 30 minutes) before the code is +// issued. Updates are not supported. +type KernelCardSpec = { + provider: "kernel"; + wallet: string; // Kernel wallet item key + amount: number; // integer minor units; 1..50000 + currency: string; // ISO 4217 three letters + merchant_name: string; // 1..255 characters + merchant_url: string; // HTTPS merchant checkout URL; fill is locked to its origin + merchant_country?: string; // ISO 3166-1 alpha-2; required for Visa cards +}; + type LinkLineItem = { name: string; quantity?: number; // integer >= 1 diff --git a/cmd/vaults_managed_auth_test.go b/cmd/vaults_managed_auth_test.go new file mode 100644 index 00000000..5de2da2a --- /dev/null +++ b/cmd/vaults_managed_auth_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const managedAuthCredentialFixture = `{"id":"credential-3","key":"amazon","type":"credential","version":1,"spec":{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"},"state":{"provider":"managed_auth","status":"ready","fields":{"username":{"type":"email"},"password":{"type":"password"}}},"available_operations":[{"type":"fill","description":"Fill the login form."}],"available_expansions":[],"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` + +func TestCredentialSpecInputManagedAuth(t *testing.T) { + spec, err := credentialSpecInput([]byte(`{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"}`)) + require.NoError(t, err) + require.NotNil(t, spec.OfManagedAuth) + assert.Equal(t, "ma_abc123xyz", spec.OfManagedAuth.ConnectionID) + assert.Equal(t, "Amazon", spec.OfManagedAuth.Description.Value) + + _, err = credentialSpecInput([]byte(`{"provider":"managed_auth"}`)) + assert.EqualError(t, err, "managed auth credential spec requires connection_id") +} + +func TestCredentialCreateManagedAuth(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/vaults/user/items/amazon", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"credential","spec":{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"}}`, string(body)) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, managedAuthCredentialFixture) + }) + spec := `{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"}` + out, _, err := executeVaultInputCommand(t, client, spec, "vaults", "credentials", "create", "user", "amazon", "--spec-file", "-", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"connection_id": "ma_abc123xyz"`) + assert.Contains(t, out, `"type": "password"`) + assert.NotContains(t, out, `"has_value"`) + + out, text, err := executeVaultInputCommand(t, client, spec, "vaults", "credentials", "create", "user", "amazon", "--spec-file", "-") + require.NoError(t, err) + assert.Contains(t, out+text, "Managed auth connection (immutable)") + assert.Contains(t, out+text, "ma_abc123xyz") +} diff --git a/cmd/vaults_onepassword_test.go b/cmd/vaults_onepassword_test.go index ec7d6b72..e6f3aef7 100644 --- a/cmd/vaults_onepassword_test.go +++ b/cmd/vaults_onepassword_test.go @@ -79,7 +79,7 @@ func TestCredentialCreateOnePassword(t *testing.T) { {`{"provider":"1password","account":"onepassword"}`, "1-5 login entries"}, {`{"provider":"1password","access_token":"token-secret","integration_key":"key-secret","website":"https://github.com"}`, "1-5 login entries"}, {`{"provider":"1password","account":"onepassword","requests":{"version":2,"entries":[` + strings.Repeat(entry+",", 5) + entry + `]}}`, "1-5 login entries"}, - {`{"provider":"lastpass","fields":[{"name":"password","type":"password"}]}`, "kernel or 1password"}, + {`{"provider":"lastpass","fields":[{"name":"password","type":"password"}]}`, "kernel, 1password, or managed_auth"}, } { _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user", "github", "--spec-file", credentialSpecFile(t, tc.spec)) require.ErrorContains(t, err, tc.err, tc.spec) diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index aa5ecff8..2dfb6d53 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -44,8 +44,8 @@ var vaultItemFields = vaultOutputFields{ "expanded": {"payment_methods": vaultMethodFields}, "spec": { "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, + "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "merchant_country": nil, + "context": nil, "expires_at": nil, "description": nil, "account": nil, "connection_id": nil, "requests": onePasswordRequestFields, "fields": vaultFieldsOf("name label type required sensitive"), "provider_config": vaultFieldsOf("id name"), @@ -62,8 +62,8 @@ var vaultItemFields = vaultOutputFields{ "id": nil, "state": nil, "goal": nil, "createdAt": nil, "has_autofill_token": nil, "granted_count": nil, "request": onePasswordRequestFields, "entries": onePasswordRequestEntryFields, }, - "fields": {"*": vaultFieldsOf("has_value")}, - "masks": vaultFieldsOf("brand last4"), + "fields": {"*": vaultFieldsOf("has_value type")}, + "masks": vaultFieldsOf("brand last4 token_last4"), "aliases": vaultFieldsOf("number cvc exp_month exp_year"), "preparation": vaultFieldsOf("id status browser_id merchant_origin environment psp created_at expires_at approval_url"), "authorization": vaultFieldsOf("id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered"), @@ -155,7 +155,8 @@ func preservePublicCredentialValues(source, result vaultJSON) error { Sensitive *bool `json:"sensitive"` } var spec struct { - Fields []definition `json:"fields"` + Provider string `json:"provider"` + Fields []definition `json:"fields"` } var values struct { Fields map[string]struct { @@ -166,6 +167,10 @@ func preservePublicCredentialValues(source, result vaultJSON) error { if json.Unmarshal(source["spec"], &spec) != nil || json.Unmarshal(source["state"], &values) != nil || values.Fields == nil { return nil } + // Managed auth state lists binding names and types only; values are never returned. + if spec.Provider == "managed_auth" { + return nil + } definitions := make(map[string]definition, len(spec.Fields)) for _, field := range spec.Fields { definitions[field.Name] = field @@ -339,6 +344,8 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { } } } + } else if item.Spec.Provider == "managed_auth" { + rows = append(rows, []string{"Managed auth connection (immutable)", item.Spec.ConnectionID}) } else { pterm.Info.Println("Use -o json for field definitions, presence, and non-sensitive values; sensitive values are omitted") } @@ -371,6 +378,18 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.Spec.Provider == "agentcard" && item.Spec.CheckoutOrigin != "" { rows = append(rows, []string{"Checkout origin", item.Spec.CheckoutOrigin}) } + if item.Spec.Provider == "kernel" && item.Spec.MerchantURL != "" { + rows = append(rows, []string{"Merchant URL", item.Spec.MerchantURL}) + } + if item.Spec.Provider == "kernel" && item.Spec.MerchantCountry != "" { + rows = append(rows, []string{"Merchant country", item.Spec.MerchantCountry}) + } + if masks := item.State.Masks; masks.Last4 != "" || masks.TokenLast4 != "" { + rows = append(rows, []string{"Card last4", util.OrDash(masks.Last4)}) + if masks.TokenLast4 != "" { + rows = append(rows, []string{"Network token last4", masks.TokenLast4}) + } + } } if item.State.JSON.Domains.Valid() { rows = append(rows, []string{"Permitted domains (provider-assigned)", strings.Join(item.State.Domains, ", ")}) @@ -447,6 +466,10 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction pterm.Info.Println("Ready means the account owner approved access, not that sign-in succeeded. 1pw_fill submits the form; inspect the page afterward. Never retry a request or fill automatically; after an uncertain outcome, do not delete and recreate the item.") return } + if item.Type == "credential" && item.Spec.Provider == "managed_auth" { + pterm.Info.Println("Fill reads the managed auth connection's saved credential at fill time; use -o json for fill binding names. Ready means a saved credential exists, not that login succeeded. Fill only when advertised; fill does not submit the form.") + return + } if item.Type == "credential" { if actions.RequiredAction != "" { pterm.Info.Println("Share the collection URL with the user to complete the credential form. Observe readiness with items get --wait 60; for edits to an already-ready item, compare versions without --wait.") diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index 034f05fe..b9fd8a05 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -104,6 +104,26 @@ func TestVaultOutputLinkHasNoAliases(t *testing.T) { } } +func TestVaultOutputKernelCardMerchantCountry(t *testing.T) { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(`{ + "id":"card-id","key":"order-1","type":"card", + "spec":{"provider":"kernel","wallet":"wallet-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"}, + "state":{"provider":"kernel","status":"pending_authorization"}, + "available_operations":[],"available_expansions":[] + }`), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + assert.Contains(t, buf.String(), "Merchant country") + assert.Contains(t, buf.String(), "US") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + var decoded struct { + Spec vaultJSON `json:"spec"` + } + require.NoError(t, json.Unmarshal([]byte(out), &decoded)) + assert.JSONEq(t, `"US"`, string(decoded.Spec["merchant_country"])) +} + func TestVaultOutputAgentCardAuthorizationIsNotPaymentSuccess(t *testing.T) { var item kernel.VaultItemUnion require.NoError(t, json.Unmarshal([]byte(`{ diff --git a/cmd/vaults_wallet_spec.go b/cmd/vaults_wallet_spec.go index 8136b3a1..1cf5ded1 100644 --- a/cmd/vaults_wallet_spec.go +++ b/cmd/vaults_wallet_spec.go @@ -41,7 +41,11 @@ func vaultWalletSpecFromFlags(cmd *cobra.Command) (kernel.VaultItemUpsertParamsB return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, err } provider, _ := cmd.Flags().GetString("provider") - if provider == "agentcard" { + if provider == "kernel" { + if reference != nil || cmd.Flags().Changed("tokens-file") { + return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, fmt.Errorf("Kernel wallets use Kernel-managed credentials; omit provider config and --tokens-file") + } + } else if provider == "agentcard" { if cmd.Flags().Changed("tokens-file") { return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, fmt.Errorf("--tokens-file is only for imported Link wallet grants") } diff --git a/cmd/vaults_wallet_spec_test.go b/cmd/vaults_wallet_spec_test.go index 9711ddba..0d6f2be1 100644 --- a/cmd/vaults_wallet_spec_test.go +++ b/cmd/vaults_wallet_spec_test.go @@ -63,3 +63,37 @@ func TestVaultImportedAuthorizationPreservesRawFields(t *testing.T) { }) } } + +func TestVaultKernelWalletSendsProviderOnly(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Type string `json:"type"` + Spec map[string]json.RawMessage `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "wallet", body.Type) + raw, err := json.Marshal(body.Spec) + require.NoError(t, err) + assert.JSONEq(t, `{"provider":"kernel"}`, string(raw)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, connectedWalletFixture) + }) + _, _, err := executeVaultInputCommand(t, client, "", "vaults", "wallets", "create", "checkout", "wallet-1", "--provider", "kernel", "--spec", "{}", "-o", "json") + require.NoError(t, err) +} + +func TestVaultKernelProviderRejectsUnsupportedInputs(t *testing.T) { + for name, args := range map[string][]string{ + "provider config": {"wallets", "create", "checkout", "wallet-1", "--provider", "kernel", "--spec", "{}", "--provider-config-name", "cfg"}, + "tokens file": {"wallets", "create", "checkout", "wallet-1", "--provider", "kernel", "--spec", "{}", "--tokens-file", "-"}, + "card update": {"cards", "update", "checkout", "card-1", "--provider", "kernel", "--spec", "{}"}, + } { + t.Run(name, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + }) + _, _, err := executeVaultInputCommand(t, client, "", append([]string{"vaults"}, args...)...) + require.Error(t, err) + }) + } +} diff --git a/go.mod b/go.mod index 2696f88c..fd049025 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.117.0 + github.com/kernel/kernel-go-sdk v0.120.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 080d8770..27c84c37 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.117.0 h1:b6/am7RkJyhadi/98pMHAyuiwyEoDD6smZ4V92pUJ40= -github.com/kernel/kernel-go-sdk v0.117.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.120.0 h1:m4E5OPcv3jZfODi+zP5waICXL1fvcoBD0xdvDAAP4hM= +github.com/kernel/kernel-go-sdk v0.120.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=