diff --git a/README.md b/README.md index 3b2cef3..4f7655e 100644 --- a/README.md +++ b/README.md @@ -619,6 +619,7 @@ Managed auth connections (`kernel auth connections`). The commands below are new - `kernel auth connections submit ` - New flags: - `--field-value ` - Canonical field-id=value pair from the connection's `fields` list (repeatable); preferred over the legacy `--field` - `--choice-id ` - Canonical choice ID from the connection's `choices` list + - `--interaction-id ` - Canonical interaction the submitted values answer. Only valid with `--field-value` or `--choice-id`; omit it and the CLI reads the connection's current interaction ID for you. Pass it to pin the submission, so the API rejects it if the flow has already moved on. `kernel auth connections get` and `follow` list those IDs alongside the metadata the API captured for them, so you can tell the options apart before submitting. Fields show their type, ref, and any hint (which names the masked destination a one-time code was sent to); choices show their type, semantic MFA method (`sms`, `totp`, `push`, …), and masked destination. diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 79a3456..d7fc1f9 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -57,6 +57,7 @@ type AuthConnectionCreateInput struct { NoAutoReauth bool RecordSession BoolFlag Telemetry string + TelemetryCdpExclude string TelemetryExport string Output string } @@ -92,6 +93,7 @@ type AuthConnectionUpdateInput struct { AutoReauth BoolFlag RecordSession BoolFlag Telemetry string + TelemetryCdpExclude string TelemetryExport string Output string } @@ -111,15 +113,16 @@ type AuthConnectionDeleteInput struct { } type AuthConnectionLoginInput struct { - ID string - ProxyID string - ProxyName string - ProxyMode string - Stealth BoolFlag - RecordSession BoolFlag - Telemetry string - TelemetryExport string - Output string + ID string + ProxyID string + ProxyName string + ProxyMode string + Stealth BoolFlag + RecordSession BoolFlag + Telemetry string + TelemetryCdpExclude string + TelemetryExport string + Output string } type AuthConnectionSubmitInput struct { @@ -130,7 +133,11 @@ type AuthConnectionSubmitInput struct { // canonical `field_values` keyed by the field IDs the API returned. CanonicalFieldValues map[string]string // SelectedChoiceID is the canonical choice ID from the API's `choices` list. - SelectedChoiceID string + SelectedChoiceID string + // InteractionID pins the submission to the canonical interaction the values + // were read from. Left empty, the CLI reads the connection's current + // interaction ID, since the API requires one for canonical submissions. + InteractionID string MfaOptionID string SignInOptionID string SSOButtonSelector string @@ -233,8 +240,8 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, true) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, true) if err != nil { return err } @@ -379,8 +386,8 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn hasChanges = true } - if in.Telemetry != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, false) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) if err != nil { return err } @@ -414,13 +421,13 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn // models the one on `get` and the one on the `follow` event stream as two // identical but distinct types, so both are converted to this before rendering. type managedAuthInputField struct { - ID string - Label string - Type string - Ref string - Hint string - Required bool - ReplaceExisting bool + ID string + Label string + Type string + Ref string + Hint string + Reason string + Required bool } // managedAuthInputChoice is the choice counterpart of managedAuthInputField. @@ -448,8 +455,8 @@ func formatManagedAuthField(f managedAuthInputField) string { if f.Required { meta = append(meta, "required") } - if f.ReplaceExisting { - meta = append(meta, "replace-existing") + if f.Reason != "" { + meta = append(meta, "reason="+f.Reason) } if f.Hint != "" { meta = append(meta, fmt.Sprintf("hint=%q", f.Hint)) @@ -538,17 +545,22 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e // Canonical fields/choices supersede discovered_fields, mfa_options and // pending_sso_buttons. Show them first so the IDs needed by `submit // --field-value` and `submit --choice-id` are the first thing visible. + // The interaction ID scopes those submissions and only accompanies canonical + // input, so show it alongside them. + if auth.InteractionID != "" { + tableData = append(tableData, []string{"Interaction ID", auth.InteractionID}) + } if len(auth.Fields) > 0 { fields := make([]string, 0, len(auth.Fields)) for _, f := range auth.Fields { fields = append(fields, formatManagedAuthField(managedAuthInputField{ - ID: f.ID, - Label: f.Label, - Type: f.Type, - Ref: f.Ref, - Hint: f.Hint, - Required: f.Required, - ReplaceExisting: f.ReplaceExisting, + ID: f.ID, + Label: f.Label, + Type: f.Type, + Ref: f.Ref, + Hint: f.Hint, + Reason: f.Reason, + Required: f.Required, })) } tableData = append(tableData, []string{"Fields", strings.Join(fields, "; ")}) @@ -772,8 +784,8 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu params.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, false) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) if err != nil { return err } @@ -838,6 +850,28 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn return fmt.Errorf("provide exactly one of: %s", submitModeFlags) } + // The API binds canonical submissions to the interaction the values were read + // from, and rejects an interaction ID sent with a legacy submit mode. + isCanonical := hasCanonicalFields || hasChoice + if in.InteractionID != "" && !isCanonical { + return fmt.Errorf("the --interaction-id flag is only valid with --field-value or --choice-id") + } + if isCanonical && in.InteractionID == "" { + // Resolve the current interaction rather than making the user copy it out + // of `get` or `follow` first. The ID changes on every actionable pause, so + // the freshly read one is the only one worth defaulting to; passing + // --interaction-id explicitly pins the submission to an older interaction + // and lets the API reject it as stale. + conn, err := c.svc.Get(ctx, in.ID) + if err != nil { + return util.CleanedUpSdkError{Err: fmt.Errorf("failed to fetch connection for interaction ID resolution: %w", err)} + } + if conn == nil || conn.InteractionID == "" { + return fmt.Errorf("connection %s has no canonical interaction awaiting input; run 'kernel auth connections get %s' to see what the flow is waiting on", in.ID, in.ID) + } + in.InteractionID = conn.InteractionID + } + // Resolve MFA option: the user may pass the label (e.g. "Get a text"), the // type (e.g. "sms"), or the display string ("Get a text (sms)"). The API // expects the type, so look up the connection's available options and map @@ -884,6 +918,9 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn if hasChoice { params.SubmitFieldsRequest.SelectedChoiceID = kernel.Opt(in.SelectedChoiceID) } + if in.InteractionID != "" { + params.SubmitFieldsRequest.InteractionID = kernel.Opt(in.InteractionID) + } if hasMfaOption { params.SubmitFieldsRequest.MfaOptionID = kernel.Opt(in.MfaOptionID) } @@ -1063,17 +1100,20 @@ func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowIn state.Timestamp.Local().Format(time.RFC3339), state.FlowStatus, state.FlowStep) + if state.InteractionID != "" { + pterm.Info.Printf(" Interaction ID: %s\n", state.InteractionID) + } if len(state.Fields) > 0 { fields := make([]string, 0, len(state.Fields)) for _, f := range state.Fields { fields = append(fields, formatManagedAuthField(managedAuthInputField{ - ID: f.ID, - Label: f.Label, - Type: f.Type, - Ref: f.Ref, - Hint: f.Hint, - Required: f.Required, - ReplaceExisting: f.ReplaceExisting, + ID: f.ID, + Label: f.Label, + Type: f.Type, + Ref: f.Ref, + Hint: f.Hint, + Reason: f.Reason, + Required: f.Required, })) } pterm.Info.Printf(" Fields: %s\n", strings.Join(fields, ", ")) @@ -1181,8 +1221,18 @@ var authConnectionsSubmitCmd = &cobra.Command{ Short: "Submit field values to a login flow", Long: `Submit field values for the login form. Poll the managed auth to track progress. +Canonical submissions (--field-value, --choice-id) are bound to the interaction +they answer. The CLI reads the connection's current interaction ID for you; pass +--interaction-id to pin the submission to a specific interaction instead. + Examples: - # Submit field values + # Submit canonical field values from the connection's fields list + kernel auth connections submit --field-value field_email=me@example.com --field-value field_password=secret + + # Answer a specific interaction (rejected if the flow has moved on) + kernel auth connections submit --choice-id mfa_sms --interaction-id mai_abc123xyz + + # Submit legacy field values kernel auth connections submit --field username=myuser --field password=mypass # Select an MFA option @@ -1232,6 +1282,7 @@ func init() { authConnectionsCreateCmd.Flags().Bool("record-session", false, "Record browser sessions for this connection by default (useful for debugging)") authConnectionsCreateCmd.Flags().String("telemetry", "", "Configure telemetry for this connection's browser sessions (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") authConnectionsCreateCmd.Flags().String("telemetry-export-otlp", "", "Export this connection's captured telemetry over OTLP to one of the org's configured destinations, by ID or name; --telemetry-export-otlp=off disables export. Implies --telemetry=all when --telemetry is not set, since export requires capture") + authConnectionsCreateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") _ = authConnectionsCreateCmd.MarkFlagRequired("domain") _ = authConnectionsCreateCmd.MarkFlagRequired("profile-name") authConnectionsCreateCmd.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") @@ -1261,6 +1312,7 @@ func init() { authConnectionsUpdateCmd.Flags().Bool("record-session", false, "Set whether browser sessions are recorded by default; use --record-session=false to disable") authConnectionsUpdateCmd.Flags().String("telemetry", "", "Update telemetry for future browser sessions: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection)") authConnectionsUpdateCmd.Flags().String("telemetry-export-otlp", "", "Update where future sessions export captured telemetry over OTLP, by destination ID or name; --telemetry-export-otlp=off disables export. Naming a destination requires passing --telemetry in the same command, since export and capture are validated together") + authConnectionsUpdateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("save-credentials", "no-save-credentials") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("health-checks", "no-health-checks") @@ -1286,11 +1338,13 @@ func init() { authConnectionsLoginCmd.Flags().Bool("record-session", false, "Override whether this login's browser session is recorded; use --record-session=false to disable") authConnectionsLoginCmd.Flags().String("telemetry", "", "Telemetry override for this login only, merged onto the connection's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") authConnectionsLoginCmd.Flags().String("telemetry-export-otlp", "", "Export override for this login only: an OTLP destination ID or name; --telemetry-export-otlp=off disables export for this login. Naming a destination requires passing --telemetry in the same command, since export and capture are validated together") + authConnectionsLoginCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") // Submit flags addJSONOutputFlag(authConnectionsSubmitCmd) authConnectionsSubmitCmd.Flags().StringArray("field-value", []string{}, "Canonical field-id=value pair from the connection's `fields` list (repeatable)") authConnectionsSubmitCmd.Flags().String("choice-id", "", "Canonical choice ID from the connection's `choices` list") + authConnectionsSubmitCmd.Flags().String("interaction-id", "", "Canonical interaction ID the submitted values belong to; defaults to the connection's current interaction. Only valid with --field-value or --choice-id") authConnectionsSubmitCmd.Flags().StringArray("field", []string{}, "Legacy field name=value pair (repeatable); prefer --field-value") authConnectionsSubmitCmd.Flags().String("mfa-option-id", "", "MFA option ID if user selected an MFA method") authConnectionsSubmitCmd.Flags().String("sign-in-option-id", "", "Sign-in option ID if the flow returned non-MFA choices") @@ -1339,6 +1393,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { noHealthChecks, _ := cmd.Flags().GetBool("no-health-checks") noAutoReauth, _ := cmd.Flags().GetBool("no-auto-reauth") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") svc := client.Auth.Connections @@ -1362,6 +1417,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { NoAutoReauth: noAutoReauth, RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, Output: output, }) @@ -1395,6 +1451,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") saveCredentialsFlag := BoolFlag{} @@ -1448,6 +1505,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { AutoReauth: togglePair("auto-reauth", "no-auto-reauth"), RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, Output: output, }) @@ -1493,20 +1551,22 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} return c.Login(cmd.Context(), AuthConnectionLoginInput{ - ID: args[0], - ProxyID: proxyID, - ProxyName: proxyName, - ProxyMode: proxyMode, - Stealth: readBoolFlag(cmd.Flags(), "stealth"), - RecordSession: readBoolFlag(cmd.Flags(), "record-session"), - Telemetry: telemetry, - TelemetryExport: telemetryExport, - Output: output, + ID: args[0], + ProxyID: proxyID, + ProxyName: proxyName, + ProxyMode: proxyMode, + Stealth: readBoolFlag(cmd.Flags(), "stealth"), + RecordSession: readBoolFlag(cmd.Flags(), "record-session"), + Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, + TelemetryExport: telemetryExport, + Output: output, }) } @@ -1516,6 +1576,7 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error { fieldPairs, _ := cmd.Flags().GetStringArray("field") canonicalFieldPairs, _ := cmd.Flags().GetStringArray("field-value") choiceID, _ := cmd.Flags().GetString("choice-id") + interactionID, _ := cmd.Flags().GetString("interaction-id") mfaOptionID, _ := cmd.Flags().GetString("mfa-option-id") signInOptionID, _ := cmd.Flags().GetString("sign-in-option-id") ssoButtonSelector, _ := cmd.Flags().GetString("sso-button-selector") @@ -1543,6 +1604,7 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error { FieldValues: fieldValues, CanonicalFieldValues: canonicalFieldValues, SelectedChoiceID: choiceID, + InteractionID: interactionID, MfaOptionID: mfaOptionID, SignInOptionID: signInOptionID, SSOButtonSelector: ssoButtonSelector, diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index b1c654a..4466e48 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -147,6 +147,9 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) { Status: kernel.ManagedAuthStatusNeedsAuth, FlowStatus: kernel.ManagedAuthFlowStatusInProgress, FlowStep: kernel.ManagedAuthFlowStepAwaitingInput, + // Canonical fields and choices always arrive with the interaction + // they belong to, which `submit` needs. + InteractionID: "mai_abc123xyz", Fields: []kernel.ManagedAuthField{ { ID: "otp", @@ -154,6 +157,7 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) { Type: "code", Ref: "totp_code", Hint: "Enter the code sent to +1 ••• ••• 1234", + Reason: "rejected", Required: true, }, }, @@ -181,8 +185,11 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) { require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "e0x3vbw4z66kpwny3k5k46tj"})) out := outBuf.String() + assert.Contains(t, out, `mai_abc123xyz`) assert.Contains(t, out, `otp (One-time code)`) - assert.Contains(t, out, `code, ref=totp_code, required`) + // The reason tells the user why the field is being asked for: "rejected" + // means a stored credential was refused, so a new value has to replace it. + assert.Contains(t, out, `code, ref=totp_code, required, reason=rejected`) assert.Contains(t, out, `hint="Enter the code sent to +1 ••• ••• 1234"`) assert.Contains(t, out, `mfa_sms (Text message)`) assert.Contains(t, out, `mfa_method, sms, to=+1 ••• ••• 1234`) @@ -820,16 +827,24 @@ func TestLogin_TelemetryOverride(t *testing.T) { assert.True(t, captured.Browser.Telemetry.Browser.Screenshot.Enabled.Value) } -func TestSubmit_CanonicalChoiceID(t *testing.T) { - capturePtermOutput(t) - var captured kernel.AuthConnectionSubmitParams - fake := &FakeAuthConnectionService{ +// canonicalSubmitFake serves the current interaction ID from `get` and captures +// what `submit` sends, which is what every canonical submission needs. +func canonicalSubmitFake(interactionID string, captured *kernel.AuthConnectionSubmitParams) *FakeAuthConnectionService { + return &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + return &kernel.ManagedAuth{ID: id, InteractionID: interactionID}, nil + }, SubmitFunc: func(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (*kernel.SubmitFieldsResponse, error) { - captured = body + *captured = body return &kernel.SubmitFieldsResponse{Accepted: true}, nil }, } - c := AuthConnectionCmd{svc: fake} +} + +func TestSubmit_CanonicalChoiceID(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionSubmitParams + c := AuthConnectionCmd{svc: canonicalSubmitFake("mai_current", &captured)} require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ ID: "auth_1", SelectedChoiceID: "choice_sms", @@ -841,6 +856,53 @@ func TestSubmit_CanonicalChoiceID(t *testing.T) { } func TestSubmit_CanonicalFieldValues(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionSubmitParams + c := AuthConnectionCmd{svc: canonicalSubmitFake("mai_current", &captured)} + require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + CanonicalFieldValues: map[string]string{"field_email": "me@example.com"}, + })) + assert.Equal(t, map[string]string{"field_email": "me@example.com"}, captured.SubmitFieldsRequest.FieldValues) + assert.Nil(t, captured.SubmitFieldsRequest.Fields) +} + +func TestSubmit_CanonicalResolvesCurrentInteractionID(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionSubmitParams + c := AuthConnectionCmd{svc: canonicalSubmitFake("mai_current", &captured)} + require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + CanonicalFieldValues: map[string]string{"field_email": "me@example.com"}, + })) + require.True(t, captured.SubmitFieldsRequest.InteractionID.Valid()) + assert.Equal(t, "mai_current", captured.SubmitFieldsRequest.InteractionID.Value) +} + +func TestSubmit_ExplicitInteractionIDIsNotOverwritten(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionSubmitParams + fake := canonicalSubmitFake("mai_current", &captured) + getCalls := 0 + inner := fake.GetFunc + fake.GetFunc = func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + getCalls++ + return inner(ctx, id, opts...) + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + SelectedChoiceID: "choice_sms", + // Pinning an older interaction is how a caller detects that the flow moved + // on, so the CLI must forward it untouched. + InteractionID: "mai_pinned", + })) + assert.Equal(t, 0, getCalls) + require.True(t, captured.SubmitFieldsRequest.InteractionID.Valid()) + assert.Equal(t, "mai_pinned", captured.SubmitFieldsRequest.InteractionID.Value) +} + +func TestSubmit_LegacyModeOmitsInteractionID(t *testing.T) { capturePtermOutput(t) var captured kernel.AuthConnectionSubmitParams fake := &FakeAuthConnectionService{ @@ -851,11 +913,61 @@ func TestSubmit_CanonicalFieldValues(t *testing.T) { } c := AuthConnectionCmd{svc: fake} require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + FieldValues: map[string]string{"username": "me"}, + })) + // The API rejects an interaction ID paired with a legacy submit mode. + assert.False(t, captured.SubmitFieldsRequest.InteractionID.Valid()) +} + +func TestSubmit_InteractionIDRequiresCanonicalMode(t *testing.T) { + capturePtermOutput(t) + c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} + err := c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + FieldValues: map[string]string{"username": "me"}, + InteractionID: "mai_current", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "the --interaction-id flag is only valid with --field-value or --choice-id") +} + +func TestSubmit_CanonicalWithoutPendingInteractionErrors(t *testing.T) { + capturePtermOutput(t) + submitted := false + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + return &kernel.ManagedAuth{ID: id}, nil + }, + SubmitFunc: func(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (*kernel.SubmitFieldsResponse, error) { + submitted = true + return &kernel.SubmitFieldsResponse{Accepted: true}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + err := c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + SelectedChoiceID: "choice_sms", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no canonical interaction awaiting input") + assert.False(t, submitted) +} + +func TestSubmit_CanonicalGetErrorSurfaced(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + return nil, errors.New("boom") + }, + } + c := AuthConnectionCmd{svc: fake} + err := c.Submit(context.Background(), AuthConnectionSubmitInput{ ID: "auth_1", CanonicalFieldValues: map[string]string{"field_email": "me@example.com"}, - })) - assert.Equal(t, map[string]string{"field_email": "me@example.com"}, captured.SubmitFieldsRequest.FieldValues) - assert.Nil(t, captured.SubmitFieldsRequest.Fields) + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "interaction ID resolution") } func TestSubmit_CanonicalAndLegacyAreMutuallyExclusive(t *testing.T) { diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index c6f7051..276018d 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -108,21 +108,24 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err return nil } -// buildPoolNewTelemetryParam converts a --telemetry flag value to the pool create param. -func buildPoolNewTelemetryParam(s string) (kernel.BrowserPoolNewParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) +// buildPoolNewTelemetryParam converts --telemetry and --telemetry-cdp-exclude flag +// values to the pool create param. +func buildPoolNewTelemetryParam(s, cdpExclude string) (kernel.BrowserPoolNewParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) return kernel.BrowserPoolNewParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// buildPoolUpdateTelemetryParam converts a --telemetry flag value to the pool update param. -func buildPoolUpdateTelemetryParam(s string) (kernel.BrowserPoolUpdateParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) +// buildPoolUpdateTelemetryParam converts --telemetry and --telemetry-cdp-exclude flag +// values to the pool update param. +func buildPoolUpdateTelemetryParam(s, cdpExclude string) (kernel.BrowserPoolUpdateParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) return kernel.BrowserPoolUpdateParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// buildPoolAcquireTelemetryParam converts a --telemetry flag value to the acquire override param. -func buildPoolAcquireTelemetryParam(s string) (kernel.BrowserPoolAcquireParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) +// buildPoolAcquireTelemetryParam converts --telemetry and --telemetry-cdp-exclude flag +// values to the acquire override param. +func buildPoolAcquireTelemetryParam(s, cdpExclude string) (kernel.BrowserPoolAcquireParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) return kernel.BrowserPoolAcquireParamsTelemetry{Enabled: enabled, Browser: browser}, err } @@ -132,7 +135,11 @@ func formatPoolTelemetry(cfg kernel.BrowserTelemetryConfig) string { if len(on) == 0 { return "disabled" } - return strings.Join(on, ", ") + base := strings.Join(on, ", ") + if ex := formatCdpExcludedMethods(cfg.Browser.Control.Cdp.ExcludedMethods); ex != "" { + return base + " (excluding CDP methods: " + ex + ")" + } + return base } type BrowserPoolsCreateInput struct { @@ -155,6 +162,7 @@ type BrowserPoolsCreateInput struct { ChromePolicy string ChromePolicyFile string Telemetry string + TelemetryCdpExclude string Output string } @@ -247,8 +255,8 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) params.ChromePolicy = chromePolicy } - if in.Telemetry != "" { - t, err := buildPoolNewTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { + t, err := buildPoolNewTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) if err != nil { return err } @@ -269,7 +277,7 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) } else { pterm.Success.Printf("Created browser pool %s\n", pool.ID) } - if in.Telemetry != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { printTelemetrySummary(pool.BrowserPoolConfig.Telemetry) } return nil @@ -350,6 +358,7 @@ type BrowserPoolsUpdateInput struct { ChromePolicyFile string ClearChromePolicy bool Telemetry string + TelemetryCdpExclude string DiscardAllIdle BoolFlag Output string } @@ -488,8 +497,8 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) params.SetExtraFields(extraFields) } - if in.Telemetry != "" { - t, err := buildPoolUpdateTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { + t, err := buildPoolUpdateTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) if err != nil { return err } @@ -510,7 +519,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) } else { pterm.Success.Printf("Updated browser pool %s\n", pool.ID) } - if in.Telemetry != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { printTelemetrySummary(pool.BrowserPoolConfig.Telemetry) } return nil @@ -535,13 +544,14 @@ func (c BrowserPoolsCmd) Delete(ctx context.Context, in BrowserPoolsDeleteInput) } type BrowserPoolsAcquireInput struct { - IDOrName string - TimeoutSeconds int64 - Name string - StartURL string - Tags map[string]string - Telemetry string - Output string + IDOrName string + TimeoutSeconds int64 + Name string + StartURL string + Tags map[string]string + Telemetry string + TelemetryCdpExclude string + Output string } // buildAcquireParams builds the SDK params for acquiring a browser from a pool. @@ -549,7 +559,7 @@ type BrowserPoolsAcquireInput struct { // path so the per-lease name/tags/start-url/telemetry forwarding cannot silently // diverge between them. The telemetry override merges onto the pool's config for // this lease. -func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64, telemetry, startURL string) (kernel.BrowserPoolAcquireParams, error) { +func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64, telemetry, telemetryCdpExclude, startURL string) (kernel.BrowserPoolAcquireParams, error) { params := kernel.BrowserPoolAcquireParams{} if timeoutSeconds > 0 { params.AcquireTimeoutSeconds = kernel.Int(timeoutSeconds) @@ -563,8 +573,8 @@ func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int6 if len(tags) > 0 { params.Tags = kernel.Tags(tags) } - if telemetry != "" { - t, err := buildPoolAcquireTelemetryParam(telemetry) + if telemetry != "" || telemetryCdpExclude != "" { + t, err := buildPoolAcquireTelemetryParam(telemetry, telemetryCdpExclude) if err != nil { return kernel.BrowserPoolAcquireParams{}, err } @@ -578,7 +588,7 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu return err } - params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.StartURL) + params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.TelemetryCdpExclude, in.StartURL) if err != nil { return err } @@ -749,6 +759,7 @@ func init() { browserPoolsCreateCmd.Flags().String("chrome-policy", "", "Custom Chrome enterprise policy as a JSON object") browserPoolsCreateCmd.Flags().String("chrome-policy-file", "", "Read Chrome enterprise policy (JSON object) from a file (use '-' for stdin)") browserPoolsCreateCmd.Flags().String("telemetry", "", "Configure telemetry for browsers warmed into the pool (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") + browserPoolsCreateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") browserPoolsCreateCmd.MarkFlagsMutuallyExclusive("chrome-policy", "chrome-policy-file") addJSONOutputFlag(browserPoolsGetCmd) @@ -779,6 +790,7 @@ func init() { browserPoolsUpdateCmd.MarkFlagsMutuallyExclusive("chrome-policy", "chrome-policy-file") browserPoolsUpdateCmd.MarkFlagsMutuallyExclusive("private-host", "clear-private-hosts") browserPoolsUpdateCmd.Flags().String("telemetry", "", "Update pool telemetry: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection). Applies only to browsers warmed after the update.") + browserPoolsUpdateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") browserPoolsUpdateCmd.Flags().Bool("discard-all-idle", false, "Discard all idle browsers") addJSONOutputFlag(browserPoolsUpdateCmd) @@ -789,6 +801,7 @@ func init() { browserPoolsAcquireCmd.Flags().String("start-url", "", "URL to navigate the acquired browser to, overriding the pool's start URL for this acquire only (best-effort)") browserPoolsAcquireCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the acquired session (repeatable; applies to this lease)") browserPoolsAcquireCmd.Flags().String("telemetry", "", "Telemetry override for this lease only, merged onto the pool's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") + browserPoolsAcquireCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") addJSONOutputFlag(browserPoolsAcquireCmd) browserPoolsReleaseCmd.Flags().String("session-id", "", "Browser session ID to release") @@ -845,6 +858,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { chromePolicy, _ := cmd.Flags().GetString("chrome-policy") chromePolicyFile, _ := cmd.Flags().GetString("chrome-policy-file") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") output, _ := cmd.Flags().GetString("output") in := BrowserPoolsCreateInput{ @@ -867,6 +881,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { ChromePolicy: chromePolicy, ChromePolicyFile: chromePolicyFile, Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, Output: output, } @@ -908,6 +923,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { chromePolicyFile, _ := cmd.Flags().GetString("chrome-policy-file") clearChromePolicy, _ := cmd.Flags().GetBool("clear-chrome-policy") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") discardIdle, _ := cmd.Flags().GetBool("discard-all-idle") output, _ := cmd.Flags().GetString("output") @@ -937,6 +953,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { ChromePolicyFile: chromePolicyFile, ClearChromePolicy: clearChromePolicy, Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, DiscardAllIdle: BoolFlag{Set: cmd.Flags().Changed("discard-all-idle"), Value: discardIdle}, Output: output, } @@ -959,16 +976,18 @@ func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { startURL, _ := cmd.Flags().GetString("start-url") tags, _ := tagsFromFlag(cmd, "tag") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") output, _ := cmd.Flags().GetString("output") c := BrowserPoolsCmd{client: &client.BrowserPools} return c.Acquire(cmd.Context(), BrowserPoolsAcquireInput{ - IDOrName: args[0], - TimeoutSeconds: timeout, - Name: name, - StartURL: startURL, - Tags: tags, - Telemetry: telemetry, - Output: output, + IDOrName: args[0], + TimeoutSeconds: timeout, + Name: name, + StartURL: startURL, + Tags: tags, + Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, + Output: output, }) } diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index e0a143f..387f3f1 100644 --- a/cmd/browser_pools_test.go +++ b/cmd/browser_pools_test.go @@ -239,7 +239,7 @@ func TestBrowserPoolsCreate_PrivateHostNormalization(t *testing.T) { // forwarding used by both `browser-pools acquire` and the `browsers create // --pool-id` lease path. func TestBuildAcquireParams(t *testing.T) { - p, err := buildAcquireParams("lease", map[string]string{"env": "prod"}, 30, "console,network", "https://example.com") + p, err := buildAcquireParams("lease", map[string]string{"env": "prod"}, 30, "console,network", "", "https://example.com") assert.NoError(t, err) assert.True(t, p.Name.Valid()) assert.Equal(t, "lease", p.Name.Value) @@ -252,7 +252,7 @@ func TestBuildAcquireParams(t *testing.T) { assert.True(t, p.Telemetry.Browser.Network.Enabled.Value) // Unset inputs produce an empty params struct (nothing forwarded). - empty, err := buildAcquireParams("", nil, 0, "", "") + empty, err := buildAcquireParams("", nil, 0, "", "", "") assert.NoError(t, err) assert.False(t, empty.Name.Valid()) assert.Len(t, empty.Tags, 0) @@ -260,7 +260,7 @@ func TestBuildAcquireParams(t *testing.T) { assert.False(t, empty.StartURL.Valid()) // An invalid category surfaces an error rather than a partial param. - _, err = buildAcquireParams("", nil, 0, "bogus", "") + _, err = buildAcquireParams("", nil, 0, "bogus", "", "") assert.Error(t, err) } diff --git a/cmd/browsers.go b/cmd/browsers.go index 326f88d..5e4c82b 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -360,31 +360,32 @@ func formatTags(tags kernel.Tags) string { // Inputs for each command type BrowsersCreateInput struct { - TimeoutSeconds int - Stealth BoolFlag - Headless BoolFlag - GPU BoolFlag - Memory string - InvocationID string - Kiosk BoolFlag - ProfileID string - ProfileName string - ProfileSaveChanges BoolFlag - ProxyID string - ProxyName string - ProxyMode string - Region string - PrivateHosts []string - StartURL string - Extensions []string - Viewport string - Telemetry string - TelemetryExport string - ChromePolicy string - ChromePolicyFile string - Name string - Tags map[string]string - Output string + TimeoutSeconds int + Stealth BoolFlag + Headless BoolFlag + GPU BoolFlag + Memory string + InvocationID string + Kiosk BoolFlag + ProfileID string + ProfileName string + ProfileSaveChanges BoolFlag + ProxyID string + ProxyName string + ProxyMode string + Region string + PrivateHosts []string + StartURL string + Extensions []string + Viewport string + Telemetry string + TelemetryCdpExclude string + TelemetryExport string + ChromePolicy string + ChromePolicyFile string + Name string + Tags map[string]string + Output string } type BrowsersDeleteInput struct { @@ -415,6 +416,7 @@ type BrowsersUpdateInput struct { Viewport string Force bool Telemetry string + TelemetryCdpExclude string Name string SetName bool ClearName bool @@ -669,8 +671,8 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } - if in.Telemetry != "" || in.TelemetryExport != "" { - t, err := buildNewTelemetryParam(in.Telemetry, in.TelemetryExport) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + t, err := buildNewTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport) if err != nil { return err } @@ -705,7 +707,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags) - if in.Telemetry != "" || in.TelemetryExport != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { printTelemetrySummary(browser.Telemetry) } return nil @@ -941,8 +943,8 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } // Validate that at least one update option is provided - if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && !hasNameChange && !hasTagsChange { - return fmt.Errorf("must specify at least one of: --proxy-id, --proxy-name, --proxy-mode, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, --viewport, --telemetry, --name, --clear-name, --tag, or --clear-tags") + if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && in.TelemetryCdpExclude == "" && !hasNameChange && !hasTagsChange { + return fmt.Errorf("must specify at least one of: --proxy-id, --proxy-name, --proxy-mode, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, --viewport, --telemetry, --telemetry-cdp-exclude, --name, --clear-name, --tag, or --clear-tags") } params := kernel.BrowserUpdateParams{} @@ -985,8 +987,8 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } // Handle telemetry changes - if in.Telemetry != "" { - t, err := buildUpdateTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { + t, err := buildUpdateTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) if err != nil { return err } @@ -1036,7 +1038,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { if hasProfileChange { pterm.Info.Printf("Profile save changes: %t\n", browser.ProfileSaveChanges) } - if in.Telemetry != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" { printTelemetrySummary(browser.Telemetry) } return nil @@ -2682,6 +2684,7 @@ func init() { browsersUpdateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60") browsersUpdateCmd.Flags().Bool("force", false, "Force viewport resize even when a live view or recording/replay is active") browsersUpdateCmd.Flags().String("telemetry", "", "Update telemetry: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection)") + browsersUpdateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") browsersUpdateCmd.Flags().String("name", "", "Set a new unique name for the browser session (mutually exclusive with --clear-name)") browsersUpdateCmd.Flags().Bool("clear-name", false, "Clear the browser session name") browsersUpdateCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE (repeatable; up to 50 pairs). Replaces the entire tag set; mutually exclusive with --clear-tags") @@ -2963,6 +2966,7 @@ func init() { browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") browsersCreateCmd.Flags().String("telemetry", "", "Configure telemetry (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") + browsersCreateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") browsersCreateCmd.Flags().String("telemetry-export-otlp", "", "Export captured telemetry over OTLP to one of the org's configured destinations, by ID or name; --telemetry-export-otlp=off disables export. Implies --telemetry=all when --telemetry is not set, since export requires capture") browsersCreateCmd.Flags().String("name", "", "Optional unique name for the browser session (used to find it later; can be changed with 'browsers update --name')") browsersCreateCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the session (repeatable; up to 50 pairs)") @@ -3094,6 +3098,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { poolID, _ := cmd.Flags().GetString("pool-id") poolName, _ := cmd.Flags().GetString("pool-name") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") name, _ := cmd.Flags().GetString("name") tags, _ := tagsFromFlag(cmd, "tag") @@ -3160,7 +3165,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("timeout") && timeout > 0 { acquireTimeout = int64(timeout) } - acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry, startURL) + acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry, telemetryCdpExclude, startURL) if err != nil { return err } @@ -3202,31 +3207,32 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { } in := BrowsersCreateInput{ - TimeoutSeconds: timeout, - Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, - Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, - GPU: BoolFlag{Set: cmd.Flags().Changed("gpu"), Value: gpuVal}, - Memory: memory, - InvocationID: invocationID, - Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kioskVal}, - ProfileID: profileID, - ProfileName: profileName, - ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, - ProxyID: proxyID, - ProxyName: proxyName, - ProxyMode: proxyMode, - Region: region, - PrivateHosts: privateHosts, - StartURL: startURL, - Extensions: extensions, - Viewport: viewport, - Telemetry: telemetry, - TelemetryExport: telemetryExport, - ChromePolicy: chromePolicy, - ChromePolicyFile: chromePolicyFile, - Name: name, - Tags: tags, - Output: output, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, + GPU: BoolFlag{Set: cmd.Flags().Changed("gpu"), Value: gpuVal}, + Memory: memory, + InvocationID: invocationID, + Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kioskVal}, + ProfileID: profileID, + ProfileName: profileName, + ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, + ProxyID: proxyID, + ProxyName: proxyName, + ProxyMode: proxyMode, + Region: region, + PrivateHosts: privateHosts, + StartURL: startURL, + Extensions: extensions, + Viewport: viewport, + Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, + TelemetryExport: telemetryExport, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, + Name: name, + Tags: tags, + Output: output, } svc := client.Browsers @@ -3288,6 +3294,7 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { viewport, _ := cmd.Flags().GetString("viewport") force, _ := cmd.Flags().GetBool("force") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") name, _ := cmd.Flags().GetString("name") clearName, _ := cmd.Flags().GetBool("clear-name") tags, tagsProvided := tagsFromFlag(cmd, "tag") @@ -3308,6 +3315,7 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { Viewport: viewport, Force: force, Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, Name: name, SetName: cmd.Flags().Changed("name"), ClearName: clearName, diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 9a37ef3..0369977 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -76,7 +76,9 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig case "interaction": p.Interaction = on() case "control": - p.Control = on() + p.Control = kernel.BrowserTelemetryControlConfigParam{Enabled: kernel.Opt(true)} + case "platform": + p.Platform = on() case "connection": p.Connection = on() case "system": @@ -92,20 +94,112 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig return p, nil } -// resolveTelemetryFlag interprets a --telemetry flag value shared by every browser -// and browser-pool command: "all" enables the default set, "off" disables capture, -// and a comma-separated list opts into exactly those categories. It returns the -// resolved (enabled, browser) pair so each endpoint can assemble its own param type. -func resolveTelemetryFlag(s string) (param.Opt[bool], kernel.BrowserTelemetryCategoriesConfigParam, error) { +// cdpCommandMethods are the browser-control commands the CDP proxy reports as +// cdp_command events, and so the values --telemetry-cdp-exclude accepts. +var cdpCommandMethods = []string{ + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent", + "Input.insertText", + "Input.imeSetComposition", + "Input.dispatchTouchEvent", + "Input.dispatchDragEvent", + "Input.cancelDragging", + "Input.emulateTouchFromMouseEvent", + "Input.synthesizePinchGesture", + "Input.synthesizeScrollGesture", + "Input.synthesizeTapGesture", + "DOM.setFileInputFiles", + "DOM.focus", + "DOM.scrollIntoViewIfNeeded", + "Page.bringToFront", + "Page.captureScreenshot", + "Page.captureSnapshot", + "Page.handleJavaScriptDialog", + "Page.navigate", + "Page.navigateToHistoryEntry", + "Page.reload", + "Page.printToPDF", + "Page.startScreencast", + "Page.stopScreencast", + "Page.stopLoading", + "Page.close", + "Page.setWebLifecycleState", + "Target.activateTarget", + "Target.closeTarget", + "Target.createTarget", + "Target.createBrowserContext", + "Target.disposeBrowserContext", + "Target.openDevTools", + "Browser.cancelDownload", + "Browser.close", + "Browser.setWindowBounds", + "Browser.setContentsSize", + "Autofill.trigger", +} + +// telemetryCdpExcludeNone is the --telemetry-cdp-exclude value that clears the +// exclusion list rather than naming methods to drop. +const telemetryCdpExcludeNone = "none" + +// parseTelemetryCdpExcludedMethods parses a --telemetry-cdp-exclude value into the +// exclusion list carried by the control category. "none" resolves to an empty list, +// which tells the API to report every supported method again. Method names are +// matched case-insensitively and returned in their canonical CDP spelling. +func parseTelemetryCdpExcludedMethods(s string) ([]kernel.BrowserCdpCommandMethod, error) { + methods := []kernel.BrowserCdpCommandMethod{} + if strings.TrimSpace(s) == telemetryCdpExcludeNone { + return methods, nil + } + for _, part := range strings.Split(s, ",") { + name := strings.TrimSpace(part) + if name == "" { + continue + } + i := slices.IndexFunc(cdpCommandMethods, func(m string) bool { return strings.EqualFold(m, name) }) + if i < 0 { + return nil, fmt.Errorf("unknown CDP method %q: must be one of %s, or %q to clear the exclusion list", name, strings.Join(cdpCommandMethods, ", "), telemetryCdpExcludeNone) + } + methods = append(methods, kernel.BrowserCdpCommandMethod(cdpCommandMethods[i])) + } + return methods, nil +} + +// resolveTelemetryFlag interprets the --telemetry and --telemetry-cdp-exclude flag +// values shared by every browser and browser-pool command: "all" enables the default +// set, "off" disables capture, and a comma-separated list opts into exactly those +// categories. Excluded CDP methods are merged into the control category independently +// of the selection, so they survive a later update that only names categories. It +// returns the resolved (enabled, browser) pair so each endpoint can assemble its own +// param type. +func resolveTelemetryFlag(s, cdpExclude string) (param.Opt[bool], kernel.BrowserTelemetryCategoriesConfigParam, error) { + var enabled param.Opt[bool] + var p kernel.BrowserTelemetryCategoriesConfigParam switch s { case "all": - return kernel.Opt(true), kernel.BrowserTelemetryCategoriesConfigParam{}, nil + enabled = kernel.Opt(true) case "off": - return kernel.Opt(false), kernel.BrowserTelemetryCategoriesConfigParam{}, nil + enabled = kernel.Opt(false) default: - p, err := parseTelemetryCategories(s) - return param.Opt[bool]{}, p, err + var err error + if p, err = parseTelemetryCategories(s); err != nil { + return enabled, p, err + } + } + if cdpExclude == "" { + return enabled, p, nil } + // Exclusion is a control-telemetry setting, so it has no meaning in a request + // that turns capture off. Error messages never lead with a flag token — the + // error style title-cases the first word. + if s == "off" { + return enabled, p, fmt.Errorf("cannot combine --telemetry=off with --telemetry-cdp-exclude: excluding CDP methods only applies while control telemetry is captured") + } + methods, err := parseTelemetryCdpExcludedMethods(cdpExclude) + if err != nil { + return enabled, p, err + } + p.Control.Cdp.ExcludedMethods = methods + return enabled, p, nil } // telemetryExportOff is the --telemetry-export-otlp value that turns export off @@ -167,10 +261,10 @@ func validateTelemetryExportCombo(telemetry, id, name string, canImply bool) err return nil } -// buildNewTelemetryParam converts --telemetry and --telemetry-export-otlp flag -// values to the create API param. -func buildNewTelemetryParam(s, export string) (kernel.BrowserNewParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) +// buildNewTelemetryParam converts --telemetry, --telemetry-cdp-exclude and +// --telemetry-export-otlp flag values to the create API param. +func buildNewTelemetryParam(s, cdpExclude, export string) (kernel.BrowserNewParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) p := kernel.BrowserNewParamsTelemetry{Enabled: enabled, Browser: browser} if err != nil || export == "" { return p, err @@ -207,26 +301,37 @@ func optIfSet(s string) param.Opt[string] { return kernel.Opt(s) } -// buildUpdateTelemetryParam converts a --telemetry flag value to the update API param. -func buildUpdateTelemetryParam(s string) (kernel.BrowserUpdateParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s) +// buildUpdateTelemetryParam converts --telemetry and --telemetry-cdp-exclude flag +// values to the update API param. +func buildUpdateTelemetryParam(s, cdpExclude string) (kernel.BrowserUpdateParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) return kernel.BrowserUpdateParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// buildManagedAuthTelemetryParam converts --telemetry and --telemetry-export-otlp -// flag values to the browser telemetry config carried by an auth connection's -// browser settings, shared by create, update, and login. +// buildManagedAuthTelemetryParam converts --telemetry, --telemetry-cdp-exclude and +// --telemetry-export-otlp flag values to the browser telemetry config carried by an +// auth connection's browser settings, shared by create, update, and login. // // canImply is true only on create, where there is no stored selection to clobber // and capture can safely be turned on for the user so a destination works on its // own. On update and login it is false: enabling capture there would replace the // connection's current category selection rather than merge onto it. -func buildManagedAuthTelemetryParam(s, export string, canImply bool) (kernel.ManagedAuthBrowserConfigTelemetryParam, error) { - enabled, browser, err := resolveTelemetryFlag(s) +func buildManagedAuthTelemetryParam(s, cdpExclude, export string, canImply bool) (kernel.ManagedAuthBrowserConfigTelemetryParam, error) { + enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) p := kernel.ManagedAuthBrowserConfigTelemetryParam{Enabled: enabled, Browser: browser} - if err != nil || export == "" { + if err != nil { return p, err } + // A connection stores the browser config as sent rather than resolving it, so a + // request carrying only CDP exclusions would drop the connection's category + // selection. On update and login the user has to restate what to capture; on + // create there is nothing to lose. + if cdpExclude != "" && s == "" && !canImply { + return p, fmt.Errorf("setting --telemetry-cdp-exclude also requires --telemetry in the same command: the connection stores its browser config as sent, so exclusions on their own would drop its category selection") + } + if export == "" { + return p, nil + } exEnabled, id, name, err := resolveTelemetryExportFlag(export) if err != nil { return p, err @@ -264,6 +369,9 @@ func formatManagedAuthTelemetry(cfg kernel.ManagedAuthBrowserConfigTelemetry) st } return "disabled" }() + if ex := formatCdpExcludedMethods(cfg.Browser.Control.Cdp.ExcludedMethods); ex != "" { + base += " (excluding CDP methods: " + ex + ")" + } if dest := managedAuthExportDestination(cfg.Export); dest != "" { return base + " (exporting to " + dest + ")" } @@ -287,7 +395,7 @@ func managedAuthExportDestination(ex kernel.ManagedAuthBrowserConfigTelemetryExp // flows automatically whenever a CDP category is captured. var settableCategories = []string{ "console", "network", "page", "interaction", - "control", "connection", "system", "screenshot", "captcha", + "control", "connection", "system", "screenshot", "platform", "captcha", } // streamFilterCategories are the categories accepted by `telemetry stream --categories`. @@ -310,6 +418,7 @@ func telemetryEnabledCategories(cfg kernel.BrowserTelemetryConfig) []string { {"connection", b.Connection.Enabled}, {"system", b.System.Enabled}, {"screenshot", b.Screenshot.Enabled}, + {"platform", b.Platform.Enabled}, {"captcha", b.Captcha.Enabled}, } on := make([]string, 0, len(ordered)) @@ -330,6 +439,9 @@ func printTelemetrySummary(cfg kernel.BrowserTelemetryConfig) { return } pterm.Info.Printf("Telemetry capturing: %s\n", strings.Join(on, ", ")) + if ex := formatCdpExcludedMethods(cfg.Browser.Control.Cdp.ExcludedMethods); ex != "" { + pterm.Info.Printf("Telemetry excluding CDP methods: %s\n", ex) + } if cfg.Export.Otlp.Enabled { // The response reports the resolved destination by ID even when the request // selected it by name. @@ -341,6 +453,19 @@ func printTelemetrySummary(cfg kernel.BrowserTelemetryConfig) { } } +// formatCdpExcludedMethods renders the CDP methods left out of control +// telemetry's cdp_command stream, or "" when every supported method is reported. +func formatCdpExcludedMethods(methods []kernel.BrowserCdpCommandMethod) string { + if len(methods) == 0 { + return "" + } + names := make([]string, 0, len(methods)) + for _, m := range methods { + names = append(names, string(m)) + } + return strings.Join(names, ", ") +} + // shouldEmit applies client-side category/type filters to a telemetry event. func shouldEmit(category, eventType string, categories, types []string) bool { if len(categories) > 0 && !slices.Contains(categories, category) { diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index fe3b88e..d9458bf 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -299,14 +299,17 @@ func TestShouldEmit(t *testing.T) { } func TestParseTelemetryCategories_OptInList(t *testing.T) { - p, err := parseTelemetryCategories("network,control,captcha") + p, err := parseTelemetryCategories("network,control,captcha,platform") assert.NoError(t, err) // Listed categories are enabled. - for _, c := range []kernel.BrowserTelemetryCategoryConfigParam{p.Network, p.Control, p.Captcha} { + for _, c := range []kernel.BrowserTelemetryCategoryConfigParam{p.Network, p.Captcha, p.Platform} { assert.True(t, c.Enabled.Valid()) assert.True(t, c.Enabled.Value) } + // Control carries its own config type, so it is checked separately. + assert.True(t, p.Control.Enabled.Valid()) + assert.True(t, p.Control.Enabled.Value) // Unlisted categories are omitted (opt-in: the instance treats them as off). assert.False(t, p.Console.Enabled.Valid()) assert.False(t, p.Page.Enabled.Valid()) @@ -336,21 +339,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()) @@ -366,7 +369,7 @@ func TestBuildTelemetryParam_WireEncoding(t *testing.T) { // enabled=false combined with one. func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { t.Run("destination by CUID sets id", func(t *testing.T) { - p, err := buildNewTelemetryParam("", "abcdefghijklmnopqrstuvwx") + p, err := buildNewTelemetryParam("", "", "abcdefghijklmnopqrstuvwx") assert.NoError(t, err) otlp := p.Export.Otlp assert.True(t, otlp.Destination.ID.Valid()) @@ -375,7 +378,7 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { assert.False(t, otlp.Enabled.Valid(), "a destination implies enabled server-side") }) t.Run("destination by name sets name", func(t *testing.T) { - p, err := buildNewTelemetryParam("", "my-collector") + p, err := buildNewTelemetryParam("", "", "my-collector") assert.NoError(t, err) otlp := p.Export.Otlp assert.True(t, otlp.Destination.Name.Valid()) @@ -383,20 +386,20 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { assert.False(t, otlp.Destination.ID.Valid(), "id must be unset when name is sent") }) t.Run("destination implies capture on create", func(t *testing.T) { - p, err := buildNewTelemetryParam("", "my-collector") + p, err := buildNewTelemetryParam("", "", "my-collector") assert.NoError(t, err) assert.True(t, p.Enabled.Valid(), "export requires capture, so create implies it") assert.True(t, p.Enabled.Value) }) t.Run("explicit --telemetry selection is preserved", func(t *testing.T) { - p, err := buildNewTelemetryParam("network,control", "my-collector") + p, err := buildNewTelemetryParam("network,control", "", "my-collector") assert.NoError(t, err) assert.False(t, p.Enabled.Valid(), "an opt-in selection must not be overridden") assert.True(t, p.Browser.Network.Enabled.Value) assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) }) t.Run("off disables export without a destination", func(t *testing.T) { - p, err := buildNewTelemetryParam("all", "off") + p, err := buildNewTelemetryParam("all", "", "off") assert.NoError(t, err) otlp := p.Export.Otlp assert.True(t, otlp.Enabled.Valid()) @@ -405,7 +408,7 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { assert.False(t, otlp.Destination.Name.Valid()) }) t.Run("off does not imply capture", func(t *testing.T) { - p, err := buildNewTelemetryParam("", "off") + p, err := buildNewTelemetryParam("", "", "off") assert.NoError(t, err) assert.False(t, p.Enabled.Valid(), "disabling export must not turn capture on") }) @@ -414,44 +417,44 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { // same request. Update and login refuse to supply one: doing so would replace // the connection's current category selection. t.Run("update requires an explicit --telemetry alongside a destination", func(t *testing.T) { - _, err := buildManagedAuthTelemetryParam("", "my-collector", false) + _, err := buildManagedAuthTelemetryParam("", "", "my-collector", false) assert.Error(t, err) assert.Contains(t, err.Error(), "also requires --telemetry") }) t.Run("login requires an explicit --telemetry alongside a destination", func(t *testing.T) { - _, err := buildManagedAuthTelemetryParam("", "my-collector", false) + _, err := buildManagedAuthTelemetryParam("", "", "my-collector", false) assert.Error(t, err) assert.Contains(t, err.Error(), "also requires --telemetry") }) t.Run("update never implies capture when --telemetry is given", func(t *testing.T) { - p, err := buildManagedAuthTelemetryParam("console", "my-collector", false) + p, err := buildManagedAuthTelemetryParam("console", "", "my-collector", false) assert.NoError(t, err) assert.False(t, p.Enabled.Valid(), "an opt-in selection must not be widened to the default set") assert.True(t, p.Browser.Console.Enabled.Value) assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) }) t.Run("login never implies capture when --telemetry is given", func(t *testing.T) { - p, err := buildManagedAuthTelemetryParam("console", "my-collector", false) + p, err := buildManagedAuthTelemetryParam("console", "", "my-collector", false) assert.NoError(t, err) assert.False(t, p.Enabled.Valid()) assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) }) t.Run("update and login allow export=off without --telemetry", func(t *testing.T) { - u, err := buildManagedAuthTelemetryParam("", "off", false) + u, err := buildManagedAuthTelemetryParam("", "", "off", false) assert.NoError(t, err) assert.False(t, u.Export.Otlp.Enabled.Value) - l, err := buildManagedAuthTelemetryParam("", "off", false) + l, err := buildManagedAuthTelemetryParam("", "", "off", false) assert.NoError(t, err) assert.False(t, l.Export.Otlp.Enabled.Value) }) t.Run("auth connection create implies capture", func(t *testing.T) { - p, err := buildManagedAuthTelemetryParam("", "my-collector", true) + p, err := buildManagedAuthTelemetryParam("", "", "my-collector", true) assert.NoError(t, err) assert.True(t, p.Enabled.Valid()) assert.True(t, p.Enabled.Value) }) t.Run("invalid category still errors with export set", func(t *testing.T) { - _, err := buildNewTelemetryParam("bogus", "my-collector") + _, err := buildNewTelemetryParam("bogus", "", "my-collector") assert.Error(t, err) }) t.Run("telemetry=off with a destination is rejected", func(t *testing.T) { @@ -459,9 +462,9 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { name string fn func() error }{ - {"create", func() error { _, e := buildNewTelemetryParam("off", "my-collector"); return e }}, - {"auth create", func() error { _, e := buildManagedAuthTelemetryParam("off", "my-collector", true); return e }}, - {"auth update/login", func() error { _, e := buildManagedAuthTelemetryParam("off", "my-collector", false); return e }}, + {"create", func() error { _, e := buildNewTelemetryParam("off", "", "my-collector"); return e }}, + {"auth create", func() error { _, e := buildManagedAuthTelemetryParam("off", "", "my-collector", true); return e }}, + {"auth update/login", func() error { _, e := buildManagedAuthTelemetryParam("off", "", "my-collector", false); return e }}, } { err := tc.fn() assert.Error(t, err, tc.name) @@ -469,13 +472,13 @@ func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { } }) t.Run("telemetry=off with export=off is allowed", func(t *testing.T) { - p, err := buildNewTelemetryParam("off", "off") + p, err := buildNewTelemetryParam("off", "", "off") assert.NoError(t, err) assert.False(t, p.Enabled.Value) assert.False(t, p.Export.Otlp.Enabled.Value) }) t.Run("empty export value errors", func(t *testing.T) { - _, err := buildNewTelemetryParam("all", " ") + _, err := buildNewTelemetryParam("all", "", " ") assert.Error(t, err) }) } @@ -718,3 +721,82 @@ func TestTelemetryEvents_FullScanIgnoresOffsetUsesSince(t *testing.T) { assert.Equal(t, "5m", gotQuery.Since.Value, "--all walks the window from --since") _ = buf } + +func TestParseTelemetryCategories_Platform(t *testing.T) { + p, err := parseTelemetryCategories("platform") + + assert.NoError(t, err) + assert.True(t, p.Platform.Enabled.Valid()) + assert.True(t, p.Platform.Enabled.Value) + // platform is opt-in only, so it must be offered by the flag's error message too. + _, err = parseTelemetryCategories("bogus") + assert.ErrorContains(t, err, "platform") +} + +func TestTelemetryEnabledCategories_Platform(t *testing.T) { + cfg := kernel.BrowserTelemetryConfig{Browser: kernel.BrowserTelemetryCategoriesConfig{}} + cfg.Browser.Platform.Enabled = true + + assert.Equal(t, []string{"platform"}, telemetryEnabledCategories(cfg)) +} + +func TestParseTelemetryCdpExcludedMethods(t *testing.T) { + t.Run("canonicalizes and trims", func(t *testing.T) { + got, err := parseTelemetryCdpExcludedMethods(" input.dispatchmouseevent , Page.captureScreenshot ") + assert.NoError(t, err) + assert.Equal(t, []kernel.BrowserCdpCommandMethod{ + kernel.BrowserCdpCommandMethodInputDispatchMouseEvent, + kernel.BrowserCdpCommandMethodPageCaptureScreenshot, + }, got) + }) + t.Run("none clears the list", func(t *testing.T) { + got, err := parseTelemetryCdpExcludedMethods("none") + assert.NoError(t, err) + assert.NotNil(t, got, "an empty list must still be sent, so the API reports every method again") + assert.Empty(t, got) + }) + t.Run("rejects unknown methods", func(t *testing.T) { + _, err := parseTelemetryCdpExcludedMethods("Page.doesNotExist") + assert.ErrorContains(t, err, "unknown CDP method") + }) +} + +func TestBuildTelemetryParam_CdpExclude(t *testing.T) { + t.Run("merges into control without enabling it", func(t *testing.T) { + p, err := buildNewTelemetryParam("", "Input.dispatchMouseEvent", "") + assert.NoError(t, err) + assert.False(t, p.Enabled.Valid()) + assert.False(t, p.Browser.Control.Enabled.Valid(), "exclusions must not silently flip the control category") + assert.Equal(t, []kernel.BrowserCdpCommandMethod{ + kernel.BrowserCdpCommandMethodInputDispatchMouseEvent, + }, p.Browser.Control.Cdp.ExcludedMethods) + }) + t.Run("combines with a category selection", func(t *testing.T) { + p, err := buildUpdateTelemetryParam("control,network", "Page.captureScreenshot") + assert.NoError(t, err) + assert.True(t, p.Browser.Control.Enabled.Value) + assert.Equal(t, []kernel.BrowserCdpCommandMethod{ + kernel.BrowserCdpCommandMethodPageCaptureScreenshot, + }, p.Browser.Control.Cdp.ExcludedMethods) + }) + t.Run("rejects combining with telemetry off", func(t *testing.T) { + _, err := buildNewTelemetryParam("off", "Page.captureScreenshot", "") + assert.ErrorContains(t, err, "cannot combine --telemetry=off with --telemetry-cdp-exclude") + }) +} + +func TestBuildManagedAuthTelemetryParam_CdpExcludeNeedsCategories(t *testing.T) { + // The connection stores the config verbatim, so exclusions on their own would + // replace its category selection — allowed on create, rejected on update/login. + _, err := buildManagedAuthTelemetryParam("", "Page.navigate", "", false) + assert.ErrorContains(t, err, "also requires --telemetry in the same command") + + p, err := buildManagedAuthTelemetryParam("", "Page.navigate", "", true) + assert.NoError(t, err) + assert.Equal(t, []kernel.BrowserCdpCommandMethod{ + kernel.BrowserCdpCommandMethodPageNavigate, + }, p.Browser.Control.Cdp.ExcludedMethods) + + _, err = buildManagedAuthTelemetryParam("control", "Page.navigate", "", false) + assert.NoError(t, err) +} diff --git a/go.mod b/go.mod index 502421b..4e7fd39 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.92.0 + github.com/kernel/kernel-go-sdk v0.94.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 04679b8..f0e1951 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.92.0 h1:3EeoPahTcGEo97BCbwT50gu8QJnawfL166z12hc8Ucg= -github.com/kernel/kernel-go-sdk v0.92.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.94.0 h1:YepYs5dadsnZq+gI5Ic4+ky4IIWNpm/791S6YFK2u0Y= +github.com/kernel/kernel-go-sdk v0.94.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=