diff --git a/README.md b/README.md index 3db24c1..80cb0b0 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,8 @@ Commands with JSON output support: - `-H, --headless` - Launch browser without GUI access - `--kiosk` - Launch browser in kiosk mode - `--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`) + - `--proxy-mode direct|default` - Egress mode instead of a selected proxy: `direct` for no proxy regardless of stealth, `default` for the stealth-derived default (Kernel's stealth proxy with `--stealth`, direct egress otherwise). Omit all proxy flags to get the default. - `--name ` - Optional unique name for the session (used to find it later by name; can be changed with `browsers update --name`) - `--tag ` - Set a tag on the session, repeatable; up to 50 pairs - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags). `--name`/`--tag` still apply to the acquired session. @@ -224,6 +226,7 @@ Commands with JSON output support: - `--telemetry=all` - Enable telemetry for all categories - `--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. - `--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 @@ -241,7 +244,10 @@ Commands with JSON output support: - `--telemetry=all` - Enable telemetry for all categories - `--telemetry=off` - Disable telemetry - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` - - `--disable-default-proxy` - Disable the default stealth proxy so the browser connects directly; use `--disable-default-proxy=false` to re-enable it + - `--proxy-id ` / `--proxy-name ` - Switch the session to that proxy regardless of stealth (mutually exclusive with each other and with `--proxy-mode`) + - `--proxy-mode direct|default` - Change egress mode: `direct` for no proxy regardless of stealth, `default` to restore the browser default after using a selected proxy. Changing the proxy does not change stealth or CAPTCHA solver behavior. + - `--clear-proxy` - Drop the selected proxy and restore the browser default (same as `--proxy-mode=default`) + - `--disable-default-proxy` - Connect directly instead of through the default stealth proxy (same as `--proxy-mode=direct`); use `--disable-default-proxy=false` to restore the default - `--output json`, `-o json` - Output raw JSON object - `kernel browsers curl ` - Make HTTP requests through a browser session's Chrome network stack - `-X, --request ` - HTTP method (default: GET; defaults to POST when `--data` is set) @@ -321,6 +327,16 @@ Telemetry config is a sub-field of the browser session. Use `browsers create` or Per-category updates are partial — only categories you name are changed; others retain their current state. `--telemetry=all` and `--telemetry=off` reset the entire config. +#### Exporting telemetry + +Captured telemetry can be exported over OTLP to one of the org's configured destinations with `--telemetry-export-otlp `. A value that looks like an ID is sent as one; anything else is resolved as a destination name, which must match exactly one destination in the org. + +- 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 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. + - `kernel browsers telemetry stream ` - Stream live telemetry events (NDJSON with `-o json`) - `--categories ` - Filter by event category (`console`, `network`, `page`, `interaction`, `control`, `connection`, `system`, `screenshot`, `captcha`, `monitor`) - `--types ` - Filter by event type (e.g. `network_response`, `console_error`) @@ -534,6 +550,11 @@ Per-category updates are partial — only categories you name are changed; other - `kernel proxies delete ` - Delete a proxy configuration - `-y, --yes` - Skip confirmation prompt +### Auth Context + +- `kernel auth context` - Show the identity and authorization context resolved for the current credentials: the authenticated principal, organization, credential scope, and the effective scope for the request. Credential secrets are never returned. Pass `--project ` to see the effective scope a project-scoped request would get. + - `--output json`, `-o json` - Output raw JSON object + ### Auth Connections Managed auth connections (`kernel auth connections`). The commands below are new or gained new flags; run `kernel auth connections --help` for the full command list. @@ -543,12 +564,21 @@ Managed auth connections (`kernel auth connections`). The commands below are new - `--page ` - Page number (1-based, default: 1) - `--per-page ` - Items per page (default: 20) - `--output json`, `-o json` - Output raw JSON array -- `kernel auth connections create` - New flag: +- `kernel auth connections create` - New flags: + - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy configuration for this connection's login, reauth, and health-check browser sessions (mutually exclusive). Omit to derive the default from stealth. + - `--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` -- `kernel auth connections update ` - New flag: + - `--telemetry-export-otlp ` - Export this connection's captured telemetry over OTLP to one of the org's configured destinations. Implies `--telemetry=all` when `--telemetry` is not set. Use `=off` to disable export. +- `kernel auth connections update ` - New flags: + - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy configuration for future browser sessions (mutually exclusive). Use `--proxy-mode=default` to drop a selected proxy rather than passing an empty value. + - `--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 -- `kernel auth connections login ` - New flag: + - `--telemetry-export-otlp ` - Update where future sessions export captured telemetry. Naming a destination requires passing `--telemetry` in the same command, since the API validates capture and export together and enabling capture here would replace the connection's current category selection. Use `=off` to disable export. +- `kernel auth connections login ` - New flags: + - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy override for this login's browser session (mutually exclusive); omitted properties inherit the connection defaults + - `--stealth` - Stealth override for this login's browser session; use `--stealth=false` to disable - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Telemetry override for this login only, merged onto the connection's config + - `--telemetry-export-otlp ` - Export override for this login only. Naming a destination requires passing `--telemetry` in the same command. Use `=off` to disable export. - `kernel auth connections submit ` - New flags: - `--field-value ` - Canonical field-id=value pair from the connection's `fields` list (repeatable); preferred over the legacy `--field` - `--choice-id ` - Canonical choice ID from the connection's `choices` list diff --git a/cmd/api_keys.go b/cmd/api_keys.go index c83f140..428b9cc 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -35,9 +35,15 @@ type APIKeysCreateInput struct { } type APIKeysListInput struct { - Limit int - Offset int - Output string + Limit int + Offset int + Name string + Query string + Status string + IncludeDeleted bool + SortBy string + SortDirection string + Output string } type APIKeysGetInput struct { @@ -116,6 +122,50 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { if in.Offset > 0 { params.Offset = kernel.Int(int64(in.Offset)) } + if in.Name != "" { + params.Name = kernel.String(in.Name) + } + if in.Query != "" { + params.Query = kernel.String(in.Query) + } + // Prefer the newer --status filter; fall back to the deprecated + // --include-deleted so existing scripts keep working. + if in.Status != "" { + switch in.Status { + case "active": + params.Status = kernel.APIKeyListParamsStatusActive + case "deleted": + params.Status = kernel.APIKeyListParamsStatusDeleted + case "all": + params.Status = kernel.APIKeyListParamsStatusAll + default: + return fmt.Errorf("invalid --status value: %s (must be 'active', 'deleted', or 'all')", in.Status) + } + } else if in.IncludeDeleted { + params.IncludeDeleted = kernel.Opt(true) + } + if in.SortBy != "" { + switch in.SortBy { + case "created_at": + params.SortBy = kernel.APIKeyListParamsSortByCreatedAt + case "name": + params.SortBy = kernel.APIKeyListParamsSortByName + case "expires_at": + params.SortBy = kernel.APIKeyListParamsSortByExpiresAt + default: + return fmt.Errorf("invalid --sort-by value: %s (must be 'created_at', 'name', or 'expires_at')", in.SortBy) + } + } + if in.SortDirection != "" { + switch in.SortDirection { + case "asc": + params.SortDirection = kernel.APIKeyListParamsSortDirectionAsc + case "desc": + params.SortDirection = kernel.APIKeyListParamsSortDirectionDesc + default: + return fmt.Errorf("invalid --sort-direction value: %s (must be 'asc' or 'desc')", in.SortDirection) + } + } page, err := c.apiKeys.List(ctx, params) if err != nil { @@ -136,9 +186,15 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { return nil } - table := pterm.TableData{{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Created At"}} + // Only surface Deleted At when the filter can actually return deleted keys. + showDeletedAt := in.IncludeDeleted || in.Status == "deleted" || in.Status == "all" + header := []string{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Created At"} + if showDeletedAt { + header = append(header, "Deleted At") + } + table := pterm.TableData{header} for _, key := range keys { - table = append(table, []string{ + row := []string{ key.ID, key.Name, formatAPIKeyScope(key), @@ -146,7 +202,11 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { key.MaskedKey, formatAPIKeyExpiresAt(key), util.FormatLocal(key.CreatedAt), - }) + } + if showDeletedAt { + row = append(row, util.FormatLocal(key.DeletedAt)) + } + table = append(table, row) } PrintTableNoPad(table, true) return nil @@ -362,11 +422,23 @@ func runAPIKeysList(cmd *cobra.Command, args []string) error { c := getAPIKeysHandler(cmd) limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") + name, _ := cmd.Flags().GetString("name") + query, _ := cmd.Flags().GetString("query") + status, _ := cmd.Flags().GetString("status") + includeDeleted, _ := cmd.Flags().GetBool("include-deleted") + sortBy, _ := cmd.Flags().GetString("sort-by") + sortDirection, _ := cmd.Flags().GetString("sort-direction") output, _ := cmd.Flags().GetString("output") return c.List(cmd.Context(), APIKeysListInput{ - Limit: limit, - Offset: offset, - Output: output, + Limit: limit, + Offset: offset, + Name: name, + Query: query, + Status: status, + IncludeDeleted: includeDeleted, + SortBy: sortBy, + SortDirection: sortDirection, + Output: output, }) } @@ -468,6 +540,12 @@ func init() { addJSONOutputFlag(apiKeysListCmd) apiKeysListCmd.Flags().Int("limit", 0, "Maximum number of results to return") apiKeysListCmd.Flags().Int("offset", 0, "Number of results to skip") + apiKeysListCmd.Flags().String("name", "", "Exact-match filter on API key name (names are not unique, so several keys may match)") + apiKeysListCmd.Flags().String("query", "", "Search API keys by name, creator, or project (identifiers and masked keys match by exact value or prefix)") + apiKeysListCmd.Flags().String("status", "", "Filter by status: 'active' (default), 'deleted', or 'all'") + apiKeysListCmd.Flags().Bool("include-deleted", false, "Deprecated: Use --status all instead. Include soft-deleted API keys in the results") + apiKeysListCmd.Flags().String("sort-by", "", "Sort by: created_at, name, or expires_at") + apiKeysListCmd.Flags().String("sort-direction", "", "Sort direction: asc or desc") addJSONOutputFlag(apiKeysGetCmd) apiKeysGetCmd.Flags().Bool("include-deleted", false, "Include soft-deleted API keys in the lookup") diff --git a/cmd/audit_logs_download.go b/cmd/audit_logs_download.go index a49954e..56c5ec8 100644 --- a/cmd/audit_logs_download.go +++ b/cmd/audit_logs_download.go @@ -30,6 +30,7 @@ type AuditLogsDownloadInput struct { Service string AuthStrategy string UserIDs []string + Format string To string Force bool } @@ -50,7 +51,7 @@ func (c AuditLogsCmd) Download(ctx context.Context, in AuditLogsDownloadInput) e outPath := in.To if outPath == "" { - outPath = defaultAuditLogsDownloadPath(params.Start, params.End) + outPath = defaultAuditLogsDownloadPath(params.Start, params.End, params.Format) } partialPath := outPath + ".partial" out, err := openAuditLogsDownloadOutput(partialPath, outPath, in.Force) @@ -202,7 +203,14 @@ func buildAuditLogsDownloadParams(in AuditLogsDownloadInput) (kernel.AuditLogExp params.Start = start params.End = end - params.Format = kernel.AuditLogExportChunkParamsFormatJSONLGz + switch in.Format { + case "", "jsonl.gz": + params.Format = kernel.AuditLogExportChunkParamsFormatJSONLGz + case "jsonl": + params.Format = kernel.AuditLogExportChunkParamsFormatJSONL + default: + return params, fmt.Errorf("invalid --format value: %s (must be 'jsonl.gz' or 'jsonl')", in.Format) + } if in.Search != "" { params.Search = kernel.String(in.Search) } @@ -222,9 +230,9 @@ func buildAuditLogsDownloadParams(in AuditLogsDownloadInput) (kernel.AuditLogExp return params, nil } -func defaultAuditLogsDownloadPath(start, end time.Time) string { +func defaultAuditLogsDownloadPath(start, end time.Time, format kernel.AuditLogExportChunkParamsFormat) string { const stamp = "20060102" - return fmt.Sprintf("audit-logs-%s-%s.jsonl.gz", start.UTC().Format(stamp), end.UTC().Format(stamp)) + return fmt.Sprintf("audit-logs-%s-%s.%s", start.UTC().Format(stamp), end.UTC().Format(stamp), format) } // checkAuditLogsDownloadTarget rejects paths that are not replaceable @@ -289,20 +297,23 @@ func runAuditLogsDownload(cmd *cobra.Command, args []string) error { service, _ := cmd.Flags().GetString("service") authStrategy, _ := cmd.Flags().GetString("auth-strategy") userIDs, _ := cmd.Flags().GetStringArray("user-id") + format, _ := cmd.Flags().GetString("format") to, _ := cmd.Flags().GetString("to") force, _ := cmd.Flags().GetBool("force") return c.Download(cmd.Context(), AuditLogsDownloadInput{ Start: start, End: end, Search: search, Method: method, ExcludeMethod: excludeMethod, IncludeGet: includeGet, Service: service, - AuthStrategy: authStrategy, UserIDs: userIDs, To: to, Force: force, + AuthStrategy: authStrategy, UserIDs: userIDs, Format: format, + To: to, Force: force, }) } var auditLogsDownloadCmd = &cobra.Command{ Use: "download", - Short: "Download audit logs as gzip-compressed JSONL", - Long: "Download audit logs as gzip-compressed JSONL in verified chunks. The time range is [start, end).\n\n" + + Short: "Download audit logs as JSONL", + Long: "Download audit logs as JSONL in verified chunks. The time range is [start, end).\n\n" + + "Output is gzip-compressed by default; pass --format jsonl for uncompressed JSONL.\n\n" + "The API allows at most 30 days per download.\n\n" + "GET requests are excluded by default; pass --include-get to include them.\n\n" + "The output file is published only after every chunk is downloaded.", @@ -321,7 +332,8 @@ func init() { auditLogsDownloadCmd.Flags().String("service", "", "Filter by service") auditLogsDownloadCmd.Flags().String("auth-strategy", "", "Filter by authentication strategy") auditLogsDownloadCmd.Flags().StringArray("user-id", nil, "Filter by user ID (repeatable)") - auditLogsDownloadCmd.Flags().String("to", "", "Output .jsonl.gz file path") + auditLogsDownloadCmd.Flags().String("format", "jsonl.gz", "Export format: jsonl.gz or jsonl") + auditLogsDownloadCmd.Flags().String("to", "", "Output file path (defaults to a name derived from the window and --format)") auditLogsDownloadCmd.Flags().Bool("force", false, "Overwrite the output file") _ = auditLogsDownloadCmd.MarkFlagRequired("start") _ = auditLogsDownloadCmd.MarkFlagRequired("end") diff --git a/cmd/audit_logs_download_test.go b/cmd/audit_logs_download_test.go index 9df3d67..9a9fcda 100644 --- a/cmd/audit_logs_download_test.go +++ b/cmd/audit_logs_download_test.go @@ -246,9 +246,36 @@ func TestAuditLogsDownloadDoesNotRetryClientErrors(t *testing.T) { func TestDefaultAuditLogsDownloadPath(t *testing.T) { start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 6, 28, 0, 0, 0, 0, time.UTC) - path := defaultAuditLogsDownloadPath(start, end) - assert.Equal(t, "audit-logs-20260601-20260628.jsonl.gz", path) + gz := defaultAuditLogsDownloadPath(start, end, kernel.AuditLogExportChunkParamsFormatJSONLGz) + assert.Equal(t, "audit-logs-20260601-20260628.jsonl.gz", gz) + + plain := defaultAuditLogsDownloadPath(start, end, kernel.AuditLogExportChunkParamsFormatJSONL) + assert.Equal(t, "audit-logs-20260601-20260628.jsonl", plain) +} + +func TestAuditLogsDownloadFormatParam(t *testing.T) { + base := AuditLogsDownloadInput{Start: "2026-06-01", End: "2026-06-02"} + + for _, tc := range []struct { + format string + want kernel.AuditLogExportChunkParamsFormat + }{ + {format: "", want: kernel.AuditLogExportChunkParamsFormatJSONLGz}, + {format: "jsonl.gz", want: kernel.AuditLogExportChunkParamsFormatJSONLGz}, + {format: "jsonl", want: kernel.AuditLogExportChunkParamsFormatJSONL}, + } { + in := base + in.Format = tc.format + params, err := buildAuditLogsDownloadParams(in) + require.NoError(t, err) + assert.Equal(t, tc.want, params.Format) + } + + in := base + in.Format = "csv" + _, err := buildAuditLogsDownloadParams(in) + require.ErrorContains(t, err, "invalid --format value") } func TestAuditLogsDownloadRejectsBadChunkBeforeWriting(t *testing.T) { diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 1f8b5f1..b40926f 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -48,6 +48,8 @@ type AuthConnectionCreateInput struct { CredentialAuto bool ProxyID string ProxyName string + ProxyMode string + Stealth BoolFlag SaveCredentials bool NoSaveCredentials bool HealthCheckInterval int @@ -55,6 +57,7 @@ type AuthConnectionCreateInput struct { NoAutoReauth bool RecordSession BoolFlag Telemetry string + TelemetryExport string Output string } @@ -80,6 +83,8 @@ type AuthConnectionUpdateInput struct { ProxyIDSet bool ProxyName string ProxyNameSet bool + ProxyMode string + Stealth BoolFlag SaveCredentials BoolFlag HealthCheckInterval int HealthCheckIntervalSet bool @@ -87,12 +92,14 @@ type AuthConnectionUpdateInput struct { AutoReauth BoolFlag RecordSession BoolFlag Telemetry string + TelemetryExport string Output string } type AuthConnectionListInput struct { Domain string ProfileName string + Query string Limit int Offset int Output string @@ -104,12 +111,15 @@ type AuthConnectionDeleteInput struct { } type AuthConnectionLoginInput struct { - ID string - ProxyID string - ProxyName string - RecordSession BoolFlag - Telemetry string - Output string + ID string + ProxyID string + ProxyName string + ProxyMode string + Stealth BoolFlag + RecordSession BoolFlag + Telemetry string + TelemetryExport string + Output string } type AuthConnectionSubmitInput struct { @@ -194,14 +204,17 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn } } - if in.ProxyID != "" || in.ProxyName != "" { - params.ManagedAuthCreateRequest.Proxy = kernel.ManagedAuthCreateRequestProxyParam{} - if in.ProxyID != "" { - params.ManagedAuthCreateRequest.Proxy.ID = kernel.Opt(in.ProxyID) - } - if in.ProxyName != "" { - params.ManagedAuthCreateRequest.Proxy.Name = kernel.Opt(in.ProxyName) + sel := proxySelection{ID: in.ProxyID, Name: in.ProxyName, Mode: in.ProxyMode} + if sel.set() { + proxy, err := buildProxyConfigParam(sel) + if err != nil { + return err } + params.ManagedAuthCreateRequest.Browser.Proxy = proxy + } + + if in.Stealth.Set { + params.ManagedAuthCreateRequest.Browser.Stealth = kernel.Opt(in.Stealth.Value) } if in.NoSaveCredentials { @@ -220,12 +233,12 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" { - t, err := buildAuthConnectionCreateTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, true) if err != nil { return err } - params.ManagedAuthCreateRequest.BrowserTelemetry = t + params.ManagedAuthCreateRequest.Browser.Telemetry = t } if in.Output != "json" { @@ -264,13 +277,25 @@ func printManagedAuthSummary(auth *kernel.ManagedAuth) { if auth.Credential.Provider != "" { tableData = append(tableData, []string{"Credential Provider", auth.Credential.Provider}) } - if auth.ProxyID != "" { - tableData = append(tableData, []string{"Proxy ID", auth.ProxyID}) + tableData = append(tableData, managedAuthBrowserRows(auth.Browser)...) + PrintTableNoPad(tableData, true) +} + +// managedAuthBrowserRows renders the browser configuration a connection applies to +// its login, reauthentication, and health-check sessions. +func managedAuthBrowserRows(cfg kernel.ManagedAuthBrowserConfig) pterm.TableData { + rows := pterm.TableData{} + if proxy := formatBrowserProxyConfig(cfg.Proxy); proxy != "" { + rows = append(rows, []string{"Browser Proxy", proxy}) } - if auth.BrowserTelemetry.Enabled || len(telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: auth.BrowserTelemetry.Browser})) > 0 { - tableData = append(tableData, []string{"Browser Telemetry", formatManagedAuthTelemetry(auth.BrowserTelemetry)}) + // Stealth defaults to true when omitted, so only report what the API sent. + if cfg.JSON.Stealth.Valid() { + rows = append(rows, []string{"Browser Stealth", fmt.Sprintf("%t", cfg.Stealth)}) } - PrintTableNoPad(tableData, true) + if cfg.Telemetry.Enabled || len(telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: cfg.Telemetry.Browser})) > 0 { + rows = append(rows, []string{"Browser Telemetry", formatManagedAuthTelemetry(cfg.Telemetry)}) + } + return rows } func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateInput) error { @@ -333,24 +358,33 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn hasChanges = true } - proxyChanged := in.ProxyIDSet || in.ProxyNameSet - if proxyChanged { - params.ManagedAuthUpdateRequest.Proxy = kernel.ManagedAuthUpdateRequestProxyParam{} - if in.ProxyIDSet { - params.ManagedAuthUpdateRequest.Proxy.ID = kernel.Opt(in.ProxyID) - } - if in.ProxyNameSet { - params.ManagedAuthUpdateRequest.Proxy.Name = kernel.Opt(in.ProxyName) + // A proxy is selected by ID or name, so an empty value is not a way to clear it: + // dropping back to the connection's stealth-derived egress is a mode change. + if (in.ProxyIDSet && in.ProxyID == "") || (in.ProxyNameSet && in.ProxyName == "") { + return fmt.Errorf("proxy selection requires a non-empty value; use --proxy-mode=default to drop the selected proxy, or --proxy-mode=direct for direct egress") + } + + sel := proxySelection{ID: in.ProxyID, Name: in.ProxyName, Mode: in.ProxyMode} + if sel.set() { + proxy, err := buildProxyConfigParam(sel) + if err != nil { + return err } + params.ManagedAuthUpdateRequest.Browser.Proxy = proxy hasChanges = true } - if in.Telemetry != "" { - t, err := buildAuthConnectionUpdateTelemetryParam(in.Telemetry) + if in.Stealth.Set { + params.ManagedAuthUpdateRequest.Browser.Stealth = kernel.Opt(in.Stealth.Value) + hasChanges = true + } + + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, false) if err != nil { return err } - params.ManagedAuthUpdateRequest.BrowserTelemetry = t + params.ManagedAuthUpdateRequest.Browser.Telemetry = t hasChanges = true } @@ -429,6 +463,9 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e if f.Required { meta = append(meta, "required") } + if f.ReplaceExisting { + meta = append(meta, "replace-existing") + } entry := f.ID if f.Label != "" { entry = fmt.Sprintf("%s (%s)", f.ID, f.Label) @@ -537,9 +574,7 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e if auth.BrowserSessionID != "" { tableData = append(tableData, []string{"Browser Session ID", auth.BrowserSessionID}) } - if auth.BrowserTelemetry.Enabled || len(telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: auth.BrowserTelemetry.Browser})) > 0 { - tableData = append(tableData, []string{"Browser Telemetry", formatManagedAuthTelemetry(auth.BrowserTelemetry)}) - } + tableData = append(tableData, managedAuthBrowserRows(auth.Browser)...) PrintTableNoPad(tableData, true) return nil @@ -557,6 +592,9 @@ func (c AuthConnectionCmd) List(ctx context.Context, in AuthConnectionListInput) if in.ProfileName != "" { params.ProfileName = kernel.Opt(in.ProfileName) } + if in.Query != "" { + params.Query = kernel.Opt(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Opt(int64(in.Limit)) } @@ -641,26 +679,29 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu } params := kernel.AuthConnectionLoginParams{} - if in.ProxyID != "" || in.ProxyName != "" { - params.Proxy = kernel.AuthConnectionLoginParamsProxy{} - if in.ProxyID != "" { - params.Proxy.ID = kernel.Opt(in.ProxyID) - } - if in.ProxyName != "" { - params.Proxy.Name = kernel.Opt(in.ProxyName) + sel := proxySelection{ID: in.ProxyID, Name: in.ProxyName, Mode: in.ProxyMode} + if sel.set() { + proxy, err := buildProxyConfigParam(sel) + if err != nil { + return err } + params.Browser.Proxy = proxy + } + + if in.Stealth.Set { + params.Browser.Stealth = kernel.Opt(in.Stealth.Value) } if in.RecordSession.Set { params.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" { - t, err := buildAuthConnectionLoginTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, false) if err != nil { return err } - params.BrowserTelemetry = t + params.Browser.Telemetry = t } if in.Output != "json" { @@ -949,7 +990,11 @@ func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowIn if len(state.Fields) > 0 { fieldIDs := make([]string, 0, len(state.Fields)) for _, f := range state.Fields { - fieldIDs = append(fieldIDs, f.ID) + id := f.ID + if f.ReplaceExisting { + id += " (replace-existing)" + } + fieldIDs = append(fieldIDs, id) } pterm.Info.Printf(" Fields: %s\n", strings.Join(fieldIDs, ", ")) } @@ -1089,14 +1134,17 @@ func init() { authConnectionsCreateCmd.Flags().String("credential-provider", "", "External credential provider name") authConnectionsCreateCmd.Flags().String("credential-path", "", "Provider-specific path (e.g., VaultName/ItemName)") authConnectionsCreateCmd.Flags().Bool("credential-auto", false, "Lookup by domain from the specified provider (defaults to true when --credential-provider is set without --credential-path)") - authConnectionsCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use") - authConnectionsCreateCmd.Flags().String("proxy-name", "", "Proxy name to use") + 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().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 (300-86400)") + 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)") authConnectionsCreateCmd.Flags().Bool("no-health-checks", false, "Disable periodic health checks (enabled by default)") authConnectionsCreateCmd.Flags().Bool("no-auto-reauth", false, "Mark expired sessions as NEEDS_AUTH instead of attempting automatic re-authentication (auto re-auth is enabled by default)") 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.MarkFlagRequired("domain") _ = authConnectionsCreateCmd.MarkFlagRequired("profile-name") authConnectionsCreateCmd.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") @@ -1112,17 +1160,20 @@ func init() { authConnectionsUpdateCmd.Flags().String("credential-provider", "", "External credential provider name") authConnectionsUpdateCmd.Flags().String("credential-path", "", "Provider-specific path (e.g., VaultName/ItemName)") authConnectionsUpdateCmd.Flags().Bool("credential-auto", false, "Lookup by domain from the specified provider") - authConnectionsUpdateCmd.Flags().String("proxy-id", "", "Proxy ID to use") - authConnectionsUpdateCmd.Flags().String("proxy-name", "", "Proxy name to use") + 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().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") - authConnectionsUpdateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks") + authConnectionsUpdateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks. The maximum is 86400; the minimum depends on your plan (Enterprise 300, Startup 1200, Hobbyist 3600, Free 21600)") authConnectionsUpdateCmd.Flags().Bool("health-checks", false, "Enable periodic health checks") authConnectionsUpdateCmd.Flags().Bool("no-health-checks", false, "Disable periodic health checks") authConnectionsUpdateCmd.Flags().Bool("auto-reauth", false, "Permit automatic re-authentication when a health check detects an expired session") authConnectionsUpdateCmd.Flags().Bool("no-auto-reauth", false, "Mark expired sessions as NEEDS_AUTH instead of attempting automatic re-authentication") 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.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("save-credentials", "no-save-credentials") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("health-checks", "no-health-checks") @@ -1132,6 +1183,7 @@ func init() { addJSONOutputFlag(authConnectionsListCmd) authConnectionsListCmd.Flags().String("domain", "", "Filter by domain") authConnectionsListCmd.Flags().String("profile-name", "", "Filter by profile name") + authConnectionsListCmd.Flags().String("query", "", "Search auth connections by ID, domain, or profile name") authConnectionsListCmd.Flags().Int("limit", 0, "Maximum number of results to return") authConnectionsListCmd.Flags().Int("offset", 0, "Number of results to skip") @@ -1140,10 +1192,13 @@ func init() { // Login flags addJSONOutputFlag(authConnectionsLoginCmd) - authConnectionsLoginCmd.Flags().String("proxy-id", "", "Proxy ID to use for this login") - authConnectionsLoginCmd.Flags().String("proxy-name", "", "Proxy name to use for this login") + 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().Bool("stealth", true, "Override stealth mode for this login's browser session; use --stealth=false to disable") authConnectionsLoginCmd.Flags().Bool("record-session", false, "Override whether this login's browser session is recorded; use --record-session=false to disable") authConnectionsLoginCmd.Flags().String("telemetry", "", "Telemetry override for this login only, merged onto the connection's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") + authConnectionsLoginCmd.Flags().String("telemetry-export-otlp", "", "Export override for this login only: an OTLP destination ID or name; --telemetry-export-otlp=off disables export for this login. Naming a destination requires passing --telemetry in the same command, since export and capture are validated together") // Submit flags addJSONOutputFlag(authConnectionsSubmitCmd) @@ -1191,11 +1246,13 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { credentialAuto, _ := cmd.Flags().GetBool("credential-auto") proxyID, _ := cmd.Flags().GetString("proxy-id") proxyName, _ := cmd.Flags().GetString("proxy-name") + proxyMode, _ := cmd.Flags().GetString("proxy-mode") noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") noHealthChecks, _ := cmd.Flags().GetBool("no-health-checks") noAutoReauth, _ := cmd.Flags().GetBool("no-auto-reauth") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} @@ -1210,12 +1267,15 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { CredentialAuto: credentialAuto, ProxyID: proxyID, ProxyName: proxyName, + ProxyMode: proxyMode, + Stealth: readBoolFlag(cmd.Flags(), "stealth"), NoSaveCredentials: noSaveCredentials, HealthCheckInterval: healthCheckInterval, NoHealthChecks: noHealthChecks, NoAutoReauth: noAutoReauth, RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, + TelemetryExport: telemetryExport, Output: output, }) } @@ -1243,10 +1303,12 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { credentialAuto, _ := cmd.Flags().GetBool("credential-auto") proxyID, _ := cmd.Flags().GetString("proxy-id") proxyName, _ := cmd.Flags().GetString("proxy-name") + proxyMode, _ := cmd.Flags().GetString("proxy-mode") saveCredentials, _ := cmd.Flags().GetBool("save-credentials") noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") saveCredentialsFlag := BoolFlag{} @@ -1290,6 +1352,8 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { ProxyIDSet: cmd.Flags().Changed("proxy-id"), ProxyName: proxyName, ProxyNameSet: cmd.Flags().Changed("proxy-name"), + ProxyMode: proxyMode, + Stealth: readBoolFlag(cmd.Flags(), "stealth"), SaveCredentials: saveCredentialsFlag, HealthCheckInterval: healthCheckInterval, HealthCheckIntervalSet: cmd.Flags().Changed("health-check-interval"), @@ -1297,6 +1361,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { AutoReauth: togglePair("auto-reauth", "no-auto-reauth"), RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, + TelemetryExport: telemetryExport, Output: output, }) } @@ -1306,6 +1371,7 @@ func runAuthConnectionsList(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") domain, _ := cmd.Flags().GetString("domain") profileName, _ := cmd.Flags().GetString("profile-name") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") @@ -1314,6 +1380,7 @@ func runAuthConnectionsList(cmd *cobra.Command, args []string) error { return c.List(cmd.Context(), AuthConnectionListInput{ Domain: domain, ProfileName: profileName, + Query: query, Limit: limit, Offset: offset, Output: output, @@ -1337,17 +1404,22 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") proxyID, _ := cmd.Flags().GetString("proxy-id") proxyName, _ := cmd.Flags().GetString("proxy-name") + proxyMode, _ := cmd.Flags().GetString("proxy-mode") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} return c.Login(cmd.Context(), AuthConnectionLoginInput{ - ID: args[0], - ProxyID: proxyID, - ProxyName: proxyName, - RecordSession: readBoolFlag(cmd.Flags(), "record-session"), - Telemetry: telemetry, - Output: output, + ID: args[0], + ProxyID: proxyID, + ProxyName: proxyName, + ProxyMode: proxyMode, + Stealth: readBoolFlag(cmd.Flags(), "stealth"), + RecordSession: readBoolFlag(cmd.Flags(), "record-session"), + Telemetry: telemetry, + TelemetryExport: telemetryExport, + Output: output, }) } diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index ada9cb1..c834257 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -408,8 +408,8 @@ func TestAuthConnectionsUpdate_MapsParams(t *testing.T) { assert.Equal(t, "Vault/Item", captured.ManagedAuthUpdateRequest.Credential.Path.Value) require.True(t, captured.ManagedAuthUpdateRequest.Credential.Auto.Valid()) assert.True(t, captured.ManagedAuthUpdateRequest.Credential.Auto.Value) - require.True(t, captured.ManagedAuthUpdateRequest.Proxy.ID.Valid()) - assert.Equal(t, "proxy-123", captured.ManagedAuthUpdateRequest.Proxy.ID.Value) + require.True(t, captured.ManagedAuthUpdateRequest.Browser.Proxy.ID.Valid()) + assert.Equal(t, "proxy-123", captured.ManagedAuthUpdateRequest.Browser.Proxy.ID.Value) require.True(t, captured.ManagedAuthUpdateRequest.SaveCredentials.Valid()) assert.False(t, captured.ManagedAuthUpdateRequest.SaveCredentials.Value) require.True(t, captured.ManagedAuthUpdateRequest.RecordSession.Valid()) @@ -643,7 +643,7 @@ func TestCreate_TelemetryCategoriesOptIn(t *testing.T) { }) require.NoError(t, err) - tel := captured.ManagedAuthCreateRequest.BrowserTelemetry + tel := captured.ManagedAuthCreateRequest.Browser.Telemetry assert.False(t, tel.Enabled.Valid()) assert.True(t, tel.Browser.Console.Enabled.Value) assert.True(t, tel.Browser.Network.Enabled.Value) @@ -664,11 +664,62 @@ func TestCreate_TelemetryOff(t *testing.T) { Domain: "example.com", ProfileName: "prof", Telemetry: "off", })) - tel := captured.ManagedAuthCreateRequest.BrowserTelemetry + tel := captured.ManagedAuthCreateRequest.Browser.Telemetry require.True(t, tel.Enabled.Valid()) assert.False(t, tel.Enabled.Value) } +// Proxy and stealth are carried by the connection's browser config, which the +// API applies to its login, reauth, and health-check sessions. +func TestCreate_BrowserConfig(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionNewParams + fake := &FakeAuthConnectionService{ + NewFunc: func(ctx context.Context, body kernel.AuthConnectionNewParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + captured = body + return &kernel.ManagedAuth{ID: "auth_1"}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Create(context.Background(), AuthConnectionCreateInput{ + Domain: "example.com", + ProfileName: "prof", + ProxyName: "my-proxy", + Stealth: BoolFlag{Set: true, Value: false}, + })) + + browser := captured.ManagedAuthCreateRequest.Browser + assert.Equal(t, "my-proxy", browser.Proxy.Name.Value) + require.True(t, browser.Stealth.Valid()) + assert.False(t, browser.Stealth.Value) +} + +func TestLogin_BrowserProxyMode(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", ProxyMode: "direct"})) + assert.Equal(t, kernel.BrowserProxyModeDirect, captured.Browser.Proxy.Mode) +} + +// A proxy is selected by ID or name, so an empty value is no longer a way to +// clear it: that is what --proxy-mode=default is for. +func TestUpdate_EmptyProxySelection_Errors(t *testing.T) { + capturePtermOutput(t) + c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} + + err := c.Update(context.Background(), AuthConnectionUpdateInput{ID: "auth_1", ProxyIDSet: true}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--proxy-mode=default") +} + func TestCreate_TelemetryUnknownCategoryErrors(t *testing.T) { capturePtermOutput(t) c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} @@ -692,7 +743,7 @@ func TestUpdate_TelemetryCountsAsChange(t *testing.T) { // --telemetry alone must satisfy the "at least one field" guard. require.NoError(t, c.Update(context.Background(), AuthConnectionUpdateInput{ID: "auth_1", Telemetry: "all"})) - tel := captured.ManagedAuthUpdateRequest.BrowserTelemetry + tel := captured.ManagedAuthUpdateRequest.Browser.Telemetry require.True(t, tel.Enabled.Valid()) assert.True(t, tel.Enabled.Value) } @@ -708,7 +759,7 @@ func TestLogin_TelemetryOverride(t *testing.T) { } c := AuthConnectionCmd{svc: fake} require.NoError(t, c.Login(context.Background(), AuthConnectionLoginInput{ID: "auth_1", Telemetry: "screenshot"})) - assert.True(t, captured.BrowserTelemetry.Browser.Screenshot.Enabled.Value) + assert.True(t, captured.Browser.Telemetry.Browser.Screenshot.Enabled.Value) } func TestSubmit_CanonicalChoiceID(t *testing.T) { @@ -859,7 +910,7 @@ func TestAuthConnectionsGet_TelemetryEnabledWithoutCategories(t *testing.T) { fake := &FakeAuthConnectionService{ GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { var auth kernel.ManagedAuth - require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser_telemetry":{"enabled":true}}`), &auth)) + require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser":{"telemetry":{"enabled":true}}}`), &auth)) return &auth, nil }, } @@ -869,6 +920,60 @@ func TestAuthConnectionsGet_TelemetryEnabledWithoutCategories(t *testing.T) { assert.Contains(t, outBuf.String(), "enabled (default categories)") } +func TestAuthConnectionsGet_TelemetryShowsExportDestination(t *testing.T) { + setupStdoutCapture(t) + // A connection bound to an OTLP destination reports where its sessions export, + // so the destination is visible without reading the raw JSON. Unlike a browser + // session's resolved config (where destination is the ID string), a connection + // echoes the stored request verbatim, so destination is an {id, name} object. + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + var auth kernel.ManagedAuth + require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser":{"telemetry":{"enabled":true,"browser":{"console":{"enabled":true}},"export":{"otlp":{"enabled":true,"destination":{"id":"dest-abc"}}}}}}`), &auth)) + return &auth, nil + }, + } + c := AuthConnectionCmd{svc: fake} + + require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"})) + out := outBuf.String() + assert.Contains(t, out, "console") + assert.Contains(t, out, "exporting to dest-abc") +} + +func TestAuthConnectionsGet_TelemetryShowsExportDestinationByName(t *testing.T) { + setupStdoutCapture(t) + // A connection created with --telemetry-export-otlp stores the name, so + // that is what comes back; fall back to it when no ID is present. + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + var auth kernel.ManagedAuth + require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser":{"telemetry":{"enabled":true,"export":{"otlp":{"enabled":true,"destination":{"name":"my-collector"}}}}}}`), &auth)) + return &auth, nil + }, + } + c := AuthConnectionCmd{svc: fake} + + require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"})) + assert.Contains(t, outBuf.String(), "exporting to my-collector") +} + +func TestAuthConnectionsGet_TelemetryOmitsExportWhenDisabled(t *testing.T) { + setupStdoutCapture(t) + // An export block that is present but disabled must not read as "exporting". + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + var auth kernel.ManagedAuth + require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser":{"telemetry":{"enabled":true,"export":{"otlp":{"enabled":false}}}}}`), &auth)) + return &auth, nil + }, + } + c := AuthConnectionCmd{svc: fake} + + require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"})) + assert.NotContains(t, outBuf.String(), "exporting to") +} + func TestAuthConnectionsGet_TelemetryRowOmittedWhenOff(t *testing.T) { setupStdoutCapture(t) // Telemetry that is off is not reported at all, rather than shown as a @@ -876,7 +981,7 @@ func TestAuthConnectionsGet_TelemetryRowOmittedWhenOff(t *testing.T) { fake := &FakeAuthConnectionService{ GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { var auth kernel.ManagedAuth - require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser_telemetry":{"enabled":false}}`), &auth)) + require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser":{"telemetry":{"enabled":false}}}`), &auth)) return &auth, nil }, } diff --git a/cmd/auth_context.go b/cmd/auth_context.go new file mode 100644 index 0000000..9019c92 --- /dev/null +++ b/cmd/auth_context.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/kernel/cli/pkg/util" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// AuthContextService defines the subset of the Kernel SDK auth context client that we use. +type AuthContextService interface { + Get(ctx context.Context, opts ...option.RequestOption) (res *kernel.AuthContext, err error) +} + +// AuthContextCmd handles auth context operations independent of cobra. +type AuthContextCmd struct { + svc AuthContextService +} + +type AuthContextGetInput struct { + Output string +} + +func (c AuthContextCmd) Get(ctx context.Context, in AuthContextGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + authCtx, err := c.svc.Get(ctx) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + if authCtx == nil { + fmt.Println("null") + return nil + } + return util.PrintPrettyJSON(authCtx) + } + + renderAuthContext(authCtx) + return nil +} + +func renderAuthContext(authCtx *kernel.AuthContext) { + if authCtx == nil { + pterm.Info.Println("No authentication context found") + return + } + + rows := pterm.TableData{ + {"Field", "Value"}, + {"Principal ID", authCtx.Principal.ID}, + {"Principal Type", authCtx.Principal.Type}, + {"Organization ID", authCtx.Organization.ID}, + {"Auth Method", authCtx.Authentication.Method}, + {"Auth Source", authCtx.Authentication.Source}, + // credential_id is null for session credentials. + {"Credential ID", formatAuthContextOptional(authCtx.Authentication.CredentialID)}, + // A null project_id means the scope is organization-wide. + {"Credential Scope", formatAuthContextScope(authCtx.Authorization.CredentialScope.ProjectID)}, + {"Effective Scope", formatAuthContextScope(authCtx.Authorization.EffectiveScope.ProjectID)}, + } + PrintTableNoPad(rows, true) +} + +func formatAuthContextOptional(value string) string { + if value == "" { + return "-" + } + return value +} + +func formatAuthContextScope(projectID string) string { + if projectID == "" { + return "organization-wide" + } + return projectID +} + +// --- Cobra wiring --- + +var authContextCmd = &cobra.Command{ + Use: "context", + Short: "Show the authentication context for the current credentials", + Long: `Show the identity and authorization context resolved for requests made with the current credentials. + +Displays the authenticated principal, organization, credential scope, and effective request scope. +Credential secrets are never returned.`, + Args: cobra.NoArgs, + RunE: runAuthContext, +} + +func runAuthContext(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + svc := client.Auth.Context + c := AuthContextCmd{svc: &svc} + return c.Get(cmd.Context(), AuthContextGetInput{Output: output}) +} + +func init() { + addJSONOutputFlag(authContextCmd) + authCmd.AddCommand(authContextCmd) +} diff --git a/cmd/auth_context_test.go b/cmd/auth_context_test.go new file mode 100644 index 0000000..aa806d3 --- /dev/null +++ b/cmd/auth_context_test.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "context" + "errors" + "testing" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/stretchr/testify/assert" +) + +type FakeAuthContextService struct { + GetFunc func(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) +} + +func (f *FakeAuthContextService) Get(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, opts...) + } + return &kernel.AuthContext{}, nil +} + +// sampleAuthContext unmarshals from raw JSON so RawJSON() is populated, which +// the --output json path relies on. +func sampleAuthContext(t *testing.T, projectID string) *kernel.AuthContext { + t.Helper() + scope := "null" + if projectID != "" { + scope = `"` + projectID + `"` + } + raw := `{ + "principal": {"id": "key_123", "type": "api_key"}, + "organization": {"id": "org_456"}, + "authentication": {"credential_id": "key_123", "method": "api_key", "source": "api_key"}, + "authorization": { + "credential_scope": {"project_id": ` + scope + `}, + "effective_scope": {"project_id": ` + scope + `} + } + }` + authCtx := &kernel.AuthContext{} + if err := authCtx.UnmarshalJSON([]byte(raw)); err != nil { + t.Fatalf("UnmarshalJSON: %v", err) + } + return authCtx +} + +func TestAuthContextGet_RendersContext(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuthContextService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) { + return sampleAuthContext(t, "proj_789"), nil + }, + } + c := AuthContextCmd{svc: fake} + assert.NoError(t, c.Get(context.Background(), AuthContextGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Principal ID") + assert.Contains(t, out, "key_123") + assert.Contains(t, out, "org_456") + assert.Contains(t, out, "proj_789") +} + +func TestAuthContextGet_NullFieldsRenderPlaceholders(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuthContextService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) { + // An org-wide credential has a null project_id in both scopes. + return sampleAuthContext(t, ""), nil + }, + } + c := AuthContextCmd{svc: fake} + assert.NoError(t, c.Get(context.Background(), AuthContextGetInput{})) + assert.Contains(t, buf.String(), "organization-wide") +} + +func TestAuthContextGet_JSONOutput(t *testing.T) { + fake := &FakeAuthContextService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) { + return sampleAuthContext(t, "proj_789"), nil + }, + } + c := AuthContextCmd{svc: fake} + out := captureStdout(t, func() { + assert.NoError(t, c.Get(context.Background(), AuthContextGetInput{Output: "json"})) + }) + assert.Contains(t, out, "org_456") +} + +func TestAuthContextGet_SurfacesAPIError(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuthContextService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) { + return nil, errors.New("boom") + }, + } + c := AuthContextCmd{svc: fake} + assert.Error(t, c.Get(context.Background(), AuthContextGetInput{})) +} diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index c80bcdc..1d6dd5d 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -31,6 +31,8 @@ type BrowserPoolsCmd struct { } type BrowserPoolsListInput struct { + Name string + Query string Limit int Offset int Output string @@ -42,6 +44,12 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err } params := kernel.BrowserPoolListParams{} + if in.Name != "" { + params.Name = kernel.String(in.Name) + } + if in.Query != "" { + params.Query = kernel.String(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Int(int64(in.Limit)) } @@ -486,6 +494,7 @@ type BrowserPoolsAcquireInput struct { IDOrName string TimeoutSeconds int64 Name string + StartURL string Tags map[string]string Telemetry string Output string @@ -493,9 +502,10 @@ type BrowserPoolsAcquireInput struct { // 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/telemetry forwarding cannot silently diverge -// between them. The telemetry override merges onto the pool's config for this lease. -func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64, telemetry string) (kernel.BrowserPoolAcquireParams, error) { +// path so the per-lease name/tags/start-url/telemetry forwarding cannot silently +// diverge between them. The telemetry override merges onto the pool's config for +// this lease. +func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64, telemetry, startURL string) (kernel.BrowserPoolAcquireParams, error) { params := kernel.BrowserPoolAcquireParams{} if timeoutSeconds > 0 { params.AcquireTimeoutSeconds = kernel.Int(timeoutSeconds) @@ -503,6 +513,9 @@ func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int6 if name != "" { params.Name = kernel.Opt(name) } + if startURL != "" { + params.StartURL = kernel.Opt(startURL) + } if len(tags) > 0 { params.Tags = kernel.Tags(tags) } @@ -521,7 +534,7 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu return err } - params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry) + params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.StartURL) if err != nil { return err } @@ -665,6 +678,8 @@ var browserPoolsFlushCmd = &cobra.Command{ func init() { addJSONOutputFlag(browserPoolsListCmd) + browserPoolsListCmd.Flags().String("name", "", "Exact-match filter on browser pool name") + 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)") @@ -721,6 +736,7 @@ func init() { browserPoolsAcquireCmd.Flags().Int64("timeout", 0, "Acquire timeout in seconds") browserPoolsAcquireCmd.Flags().String("name", "", "Optional name for the acquired session (applies to this lease; cleared on release)") + 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") addJSONOutputFlag(browserPoolsAcquireCmd) @@ -742,10 +758,12 @@ func init() { func runBrowserPoolsList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) out, _ := cmd.Flags().GetString("output") + name, _ := cmd.Flags().GetString("name") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") c := BrowserPoolsCmd{client: &client.BrowserPools} - return c.List(cmd.Context(), BrowserPoolsListInput{Limit: limit, Offset: offset, Output: out}) + return c.List(cmd.Context(), BrowserPoolsListInput{Name: name, Query: query, Limit: limit, Offset: offset, Output: out}) } func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { @@ -879,6 +897,7 @@ func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) timeout, _ := cmd.Flags().GetInt64("timeout") name, _ := cmd.Flags().GetString("name") + startURL, _ := cmd.Flags().GetString("start-url") tags, _ := tagsFromFlag(cmd, "tag") telemetry, _ := cmd.Flags().GetString("telemetry") output, _ := cmd.Flags().GetString("output") @@ -887,6 +906,7 @@ func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { IDOrName: args[0], TimeoutSeconds: timeout, Name: name, + StartURL: startURL, Tags: tags, Telemetry: telemetry, Output: output, diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index 921220e..672a21e 100644 --- a/cmd/browser_pools_test.go +++ b/cmd/browser_pools_test.go @@ -124,28 +124,32 @@ func TestBrowserPoolsList_ForwardsLimitOffset(t *testing.T) { assert.Equal(t, int64(8), captured.Offset.Value) } -// TestBuildAcquireParams covers the shared name/tags/timeout/telemetry forwarding -// used by both `browser-pools acquire` and the `browsers create --pool-id` lease path. +// TestBuildAcquireParams covers the shared name/tags/timeout/telemetry/start-url +// 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") + p, err := buildAcquireParams("lease", map[string]string{"env": "prod"}, 30, "console,network", "https://example.com") assert.NoError(t, err) assert.True(t, p.Name.Valid()) assert.Equal(t, "lease", p.Name.Value) assert.Equal(t, "prod", p.Tags["env"]) assert.True(t, p.AcquireTimeoutSeconds.Valid()) assert.Equal(t, int64(30), p.AcquireTimeoutSeconds.Value) + assert.True(t, p.StartURL.Valid()) + 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) // Unset inputs produce an empty params struct (nothing forwarded). - empty, err := buildAcquireParams("", nil, 0, "") + empty, err := buildAcquireParams("", nil, 0, "", "") assert.NoError(t, err) assert.False(t, empty.Name.Valid()) assert.Len(t, empty.Tags, 0) assert.False(t, empty.AcquireTimeoutSeconds.Valid()) + assert.False(t, empty.StartURL.Valid()) // An invalid category surfaces an error rather than a partial param. - _, err = buildAcquireParams("", nil, 0, "bogus") + _, err = buildAcquireParams("", nil, 0, "bogus", "") assert.Error(t, err) } diff --git a/cmd/browser_proxy.go b/cmd/browser_proxy.go new file mode 100644 index 0000000..627646f --- /dev/null +++ b/cmd/browser_proxy.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "fmt" + "strings" + + kernel "github.com/kernel/kernel-go-sdk" +) + +// proxyModes are the values accepted by --proxy-mode. direct forces egress +// without a proxy regardless of stealth; default restores the browser's +// stealth-derived default (Kernel's stealth proxy when stealth is on, direct +// egress otherwise). +var proxyModes = []string{ + string(kernel.BrowserProxyModeDirect), + string(kernel.BrowserProxyModeDefault), +} + +// proxySelection holds the mutually exclusive proxy flags shared by the browser +// and auth-connection commands. The API accepts exactly one of id, name, or +// mode, so the CLI validates that before building the param. +type proxySelection struct { + ID string + Name string + Mode string +} + +// set reports whether any proxy flag was provided. +func (p proxySelection) set() bool { + return p.ID != "" || p.Name != "" || p.Mode != "" +} + +// buildProxyConfigParam converts the proxy flags to the API's proxy config. +// Callers should only use the result when set() is true: an empty config is +// rejected by the API rather than treated as "leave unchanged". +func buildProxyConfigParam(sel proxySelection) (kernel.BrowserProxyConfigParam, error) { + p := kernel.BrowserProxyConfigParam{} + + provided := make([]string, 0, 3) + if sel.ID != "" { + provided = append(provided, "--proxy-id") + } + if sel.Name != "" { + provided = append(provided, "--proxy-name") + } + if sel.Mode != "" { + provided = append(provided, "--proxy-mode") + } + if len(provided) > 1 { + return p, fmt.Errorf("must specify at most one of %s", strings.Join(provided, ", ")) + } + + switch { + case sel.ID != "": + p.ID = kernel.Opt(sel.ID) + case sel.Name != "": + p.Name = kernel.Opt(sel.Name) + case sel.Mode != "": + mode := strings.TrimSpace(strings.ToLower(sel.Mode)) + switch kernel.BrowserProxyMode(mode) { + case kernel.BrowserProxyModeDirect, kernel.BrowserProxyModeDefault: + p.Mode = kernel.BrowserProxyMode(mode) + default: + return p, fmt.Errorf("unknown proxy mode %q: must be one of %s", sel.Mode, strings.Join(proxyModes, ", ")) + } + } + return p, nil +} + +// formatBrowserProxy renders a session's resolved proxy configuration for a +// details table, or "" when the API reported none. +func formatBrowserProxy(p kernel.BrowserProxy) string { + return formatProxyRef(p.ID, p.Name, p.Mode) +} + +// formatBrowserProxyConfig renders a stored proxy configuration (such as an auth +// connection's browser default) for a details table, or "" when there is none. +func formatBrowserProxyConfig(p kernel.BrowserProxyConfig) string { + return formatProxyRef(p.ID, p.Name, p.Mode) +} + +// formatProxyRef shows a selected proxy by name (falling back to its ID), or the +// egress mode when no proxy is selected. +func formatProxyRef(id, name string, mode kernel.BrowserProxyMode) string { + switch { + case name != "" && id != "": + return fmt.Sprintf("%s (%s)", name, id) + case name != "": + return name + case id != "": + return id + case mode != "": + return string(mode) + default: + return "" + } +} diff --git a/cmd/browsers.go b/cmd/browsers.go index 1d27d91..e6a7f3d 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -286,10 +286,13 @@ type BrowsersCreateInput struct { ProfileName string ProfileSaveChanges BoolFlag ProxyID string + ProxyName string + ProxyMode string StartURL string Extensions []string Viewport string Telemetry string + TelemetryExport string ChromePolicy string ChromePolicyFile string Name string @@ -315,6 +318,8 @@ type BrowsersGetInput struct { type BrowsersUpdateInput struct { Identifier string ProxyID string + ProxyName string + ProxyMode string ClearProxy bool DisableDefaultProxy BoolFlag ProfileID string @@ -499,9 +504,15 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } - // Add proxy if specified - if in.ProxyID != "" { - params.ProxyID = kernel.Opt(in.ProxyID) + // Add proxy if specified. Omitting it lets the browser default apply: + // Kernel's default stealth proxy for stealth sessions, direct egress otherwise. + sel := proxySelection{ID: in.ProxyID, Name: in.ProxyName, Mode: in.ProxyMode} + if sel.set() { + proxy, err := buildProxyConfigParam(sel) + if err != nil { + return err + } + params.Proxy = proxy } if in.StartURL != "" { params.StartURL = kernel.Opt(in.StartURL) @@ -540,8 +551,8 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } - if in.Telemetry != "" { - t, err := buildNewTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildNewTelemetryParam(in.Telemetry, in.TelemetryExport) if err != nil { return err } @@ -575,20 +586,20 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { return util.PrintPrettyJSON(browser) } - printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.StartURL, browser.Name, browser.Tags) - if in.Telemetry != "" { + printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags) + if in.Telemetry != "" || in.TelemetryExport != "" { printTelemetrySummary(browser.Telemetry) } return nil } -func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, startURL, name string, tags kernel.Tags) { - tableData := buildBrowserTableData(sessionID, cdpURL, liveViewURL, profile, startURL, name, tags) +func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, profileSaveChanges bool, startURL, name string, tags kernel.Tags) { + tableData := buildBrowserTableData(sessionID, cdpURL, liveViewURL, profile, profileSaveChanges, startURL, name, tags) PrintTableNoPad(tableData, true) } // buildBrowserTableData creates a base table with common browser session fields. -func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, startURL, name string, tags kernel.Tags) pterm.TableData { +func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, profileSaveChanges bool, startURL, name string, tags kernel.Tags) pterm.TableData { tableData := pterm.TableData{ {"Property", "Value"}, {"Session ID", sessionID}, @@ -606,6 +617,7 @@ func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel profVal = profile.ID } tableData = append(tableData, []string{"Profile", profVal}) + tableData = append(tableData, []string{"Profile Save Changes", fmt.Sprintf("%t", profileSaveChanges)}) } if startURL != "" { tableData = append(tableData, []string{"Start URL", startURL}) @@ -683,6 +695,7 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, + browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags, @@ -702,8 +715,8 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { } tableData = append(tableData, []string{"Viewport", viewportStr}) } - if browser.ProxyID != "" { - tableData = append(tableData, []string{"Proxy ID", browser.ProxyID}) + if proxy := formatBrowserProxy(browser.Proxy); proxy != "" { + tableData = append(tableData, []string{"Proxy", proxy}) } if !browser.DeletedAt.IsZero() { tableData = append(tableData, []string{"Deleted At", util.FormatLocal(browser.DeletedAt)}) @@ -713,6 +726,39 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { return nil } +// browserUpdateProxySelection folds every proxy flag `browsers update` accepts +// into the single proxy configuration the API takes. --clear-proxy drops a +// selected proxy back to the browser default, and --disable-default-proxy +// switches between direct and default egress, so both are mode changes. +func browserUpdateProxySelection(in BrowsersUpdateInput) (proxySelection, error) { + sel := proxySelection{ID: in.ProxyID, Name: in.ProxyName, Mode: in.ProxyMode} + + if !in.ClearProxy && !in.DisableDefaultProxy.Set { + return sel, nil + } + if in.ClearProxy && in.DisableDefaultProxy.Set { + return sel, fmt.Errorf("cannot specify both --clear-proxy and --disable-default-proxy") + } + + // The legacy flags mean the same thing as --proxy-mode, so a selected proxy or + // an explicit mode alongside them is contradictory. + if sel.set() { + legacy := "--disable-default-proxy" + if in.ClearProxy { + legacy = "--clear-proxy" + } + return sel, fmt.Errorf("cannot combine %s with --proxy-id, --proxy-name, or --proxy-mode", legacy) + } + + // --clear-proxy and --disable-default-proxy=false both ask for the browser + // default; only --disable-default-proxy=true forces direct egress. + sel.Mode = string(kernel.BrowserProxyModeDefault) + if in.DisableDefaultProxy.Value { + sel.Mode = string(kernel.BrowserProxyModeDirect) + } + return sel, nil +} + func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -723,9 +769,12 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { return fmt.Errorf("must specify at most one of --profile-id or --profile-name") } - // Cannot specify both --proxy-id and --clear-proxy - if in.ProxyID != "" && in.ClearProxy { - return fmt.Errorf("cannot specify both --proxy-id and --clear-proxy") + // The API takes a single proxy configuration, so the flags that select one are + // mutually exclusive. --clear-proxy and --disable-default-proxy are older + // spellings of a mode change and are folded into it below. + proxySel, err := browserUpdateProxySelection(in) + if err != nil { + return err } // Cannot specify both --name and --clear-name @@ -753,7 +802,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { return fmt.Errorf("--name requires a non-empty value; use --clear-name to clear the name") } - hasProxyChange := in.ProxyID != "" || in.ClearProxy || in.DisableDefaultProxy.Set + hasProxyChange := proxySel.set() hasProfileChange := in.ProfileID != "" || in.ProfileName != "" hasViewportChange := in.Viewport != "" // By this point a set name is guaranteed non-empty (the guard above rejects --name ""). @@ -772,7 +821,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { // Validate that at least one update option is provided if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && !hasNameChange && !hasTagsChange { - return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, --viewport, --telemetry, --name, --clear-name, --tag, or --clear-tags") + return fmt.Errorf("must specify at least one of: --proxy-id, --proxy-name, --proxy-mode, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, --viewport, --telemetry, --name, --clear-name, --tag, or --clear-tags") } params := kernel.BrowserUpdateParams{} @@ -793,13 +842,12 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } // Handle proxy changes - if in.ClearProxy { - params.ProxyID = kernel.Opt("") - } else if in.ProxyID != "" { - params.ProxyID = kernel.Opt(in.ProxyID) - } - if in.DisableDefaultProxy.Set { - params.DisableDefaultProxy = kernel.Opt(in.DisableDefaultProxy.Value) + if hasProxyChange { + proxy, err := buildProxyConfigParam(proxySel) + if err != nil { + return err + } + params.Proxy = proxy } // Handle profile changes @@ -864,6 +912,9 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { if hasTagsChange { pterm.Info.Printf("Tags: %s\n", util.OrDash(formatTags(browser.Tags))) } + if hasProfileChange { + pterm.Info.Printf("Profile save changes: %t\n", browser.ProfileSaveChanges) + } if in.Telemetry != "" { printTelemetrySummary(browser.Telemetry) } @@ -2456,8 +2507,8 @@ var browsersUpdateCmd = &cobra.Command{ Long: `Update a running browser session. Supported operations: - - Change or remove proxy (--proxy-id or --clear-proxy) - - Disable the default stealth proxy (--disable-default-proxy) + - Select a proxy by ID or name (--proxy-id or --proxy-name) + - Change proxy egress mode (--proxy-mode=direct or --proxy-mode=default) - Load a profile into a session that doesn't have one (--profile-id or --profile-name) - Change viewport dimensions (--viewport) - Force viewport resize during active live view or recording (--force with --viewport) @@ -2498,9 +2549,11 @@ func init() { // update flags addJSONOutputFlag(browsersUpdateCmd) - browsersUpdateCmd.Flags().String("proxy-id", "", "ID of the proxy to use for the browser session") - browsersUpdateCmd.Flags().Bool("clear-proxy", false, "Remove the proxy from the browser session") - browsersUpdateCmd.Flags().Bool("disable-default-proxy", false, "Disable the default stealth proxy so the browser connects directly; use --disable-default-proxy=false to re-enable it") + browsersUpdateCmd.Flags().String("proxy-id", "", "ID of the proxy to use for the browser session (mutually exclusive with --proxy-name and --proxy-mode)") + browsersUpdateCmd.Flags().String("proxy-name", "", "Name of the proxy to use for the browser session; must match exactly one active proxy in the project (mutually exclusive with --proxy-id and --proxy-mode)") + browsersUpdateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' to restore the browser default (Kernel's stealth proxy when stealth is on, direct egress otherwise)") + browsersUpdateCmd.Flags().Bool("clear-proxy", false, "Drop the selected proxy and restore the browser default (same as --proxy-mode=default)") + browsersUpdateCmd.Flags().Bool("disable-default-proxy", false, "Connect directly instead of through the default stealth proxy (same as --proxy-mode=direct); use --disable-default-proxy=false to restore the default") browsersUpdateCmd.Flags().String("profile-id", "", "Profile ID to load into the browser session (mutually exclusive with --profile-name)") browsersUpdateCmd.Flags().String("profile-name", "", "Profile name to load into the browser session (mutually exclusive with --profile-id)") browsersUpdateCmd.Flags().Bool("save-changes", false, "If set, save changes back to the profile when the session ends") @@ -2775,7 +2828,9 @@ func init() { browsersCreateCmd.Flags().String("profile-id", "", "Profile ID to load into the browser session (mutually exclusive with --profile-name)") browsersCreateCmd.Flags().String("profile-name", "", "Profile name to load into the browser session (mutually exclusive with --profile-id)") browsersCreateCmd.Flags().Bool("save-changes", false, "If set, save changes back to the profile when the session ends") - browsersCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for the browser session") + 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("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)") browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60") @@ -2783,6 +2838,7 @@ func init() { browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") browsersCreateCmd.Flags().String("telemetry", "", "Configure telemetry (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") + browsersCreateCmd.Flags().String("telemetry-export-otlp", "", "Export captured telemetry over OTLP to one of the org's configured destinations, by ID or name; --telemetry-export-otlp=off disables export. Implies --telemetry=all when --telemetry is not set, since export requires capture") browsersCreateCmd.Flags().String("name", "", "Optional unique name for the browser session (used to find it later; can be changed with 'browsers update --name')") browsersCreateCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the session (repeatable; up to 50 pairs)") browsersCreateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompts") @@ -2828,6 +2884,7 @@ followed automatically by Chromium.`, telemetryEvents := &cobra.Command{Use: "events ", Short: "Read historical telemetry events (paged)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryEvents} telemetryEvents.Flags().Int64("limit", 0, "Maximum number of events per page (1-100, default 20)") telemetryEvents.Flags().Int64("offset", 0, "Pagination cursor: pass the X-Next-Offset from a previous response") + 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)") @@ -2873,6 +2930,7 @@ func poolLeaseAllowedFlags() map[string]bool { "pool-name": true, "timeout": true, "name": true, + "start-url": true, "tag": true, "telemetry": true, "output": true, @@ -2897,6 +2955,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { profileName, _ := cmd.Flags().GetString("profile-name") saveChanges, _ := cmd.Flags().GetBool("save-changes") proxyID, _ := cmd.Flags().GetString("proxy-id") + proxyName, _ := cmd.Flags().GetString("proxy-name") + proxyMode, _ := cmd.Flags().GetString("proxy-mode") startURL, _ := cmd.Flags().GetString("start-url") extensions, _ := cmd.Flags().GetStringSlice("extension") viewport, _ := cmd.Flags().GetString("viewport") @@ -2904,6 +2964,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { poolID, _ := cmd.Flags().GetString("pool-id") poolName, _ := cmd.Flags().GetString("pool-name") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") name, _ := cmd.Flags().GetString("name") tags, _ := tagsFromFlag(cmd, "tag") chromePolicy, _ := cmd.Flags().GetString("chrome-policy") @@ -2918,7 +2979,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if poolID != "" || poolName != "" { // When using a pool, configuration comes from the pool itself, but - // name, tags, and telemetry apply per-lease to the acquired session. + // name, start URL, tags, and telemetry apply per-lease to the acquired + // session — they mirror the fields BrowserPoolAcquireParams accepts. allowedFlags := poolLeaseAllowedFlags() // Check if any browser configuration flags were set (which would conflict). @@ -2968,7 +3030,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("timeout") && timeout > 0 { acquireTimeout = int64(timeout) } - acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry) + acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry, startURL) if err != nil { return err } @@ -2988,7 +3050,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if output == "json" { return util.PrintPrettyJSON(resp) } - printBrowserSessionResult(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Profile, resp.StartURL, resp.Name, resp.Tags) + printBrowserSessionResult(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Profile, resp.ProfileSaveChanges, resp.StartURL, resp.Name, resp.Tags) return nil } @@ -3020,10 +3082,13 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { ProfileName: profileName, ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, ProxyID: proxyID, + ProxyName: proxyName, + ProxyMode: proxyMode, StartURL: startURL, Extensions: extensions, Viewport: viewport, Telemetry: telemetry, + TelemetryExport: telemetryExport, ChromePolicy: chromePolicy, ChromePolicyFile: chromePolicyFile, Name: name, @@ -3080,6 +3145,8 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) out, _ := cmd.Flags().GetString("output") proxyID, _ := cmd.Flags().GetString("proxy-id") + proxyName, _ := cmd.Flags().GetString("proxy-name") + proxyMode, _ := cmd.Flags().GetString("proxy-mode") clearProxy, _ := cmd.Flags().GetBool("clear-proxy") disableDefaultProxy, _ := cmd.Flags().GetBool("disable-default-proxy") profileID, _ := cmd.Flags().GetString("profile-id") @@ -3098,6 +3165,8 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { return b.Update(cmd.Context(), BrowsersUpdateInput{ Identifier: args[0], ProxyID: proxyID, + ProxyName: proxyName, + ProxyMode: proxyMode, ClearProxy: clearProxy, DisableDefaultProxy: BoolFlag{Set: cmd.Flags().Changed("disable-default-proxy"), Value: disableDefaultProxy}, ProfileID: profileID, diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 3eccd74..e19a9fb 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -44,6 +44,7 @@ type BrowsersTelemetryEventsInput struct { Identifier string Limit int64 Offset int64 + Order string Since string Until string Categories []string @@ -107,52 +108,177 @@ func resolveTelemetryFlag(s string) (param.Opt[bool], kernel.BrowserTelemetryCat } } -// buildNewTelemetryParam converts a --telemetry flag value to the create API param. -func buildNewTelemetryParam(s string) (kernel.BrowserNewParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) - return kernel.BrowserNewParamsTelemetry{Enabled: enabled, Browser: browser}, err +// telemetryExportOff is the --telemetry-export-otlp value that turns export off +// rather than naming a destination. +const telemetryExportOff = "off" + +// resolveTelemetryExportFlag interprets a --telemetry-export-otlp flag value: +// "off" disables OTLP export, and any other value selects a destination by ID or +// name. Setting a destination implies enabled=true server-side, so enabled is only +// sent for "off" (the API rejects enabled=false combined with a destination). +// It returns the resolved (enabled, id, name) triple so each endpoint can assemble +// its own param type. +func resolveTelemetryExportFlag(s string) (enabled param.Opt[bool], id, name string, err error) { + val := strings.TrimSpace(s) + if val == telemetryExportOff { + return kernel.Opt(false), "", "", nil + } + if val == "" { + return param.Opt[bool]{}, "", "", fmt.Errorf("empty --telemetry-export-otlp value: pass an OTLP destination ID or name, or %q to disable export", telemetryExportOff) + } + // Destinations have no list endpoint to disambiguate against, so fall back to + // the same CUID shape test the CLI uses for other ID-or-name references. + if cuidRegex.MatchString(val) { + return param.Opt[bool]{}, val, "", nil + } + return param.Opt[bool]{}, "", val, nil } -// buildUpdateTelemetryParam converts a --telemetry flag value to the update API param. -func buildUpdateTelemetryParam(s string) (kernel.BrowserUpdateParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) - return kernel.BrowserUpdateParamsTelemetry{Enabled: enabled, Browser: browser}, err +// telemetryFlagEnablesCapture reports whether a --telemetry value turns capture on. +// A destination requires capture to be enabled, so the create paths use this to +// decide whether to imply it. +func telemetryFlagEnablesCapture(s string) bool { + return s != "" && s != "off" +} + +// validateTelemetryExportCombo checks an export destination against the --telemetry +// value in the same command. The API validates the request payload on its own — it +// does not consult the stored config — so a destination is rejected unless that +// same request also enables capture, whether via enabled=true or category settings. +// +// canImply is true on the create paths, where there is no stored selection to +// clobber and capture can safely be turned on for the user. On update and login it +// is false: enabling capture there would replace the connection's current category +// selection, so the user has to say what to capture. +// +// Error messages never lead with a flag token — the error style title-cases the +// first word and treats - and = as word boundaries inside it. +func validateTelemetryExportCombo(telemetry, id, name string, canImply bool) error { + if id == "" && name == "" { + return nil + } + if telemetry == "off" { + return fmt.Errorf("cannot combine --telemetry=off with an export destination: export requires telemetry capture to be enabled") + } + if telemetry == "" && !canImply { + return fmt.Errorf("setting an export destination also requires --telemetry in the same command: use --telemetry=all for the default set, or --telemetry=console,network to select categories") + } + return nil } -// buildAuthConnectionCreateTelemetryParam converts a --telemetry flag value to the -// browser telemetry default stored on a new auth connection. -func buildAuthConnectionCreateTelemetryParam(s string) (kernel.ManagedAuthCreateRequestBrowserTelemetryParam, error) { +// buildNewTelemetryParam converts --telemetry and --telemetry-export-otlp flag +// values to the create API param. +func buildNewTelemetryParam(s, export string) (kernel.BrowserNewParamsTelemetry, error) { enabled, browser, err := resolveTelemetryFlag(s) - return kernel.ManagedAuthCreateRequestBrowserTelemetryParam{Enabled: enabled, Browser: browser}, err + p := kernel.BrowserNewParamsTelemetry{Enabled: enabled, Browser: browser} + if err != nil || export == "" { + return p, err + } + exEnabled, id, name, err := resolveTelemetryExportFlag(export) + if err != nil { + return p, err + } + if err := validateTelemetryExportCombo(s, id, name, true); err != nil { + return p, err + } + // A destination needs capture on. Nothing exists yet to clobber on create, so + // imply the default set rather than making the user repeat --telemetry=all. + if (id != "" || name != "") && !telemetryFlagEnablesCapture(s) { + p.Enabled = kernel.Opt(true) + } + p.Export = kernel.BrowserNewParamsTelemetryExport{ + Otlp: kernel.BrowserNewParamsTelemetryExportOtlp{ + Enabled: exEnabled, + Destination: kernel.BrowserNewParamsTelemetryExportOtlpDestination{ + ID: optIfSet(id), + Name: optIfSet(name), + }, + }, + } + return p, nil } -// buildAuthConnectionUpdateTelemetryParam converts a --telemetry flag value to the -// browser telemetry default for future sessions of an existing auth connection. -func buildAuthConnectionUpdateTelemetryParam(s string) (kernel.ManagedAuthUpdateRequestBrowserTelemetryParam, error) { +// optIfSet wraps a non-empty string as a set param, leaving it omitted otherwise. +func optIfSet(s string) param.Opt[string] { + if s == "" { + return param.Opt[string]{} + } + return kernel.Opt(s) +} + +// buildUpdateTelemetryParam converts a --telemetry flag value to the update API param. +func buildUpdateTelemetryParam(s string) (kernel.BrowserUpdateParamsTelemetry, error) { enabled, browser, err := resolveTelemetryFlag(s) - return kernel.ManagedAuthUpdateRequestBrowserTelemetryParam{Enabled: enabled, Browser: browser}, err + return kernel.BrowserUpdateParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// buildAuthConnectionLoginTelemetryParam converts a --telemetry flag value to the -// per-login browser telemetry override. -func buildAuthConnectionLoginTelemetryParam(s string) (kernel.AuthConnectionLoginParamsBrowserTelemetry, error) { +// buildManagedAuthTelemetryParam converts --telemetry and --telemetry-export-otlp +// flag values to the browser telemetry config carried by an auth connection's +// browser settings, shared by create, update, and login. +// +// canImply is true only on create, where there is no stored selection to clobber +// and capture can safely be turned on for the user so a destination works on its +// own. On update and login it is false: enabling capture there would replace the +// connection's current category selection rather than merge onto it. +func buildManagedAuthTelemetryParam(s, export string, canImply bool) (kernel.ManagedAuthBrowserConfigTelemetryParam, error) { enabled, browser, err := resolveTelemetryFlag(s) - return kernel.AuthConnectionLoginParamsBrowserTelemetry{Enabled: enabled, Browser: browser}, err + p := kernel.ManagedAuthBrowserConfigTelemetryParam{Enabled: enabled, Browser: browser} + if err != nil || export == "" { + return p, err + } + exEnabled, id, name, err := resolveTelemetryExportFlag(export) + if err != nil { + return p, err + } + if err := validateTelemetryExportCombo(s, id, name, canImply); err != nil { + return p, err + } + if canImply && (id != "" || name != "") && !telemetryFlagEnablesCapture(s) { + p.Enabled = kernel.Opt(true) + } + p.Export = kernel.ManagedAuthBrowserConfigTelemetryExportParam{ + Otlp: kernel.ManagedAuthBrowserConfigTelemetryExportOtlpParam{ + Enabled: exEnabled, + Destination: kernel.ManagedAuthBrowserConfigTelemetryExportOtlpDestinationParam{ + ID: optIfSet(id), + Name: optIfSet(name), + }, + }, + } + return p, nil } // formatManagedAuthTelemetry renders an auth connection's default browser telemetry // config for the details table. -func formatManagedAuthTelemetry(cfg kernel.ManagedAuthBrowserTelemetry) string { - if on := telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: cfg.Browser}); len(on) > 0 { - return strings.Join(on, ", ") +func formatManagedAuthTelemetry(cfg kernel.ManagedAuthBrowserConfigTelemetry) string { + base := func() string { + if on := telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: cfg.Browser}); len(on) > 0 { + return strings.Join(on, ", ") + } + // The API preserves the create-browser config verbatim rather than resolving + // it, so `{"enabled": true}` with no per-category settings means the default + // set. Reporting that as "disabled" would invert the connection's state. + if cfg.Enabled { + return "enabled (default categories)" + } + return "disabled" + }() + if dest := managedAuthExportDestination(cfg.Export); dest != "" { + return base + " (exporting to " + dest + ")" + } + return base +} + +// managedAuthExportDestination returns the OTLP destination an auth connection's +// sessions export to, or "" when export is off or unset. +func managedAuthExportDestination(ex kernel.ManagedAuthBrowserConfigTelemetryExport) string { + if !ex.Otlp.Enabled { + return "" } - // The API preserves the create-browser config verbatim rather than resolving - // it, so `{"enabled": true}` with no per-category settings means the default - // set. Reporting that as "disabled" would invert the connection's state. - if cfg.Enabled { - return "enabled (default categories)" + if id := ex.Otlp.Destination.ID; id != "" { + return id } - return "disabled" + return ex.Otlp.Destination.Name } // settableCategories are the categories accepted by --telemetry=. @@ -203,6 +329,15 @@ func printTelemetrySummary(cfg kernel.BrowserTelemetryConfig) { return } pterm.Info.Printf("Telemetry capturing: %s\n", strings.Join(on, ", ")) + if cfg.Export.Otlp.Enabled { + // The response reports the resolved destination by ID even when the request + // selected it by name. + if dest := cfg.Export.Otlp.Destination; dest != "" { + pterm.Info.Printf("Telemetry exporting over OTLP to: %s\n", dest) + } else { + pterm.Info.Println("Telemetry exporting over OTLP") + } + } } // shouldEmit applies client-side category/type filters to a telemetry event. @@ -307,6 +442,14 @@ func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEv return fmt.Errorf("invalid --categories value %q: must be one of %s", c, strings.Join(streamFilterCategories, ", ")) } } + if in.Order != "" && in.Order != "asc" && in.Order != "desc" { + return fmt.Errorf("invalid --order value %q: must be asc or desc", in.Order) + } + // The endpoint rejects desc combined with a window start, since desc pages + // backwards from --until (or the newest archived event) instead. + if in.Order == "desc" && in.Since != "" { + return fmt.Errorf("--order desc cannot be combined with --since; use --until to bound the window instead") + } // Resolve a name to a session ID. The events archive outlives the session, so // a 404 (ended or unknown session) is not fatal: fall back to the identifier @@ -328,6 +471,9 @@ func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEv if in.Limit > 0 { params.Limit = kernel.Opt(in.Limit) } + if in.Order != "" { + params.Order = kernel.Opt(in.Order) + } if in.Offset > 0 && !fullScan { params.Offset = kernel.Opt(in.Offset) } else if in.Since != "" { @@ -428,6 +574,7 @@ func runBrowsersTelemetryEvents(cmd *cobra.Command, args []string) error { out, _ := cmd.Flags().GetString("output") limit, _ := cmd.Flags().GetInt64("limit") offset, _ := cmd.Flags().GetInt64("offset") + order, _ := cmd.Flags().GetString("order") since, _ := cmd.Flags().GetString("since") until, _ := cmd.Flags().GetString("until") categories, _ := cmd.Flags().GetStringSlice("categories") @@ -438,6 +585,7 @@ func runBrowsersTelemetryEvents(cmd *cobra.Command, args []string) error { Identifier: args[0], Limit: limit, Offset: offset, + Order: order, Since: since, Until: until, Categories: categories, diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index 982d2d8..fe3b88e 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -336,21 +336,21 @@ func TestParseTelemetryCategories_WhitespaceTolerance(t *testing.T) { // listed categories enabled (Enabled unset). func TestBuildTelemetryParam_WireEncoding(t *testing.T) { t.Run("all", func(t *testing.T) { - p, err := buildNewTelemetryParam("all") + p, err := buildNewTelemetryParam("all", "") assert.NoError(t, err) assert.True(t, p.Enabled.Valid()) assert.True(t, p.Enabled.Value) assert.False(t, p.Browser.Network.Enabled.Valid()) }) t.Run("off", func(t *testing.T) { - p, err := buildNewTelemetryParam("off") + p, err := buildNewTelemetryParam("off", "") assert.NoError(t, err) assert.True(t, p.Enabled.Valid()) assert.False(t, p.Enabled.Value) assert.False(t, p.Browser.Network.Enabled.Valid()) }) t.Run("opt-in list sets only Browser", func(t *testing.T) { - p, err := buildNewTelemetryParam("network,control") + p, err := buildNewTelemetryParam("network,control", "") assert.NoError(t, err) assert.False(t, p.Enabled.Valid(), "Enabled must be unset for an opt-in selection") assert.True(t, p.Browser.Network.Enabled.Valid()) @@ -360,6 +360,149 @@ func TestBuildTelemetryParam_WireEncoding(t *testing.T) { }) } +// TestBuildTelemetryParam_ExportWireEncoding locks in the OTLP export wire shapes. +// A destination is sent as id or name (never both) and implies capture on create; +// "off" sends enabled=false with no destination, since the API rejects +// enabled=false combined with one. +func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { + t.Run("destination by CUID sets id", func(t *testing.T) { + p, err := buildNewTelemetryParam("", "abcdefghijklmnopqrstuvwx") + assert.NoError(t, err) + otlp := p.Export.Otlp + assert.True(t, otlp.Destination.ID.Valid()) + assert.Equal(t, "abcdefghijklmnopqrstuvwx", otlp.Destination.ID.Value) + assert.False(t, otlp.Destination.Name.Valid(), "name must be unset when id is sent") + assert.False(t, otlp.Enabled.Valid(), "a destination implies enabled server-side") + }) + t.Run("destination by name sets name", func(t *testing.T) { + p, err := buildNewTelemetryParam("", "my-collector") + assert.NoError(t, err) + otlp := p.Export.Otlp + assert.True(t, otlp.Destination.Name.Valid()) + assert.Equal(t, "my-collector", otlp.Destination.Name.Value) + assert.False(t, otlp.Destination.ID.Valid(), "id must be unset when name is sent") + }) + t.Run("destination implies capture on create", func(t *testing.T) { + p, err := buildNewTelemetryParam("", "my-collector") + assert.NoError(t, err) + assert.True(t, p.Enabled.Valid(), "export requires capture, so create implies it") + assert.True(t, p.Enabled.Value) + }) + t.Run("explicit --telemetry selection is preserved", func(t *testing.T) { + p, err := buildNewTelemetryParam("network,control", "my-collector") + assert.NoError(t, err) + assert.False(t, p.Enabled.Valid(), "an opt-in selection must not be overridden") + assert.True(t, p.Browser.Network.Enabled.Value) + assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) + }) + t.Run("off disables export without a destination", func(t *testing.T) { + p, err := buildNewTelemetryParam("all", "off") + assert.NoError(t, err) + otlp := p.Export.Otlp + assert.True(t, otlp.Enabled.Valid()) + assert.False(t, otlp.Enabled.Value) + assert.False(t, otlp.Destination.ID.Valid()) + assert.False(t, otlp.Destination.Name.Valid()) + }) + t.Run("off does not imply capture", func(t *testing.T) { + p, err := buildNewTelemetryParam("", "off") + assert.NoError(t, err) + assert.False(t, p.Enabled.Valid(), "disabling export must not turn capture on") + }) + // The API validates the request payload on its own rather than consulting the + // stored config, so a destination needs a capture-enabling --telemetry in the + // same request. Update and login refuse to supply one: doing so would replace + // the connection's current category selection. + t.Run("update requires an explicit --telemetry alongside a destination", func(t *testing.T) { + _, err := buildManagedAuthTelemetryParam("", "my-collector", false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "also requires --telemetry") + }) + t.Run("login requires an explicit --telemetry alongside a destination", func(t *testing.T) { + _, err := buildManagedAuthTelemetryParam("", "my-collector", false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "also requires --telemetry") + }) + t.Run("update never implies capture when --telemetry is given", func(t *testing.T) { + p, err := buildManagedAuthTelemetryParam("console", "my-collector", false) + assert.NoError(t, err) + assert.False(t, p.Enabled.Valid(), "an opt-in selection must not be widened to the default set") + assert.True(t, p.Browser.Console.Enabled.Value) + assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) + }) + t.Run("login never implies capture when --telemetry is given", func(t *testing.T) { + p, err := buildManagedAuthTelemetryParam("console", "my-collector", false) + assert.NoError(t, err) + assert.False(t, p.Enabled.Valid()) + assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) + }) + t.Run("update and login allow export=off without --telemetry", func(t *testing.T) { + u, err := buildManagedAuthTelemetryParam("", "off", false) + assert.NoError(t, err) + assert.False(t, u.Export.Otlp.Enabled.Value) + l, err := buildManagedAuthTelemetryParam("", "off", false) + assert.NoError(t, err) + assert.False(t, l.Export.Otlp.Enabled.Value) + }) + t.Run("auth connection create implies capture", func(t *testing.T) { + p, err := buildManagedAuthTelemetryParam("", "my-collector", true) + assert.NoError(t, err) + assert.True(t, p.Enabled.Valid()) + assert.True(t, p.Enabled.Value) + }) + t.Run("invalid category still errors with export set", func(t *testing.T) { + _, err := buildNewTelemetryParam("bogus", "my-collector") + assert.Error(t, err) + }) + t.Run("telemetry=off with a destination is rejected", func(t *testing.T) { + for _, tc := range []struct { + name string + fn func() error + }{ + {"create", func() error { _, e := buildNewTelemetryParam("off", "my-collector"); return e }}, + {"auth create", func() error { _, e := buildManagedAuthTelemetryParam("off", "my-collector", true); return e }}, + {"auth update/login", func() error { _, e := buildManagedAuthTelemetryParam("off", "my-collector", false); return e }}, + } { + err := tc.fn() + assert.Error(t, err, tc.name) + assert.Contains(t, err.Error(), "requires telemetry capture", tc.name) + } + }) + t.Run("telemetry=off with export=off is allowed", func(t *testing.T) { + p, err := buildNewTelemetryParam("off", "off") + assert.NoError(t, err) + assert.False(t, p.Enabled.Value) + assert.False(t, p.Export.Otlp.Enabled.Value) + }) + t.Run("empty export value errors", func(t *testing.T) { + _, err := buildNewTelemetryParam("all", " ") + assert.Error(t, err) + }) +} + +// TestPrintTelemetrySummary_Export covers the browser-session shape, where the +// resolved config reports destination as the ID string (not the {id, name} object +// an auth connection echoes back). +func TestPrintTelemetrySummary_Export(t *testing.T) { + parse := func(raw string) kernel.BrowserTelemetryConfig { + var cfg kernel.BrowserTelemetryConfig + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return cfg + } + t.Run("reports the destination", func(t *testing.T) { + setupStdoutCapture(t) + printTelemetrySummary(parse(`{"browser":{"control":{"enabled":true}},"export":{"otlp":{"enabled":true,"destination":"dest-abc"}}}`)) + assert.Contains(t, outBuf.String(), "OTLP to: dest-abc") + }) + t.Run("stays quiet when export is off", func(t *testing.T) { + setupStdoutCapture(t) + printTelemetrySummary(parse(`{"browser":{"control":{"enabled":true}},"export":{"otlp":{"enabled":false}}}`)) + assert.NotContains(t, outBuf.String(), "OTLP") + }) +} + func TestTelemetryEnabledCategories(t *testing.T) { var cfg kernel.BrowserTelemetryConfig raw := `{"browser":{"control":{"enabled":true},"system":{"enabled":true},"network":{"enabled":false}}}` diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index deb2e36..ca98454 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -486,6 +486,65 @@ func TestBrowsersCreate_WithNameAndTags(t *testing.T) { assert.Contains(t, out, "env=staging, team=backend") } +// The API takes a proxy object rather than the deprecated proxy_id field, and +// accepts exactly one of id, name, or mode. +func TestBrowsersCreate_ProxySelection(t *testing.T) { + setupStdoutCapture(t) + for _, tc := range []struct { + name string + in BrowsersCreateInput + assert func(t *testing.T, p kernel.BrowserProxyConfigParam) + }{ + {"by id", BrowsersCreateInput{ProxyID: "proxy-123"}, func(t *testing.T, p kernel.BrowserProxyConfigParam) { + assert.Equal(t, "proxy-123", p.ID.Value) + }}, + {"by name", BrowsersCreateInput{ProxyName: "my-proxy"}, func(t *testing.T, p kernel.BrowserProxyConfigParam) { + assert.Equal(t, "my-proxy", p.Name.Value) + }}, + {"by mode", BrowsersCreateInput{ProxyMode: "direct"}, func(t *testing.T, p kernel.BrowserProxyConfigParam) { + assert.Equal(t, kernel.BrowserProxyModeDirect, p.Mode) + }}, + {"omitted", BrowsersCreateInput{}, func(t *testing.T, p kernel.BrowserProxyConfigParam) { + assert.False(t, p.ID.Valid()) + assert.False(t, p.Name.Valid()) + assert.Empty(t, p.Mode) + }}, + } { + t.Run(tc.name, func(t *testing.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-new"}, nil + }, + } + b := BrowsersCmd{browsers: fake} + require.NoError(t, b.Create(context.Background(), tc.in)) + tc.assert(t, captured.Proxy) + }) + } +} + +func TestBrowsersCreate_ConflictingProxyFlags_Error(t *testing.T) { + setupStdoutCapture(t) + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.Create(context.Background(), BrowsersCreateInput{ProxyID: "proxy-123", ProxyMode: "direct"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "at most one") +} + +func TestBrowsersCreate_UnknownProxyMode_Errors(t *testing.T) { + setupStdoutCapture(t) + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.Create(context.Background(), BrowsersCreateInput{ProxyMode: "bogus"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown proxy mode") +} + func TestBrowsersCreate_WithChromePolicy(t *testing.T) { setupStdoutCapture(t) @@ -972,7 +1031,7 @@ func TestBrowsersGet_PrintsDetails(t *testing.T) { KioskMode: false, Viewport: shared.BrowserViewport{Width: 1920, Height: 1080, RefreshRate: 25}, Profile: kernel.Profile{ID: "prof-id", Name: "my-profile"}, - ProxyID: "proxy-123", + Proxy: kernel.BrowserProxy{ID: "proxy-123", Name: "my-proxy"}, }, nil }, } @@ -988,7 +1047,7 @@ func TestBrowsersGet_PrintsDetails(t *testing.T) { assert.Contains(t, out, "true") // Stealth assert.Contains(t, out, "1920x1080@25") assert.Contains(t, out, "my-profile") - assert.Contains(t, out, "proxy-123") + assert.Contains(t, out, "my-proxy (proxy-123)") } func TestBrowsersGet_JSONOutput(t *testing.T) { @@ -2154,23 +2213,71 @@ func TestBrowsersUpdate_WithViewportNoForce(t *testing.T) { assert.False(t, captured.Viewport.Force.Valid()) } -func TestBrowsersUpdate_WithDisableDefaultProxy(t *testing.T) { +// --disable-default-proxy and --clear-proxy are older spellings of a proxy mode +// change, so both are sent as the mode the API now takes. +func TestBrowsersUpdate_LegacyProxyFlagsMapToMode(t *testing.T) { setupStdoutCapture(t) - var captured kernel.BrowserUpdateParams - fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { - captured = body - return &kernel.BrowserUpdateResponse{SessionID: "session123"}, nil - }} + for _, tc := range []struct { + name string + in BrowsersUpdateInput + want kernel.BrowserProxyMode + }{ + {"disable default proxy", BrowsersUpdateInput{DisableDefaultProxy: BoolFlag{Set: true, Value: true}}, kernel.BrowserProxyModeDirect}, + {"re-enable default proxy", BrowsersUpdateInput{DisableDefaultProxy: BoolFlag{Set: true, Value: false}}, kernel.BrowserProxyModeDefault}, + {"clear proxy", BrowsersUpdateInput{ClearProxy: true}, kernel.BrowserProxyModeDefault}, + } { + t.Run(tc.name, func(t *testing.T) { + fake, captured := captureUpdateParams(t) + b := BrowsersCmd{browsers: fake} + + tc.in.Identifier = "session123" + require.NoError(t, b.Update(context.Background(), tc.in)) + assert.Equal(t, tc.want, captured.Proxy.Mode) + }) + } +} + +// A selected proxy and a mode are two ways to say the same thing, so combining +// them is a user error rather than a request the API has to reject. +func TestBrowsersUpdate_ConflictingProxyFlags_Error(t *testing.T) { + setupStdoutCapture(t) + for _, tc := range []struct { + name string + in BrowsersUpdateInput + }{ + {"id and name", BrowsersUpdateInput{ProxyID: "proxy-123", ProxyName: "my-proxy"}}, + {"id and mode", BrowsersUpdateInput{ProxyID: "proxy-123", ProxyMode: "direct"}}, + {"id and clear", BrowsersUpdateInput{ProxyID: "proxy-123", ClearProxy: true}}, + {"mode and disable default", BrowsersUpdateInput{ProxyMode: "direct", DisableDefaultProxy: BoolFlag{Set: true, Value: true}}}, + {"clear and disable default", BrowsersUpdateInput{ClearProxy: true, DisableDefaultProxy: BoolFlag{Set: true, Value: true}}}, + } { + t.Run(tc.name, func(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + tc.in.Identifier = "session123" + assert.Error(t, b.Update(context.Background(), tc.in)) + }) + } +} + +func TestBrowsersUpdate_UnknownProxyMode_Errors(t *testing.T) { + setupStdoutCapture(t) + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.Update(context.Background(), BrowsersUpdateInput{Identifier: "session123", ProxyMode: "bogus"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown proxy mode") +} + +func TestBrowsersUpdate_ProxyName_Forwarded(t *testing.T) { + setupStdoutCapture(t) + fake, captured := captureUpdateParams(t) b := BrowsersCmd{browsers: fake} - err := b.Update(context.Background(), BrowsersUpdateInput{ - Identifier: "session123", - DisableDefaultProxy: BoolFlag{Set: true, Value: true}, - }) + err := b.Update(context.Background(), BrowsersUpdateInput{Identifier: "session123", ProxyName: "my-proxy"}) - assert.NoError(t, err) - assert.True(t, captured.DisableDefaultProxy.Valid()) - assert.True(t, captured.DisableDefaultProxy.Value) + require.NoError(t, err) + assert.Equal(t, "my-proxy", captured.Proxy.Name.Value) } func TestBrowsersUpdate_ForceWithoutViewport_Errors(t *testing.T) { @@ -2365,7 +2472,7 @@ func TestBrowsersUpdate_NameAndTagsWithProxy_AllForwarded(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "combo", captured.Name.Value) assert.Equal(t, "v", captured.Tags["k"]) - assert.Equal(t, "proxy-123", captured.ProxyID.Value) + assert.Equal(t, "proxy-123", captured.Proxy.ID.Value) } // Regression guard: a non-name/non-tags update must omit both fields entirely diff --git a/cmd/credential_providers.go b/cmd/credential_providers.go index 2f47234..5b8041c 100644 --- a/cmd/credential_providers.go +++ b/cmd/credential_providers.go @@ -32,6 +32,7 @@ type CredentialProvidersCmd struct { } type CredentialProvidersListInput struct { + Query string Limit int Offset int Output string @@ -81,6 +82,9 @@ func (c CredentialProvidersCmd) List(ctx context.Context, in CredentialProviders } params := kernel.CredentialProviderListParams{} + if in.Query != "" { + params.Query = kernel.String(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Int(int64(in.Limit)) } @@ -446,6 +450,7 @@ func init() { // List flags addJSONOutputFlag(credentialProvidersListCmd) + credentialProvidersListCmd.Flags().String("query", "", "Search credential providers by name (IDs match by exact value)") credentialProvidersListCmd.Flags().Int("limit", 0, "Maximum number of credential providers to return") credentialProvidersListCmd.Flags().Int("offset", 0, "Number of credential providers to skip (for pagination)") @@ -483,12 +488,14 @@ func init() { func runCredentialProvidersList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") svc := client.CredentialProviders c := CredentialProvidersCmd{providers: &svc} return c.List(cmd.Context(), CredentialProvidersListInput{ + Query: query, Limit: limit, Offset: offset, Output: output, diff --git a/cmd/credentials.go b/cmd/credentials.go index 56fcbdc..bf7b764 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -32,6 +32,7 @@ type CredentialsCmd struct { type CredentialsListInput struct { Domain string + Query string Limit int Offset int Output string @@ -80,6 +81,9 @@ func (c CredentialsCmd) List(ctx context.Context, in CredentialsListInput) error if in.Domain != "" { params.Domain = kernel.Opt(in.Domain) } + if in.Query != "" { + params.Query = kernel.Opt(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Opt(int64(in.Limit)) } @@ -410,6 +414,7 @@ func init() { // List flags addJSONOutputFlag(credentialsListCmd) credentialsListCmd.Flags().String("domain", "", "Filter by domain") + credentialsListCmd.Flags().String("query", "", "Search credentials by name or domain (IDs match by exact value)") credentialsListCmd.Flags().Int("limit", 0, "Maximum number of results to return") credentialsListCmd.Flags().Int("offset", 0, "Number of results to skip") @@ -445,6 +450,7 @@ func runCredentialsList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") domain, _ := cmd.Flags().GetString("domain") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") @@ -452,6 +458,7 @@ func runCredentialsList(cmd *cobra.Command, args []string) error { c := CredentialsCmd{credentials: &svc} return c.List(cmd.Context(), CredentialsListInput{ Domain: domain, + Query: query, Limit: limit, Offset: offset, Output: output, diff --git a/cmd/deploy.go b/cmd/deploy.go index a5000d0..09a8498 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -93,6 +93,7 @@ func init() { deployHistoryCmd.Flags().Int("per-page", 20, "Items per page (alias of --limit)") deployHistoryCmd.Flags().Int("page", 1, "Page number (1-based)") deployHistoryCmd.Flags().String("app-version", "", "Filter by application version (requires app_name)") + deployHistoryCmd.Flags().String("query", "", "Search deployments by ID or app name") addJSONOutputFlag(deployHistoryCmd) deployCmd.AddCommand(deployHistoryCmd) @@ -492,6 +493,7 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { perPage, _ := cmd.Flags().GetInt("per-page") page, _ := cmd.Flags().GetInt("page") appVersionFilter, _ := cmd.Flags().GetString("app-version") + queryFilter, _ := cmd.Flags().GetString("query") output, _ := cmd.Flags().GetString("output") if err := validateJSONOutput(output); err != nil { @@ -530,6 +532,9 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { if appVersionFilter != "" { params.AppVersion = kernel.Opt(appVersionFilter) } + if queryFilter != "" { + params.Query = kernel.Opt(queryFilter) + } // Request one extra item to detect hasMore params.Limit = kernel.Opt(int64(perPage + 1)) params.Offset = kernel.Opt(int64((page - 1) * perPage)) @@ -589,6 +594,9 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { if appVersionFilter != "" { nextCmd += fmt.Sprintf(" --app-version %s", quoteIfNeeded(appVersionFilter)) } + if queryFilter != "" { + nextCmd += fmt.Sprintf(" --query %s", quoteIfNeeded(queryFilter)) + } pterm.Printf("Next: %s\n", nextCmd) } // Concise notes when user-specified per-page/limit/page are outside API-allowed range diff --git a/cmd/extensions.go b/cmd/extensions.go index 69aa33f..09bacf4 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -54,6 +54,8 @@ type ExtensionsService interface { } type ExtensionsListInput struct { + Name string + Query string Limit int Offset int Output string @@ -101,6 +103,12 @@ func (e ExtensionsCmd) List(ctx context.Context, in ExtensionsListInput) error { pterm.Info.Println("Fetching extensions...") } params := kernel.ExtensionListParams{} + if in.Name != "" { + params.Name = kernel.String(in.Name) + } + if in.Query != "" { + params.Query = kernel.String(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Int(int64(in.Limit)) } @@ -452,11 +460,13 @@ var extensionsListCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + name, _ := cmd.Flags().GetString("name") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") svc := client.Extensions e := ExtensionsCmd{extensions: &svc} - return e.List(cmd.Context(), ExtensionsListInput{Limit: limit, Offset: offset, Output: output}) + return e.List(cmd.Context(), ExtensionsListInput{Name: name, Query: query, Limit: limit, Offset: offset, Output: output}) }, } @@ -586,6 +596,8 @@ func init() { addJSONOutputFlag(extensionsListCmd) addJSONOutputFlag(extensionsGetCmd) + extensionsListCmd.Flags().String("name", "", "Exact-match filter on extension name") + extensionsListCmd.Flags().String("query", "", "Search extensions by name (IDs match by exact value)") extensionsListCmd.Flags().Int("limit", 0, "Maximum number of extensions to return") extensionsListCmd.Flags().Int("offset", 0, "Number of extensions to skip (for pagination)") extensionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") diff --git a/cmd/invoke.go b/cmd/invoke.go index cca3804..196aef2 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -80,6 +80,7 @@ func init() { invocationHistoryCmd.Flags().String("action", "", "Filter by action name") invocationHistoryCmd.Flags().StringP("app", "a", "", "Filter by app name") invocationHistoryCmd.Flags().String("deployment-id", "", "Filter by deployment ID") + invocationHistoryCmd.Flags().String("query", "", "Search invocations by ID, app name, or action name") invocationHistoryCmd.Flags().Int("offset", 0, "Number of results to skip") invocationHistoryCmd.Flags().String("since", "", "Show invocations that started since the given time") invocationHistoryCmd.Flags().String("status", "", "Filter by invocation status: queued, running, succeeded, failed") @@ -421,6 +422,7 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { actionFilter, _ := cmd.Flags().GetString("action") appFilter, _ := cmd.Flags().GetString("app") deploymentID, _ := cmd.Flags().GetString("deployment-id") + query, _ := cmd.Flags().GetString("query") offset, _ := cmd.Flags().GetInt("offset") since, _ := cmd.Flags().GetString("since") statusFilter, _ := cmd.Flags().GetString("status") @@ -446,6 +448,9 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { if deploymentID != "" { params.DeploymentID = kernel.Opt(deploymentID) } + if query != "" { + params.Query = kernel.Opt(query) + } if offset > 0 { params.Offset = kernel.Opt(int64(offset)) } diff --git a/cmd/profiles.go b/cmd/profiles.go index 1820ad1..1a9264f 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -41,6 +41,7 @@ type ProfilesListInput struct { Output string Page int PerPage int + Name string Query string } @@ -91,6 +92,9 @@ func (p ProfilesCmd) List(ctx context.Context, in ProfilesListInput) error { } params := kernel.ProfileListParams{} + if in.Name != "" { + params.Name = kernel.Opt(in.Name) + } if in.Query != "" { params.Query = kernel.Opt(in.Query) } @@ -144,7 +148,10 @@ func (p ProfilesCmd) List(ctx context.Context, in ProfilesListInput) error { pterm.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, itemsThisPage, lo.Ternary(hasMore, "yes", "no")) if hasMore { nextPage := page + 1 - nextCmd := fmt.Sprintf("kernel profile list --page %d --per-page %d", nextPage, perPage) + nextCmd := fmt.Sprintf("kernel profiles list --page %d --per-page %d", nextPage, perPage) + if in.Name != "" { + nextCmd += fmt.Sprintf(" --name \"%s\"", in.Name) + } if in.Query != "" { nextCmd += fmt.Sprintf(" --query \"%s\"", in.Query) } @@ -451,6 +458,7 @@ func init() { addJSONOutputFlag(profilesListCmd) profilesListCmd.Flags().Int("per-page", 20, "Items per page (default 20)") profilesListCmd.Flags().Int("page", 1, "Page number (1-based)") + profilesListCmd.Flags().String("name", "", "Exact-match filter on profile name") profilesListCmd.Flags().String("query", "", "Search profiles by name or ID") addJSONOutputFlag(profilesGetCmd) addJSONOutputFlag(profilesCreateCmd) @@ -469,6 +477,7 @@ func runProfilesList(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") perPage, _ := cmd.Flags().GetInt("per-page") page, _ := cmd.Flags().GetInt("page") + name, _ := cmd.Flags().GetString("name") query, _ := cmd.Flags().GetString("query") svc := client.Profiles @@ -477,6 +486,7 @@ func runProfilesList(cmd *cobra.Command, args []string) error { Output: output, Page: page, PerPage: perPage, + Name: name, Query: query, }) } diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go index 3546749..d8af479 100644 --- a/cmd/profiles_test.go +++ b/cmd/profiles_test.go @@ -108,7 +108,7 @@ func TestProfilesList_HasMore(t *testing.T) { _ = p.List(context.Background(), ProfilesListInput{Page: 1, PerPage: perPage}) out := buf.String() assert.Contains(t, out, "Has more: yes") - assert.Contains(t, out, "Next: kernel profile list --page 2 --per-page 2") + assert.Contains(t, out, "Next: kernel profiles list --page 2 --per-page 2") assert.Contains(t, out, "p0") assert.Contains(t, out, "p1") assert.NotContains(t, out, "p2") diff --git a/cmd/projects.go b/cmd/projects.go index 68d05fd..fa2c6a1 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -43,6 +43,8 @@ type ProjectsCmd struct { type ProjectsListInput struct { Limit int Offset int + Name string + Query string Output string } @@ -104,11 +106,19 @@ func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { return fmt.Errorf("--offset must be non-negative") } - var response *http.Response - projects, err := c.projects.List(ctx, kernel.ProjectListParams{ + params := kernel.ProjectListParams{ Limit: param.NewOpt(int64(in.Limit)), Offset: param.NewOpt(int64(in.Offset)), - }, option.WithResponseInto(&response)) + } + if in.Name != "" { + params.Name = param.NewOpt(in.Name) + } + if in.Query != "" { + params.Query = param.NewOpt(in.Query) + } + + var response *http.Response + projects, err := c.projects.List(ctx, params, option.WithResponseInto(&response)) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -149,9 +159,16 @@ func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { PrintTableNoPad(table, true) if pagination.HasMore { + filters := "" + if in.Name != "" { + filters += fmt.Sprintf(" --name %q", in.Name) + } + if in.Query != "" { + filters += fmt.Sprintf(" --query %q", in.Query) + } pterm.Warning.Printfln( - "Output truncated after index %d. Continue with: kernel projects list --limit %d --offset %d", - in.Offset+len(items)-1, in.Limit, pagination.NextOffset, + "Output truncated after index %d. Continue with: kernel projects list --limit %d --offset %d%s", + in.Offset+len(items)-1, in.Limit, pagination.NextOffset, filters, ) } return nil @@ -420,8 +437,16 @@ func runProjectsList(cmd *cobra.Command, args []string) error { c := getProjectsHandler(cmd) limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") + name, _ := cmd.Flags().GetString("name") + query, _ := cmd.Flags().GetString("query") output, _ := cmd.Flags().GetString("output") - return c.List(cmd.Context(), ProjectsListInput{Limit: limit, Offset: offset, Output: output}) + return c.List(cmd.Context(), ProjectsListInput{ + Limit: limit, + Offset: offset, + Name: name, + Query: query, + Output: output, + }) } func runProjectsCreate(cmd *cobra.Command, args []string) error { @@ -577,6 +602,8 @@ var projectsSetLimitsCompatCmd = &cobra.Command{ func init() { projectsListCmd.Flags().Int("limit", 100, "Maximum number of projects to return (1-100)") projectsListCmd.Flags().Int("offset", 0, "Number of projects to skip (for pagination)") + projectsListCmd.Flags().String("name", "", "Filter by exact project name (case- and accent-insensitive)") + projectsListCmd.Flags().String("query", "", "Search projects by name substring (case-insensitive)") addJSONOutputFlag(projectsListCmd) projectsUpdateCmd.Flags().String("name", "", "New project name (1-255 characters)") diff --git a/cmd/projects_test.go b/cmd/projects_test.go index bce3d9c..24d2500 100644 --- a/cmd/projects_test.go +++ b/cmd/projects_test.go @@ -113,6 +113,50 @@ func TestProjectsList_UsesSDKResponsePaginationMetadata(t *testing.T) { assert.JSONEq(t, `{"projects":`+responseBody+`,"next_offset":22}`, jsonOutput) } +func TestProjectsList_SendsNameAndQueryFilters(t *testing.T) { + const responseBody = `[ + {"id":"project-alpha","name":"alpha","status":"active","created_at":"2026-08-08T12:00:00Z","updated_at":"2026-08-08T12:00:00Z"} + ]` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/org/projects", r.URL.Path) + assert.Equal(t, "alpha", r.URL.Query().Get("name")) + assert.Equal(t, "alph", 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", "1") + _, _ = w.Write([]byte(responseBody)) + })) + defer server.Close() + + client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test")) + c := ProjectsCmd{projects: &client.Projects, limits: &client.Projects.Limits} + + buf := capturePtermOutput(t) + err := c.List(context.Background(), ProjectsListInput{Limit: 1, Name: "alpha", Query: "alph"}) + require.NoError(t, err) + out := pterm.RemoveColorFromString(buf.String()) + // The continuation hint preserves the filter flags. + assert.Contains(t, out, `kernel projects list --limit 1 --offset 1 --name "alpha" --query "alph"`) +} + +func TestProjectsList_OmitsEmptyFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.False(t, r.URL.Query().Has("name")) + assert.False(t, r.URL.Query().Has("query")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Has-More", "false") + w.Header().Set("X-Next-Offset", "0") + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + + client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test")) + c := ProjectsCmd{projects: &client.Projects, limits: &client.Projects.Limits} + + capturePtermOutput(t) + require.NoError(t, c.List(context.Background(), ProjectsListInput{Limit: 10})) +} + func TestProjectsList_RejectsInvalidPagination(t *testing.T) { fakeProjects := &FakeProjectsService{ ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index 83a8f74..1efb5f3 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -22,6 +22,12 @@ func (p ProxyCmd) List(ctx context.Context, in ProxyListInput) error { } params := kernel.ProxyListParams{} + if in.Name != "" { + params.Name = kernel.String(in.Name) + } + if in.Query != "" { + params.Query = kernel.String(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Int(int64(in.Limit)) } @@ -154,9 +160,11 @@ func formatProxyConfig(proxy *kernel.ProxyListResponse) string { func runProxiesList(cmd *cobra.Command, args []string) error { client := util.GetKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + name, _ := cmd.Flags().GetString("name") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") svc := client.Proxies p := ProxyCmd{proxies: &svc} - return p.List(cmd.Context(), ProxyListInput{Limit: limit, Offset: offset, Output: output}) + return p.List(cmd.Context(), ProxyListInput{Name: name, Query: query, Limit: limit, Offset: offset, Output: output}) } diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index 7642001..a3229c9 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -93,6 +93,8 @@ func init() { // Add output flags addJSONOutputFlag(proxiesListCmd) + proxiesListCmd.Flags().String("name", "", "Exact-match filter on proxy name") + proxiesListCmd.Flags().String("query", "", "Search proxies by name, host, or IP address (IDs match by exact value)") proxiesListCmd.Flags().Int("limit", 0, "Maximum number of proxies to return") proxiesListCmd.Flags().Int("offset", 0, "Number of proxies to skip (for pagination)") addJSONOutputFlag(proxiesGetCmd) diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index 7ca96f9..ee0b624 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -27,6 +27,8 @@ type ProxyCmd struct { // Input types for proxy operations type ProxyListInput struct { + Name string + Query string Limit int Offset int Output string diff --git a/go.mod b/go.mod index 017cfda..4f0734a 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.87.0 + github.com/kernel/kernel-go-sdk v0.88.1-0.20260810220556-1b7bde817875 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 da853f3..a51be3a 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.87.0 h1:Z5qRzvK9vSZbiOryVQ7XFT4WWh9W2Lcy+e5Brvk1TkY= -github.com/kernel/kernel-go-sdk v0.87.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.88.1-0.20260810220556-1b7bde817875 h1:EpRLCYwQIsKwbvOene7VkvPdtpdUPGqiEPWn6jgNx9g= +github.com/kernel/kernel-go-sdk v0.88.1-0.20260810220556-1b7bde817875/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=