From 9cf24318fab8b6c38e54c143242ae353093babeb Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:47:32 +0000 Subject: [PATCH 01/51] CLI: Update SDK to 0a28735 and add org entitlements command Bump github.com/kernel/kernel-go-sdk to v0.91.1-0.20260817203807-0a287359dcc5 (0a28735). Coverage gap found by enumerating all 140 methods in the SDK's api.md against the CLI command tree: the new Organization.Entitlements resource had no CLI surface. Everything else was already covered. New command: - `kernel org entitlements get` for client.Organization.Entitlements.Get (GET /org/entitlements). Renders Plan, Features, and Limits sections; supports --output json. Null constraint values mean unlimited in this API, and the SDK models them as non-pointer int64, so rendering keys off respjson field validity rather than the zero value. Tested against the real API: - kernel org entitlements get (table output, ENTERPRISE plan) - kernel org entitlements get --output json - kernel org entitlements get --output yaml (rejected as expected) - go build ./... and go test ./... pass, including 5 new unit tests covering populated constraints, null-as-unlimited, null plan fields, invalid --output, and API errors. Co-Authored-By: Claude Opus 5 --- README.md | 2 + cmd/org.go | 157 +++++++++++++++++++++++++++++++++++++++++++++++- cmd/org_test.go | 148 +++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 5 files changed, 308 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 96eed04e..64611f30 100644 --- a/README.md +++ b/README.md @@ -729,6 +729,8 @@ Automated authentication for web services. The `run` command orchestrates the fu - `kernel org limits set` - Set the default per-project concurrency cap applied to projects without an explicit override - `--default-project-max-concurrent-sessions ` - Default maximum concurrent browsers for projects without an explicit override (`0` to remove the default) - `--output json`, `-o json` - Output raw JSON object +- `kernel org entitlements get` - Show the organization's effective feature access and constraints after applying its plan, active trial treatment, plan status, and organization-specific overrides; unlimited constraints are shown as `unlimited` + - `--output json`, `-o json` - Output raw JSON object ## Examples diff --git a/cmd/org.go b/cmd/org.go index ffd8c02b..790087bc 100644 --- a/cmd/org.go +++ b/cmd/org.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "time" "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" @@ -10,6 +11,7 @@ import ( "github.com/kernel/kernel-go-sdk/packages/param" "github.com/kernel/kernel-go-sdk/packages/respjson" "github.com/pterm/pterm" + "github.com/samber/lo" "github.com/spf13/cobra" ) @@ -19,14 +21,24 @@ type OrgLimitsService interface { Update(ctx context.Context, body kernel.OrganizationLimitUpdateParams, opts ...option.RequestOption) (res *kernel.OrgLimits, err error) } +// OrgEntitlementsService defines the subset of the Kernel SDK organization entitlements client that we use. +type OrgEntitlementsService interface { + Get(ctx context.Context, opts ...option.RequestOption) (res *kernel.OrgEntitlements, err error) +} + type OrgCmd struct { - limits OrgLimitsService + limits OrgLimitsService + entitlements OrgEntitlementsService } type OrgLimitsGetInput struct { Output string } +type OrgEntitlementsGetInput struct { + Output string +} + type OrgLimitsSetInput struct { DefaultProjectMaxConcurrentSessions Int64Flag Output string @@ -88,6 +100,118 @@ func (c OrgCmd) LimitsSet(ctx context.Context, in OrgLimitsSetInput) error { return nil } +func (c OrgCmd) EntitlementsGet(ctx context.Context, in OrgEntitlementsGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + entitlements, err := c.entitlements.Get(ctx) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + if entitlements == nil { + fmt.Println("null") + return nil + } + return util.PrintPrettyJSON(entitlements) + } + + renderOrgEntitlements(entitlements) + return nil +} + +func renderOrgEntitlements(ent *kernel.OrgEntitlements) { + if ent == nil { + pterm.Info.Println("No organization entitlements found") + return + } + + plan := ent.Plan + planRows := pterm.TableData{ + {"Field", "Value"}, + {"Plan", plan.ID}, + // Active trials resolve to a different effective plan than the + // contractual one, so show both. + {"Effective Plan", plan.EffectiveID}, + {"Trialing", lo.Ternary(plan.IsTrialing, "yes", "no")}, + // Billing status and trial end are both nullable. + {"Billing Status", formatOrgEntitlementString(plan.Status, plan.JSON.Status)}, + {"Trial Ends At", formatOrgEntitlementTime(plan.TrialEndsAt, plan.JSON.TrialEndsAt)}, + } + pterm.DefaultSection.Println("Plan") + PrintTableNoPad(planRows, true) + + f := ent.Features + featureRows := pterm.TableData{ + {"Feature", "Enabled", "Constraints"}, + {"Browser Extensions", formatOrgEntitlementEnabled(f.BrowserExtensions.Enabled), fmt.Sprintf("max stored per org: %s", formatProjectLimitValue(f.BrowserExtensions.MaxStoredPerOrg, f.BrowserExtensions.JSON.MaxStoredPerOrg))}, + {"Browser Pools", formatOrgEntitlementEnabled(f.BrowserPools.Enabled), ""}, + {"Browser Replays", formatOrgEntitlementEnabled(f.BrowserReplays.Enabled), fmt.Sprintf("retention: %s", formatOrgEntitlementDays(f.BrowserReplays.RetentionDays, f.BrowserReplays.JSON.RetentionDays))}, + {"Credential Providers", formatOrgEntitlementEnabled(f.CredentialProviders.Enabled), ""}, + {"Credentials", formatOrgEntitlementEnabled(f.Credentials.Enabled), ""}, + {"Custom Proxies", formatOrgEntitlementEnabled(f.CustomProxies.Enabled), ""}, + {"File I/O", formatOrgEntitlementEnabled(f.FileIo.Enabled), ""}, + {"GPU", formatOrgEntitlementEnabled(f.GPU.Enabled), ""}, + {"Managed Auth", formatOrgEntitlementEnabled(f.ManagedAuth.Enabled), formatManagedAuthConstraints(f.ManagedAuth)}, + {"Managed Proxies", formatOrgEntitlementEnabled(f.ManagedProxies.Enabled), ""}, + {"Profiles", formatOrgEntitlementEnabled(f.Profiles.Enabled), ""}, + {"Proxy Bypass Hosts", formatOrgEntitlementEnabled(f.ProxyBypassHosts.Enabled), ""}, + } + pterm.DefaultSection.Println("Features") + PrintTableNoPad(featureRows, true) + + l := ent.Limits + limitRows := pterm.TableData{ + {"Limit", "Value"}, + {"Max Concurrent Browsers", formatProjectLimitValue(l.MaxConcurrentBrowsers, l.JSON.MaxConcurrentBrowsers)}, + {"Max Concurrent Invocations", formatProjectLimitValue(l.MaxConcurrentInvocations, l.JSON.MaxConcurrentInvocations)}, + {"Default Max Concurrent Invocations Per App", formatProjectLimitValue(l.DefaultMaxConcurrentInvocationsPerApp, l.JSON.DefaultMaxConcurrentInvocationsPerApp)}, + } + pterm.DefaultSection.Println("Limits") + PrintTableNoPad(limitRows, true) +} + +func formatOrgEntitlementEnabled(enabled bool) string { + return lo.Ternary(enabled, "yes", "no") +} + +// formatManagedAuthConstraints summarizes the managed auth connection cap and the +// accepted health-check interval window in a single cell. +func formatManagedAuthConstraints(ma kernel.OrgEntitlementsFeaturesManagedAuth) string { + return fmt.Sprintf( + "max connections: %s, health check interval: %ds default (%ds-%ds)", + formatProjectLimitValue(ma.MaxConnections, ma.JSON.MaxConnections), + ma.HealthCheckIntervalDefaultSeconds, + ma.HealthCheckIntervalMinSeconds, + ma.HealthCheckIntervalMaxSeconds, + ) +} + +// formatOrgEntitlementDays renders a retention window, treating a null value as +// unlimited retention rather than zero days. +func formatOrgEntitlementDays(value int64, field respjson.Field) string { + if !field.Valid() { + return "unlimited" + } + return fmt.Sprintf("%d days", value) +} + +func formatOrgEntitlementString(value string, field respjson.Field) string { + if !field.Valid() || value == "" { + return "-" + } + return value +} + +func formatOrgEntitlementTime(value time.Time, field respjson.Field) string { + if !field.Valid() || value.IsZero() { + return "-" + } + return util.FormatLocal(value) +} + func renderOrgLimits(limits *kernel.OrgLimits) { if limits == nil { pterm.Info.Println("No organization limits found") @@ -144,6 +268,22 @@ var orgLimitsCmd = &cobra.Command{ }, } +var orgEntitlementsCmd = &cobra.Command{ + Use: "entitlements", + Short: "Read organization entitlements", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +var orgEntitlementsGetCmd = &cobra.Command{ + Use: "get", + Short: "Get organization entitlements", + Long: "Show the organization's effective feature access and constraints after applying its plan, active trial treatment, plan status, and organization-specific overrides. Unlimited constraints are shown as \"unlimited\".", + Args: cobra.NoArgs, + RunE: runOrgEntitlementsGet, +} + var orgLimitsGetCmd = &cobra.Command{ Use: "get", Short: "Get organization limits", @@ -162,7 +302,16 @@ var orgLimitsSetCmd = &cobra.Command{ func getOrgHandler(cmd *cobra.Command) OrgCmd { client := getKernelClient(cmd) - return OrgCmd{limits: &client.Organization.Limits} + return OrgCmd{ + limits: &client.Organization.Limits, + entitlements: &client.Organization.Entitlements, + } +} + +func runOrgEntitlementsGet(cmd *cobra.Command, args []string) error { + c := getOrgHandler(cmd) + output, _ := cmd.Flags().GetString("output") + return c.EntitlementsGet(cmd.Context(), OrgEntitlementsGetInput{Output: output}) } func runOrgLimitsGet(cmd *cobra.Command, args []string) error { @@ -189,7 +338,11 @@ func init() { orgLimitsSetCmd.Flags().Int64("default-project-max-concurrent-sessions", 0, "Default maximum concurrent browsers for projects without an explicit override (0 to remove the default)") addJSONOutputFlag(orgLimitsSetCmd) + addJSONOutputFlag(orgEntitlementsGetCmd) + orgLimitsCmd.AddCommand(orgLimitsGetCmd) orgLimitsCmd.AddCommand(orgLimitsSetCmd) + orgEntitlementsCmd.AddCommand(orgEntitlementsGetCmd) orgCmd.AddCommand(orgLimitsCmd) + orgCmd.AddCommand(orgEntitlementsCmd) } diff --git a/cmd/org_test.go b/cmd/org_test.go index 946800b0..ea2110db 100644 --- a/cmd/org_test.go +++ b/cmd/org_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" @@ -177,3 +178,150 @@ func TestOrgLimitsSet_RejectsNegative(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "must be non-negative") } + +type FakeOrgEntitlementsService struct { + GetFunc func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) +} + +func (f *FakeOrgEntitlementsService) Get(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, opts...) + } + return &kernel.OrgEntitlements{}, nil +} + +// populatedEntitlements builds an entitlements payload with every nullable field +// present, so renders exercise the non-"unlimited" branches. +func populatedEntitlements() *kernel.OrgEntitlements { + ent := &kernel.OrgEntitlements{} + + ent.Plan.ID = "START_UP" + ent.Plan.EffectiveID = "START_UP" + ent.Plan.IsTrialing = true + ent.Plan.Status = "ACTIVE" + ent.Plan.TrialEndsAt = time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + ent.Plan.JSON.Status = respjson.NewField(`"ACTIVE"`) + ent.Plan.JSON.TrialEndsAt = respjson.NewField(`"2030-01-02T03:04:05Z"`) + + ent.Features.BrowserExtensions.Enabled = true + ent.Features.BrowserExtensions.MaxStoredPerOrg = 25 + ent.Features.BrowserExtensions.JSON.MaxStoredPerOrg = respjson.NewField("25") + ent.Features.BrowserPools.Enabled = true + ent.Features.BrowserReplays.Enabled = true + ent.Features.BrowserReplays.RetentionDays = 7 + ent.Features.BrowserReplays.JSON.RetentionDays = respjson.NewField("7") + ent.Features.CredentialProviders.Enabled = true + ent.Features.Credentials.Enabled = true + ent.Features.CustomProxies.Enabled = false + ent.Features.FileIo.Enabled = true + ent.Features.GPU.Enabled = false + ent.Features.ManagedAuth.Enabled = true + ent.Features.ManagedAuth.MaxConnections = 10 + ent.Features.ManagedAuth.HealthCheckIntervalDefaultSeconds = 600 + ent.Features.ManagedAuth.HealthCheckIntervalMinSeconds = 300 + ent.Features.ManagedAuth.HealthCheckIntervalMaxSeconds = 86400 + ent.Features.ManagedAuth.JSON.MaxConnections = respjson.NewField("10") + ent.Features.ManagedProxies.Enabled = true + ent.Features.Profiles.Enabled = true + ent.Features.ProxyBypassHosts.Enabled = true + + ent.Limits.MaxConcurrentBrowsers = 50 + ent.Limits.MaxConcurrentInvocations = 20 + ent.Limits.DefaultMaxConcurrentInvocationsPerApp = 5 + ent.Limits.JSON.MaxConcurrentBrowsers = respjson.NewField("50") + ent.Limits.JSON.MaxConcurrentInvocations = respjson.NewField("20") + ent.Limits.JSON.DefaultMaxConcurrentInvocationsPerApp = respjson.NewField("5") + + return ent +} + +func TestOrgEntitlementsGet_RendersPlanFeaturesAndLimits(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgEntitlementsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { + return populatedEntitlements(), nil + }, + } + c := OrgCmd{entitlements: fake} + assert.NoError(t, c.EntitlementsGet(context.Background(), OrgEntitlementsGetInput{})) + + out := buf.String() + // Plan section + assert.Contains(t, out, "START_UP") + assert.Contains(t, out, "Effective Plan") + assert.Contains(t, out, "Trialing") + assert.Contains(t, out, "ACTIVE") + // Features section — every feature should get a row. + for _, feature := range []string{ + "Browser Extensions", "Browser Pools", "Browser Replays", "Credential Providers", + "Credentials", "Custom Proxies", "File I/O", "GPU", "Managed Auth", + "Managed Proxies", "Profiles", "Proxy Bypass Hosts", + } { + assert.Contains(t, out, feature) + } + assert.Contains(t, out, "max stored per org: 25") + assert.Contains(t, out, "retention: 7 days") + assert.Contains(t, out, "max connections: 10") + assert.Contains(t, out, "600s default (300s-86400s)") + // Limits section + assert.Contains(t, out, "Max Concurrent Browsers") + assert.Contains(t, out, "Max Concurrent Invocations") + assert.Contains(t, out, "Default Max Concurrent Invocations Per App") +} + +func TestOrgEntitlementsGet_NullConstraintsShownAsUnlimited(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgEntitlementsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { + ent := populatedEntitlements() + // Null (not omitted) constraints mean unlimited. + ent.Features.BrowserExtensions.JSON.MaxStoredPerOrg = respjson.NewField(respjson.Null) + ent.Features.ManagedAuth.JSON.MaxConnections = respjson.NewField(respjson.Null) + ent.Limits.JSON.MaxConcurrentBrowsers = respjson.NewField(respjson.Null) + return ent, nil + }, + } + c := OrgCmd{entitlements: fake} + assert.NoError(t, c.EntitlementsGet(context.Background(), OrgEntitlementsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "max stored per org: unlimited") + assert.Contains(t, out, "max connections: unlimited") + assert.Contains(t, out, "unlimited") +} + +func TestOrgEntitlementsGet_NullPlanFieldsShownAsDash(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgEntitlementsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { + ent := populatedEntitlements() + ent.Plan.IsTrialing = false + ent.Plan.JSON.Status = respjson.NewField(respjson.Null) + ent.Plan.JSON.TrialEndsAt = respjson.NewField(respjson.Null) + return ent, nil + }, + } + c := OrgCmd{entitlements: fake} + assert.NoError(t, c.EntitlementsGet(context.Background(), OrgEntitlementsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Billing Status") + assert.Contains(t, out, "Trial Ends At") + assert.NotContains(t, out, "ACTIVE") +} + +func TestOrgEntitlementsGet_RejectsUnknownOutput(t *testing.T) { + c := OrgCmd{entitlements: &FakeOrgEntitlementsService{}} + assert.Error(t, c.EntitlementsGet(context.Background(), OrgEntitlementsGetInput{Output: "yaml"})) +} + +func TestOrgEntitlementsGet_SurfacesAPIError(t *testing.T) { + capturePtermOutput(t) + fake := &FakeOrgEntitlementsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { + return nil, errors.New("boom") + }, + } + c := OrgCmd{entitlements: fake} + assert.Error(t, c.EntitlementsGet(context.Background(), OrgEntitlementsGetInput{})) +} diff --git a/go.mod b/go.mod index ebe99635..85d0a029 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.91.0 + github.com/kernel/kernel-go-sdk v0.91.1-0.20260817203807-0a287359dcc5 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 7808eb39..b91fd6b3 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.91.0 h1:/bJKFJQ8ZwAyl+r8P1sUW8NQYEjDekYZJ5R8Sml5bus= -github.com/kernel/kernel-go-sdk v0.91.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.91.1-0.20260817203807-0a287359dcc5 h1:Kaq0Dhh1VW36HzqOUOpvWnB1PF3XtPekC++RYdgePNQ= +github.com/kernel/kernel-go-sdk v0.91.1-0.20260817203807-0a287359dcc5/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= From dfdba4fccc49d1e67bce163b8122769024932fbc Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:07:08 +0000 Subject: [PATCH 02/51] CLI: Update Go SDK to v0.92.0 (a156820) Updates github.com/kernel/kernel-go-sdk from v0.91.1-0.20260817203807-0a287359dcc5 to v0.92.0. ## Coverage Analysis Diffing the two module sources shows the SDK API surface is byte-identical between these versions -- the only changes are release metadata (.release-please-manifest.json, CHANGELOG.md, README.md, internal/version.go). A full enumeration was still performed: - All 140 SDK methods in api.md have corresponding CLI commands. - The 4 x-cli-skip endpoints (/site-configs/lookup, /site-configs/resolve, /site-configs/analyses/{id}, /auth/connections/{id}/exchange) are absent from the SDK surface, so nothing to skip. - All params struct fields are covered by CLI flags except three, each intentional: - AuthConnectionLoginParams.BrowserTelemetry -- deprecated in favor of browser.telemetry, which the CLI already uses via ManagedAuthBrowserConfigParam. - AuditLogListParams.PageToken -- opaque cursor handled internally by ListAutoPaging; CLI exposes --limit instead. - BrowserCurlParams.TimeoutMs / ResponseEncoding -- `browsers curl` is implemented against browsers.HTTPClient rather than the SDK curl endpoint; --max-time covers the timeout and raw bytes are streamed, so response encoding is not applicable. No coverage gaps found; no new commands or flags added. ## Tested - go build ./... and go vet ./... clean - go test ./... all packages pass - Smoke tested rebuilt binary against the live API: `kernel browsers list` Triggered by: kernel/kernel-go-sdk@a1568205c576686eeafc634fff0ea72b75c28c0e Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 85d0a029..502421b9 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.91.1-0.20260817203807-0a287359dcc5 + github.com/kernel/kernel-go-sdk v0.92.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 b91fd6b3..04679b80 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.91.1-0.20260817203807-0a287359dcc5 h1:Kaq0Dhh1VW36HzqOUOpvWnB1PF3XtPekC++RYdgePNQ= -github.com/kernel/kernel-go-sdk v0.91.1-0.20260817203807-0a287359dcc5/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +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/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= From 8a7b36334f3cc3f878df9b17d90da78d24e5b6d2 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:13:13 +0000 Subject: [PATCH 03/51] CLI: Update Go SDK to 6e62bf5 and track managed-auth field reason Bumps kernel-go-sdk to 6e62bf5b91e5d315b90b6c9c7296e09e312fb338. That SDK release reshapes the canonical managed-auth input field: the boolean `replace_existing` is gone and a `reason` enum ("missing" | "rejected") takes its place, so `auth connections get` and the `auth connections follow` event stream now render `reason=` instead of the `replace-existing` marker. A rejected credential is still visible, now alongside the missing-value case it could not previously express. A full enumeration of api.md against the CLI's service interfaces and flags found no other coverage gaps: all 136 non-x-cli-skip SDK methods have commands, and every params field maps to an existing flag. Tested: auth connections list, auth connections get (table + json), browsers create -t 60, browsers get , browsers delete against the live API; go build ./... and go test ./cmd/... pass. Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 46 ++++++++++++++++++------------------ cmd/auth_connections_test.go | 5 +++- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 79a34567..4237e9a3 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -414,13 +414,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 +448,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)) @@ -542,13 +542,13 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e 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, "; ")}) @@ -1067,13 +1067,13 @@ func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowIn 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, ", ")) diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index b1c654a9..a403464e 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -154,6 +154,7 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) { Type: "code", Ref: "totp_code", Hint: "Enter the code sent to +1 ••• ••• 1234", + Reason: "rejected", Required: true, }, }, @@ -182,7 +183,9 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) { out := outBuf.String() 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`) diff --git a/go.mod b/go.mod index 502421b9..52d4a655 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.92.1-0.20260818210401-6e62bf5b91e5 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 04679b80..ef49c798 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.92.1-0.20260818210401-6e62bf5b91e5 h1:xnui88jn6CAp2Ys15AP7aagFGPveqrT/3LfdDsuIeY4= +github.com/kernel/kernel-go-sdk v0.92.1-0.20260818210401-6e62bf5b91e5/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= From ca46838accfb10bf06678d3de3185d3dbca51f3a Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:02:46 +0000 Subject: [PATCH 04/51] CLI: Update Go SDK to 796d424 and bind canonical submits to interactions Bumps kernel-go-sdk to 796d4245c87a39acbb0d408b05f0de830c500772. That SDK release adds `interaction_id` to managed auth state and to the submit request. The API requires it for canonical submissions (field_values / selected_choice_id) and rejects it when paired with a legacy submit mode, so before this change every canonical `auth connections submit` failed with "interaction_id is required for canonical submissions". `auth connections submit` gains --interaction-id. Left off, the CLI reads the connection's current interaction ID, since the ID changes on every actionable pause and the freshly read one is the only sane default; passing it pins the submission so the API can reject it as stale. Legacy submit modes never send one, and --interaction-id with a legacy mode is rejected locally with the same rule the API enforces. `auth connections get` and `follow` now show the interaction ID next to the canonical fields and choices it scopes. Also resolves the stale merge of main into this branch, which had left two competing org entitlements implementations in cmd/org.go (the branch built `org entitlements get`; main shipped `org entitlements` in #232) so the package no longer compiled. Main's reviewed version wins. A full enumeration of api.md against the CLI found no other gaps: all 136 non-x-cli-skip SDK methods have commands, and the only new params field in this release is SubmitFieldsRequest.interaction_id. Tested against the live API: created a managed auth connection, started a login flow, and confirmed `get` (table + JSON) and `follow` render the interaction ID at AWAITING_INPUT; canonical submit with and without --interaction-id now clears the API's interaction validation (it stops at this org's submit-v2 feature gate, while the same request sent without interaction_id still returns "interaction_id is required"); legacy `--field` submit still accepted; `--interaction-id` with `--field` rejected locally; org entitlements, browsers create/get/delete pass. go build ./..., go vet ./... and go test ./... pass. Co-Authored-By: Claude Opus 5 --- README.md | 3 +- cmd/auth_connections.go | 54 ++++++++++++- cmd/auth_connections_test.go | 127 +++++++++++++++++++++++++++--- cmd/org.go | 137 -------------------------------- cmd/org_test.go | 147 ----------------------------------- go.mod | 2 +- go.sum | 4 +- 7 files changed, 174 insertions(+), 300 deletions(-) diff --git a/README.md b/README.md index b45c27af..4f7655e4 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. @@ -731,8 +732,6 @@ Automated authentication for web services. The `run` command orchestrates the fu - `kernel org limits set` - Set the default per-project concurrency cap applied to projects without an explicit override - `--default-project-max-concurrent-sessions ` - Default maximum concurrent browsers for projects without an explicit override (`0` to remove the default) - `--output json`, `-o json` - Output raw JSON object -- `kernel org entitlements get` - Show the organization's effective feature access and constraints after applying its plan, active trial treatment, plan status, and organization-specific overrides; unlimited constraints are shown as `unlimited` - - `--output json`, `-o json` - Output raw JSON object ## Examples diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 4237e9a3..c6aedcfa 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -130,7 +130,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 @@ -538,6 +542,11 @@ 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 { @@ -838,6 +847,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 +915,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,6 +1097,9 @@ 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 { @@ -1181,8 +1218,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 @@ -1291,6 +1338,7 @@ func init() { 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") @@ -1516,6 +1564,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 +1592,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 a403464e..4466e483 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", @@ -182,6 +185,7 @@ 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)`) // 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. @@ -823,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", @@ -844,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{ @@ -854,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/org.go b/cmd/org.go index f6b58675..e8ba0d52 100644 --- a/cmd/org.go +++ b/cmd/org.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "time" "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" @@ -12,7 +11,6 @@ import ( "github.com/kernel/kernel-go-sdk/packages/param" "github.com/kernel/kernel-go-sdk/packages/respjson" "github.com/pterm/pterm" - "github.com/samber/lo" "github.com/spf13/cobra" ) @@ -36,10 +34,6 @@ type OrgLimitsGetInput struct { Output string } -type OrgEntitlementsGetInput struct { - Output string -} - type OrgLimitsSetInput struct { DefaultProjectMaxConcurrentSessions Int64Flag Output string @@ -127,118 +121,6 @@ func (c OrgCmd) LimitsSet(ctx context.Context, in OrgLimitsSetInput) error { return nil } -func (c OrgCmd) EntitlementsGet(ctx context.Context, in OrgEntitlementsGetInput) error { - if err := validateJSONOutput(in.Output); err != nil { - return err - } - - entitlements, err := c.entitlements.Get(ctx) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - if in.Output == "json" { - if entitlements == nil { - fmt.Println("null") - return nil - } - return util.PrintPrettyJSON(entitlements) - } - - renderOrgEntitlements(entitlements) - return nil -} - -func renderOrgEntitlements(ent *kernel.OrgEntitlements) { - if ent == nil { - pterm.Info.Println("No organization entitlements found") - return - } - - plan := ent.Plan - planRows := pterm.TableData{ - {"Field", "Value"}, - {"Plan", plan.ID}, - // Active trials resolve to a different effective plan than the - // contractual one, so show both. - {"Effective Plan", plan.EffectiveID}, - {"Trialing", lo.Ternary(plan.IsTrialing, "yes", "no")}, - // Billing status and trial end are both nullable. - {"Billing Status", formatOrgEntitlementString(plan.Status, plan.JSON.Status)}, - {"Trial Ends At", formatOrgEntitlementTime(plan.TrialEndsAt, plan.JSON.TrialEndsAt)}, - } - pterm.DefaultSection.Println("Plan") - PrintTableNoPad(planRows, true) - - f := ent.Features - featureRows := pterm.TableData{ - {"Feature", "Enabled", "Constraints"}, - {"Browser Extensions", formatOrgEntitlementEnabled(f.BrowserExtensions.Enabled), fmt.Sprintf("max stored per org: %s", formatProjectLimitValue(f.BrowserExtensions.MaxStoredPerOrg, f.BrowserExtensions.JSON.MaxStoredPerOrg))}, - {"Browser Pools", formatOrgEntitlementEnabled(f.BrowserPools.Enabled), ""}, - {"Browser Replays", formatOrgEntitlementEnabled(f.BrowserReplays.Enabled), fmt.Sprintf("retention: %s", formatOrgEntitlementDays(f.BrowserReplays.RetentionDays, f.BrowserReplays.JSON.RetentionDays))}, - {"Credential Providers", formatOrgEntitlementEnabled(f.CredentialProviders.Enabled), ""}, - {"Credentials", formatOrgEntitlementEnabled(f.Credentials.Enabled), ""}, - {"Custom Proxies", formatOrgEntitlementEnabled(f.CustomProxies.Enabled), ""}, - {"File I/O", formatOrgEntitlementEnabled(f.FileIo.Enabled), ""}, - {"GPU", formatOrgEntitlementEnabled(f.GPU.Enabled), ""}, - {"Managed Auth", formatOrgEntitlementEnabled(f.ManagedAuth.Enabled), formatManagedAuthConstraints(f.ManagedAuth)}, - {"Managed Proxies", formatOrgEntitlementEnabled(f.ManagedProxies.Enabled), ""}, - {"Profiles", formatOrgEntitlementEnabled(f.Profiles.Enabled), ""}, - {"Proxy Bypass Hosts", formatOrgEntitlementEnabled(f.ProxyBypassHosts.Enabled), ""}, - } - pterm.DefaultSection.Println("Features") - PrintTableNoPad(featureRows, true) - - l := ent.Limits - limitRows := pterm.TableData{ - {"Limit", "Value"}, - {"Max Concurrent Browsers", formatProjectLimitValue(l.MaxConcurrentBrowsers, l.JSON.MaxConcurrentBrowsers)}, - {"Max Concurrent Invocations", formatProjectLimitValue(l.MaxConcurrentInvocations, l.JSON.MaxConcurrentInvocations)}, - {"Default Max Concurrent Invocations Per App", formatProjectLimitValue(l.DefaultMaxConcurrentInvocationsPerApp, l.JSON.DefaultMaxConcurrentInvocationsPerApp)}, - } - pterm.DefaultSection.Println("Limits") - PrintTableNoPad(limitRows, true) -} - -func formatOrgEntitlementEnabled(enabled bool) string { - return lo.Ternary(enabled, "yes", "no") -} - -// formatManagedAuthConstraints summarizes the managed auth connection cap and the -// accepted health-check interval window in a single cell. -func formatManagedAuthConstraints(ma kernel.OrgEntitlementsFeaturesManagedAuth) string { - return fmt.Sprintf( - "max connections: %s, health check interval: %ds default (%ds-%ds)", - formatProjectLimitValue(ma.MaxConnections, ma.JSON.MaxConnections), - ma.HealthCheckIntervalDefaultSeconds, - ma.HealthCheckIntervalMinSeconds, - ma.HealthCheckIntervalMaxSeconds, - ) -} - -// formatOrgEntitlementDays renders a retention window, treating a null value as -// unlimited retention rather than zero days. -func formatOrgEntitlementDays(value int64, field respjson.Field) string { - if !field.Valid() { - return "unlimited" - } - return fmt.Sprintf("%d days", value) -} - -func formatOrgEntitlementString(value string, field respjson.Field) string { - if !field.Valid() || value == "" { - return "-" - } - return value -} - -func formatOrgEntitlementTime(value time.Time, field respjson.Field) string { - if !field.Valid() || value.IsZero() { - return "-" - } - return util.FormatLocal(value) -} - func renderOrgLimits(limits *kernel.OrgLimits) { if limits == nil { pterm.Info.Println("No organization limits found") @@ -370,22 +252,6 @@ var orgLimitsCmd = &cobra.Command{ }, } -var orgEntitlementsCmd = &cobra.Command{ - Use: "entitlements", - Short: "Read organization entitlements", - Run: func(cmd *cobra.Command, args []string) { - _ = cmd.Help() - }, -} - -var orgEntitlementsGetCmd = &cobra.Command{ - Use: "get", - Short: "Get organization entitlements", - Long: "Show the organization's effective feature access and constraints after applying its plan, active trial treatment, plan status, and organization-specific overrides. Unlimited constraints are shown as \"unlimited\".", - Args: cobra.NoArgs, - RunE: runOrgEntitlementsGet, -} - var orgLimitsGetCmd = &cobra.Command{ Use: "get", Short: "Get organization limits", @@ -449,11 +315,8 @@ func init() { addJSONOutputFlag(orgLimitsSetCmd) addJSONOutputFlag(orgEntitlementsCmd) - addJSONOutputFlag(orgEntitlementsGetCmd) - orgLimitsCmd.AddCommand(orgLimitsGetCmd) orgLimitsCmd.AddCommand(orgLimitsSetCmd) - orgEntitlementsCmd.AddCommand(orgEntitlementsGetCmd) orgCmd.AddCommand(orgLimitsCmd) orgCmd.AddCommand(orgEntitlementsCmd) } diff --git a/cmd/org_test.go b/cmd/org_test.go index 464d2a9a..e55713fd 100644 --- a/cmd/org_test.go +++ b/cmd/org_test.go @@ -413,150 +413,3 @@ func TestOrgLimitsSet_RejectsNegative(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "must be non-negative") } - -type FakeOrgEntitlementsService struct { - GetFunc func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) -} - -func (f *FakeOrgEntitlementsService) Get(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { - if f.GetFunc != nil { - return f.GetFunc(ctx, opts...) - } - return &kernel.OrgEntitlements{}, nil -} - -// populatedEntitlements builds an entitlements payload with every nullable field -// present, so renders exercise the non-"unlimited" branches. -func populatedEntitlements() *kernel.OrgEntitlements { - ent := &kernel.OrgEntitlements{} - - ent.Plan.ID = "START_UP" - ent.Plan.EffectiveID = "START_UP" - ent.Plan.IsTrialing = true - ent.Plan.Status = "ACTIVE" - ent.Plan.TrialEndsAt = time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) - ent.Plan.JSON.Status = respjson.NewField(`"ACTIVE"`) - ent.Plan.JSON.TrialEndsAt = respjson.NewField(`"2030-01-02T03:04:05Z"`) - - ent.Features.BrowserExtensions.Enabled = true - ent.Features.BrowserExtensions.MaxStoredPerOrg = 25 - ent.Features.BrowserExtensions.JSON.MaxStoredPerOrg = respjson.NewField("25") - ent.Features.BrowserPools.Enabled = true - ent.Features.BrowserReplays.Enabled = true - ent.Features.BrowserReplays.RetentionDays = 7 - ent.Features.BrowserReplays.JSON.RetentionDays = respjson.NewField("7") - ent.Features.CredentialProviders.Enabled = true - ent.Features.Credentials.Enabled = true - ent.Features.CustomProxies.Enabled = false - ent.Features.FileIo.Enabled = true - ent.Features.GPU.Enabled = false - ent.Features.ManagedAuth.Enabled = true - ent.Features.ManagedAuth.MaxConnections = 10 - ent.Features.ManagedAuth.HealthCheckIntervalDefaultSeconds = 600 - ent.Features.ManagedAuth.HealthCheckIntervalMinSeconds = 300 - ent.Features.ManagedAuth.HealthCheckIntervalMaxSeconds = 86400 - ent.Features.ManagedAuth.JSON.MaxConnections = respjson.NewField("10") - ent.Features.ManagedProxies.Enabled = true - ent.Features.Profiles.Enabled = true - ent.Features.ProxyBypassHosts.Enabled = true - - ent.Limits.MaxConcurrentBrowsers = 50 - ent.Limits.MaxConcurrentInvocations = 20 - ent.Limits.DefaultMaxConcurrentInvocationsPerApp = 5 - ent.Limits.JSON.MaxConcurrentBrowsers = respjson.NewField("50") - ent.Limits.JSON.MaxConcurrentInvocations = respjson.NewField("20") - ent.Limits.JSON.DefaultMaxConcurrentInvocationsPerApp = respjson.NewField("5") - - return ent -} - -func TestOrgEntitlementsGet_RendersPlanFeaturesAndLimits(t *testing.T) { - buf := capturePtermOutput(t) - fake := &FakeOrgEntitlementsService{ - GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { - return populatedEntitlements(), nil - }, - } - c := OrgCmd{entitlements: fake} - assert.NoError(t, c.EntitlementsGet(context.Background(), OrgEntitlementsGetInput{})) - - out := buf.String() - // Plan section - assert.Contains(t, out, "START_UP") - assert.Contains(t, out, "Effective Plan") - assert.Contains(t, out, "Trialing") - assert.Contains(t, out, "ACTIVE") - // Features section — every feature should get a row. - for _, feature := range []string{ - "Browser Extensions", "Browser Pools", "Browser Replays", "Credential Providers", - "Credentials", "Custom Proxies", "File I/O", "GPU", "Managed Auth", - "Managed Proxies", "Profiles", "Proxy Bypass Hosts", - } { - assert.Contains(t, out, feature) - } - assert.Contains(t, out, "max stored per org: 25") - assert.Contains(t, out, "retention: 7 days") - assert.Contains(t, out, "max connections: 10") - assert.Contains(t, out, "600s default (300s-86400s)") - // Limits section - assert.Contains(t, out, "Max Concurrent Browsers") - assert.Contains(t, out, "Max Concurrent Invocations") - assert.Contains(t, out, "Default Max Concurrent Invocations Per App") -} - -func TestOrgEntitlementsGet_NullConstraintsShownAsUnlimited(t *testing.T) { - buf := capturePtermOutput(t) - fake := &FakeOrgEntitlementsService{ - GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { - ent := populatedEntitlements() - // Null (not omitted) constraints mean unlimited. - ent.Features.BrowserExtensions.JSON.MaxStoredPerOrg = respjson.NewField(respjson.Null) - ent.Features.ManagedAuth.JSON.MaxConnections = respjson.NewField(respjson.Null) - ent.Limits.JSON.MaxConcurrentBrowsers = respjson.NewField(respjson.Null) - return ent, nil - }, - } - c := OrgCmd{entitlements: fake} - assert.NoError(t, c.EntitlementsGet(context.Background(), OrgEntitlementsGetInput{})) - - out := buf.String() - assert.Contains(t, out, "max stored per org: unlimited") - assert.Contains(t, out, "max connections: unlimited") - assert.Contains(t, out, "unlimited") -} - -func TestOrgEntitlementsGet_NullPlanFieldsShownAsDash(t *testing.T) { - buf := capturePtermOutput(t) - fake := &FakeOrgEntitlementsService{ - GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { - ent := populatedEntitlements() - ent.Plan.IsTrialing = false - ent.Plan.JSON.Status = respjson.NewField(respjson.Null) - ent.Plan.JSON.TrialEndsAt = respjson.NewField(respjson.Null) - return ent, nil - }, - } - c := OrgCmd{entitlements: fake} - assert.NoError(t, c.EntitlementsGet(context.Background(), OrgEntitlementsGetInput{})) - - out := buf.String() - assert.Contains(t, out, "Billing Status") - assert.Contains(t, out, "Trial Ends At") - assert.NotContains(t, out, "ACTIVE") -} - -func TestOrgEntitlementsGet_RejectsUnknownOutput(t *testing.T) { - c := OrgCmd{entitlements: &FakeOrgEntitlementsService{}} - assert.Error(t, c.EntitlementsGet(context.Background(), OrgEntitlementsGetInput{Output: "yaml"})) -} - -func TestOrgEntitlementsGet_SurfacesAPIError(t *testing.T) { - capturePtermOutput(t) - fake := &FakeOrgEntitlementsService{ - GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { - return nil, errors.New("boom") - }, - } - c := OrgCmd{entitlements: fake} - assert.Error(t, c.EntitlementsGet(context.Background(), OrgEntitlementsGetInput{})) -} diff --git a/go.mod b/go.mod index 502421b9..900e422a 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.92.1-0.20260819184853-796d4245c87a 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 04679b80..4ec0e491 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.92.1-0.20260819184853-796d4245c87a h1:VJcz+I1d/VTEHkKM4O7+Wf+ejSbXQtytUtDXnZ0+b+4= +github.com/kernel/kernel-go-sdk v0.92.1-0.20260819184853-796d4245c87a/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= From 31d2462fcbd0d9f53d417493761424714b81da23 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:39:11 +0000 Subject: [PATCH 05/51] CLI: Update Go SDK to 467fea7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps kernel-go-sdk to v0.92.1-0.20260819203102-467fea72ee93, which adds the proxy_error browser telemetry event (BrowserProxyErrorEvent) to the telemetry event union. No CLI coverage gaps: a full enumeration of all 137 SDK methods in api.md found a corresponding CLI command for each, and the new event type needs no code change because the telemetry commands render category/type generically and accept --types values without a fixed allowlist. Tested: go build ./..., go vet ./..., go test ./... (all pass); browsers create --telemetry all, browsers curl, browsers telemetry events (table, --output json, --categories network --all, --types proxy_error), browsers telemetry stream --categories network --types proxy_error, browsers delete — all against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 900e422a..59cb47a0 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.1-0.20260819184853-796d4245c87a + github.com/kernel/kernel-go-sdk v0.92.1-0.20260819203102-467fea72ee93 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 4ec0e491..662bb5e0 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.1-0.20260819184853-796d4245c87a h1:VJcz+I1d/VTEHkKM4O7+Wf+ejSbXQtytUtDXnZ0+b+4= -github.com/kernel/kernel-go-sdk v0.92.1-0.20260819184853-796d4245c87a/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.92.1-0.20260819203102-467fea72ee93 h1:p+OWj+8b1iK+Bx/5gSSTP9itGLbN5w2hY/CVT0eBdRM= +github.com/kernel/kernel-go-sdk v0.92.1-0.20260819203102-467fea72ee93/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= From 16880f448f52ba874488156b50db24745f4685b3 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:06:00 +0000 Subject: [PATCH 06/51] CLI: Update Go SDK to v0.93.0 (0802326) Bumps github.com/kernel/kernel-go-sdk to 08023260493e4584c4d87638849ab4491b34ec49 (v0.93.0). The 0.93.0 release only changed version/changelog metadata relative to the SDK revision the CLI was already pinned to (467fea7); api.md and all generated Go sources are byte-identical, so there are no new methods, params, or fields to expose. Coverage analysis: full enumeration of all 140 SDK methods in api.md against the CLI command tree found no gaps. Every method has a command, and every param struct field is reachable via a flag, a positional arg, or a derived value. Tested: go build ./..., go vet ./..., go test ./... (all pass), plus live API smoke tests for browsers list/create/get/delete, browsers telemetry events, auth connections list, profiles list, telemetry destinations list. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 59cb47a0..0e346401 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.1-0.20260819203102-467fea72ee93 + github.com/kernel/kernel-go-sdk v0.93.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 662bb5e0..78374443 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.1-0.20260819203102-467fea72ee93 h1:p+OWj+8b1iK+Bx/5gSSTP9itGLbN5w2hY/CVT0eBdRM= -github.com/kernel/kernel-go-sdk v0.92.1-0.20260819203102-467fea72ee93/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.93.0 h1:mPsZKoQlLsgsC0TehWJ/Q5XqWwKu33bKfnuqnfNHtjs= +github.com/kernel/kernel-go-sdk v0.93.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= From f9b126f68e11d78fd112775a398e6ac03cce2c35 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:30:19 +0000 Subject: [PATCH 07/51] CLI: Update Go SDK to 9a36566 and cover the telemetry control/platform split Updates kernel-go-sdk to 9a36566d8999ca346a9eeccede0cbf88d651b93f, which mirrors the control/platform telemetry split into the public API. BrowserTelemetryCategories gains a `platform` category and `control` becomes its own config type carrying `cdp.excluded_methods`, so the previous `p.Control = on()` no longer compiled. New coverage: - `--telemetry=platform` is now a settable category on browsers create/update, browser-pools create/update/acquire, and auth connections create/update/login, and is reported by the telemetry summaries and details tables. - `--telemetry-cdp-exclude` (new flag, same eight commands) sets BrowserTelemetryCdpControlConfigParam.ExcludedMethods. Values are the 38 CDP methods the proxy reports, matched case-insensitively and canonicalized; `--telemetry-cdp-exclude=none` sends an empty list to report every method again. Combining it with `--telemetry=off` is rejected, and on auth connection update/login it requires `--telemetry` in the same command, since a connection stores its browser config as sent and exclusions alone would drop its category selection. - Excluded methods are surfaced in the create/update telemetry summary, the browser-pool details table, and the auth connection details table. A full enumeration of the 140 methods in api.md against the CLI command tree found no missing commands. The x-cli-skip endpoints (site-configs, auth connection exchange) remain excluded. Tested against the live API: browsers create/update/delete with --telemetry=control,platform --telemetry-cdp-exclude (set, replace, and =none clear); browser-pools create/get/update/acquire/delete; auth connections create/get/update/delete; browsers telemetry events --categories platform; and the unknown-method, --telemetry=off, and missing---telemetry error paths. Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 60 +++++++----- cmd/browser_pools.go | 87 ++++++++++------- cmd/browser_pools_test.go | 6 +- cmd/browsers.go | 126 +++++++++++++----------- cmd/browsers_telemetry.go | 173 ++++++++++++++++++++++++++++----- cmd/browsers_telemetry_test.go | 130 ++++++++++++++++++++----- go.mod | 2 +- go.sum | 4 +- 8 files changed, 417 insertions(+), 171 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index c6aedcfa..d7fc1f9a 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 { @@ -237,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 } @@ -383,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 } @@ -781,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 } @@ -1279,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") @@ -1308,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") @@ -1333,6 +1338,7 @@ 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) @@ -1387,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 @@ -1410,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, }) @@ -1443,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{} @@ -1496,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, }) @@ -1541,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, }) } diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index c6f7051b..276018d7 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 e0a143ff..387f3f18 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 326f88d1..5e4c82b7 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 9a37ef36..03699772 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 fe3b88e1..d9458bff 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 0e346401..cbedb97c 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.93.0 + github.com/kernel/kernel-go-sdk v0.93.1-0.20260821151320-9a36566d8999 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 78374443..7b5172cd 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.93.0 h1:mPsZKoQlLsgsC0TehWJ/Q5XqWwKu33bKfnuqnfNHtjs= -github.com/kernel/kernel-go-sdk v0.93.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.93.1-0.20260821151320-9a36566d8999 h1:+BIiUH4JK5tCi3P57exQgbyGEXCrFNY3OpMuuI931Xo= +github.com/kernel/kernel-go-sdk v0.93.1-0.20260821151320-9a36566d8999/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= From 063d7f5e4e0f7ca1986aee458495956a39fb3d2c Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:44:24 +0000 Subject: [PATCH 08/51] CLI: Update Go SDK to c042837 and drop the telemetry control/platform split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates kernel-go-sdk to c0428370612f0ae242d9c4cbbf87e6a6436ff9d9. The previous update (f9b126f) targeted SDK commit 9a36566d8999, which is not reachable from the SDK's main branch — that telemetry control/platform split never landed. Against c042837 the CLI no longer compiled: BrowserTelemetryControlConfigParam, BrowserCdpCommandMethod, the `platform` category, and `control.cdp` do not exist. This reverts f9b126f's code changes, so the CLI is back to the nine categories the SDK actually ships (captcha, connection, console, control, interaction, network, page, screenshot, system) and the `--telemetry-cdp-exclude` flag is gone. The only API-surface change between the CLI's previous SDK and c042837 is browser_routing.go adding "computer" and "playwright" to the direct-to-VM routing allowlist — an internal default with no CLI-visible effect. Coverage analysis: a full enumeration of the 140 methods in api.md against the CLI command tree found no missing commands, and a field-by-field pass over every Params struct found no missing flags. The x-cli-skip endpoints (site-configs, auth connection exchange) remain excluded. Tested against the production API: - browsers create --telemetry=console,network / update --telemetry=page / telemetry events / get / delete - browser-pools create --telemetry=console / get / update --telemetry=network / delete - browsers create --telemetry-cdp-exclude now correctly rejects the removed flag - read-only sweep: auth context, auth connections list, browsers list, browser-pools list, app list, proxies list, profiles list, extensions list, org entitlements, telemetry destinations list, credentials list, projects list - go build ./..., go vet ./..., go test ./... all pass Triggered by: kernel/kernel-go-sdk@c0428370612f0ae242d9c4cbbf87e6a6436ff9d9 Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 60 +++++------- cmd/browser_pools.go | 87 +++++++---------- cmd/browser_pools_test.go | 6 +- cmd/browsers.go | 126 +++++++++++------------- cmd/browsers_telemetry.go | 173 +++++---------------------------- cmd/browsers_telemetry_test.go | 130 +++++-------------------- go.mod | 2 +- go.sum | 4 +- 8 files changed, 171 insertions(+), 417 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index d7fc1f9a..c6aedcfa 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -57,7 +57,6 @@ type AuthConnectionCreateInput struct { NoAutoReauth bool RecordSession BoolFlag Telemetry string - TelemetryCdpExclude string TelemetryExport string Output string } @@ -93,7 +92,6 @@ type AuthConnectionUpdateInput struct { AutoReauth BoolFlag RecordSession BoolFlag Telemetry string - TelemetryCdpExclude string TelemetryExport string Output string } @@ -113,16 +111,15 @@ type AuthConnectionDeleteInput struct { } type AuthConnectionLoginInput struct { - ID string - ProxyID string - ProxyName string - ProxyMode string - Stealth BoolFlag - RecordSession BoolFlag - Telemetry string - TelemetryCdpExclude string - TelemetryExport string - Output string + ID string + ProxyID string + ProxyName string + ProxyMode string + Stealth BoolFlag + RecordSession BoolFlag + Telemetry string + TelemetryExport string + Output string } type AuthConnectionSubmitInput struct { @@ -240,8 +237,8 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, true) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, true) if err != nil { return err } @@ -386,8 +383,8 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn hasChanges = true } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, false) if err != nil { return err } @@ -784,8 +781,8 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu params.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, false) if err != nil { return err } @@ -1282,7 +1279,6 @@ 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") @@ -1312,7 +1308,6 @@ 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") @@ -1338,7 +1333,6 @@ 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) @@ -1393,7 +1387,6 @@ 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 @@ -1417,7 +1410,6 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { NoAutoReauth: noAutoReauth, RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, - TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, Output: output, }) @@ -1451,7 +1443,6 @@ 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{} @@ -1505,7 +1496,6 @@ 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, }) @@ -1551,22 +1541,20 @@ 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, - TelemetryCdpExclude: telemetryCdpExclude, - 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, + TelemetryExport: telemetryExport, + Output: output, }) } diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 276018d7..c6f7051b 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -108,24 +108,21 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err return nil } -// 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) +// buildPoolNewTelemetryParam converts a --telemetry flag value to the pool create param. +func buildPoolNewTelemetryParam(s string) (kernel.BrowserPoolNewParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s) return kernel.BrowserPoolNewParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// 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) +// buildPoolUpdateTelemetryParam converts a --telemetry flag value to the pool update param. +func buildPoolUpdateTelemetryParam(s string) (kernel.BrowserPoolUpdateParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s) return kernel.BrowserPoolUpdateParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// 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) +// buildPoolAcquireTelemetryParam converts a --telemetry flag value to the acquire override param. +func buildPoolAcquireTelemetryParam(s string) (kernel.BrowserPoolAcquireParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s) return kernel.BrowserPoolAcquireParamsTelemetry{Enabled: enabled, Browser: browser}, err } @@ -135,11 +132,7 @@ func formatPoolTelemetry(cfg kernel.BrowserTelemetryConfig) string { if len(on) == 0 { return "disabled" } - base := strings.Join(on, ", ") - if ex := formatCdpExcludedMethods(cfg.Browser.Control.Cdp.ExcludedMethods); ex != "" { - return base + " (excluding CDP methods: " + ex + ")" - } - return base + return strings.Join(on, ", ") } type BrowserPoolsCreateInput struct { @@ -162,7 +155,6 @@ type BrowserPoolsCreateInput struct { ChromePolicy string ChromePolicyFile string Telemetry string - TelemetryCdpExclude string Output string } @@ -255,8 +247,8 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) params.ChromePolicy = chromePolicy } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" { - t, err := buildPoolNewTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) + if in.Telemetry != "" { + t, err := buildPoolNewTelemetryParam(in.Telemetry) if err != nil { return err } @@ -277,7 +269,7 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) } else { pterm.Success.Printf("Created browser pool %s\n", pool.ID) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" { + if in.Telemetry != "" { printTelemetrySummary(pool.BrowserPoolConfig.Telemetry) } return nil @@ -358,7 +350,6 @@ type BrowserPoolsUpdateInput struct { ChromePolicyFile string ClearChromePolicy bool Telemetry string - TelemetryCdpExclude string DiscardAllIdle BoolFlag Output string } @@ -497,8 +488,8 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) params.SetExtraFields(extraFields) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" { - t, err := buildPoolUpdateTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) + if in.Telemetry != "" { + t, err := buildPoolUpdateTelemetryParam(in.Telemetry) if err != nil { return err } @@ -519,7 +510,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) } else { pterm.Success.Printf("Updated browser pool %s\n", pool.ID) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" { + if in.Telemetry != "" { printTelemetrySummary(pool.BrowserPoolConfig.Telemetry) } return nil @@ -544,14 +535,13 @@ 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 - TelemetryCdpExclude string - Output string + IDOrName string + TimeoutSeconds int64 + Name string + StartURL string + Tags map[string]string + Telemetry string + Output string } // buildAcquireParams builds the SDK params for acquiring a browser from a pool. @@ -559,7 +549,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, telemetryCdpExclude, startURL string) (kernel.BrowserPoolAcquireParams, error) { +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) @@ -573,8 +563,8 @@ func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int6 if len(tags) > 0 { params.Tags = kernel.Tags(tags) } - if telemetry != "" || telemetryCdpExclude != "" { - t, err := buildPoolAcquireTelemetryParam(telemetry, telemetryCdpExclude) + if telemetry != "" { + t, err := buildPoolAcquireTelemetryParam(telemetry) if err != nil { return kernel.BrowserPoolAcquireParams{}, err } @@ -588,7 +578,7 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu return err } - params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.TelemetryCdpExclude, in.StartURL) + params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.StartURL) if err != nil { return err } @@ -759,7 +749,6 @@ 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) @@ -790,7 +779,6 @@ 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) @@ -801,7 +789,6 @@ 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") @@ -858,7 +845,6 @@ 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{ @@ -881,7 +867,6 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { ChromePolicy: chromePolicy, ChromePolicyFile: chromePolicyFile, Telemetry: telemetry, - TelemetryCdpExclude: telemetryCdpExclude, Output: output, } @@ -923,7 +908,6 @@ 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") @@ -953,7 +937,6 @@ 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, } @@ -976,18 +959,16 @@ 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, - TelemetryCdpExclude: telemetryCdpExclude, - Output: output, + 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 387f3f18..e0a143ff 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 5e4c82b7..326f88d1 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -360,32 +360,31 @@ 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 - TelemetryCdpExclude 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 + TelemetryExport string + ChromePolicy string + ChromePolicyFile string + Name string + Tags map[string]string + Output string } type BrowsersDeleteInput struct { @@ -416,7 +415,6 @@ type BrowsersUpdateInput struct { Viewport string Force bool Telemetry string - TelemetryCdpExclude string Name string SetName bool ClearName bool @@ -671,8 +669,8 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { - t, err := buildNewTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildNewTelemetryParam(in.Telemetry, in.TelemetryExport) if err != nil { return err } @@ -707,7 +705,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.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + if in.Telemetry != "" || in.TelemetryExport != "" { printTelemetrySummary(browser.Telemetry) } return nil @@ -943,8 +941,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 == "" && 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") + 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") } params := kernel.BrowserUpdateParams{} @@ -987,8 +985,8 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } // Handle telemetry changes - if in.Telemetry != "" || in.TelemetryCdpExclude != "" { - t, err := buildUpdateTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) + if in.Telemetry != "" { + t, err := buildUpdateTelemetryParam(in.Telemetry) if err != nil { return err } @@ -1038,7 +1036,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 != "" || in.TelemetryCdpExclude != "" { + if in.Telemetry != "" { printTelemetrySummary(browser.Telemetry) } return nil @@ -2684,7 +2682,6 @@ 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") @@ -2966,7 +2963,6 @@ 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)") @@ -3098,7 +3094,6 @@ 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") @@ -3165,7 +3160,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, telemetryCdpExclude, startURL) + acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry, startURL) if err != nil { return err } @@ -3207,32 +3202,31 @@ 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, - TelemetryCdpExclude: telemetryCdpExclude, - 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, + TelemetryExport: telemetryExport, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, + Name: name, + Tags: tags, + Output: output, } svc := client.Browsers @@ -3294,7 +3288,6 @@ 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") @@ -3315,7 +3308,6 @@ 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 03699772..9a37ef36 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -76,9 +76,7 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig case "interaction": p.Interaction = on() case "control": - p.Control = kernel.BrowserTelemetryControlConfigParam{Enabled: kernel.Opt(true)} - case "platform": - p.Platform = on() + p.Control = on() case "connection": p.Connection = on() case "system": @@ -94,112 +92,20 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig return p, nil } -// 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 +// 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) { switch s { case "all": - enabled = kernel.Opt(true) + return kernel.Opt(true), kernel.BrowserTelemetryCategoriesConfigParam{}, nil case "off": - enabled = kernel.Opt(false) + return kernel.Opt(false), kernel.BrowserTelemetryCategoriesConfigParam{}, nil default: - var err error - if p, err = parseTelemetryCategories(s); err != nil { - return enabled, p, err - } - } - if cdpExclude == "" { - return enabled, p, nil + p, err := parseTelemetryCategories(s) + return param.Opt[bool]{}, p, err } - // 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 @@ -261,10 +167,10 @@ func validateTelemetryExportCombo(telemetry, id, name string, canImply bool) err return nil } -// buildNewTelemetryParam converts --telemetry, --telemetry-cdp-exclude and -// --telemetry-export-otlp flag values to the create API param. -func buildNewTelemetryParam(s, cdpExclude, export string) (kernel.BrowserNewParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) +// 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) p := kernel.BrowserNewParamsTelemetry{Enabled: enabled, Browser: browser} if err != nil || export == "" { return p, err @@ -301,37 +207,26 @@ func optIfSet(s string) param.Opt[string] { return kernel.Opt(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) +// 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 } -// 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. +// 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, cdpExclude, export string, canImply bool) (kernel.ManagedAuthBrowserConfigTelemetryParam, error) { - enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) +func buildManagedAuthTelemetryParam(s, export string, canImply bool) (kernel.ManagedAuthBrowserConfigTelemetryParam, error) { + enabled, browser, err := resolveTelemetryFlag(s) p := kernel.ManagedAuthBrowserConfigTelemetryParam{Enabled: enabled, Browser: browser} - if err != nil { + if err != nil || export == "" { 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 @@ -369,9 +264,6 @@ 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 + ")" } @@ -395,7 +287,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", "platform", "captcha", + "control", "connection", "system", "screenshot", "captcha", } // streamFilterCategories are the categories accepted by `telemetry stream --categories`. @@ -418,7 +310,6 @@ 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)) @@ -439,9 +330,6 @@ 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. @@ -453,19 +341,6 @@ 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 d9458bff..fe3b88e1 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -299,17 +299,14 @@ func TestShouldEmit(t *testing.T) { } func TestParseTelemetryCategories_OptInList(t *testing.T) { - p, err := parseTelemetryCategories("network,control,captcha,platform") + p, err := parseTelemetryCategories("network,control,captcha") assert.NoError(t, err) // Listed categories are enabled. - for _, c := range []kernel.BrowserTelemetryCategoryConfigParam{p.Network, p.Captcha, p.Platform} { + for _, c := range []kernel.BrowserTelemetryCategoryConfigParam{p.Network, p.Control, p.Captcha} { 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()) @@ -339,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()) @@ -369,7 +366,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()) @@ -378,7 +375,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()) @@ -386,20 +383,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()) @@ -408,7 +405,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") }) @@ -417,44 +414,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) { @@ -462,9 +459,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) @@ -472,13 +469,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) }) } @@ -721,82 +718,3 @@ 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 cbedb97c..6b00cac9 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.93.1-0.20260821151320-9a36566d8999 + github.com/kernel/kernel-go-sdk v0.93.1-0.20260821173629-c0428370612f 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 7b5172cd..50e70bb4 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.93.1-0.20260821151320-9a36566d8999 h1:+BIiUH4JK5tCi3P57exQgbyGEXCrFNY3OpMuuI931Xo= -github.com/kernel/kernel-go-sdk v0.93.1-0.20260821151320-9a36566d8999/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.93.1-0.20260821173629-c0428370612f h1:Nqwb7HXCMYBvltbuGbiD1Ms86aJs9JH46Q9aDNU/Oc8= +github.com/kernel/kernel-go-sdk v0.93.1-0.20260821173629-c0428370612f/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= From 484e19fefed48d5bc2d906453bdd391fcf7da319 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:31:02 +0000 Subject: [PATCH 09/51] CLI: Update Go SDK to 5e48c58 and restore the telemetry control/platform split Updates kernel-go-sdk to 5e48c587a312453969141e879b8e34d60cd1ab0f. The supplied /tmp/sdk-diff.patch was empty: the SDK staging repo is a shallow clone that no longer contains c0428370612f, so the diff could not be computed. It was reconstructed by diffing the module cache copy of c042837 against the new tree. 5e48c58 sits on top of 9a36566, so the control/platform telemetry split is back in the SDK and the CLI stopped compiling on BrowserTelemetryCategoriesConfigParam.Control. This reverts 063d7f5's code changes, restoring f9b126f verbatim: - `--telemetry=platform` is a settable category again on browsers create/update, browser-pools create/update/acquire, and auth connections create/update/login, and is reported by the telemetry summaries and details tables. - `--telemetry-cdp-exclude` is back on those same eight commands, setting BrowserTelemetryCdpControlConfigParam.ExcludedMethods. Its 38 accepted values were re-verified field-by-field against the SDK's BrowserCdpCommandMethod enum and match exactly. `=none` clears the list; combining it with `--telemetry=off` is rejected, and auth connection update/login require `--telemetry` alongside it. Coverage analysis: api.md now lists 145 methods, up from 140. The five additions are the new SiteConfigs resource (Get, List, ListRecommendations, Lookup, Resolve); all five carry x-cli-skip: true in openapi.yaml and stay out of the CLI, as does the auth connection exchange endpoint. A leaf-by-leaf pass over the other 140 found no missing commands, and a field-level diff of every Params struct between the two SDK versions found no new flags beyond the telemetry ones above (the remaining additions are LookupRequestParam/ResolveRequestParam and SiteConfigList*Params, all skipped). Remaining SDK changes are comment-only or internal: the browser_routing direct-to-VM allowlist drops "computer" and "playwright", and BrowserNewParams.GPU documents a region=us-east requirement. Tested against the production API: - browsers create --telemetry=control,platform,console --telemetry-cdp-exclude (mixed case input canonicalized to Input.dispatchMouseEvent, Page.captureScreenshot), update replacing exclusions, update --telemetry-cdp-exclude=none to clear, then delete - browsers telemetry events --categories platform returns the new platform_api_call events - browser-pools create --telemetry=control,platform --telemetry-cdp-exclude / get (details table shows the exclusions) / update / delete - auth connections create --telemetry=control,platform --telemetry-cdp-exclude / update / delete - error paths: unknown CDP method, --telemetry=off with --telemetry-cdp-exclude, unknown category (lists platform), and cdp-exclude without --telemetry on auth connections update - read-only sweep: auth context, auth connections list, browsers list, browser-pools list, app list, proxies list, profiles list, extensions list, org entitlements, telemetry destinations list, credentials list, projects list - go build ./..., go vet ./..., go test ./... all pass Triggered by: kernel/kernel-go-sdk@5e48c587a312453969141e879b8e34d60cd1ab0f Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 60 +++++++----- cmd/browser_pools.go | 87 ++++++++++------- cmd/browser_pools_test.go | 6 +- cmd/browsers.go | 126 +++++++++++++----------- cmd/browsers_telemetry.go | 173 ++++++++++++++++++++++++++++----- cmd/browsers_telemetry_test.go | 130 ++++++++++++++++++++----- go.mod | 2 +- go.sum | 4 +- 8 files changed, 417 insertions(+), 171 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index c6aedcfa..d7fc1f9a 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 { @@ -237,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 } @@ -383,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 } @@ -781,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 } @@ -1279,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") @@ -1308,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") @@ -1333,6 +1338,7 @@ 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) @@ -1387,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 @@ -1410,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, }) @@ -1443,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{} @@ -1496,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, }) @@ -1541,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, }) } diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index c6f7051b..276018d7 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 e0a143ff..387f3f18 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 326f88d1..5e4c82b7 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 9a37ef36..03699772 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 fe3b88e1..d9458bff 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 6b00cac9..a7c24c52 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.93.1-0.20260821173629-c0428370612f + github.com/kernel/kernel-go-sdk v0.93.1-0.20260824182203-5e48c587a312 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 50e70bb4..6074144b 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.93.1-0.20260821173629-c0428370612f h1:Nqwb7HXCMYBvltbuGbiD1Ms86aJs9JH46Q9aDNU/Oc8= -github.com/kernel/kernel-go-sdk v0.93.1-0.20260821173629-c0428370612f/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.93.1-0.20260824182203-5e48c587a312 h1:AuicZBMEwgADoR6EvMe8n6Mq4SE0O7OTxhE+dTAEsCs= +github.com/kernel/kernel-go-sdk v0.93.1-0.20260824182203-5e48c587a312/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= From 8b5a06bdc9e755564b6f4b33e0e77dd651502809 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:13:17 +0000 Subject: [PATCH 10/51] CLI: Update Go SDK to 26309b6 and drop the telemetry control/platform split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates kernel-go-sdk to 26309b6ff244c7c729ed11101ada01757df34212. The supplied /tmp/sdk-diff.patch was empty, so the diff was recomputed by unshallowing the SDK clone. The result explains the churn: the CLI's previous pin, 5e48c587a312, is not on the SDK's main branch — it lives on origin/stlc/promotion-conflict, forked from 0802326. 26309b6 is on main, so moving to it removes the SiteConfigs resource and the control/platform telemetry split again, and the CLI stopped compiling on BrowserTelemetryControlConfigParam, BrowserCdpCommandMethod, the `platform` category, and `control.cdp`. This is the same situation 063d7f5 handled, so it reverts 484e19f's code changes, leaving cmd/ byte-identical to the 063d7f5 state (modulo the unrelated MCP install work merged from main since): - `--telemetry` is back to the nine categories the SDK actually ships (captcha, connection, console, control, interaction, network, page, screenshot, system); `platform` is rejected again. - `--telemetry-cdp-exclude` is gone from all eight commands (browsers create/update, browser-pools create/update/acquire, auth connections create/update/login). 26309b6 is c042837 plus lib/browserrouting/route_cache.go and its tests (stale-JWT eviction), so the public API surface is identical to c042837 and nothing new needs CLI coverage. Coverage analysis: api.md lists 140 methods, down from 145 — the five removals are the SiteConfigs resource, which carried x-cli-skip: true in openapi.yaml and was never in the CLI. All 140 remaining methods were checked one by one against CLI call sites and every one is reachable; the nine that looked unmatched at first (Deployments/Invocations/Auth.Connections.Follow, Browsers.Logs.Stream, Browsers.Telemetry.Stream, Browsers.Process.StdoutStream, Invocations.DeleteBrowsers/ListBrowsers, Browsers.Curl) all resolve to *Streaming variants or, for curl, a deliberate raw-HTTP-through-the-browser implementation. A field-level pass over all 100 Params structs reachable from api.md flagged only three candidates, all non-gaps: AuditLogListParams.PageToken is handled by ListAutoPaging, AuthConnectionLoginParams.BrowserTelemetry is the deprecated alias for the browser.telemetry the CLI already sets, and BrowserCurlParams.TimeoutMs / ResponseEncoding are unused because `browsers curl` streams raw bytes and maps the timeout onto --max-time. Tested against the production API: - browsers create --telemetry=console,network,control / get / update --telemetry=page / telemetry events / delete - browser-pools create --telemetry=console,control / get (details table shows the categories) / update --telemetry=network / delete - auth connections create --telemetry=console,control / update --telemetry=network / get / delete - removed surfaces now rejected: --telemetry=platform lists only the nine valid categories, and --telemetry-cdp-exclude is an unknown flag on both browsers create and auth connections update - read-only sweep: auth context, browsers list, browser-pools list, auth connections list, app list, proxies list, profiles list, extensions list, org entitlements, org limits get, telemetry destinations list, credentials list, projects list, api-keys list, credential-providers list, audit-logs search - go build ./..., go vet ./..., go test ./... all pass Triggered by: kernel/kernel-go-sdk@26309b6ff244c7c729ed11101ada01757df34212 Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 60 +++++------- cmd/browser_pools.go | 87 +++++++---------- cmd/browser_pools_test.go | 6 +- cmd/browsers.go | 126 +++++++++++------------- cmd/browsers_telemetry.go | 173 +++++---------------------------- cmd/browsers_telemetry_test.go | 130 +++++-------------------- go.mod | 2 +- go.sum | 4 +- 8 files changed, 171 insertions(+), 417 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index d7fc1f9a..c6aedcfa 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -57,7 +57,6 @@ type AuthConnectionCreateInput struct { NoAutoReauth bool RecordSession BoolFlag Telemetry string - TelemetryCdpExclude string TelemetryExport string Output string } @@ -93,7 +92,6 @@ type AuthConnectionUpdateInput struct { AutoReauth BoolFlag RecordSession BoolFlag Telemetry string - TelemetryCdpExclude string TelemetryExport string Output string } @@ -113,16 +111,15 @@ type AuthConnectionDeleteInput struct { } type AuthConnectionLoginInput struct { - ID string - ProxyID string - ProxyName string - ProxyMode string - Stealth BoolFlag - RecordSession BoolFlag - Telemetry string - TelemetryCdpExclude string - TelemetryExport string - Output string + ID string + ProxyID string + ProxyName string + ProxyMode string + Stealth BoolFlag + RecordSession BoolFlag + Telemetry string + TelemetryExport string + Output string } type AuthConnectionSubmitInput struct { @@ -240,8 +237,8 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, true) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, true) if err != nil { return err } @@ -386,8 +383,8 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn hasChanges = true } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, false) if err != nil { return err } @@ -784,8 +781,8 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu params.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { - t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryExport, false) if err != nil { return err } @@ -1282,7 +1279,6 @@ 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") @@ -1312,7 +1308,6 @@ 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") @@ -1338,7 +1333,6 @@ 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) @@ -1393,7 +1387,6 @@ 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 @@ -1417,7 +1410,6 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { NoAutoReauth: noAutoReauth, RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, - TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, Output: output, }) @@ -1451,7 +1443,6 @@ 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{} @@ -1505,7 +1496,6 @@ 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, }) @@ -1551,22 +1541,20 @@ 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, - TelemetryCdpExclude: telemetryCdpExclude, - 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, + TelemetryExport: telemetryExport, + Output: output, }) } diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 276018d7..c6f7051b 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -108,24 +108,21 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err return nil } -// 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) +// buildPoolNewTelemetryParam converts a --telemetry flag value to the pool create param. +func buildPoolNewTelemetryParam(s string) (kernel.BrowserPoolNewParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s) return kernel.BrowserPoolNewParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// 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) +// buildPoolUpdateTelemetryParam converts a --telemetry flag value to the pool update param. +func buildPoolUpdateTelemetryParam(s string) (kernel.BrowserPoolUpdateParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s) return kernel.BrowserPoolUpdateParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// 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) +// buildPoolAcquireTelemetryParam converts a --telemetry flag value to the acquire override param. +func buildPoolAcquireTelemetryParam(s string) (kernel.BrowserPoolAcquireParamsTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s) return kernel.BrowserPoolAcquireParamsTelemetry{Enabled: enabled, Browser: browser}, err } @@ -135,11 +132,7 @@ func formatPoolTelemetry(cfg kernel.BrowserTelemetryConfig) string { if len(on) == 0 { return "disabled" } - base := strings.Join(on, ", ") - if ex := formatCdpExcludedMethods(cfg.Browser.Control.Cdp.ExcludedMethods); ex != "" { - return base + " (excluding CDP methods: " + ex + ")" - } - return base + return strings.Join(on, ", ") } type BrowserPoolsCreateInput struct { @@ -162,7 +155,6 @@ type BrowserPoolsCreateInput struct { ChromePolicy string ChromePolicyFile string Telemetry string - TelemetryCdpExclude string Output string } @@ -255,8 +247,8 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) params.ChromePolicy = chromePolicy } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" { - t, err := buildPoolNewTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) + if in.Telemetry != "" { + t, err := buildPoolNewTelemetryParam(in.Telemetry) if err != nil { return err } @@ -277,7 +269,7 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) } else { pterm.Success.Printf("Created browser pool %s\n", pool.ID) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" { + if in.Telemetry != "" { printTelemetrySummary(pool.BrowserPoolConfig.Telemetry) } return nil @@ -358,7 +350,6 @@ type BrowserPoolsUpdateInput struct { ChromePolicyFile string ClearChromePolicy bool Telemetry string - TelemetryCdpExclude string DiscardAllIdle BoolFlag Output string } @@ -497,8 +488,8 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) params.SetExtraFields(extraFields) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" { - t, err := buildPoolUpdateTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) + if in.Telemetry != "" { + t, err := buildPoolUpdateTelemetryParam(in.Telemetry) if err != nil { return err } @@ -519,7 +510,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) } else { pterm.Success.Printf("Updated browser pool %s\n", pool.ID) } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" { + if in.Telemetry != "" { printTelemetrySummary(pool.BrowserPoolConfig.Telemetry) } return nil @@ -544,14 +535,13 @@ 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 - TelemetryCdpExclude string - Output string + IDOrName string + TimeoutSeconds int64 + Name string + StartURL string + Tags map[string]string + Telemetry string + Output string } // buildAcquireParams builds the SDK params for acquiring a browser from a pool. @@ -559,7 +549,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, telemetryCdpExclude, startURL string) (kernel.BrowserPoolAcquireParams, error) { +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) @@ -573,8 +563,8 @@ func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int6 if len(tags) > 0 { params.Tags = kernel.Tags(tags) } - if telemetry != "" || telemetryCdpExclude != "" { - t, err := buildPoolAcquireTelemetryParam(telemetry, telemetryCdpExclude) + if telemetry != "" { + t, err := buildPoolAcquireTelemetryParam(telemetry) if err != nil { return kernel.BrowserPoolAcquireParams{}, err } @@ -588,7 +578,7 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu return err } - params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.TelemetryCdpExclude, in.StartURL) + params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.StartURL) if err != nil { return err } @@ -759,7 +749,6 @@ 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) @@ -790,7 +779,6 @@ 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) @@ -801,7 +789,6 @@ 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") @@ -858,7 +845,6 @@ 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{ @@ -881,7 +867,6 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { ChromePolicy: chromePolicy, ChromePolicyFile: chromePolicyFile, Telemetry: telemetry, - TelemetryCdpExclude: telemetryCdpExclude, Output: output, } @@ -923,7 +908,6 @@ 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") @@ -953,7 +937,6 @@ 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, } @@ -976,18 +959,16 @@ 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, - TelemetryCdpExclude: telemetryCdpExclude, - Output: output, + 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 387f3f18..e0a143ff 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 5e4c82b7..326f88d1 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -360,32 +360,31 @@ 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 - TelemetryCdpExclude 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 + TelemetryExport string + ChromePolicy string + ChromePolicyFile string + Name string + Tags map[string]string + Output string } type BrowsersDeleteInput struct { @@ -416,7 +415,6 @@ type BrowsersUpdateInput struct { Viewport string Force bool Telemetry string - TelemetryCdpExclude string Name string SetName bool ClearName bool @@ -671,8 +669,8 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } - if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { - t, err := buildNewTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildNewTelemetryParam(in.Telemetry, in.TelemetryExport) if err != nil { return err } @@ -707,7 +705,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.TelemetryCdpExclude != "" || in.TelemetryExport != "" { + if in.Telemetry != "" || in.TelemetryExport != "" { printTelemetrySummary(browser.Telemetry) } return nil @@ -943,8 +941,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 == "" && 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") + 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") } params := kernel.BrowserUpdateParams{} @@ -987,8 +985,8 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } // Handle telemetry changes - if in.Telemetry != "" || in.TelemetryCdpExclude != "" { - t, err := buildUpdateTelemetryParam(in.Telemetry, in.TelemetryCdpExclude) + if in.Telemetry != "" { + t, err := buildUpdateTelemetryParam(in.Telemetry) if err != nil { return err } @@ -1038,7 +1036,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 != "" || in.TelemetryCdpExclude != "" { + if in.Telemetry != "" { printTelemetrySummary(browser.Telemetry) } return nil @@ -2684,7 +2682,6 @@ 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") @@ -2966,7 +2963,6 @@ 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)") @@ -3098,7 +3094,6 @@ 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") @@ -3165,7 +3160,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, telemetryCdpExclude, startURL) + acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry, startURL) if err != nil { return err } @@ -3207,32 +3202,31 @@ 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, - TelemetryCdpExclude: telemetryCdpExclude, - 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, + TelemetryExport: telemetryExport, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, + Name: name, + Tags: tags, + Output: output, } svc := client.Browsers @@ -3294,7 +3288,6 @@ 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") @@ -3315,7 +3308,6 @@ 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 03699772..9a37ef36 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -76,9 +76,7 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig case "interaction": p.Interaction = on() case "control": - p.Control = kernel.BrowserTelemetryControlConfigParam{Enabled: kernel.Opt(true)} - case "platform": - p.Platform = on() + p.Control = on() case "connection": p.Connection = on() case "system": @@ -94,112 +92,20 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig return p, nil } -// 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 +// 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) { switch s { case "all": - enabled = kernel.Opt(true) + return kernel.Opt(true), kernel.BrowserTelemetryCategoriesConfigParam{}, nil case "off": - enabled = kernel.Opt(false) + return kernel.Opt(false), kernel.BrowserTelemetryCategoriesConfigParam{}, nil default: - var err error - if p, err = parseTelemetryCategories(s); err != nil { - return enabled, p, err - } - } - if cdpExclude == "" { - return enabled, p, nil + p, err := parseTelemetryCategories(s) + return param.Opt[bool]{}, p, err } - // 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 @@ -261,10 +167,10 @@ func validateTelemetryExportCombo(telemetry, id, name string, canImply bool) err return nil } -// buildNewTelemetryParam converts --telemetry, --telemetry-cdp-exclude and -// --telemetry-export-otlp flag values to the create API param. -func buildNewTelemetryParam(s, cdpExclude, export string) (kernel.BrowserNewParamsTelemetry, error) { - enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) +// 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) p := kernel.BrowserNewParamsTelemetry{Enabled: enabled, Browser: browser} if err != nil || export == "" { return p, err @@ -301,37 +207,26 @@ func optIfSet(s string) param.Opt[string] { return kernel.Opt(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) +// 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 } -// 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. +// 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, cdpExclude, export string, canImply bool) (kernel.ManagedAuthBrowserConfigTelemetryParam, error) { - enabled, browser, err := resolveTelemetryFlag(s, cdpExclude) +func buildManagedAuthTelemetryParam(s, export string, canImply bool) (kernel.ManagedAuthBrowserConfigTelemetryParam, error) { + enabled, browser, err := resolveTelemetryFlag(s) p := kernel.ManagedAuthBrowserConfigTelemetryParam{Enabled: enabled, Browser: browser} - if err != nil { + if err != nil || export == "" { 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 @@ -369,9 +264,6 @@ 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 + ")" } @@ -395,7 +287,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", "platform", "captcha", + "control", "connection", "system", "screenshot", "captcha", } // streamFilterCategories are the categories accepted by `telemetry stream --categories`. @@ -418,7 +310,6 @@ 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)) @@ -439,9 +330,6 @@ 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. @@ -453,19 +341,6 @@ 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 d9458bff..fe3b88e1 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -299,17 +299,14 @@ func TestShouldEmit(t *testing.T) { } func TestParseTelemetryCategories_OptInList(t *testing.T) { - p, err := parseTelemetryCategories("network,control,captcha,platform") + p, err := parseTelemetryCategories("network,control,captcha") assert.NoError(t, err) // Listed categories are enabled. - for _, c := range []kernel.BrowserTelemetryCategoryConfigParam{p.Network, p.Captcha, p.Platform} { + for _, c := range []kernel.BrowserTelemetryCategoryConfigParam{p.Network, p.Control, p.Captcha} { 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()) @@ -339,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()) @@ -369,7 +366,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()) @@ -378,7 +375,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()) @@ -386,20 +383,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()) @@ -408,7 +405,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") }) @@ -417,44 +414,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) { @@ -462,9 +459,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) @@ -472,13 +469,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) }) } @@ -721,82 +718,3 @@ 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 a7c24c52..509431ed 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.93.1-0.20260824182203-5e48c587a312 + github.com/kernel/kernel-go-sdk v0.93.1-0.20260824200655-26309b6ff244 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 6074144b..42f22b66 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.93.1-0.20260824182203-5e48c587a312 h1:AuicZBMEwgADoR6EvMe8n6Mq4SE0O7OTxhE+dTAEsCs= -github.com/kernel/kernel-go-sdk v0.93.1-0.20260824182203-5e48c587a312/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.93.1-0.20260824200655-26309b6ff244 h1:g+SxCHIYaInOVKsSKsieiKzUAcsl19M7ScFCN2Mpzd0= +github.com/kernel/kernel-go-sdk v0.93.1-0.20260824200655-26309b6ff244/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= From c6c402b2f0ada60304b78b5c16049d79042e0609 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:52:21 +0000 Subject: [PATCH 11/51] CLI: Update Go SDK to 9de3679 (v0.94.0) and restore the telemetry control/platform split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates kernel-go-sdk to 9de3679a3880b1fff724377144c60e26e630f7b0, which the SDK tags v0.94.0. The supplied /tmp/sdk-diff.patch was empty again, so the diff was recomputed from the SDK clone. 9de3679 merges the release-please branch onto 26309b6, and that merge brings back the surface the previous pin had dropped: the SiteConfigs resource (140 -> 145 configured endpoints) and the control/platform telemetry split. The CLI stopped compiling on BrowserTelemetryCategoriesConfigParam.Control, whose type changed from BrowserTelemetryCategoryConfigParam to BrowserTelemetryControlConfigParam. This is the same flip 484e19f handled, so it reverts 8b5a06b's code changes, leaving cmd/ byte-identical to the 484e19f state: - `--telemetry` accepts ten categories again (captcha, connection, console, control, interaction, network, page, platform, screenshot, system); `platform` is valid. - `--telemetry-cdp-exclude` is back on all eight commands (browsers create/update, browser-pools create/update/acquire, auth connections create/update/login), carrying control.cdp.excluded_methods. The CLI's 38-entry cdpCommandMethods list was diffed against the SDK's BrowserCdpCommandMethod enum at 9de3679 and is identical. Coverage analysis: api.md lists 145 methods, up from 140. All five additions are the SiteConfigs resource (Get, List, ListRecommendations, Lookup, Resolve), and every one carries x-cli-skip: true in openapi.yaml, so none needs a CLI command. The remaining 140 methods are unchanged from the previous pin and all resolve to CLI call sites. A field-level diff of every Params and Param struct between 26309b6 and 9de3679 found additions in only two places: the SiteConfig*Params / LookupRequestParam / ResolveRequestParam structs (skipped with their endpoints) and the telemetry structs restored here (BrowserTelemetryCategoriesConfigParam.Platform, BrowserTelemetryControlConfigParam.Enabled/Cdp, BrowserTelemetryCdpControlConfigParam.ExcludedMethods). The remaining SDK changes are doc-comment rewraps plus new response-only telemetry event types (cdp_command, page_crashed, platform_api_call), which need no flags because `telemetry events --types` filters on free-form strings. Tested against the production API: - browsers create --telemetry=console,control,platform --telemetry-cdp-exclude=Input.dispatchMouseEvent,Page.captureScreenshot — the response echoes platform.enabled and both excluded methods - browsers get --output json (telemetry block round-trips) / update --telemetry=network --telemetry-cdp-exclude=none (clears the list) / delete - browsers telemetry events showed a real platform_api_call event, and --categories platform filtered to it - browser-pools create --telemetry=console,control,platform --telemetry-cdp-exclude=Page.navigate / get (details row renders "excluding CDP methods: Page.navigate") / update --telemetry-cdp-exclude=none / delete - auth connections create --telemetry=console,control,platform --telemetry-cdp-exclude=Page.navigate / update --telemetry=network / delete - validation: --telemetry=bogus lists all ten categories, --telemetry=off with --telemetry-cdp-exclude is rejected, an unknown CDP method lists the 38 valid ones, and --telemetry-cdp-exclude without --telemetry is rejected on auth connections update - read-only sweep: auth context, browsers list, browser-pools list, auth connections list, app list, proxies list, profiles list, extensions list, org entitlements, org limits get, telemetry destinations list, credentials list, projects list, api-keys list, credential-providers list, audit-logs search - go build ./..., go vet ./..., go test ./... all pass Triggered by: kernel/kernel-go-sdk@9de3679a3880b1fff724377144c60e26e630f7b0 Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 60 +++++++----- cmd/browser_pools.go | 87 ++++++++++------- cmd/browser_pools_test.go | 6 +- cmd/browsers.go | 126 +++++++++++++----------- cmd/browsers_telemetry.go | 173 ++++++++++++++++++++++++++++----- cmd/browsers_telemetry_test.go | 130 ++++++++++++++++++++----- go.mod | 2 +- go.sum | 4 +- 8 files changed, 417 insertions(+), 171 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index c6aedcfa..d7fc1f9a 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 { @@ -237,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 } @@ -383,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 } @@ -781,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 } @@ -1279,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") @@ -1308,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") @@ -1333,6 +1338,7 @@ 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) @@ -1387,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 @@ -1410,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, }) @@ -1443,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{} @@ -1496,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, }) @@ -1541,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, }) } diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index c6f7051b..276018d7 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 e0a143ff..387f3f18 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 326f88d1..5e4c82b7 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 9a37ef36..03699772 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 fe3b88e1..d9458bff 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 509431ed..4e7fd39c 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.93.1-0.20260824200655-26309b6ff244 + 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 42f22b66..f0e19511 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.93.1-0.20260824200655-26309b6ff244 h1:g+SxCHIYaInOVKsSKsieiKzUAcsl19M7ScFCN2Mpzd0= -github.com/kernel/kernel-go-sdk v0.93.1-0.20260824200655-26309b6ff244/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= From ee72f2d2faf2806b70157206a4c52f9186b37c8d Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:50:29 +0000 Subject: [PATCH 12/51] CLI: Update Go SDK to c472a30 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps kernel-go-sdk from v0.94.0 to v0.94.1-0.20260826014443-c472a306c236 (c472a30). The only SDK change is internal request routing: "process" is now in the list of path prefixes eligible for direct-to-VM routing, so `kernel browsers process ...` calls go straight to the browser VM instead of through the control plane. No API surface changed — no new methods, no new params. A full enumeration of all 145 SDK methods in api.md against the CLI command tree found no coverage gaps. The 5 SiteConfigs methods are marked x-cli-skip in openapi.yaml and are intentionally not exposed. Every Params struct field maps to an existing flag. Tested against the live API with the new SDK, exercising every browsers process subcommand now affected by direct-to-VM routing: - browsers process exec (exit 0, stdout returned) - browsers process spawn - browsers process status (running, then exited) - browsers process stdin - browsers process stdout-stream (live tick1..tick5 + exit notice) - browsers process resize (PTY 120x40) - browsers process kill (TERM) Plus browsers create/delete for setup and teardown. go build ./... and go test ./... both pass. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4e7fd39c..5cf41be2 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.94.0 + github.com/kernel/kernel-go-sdk v0.94.1-0.20260826014443-c472a306c236 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 f0e19511..1e0b42bb 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.94.0 h1:YepYs5dadsnZq+gI5Ic4+ky4IIWNpm/791S6YFK2u0Y= -github.com/kernel/kernel-go-sdk v0.94.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.94.1-0.20260826014443-c472a306c236 h1:QKuH4ly2RzLGzWzLLjrT0/SaOtcRjEe36sVtb0QtJm0= +github.com/kernel/kernel-go-sdk v0.94.1-0.20260826014443-c472a306c236/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= From 99b27bb650596fa23e9f8fa938a7dc0f37ec21e8 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:19:49 +0000 Subject: [PATCH 13/51] feat: update Go SDK to 46978e2 and add ap-southeast region Updates kernel-go-sdk to v0.94.1-0.20260826181154-46978e2734f1. Full enumeration of api.md (145 SDK methods) against the CLI command tree found no missing commands or param fields. The only functional change in this SDK bump is a new `ap-southeast` value for the browser session and browser pool region enums. - Add "ap-southeast" to availableRegions(), which backs --region validation for `browsers list`, `browsers create`, `browser-pools list`, and `browser-pools create` - Update the flag help text and README for those four flags Tested against the live API: - browsers create --region ap-southeast -> browsers get (region reported as ap-southeast) -> browsers delete - browser-pools create --region ap-southeast -> browser-pools get (region ap-southeast) -> browser-pools delete - browsers list / browser-pools list --region ap-southeast return the new resources; --region eu-west correctly excludes them - browsers list --region bogus still rejected with the updated "us-east, eu-west, ap-southeast" message - go build ./... and go test ./cmd/... pass Co-Authored-By: Claude Opus 5 --- README.md | 6 +++--- cmd/browser_pools.go | 4 ++-- cmd/browsers.go | 6 +++--- go.mod | 2 +- go.sum | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4f7655e4..cdb1c9b3 100644 --- a/README.md +++ b/README.md @@ -210,14 +210,14 @@ Commands with JSON output support: - `kernel browsers list` - List running browsers - `--query ` - Search by name, session ID, profile ID, proxy ID, or pool name - - `--region us-east|eu-west` - Filter by geographic region; omit to list sessions in all regions + - `--region us-east|eu-west|ap-southeast` - Filter by geographic region; omit to list sessions in all regions - `--tag ` - Filter by tag, repeatable; a session must match every pair - `--output json`, `-o json` - Output raw JSON array - `kernel browsers create` - Create a new browser session - `-s, --stealth` - Launch browser in stealth mode to avoid detection - `-H, --headless` - Launch browser without GUI access - `--kiosk` - Launch browser in kiosk mode - - `--region us-east|eu-west` - Geographic region for the session. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to `us-east`. + - `--region us-east|eu-west|ap-southeast` - Geographic region for the session. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to `us-east`. - `--private-host ` - Destination the browser reaches directly through the session's own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeatable or comma-separated, max 32). Accepts hostname patterns (`*.example.ts.net`), IPs (`10.1.30.63`, `[fd00::1]`), and private CIDRs (`100.64.0.0/10`). Replaces the default private ranges (RFC1918, `100.64.0.0/10`, `fc00::/7`); omit to keep them. Fixed once the session is created. Unrelated to a proxy's `--bypass-host`, which only chooses between upstream proxy and Kernel-managed direct egress. - `--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`) @@ -270,7 +270,7 @@ Commands with JSON output support: ### Browser Pools - `kernel browser-pools list` - List browser pools - - `--region us-east|eu-west` - Filter by geographic region; omit to list pools in all regions + - `--region us-east|eu-west|ap-southeast` - Filter by geographic region; omit to list pools in all regions - `--output json`, `-o json` - Output raw JSON array - `kernel browser-pools create` - Create a browser pool - `--name ` - Optional unique name for the pool diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 276018d7..14bc79fa 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -736,7 +736,7 @@ func init() { browserPoolsListCmd.Flags().String("query", "", "Search browser pools by name (IDs match by exact value)") browserPoolsListCmd.Flags().Int("limit", 0, "Maximum number of pools to return") browserPoolsListCmd.Flags().Int("offset", 0, "Number of pools to skip (for pagination)") - browserPoolsListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east' or 'eu-west' (omit to list pools in all regions)") + browserPoolsListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east', 'eu-west', or 'ap-southeast' (omit to list pools in all regions)") addJSONOutputFlag(browserPoolsCreateCmd) browserPoolsCreateCmd.Flags().String("name", "", "Optional unique name for the pool") @@ -751,7 +751,7 @@ func init() { browserPoolsCreateCmd.Flags().String("profile-id", "", "Profile ID") browserPoolsCreateCmd.Flags().String("profile-name", "", "Profile name") browserPoolsCreateCmd.Flags().String("proxy-id", "", "Proxy ID") - browserPoolsCreateCmd.Flags().String("region", "", "Geographic region for the pool: 'us-east' or 'eu-west'. Fixed once the pool is created; requires a Start-Up or Enterprise plan and defaults to us-east") + browserPoolsCreateCmd.Flags().String("region", "", "Geographic region for the pool: 'us-east', 'eu-west', or 'ap-southeast'. Fixed once the pool is created; requires a Start-Up or Enterprise plan and defaults to us-east") browserPoolsCreateCmd.Flags().StringSlice("private-host", nil, "Destinations browsers in the pool reach directly through their own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel they join (repeat or comma-separated, max 32). Accepts hostname patterns ('*.example.ts.net'), IPs ('10.1.30.63', '[fd00::1]'), and private CIDRs ('100.64.0.0/10'). Replaces the default private ranges (RFC1918, 100.64.0.0/10, fc00::/7); omit to keep them") browserPoolsCreateCmd.Flags().String("start-url", "", "Initial page to open for new browsers") browserPoolsCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") diff --git a/cmd/browsers.go b/cmd/browsers.go index 5e4c82b7..0e014929 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -165,7 +165,7 @@ func parseViewport(viewport string) (width, height, refreshRate int64, err error // availableRegions returns the geographic regions the API accepts for browser // sessions and pools. func availableRegions() []string { - return []string{"us-east", "eu-west"} + return []string{"us-east", "eu-west", "ap-southeast"} } // parseRegionFlag validates a --region value. An empty value means the flag was @@ -2661,7 +2661,7 @@ func init() { browsersListCmd.Flags().Int("limit", 0, "Maximum number of results to return (default 20, max 100)") browsersListCmd.Flags().Int("offset", 0, "Number of results to skip (for pagination)") browsersListCmd.Flags().String("query", "", "Search browsers by name, session ID, profile ID, proxy ID, or pool name") - browsersListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east' or 'eu-west' (omit to list sessions in all regions)") + browsersListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east', 'eu-west', or 'ap-southeast' (omit to list sessions in all regions)") browsersListCmd.Flags().StringArray("tag", nil, "Filter by tag KEY=VALUE (repeatable; a session must match every pair)") // get flags @@ -2957,7 +2957,7 @@ func init() { browsersCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for the browser session (mutually exclusive with --proxy-name and --proxy-mode)") browsersCreateCmd.Flags().String("proxy-name", "", "Proxy name to use for the browser session; must match exactly one active proxy in the project (mutually exclusive with --proxy-id and --proxy-mode)") browsersCreateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the browser default (Kernel's stealth proxy when --stealth is set, direct egress otherwise)") - browsersCreateCmd.Flags().String("region", "", "Geographic region for the session: 'us-east' or 'eu-west'. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to us-east") + browsersCreateCmd.Flags().String("region", "", "Geographic region for the session: 'us-east', 'eu-west', or 'ap-southeast'. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to us-east") browsersCreateCmd.Flags().StringSlice("private-host", nil, "Destinations the browser reaches directly through its own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeat or comma-separated, max 32). Accepts hostname patterns ('*.example.ts.net'), IPs ('10.1.30.63', '[fd00::1]'), and private CIDRs ('100.64.0.0/10'). Replaces the default private ranges (RFC1918, 100.64.0.0/10, fc00::/7); omit to keep them. Fixed once the session is created") browsersCreateCmd.Flags().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)") diff --git a/go.mod b/go.mod index 5cf41be2..ac1f24fc 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.94.1-0.20260826014443-c472a306c236 + github.com/kernel/kernel-go-sdk v0.94.1-0.20260826181154-46978e2734f1 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 1e0b42bb..4c723f14 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.94.1-0.20260826014443-c472a306c236 h1:QKuH4ly2RzLGzWzLLjrT0/SaOtcRjEe36sVtb0QtJm0= -github.com/kernel/kernel-go-sdk v0.94.1-0.20260826014443-c472a306c236/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.94.1-0.20260826181154-46978e2734f1 h1:zf6tNuvXVfUxSyZUrjYo+owt+lk3Vn41OX+NCCP7Y+s= +github.com/kernel/kernel-go-sdk v0.94.1-0.20260826181154-46978e2734f1/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= From 3ce0c81c5baf2b7695b0ffd7f895406af7d7c694 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:28:37 +0000 Subject: [PATCH 14/51] chore: update Go SDK to v0.95.0 (0c36fa4) Bumps github.com/kernel/kernel-go-sdk from v0.94.1-0.20260826181154-46978e2734f1 to v0.95.0. The SDK diff between the two versions touches only internal/version.go (release 0.95.0) -- no new methods, params, or fields. A full enumeration of all 152 SDK methods in api.md against every CLI leaf command confirmed no coverage gaps, and a field-level comparison of all 105 SDK *Params structs against CLI flags found no missing flags. Tested: go build ./..., go test ./cmd/... (all pass), kernel browsers list, kernel app list against the live API. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ac1f24fc..45ca6fa1 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.94.1-0.20260826181154-46978e2734f1 + github.com/kernel/kernel-go-sdk v0.95.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 4c723f14..8c92c4ec 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.94.1-0.20260826181154-46978e2734f1 h1:zf6tNuvXVfUxSyZUrjYo+owt+lk3Vn41OX+NCCP7Y+s= -github.com/kernel/kernel-go-sdk v0.94.1-0.20260826181154-46978e2734f1/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.95.0 h1:VoEneqrqqT5i3cO1L6faoHo4PqrndG0pAEs6Abivd5A= +github.com/kernel/kernel-go-sdk v0.95.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= From 11fa9ed702628d74da3268db9374170f94c102df Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:04:44 +0000 Subject: [PATCH 15/51] chore: update Go SDK to d348ffc and repair merge fallout Bumps github.com/kernel/kernel-go-sdk to d348ffc2b54ec8bc313b09abb34184d8cfe5681c (v0.95.1-0.20260826235549). The only API change in this range renames SiteConfigs to ConfigRegistry and moves analysis get/list onto a ConfigRegistry.Analyses subresource. Every config-registry endpoint carries x-cli-skip: true in openapi.yaml, so no CLI coverage is needed. A full enumeration of the remaining 139 SDK methods and their param fields against the CLI found no other gaps. The merge that produced this branch left two conflicts half-resolved, both of which are fixed here: - auth_connections.go kept main's `ReplaceExisting: f.Reason == "rejected"` call sites against this branch's `Reason string` field, so the package did not compile. They now pass the reason through, matching the branch's formatter and its `reason=rejected` assertion. - browsers_telemetry.go listed "platform" in settableCategories but had no matching case in parseTelemetryCategories, so --telemetry=platform failed with an error naming platform as a valid value. Tested: go build ./... and go test ./... pass (previously the cmd package did not compile). Against the live API: `browsers create --telemetry platform` reports "Telemetry capturing: ... platform", and after an in-VM call `browsers telemetry events ` shows a platform/platform_api_call event. `auth connections list` verified; sessions cleaned up. Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 4 ++-- cmd/browsers_telemetry.go | 2 ++ go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 26dd175f..9edb4036 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -560,7 +560,7 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e Ref: f.Ref, Hint: f.Hint, Required: f.Required, - ReplaceExisting: f.Reason == "rejected", + Reason: string(f.Reason), })) } tableData = append(tableData, []string{"Fields", strings.Join(fields, "; ")}) @@ -1113,7 +1113,7 @@ func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowIn Ref: f.Ref, Hint: f.Hint, Required: f.Required, - ReplaceExisting: f.Reason == "rejected", + Reason: string(f.Reason), })) } pterm.Info.Printf(" Fields: %s\n", strings.Join(fields, ", ")) diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 203d7c53..2d94f99f 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -83,6 +83,8 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig p.System = on() case "screenshot": p.Screenshot = on() + case "platform": + p.Platform = on() case "captcha": p.Captcha = on() default: diff --git a/go.mod b/go.mod index 45ca6fa1..0da0453a 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.95.0 + github.com/kernel/kernel-go-sdk v0.95.1-0.20260826235549-d348ffc2b54e 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 8c92c4ec..01448a70 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.95.0 h1:VoEneqrqqT5i3cO1L6faoHo4PqrndG0pAEs6Abivd5A= -github.com/kernel/kernel-go-sdk v0.95.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.95.1-0.20260826235549-d348ffc2b54e h1:k2o1cCZ39o7N1uv5tolgt7cpYlgZ7lwI9yzKlMiwvXc= +github.com/kernel/kernel-go-sdk v0.95.1-0.20260826235549-d348ffc2b54e/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= From 3ce899db3cd77dc8cf00975ad6494ee0f8eb057d Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:13:18 +0000 Subject: [PATCH 16/51] chore: update Go SDK to v0.96.0 (bb4371c) Bumps github.com/kernel/kernel-go-sdk from v0.95.1-0.20260826235549-d348ffc2b54e to v0.96.0 (bb4371c). The upstream diff is release-only (CHANGELOG, README, internal/version.go, release-please manifest) with no API surface changes. Coverage analysis: full enumeration of all 145 SDK methods in api.md against the CLI command tree found no gaps. 5 ConfigRegistry methods are marked x-cli-skip in openapi.yaml and are excluded; the remaining 140 all have CLI commands. A recursive field-level sweep of all 100 param types (606 fields including nested structs) found no missing flags -- the only unreferenced fields are intentional: - AuditLogListParams.PageToken: handled internally by ListAutoPaging - AuthConnectionLoginParams.BrowserTelemetry/Proxy: deprecated in the SDK, superseded by browser.telemetry / browser.proxy, which are covered - BrowserCurlParams.TimeoutMs/ResponseEncoding: `browsers curl` goes through the browser HTTP proxy, where --max-time covers the timeout and binary responses are streamed natively - BrowserComputerBatchParams.Actions.SetCursor/Sleep: `computer batch` takes raw JSON via --actions, so all action types pass through Tested: go build ./..., go vet ./..., go test ./... (all pass); smoke tested against the live API with browsers list, profiles list (pagination footer), and a browsers create -> get -> delete round-trip. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0da0453a..4950784b 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.95.1-0.20260826235549-d348ffc2b54e + github.com/kernel/kernel-go-sdk v0.96.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 01448a70..fc0778f8 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.95.1-0.20260826235549-d348ffc2b54e h1:k2o1cCZ39o7N1uv5tolgt7cpYlgZ7lwI9yzKlMiwvXc= -github.com/kernel/kernel-go-sdk v0.95.1-0.20260826235549-d348ffc2b54e/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.96.0 h1:D5e82/tZItT9qzBgoS1eDlw6J/en8Irf8Buu88exxiA= +github.com/kernel/kernel-go-sdk v0.96.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= From 77bddaed4d797cfa9fbb0a7b46454ebc266f1eb0 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:03:35 +0000 Subject: [PATCH 17/51] chore: update Go SDK to 6e498fb (captcha task and challenge outcomes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps github.com/kernel/kernel-go-sdk from v0.96.0 to v0.96.1-0.20260828125604-6e498fbbc12d (6e498fb). The SDK change is response-type only: two new browser telemetry event variants (captcha_solve_started, captcha_challenge_result) plus a challenge_id field on captcha_solve_result. No new API methods and no new request params, so no new commands or flags are needed — the telemetry commands render event category/type generically and the --types filter is free-form, so the new event types flow through unchanged. A full enumeration of all 148 SDK methods in api.md against the CLI command tree found no coverage gaps. The only uncovered endpoints are the /config-registry routes, which are marked x-cli-skip: true in openapi.yaml. Param-field coverage was likewise checked across all 105 *Params structs. Tested: go test ./... passes; browsers create --telemetry=all, browsers telemetry events, browsers telemetry stream, browsers delete all verified against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4950784b..f59697e7 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.96.0 + github.com/kernel/kernel-go-sdk v0.96.1-0.20260828125604-6e498fbbc12d 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 fc0778f8..3549df66 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.96.0 h1:D5e82/tZItT9qzBgoS1eDlw6J/en8Irf8Buu88exxiA= -github.com/kernel/kernel-go-sdk v0.96.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.96.1-0.20260828125604-6e498fbbc12d h1:vzv+N66Xr63nepg9plm0E0ogptewKkkSJmhVmRb4M2c= +github.com/kernel/kernel-go-sdk v0.96.1-0.20260828125604-6e498fbbc12d/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= From aa1b67c69e6755b9428359c0135d1e0af20d59ea Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:48:46 +0000 Subject: [PATCH 18/51] chore: update Go SDK to 94c784a (managed auth reauth reasons) Bumps github.com/kernel/kernel-go-sdk to v0.96.1-0.20260831004050-94c784ab3169 (94c784ab). SDK change is response-side only: ManagedAuthCanReauthReason gains a totp_reauth_allowed value and requirements_satisfiable is redocumented. The CLI renders CanReauthReason as a raw string in `auth connections get`/`list`, so the new value flows through with no code change. Coverage analysis: full enumeration of all 145 methods in api.md against the CLI command tree found no gaps. Every method has a command (the six config-registry and auth exchange endpoints are x-cli-skip), and every field across the 104 params structs maps to a flag or positional arg. Tested: go build ./..., go vet ./..., go test ./... all pass; `kernel auth connections list` and `kernel auth connections get ` against the live API render Can Reauth / Can Reauth Reason correctly. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f59697e7..314fc3bc 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.96.1-0.20260828125604-6e498fbbc12d + github.com/kernel/kernel-go-sdk v0.96.1-0.20260831004050-94c784ab3169 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 3549df66..aa4ff4bf 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.96.1-0.20260828125604-6e498fbbc12d h1:vzv+N66Xr63nepg9plm0E0ogptewKkkSJmhVmRb4M2c= -github.com/kernel/kernel-go-sdk v0.96.1-0.20260828125604-6e498fbbc12d/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.96.1-0.20260831004050-94c784ab3169 h1:gPikYLp5vAJ5pfe8UbZOnTlhn6IGuwxT56oclbheO5A= +github.com/kernel/kernel-go-sdk v0.96.1-0.20260831004050-94c784ab3169/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= From bd990592c4af1b0a0e981ca562511c764e977b7e Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:11:48 +0000 Subject: [PATCH 19/51] chore: update Go SDK to v0.97.0 (e9ee30b) Bumps github.com/kernel/kernel-go-sdk from v0.96.1-0.20260831004050-94c784ab3169 to v0.97.0 (e9ee30b). The SDK delta is a release-only bump (CHANGELOG, README, version.go, release-please manifest); no API surface changed. Coverage analysis: enumerated all 145 methods in the SDK api.md and the full CLI command tree (153 commands). The only uncovered SDK methods are the ConfigRegistry endpoints (/config-registry, /lookup, /resolve, /analyses, /analyses/{id}), all marked x-cli-skip: true in openapi.yaml. Param-field cross-check surfaced no missing flags. Tested: go build ./..., go test ./... (all pass), and smoke-tested `kernel browsers list` and `kernel profiles list` against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 314fc3bc..3b361f0e 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.96.1-0.20260831004050-94c784ab3169 + github.com/kernel/kernel-go-sdk v0.97.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 aa4ff4bf..8acc36e1 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.96.1-0.20260831004050-94c784ab3169 h1:gPikYLp5vAJ5pfe8UbZOnTlhn6IGuwxT56oclbheO5A= -github.com/kernel/kernel-go-sdk v0.96.1-0.20260831004050-94c784ab3169/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.97.0 h1:jj6dFhiGdjkdFMdEsUDbnWkMJ2js/p1BBipa2QqvWXA= +github.com/kernel/kernel-go-sdk v0.97.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= From fc5ba8231fa3d6942e32361b1a1ef1b36883ff40 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:12:00 +0000 Subject: [PATCH 20/51] chore: update Go SDK to ed434f7 and add browsers webmcp commands Updates github.com/kernel/kernel-go-sdk to v0.97.1-0.20260902190118-ed434f757fca (ed434f7). SDK changes covered: - New resource client.Browsers.Webmcp -> `kernel browsers webmcp` - `list-tools ` for client.Browsers.Webmcp.ListTools - `invoke-tool ` for client.Browsers.Webmcp.InvokeTool with --tool-ref, --input (JSON, '-' reads stdin), --timeout-sec for InvokeRequestParam.{ToolRef,Input,TimeoutSec} - All /browsers/{id_or_name} sub-resource routes now accept a browser session name. Migrated the renamed path param field (Params.ID -> Params.IDOrName) for replays stop/download, process kill/status/stdin/stdout-stream/resize, and fs watch stop/events, and synced the local service interface signatures to idOrName. Coverage analysis: full enumeration of api.md against the CLI command tree found no other gaps. ConfigRegistry endpoints are x-cli-skip. Tested against the live API: - browsers webmcp list-tools / invoke-tool: correct paths and flag handling; the API returns 404 because /browsers/*/webmcp/* is not deployed yet (verified identically via direct curl). - browsers replays list/start/stop/download, browsers process spawn/status/kill, browsers fs watch start/stop, browsers computer get-mouse-position -- all pass, addressed by session name. - go build ./... and go test ./... pass. Co-Authored-By: Claude Opus 5 --- cmd/browsers.go | 244 ++++++++++++++++++++++++++++++++++++++++-------- go.mod | 2 +- go.sum | 4 +- 3 files changed, 206 insertions(+), 44 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 0e014929..60627c25 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -39,39 +39,39 @@ type BrowsersService interface { Update(ctx context.Context, idOrName string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (res *kernel.BrowserUpdateResponse, err error) DeleteByID(ctx context.Context, idOrName string, opts ...option.RequestOption) (err error) HTTPClient(id string, opts ...option.RequestOption) (*http.Client, error) - LoadExtensions(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) (err error) + LoadExtensions(ctx context.Context, idOrName string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) (err error) } // BrowserReplaysService defines the subset we use for browser replays. type BrowserReplaysService interface { - List(ctx context.Context, id string, opts ...option.RequestOption) (res *[]kernel.BrowserReplayListResponse, err error) + List(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *[]kernel.BrowserReplayListResponse, err error) Download(ctx context.Context, replayID string, query kernel.BrowserReplayDownloadParams, opts ...option.RequestOption) (res *http.Response, err error) - Start(ctx context.Context, id string, body kernel.BrowserReplayStartParams, opts ...option.RequestOption) (res *kernel.BrowserReplayStartResponse, err error) + Start(ctx context.Context, idOrName string, body kernel.BrowserReplayStartParams, opts ...option.RequestOption) (res *kernel.BrowserReplayStartResponse, err error) Stop(ctx context.Context, replayID string, body kernel.BrowserReplayStopParams, opts ...option.RequestOption) (err error) } // BrowserFSService defines the subset we use for browser filesystem APIs. type BrowserFSService interface { - NewDirectory(ctx context.Context, id string, body kernel.BrowserFNewDirectoryParams, opts ...option.RequestOption) (err error) - DeleteDirectory(ctx context.Context, id string, body kernel.BrowserFDeleteDirectoryParams, opts ...option.RequestOption) (err error) - DeleteFile(ctx context.Context, id string, body kernel.BrowserFDeleteFileParams, opts ...option.RequestOption) (err error) - DownloadDirZip(ctx context.Context, id string, query kernel.BrowserFDownloadDirZipParams, opts ...option.RequestOption) (res *http.Response, err error) - FileInfo(ctx context.Context, id string, query kernel.BrowserFFileInfoParams, opts ...option.RequestOption) (res *kernel.BrowserFFileInfoResponse, err error) - ListFiles(ctx context.Context, id string, query kernel.BrowserFListFilesParams, opts ...option.RequestOption) (res *[]kernel.BrowserFListFilesResponse, err error) - Move(ctx context.Context, id string, body kernel.BrowserFMoveParams, opts ...option.RequestOption) (err error) - ReadFile(ctx context.Context, id string, query kernel.BrowserFReadFileParams, opts ...option.RequestOption) (res *http.Response, err error) - SetFilePermissions(ctx context.Context, id string, body kernel.BrowserFSetFilePermissionsParams, opts ...option.RequestOption) (err error) - Upload(ctx context.Context, id string, body kernel.BrowserFUploadParams, opts ...option.RequestOption) (err error) - UploadZip(ctx context.Context, id string, body kernel.BrowserFUploadZipParams, opts ...option.RequestOption) (err error) - WriteFile(ctx context.Context, id string, contents io.Reader, body kernel.BrowserFWriteFileParams, opts ...option.RequestOption) (err error) + NewDirectory(ctx context.Context, idOrName string, body kernel.BrowserFNewDirectoryParams, opts ...option.RequestOption) (err error) + DeleteDirectory(ctx context.Context, idOrName string, body kernel.BrowserFDeleteDirectoryParams, opts ...option.RequestOption) (err error) + DeleteFile(ctx context.Context, idOrName string, body kernel.BrowserFDeleteFileParams, opts ...option.RequestOption) (err error) + DownloadDirZip(ctx context.Context, idOrName string, query kernel.BrowserFDownloadDirZipParams, opts ...option.RequestOption) (res *http.Response, err error) + FileInfo(ctx context.Context, idOrName string, query kernel.BrowserFFileInfoParams, opts ...option.RequestOption) (res *kernel.BrowserFFileInfoResponse, err error) + ListFiles(ctx context.Context, idOrName string, query kernel.BrowserFListFilesParams, opts ...option.RequestOption) (res *[]kernel.BrowserFListFilesResponse, err error) + Move(ctx context.Context, idOrName string, body kernel.BrowserFMoveParams, opts ...option.RequestOption) (err error) + ReadFile(ctx context.Context, idOrName string, query kernel.BrowserFReadFileParams, opts ...option.RequestOption) (res *http.Response, err error) + SetFilePermissions(ctx context.Context, idOrName string, body kernel.BrowserFSetFilePermissionsParams, opts ...option.RequestOption) (err error) + Upload(ctx context.Context, idOrName string, body kernel.BrowserFUploadParams, opts ...option.RequestOption) (err error) + UploadZip(ctx context.Context, idOrName string, body kernel.BrowserFUploadZipParams, opts ...option.RequestOption) (err error) + WriteFile(ctx context.Context, idOrName string, contents io.Reader, body kernel.BrowserFWriteFileParams, opts ...option.RequestOption) (err error) } // BrowserProcessService defines the subset we use for browser process APIs. type BrowserProcessService interface { - Exec(ctx context.Context, id string, body kernel.BrowserProcessExecParams, opts ...option.RequestOption) (res *kernel.BrowserProcessExecResponse, err error) + Exec(ctx context.Context, idOrName string, body kernel.BrowserProcessExecParams, opts ...option.RequestOption) (res *kernel.BrowserProcessExecResponse, err error) Kill(ctx context.Context, processID string, params kernel.BrowserProcessKillParams, opts ...option.RequestOption) (res *kernel.BrowserProcessKillResponse, err error) Resize(ctx context.Context, processID string, params kernel.BrowserProcessResizeParams, opts ...option.RequestOption) (res *kernel.BrowserProcessResizeResponse, err error) - Spawn(ctx context.Context, id string, body kernel.BrowserProcessSpawnParams, opts ...option.RequestOption) (res *kernel.BrowserProcessSpawnResponse, err error) + Spawn(ctx context.Context, idOrName string, body kernel.BrowserProcessSpawnParams, opts ...option.RequestOption) (res *kernel.BrowserProcessSpawnResponse, err error) Status(ctx context.Context, processID string, query kernel.BrowserProcessStatusParams, opts ...option.RequestOption) (res *kernel.BrowserProcessStatusResponse, err error) Stdin(ctx context.Context, processID string, params kernel.BrowserProcessStdinParams, opts ...option.RequestOption) (res *kernel.BrowserProcessStdinResponse, err error) StdoutStreamStreaming(ctx context.Context, processID string, query kernel.BrowserProcessStdoutStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserProcessStdoutStreamResponse]) @@ -80,34 +80,40 @@ type BrowserProcessService interface { // BrowserFWatchService defines the subset we use for browser filesystem watch APIs. type BrowserFWatchService interface { EventsStreaming(ctx context.Context, watchID string, query kernel.BrowserFWatchEventsParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserFWatchEventsResponse]) - Start(ctx context.Context, id string, body kernel.BrowserFWatchStartParams, opts ...option.RequestOption) (res *kernel.BrowserFWatchStartResponse, err error) + Start(ctx context.Context, idOrName string, body kernel.BrowserFWatchStartParams, opts ...option.RequestOption) (res *kernel.BrowserFWatchStartResponse, err error) Stop(ctx context.Context, watchID string, body kernel.BrowserFWatchStopParams, opts ...option.RequestOption) (err error) } // BrowserLogService defines the subset we use for browser log APIs. type BrowserLogService interface { - StreamStreaming(ctx context.Context, id string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) + StreamStreaming(ctx context.Context, idOrName string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) } // BrowserPlaywrightService defines the subset we use for Playwright execution. type BrowserPlaywrightService interface { - Execute(ctx context.Context, id string, body kernel.BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *kernel.BrowserPlaywrightExecuteResponse, err error) + Execute(ctx context.Context, idOrName string, body kernel.BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *kernel.BrowserPlaywrightExecuteResponse, err error) +} + +// BrowserWebmcpService defines the subset we use for WebMCP tool discovery and invocation. +type BrowserWebmcpService interface { + InvokeTool(ctx context.Context, idOrName string, body kernel.BrowserWebmcpInvokeToolParams, opts ...option.RequestOption) (res *kernel.InvocationResult, err error) + ListTools(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.ToolsResponse, err error) } // BrowserComputerService defines the subset we use for OS-level mouse & screen. type BrowserComputerService interface { - Batch(ctx context.Context, id string, body kernel.BrowserComputerBatchParams, opts ...option.RequestOption) (err error) - CaptureScreenshot(ctx context.Context, id string, body kernel.BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (res *http.Response, err error) - ClickMouse(ctx context.Context, id string, body kernel.BrowserComputerClickMouseParams, opts ...option.RequestOption) (err error) - DragMouse(ctx context.Context, id string, body kernel.BrowserComputerDragMouseParams, opts ...option.RequestOption) (err error) - GetMousePosition(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.BrowserComputerGetMousePositionResponse, err error) - MoveMouse(ctx context.Context, id string, body kernel.BrowserComputerMoveMouseParams, opts ...option.RequestOption) (err error) - PressKey(ctx context.Context, id string, body kernel.BrowserComputerPressKeyParams, opts ...option.RequestOption) (err error) - ReadClipboard(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.BrowserComputerReadClipboardResponse, err error) - Scroll(ctx context.Context, id string, body kernel.BrowserComputerScrollParams, opts ...option.RequestOption) (err error) - SetCursorVisibility(ctx context.Context, id string, body kernel.BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (res *kernel.BrowserComputerSetCursorVisibilityResponse, err error) - TypeText(ctx context.Context, id string, body kernel.BrowserComputerTypeTextParams, opts ...option.RequestOption) (err error) - WriteClipboard(ctx context.Context, id string, body kernel.BrowserComputerWriteClipboardParams, opts ...option.RequestOption) (err error) + Batch(ctx context.Context, idOrName string, body kernel.BrowserComputerBatchParams, opts ...option.RequestOption) (err error) + CaptureScreenshot(ctx context.Context, idOrName string, body kernel.BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (res *http.Response, err error) + ClickMouse(ctx context.Context, idOrName string, body kernel.BrowserComputerClickMouseParams, opts ...option.RequestOption) (err error) + DragMouse(ctx context.Context, idOrName string, body kernel.BrowserComputerDragMouseParams, opts ...option.RequestOption) (err error) + GetMousePosition(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.BrowserComputerGetMousePositionResponse, err error) + MoveMouse(ctx context.Context, idOrName string, body kernel.BrowserComputerMoveMouseParams, opts ...option.RequestOption) (err error) + PressKey(ctx context.Context, idOrName string, body kernel.BrowserComputerPressKeyParams, opts ...option.RequestOption) (err error) + ReadClipboard(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.BrowserComputerReadClipboardResponse, err error) + Scroll(ctx context.Context, idOrName string, body kernel.BrowserComputerScrollParams, opts ...option.RequestOption) (err error) + SetCursorVisibility(ctx context.Context, idOrName string, body kernel.BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (res *kernel.BrowserComputerSetCursorVisibilityResponse, err error) + TypeText(ctx context.Context, idOrName string, body kernel.BrowserComputerTypeTextParams, opts ...option.RequestOption) (err error) + WriteClipboard(ctx context.Context, idOrName string, body kernel.BrowserComputerWriteClipboardParams, opts ...option.RequestOption) (err error) } // Regular expression to validate CUID2 identifiers (starts with a letter, 24 lowercase alphanumeric characters). @@ -436,6 +442,7 @@ type BrowsersCmd struct { logs BrowserLogService computer BrowserComputerService playwright BrowserPlaywrightService + webmcp BrowserWebmcpService telemetry BrowserTelemetryService } @@ -1592,7 +1599,7 @@ func (b BrowsersCmd) ReplaysStop(ctx context.Context, in BrowsersReplaysStopInpu if err != nil { return util.CleanedUpSdkError{Err: err} } - err = b.replays.Stop(ctx, in.ReplayID, kernel.BrowserReplayStopParams{ID: br.SessionID}) + err = b.replays.Stop(ctx, in.ReplayID, kernel.BrowserReplayStopParams{IDOrName: br.SessionID}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1601,7 +1608,7 @@ func (b BrowsersCmd) ReplaysStop(ctx context.Context, in BrowsersReplaysStopInpu } func (b BrowsersCmd) ReplaysDownload(ctx context.Context, in BrowsersReplaysDownloadInput) error { - res, err := b.replays.Download(ctx, in.ReplayID, kernel.BrowserReplayDownloadParams{ID: in.Identifier}) + res, err := b.replays.Download(ctx, in.ReplayID, kernel.BrowserReplayDownloadParams{IDOrName: in.Identifier}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1758,6 +1765,113 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh return nil } +// WebMCP +type BrowsersWebmcpListToolsInput struct { + Identifier string + Output string +} + +func (b BrowsersCmd) WebmcpListTools(ctx context.Context, in BrowsersWebmcpListToolsInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + if b.webmcp == nil { + pterm.Error.Println("webmcp service not available") + return nil + } + br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + res, err := b.webmcp.ListTools(ctx, br.SessionID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + + if res == nil || len(res.Tools) == 0 { + pterm.Info.Println("No WebMCP tools found") + return nil + } + rows := pterm.TableData{{"Name", "Tool Ref", "Page", "Frame", "Description"}} + for _, t := range res.Tools { + frame := "-" + if t.Source.Frame.URL != "" { + frame = truncateURL(t.Source.Frame.URL, 40) + } + rows = append(rows, []string{ + t.Name, + t.ToolRef, + truncateURL(t.Source.PageURL, 40), + frame, + truncateURL(t.Description, 60), + }) + } + PrintTableNoPad(rows, true) + return nil +} + +type BrowsersWebmcpInvokeToolInput struct { + Identifier string + ToolRef string + InputJSON string + TimeoutSec int64 + Output string +} + +func (b BrowsersCmd) WebmcpInvokeTool(ctx context.Context, in BrowsersWebmcpInvokeToolInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + if b.webmcp == nil { + pterm.Error.Println("webmcp service not available") + return nil + } + toolInput := map[string]any{} + if strings.TrimSpace(in.InputJSON) != "" { + if err := json.Unmarshal([]byte(in.InputJSON), &toolInput); err != nil { + pterm.Error.Printf("Invalid --input JSON: %v\n", err) + return nil + } + } + br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + req := kernel.InvokeRequestParam{ToolRef: in.ToolRef, Input: toolInput} + if in.TimeoutSec > 0 { + req.TimeoutSec = kernel.Opt(in.TimeoutSec) + } + res, err := b.webmcp.InvokeTool(ctx, br.SessionID, kernel.BrowserWebmcpInvokeToolParams{InvokeRequest: req}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + + rows := pterm.TableData{{"Property", "Value"}, {"Invocation ID", res.InvocationID}, {"Status", string(res.Status)}} + PrintTableNoPad(rows, true) + + if res.Output != nil { + bs, err := json.MarshalIndent(res.Output, "", " ") + if err == nil { + pterm.Info.Println("output:") + fmt.Println(string(bs)) + } + } + if res.ErrorText != "" { + pterm.Error.Printf("error: %s\n", res.ErrorText) + } + return nil +} + func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -1907,7 +2021,7 @@ func (b BrowsersCmd) ProcessKill(ctx context.Context, in BrowsersProcessKillInpu if err != nil { return util.CleanedUpSdkError{Err: err} } - params := kernel.BrowserProcessKillParams{ID: br.SessionID, Signal: kernel.BrowserProcessKillParamsSignal(in.Signal)} + params := kernel.BrowserProcessKillParams{IDOrName: br.SessionID, Signal: kernel.BrowserProcessKillParamsSignal(in.Signal)} _, err = b.process.Kill(ctx, in.ProcessID, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -1925,7 +2039,7 @@ func (b BrowsersCmd) ProcessStatus(ctx context.Context, in BrowsersProcessStatus if err != nil { return util.CleanedUpSdkError{Err: err} } - res, err := b.process.Status(ctx, in.ProcessID, kernel.BrowserProcessStatusParams{ID: br.SessionID}) + res, err := b.process.Status(ctx, in.ProcessID, kernel.BrowserProcessStatusParams{IDOrName: br.SessionID}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1943,7 +2057,7 @@ func (b BrowsersCmd) ProcessStdin(ctx context.Context, in BrowsersProcessStdinIn if err != nil { return util.CleanedUpSdkError{Err: err} } - _, err = b.process.Stdin(ctx, in.ProcessID, kernel.BrowserProcessStdinParams{ID: br.SessionID, DataB64: in.DataB64}) + _, err = b.process.Stdin(ctx, in.ProcessID, kernel.BrowserProcessStdinParams{IDOrName: br.SessionID, DataB64: in.DataB64}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1960,7 +2074,7 @@ func (b BrowsersCmd) ProcessStdoutStream(ctx context.Context, in BrowsersProcess if err != nil { return util.CleanedUpSdkError{Err: err} } - stream := b.process.StdoutStreamStreaming(ctx, in.ProcessID, kernel.BrowserProcessStdoutStreamParams{ID: br.SessionID}) + stream := b.process.StdoutStreamStreaming(ctx, in.ProcessID, kernel.BrowserProcessStdoutStreamParams{IDOrName: br.SessionID}) if stream == nil { pterm.Error.Println("failed to open stdout stream") return nil @@ -1994,7 +2108,7 @@ func (b BrowsersCmd) ProcessResize(ctx context.Context, in BrowsersProcessResize if err != nil { return util.CleanedUpSdkError{Err: err} } - params := kernel.BrowserProcessResizeParams{ID: br.SessionID, Cols: in.Cols, Rows: in.Rows} + params := kernel.BrowserProcessResizeParams{IDOrName: br.SessionID, Cols: in.Cols, Rows: in.Rows} _, err = b.process.Resize(ctx, in.ProcessID, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -2043,7 +2157,7 @@ func (b BrowsersCmd) FSWatchStop(ctx context.Context, in BrowsersFSWatchStopInpu if err != nil { return util.CleanedUpSdkError{Err: err} } - err = b.fsWatch.Stop(ctx, in.WatchID, kernel.BrowserFWatchStopParams{ID: br.SessionID}) + err = b.fsWatch.Stop(ctx, in.WatchID, kernel.BrowserFWatchStopParams{IDOrName: br.SessionID}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -2060,7 +2174,7 @@ func (b BrowsersCmd) FSWatchEvents(ctx context.Context, in BrowsersFSWatchEvents if err != nil { return util.CleanedUpSdkError{Err: err} } - stream := b.fsWatch.EventsStreaming(ctx, in.WatchID, kernel.BrowserFWatchEventsParams{ID: br.SessionID}) + stream := b.fsWatch.EventsStreaming(ctx, in.WatchID, kernel.BrowserFWatchEventsParams{IDOrName: br.SessionID}) if stream == nil { pterm.Error.Println("failed to open watch events stream") return nil @@ -2942,6 +3056,19 @@ func init() { playwrightRoot.AddCommand(playwrightExecute) browsersCmd.AddCommand(playwrightRoot) + // webmcp + webmcpRoot := &cobra.Command{Use: "webmcp", Short: "Discover and invoke native page (WebMCP) tools"} + webmcpListTools := &cobra.Command{Use: "list-tools ", Short: "List WebMCP tools across every open tab and embedded frame", Args: cobra.ExactArgs(1), RunE: runBrowsersWebmcpListTools} + addJSONOutputFlag(webmcpListTools) + webmcpInvoke := &cobra.Command{Use: "invoke-tool ", Short: "Invoke a discovered WebMCP tool and wait for its result", Args: cobra.ExactArgs(1), RunE: runBrowsersWebmcpInvokeTool} + webmcpInvoke.Flags().String("tool-ref", "", "Opaque tool reference from 'browsers webmcp list-tools'") + webmcpInvoke.Flags().String("input", "", "Tool input as a JSON object (defaults to {}); use '-' to read from stdin") + webmcpInvoke.Flags().Int64("timeout-sec", 0, "Maximum time to wait for the tool result in seconds (1-120, default 60)") + _ = webmcpInvoke.MarkFlagRequired("tool-ref") + addJSONOutputFlag(webmcpInvoke) + webmcpRoot.AddCommand(webmcpListTools, webmcpInvoke) + browsersCmd.AddCommand(webmcpRoot) + // Add flags for create command addJSONOutputFlag(browsersCreateCmd) browsersCreateCmd.Flags().BoolP("stealth", "s", false, "Launch browser in stealth mode to avoid detection") @@ -3529,6 +3656,41 @@ func runBrowsersPlaywrightExecute(cmd *cobra.Command, args []string) error { return b.PlaywrightExecute(cmd.Context(), BrowsersPlaywrightExecuteInput{Identifier: args[0], Code: strings.TrimSpace(code), Timeout: timeout, Output: output}) } +func runBrowsersWebmcpListTools(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + output, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc, webmcp: &svc.Webmcp} + return b.WebmcpListTools(cmd.Context(), BrowsersWebmcpListToolsInput{Identifier: args[0], Output: output}) +} + +func runBrowsersWebmcpInvokeTool(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + toolRef, _ := cmd.Flags().GetString("tool-ref") + inputJSON, _ := cmd.Flags().GetString("input") + timeoutSec, _ := cmd.Flags().GetInt64("timeout-sec") + output, _ := cmd.Flags().GetString("output") + + if inputJSON == "-" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + pterm.Error.Printf("failed to read stdin: %v\n", err) + return nil + } + inputJSON = string(data) + } + + b := BrowsersCmd{browsers: &svc, webmcp: &svc.Webmcp} + return b.WebmcpInvokeTool(cmd.Context(), BrowsersWebmcpInvokeToolInput{ + Identifier: args[0], + ToolRef: toolRef, + InputJSON: inputJSON, + TimeoutSec: timeoutSec, + Output: output, + }) +} + func runBrowsersFSNewDirectory(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers diff --git a/go.mod b/go.mod index 3b361f0e..83df8ffd 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.97.0 + github.com/kernel/kernel-go-sdk v0.97.1-0.20260902190118-ed434f757fca 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 8acc36e1..1f5fb6c9 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.97.0 h1:jj6dFhiGdjkdFMdEsUDbnWkMJ2js/p1BBipa2QqvWXA= -github.com/kernel/kernel-go-sdk v0.97.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.97.1-0.20260902190118-ed434f757fca h1:YJaqLCdRDZDNvPepRucbQIKaapQBTkASy7PeYZB6gP8= +github.com/kernel/kernel-go-sdk v0.97.1-0.20260902190118-ed434f757fca/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= From 36bd0eba0dd76fe130f617740e29c8eae1ee95df Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:38:19 +0000 Subject: [PATCH 21/51] chore: update Go SDK to v0.98.0 (d02140d) The SDK diff from the CLI's previous pin (ed434f7) to d02140d is a release-only bump: .release-please-manifest.json, CHANGELOG.md, README.md and internal/version.go. No API surface changed. Full enumeration of api.md (147 methods) against the CLI command tree found no coverage gaps. The 5 /config-registry methods are marked x-cli-skip in openapi.yaml; the remaining 142 all have CLI commands, and every *Params field (including the nested *RequestParam structs and the ProxyNewParams config union variants) maps to an existing flag. Tested: go build ./..., go test ./... (all pass), and live API smoke tests of `browsers list`, `profiles list`, `app list`. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 83df8ffd..816bc1a8 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.97.1-0.20260902190118-ed434f757fca + github.com/kernel/kernel-go-sdk v0.98.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 1f5fb6c9..4a009f70 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.97.1-0.20260902190118-ed434f757fca h1:YJaqLCdRDZDNvPepRucbQIKaapQBTkASy7PeYZB6gP8= -github.com/kernel/kernel-go-sdk v0.97.1-0.20260902190118-ed434f757fca/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.98.0 h1:PefacBIPuhDU1NBood5wbgy8UWf3z6fmbrjJDn4/LC0= +github.com/kernel/kernel-go-sdk v0.98.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= From 073992ed2b5969e2ab04dc955230aa7f37b1ca8a Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:39:22 +0000 Subject: [PATCH 22/51] chore: update Go SDK to 7a377c7 and surface OTLP destination delivery health Updates github.com/kernel/kernel-go-sdk to v0.98.1-0.20260903142348-7a377c78440a (7a377c7). The only SDK change since v0.98.0 is on the OtlpDestination response, which gained consecutive_failures, last_error, last_error_at and last_export_at. No new endpoints or request params, so a full enumeration of api.md against the command tree turned up no missing commands or flags. 'kernel telemetry destinations get/create/update' now print Delivery, Consecutive Failures, Last Export At, Last Error and Last Error At, and 'list' gained a Delivery column. Delivery keys off consecutive_failures alone, since last_error/last_error_at are retained after a later success and would otherwise read as a live failure. Tested against the live API: telemetry destinations create/get/list/delete, get -o json (health fields pass through), and a browser session created with --telemetry-export-otlp. The API reported consecutive_failures 0 with no health timestamps throughout, so the healthy and never-delivered paths are covered live and the failing and retained-error paths by unit tests. Co-Authored-By: Claude Opus 5 --- cmd/telemetry_destinations.go | 33 ++++++++++++++-- cmd/telemetry_destinations_test.go | 62 ++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/cmd/telemetry_destinations.go b/cmd/telemetry_destinations.go index f8b4d417..452c3690 100644 --- a/cmd/telemetry_destinations.go +++ b/cmd/telemetry_destinations.go @@ -146,7 +146,7 @@ func (c TelemetryDestinationsCmd) List(ctx context.Context, in TelemetryDestinat return nil } - rows := pterm.TableData{{"ID", "Name", "Endpoint", "Description", "Headers", "Created At"}} + rows := pterm.TableData{{"ID", "Name", "Endpoint", "Description", "Headers", "Delivery", "Created At"}} for _, d := range items { rows = append(rows, []string{ d.ID, @@ -154,6 +154,7 @@ func (c TelemetryDestinationsCmd) List(ctx context.Context, in TelemetryDestinat d.Endpoint, util.OrDash(d.Description), util.OrDash(formatOtlpDestinationHeaders(d.Headers)), + formatOtlpDestinationDelivery(d), util.FormatLocal(d.CreatedAt), }) } @@ -332,6 +333,20 @@ func formatOtlpDestinationHeaders(headers map[string]string) string { return strings.Join(names, ", ") } +// formatOtlpDestinationDelivery summarizes whether exports are currently +// landing. Only ConsecutiveFailures answers that: LastError and LastErrorAt are +// retained after a later success, so a destination can carry both a recorded +// error and a healthy status. +func formatOtlpDestinationDelivery(d kernel.OtlpDestination) string { + if d.ConsecutiveFailures > 0 { + return fmt.Sprintf("failing (%d consecutive)", d.ConsecutiveFailures) + } + if d.LastExportAt.IsZero() && d.LastErrorAt.IsZero() { + return "no deliveries yet" + } + return "ok" +} + func printOtlpDestinationDetail(d *kernel.OtlpDestination) { rows := pterm.TableData{ {"Property", "Value"}, @@ -340,6 +355,13 @@ func printOtlpDestinationDetail(d *kernel.OtlpDestination) { {"Endpoint", d.Endpoint}, {"Description", util.OrDash(d.Description)}, {"Headers", util.OrDash(formatOtlpDestinationHeaders(d.Headers))}, + {"Delivery", formatOtlpDestinationDelivery(*d)}, + {"Consecutive Failures", fmt.Sprintf("%d", d.ConsecutiveFailures)}, + {"Last Export At", util.FormatLocal(d.LastExportAt)}, + // Kept even once exports recover, so it is labelled as the last recorded + // failure rather than as the destination's current state. + {"Last Error", util.OrDash(d.LastError)}, + {"Last Error At", util.FormatLocal(d.LastErrorAt)}, {"Created At", util.FormatLocal(d.CreatedAt)}, {"Updated At", util.FormatLocal(d.UpdatedAt)}, } @@ -379,8 +401,13 @@ var telemetryDestinationsListCmd = &cobra.Command{ var telemetryDestinationsGetCmd = &cobra.Command{ Use: "get ", Short: "Get an OTLP destination by ID or name", - Args: cobra.ExactArgs(1), - RunE: runTelemetryDestinationsGet, + Long: "Get an OTLP destination, including its delivery health.\n\n" + + "Delivery reads Consecutive Failures: zero means the most recently recorded delivery succeeded. " + + "Last Error and Last Error At describe the last failure Kernel recorded and are kept after a later " + + "success, so they can predate Last Export At and do not by themselves mean export is broken. " + + "Response bodies, endpoint URLs and credentials are never returned in Last Error.", + Args: cobra.ExactArgs(1), + RunE: runTelemetryDestinationsGet, } var telemetryDestinationsCreateCmd = &cobra.Command{ diff --git a/cmd/telemetry_destinations_test.go b/cmd/telemetry_destinations_test.go index 47e41020..73d174ec 100644 --- a/cmd/telemetry_destinations_test.go +++ b/cmd/telemetry_destinations_test.go @@ -281,3 +281,65 @@ func TestFormatOtlpDestinationHeaders(t *testing.T) { assert.Equal(t, "", formatOtlpDestinationHeaders(nil)) assert.Equal(t, "Authorization, X-Api-Key", formatOtlpDestinationHeaders(map[string]string{"X-Api-Key": "", "Authorization": ""})) } + +func TestFormatOtlpDestinationDelivery(t *testing.T) { + exported := time.Unix(1_700_000_000, 0) + failed := time.Unix(1_600_000_000, 0) + + assert.Equal(t, "no deliveries yet", formatOtlpDestinationDelivery(kernel.OtlpDestination{})) + assert.Equal(t, "ok", formatOtlpDestinationDelivery(kernel.OtlpDestination{LastExportAt: exported})) + assert.Equal(t, "failing (3 consecutive)", formatOtlpDestinationDelivery(kernel.OtlpDestination{ + ConsecutiveFailures: 3, + LastExportAt: exported, + LastErrorAt: exported, + })) + // A retained error from before the last success must not read as failing. + assert.Equal(t, "ok", formatOtlpDestinationDelivery(kernel.OtlpDestination{ + LastExportAt: exported, + LastError: "http_401", + LastErrorAt: failed, + })) +} + +func TestTelemetryDestinationsGet_ShowsDeliveryHealth(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeTelemetryDestinationsService{GetFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.OtlpDestination, error) { + return &kernel.OtlpDestination{ + ID: "d1", + Name: "honeycomb", + Endpoint: "https://api.honeycomb.io", + ConsecutiveFailures: 2, + LastError: "http_401", + LastErrorAt: time.Unix(1_700_000_000, 0), + LastExportAt: time.Unix(1_600_000_000, 0), + CreatedAt: time.Unix(0, 0), + UpdatedAt: time.Unix(0, 0), + }, nil + }} + c := TelemetryDestinationsCmd{destinations: fake} + require.NoError(t, c.Get(context.Background(), TelemetryDestinationsGetInput{Identifier: "d1"})) + out := buf.String() + assert.Contains(t, out, "failing (2 consecutive)") + assert.Contains(t, out, "http_401") + assert.Contains(t, out, "Last Export At") +} + +func TestTelemetryDestinationsList_ShowsDeliveryColumn(t *testing.T) { + buf := capturePtermOutput(t) + items := []kernel.OtlpDestination{{ + ID: "d1", + Name: "honeycomb", + Endpoint: "https://api.honeycomb.io", + ConsecutiveFailures: 5, + CreatedAt: time.Unix(0, 0), + UpdatedAt: time.Unix(0, 0), + }} + fake := &FakeTelemetryDestinationsService{ListFunc: func(ctx context.Context, query kernel.TelemetryDestinationListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.OtlpDestination], error) { + return &pagination.OffsetPagination[kernel.OtlpDestination]{Items: items}, nil + }} + c := TelemetryDestinationsCmd{destinations: fake} + require.NoError(t, c.List(context.Background(), TelemetryDestinationsListInput{Page: 1, PerPage: 20})) + out := buf.String() + assert.Contains(t, out, "Delivery") + assert.Contains(t, out, "failing (5 consecutive)") +} diff --git a/go.mod b/go.mod index 816bc1a8..bc751a59 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.98.0 + github.com/kernel/kernel-go-sdk v0.98.1-0.20260903142348-7a377c78440a 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 4a009f70..671e4b7c 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.98.0 h1:PefacBIPuhDU1NBood5wbgy8UWf3z6fmbrjJDn4/LC0= -github.com/kernel/kernel-go-sdk v0.98.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.98.1-0.20260903142348-7a377c78440a h1:ox8aHPzZHvognOoyjXeECk/tFh1Gg+ceY5D9DrLTdFA= +github.com/kernel/kernel-go-sdk v0.98.1-0.20260903142348-7a377c78440a/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= From 75eae00a9d6b6fda34c92019e1dcdc88bd191a44 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" Date: Fri, 4 Sep 2026 18:33:47 +0000 Subject: [PATCH 23/51] chore: update Go SDK to 31c5fee and add vaults commands Update kernel-go-sdk to 31c5fee384421d8c66f9c8bb5e5ef0c0ac7e194a, which adds the Vaults resource (11 endpoints) and links vaults to browser sessions. New commands (kernel vaults): - create, get, list, delete for client.Vaults.{Upsert,Get,List,Delete} - items {create,get,list,update,delete,events,perform-operation} for client.Vaults.Items.* Vault item specs are discriminated unions spanning four provider variants, so they are taken as JSON via --spec / --spec-file (matching the --chrome-policy convention) rather than a flag per provider-conditional field. New flags: - browsers create --vault for BrowserNewParams.Vaults (repeatable, ID or name) - browsers get now shows Vaults and Usage Status for the new response fields vaults list uses the page-based pagination UX (--page/--per-page + footer). Tested against the real API: vaults create/get/list/delete (incl. pagination footer), items create for wallet(link), wallet(agentcard) and card(agentcard) via both --spec and --spec-file -, items get (incl. --wait/--expand), list, update (verified persisted), events (incl. --after/--wait), perform-operation, delete; browsers create --vault by both name and ID, verified the Vaults row in browsers get and Usage Status on a deleted session. All test resources cleaned up. go build, go vet and go test ./... pass. Co-Authored-By: Claude Opus 5 --- cmd/browsers.go | 48 +++ cmd/vaults.go | 950 ++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 4 files changed, 1001 insertions(+), 3 deletions(-) create mode 100644 cmd/vaults.go diff --git a/cmd/browsers.go b/cmd/browsers.go index 60627c25..5af2cd39 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -252,6 +252,24 @@ func formatPrivateHosts(network kernel.BrowserNetworkConfig) string { return strings.Join(network.PrivateHosts, ", ") } +// formatVaultReferences renders the vaults linked to a session for table output, +// preferring each vault's name and falling back to its ID. It returns an empty +// string when no vaults are linked, so the row can be omitted entirely. +func formatVaultReferences(vaults []kernel.VaultReference) string { + if len(vaults) == 0 { + return "" + } + labels := make([]string, 0, len(vaults)) + for _, vault := range vaults { + if vault.Name != "" { + labels = append(labels, vault.Name) + continue + } + labels = append(labels, vault.ID) + } + return strings.Join(labels, ", ") +} + // parseStringMapFlag parses repeated KEY=value flag values into a map. It returns a nil // map when no values were given, so callers can distinguish "flag absent" from "flag set // to an empty map". @@ -383,6 +401,7 @@ type BrowsersCreateInput struct { PrivateHosts []string StartURL string Extensions []string + Vaults []string Viewport string Telemetry string TelemetryCdpExclude string @@ -662,6 +681,24 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } + // Map vaults (IDs or names) into params.Vaults. Links are immutable once the + // session is created. + if len(in.Vaults) > 0 { + for _, vault := range in.Vaults { + val := strings.TrimSpace(vault) + if val == "" { + continue + } + item := kernel.VaultReferenceParam{} + if cuidRegex.MatchString(val) { + item.ID = kernel.Opt(val) + } else { + item.Name = kernel.Opt(val) + } + params.Vaults = append(params.Vaults, item) + } + } + // Add viewport if specified if in.Viewport != "" { width, height, refreshRate, err := parseViewport(in.Viewport) @@ -848,9 +885,17 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { tableData = append(tableData, []string{"Proxy", proxy}) } tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}) + if vaults := formatVaultReferences(browser.Vaults); vaults != "" { + tableData = append(tableData, []string{"Vaults", vaults}) + } if !browser.DeletedAt.IsZero() { tableData = append(tableData, []string{"Deleted At", util.FormatLocal(browser.DeletedAt)}) } + // Only populated for deleted sessions, where "pending" means the usage figures + // above are not yet the final billed ones. + if browser.UsageStatus != "" { + tableData = append(tableData, []string{"Usage Status", string(browser.UsageStatus)}) + } PrintTableNoPad(tableData, true) return nil @@ -3088,6 +3133,7 @@ func init() { browsersCreateCmd.Flags().StringSlice("private-host", nil, "Destinations the browser reaches directly through its own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeat or comma-separated, max 32). Accepts hostname patterns ('*.example.ts.net'), IPs ('10.1.30.63', '[fd00::1]'), and private CIDRs ('100.64.0.0/10'). Replaces the default private ranges (RFC1918, 100.64.0.0/10, fc00::/7); omit to keep them. Fixed once the session is created") browsersCreateCmd.Flags().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().StringSlice("vault", []string{}, "Vault IDs or names to link to the session (repeatable; may be passed multiple times or comma-separated). Fixed once the session is created") 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") browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") @@ -3220,6 +3266,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { privateHosts, _ := cmd.Flags().GetStringSlice("private-host") startURL, _ := cmd.Flags().GetString("start-url") extensions, _ := cmd.Flags().GetStringSlice("extension") + vaults, _ := cmd.Flags().GetStringSlice("vault") viewport, _ := cmd.Flags().GetString("viewport") viewportInteractive, _ := cmd.Flags().GetBool("viewport-interactive") poolID, _ := cmd.Flags().GetString("pool-id") @@ -3351,6 +3398,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { PrivateHosts: privateHosts, StartURL: startURL, Extensions: extensions, + Vaults: vaults, Viewport: viewport, Telemetry: telemetry, TelemetryCdpExclude: telemetryCdpExclude, diff --git a/cmd/vaults.go b/cmd/vaults.go new file mode 100644 index 00000000..e7c6314a --- /dev/null +++ b/cmd/vaults.go @@ -0,0 +1,950 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/kernel/cli/pkg/interactive" + "github.com/kernel/cli/pkg/util" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/pterm/pterm" + "github.com/samber/lo" + "github.com/spf13/cobra" +) + +// VaultsService defines the subset of the Kernel SDK vault client that we use. +type VaultsService interface { + Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.Vault, err error) + List(ctx context.Context, query kernel.VaultListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.Vault], err error) + Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) (err error) + Upsert(ctx context.Context, body kernel.VaultUpsertParams, opts ...option.RequestOption) (res *kernel.Vault, err error) +} + +// VaultItemsService defines the subset of the Kernel SDK vault item client that we use. +type VaultItemsService interface { + Get(ctx context.Context, key string, params kernel.VaultItemGetParams, opts ...option.RequestOption) (res *kernel.VaultItemUnion, err error) + Update(ctx context.Context, key string, params kernel.VaultItemUpdateParams, opts ...option.RequestOption) (res *kernel.VaultItemUnion, err error) + List(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *[]kernel.VaultItemUnion, err error) + Delete(ctx context.Context, key string, body kernel.VaultItemDeleteParams, opts ...option.RequestOption) (err error) + Events(ctx context.Context, key string, params kernel.VaultItemEventsParams, opts ...option.RequestOption) (res *[]kernel.VaultItemEvent, err error) + PerformOperation(ctx context.Context, key string, params kernel.VaultItemPerformOperationParams, opts ...option.RequestOption) (res *kernel.VaultItemUnion, err error) + Upsert(ctx context.Context, key string, params kernel.VaultItemUpsertParams, opts ...option.RequestOption) (res *kernel.VaultItemUnion, err error) +} + +// VaultsCmd handles vault and vault item operations independent of cobra. +type VaultsCmd struct { + vaults VaultsService + items VaultItemsService + prompter interactive.Prompter +} + +type VaultsListInput struct { + Page int + PerPage int + Output string +} + +type VaultsGetInput struct { + Identifier string + Output string +} + +type VaultsCreateInput struct { + Name string + Output string +} + +type VaultsDeleteInput struct { + Identifier string + SkipConfirm bool +} + +type VaultItemsListInput struct { + Vault string + Output string +} + +type VaultItemsGetInput struct { + Vault string + Key string + Wait int + Expand []string + Output string +} + +type VaultItemsCreateInput struct { + Vault string + Key string + Type string + Spec string + SpecFile string + Output string +} + +type VaultItemsUpdateInput struct { + Vault string + Key string + Spec string + SpecFile string + Output string +} + +type VaultItemsDeleteInput struct { + Vault string + Key string + SkipConfirm bool +} + +type VaultItemsEventsInput struct { + Vault string + Key string + After string + Wait int + Output string +} + +type VaultItemsPerformOperationInput struct { + Vault string + Key string + Type string + Output string +} + +// --- Vaults --- + +func (v VaultsCmd) List(ctx context.Context, in VaultsListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + page := in.Page + perPage := in.PerPage + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 20 + } + + if in.Output != "json" { + pterm.Info.Println("Fetching vaults...") + } + + params := kernel.VaultListParams{} + params.Limit = kernel.Opt(int64(perPage + 1)) + params.Offset = kernel.Opt(int64((page - 1) * perPage)) + + result, err := v.vaults.List(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + var items []kernel.Vault + if result != nil { + items = result.Items + } + + hasMore := len(items) > perPage + if hasMore { + items = items[:perPage] + } + itemsThisPage := len(items) + + if in.Output == "json" { + return util.PrintPrettyJSONSlice(items) + } + + if len(items) == 0 { + pterm.Info.Println("No vaults found") + return nil + } + + rows := pterm.TableData{{"Vault ID", "Name", "Created At", "Updated At"}} + for _, vault := range items { + rows = append(rows, []string{ + vault.ID, + vault.Name, + util.FormatLocal(vault.CreatedAt), + util.FormatLocal(vault.UpdatedAt), + }) + } + PrintTableNoPad(rows, true) + + 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 { + pterm.Printf("Next: %s\n", fmt.Sprintf("kernel vaults list --page %d --per-page %d", page+1, perPage)) + } + + return nil +} + +func (v VaultsCmd) Get(ctx context.Context, in VaultsGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + vault, err := v.vaults.Get(ctx, in.Identifier) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if vault == nil || vault.ID == "" { + if in.Output == "json" { + fmt.Println("null") + return nil + } + pterm.Error.Printf("Vault '%s' not found\n", in.Identifier) + return nil + } + + if in.Output == "json" { + return util.PrintPrettyJSON(vault) + } + + printVaultTable(vault) + return nil +} + +func (v VaultsCmd) Create(ctx context.Context, in VaultsCreateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if strings.TrimSpace(in.Name) == "" { + return fmt.Errorf("--name is required") + } + + vault, err := v.vaults.Upsert(ctx, kernel.VaultUpsertParams{Name: in.Name}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(vault) + } + + printVaultTable(vault) + return nil +} + +func (v VaultsCmd) Delete(ctx context.Context, in VaultsDeleteInput) error { + if !in.SkipConfirm { + ok, err := v.prompter.Confirm( + fmt.Sprintf("delete vault '%s'", in.Identifier), + fmt.Sprintf("Are you sure you want to delete vault '%s'? Its items are invalidated.", in.Identifier), + ) + if err != nil { + return err + } + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + + if err := v.vaults.Delete(ctx, in.Identifier); err != nil { + if util.IsNotFound(err) { + pterm.Info.Printf("Vault '%s' not found\n", in.Identifier) + return nil + } + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Deleted vault: %s\n", in.Identifier) + return nil +} + +// --- Vault items --- + +func (v VaultsCmd) ItemsList(ctx context.Context, in VaultItemsListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + if in.Output != "json" { + pterm.Info.Printf("Fetching items in vault '%s'...\n", in.Vault) + } + + res, err := v.items.List(ctx, in.Vault) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + var items []kernel.VaultItemUnion + if res != nil { + items = *res + } + + if in.Output == "json" { + return util.PrintPrettyJSONSlice(items) + } + + if len(items) == 0 { + pterm.Info.Printf("No items found in vault '%s'\n", in.Vault) + return nil + } + + rows := pterm.TableData{{"Key", "Type", "Provider", "Status", "Created At", "Updated At"}} + for _, item := range items { + rows = append(rows, []string{ + item.Key, + orDash(item.Type), + orDash(item.State.Provider), + orDash(item.State.Status), + util.FormatLocal(item.CreatedAt), + util.FormatLocal(item.UpdatedAt), + }) + } + PrintTableNoPad(rows, true) + return nil +} + +func (v VaultsCmd) ItemsGet(ctx context.Context, in VaultItemsGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + params := kernel.VaultItemGetParams{IDOrName: in.Vault} + if in.Wait > 0 { + params.Wait = kernel.Opt(int64(in.Wait)) + } + if expand := normalizeList(in.Expand); len(expand) > 0 { + params.Expand = expand + } + + item, err := v.items.Get(ctx, in.Key, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if item == nil || item.ID == "" { + if in.Output == "json" { + fmt.Println("null") + return nil + } + pterm.Error.Printf("Vault item '%s' not found in vault '%s'\n", in.Key, in.Vault) + return nil + } + + if in.Output == "json" { + return util.PrintPrettyJSON(item) + } + + printVaultItemTable(item) + return nil +} + +func (v VaultsCmd) ItemsCreate(ctx context.Context, in VaultItemsCreateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + itemType := strings.ToLower(strings.TrimSpace(in.Type)) + if itemType != "wallet" && itemType != "card" { + return fmt.Errorf("invalid --type %q: must be one of wallet, card", in.Type) + } + + raw, err := readVaultItemSpec(in.Spec, in.SpecFile) + if err != nil { + return err + } + if len(raw) == 0 { + return fmt.Errorf("must specify one of --spec or --spec-file") + } + + params := kernel.VaultItemUpsertParams{IDOrName: in.Vault} + switch itemType { + case "wallet": + var spec kernel.WalletVaultItemSpecUnionParam + if err := json.Unmarshal(raw, &spec); err != nil { + return fmt.Errorf("invalid JSON in wallet spec: %w", err) + } + params.OfWallet = &kernel.VaultItemUpsertParamsBodyWallet{Spec: spec} + case "card": + var spec kernel.CardVaultItemSpecUnionParam + if err := json.Unmarshal(raw, &spec); err != nil { + return fmt.Errorf("invalid JSON in card spec: %w", err) + } + params.OfCard = &kernel.VaultItemUpsertParamsBodyCard{Spec: spec} + } + + item, err := v.items.Upsert(ctx, in.Key, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(item) + } + + printVaultItemTable(item) + return nil +} + +func (v VaultsCmd) ItemsUpdate(ctx context.Context, in VaultItemsUpdateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + raw, err := readVaultItemSpec(in.Spec, in.SpecFile) + if err != nil { + return err + } + if len(raw) == 0 { + return fmt.Errorf("must specify one of --spec or --spec-file") + } + + var spec kernel.CardVaultItemSpecUnionParam + if err := json.Unmarshal(raw, &spec); err != nil { + return fmt.Errorf("invalid JSON in card spec: %w", err) + } + + item, err := v.items.Update(ctx, in.Key, kernel.VaultItemUpdateParams{ + IDOrName: in.Vault, + Spec: spec, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(item) + } + + printVaultItemTable(item) + return nil +} + +func (v VaultsCmd) ItemsDelete(ctx context.Context, in VaultItemsDeleteInput) error { + if !in.SkipConfirm { + ok, err := v.prompter.Confirm( + fmt.Sprintf("delete vault item '%s'", in.Key), + fmt.Sprintf("Are you sure you want to delete item '%s' from vault '%s'? Its secret value is invalidated.", in.Key, in.Vault), + ) + if err != nil { + return err + } + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + + if err := v.items.Delete(ctx, in.Key, kernel.VaultItemDeleteParams{IDOrName: in.Vault}); err != nil { + if util.IsNotFound(err) { + pterm.Info.Printf("Vault item '%s' not found in vault '%s'\n", in.Key, in.Vault) + return nil + } + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Deleted vault item: %s\n", in.Key) + return nil +} + +func (v VaultsCmd) ItemsEvents(ctx context.Context, in VaultItemsEventsInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + params := kernel.VaultItemEventsParams{IDOrName: in.Vault} + if in.After != "" { + params.After = kernel.Opt(in.After) + } + if in.Wait > 0 { + params.Wait = kernel.Opt(int64(in.Wait)) + } + + res, err := v.items.Events(ctx, in.Key, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + var events []kernel.VaultItemEvent + if res != nil { + events = *res + } + + if in.Output == "json" { + return util.PrintPrettyJSONSlice(events) + } + + if len(events) == 0 { + pterm.Info.Printf("No events found for item '%s'\n", in.Key) + return nil + } + + rows := pterm.TableData{{"Event ID", "Name", "Browser ID", "Created At"}} + for _, event := range events { + rows = append(rows, []string{ + event.ID, + event.Name, + orDash(event.BrowserID), + util.FormatLocal(event.CreatedAt), + }) + } + PrintTableNoPad(rows, true) + return nil +} + +func (v VaultsCmd) ItemsPerformOperation(ctx context.Context, in VaultItemsPerformOperationInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + opType := strings.ToLower(strings.TrimSpace(in.Type)) + if opType == "" { + return fmt.Errorf("--type is required") + } + + item, err := v.items.PerformOperation(ctx, in.Key, kernel.VaultItemPerformOperationParams{ + IDOrName: in.Vault, + Type: kernel.VaultItemPerformOperationParamsType(opType), + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(item) + } + + printVaultItemTable(item) + return nil +} + +// --- Display helpers --- + +func printVaultTable(vault *kernel.Vault) { + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"ID", vault.ID}) + rows = append(rows, []string{"Name", vault.Name}) + rows = append(rows, []string{"Created At", util.FormatLocal(vault.CreatedAt)}) + rows = append(rows, []string{"Updated At", util.FormatLocal(vault.UpdatedAt)}) + PrintTableNoPad(rows, true) +} + +func printVaultItemTable(item *kernel.VaultItemUnion) { + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"ID", item.ID}) + rows = append(rows, []string{"Key", item.Key}) + rows = append(rows, []string{"Type", orDash(item.Type)}) + rows = append(rows, []string{"Provider", orDash(item.State.Provider)}) + rows = append(rows, []string{"Status", orDash(item.State.Status)}) + if item.State.StatusReason != "" { + rows = append(rows, []string{"Status Reason", item.State.StatusReason}) + } + if item.Action.Name != "" { + rows = append(rows, []string{"Action", item.Action.Name}) + } + if item.Action.URL != "" { + rows = append(rows, []string{"Action URL", item.Action.URL}) + } + if ops := vaultItemOperationTypes(item); len(ops) > 0 { + rows = append(rows, []string{"Available Operations", strings.Join(ops, ", ")}) + } + if exp := vaultItemExpansionTypes(item); len(exp) > 0 { + rows = append(rows, []string{"Available Expansions", strings.Join(exp, ", ")}) + } + rows = append(rows, []string{"Created At", util.FormatLocal(item.CreatedAt)}) + rows = append(rows, []string{"Updated At", util.FormatLocal(item.UpdatedAt)}) + if !item.ExpiresAt.IsZero() { + rows = append(rows, []string{"Expires At", util.FormatLocal(item.ExpiresAt)}) + } + PrintTableNoPad(rows, true) + + // The operation descriptions tell the caller which operation to invoke next, + // so surface them rather than only the bare type names. + if descriptions := vaultItemOperationDescriptions(item); len(descriptions) > 0 { + pterm.Println() + pterm.DefaultSection.Println("Available operations") + for _, d := range descriptions { + pterm.Printf(" %s: %s\n", d[0], d[1]) + } + } +} + +// vaultItemOperationTypes flattens the wallet/card variants of available_operations +// into their type names. +func vaultItemOperationTypes(item *kernel.VaultItemUnion) []string { + var out []string + for _, op := range item.AvailableOperations.OfVaultItemWalletAvailableOperations { + out = append(out, op.Type) + } + for _, op := range item.AvailableOperations.OfVaultItemCardAvailableOperations { + out = append(out, op.Type) + } + return out +} + +func vaultItemOperationDescriptions(item *kernel.VaultItemUnion) [][2]string { + var out [][2]string + for _, op := range item.AvailableOperations.OfVaultItemWalletAvailableOperations { + out = append(out, [2]string{op.Type, op.Description}) + } + for _, op := range item.AvailableOperations.OfVaultItemCardAvailableOperations { + out = append(out, [2]string{op.Type, op.Description}) + } + return out +} + +func vaultItemExpansionTypes(item *kernel.VaultItemUnion) []string { + var out []string + for _, e := range item.AvailableExpansions.OfVaultItemWalletAvailableExpansions { + out = append(out, e.Type) + } + for _, e := range item.AvailableExpansions.OfVaultItemCardAvailableExpansions { + out = append(out, e.Type) + } + return out +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} + +// normalizeList splits comma-separated entries out of a repeatable string flag +// and drops blanks, so --expand a,b and --expand a --expand b behave the same. +func normalizeList(values []string) []string { + var out []string + for _, value := range values { + for _, part := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + } + return out +} + +// readVaultItemSpec resolves the --spec / --spec-file inputs into raw JSON. The two +// inputs are mutually exclusive (enforced by cobra); a file path of "-" reads stdin. +// It returns nil when neither input is set. +func readVaultItemSpec(inline, file string) ([]byte, error) { + data := strings.TrimSpace(inline) + if file != "" { + var b []byte + var err error + if file == "-" { + b, err = io.ReadAll(os.Stdin) + } else { + b, err = os.ReadFile(file) + } + if err != nil { + return nil, fmt.Errorf("failed to read spec file: %w", err) + } + data = strings.TrimSpace(string(b)) + } + + if data == "" { + return nil, nil + } + if !json.Valid([]byte(data)) { + return nil, fmt.Errorf("invalid JSON in spec (must be a JSON object)") + } + return []byte(data), nil +} + +// --- Cobra wiring --- + +var vaultsCmd = &cobra.Command{ + Use: "vaults", + Aliases: []string{"vault"}, + Short: "Manage vaults", + Long: "Manage project-scoped vaults and the items they hold.\n\n" + + "A vault is a named container for payment items. Link vaults to a browser session with " + + "'kernel browsers create --vault' so the session can use their items.", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +var vaultsListCmd = &cobra.Command{ + Use: "list", + Short: "List vaults in the current project", + Args: cobra.NoArgs, + RunE: runVaultsList, +} + +var vaultsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a vault by ID or name", + Args: cobra.ExactArgs(1), + RunE: runVaultsGet, +} + +var vaultsCreateCmd = &cobra.Command{ + Use: "create --name ", + Short: "Create or retrieve a vault by name", + Long: "Create a vault with the given name. The name is immutable, and creating with a name that " + + "already exists returns the existing vault rather than failing.", + Args: cobra.NoArgs, + RunE: runVaultsCreate, +} + +var vaultsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a vault by ID or name", + Long: "Delete a vault. Every item it holds is invalidated along with it.", + Args: cobra.ExactArgs(1), + RunE: runVaultsDelete, +} + +var vaultItemsCmd = &cobra.Command{ + Use: "items", + Aliases: []string{"item"}, + Short: "Manage vault items", + Long: "Manage the items held in a vault.\n\n" + + "An item is either a wallet (an authorized funding source) or a card (a payment credential minted " + + "from a wallet). Items advertise the operations valid in their current state; run 'kernel vaults items get' " + + "and read Available Operations before invoking one with 'kernel vaults items perform-operation'.", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +var vaultItemsListCmd = &cobra.Command{ + Use: "list ", + Short: "List items in a vault", + Long: "List the items in a vault. Secret values are never returned.", + Args: cobra.ExactArgs(1), + RunE: runVaultItemsList, +} + +var vaultItemsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a vault item", + Long: "Get a vault item along with the operations currently valid for it.\n\n" + + "--wait holds for up to that many seconds while the item is pending authorization or approval. " + + "--expand requests live provider data listed under Available Expansions; expanded data is fetched " + + "from the provider and is not persisted in the item.", + Args: cobra.ExactArgs(2), + RunE: runVaultItemsGet, +} + +var vaultItemsCreateCmd = &cobra.Command{ + Use: "create --type --spec ", + Short: "Create or retrieve a vault item by key", + Long: "Create a vault item under the given key. The key is immutable, and creating with a key that " + + "already exists returns the existing item rather than failing.\n\n" + + "--spec takes the provider-specific spec as a JSON object, discriminated by its \"provider\" field.\n\n" + + "Wallet examples:\n" + + " --type wallet --spec '{\"provider\":\"agentcard\",\"user_id\":\"usr_123\"}'\n" + + " --type wallet --spec '{\"provider\":\"link\",\"authorization\":{\"method\":\"oauth\",\"client\":{\"type\":\"kernel_managed\"}}}'\n\n" + + "Card example:\n" + + " --type card --spec '{\"provider\":\"agentcard\",\"wallet\":\"my-wallet\",\"merchant\":\"Acme\",\"amount\":1250,\"currency\":\"USD\"}'\n\n" + + "Amounts are integers in minor currency units (1250 = $12.50).", + Args: cobra.ExactArgs(2), + RunE: runVaultItemsCreate, +} + +var vaultItemsUpdateCmd = &cobra.Command{ + Use: "update --spec ", + Short: "Update a card item's specification", + Long: "Update a card item's specification before or between authorizations. Only card items can be " + + "updated; --spec takes the full replacement card spec as a JSON object.", + Args: cobra.ExactArgs(2), + RunE: runVaultItemsUpdate, +} + +var vaultItemsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a vault item", + Long: "Delete a vault item. Its secret value is invalidated.", + Args: cobra.ExactArgs(2), + RunE: runVaultItemsDelete, +} + +var vaultItemsEventsCmd = &cobra.Command{ + Use: "events ", + Short: "List audit events for a vault item", + Long: "List the immutable audit events recorded for a vault item, oldest first.\n\n" + + "--after returns only events after the given event ID, and --wait long-polls for up to that many " + + "seconds when there is nothing new yet, so the two together follow an item's progress.", + Args: cobra.ExactArgs(2), + RunE: runVaultItemsEvents, +} + +var vaultItemsPerformOperationCmd = &cobra.Command{ + Use: "perform-operation --type ", + Short: "Perform an operation advertised by a vault item", + Long: "Perform one of the operations the item currently advertises. Run 'kernel vaults items get' first " + + "and invoke only an operation listed under Available Operations, following its description. " + + "Operations may call an external provider and return the item's updated state.", + Args: cobra.ExactArgs(2), + RunE: runVaultItemsPerformOperation, +} + +func init() { + vaultsCmd.AddCommand(vaultsListCmd) + vaultsCmd.AddCommand(vaultsGetCmd) + vaultsCmd.AddCommand(vaultsCreateCmd) + vaultsCmd.AddCommand(vaultsDeleteCmd) + vaultsCmd.AddCommand(vaultItemsCmd) + + vaultItemsCmd.AddCommand(vaultItemsListCmd) + vaultItemsCmd.AddCommand(vaultItemsGetCmd) + vaultItemsCmd.AddCommand(vaultItemsCreateCmd) + vaultItemsCmd.AddCommand(vaultItemsUpdateCmd) + vaultItemsCmd.AddCommand(vaultItemsDeleteCmd) + vaultItemsCmd.AddCommand(vaultItemsEventsCmd) + vaultItemsCmd.AddCommand(vaultItemsPerformOperationCmd) + + addJSONOutputFlag(vaultsListCmd) + vaultsListCmd.Flags().Int("page", 1, "Page number (1-based)") + vaultsListCmd.Flags().Int("per-page", 20, "Items per page (default 20)") + + addJSONOutputFlag(vaultsGetCmd) + + addJSONOutputFlag(vaultsCreateCmd) + vaultsCreateCmd.Flags().String("name", "", "Immutable name used to create or retrieve the vault (required)") + _ = vaultsCreateCmd.MarkFlagRequired("name") + + vaultsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + addJSONOutputFlag(vaultItemsListCmd) + + addJSONOutputFlag(vaultItemsGetCmd) + vaultItemsGetCmd.Flags().Int("wait", 0, "Hold for up to this many seconds while the item is pending authorization or approval (max 60)") + vaultItemsGetCmd.Flags().StringArray("expand", nil, "Live fields to include, from the item's available expansions (repeatable or comma-separated; e.g. payment_methods)") + + addJSONOutputFlag(vaultItemsCreateCmd) + vaultItemsCreateCmd.Flags().String("type", "", "Item type: wallet or card (required)") + vaultItemsCreateCmd.Flags().String("spec", "", "Provider-specific item spec as a JSON object") + vaultItemsCreateCmd.Flags().String("spec-file", "", "Read the item spec (JSON object) from a file (use '-' for stdin)") + vaultItemsCreateCmd.MarkFlagsMutuallyExclusive("spec", "spec-file") + _ = vaultItemsCreateCmd.MarkFlagRequired("type") + + addJSONOutputFlag(vaultItemsUpdateCmd) + vaultItemsUpdateCmd.Flags().String("spec", "", "Replacement card spec as a JSON object") + vaultItemsUpdateCmd.Flags().String("spec-file", "", "Read the card spec (JSON object) from a file (use '-' for stdin)") + vaultItemsUpdateCmd.MarkFlagsMutuallyExclusive("spec", "spec-file") + + vaultItemsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + addJSONOutputFlag(vaultItemsEventsCmd) + vaultItemsEventsCmd.Flags().String("after", "", "Return only events after this event ID") + vaultItemsEventsCmd.Flags().Int("wait", 0, "Long-poll for new events for up to this many seconds (max 60)") + + addJSONOutputFlag(vaultItemsPerformOperationCmd) + vaultItemsPerformOperationCmd.Flags().String("type", "authorize", "Operation to perform, from the item's available operations") + + rootCmd.AddCommand(vaultsCmd) +} + +func getVaultsHandler(cmd *cobra.Command) VaultsCmd { + client := getKernelClient(cmd) + svc := client.Vaults + items := client.Vaults.Items + return VaultsCmd{vaults: &svc, items: &items, prompter: interactive.NewPrompter()} +} + +func runVaultsList(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + page, _ := cmd.Flags().GetInt("page") + perPage, _ := cmd.Flags().GetInt("per-page") + return getVaultsHandler(cmd).List(cmd.Context(), VaultsListInput{ + Page: page, + PerPage: perPage, + Output: output, + }) +} + +func runVaultsGet(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + return getVaultsHandler(cmd).Get(cmd.Context(), VaultsGetInput{Identifier: args[0], Output: output}) +} + +func runVaultsCreate(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + output, _ := cmd.Flags().GetString("output") + return getVaultsHandler(cmd).Create(cmd.Context(), VaultsCreateInput{Name: name, Output: output}) +} + +func runVaultsDelete(cmd *cobra.Command, args []string) error { + skip, _ := cmd.Flags().GetBool("yes") + return getVaultsHandler(cmd).Delete(cmd.Context(), VaultsDeleteInput{Identifier: args[0], SkipConfirm: skip}) +} + +func runVaultItemsList(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + return getVaultsHandler(cmd).ItemsList(cmd.Context(), VaultItemsListInput{Vault: args[0], Output: output}) +} + +func runVaultItemsGet(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + wait, _ := cmd.Flags().GetInt("wait") + expand, _ := cmd.Flags().GetStringArray("expand") + return getVaultsHandler(cmd).ItemsGet(cmd.Context(), VaultItemsGetInput{ + Vault: args[0], + Key: args[1], + Wait: wait, + Expand: expand, + Output: output, + }) +} + +func runVaultItemsCreate(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + itemType, _ := cmd.Flags().GetString("type") + spec, _ := cmd.Flags().GetString("spec") + specFile, _ := cmd.Flags().GetString("spec-file") + return getVaultsHandler(cmd).ItemsCreate(cmd.Context(), VaultItemsCreateInput{ + Vault: args[0], + Key: args[1], + Type: itemType, + Spec: spec, + SpecFile: specFile, + Output: output, + }) +} + +func runVaultItemsUpdate(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + spec, _ := cmd.Flags().GetString("spec") + specFile, _ := cmd.Flags().GetString("spec-file") + return getVaultsHandler(cmd).ItemsUpdate(cmd.Context(), VaultItemsUpdateInput{ + Vault: args[0], + Key: args[1], + Spec: spec, + SpecFile: specFile, + Output: output, + }) +} + +func runVaultItemsDelete(cmd *cobra.Command, args []string) error { + skip, _ := cmd.Flags().GetBool("yes") + return getVaultsHandler(cmd).ItemsDelete(cmd.Context(), VaultItemsDeleteInput{ + Vault: args[0], + Key: args[1], + SkipConfirm: skip, + }) +} + +func runVaultItemsEvents(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + after, _ := cmd.Flags().GetString("after") + wait, _ := cmd.Flags().GetInt("wait") + return getVaultsHandler(cmd).ItemsEvents(cmd.Context(), VaultItemsEventsInput{ + Vault: args[0], + Key: args[1], + After: after, + Wait: wait, + Output: output, + }) +} + +func runVaultItemsPerformOperation(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + opType, _ := cmd.Flags().GetString("type") + return getVaultsHandler(cmd).ItemsPerformOperation(cmd.Context(), VaultItemsPerformOperationInput{ + Vault: args[0], + Key: args[1], + Type: opType, + Output: output, + }) +} diff --git a/go.mod b/go.mod index bc751a59..f468e82b 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.98.1-0.20260903142348-7a377c78440a + github.com/kernel/kernel-go-sdk v0.98.1-0.20260904181826-31c5fee38442 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 671e4b7c..f90ecaeb 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.98.1-0.20260903142348-7a377c78440a h1:ox8aHPzZHvognOoyjXeECk/tFh1Gg+ceY5D9DrLTdFA= -github.com/kernel/kernel-go-sdk v0.98.1-0.20260903142348-7a377c78440a/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.98.1-0.20260904181826-31c5fee38442 h1:eJlEvDsLczaMx7TGoh1l/wwa0wQfL2GYlmL8QyMUI5A= +github.com/kernel/kernel-go-sdk v0.98.1-0.20260904181826-31c5fee38442/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= From c3ed6f1099c2acdc1cec25cdca3a68a592d774ac Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:43:54 +0000 Subject: [PATCH 24/51] chore: update Go SDK to v0.99.0 (b228059), test and document vaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps github.com/kernel/kernel-go-sdk from 31c5fee to v0.99.0 (b228059). That range is the release commit only — release metadata and changelog, no API surface — so the vault commands already on this branch cover every method in api.md that is not marked x-cli-skip. A full enumeration of api.md against the CLI found no other gaps: the only endpoints without commands are the Config Registry ones and /auth/connections/{id}/exchange, all x-cli-skip. What this commit adds on top of the version bump: - cmd/vaults_test.go: coverage for the new commands, which shipped without any. Pins the list pagination contract (limit = per-page + 1, the extra item trimmed before display, footer and Next hint), the wallet/card spec discrimination for items create/update, --spec vs --spec-file, --wait / --expand / --after plumbing, and the delete confirmations. The two delete paths get the non-interactive fail-fast tests the repo requires of any command that can prompt. - cmd/browsers_test.go: --vault maps Kernel-shaped values to IDs and anything else to names, and drops blanks. - README.md: a Vaults section in the command reference (vaults and vault items, with spec examples) and the browsers create --vault flag. Tested against the production API with the bumped SDK: vaults create/get/list/delete, items create (wallet+agentcard via --spec), list/get (incl. --wait), events, perform-operation (409 as expected for an unauthorized wallet), delete, and browsers create --vault — verified the Vaults row in browsers get and Usage Status on the deleted session. All test resources cleaned up. go build, go vet and go test ./... pass. Co-Authored-By: Claude Opus 5 --- README.md | 66 ++++++ cmd/browsers_test.go | 42 ++++ cmd/vaults_test.go | 542 +++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 5 files changed, 653 insertions(+), 3 deletions(-) create mode 100644 cmd/vaults_test.go diff --git a/README.md b/README.md index 1ced9238..607f944e 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,7 @@ Commands with JSON output support: - `--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 + - `--vault ` - Link a project-scoped vault to the session so it can use the vault's items (repeatable or comma-separated). Fixed once the session is created; `browsers get` lists the linked vaults. - `--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. - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - `--telemetry=all` - Enable telemetry for all categories @@ -511,6 +512,71 @@ Destinations are the OTLP/HTTP endpoints sessions export to, managed per project - `--to ` - Directory to extract the profile into (required) - `--format ` - Archive format to request: `tar.zst` (compressed, default) or `tar` (decompressed server-side) +### Vaults + +A vault is a named, project-scoped container for payment items. Vault names and +item keys are immutable, so `create` behaves as an upsert: creating with a name +or key that already exists returns what is there rather than failing. Link +vaults to a session with `kernel browsers create --vault `. + +- `kernel vaults list` - List vaults in the current project + - `--page ` - Page number, 1-based (default 1) + - `--per-page ` - Items per page (default 20) + - `--output json`, `-o json` - Output raw JSON array + - When more vaults are available, the CLI prints the exact command to fetch the next page +- `kernel vaults get ` - Get a vault by ID or name + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults create --name ` - Create or retrieve a vault by name + - `--name ` - Immutable vault name (required) + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults delete ` - Delete a vault; every item it holds is invalidated + - `-y, --yes` - Skip confirmation prompt + +#### Vault Items + +An item is either a wallet (an authorized funding source) or a card (a payment +credential minted from a wallet). Items advertise the operations valid in their +current state, so run `kernel vaults items get` and read `Available Operations` +before invoking one. + +- `kernel vaults items list ` - List a vault's items; secret values are never returned + - `--output json`, `-o json` - Output raw JSON array +- `kernel vaults items get ` - Get an item and the operations currently valid for it + - `--wait ` - Hold for up to this many seconds while the item is pending authorization or approval (max 60) + - `--expand ` - Request live provider data listed under `Available Expansions`, e.g. `payment_methods` (repeatable or comma-separated). Expanded data is fetched from the provider and is not persisted in the item. + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults items create --type --spec ` - Create or retrieve an item by key + - `--type wallet|card` - Item type (required) + - `--spec ` - Provider-specific spec as a JSON object, discriminated by its `provider` field. Amounts are integers in minor currency units (`1250` = $12.50). + - `--spec-file ` - Read the spec from a file (use `-` for stdin). Mutually exclusive with `--spec`. + - `--output json`, `-o json` - Output raw JSON object + + Examples: + + ```bash + kernel vaults items create my-vault my-wallet --type wallet \ + --spec '{"provider":"agentcard","user_id":"usr_123"}' + + kernel vaults items create my-vault my-wallet --type wallet \ + --spec '{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}' + + kernel vaults items create my-vault my-card --type card \ + --spec '{"provider":"agentcard","wallet":"my-wallet","merchant":"Acme","amount":1250,"currency":"USD"}' + ``` + +- `kernel vaults items update --spec ` - Update a card item's spec before or between authorizations + - `--spec ` / `--spec-file ` - Full replacement card spec (only card items can be updated) + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults items perform-operation ` - Perform an operation the item advertises + - `--type ` - Operation to perform (default `authorize`). Operations may call an external provider and return the item's updated state. + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults items events ` - List an item's immutable audit events, oldest first + - `--after ` - Return only events after this event ID + - `--wait ` - Long-poll for new events for up to this many seconds (max 60). Together with `--after`, this follows an item's progress. + - `--output json`, `-o json` - Output raw JSON array +- `kernel vaults items delete ` - Delete an item; its secret value is invalidated + - `-y, --yes` - Skip confirmation prompt + ### Projects - `kernel projects list` - List projects (up to 100 by default) diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index a039f2fd..6e91fb9a 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -518,6 +518,48 @@ func TestBrowsersCreate_WithNameAndTags(t *testing.T) { assert.Contains(t, out, "env=staging, team=backend") } +func TestBrowsersCreate_WithVaults(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "sess-vaults"}, nil + }, + } + + b := BrowsersCmd{browsers: fake} + err := b.Create(context.Background(), BrowsersCreateInput{ + // A Kernel-shaped identifier is sent as an ID, anything else as a name, + // and blank entries are dropped. + Vaults: []string{"gtw36zdwv9as2etqetxpnspl", " payments ", ""}, + }) + assert.NoError(t, err) + + require.Len(t, captured.Vaults, 2) + assert.Equal(t, "gtw36zdwv9as2etqetxpnspl", captured.Vaults[0].ID.Value) + assert.False(t, captured.Vaults[0].Name.Valid()) + assert.Equal(t, "payments", captured.Vaults[1].Name.Value) + assert.False(t, captured.Vaults[1].ID.Valid()) +} + +func TestBrowsersCreate_WithoutVaults(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "sess-no-vaults"}, nil + }, + } + + b := BrowsersCmd{browsers: fake} + assert.NoError(t, b.Create(context.Background(), BrowsersCreateInput{})) + assert.Empty(t, captured.Vaults) +} + func TestBrowsersCreate_WithPrivateHosts(t *testing.T) { setupStdoutCapture(t) diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go new file mode 100644 index 00000000..01b590c9 --- /dev/null +++ b/cmd/vaults_test.go @@ -0,0 +1,542 @@ +package cmd + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kernel/cli/pkg/interactive" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// FakeVaultsService implements VaultsService +type FakeVaultsService struct { + GetFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Vault, error) + ListFunc func(ctx context.Context, query kernel.VaultListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Vault], error) + DeleteFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) error + UpsertFunc func(ctx context.Context, body kernel.VaultUpsertParams, opts ...option.RequestOption) (*kernel.Vault, error) +} + +func (f *FakeVaultsService) Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Vault, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, idOrName, opts...) + } + return &kernel.Vault{ID: "v1", Name: idOrName, CreatedAt: time.Unix(0, 0), UpdatedAt: time.Unix(0, 0)}, nil +} + +func (f *FakeVaultsService) List(ctx context.Context, query kernel.VaultListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Vault], error) { + if f.ListFunc != nil { + return f.ListFunc(ctx, query, opts...) + } + return &pagination.OffsetPagination[kernel.Vault]{Items: []kernel.Vault{}}, nil +} + +func (f *FakeVaultsService) Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) error { + if f.DeleteFunc != nil { + return f.DeleteFunc(ctx, idOrName, opts...) + } + return nil +} + +func (f *FakeVaultsService) Upsert(ctx context.Context, body kernel.VaultUpsertParams, opts ...option.RequestOption) (*kernel.Vault, error) { + if f.UpsertFunc != nil { + return f.UpsertFunc(ctx, body, opts...) + } + return &kernel.Vault{ID: "v1", Name: body.Name, CreatedAt: time.Unix(0, 0), UpdatedAt: time.Unix(0, 0)}, nil +} + +// FakeVaultItemsService implements VaultItemsService +type FakeVaultItemsService struct { + GetFunc func(ctx context.Context, key string, params kernel.VaultItemGetParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) + UpdateFunc func(ctx context.Context, key string, params kernel.VaultItemUpdateParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) + ListFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*[]kernel.VaultItemUnion, error) + DeleteFunc func(ctx context.Context, key string, body kernel.VaultItemDeleteParams, opts ...option.RequestOption) error + EventsFunc func(ctx context.Context, key string, params kernel.VaultItemEventsParams, opts ...option.RequestOption) (*[]kernel.VaultItemEvent, error) + PerformOperationFunc func(ctx context.Context, key string, params kernel.VaultItemPerformOperationParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) + UpsertFunc func(ctx context.Context, key string, params kernel.VaultItemUpsertParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) +} + +func (f *FakeVaultItemsService) Get(ctx context.Context, key string, params kernel.VaultItemGetParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, key, params, opts...) + } + return &kernel.VaultItemUnion{ID: "i1", Key: key, Type: "wallet"}, nil +} + +func (f *FakeVaultItemsService) Update(ctx context.Context, key string, params kernel.VaultItemUpdateParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + if f.UpdateFunc != nil { + return f.UpdateFunc(ctx, key, params, opts...) + } + return &kernel.VaultItemUnion{ID: "i1", Key: key, Type: "card"}, nil +} + +func (f *FakeVaultItemsService) List(ctx context.Context, idOrName string, opts ...option.RequestOption) (*[]kernel.VaultItemUnion, error) { + if f.ListFunc != nil { + return f.ListFunc(ctx, idOrName, opts...) + } + items := []kernel.VaultItemUnion{} + return &items, nil +} + +func (f *FakeVaultItemsService) Delete(ctx context.Context, key string, body kernel.VaultItemDeleteParams, opts ...option.RequestOption) error { + if f.DeleteFunc != nil { + return f.DeleteFunc(ctx, key, body, opts...) + } + return nil +} + +func (f *FakeVaultItemsService) Events(ctx context.Context, key string, params kernel.VaultItemEventsParams, opts ...option.RequestOption) (*[]kernel.VaultItemEvent, error) { + if f.EventsFunc != nil { + return f.EventsFunc(ctx, key, params, opts...) + } + events := []kernel.VaultItemEvent{} + return &events, nil +} + +func (f *FakeVaultItemsService) PerformOperation(ctx context.Context, key string, params kernel.VaultItemPerformOperationParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + if f.PerformOperationFunc != nil { + return f.PerformOperationFunc(ctx, key, params, opts...) + } + return &kernel.VaultItemUnion{ID: "i1", Key: key, Type: "card"}, nil +} + +func (f *FakeVaultItemsService) Upsert(ctx context.Context, key string, params kernel.VaultItemUpsertParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + if f.UpsertFunc != nil { + return f.UpsertFunc(ctx, key, params, opts...) + } + return &kernel.VaultItemUnion{ID: "i1", Key: key, Type: "wallet"}, nil +} + +// vaultItemFromJSON builds a VaultItemUnion the way the SDK does, so tests +// exercise the same union field population the API responses produce. +func vaultItemFromJSON(t *testing.T, raw string) kernel.VaultItemUnion { + t.Helper() + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(raw), &item)) + return item +} + +func TestVaultsList_Empty(t *testing.T) { + buf := capturePtermOutput(t) + v := VaultsCmd{vaults: &FakeVaultsService{}} + require.NoError(t, v.List(context.Background(), VaultsListInput{Page: 1, PerPage: 20})) + assert.Contains(t, buf.String(), "No vaults found") +} + +func TestVaultsList_PaginationFooter(t *testing.T) { + buf := capturePtermOutput(t) + // Three vaults for a page size of two: the extra item is what signals that + // another page exists, and must not be displayed. + rows := []kernel.Vault{ + {ID: "v1", Name: "alpha", CreatedAt: time.Unix(0, 0), UpdatedAt: time.Unix(0, 0)}, + {ID: "v2", Name: "beta", CreatedAt: time.Unix(0, 0), UpdatedAt: time.Unix(0, 0)}, + {ID: "v3", Name: "gamma", CreatedAt: time.Unix(0, 0), UpdatedAt: time.Unix(0, 0)}, + } + var got kernel.VaultListParams + fake := &FakeVaultsService{ListFunc: func(ctx context.Context, query kernel.VaultListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Vault], error) { + got = query + return &pagination.OffsetPagination[kernel.Vault]{Items: rows}, nil + }} + v := VaultsCmd{vaults: fake} + require.NoError(t, v.List(context.Background(), VaultsListInput{Page: 2, PerPage: 2})) + + assert.Equal(t, int64(3), got.Limit.Value, "requests one extra item to detect the next page") + assert.Equal(t, int64(2), got.Offset.Value) + + out := buf.String() + assert.Contains(t, out, "alpha") + assert.Contains(t, out, "beta") + assert.NotContains(t, out, "gamma", "the extra item is trimmed before display") + assert.Contains(t, out, "Page: 2 Per-page: 2 Items this page: 2 Has more: yes") + assert.Contains(t, out, "Next: kernel vaults list --page 3 --per-page 2") +} + +func TestVaultsList_DefaultsPageAndPerPage(t *testing.T) { + _ = capturePtermOutput(t) + var got kernel.VaultListParams + fake := &FakeVaultsService{ListFunc: func(ctx context.Context, query kernel.VaultListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Vault], error) { + got = query + return &pagination.OffsetPagination[kernel.Vault]{Items: []kernel.Vault{}}, nil + }} + v := VaultsCmd{vaults: fake} + require.NoError(t, v.List(context.Background(), VaultsListInput{})) + + assert.Equal(t, int64(21), got.Limit.Value, "defaults to 20 per page plus the lookahead item") + assert.Equal(t, int64(0), got.Offset.Value) +} + +func TestVaultsCreate_RequiresName(t *testing.T) { + _ = capturePtermOutput(t) + v := VaultsCmd{vaults: &FakeVaultsService{}} + err := v.Create(context.Background(), VaultsCreateInput{Name: " "}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--name is required") +} + +func TestVaultsCreate_PassesName(t *testing.T) { + buf := capturePtermOutput(t) + var got kernel.VaultUpsertParams + fake := &FakeVaultsService{UpsertFunc: func(ctx context.Context, body kernel.VaultUpsertParams, opts ...option.RequestOption) (*kernel.Vault, error) { + got = body + return &kernel.Vault{ID: "v1", Name: body.Name, CreatedAt: time.Unix(0, 0), UpdatedAt: time.Unix(0, 0)}, nil + }} + v := VaultsCmd{vaults: fake} + require.NoError(t, v.Create(context.Background(), VaultsCreateInput{Name: "payments"})) + + assert.Equal(t, "payments", got.Name) + out := buf.String() + assert.Contains(t, out, "v1") + assert.Contains(t, out, "payments") +} + +func TestVaultsDelete_FailsFastWhenNonInteractive(t *testing.T) { + _ = capturePtermOutput(t) + fake := &FakeVaultsService{DeleteFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) error { + t.Fatal("delete must not be called without confirmation") + return nil + }} + v := VaultsCmd{vaults: fake, prompter: interactive.NewPrompterWithTerminal(false)} + + err := v.Delete(context.Background(), VaultsDeleteInput{Identifier: "payments"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "delete vault 'payments'") + assert.Contains(t, err.Error(), "--yes") +} + +func TestVaultsDelete_SkipConfirm(t *testing.T) { + buf := capturePtermOutput(t) + var deleted string + fake := &FakeVaultsService{DeleteFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) error { + deleted = idOrName + return nil + }} + v := VaultsCmd{vaults: fake, prompter: interactive.NewPrompterWithTerminal(false)} + + require.NoError(t, v.Delete(context.Background(), VaultsDeleteInput{Identifier: "payments", SkipConfirm: true})) + assert.Equal(t, "payments", deleted) + assert.Contains(t, buf.String(), "Deleted vault: payments") +} + +func TestVaultItemsDelete_FailsFastWhenNonInteractive(t *testing.T) { + _ = capturePtermOutput(t) + fake := &FakeVaultItemsService{DeleteFunc: func(ctx context.Context, key string, body kernel.VaultItemDeleteParams, opts ...option.RequestOption) error { + t.Fatal("delete must not be called without confirmation") + return nil + }} + v := VaultsCmd{items: fake, prompter: interactive.NewPrompterWithTerminal(false)} + + err := v.ItemsDelete(context.Background(), VaultItemsDeleteInput{Vault: "payments", Key: "wallet"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "delete vault item 'wallet'") + assert.Contains(t, err.Error(), "--yes") +} + +func TestVaultItemsDelete_ScopesToVault(t *testing.T) { + buf := capturePtermOutput(t) + var got kernel.VaultItemDeleteParams + fake := &FakeVaultItemsService{DeleteFunc: func(ctx context.Context, key string, body kernel.VaultItemDeleteParams, opts ...option.RequestOption) error { + got = body + return nil + }} + v := VaultsCmd{items: fake, prompter: interactive.NewPrompterWithTerminal(false)} + + require.NoError(t, v.ItemsDelete(context.Background(), VaultItemsDeleteInput{Vault: "payments", Key: "wallet", SkipConfirm: true})) + assert.Equal(t, "payments", got.IDOrName) + assert.Contains(t, buf.String(), "Deleted vault item: wallet") +} + +func TestVaultItemsGet_SendsWaitAndExpand(t *testing.T) { + _ = capturePtermOutput(t) + var got kernel.VaultItemGetParams + fake := &FakeVaultItemsService{GetFunc: func(ctx context.Context, key string, params kernel.VaultItemGetParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + got = params + return &kernel.VaultItemUnion{ID: "i1", Key: key, Type: "wallet"}, nil + }} + v := VaultsCmd{items: fake} + require.NoError(t, v.ItemsGet(context.Background(), VaultItemsGetInput{ + Vault: "payments", + Key: "wallet", + Wait: 30, + Expand: []string{"payment_methods, ", ""}, + })) + + assert.Equal(t, "payments", got.IDOrName) + assert.Equal(t, int64(30), got.Wait.Value) + assert.Equal(t, []string{"payment_methods"}, got.Expand, "comma-separated entries are split and blanks dropped") +} + +func TestVaultItemsGet_OmitsUnsetWaitAndExpand(t *testing.T) { + _ = capturePtermOutput(t) + var got kernel.VaultItemGetParams + fake := &FakeVaultItemsService{GetFunc: func(ctx context.Context, key string, params kernel.VaultItemGetParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + got = params + return &kernel.VaultItemUnion{ID: "i1", Key: key, Type: "wallet"}, nil + }} + v := VaultsCmd{items: fake} + require.NoError(t, v.ItemsGet(context.Background(), VaultItemsGetInput{Vault: "payments", Key: "wallet"})) + + assert.False(t, got.Wait.Valid()) + assert.Empty(t, got.Expand) +} + +func TestVaultItemsGet_RendersOperationsAndAction(t *testing.T) { + buf := capturePtermOutput(t) + item := vaultItemFromJSON(t, `{ + "id":"i1", + "key":"wallet", + "type":"wallet", + "spec":{"provider":"agentcard"}, + "state":{"provider":"agentcard","status":"pending_authorization"}, + "action":{"name":"card_enrollment","url":"https://vault.example.com/enroll"}, + "available_operations":[{"type":"authorize","description":"Authorize the wallet."}], + "available_expansions":[{"type":"payment_methods","description":"Live payment methods."}], + "created_at":"1970-01-01T00:00:00Z", + "updated_at":"1970-01-01T00:00:00Z" + }`) + fake := &FakeVaultItemsService{GetFunc: func(ctx context.Context, key string, params kernel.VaultItemGetParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + return &item, nil + }} + v := VaultsCmd{items: fake} + require.NoError(t, v.ItemsGet(context.Background(), VaultItemsGetInput{Vault: "payments", Key: "wallet"})) + + out := buf.String() + assert.Contains(t, out, "pending_authorization") + assert.Contains(t, out, "card_enrollment") + assert.Contains(t, out, "https://vault.example.com/enroll") + assert.Contains(t, out, "authorize") + assert.Contains(t, out, "payment_methods") + assert.Contains(t, out, "Authorize the wallet.", "operation descriptions say which operation to invoke next") +} + +func TestVaultItemsCreate_RejectsUnknownType(t *testing.T) { + _ = capturePtermOutput(t) + v := VaultsCmd{items: &FakeVaultItemsService{}} + err := v.ItemsCreate(context.Background(), VaultItemsCreateInput{Vault: "payments", Key: "k", Type: "cheque", Spec: "{}"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --type") +} + +func TestVaultItemsCreate_RequiresSpec(t *testing.T) { + _ = capturePtermOutput(t) + v := VaultsCmd{items: &FakeVaultItemsService{}} + err := v.ItemsCreate(context.Background(), VaultItemsCreateInput{Vault: "payments", Key: "k", Type: "wallet"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must specify one of --spec or --spec-file") +} + +func TestVaultItemsCreate_RejectsInvalidJSON(t *testing.T) { + _ = capturePtermOutput(t) + v := VaultsCmd{items: &FakeVaultItemsService{}} + err := v.ItemsCreate(context.Background(), VaultItemsCreateInput{Vault: "payments", Key: "k", Type: "wallet", Spec: "{not json"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid JSON in spec") +} + +func TestVaultItemsCreate_WalletSpec(t *testing.T) { + _ = capturePtermOutput(t) + var got kernel.VaultItemUpsertParams + fake := &FakeVaultItemsService{UpsertFunc: func(ctx context.Context, key string, params kernel.VaultItemUpsertParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + got = params + return &kernel.VaultItemUnion{ID: "i1", Key: key, Type: "wallet"}, nil + }} + v := VaultsCmd{items: fake} + require.NoError(t, v.ItemsCreate(context.Background(), VaultItemsCreateInput{ + Vault: "payments", + Key: "wallet", + Type: "WALLET", + Spec: `{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}`, + })) + + assert.Equal(t, "payments", got.IDOrName) + require.NotNil(t, got.OfWallet) + assert.Nil(t, got.OfCard) + + body, err := json.Marshal(got.OfWallet.Spec) + require.NoError(t, err) + assert.JSONEq(t, `{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}`, string(body)) +} + +func TestVaultItemsCreate_CardSpec(t *testing.T) { + _ = capturePtermOutput(t) + var got kernel.VaultItemUpsertParams + fake := &FakeVaultItemsService{UpsertFunc: func(ctx context.Context, key string, params kernel.VaultItemUpsertParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + got = params + return &kernel.VaultItemUnion{ID: "i1", Key: key, Type: "card"}, nil + }} + v := VaultsCmd{items: fake} + require.NoError(t, v.ItemsCreate(context.Background(), VaultItemsCreateInput{ + Vault: "payments", + Key: "card", + Type: "card", + Spec: `{"provider":"agentcard","wallet":"my-wallet","merchant":"Acme","amount":1250,"currency":"USD"}`, + })) + + require.NotNil(t, got.OfCard) + assert.Nil(t, got.OfWallet) + + body, err := json.Marshal(got.OfCard.Spec) + require.NoError(t, err) + assert.JSONEq(t, `{"provider":"agentcard","wallet":"my-wallet","merchant":"Acme","amount":1250,"currency":"USD"}`, string(body)) +} + +func TestVaultItemsCreate_SpecFile(t *testing.T) { + _ = capturePtermOutput(t) + path := filepath.Join(t.TempDir(), "spec.json") + require.NoError(t, os.WriteFile(path, []byte(" {\"provider\":\"agentcard\",\"user_id\":\"usr_123\"}\n"), 0o600)) + + var got kernel.VaultItemUpsertParams + fake := &FakeVaultItemsService{UpsertFunc: func(ctx context.Context, key string, params kernel.VaultItemUpsertParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + got = params + return &kernel.VaultItemUnion{ID: "i1", Key: key, Type: "wallet"}, nil + }} + v := VaultsCmd{items: fake} + require.NoError(t, v.ItemsCreate(context.Background(), VaultItemsCreateInput{ + Vault: "payments", + Key: "wallet", + Type: "wallet", + SpecFile: path, + })) + + require.NotNil(t, got.OfWallet) + body, err := json.Marshal(got.OfWallet.Spec) + require.NoError(t, err) + assert.JSONEq(t, `{"provider":"agentcard","user_id":"usr_123"}`, string(body)) +} + +func TestVaultItemsUpdate_SendsCardSpec(t *testing.T) { + _ = capturePtermOutput(t) + var got kernel.VaultItemUpdateParams + fake := &FakeVaultItemsService{UpdateFunc: func(ctx context.Context, key string, params kernel.VaultItemUpdateParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + got = params + return &kernel.VaultItemUnion{ID: "i1", Key: key, Type: "card"}, nil + }} + v := VaultsCmd{items: fake} + require.NoError(t, v.ItemsUpdate(context.Background(), VaultItemsUpdateInput{ + Vault: "payments", + Key: "card", + Spec: `{"provider":"agentcard","wallet":"my-wallet","merchant":"Acme","amount":500,"currency":"USD"}`, + })) + + assert.Equal(t, "payments", got.IDOrName) + body, err := json.Marshal(got.Spec) + require.NoError(t, err) + assert.JSONEq(t, `{"provider":"agentcard","wallet":"my-wallet","merchant":"Acme","amount":500,"currency":"USD"}`, string(body)) +} + +func TestVaultItemsUpdate_RequiresSpec(t *testing.T) { + _ = capturePtermOutput(t) + v := VaultsCmd{items: &FakeVaultItemsService{}} + err := v.ItemsUpdate(context.Background(), VaultItemsUpdateInput{Vault: "payments", Key: "card"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must specify one of --spec or --spec-file") +} + +func TestVaultItemsPerformOperation_SendsType(t *testing.T) { + _ = capturePtermOutput(t) + var got kernel.VaultItemPerformOperationParams + fake := &FakeVaultItemsService{PerformOperationFunc: func(ctx context.Context, key string, params kernel.VaultItemPerformOperationParams, opts ...option.RequestOption) (*kernel.VaultItemUnion, error) { + got = params + return &kernel.VaultItemUnion{ID: "i1", Key: key, Type: "card"}, nil + }} + v := VaultsCmd{items: fake} + require.NoError(t, v.ItemsPerformOperation(context.Background(), VaultItemsPerformOperationInput{ + Vault: "payments", + Key: "card", + Type: "Authorize", + })) + + assert.Equal(t, "payments", got.IDOrName) + assert.Equal(t, kernel.VaultItemPerformOperationParamsTypeAuthorize, got.Type) +} + +func TestVaultItemsPerformOperation_RequiresType(t *testing.T) { + _ = capturePtermOutput(t) + v := VaultsCmd{items: &FakeVaultItemsService{}} + err := v.ItemsPerformOperation(context.Background(), VaultItemsPerformOperationInput{Vault: "payments", Key: "card", Type: " "}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--type is required") +} + +func TestVaultItemsEvents_SendsAfterAndWait(t *testing.T) { + buf := capturePtermOutput(t) + var got kernel.VaultItemEventsParams + fake := &FakeVaultItemsService{EventsFunc: func(ctx context.Context, key string, params kernel.VaultItemEventsParams, opts ...option.RequestOption) (*[]kernel.VaultItemEvent, error) { + got = params + events := []kernel.VaultItemEvent{{ + ID: "e1", + Name: "wallet_authorization_started", + BrowserID: "b1", + CreatedAt: time.Unix(0, 0), + }} + return &events, nil + }} + v := VaultsCmd{items: fake} + require.NoError(t, v.ItemsEvents(context.Background(), VaultItemsEventsInput{ + Vault: "payments", + Key: "wallet", + After: "e0", + Wait: 10, + })) + + assert.Equal(t, "payments", got.IDOrName) + assert.Equal(t, "e0", got.After.Value) + assert.Equal(t, int64(10), got.Wait.Value) + + out := buf.String() + assert.Contains(t, out, "e1") + assert.Contains(t, out, "wallet_authorization_started") + assert.Contains(t, out, "b1") +} + +func TestVaultItemsEvents_Empty(t *testing.T) { + buf := capturePtermOutput(t) + v := VaultsCmd{items: &FakeVaultItemsService{}} + require.NoError(t, v.ItemsEvents(context.Background(), VaultItemsEventsInput{Vault: "payments", Key: "wallet"})) + assert.Contains(t, buf.String(), "No events found for item 'wallet'") +} + +func TestVaultItemsList_RendersRows(t *testing.T) { + buf := capturePtermOutput(t) + item := vaultItemFromJSON(t, `{ + "id":"i1", + "key":"card", + "type":"card", + "spec":{"provider":"agentcard","merchant":"Acme"}, + "state":{"provider":"agentcard","status":"ready"}, + "created_at":"1970-01-01T00:00:00Z", + "updated_at":"1970-01-01T00:00:00Z" + }`) + fake := &FakeVaultItemsService{ListFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*[]kernel.VaultItemUnion, error) { + items := []kernel.VaultItemUnion{item} + return &items, nil + }} + v := VaultsCmd{items: fake} + require.NoError(t, v.ItemsList(context.Background(), VaultItemsListInput{Vault: "payments"})) + + out := buf.String() + assert.Contains(t, out, "card") + assert.Contains(t, out, "agentcard") + assert.Contains(t, out, "ready") +} + +func TestVaultItemsList_Empty(t *testing.T) { + buf := capturePtermOutput(t) + v := VaultsCmd{items: &FakeVaultItemsService{}} + require.NoError(t, v.ItemsList(context.Background(), VaultItemsListInput{Vault: "payments"})) + assert.Contains(t, buf.String(), "No items found in vault 'payments'") +} + +func TestFormatVaultReferences(t *testing.T) { + assert.Empty(t, formatVaultReferences(nil), "no vaults means the row is omitted entirely") + assert.Equal(t, "payments, v2", formatVaultReferences([]kernel.VaultReference{ + {ID: "v1", Name: "payments"}, + {ID: "v2"}, + }), "names are preferred, IDs are the fallback") +} diff --git a/go.mod b/go.mod index f468e82b..0ea38e0e 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.98.1-0.20260904181826-31c5fee38442 + github.com/kernel/kernel-go-sdk v0.99.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 f90ecaeb..c3ed3ba0 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.98.1-0.20260904181826-31c5fee38442 h1:eJlEvDsLczaMx7TGoh1l/wwa0wQfL2GYlmL8QyMUI5A= -github.com/kernel/kernel-go-sdk v0.98.1-0.20260904181826-31c5fee38442/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.99.0 h1:L8P3JNwF46/oWmwhrc8BNbgK8vTUh5HlYnPXqXT4qlU= +github.com/kernel/kernel-go-sdk v0.99.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= From b4ac39e241137f65ce3f30a94dddfb6fff0b131e Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:40:45 +0000 Subject: [PATCH 25/51] chore: update Go SDK to 6ec0643 (vault provider errors, no test-mode cards) Bumps github.com/kernel/kernel-go-sdk from v0.99.0 to v0.99.1-0.20260904193100-6ec0643c8bd4. SDK changes in this range are confined to vaultitem.go: - CardVaultItemSpecLink{,Param}.Test removed (test-mode card creation is no longer supported; cards are live-only), along with the union accessor CardVaultItemSpecUnionParam.GetTest and the Test response fields. - VaultItemService.PerformOperation documents a new 429 `spend_request_rate_limited` provider error. No CLI changes were required: - The CLI never referenced the Test field. `kernel vaults items create/update` accept the provider spec as raw JSON via --spec/--spec-file and unmarshal straight into CardVaultItemSpecUnionParam, so the field removal needs no flag or help-text edit (no test-mode references exist anywhere in the repo). Coverage analysis: full enumeration of all 158 methods in the SDK api.md against cmd/. 153 require CLI coverage and all are covered; the 5 exceptions are the config-registry endpoints marked x-cli-skip in openapi.yaml. Three SSE endpoints (browsers logs stream, process stdout stream, telemetry stream) are covered via their *Streaming SDK variants. Param fields were enumerated transitively from every *Params struct; the only unreferenced fields are deprecated ones already superseded in the CLI (AuthConnectionLoginParams .BrowserTelemetry/.Proxy -> params.Browser.Telemetry/.Proxy) and BrowserCurlParams.TimeoutMs/ResponseEncoding, which do not apply because `kernel browsers curl` intentionally proxies raw HTTP through the SDK browser transport (--max-time covers the timeout; encoding is moot when streaming bytes). Tested against the production API: vaults list, vaults get, vaults items list, vaults items get (card/link item deserializes correctly without the removed test field), vaults items events, browsers list, app list, profiles list. go build, go vet, and go test ./... all pass. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0ea38e0e..4bb4aab9 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.99.0 + github.com/kernel/kernel-go-sdk v0.99.1-0.20260904193100-6ec0643c8bd4 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 c3ed3ba0..0caae332 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.99.0 h1:L8P3JNwF46/oWmwhrc8BNbgK8vTUh5HlYnPXqXT4qlU= -github.com/kernel/kernel-go-sdk v0.99.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.99.1-0.20260904193100-6ec0643c8bd4 h1:9mOJSq+HJVFvYESW4kA8TvZxoclSApYOYH7SKFTkI+8= +github.com/kernel/kernel-go-sdk v0.99.1-0.20260904193100-6ec0643c8bd4/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= From 669ea71b93d6c6208f53b4730bb7ed6603b87a55 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:44:03 +0000 Subject: [PATCH 26/51] chore: pin Go SDK to tagged v0.100.0 (07e74ed) Re-pins github.com/kernel/kernel-go-sdk from the pseudo-version v0.99.1-0.20260904193100-6ec0643c8bd4 (set in b4ac39e) to the tagged release v0.100.0. 07e74ed is the merge commit that release-please tagged as v0.100.0, so this is the same code under a proper semver tag rather than a commit pseudo-version. No API-surface change relative to b4ac39e; the vault card `test` field removal was already analysed and required no CLI change, since vault item specs are passed through as raw JSON via --spec/--spec-file. Re-verified after the bump: full enumeration of api.md (158 methods) against the CLI command tree found no missing commands (the 5 /config-registry endpoints are x-cli-skip in openapi.yaml), and a field-level sweep of all 114 *Params structs against every command's flags found no missing flags. Tested: go build ./... and go test ./... pass; `vaults list`, `vaults items list`, and `vaults items get` on a provider=link card item all return correct output against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4bb4aab9..c00426c4 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.99.1-0.20260904193100-6ec0643c8bd4 + github.com/kernel/kernel-go-sdk v0.100.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 0caae332..84a83f5a 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.99.1-0.20260904193100-6ec0643c8bd4 h1:9mOJSq+HJVFvYESW4kA8TvZxoclSApYOYH7SKFTkI+8= -github.com/kernel/kernel-go-sdk v0.99.1-0.20260904193100-6ec0643c8bd4/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.0 h1:8RKNKk0js3BHdwEkDd0aPX17OEGSHVrI26akNUwttzU= +github.com/kernel/kernel-go-sdk v0.100.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= From 29cc23de0b43b233d5110d187eb990112603cf31 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:49:32 +0000 Subject: [PATCH 27/51] chore: update Go SDK to 4b985af and surface vaults entitlement Bumps github.com/kernel/kernel-go-sdk to v0.100.1-0.20260905183853-4b985afe0962 (kernel/kernel-go-sdk@4b985af), which adds `features.vaults` to the org entitlements response, and shows it as a new "Vaults" row in `kernel org entitlements`. Also repairs three regressions left by the "Merge main into cli-coverage-update" commit, which broke the build and the CLI at startup: - BrowsersCreateInput lost its TelemetryCdpExclude field while runBrowsersCreate still referenced it (compile error); restored the field and its wiring so `browsers create --telemetry-cdp-exclude` works again. - `browsers create` registered --vault twice, panicking on every command ("create flag redefined: vault"); kept main's StringArray registration, which is what the flag reader uses. - `browsers create` mapped vault references twice, sending each vault to the API twice; kept main's buildBrowserVaults (limit, duplicate, and name validation) and dropped the inline copy. A full enumeration of api.md against the CLI command tree found no other coverage gaps; all config-registry endpoints are marked x-cli-skip. Tested against the live API: `org entitlements` (Vaults row + JSON), `browsers create --telemetry-cdp-exclude` (verified excluded_methods in `browsers get`), `browsers create --vault` (single attachment), `vaults create/delete`, `browsers delete`. `go build ./...`, `go vet ./...`, and `go test ./...` all pass. Co-Authored-By: Claude Opus 5 --- cmd/browsers.go | 127 +++++++++++++++++++------------------------ cmd/browsers_test.go | 5 +- cmd/org.go | 1 + cmd/org_test.go | 4 ++ go.mod | 2 +- go.sum | 4 +- 6 files changed, 65 insertions(+), 78 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index c71f878c..cdfbd8e0 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -384,32 +384,33 @@ func formatTags(tags kernel.Tags) string { // Inputs for each command type BrowsersCreateInput struct { - TimeoutSeconds int - Stealth BoolFlag - Headless BoolFlag - GPU BoolFlag - Memory string - InvocationID string - Kiosk BoolFlag - ProfileID string - ProfileName string - ProfileSaveChanges BoolFlag - ProxyID string - ProxyName string - ProxyMode string - Region string - PrivateHosts []string - StartURL string - Extensions []string - Vaults []string - Viewport string - Telemetry string - TelemetryExport string - ChromePolicy string - ChromePolicyFile string - Name string - Tags map[string]string - Output string + TimeoutSeconds int + Stealth BoolFlag + Headless BoolFlag + GPU BoolFlag + Memory string + InvocationID string + Kiosk BoolFlag + ProfileID string + ProfileName string + ProfileSaveChanges BoolFlag + ProxyID string + ProxyName string + ProxyMode string + Region string + PrivateHosts []string + StartURL string + Extensions []string + Vaults []string + Viewport string + Telemetry string + TelemetryCdpExclude string + TelemetryExport string + ChromePolicy string + ChromePolicyFile string + Name string + Tags map[string]string + Output string } type BrowsersDeleteInput struct { @@ -684,24 +685,6 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } - // Map vaults (IDs or names) into params.Vaults. Links are immutable once the - // session is created. - if len(in.Vaults) > 0 { - for _, vault := range in.Vaults { - val := strings.TrimSpace(vault) - if val == "" { - continue - } - item := kernel.VaultReferenceParam{} - if cuidRegex.MatchString(val) { - item.ID = kernel.Opt(val) - } else { - item.Name = kernel.Opt(val) - } - params.Vaults = append(params.Vaults, item) - } - } - // Add viewport if specified if in.Viewport != "" { width, height, refreshRate, err := parseViewport(in.Viewport) @@ -761,7 +744,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } PrintTableNoPad(rows, true) } - if in.Telemetry != "" || in.TelemetryExport != "" { + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { printTelemetrySummary(browser.Telemetry) } return nil @@ -3143,7 +3126,6 @@ func init() { browsersCreateCmd.Flags().StringSlice("private-host", nil, "Destinations the browser reaches directly through its own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeat or comma-separated, max 32). Accepts hostname patterns ('*.example.ts.net'), IPs ('10.1.30.63', '[fd00::1]'), and private CIDRs ('100.64.0.0/10'). Replaces the default private ranges (RFC1918, 100.64.0.0/10, fc00::/7); omit to keep them. Fixed once the session is created") browsersCreateCmd.Flags().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().StringSlice("vault", []string{}, "Vault IDs or names to link to the session (repeatable; may be passed multiple times or comma-separated). Fixed once the session is created") 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") browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") browsersCreateCmd.Flags().StringArray("vault", nil, "Project-owned vault ID or name to attach at creation (repeatable, max 20; incompatible with pools)") @@ -3401,32 +3383,33 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { } in := BrowsersCreateInput{ - TimeoutSeconds: timeout, - Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, - Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, - GPU: BoolFlag{Set: cmd.Flags().Changed("gpu"), Value: gpuVal}, - Memory: memory, - InvocationID: invocationID, - Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kioskVal}, - ProfileID: profileID, - ProfileName: profileName, - ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, - ProxyID: proxyID, - ProxyName: proxyName, - ProxyMode: proxyMode, - Region: region, - PrivateHosts: privateHosts, - StartURL: startURL, - Extensions: extensions, - Vaults: vaults, - Viewport: viewport, - Telemetry: telemetry, - TelemetryExport: telemetryExport, - ChromePolicy: chromePolicy, - ChromePolicyFile: chromePolicyFile, - Name: name, - Tags: tags, - Output: output, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, + GPU: BoolFlag{Set: cmd.Flags().Changed("gpu"), Value: gpuVal}, + Memory: memory, + InvocationID: invocationID, + Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kioskVal}, + ProfileID: profileID, + ProfileName: profileName, + ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, + ProxyID: proxyID, + ProxyName: proxyName, + ProxyMode: proxyMode, + Region: region, + PrivateHosts: privateHosts, + StartURL: startURL, + Extensions: extensions, + Vaults: vaults, + Viewport: viewport, + Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, + TelemetryExport: telemetryExport, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, + Name: name, + Tags: tags, + Output: output, } svc := client.Browsers diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 6e91fb9a..afa36b70 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -531,9 +531,8 @@ func TestBrowsersCreate_WithVaults(t *testing.T) { b := BrowsersCmd{browsers: fake} err := b.Create(context.Background(), BrowsersCreateInput{ - // A Kernel-shaped identifier is sent as an ID, anything else as a name, - // and blank entries are dropped. - Vaults: []string{"gtw36zdwv9as2etqetxpnspl", " payments ", ""}, + // A Kernel-shaped identifier is sent as an ID, anything else as a name. + Vaults: []string{"gtw36zdwv9as2etqetxpnspl", "payments"}, }) assert.NoError(t, err) diff --git a/cmd/org.go b/cmd/org.go index e8ba0d52..475e2361 100644 --- a/cmd/org.go +++ b/cmd/org.go @@ -199,6 +199,7 @@ func orgEntitlementRows(entitlements *kernel.OrgEntitlements) pterm.TableData { {"Feature", "Health check maximum (seconds)", fmt.Sprintf("%d", features.ManagedAuth.HealthCheckIntervalMaxSeconds)}, {"Feature", "Credentials", fmt.Sprintf("%t", features.Credentials.Enabled)}, {"Feature", "Credential providers", fmt.Sprintf("%t", features.CredentialProviders.Enabled)}, + {"Feature", "Vaults", fmt.Sprintf("%t", features.Vaults.Enabled)}, {"Feature", "Managed proxies", fmt.Sprintf("%t", features.ManagedProxies.Enabled)}, {"Feature", "Custom proxies", fmt.Sprintf("%t", features.CustomProxies.Enabled)}, {"Feature", "Proxy bypass hosts", fmt.Sprintf("%t", features.ProxyBypassHosts.Enabled)}, diff --git a/cmd/org_test.go b/cmd/org_test.go index e55713fd..0aa11ca4 100644 --- a/cmd/org_test.go +++ b/cmd/org_test.go @@ -45,6 +45,7 @@ func testOrgEntitlementsWithUnlimitedValues(t *testing.T) *kernel.OrgEntitlement "managed_auth":{"enabled":true,"max_connections":null,"health_check_interval_min_seconds":1200,"health_check_interval_default_seconds":3600,"health_check_interval_max_seconds":86400}, "credentials":{"enabled":true}, "credential_providers":{"enabled":true}, + "vaults":{"enabled":true}, "managed_proxies":{"enabled":true}, "custom_proxies":{"enabled":true}, "proxy_bypass_hosts":{"enabled":true}, @@ -69,6 +70,7 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { "managed_auth":{"enabled":false,"max_connections":29,"health_check_interval_min_seconds":31,"health_check_interval_default_seconds":37,"health_check_interval_max_seconds":41}, "credentials":{"enabled":true}, "credential_providers":{"enabled":false}, + "vaults":{"enabled":true}, "managed_proxies":{"enabled":true}, "custom_proxies":{"enabled":false}, "proxy_bypass_hosts":{"enabled":true}, @@ -99,6 +101,7 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { {"Feature", "Health check maximum (seconds)", "41"}, {"Feature", "Credentials", "true"}, {"Feature", "Credential providers", "false"}, + {"Feature", "Vaults", "true"}, {"Feature", "Managed proxies", "true"}, {"Feature", "Custom proxies", "false"}, {"Feature", "Proxy bypass hosts", "true"}, @@ -123,6 +126,7 @@ func TestOrgEntitlementRows_BooleanFieldProvenance(t *testing.T) { {"Managed auth", func(e *kernel.OrgEntitlements) { e.Features.ManagedAuth.Enabled = true }}, {"Credentials", func(e *kernel.OrgEntitlements) { e.Features.Credentials.Enabled = true }}, {"Credential providers", func(e *kernel.OrgEntitlements) { e.Features.CredentialProviders.Enabled = true }}, + {"Vaults", func(e *kernel.OrgEntitlements) { e.Features.Vaults.Enabled = true }}, {"Managed proxies", func(e *kernel.OrgEntitlements) { e.Features.ManagedProxies.Enabled = true }}, {"Custom proxies", func(e *kernel.OrgEntitlements) { e.Features.CustomProxies.Enabled = true }}, {"Proxy bypass hosts", func(e *kernel.OrgEntitlements) { e.Features.ProxyBypassHosts.Enabled = true }}, diff --git a/go.mod b/go.mod index c00426c4..e293a845 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.100.0 + github.com/kernel/kernel-go-sdk v0.100.1-0.20260905183853-4b985afe0962 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 84a83f5a..c40994ab 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.100.0 h1:8RKNKk0js3BHdwEkDd0aPX17OEGSHVrI26akNUwttzU= -github.com/kernel/kernel-go-sdk v0.100.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260905183853-4b985afe0962 h1:XleZ4t3wlwL9ESkUR01tLzaC7SCTkrtkYfATDyEFyt8= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260905183853-4b985afe0962/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= From 0632a7ac8c963b6a0967c7056e5e2b798af151c5 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:29:48 +0000 Subject: [PATCH 28/51] chore: update Go SDK to 8abbe5e and surface vault limits Bumps github.com/kernel/kernel-go-sdk to v0.100.1-0.20260905202137-8abbe5e87d79 (kernel/kernel-go-sdk@8abbe5e), which caps free organizations at three vaults and adds the fields that report that cap. The SDK diff adds no new methods and no new request params, only three response fields, so this surfaces them in the commands that already render those payloads: - `kernel org limits get` gains "Max Vaults" and "Vaults Used" rows from OrgLimits.MaxVaults / OrgLimits.VaultsUsed. Both are guarded on field presence like the managed auth rows, and a null max_vaults (paid plans and active trials) renders as "unlimited". - `kernel org entitlements` gains a "Max vaults" limit row from OrgEntitlementsLimits.MaxVaults, also unlimited-aware. - `kernel vaults create` gets a Long description carrying the new SDK method comment: the three-vault free cap, that paid plans and trials are uncapped, and that retrieving an existing vault by name still succeeds at the limit. - `kernel org limits get` help now mentions vault limits and usage, matching the updated OrganizationLimitService.Get comment. A full enumeration of api.md (158 method entries) against the CLI command tree (168 leaf commands) found no other coverage gaps; all five config-registry endpoints and /auth/connections/{id}/exchange are marked x-cli-skip. Tested against the live API: `org limits get` (table + JSON show Max Vaults unlimited, Vaults Used 7), `org entitlements` (Max vaults row + JSON), and a `vaults create` / `vaults delete --yes` round trip that moved vaults_used 7 -> 8 -> 7. `go build ./...`, `go vet ./...`, and `go test ./...` all pass. Co-Authored-By: Claude Opus 5 --- cmd/org.go | 12 ++++++++- cmd/org_test.go | 56 ++++++++++++++++++++++++++++++++++++++++-- cmd/vaults_commands.go | 1 + go.mod | 2 +- go.sum | 4 +-- 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/cmd/org.go b/cmd/org.go index 475e2361..2bce469d 100644 --- a/cmd/org.go +++ b/cmd/org.go @@ -149,6 +149,15 @@ func renderOrgLimits(limits *kernel.OrgLimits) { rows = append(rows, []string{"Min Health Check Interval", fmt.Sprintf("%ds", limits.MinHealthCheckIntervalSeconds)}) } + // Vault limits are plan-derived and, like the managed auth rows above, only + // returned by newer API versions. A null max_vaults means unlimited. + if orgLimitFieldPresent(limits.JSON.MaxVaults) { + rows = append(rows, []string{"Max Vaults", formatProjectLimitValue(limits.MaxVaults, limits.JSON.MaxVaults)}) + } + if orgLimitFieldPresent(limits.JSON.VaultsUsed) { + rows = append(rows, []string{"Vaults Used", fmt.Sprintf("%d", limits.VaultsUsed)}) + } + PrintTableNoPad(rows, true) } @@ -207,6 +216,7 @@ func orgEntitlementRows(entitlements *kernel.OrgEntitlements) pterm.TableData { {"Limit", "Max concurrent browsers", fmt.Sprintf("%d", limits.MaxConcurrentBrowsers)}, {"Limit", "Max concurrent invocations", fmt.Sprintf("%d", limits.MaxConcurrentInvocations)}, {"Limit", "Default max concurrent invocations per app", fmt.Sprintf("%d", limits.DefaultMaxConcurrentInvocationsPerApp)}, + {"Limit", "Max vaults", formatEntitlementLimitValue(limits.MaxVaults, limits.JSON.MaxVaults)}, } } @@ -256,7 +266,7 @@ var orgLimitsCmd = &cobra.Command{ var orgLimitsGetCmd = &cobra.Command{ Use: "get", Short: "Get organization limits", - Long: "Show the organization's effective limits: the concurrency limit, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth limits along with current auth connection usage.", + Long: "Show the organization's effective limits: the concurrency limit, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth and vault limits along with current auth connection and vault usage.", Args: cobra.NoArgs, RunE: runOrgLimitsGet, } diff --git a/cmd/org_test.go b/cmd/org_test.go index 0aa11ca4..9118f619 100644 --- a/cmd/org_test.go +++ b/cmd/org_test.go @@ -51,7 +51,7 @@ func testOrgEntitlementsWithUnlimitedValues(t *testing.T) *kernel.OrgEntitlement "proxy_bypass_hosts":{"enabled":true}, "gpu":{"enabled":false} }, - "limits":{"max_concurrent_browsers":150,"max_concurrent_invocations":150,"default_max_concurrent_invocations_per_app":20} + "limits":{"max_concurrent_browsers":150,"max_concurrent_invocations":150,"default_max_concurrent_invocations_per_app":20,"max_vaults":null} }`), &entitlements) assert.NoError(t, err) return &entitlements @@ -76,7 +76,7 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { "proxy_bypass_hosts":{"enabled":true}, "gpu":{"enabled":false} }, - "limits":{"max_concurrent_browsers":43,"max_concurrent_invocations":47,"default_max_concurrent_invocations_per_app":53} + "limits":{"max_concurrent_browsers":43,"max_concurrent_invocations":47,"default_max_concurrent_invocations_per_app":53,"max_vaults":59} }`), &entitlements) assert.NoError(t, err) @@ -109,6 +109,7 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { {"Limit", "Max concurrent browsers", "43"}, {"Limit", "Max concurrent invocations", "47"}, {"Limit", "Default max concurrent invocations per app", "53"}, + {"Limit", "Max vaults", "59"}, }, orgEntitlementRows(&entitlements)) } @@ -351,6 +352,57 @@ func TestOrgLimitsGet_OmitsManagedAuthRowsWhenAbsent(t *testing.T) { assert.NotContains(t, out, "Min Health Check Interval") } +func TestOrgLimitsGet_RendersVaultLimits(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{ + MaxConcurrentSessions: 100, + MaxVaults: 3, + VaultsUsed: 2, + } + limits.JSON.MaxVaults = respjson.NewField("3") + limits.JSON.VaultsUsed = respjson.NewField("2") + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Max Vaults") + assert.Contains(t, out, "Vaults Used") +} + +func TestOrgLimitsGet_NullMaxVaultsShownAsUnlimited(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{MaxConcurrentSessions: 100, DefaultProjectMaxConcurrentSessions: 25} + limits.JSON.DefaultProjectMaxConcurrentSessions = respjson.NewField("25") + // Null (not omitted) means a paid plan or active trial: no vault cap. + limits.JSON.MaxVaults = respjson.NewField(respjson.Null) + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Max Vaults") + assert.Contains(t, out, "unlimited") +} + +func TestOrgLimitsGet_OmitsVaultRowsWhenAbsent(t *testing.T) { + buf := capturePtermOutput(t) + c := OrgCmd{limits: &FakeOrgLimitsService{}} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.NotContains(t, out, "Max Vaults") + assert.NotContains(t, out, "Vaults Used") +} + func TestOrgLimitsGet_SurfacesAPIError(t *testing.T) { capturePtermOutput(t) fake := &FakeOrgLimitsService{ diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index c7c270da..d98088f5 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -75,6 +75,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d } create := &cobra.Command{Use: "create --name ", Short: "Create or retrieve a vault by immutable name", Args: cobra.NoArgs, PreRunE: vaultPreRun, + Long: "Create or retrieve a vault by immutable name.\nFree organizations can store up to 3 non-deleted vaults across all projects; paid plans and active trials have no vault cap.\nRetrieving an existing vault by name succeeds even at the limit.\nSee kernel org limits get for the current cap and usage.", RunE: func(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") return getVaultsHandler(cmd).Create(cmd.Context(), name, vaultOutput(cmd)) diff --git a/go.mod b/go.mod index e293a845..338fd0a1 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.100.1-0.20260905183853-4b985afe0962 + github.com/kernel/kernel-go-sdk v0.100.1-0.20260905202137-8abbe5e87d79 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 c40994ab..465b0f90 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.100.1-0.20260905183853-4b985afe0962 h1:XleZ4t3wlwL9ESkUR01tLzaC7SCTkrtkYfATDyEFyt8= -github.com/kernel/kernel-go-sdk v0.100.1-0.20260905183853-4b985afe0962/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260905202137-8abbe5e87d79 h1:WDl+RM1wgDLolusZjnVA2S+MxV8XC9IuruMANiHVX0M= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260905202137-8abbe5e87d79/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= From a024421e101e885160f30802cdceb5130ecc58a3 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:18:18 +0000 Subject: [PATCH 29/51] chore: update Go SDK to 78d7845 and drop removed vault limit fields Bumps kernel-go-sdk to v0.100.1-0.20260908191213-78d784504ddb (78d7845). The SDK removed the vault entitlement and limit response fields that this branch had started surfacing, so the corresponding CLI output is removed: - `org limits get`: drop the "Max Vaults" and "Vaults Used" rows (OrgLimits.MaxVaults / .VaultsUsed no longer exist) - `org entitlements`: drop the "Vaults" feature row and the "Max vaults" limit row (OrgEntitlementsFeatures.Vaults / OrgEntitlementsLimits.MaxVaults no longer exist) - `vaults create` help: drop the free-plan vault cap wording, matching the SDK's new Upsert doc comment, and stop pointing at `org limits get` for a cap it no longer reports The SDK also now routes the `fs` and `logs/stream` browser subresources directly to the VM. The CLI does not override the routing subresource list, so it picks this up with no code change. Coverage analysis: full enumeration of all 158 api.md methods against the CLI command tree found no gaps. The 5 config-registry methods are marked x-cli-skip in openapi.yaml. No param struct changed in this SDK range, so no new flags were needed. Also fixes a gofmt regression introduced earlier on this branch in cmd/auth_connections.go. Tested against the live API: org limits get, org entitlements, vaults create/delete, browsers create/delete, and the newly VM-routed browsers fs write-file/list-files/file-info/read-file and browsers logs stream. Full `go build ./...`, `go vet ./...` and `go test ./...` pass. Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 28 +++++++++---------- cmd/org.go | 13 +-------- cmd/org_test.go | 60 ++--------------------------------------- cmd/vaults_commands.go | 2 +- go.mod | 2 +- go.sum | 4 +-- 6 files changed, 21 insertions(+), 88 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 9edb4036..860866ae 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -554,13 +554,13 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e 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, - Reason: string(f.Reason), + ID: f.ID, + Label: f.Label, + Type: f.Type, + Ref: f.Ref, + Hint: f.Hint, + Required: f.Required, + Reason: string(f.Reason), })) } tableData = append(tableData, []string{"Fields", strings.Join(fields, "; ")}) @@ -1107,13 +1107,13 @@ func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowIn 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, - Reason: string(f.Reason), + ID: f.ID, + Label: f.Label, + Type: f.Type, + Ref: f.Ref, + Hint: f.Hint, + Required: f.Required, + Reason: string(f.Reason), })) } pterm.Info.Printf(" Fields: %s\n", strings.Join(fields, ", ")) diff --git a/cmd/org.go b/cmd/org.go index 2bce469d..e8ba0d52 100644 --- a/cmd/org.go +++ b/cmd/org.go @@ -149,15 +149,6 @@ func renderOrgLimits(limits *kernel.OrgLimits) { rows = append(rows, []string{"Min Health Check Interval", fmt.Sprintf("%ds", limits.MinHealthCheckIntervalSeconds)}) } - // Vault limits are plan-derived and, like the managed auth rows above, only - // returned by newer API versions. A null max_vaults means unlimited. - if orgLimitFieldPresent(limits.JSON.MaxVaults) { - rows = append(rows, []string{"Max Vaults", formatProjectLimitValue(limits.MaxVaults, limits.JSON.MaxVaults)}) - } - if orgLimitFieldPresent(limits.JSON.VaultsUsed) { - rows = append(rows, []string{"Vaults Used", fmt.Sprintf("%d", limits.VaultsUsed)}) - } - PrintTableNoPad(rows, true) } @@ -208,7 +199,6 @@ func orgEntitlementRows(entitlements *kernel.OrgEntitlements) pterm.TableData { {"Feature", "Health check maximum (seconds)", fmt.Sprintf("%d", features.ManagedAuth.HealthCheckIntervalMaxSeconds)}, {"Feature", "Credentials", fmt.Sprintf("%t", features.Credentials.Enabled)}, {"Feature", "Credential providers", fmt.Sprintf("%t", features.CredentialProviders.Enabled)}, - {"Feature", "Vaults", fmt.Sprintf("%t", features.Vaults.Enabled)}, {"Feature", "Managed proxies", fmt.Sprintf("%t", features.ManagedProxies.Enabled)}, {"Feature", "Custom proxies", fmt.Sprintf("%t", features.CustomProxies.Enabled)}, {"Feature", "Proxy bypass hosts", fmt.Sprintf("%t", features.ProxyBypassHosts.Enabled)}, @@ -216,7 +206,6 @@ func orgEntitlementRows(entitlements *kernel.OrgEntitlements) pterm.TableData { {"Limit", "Max concurrent browsers", fmt.Sprintf("%d", limits.MaxConcurrentBrowsers)}, {"Limit", "Max concurrent invocations", fmt.Sprintf("%d", limits.MaxConcurrentInvocations)}, {"Limit", "Default max concurrent invocations per app", fmt.Sprintf("%d", limits.DefaultMaxConcurrentInvocationsPerApp)}, - {"Limit", "Max vaults", formatEntitlementLimitValue(limits.MaxVaults, limits.JSON.MaxVaults)}, } } @@ -266,7 +255,7 @@ var orgLimitsCmd = &cobra.Command{ var orgLimitsGetCmd = &cobra.Command{ Use: "get", Short: "Get organization limits", - Long: "Show the organization's effective limits: the concurrency limit, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth and vault limits along with current auth connection and vault usage.", + Long: "Show the organization's effective limits: the concurrency limit, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth limits along with current auth connection usage.", Args: cobra.NoArgs, RunE: runOrgLimitsGet, } diff --git a/cmd/org_test.go b/cmd/org_test.go index 9118f619..e55713fd 100644 --- a/cmd/org_test.go +++ b/cmd/org_test.go @@ -45,13 +45,12 @@ func testOrgEntitlementsWithUnlimitedValues(t *testing.T) *kernel.OrgEntitlement "managed_auth":{"enabled":true,"max_connections":null,"health_check_interval_min_seconds":1200,"health_check_interval_default_seconds":3600,"health_check_interval_max_seconds":86400}, "credentials":{"enabled":true}, "credential_providers":{"enabled":true}, - "vaults":{"enabled":true}, "managed_proxies":{"enabled":true}, "custom_proxies":{"enabled":true}, "proxy_bypass_hosts":{"enabled":true}, "gpu":{"enabled":false} }, - "limits":{"max_concurrent_browsers":150,"max_concurrent_invocations":150,"default_max_concurrent_invocations_per_app":20,"max_vaults":null} + "limits":{"max_concurrent_browsers":150,"max_concurrent_invocations":150,"default_max_concurrent_invocations_per_app":20} }`), &entitlements) assert.NoError(t, err) return &entitlements @@ -70,13 +69,12 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { "managed_auth":{"enabled":false,"max_connections":29,"health_check_interval_min_seconds":31,"health_check_interval_default_seconds":37,"health_check_interval_max_seconds":41}, "credentials":{"enabled":true}, "credential_providers":{"enabled":false}, - "vaults":{"enabled":true}, "managed_proxies":{"enabled":true}, "custom_proxies":{"enabled":false}, "proxy_bypass_hosts":{"enabled":true}, "gpu":{"enabled":false} }, - "limits":{"max_concurrent_browsers":43,"max_concurrent_invocations":47,"default_max_concurrent_invocations_per_app":53,"max_vaults":59} + "limits":{"max_concurrent_browsers":43,"max_concurrent_invocations":47,"default_max_concurrent_invocations_per_app":53} }`), &entitlements) assert.NoError(t, err) @@ -101,7 +99,6 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { {"Feature", "Health check maximum (seconds)", "41"}, {"Feature", "Credentials", "true"}, {"Feature", "Credential providers", "false"}, - {"Feature", "Vaults", "true"}, {"Feature", "Managed proxies", "true"}, {"Feature", "Custom proxies", "false"}, {"Feature", "Proxy bypass hosts", "true"}, @@ -109,7 +106,6 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { {"Limit", "Max concurrent browsers", "43"}, {"Limit", "Max concurrent invocations", "47"}, {"Limit", "Default max concurrent invocations per app", "53"}, - {"Limit", "Max vaults", "59"}, }, orgEntitlementRows(&entitlements)) } @@ -127,7 +123,6 @@ func TestOrgEntitlementRows_BooleanFieldProvenance(t *testing.T) { {"Managed auth", func(e *kernel.OrgEntitlements) { e.Features.ManagedAuth.Enabled = true }}, {"Credentials", func(e *kernel.OrgEntitlements) { e.Features.Credentials.Enabled = true }}, {"Credential providers", func(e *kernel.OrgEntitlements) { e.Features.CredentialProviders.Enabled = true }}, - {"Vaults", func(e *kernel.OrgEntitlements) { e.Features.Vaults.Enabled = true }}, {"Managed proxies", func(e *kernel.OrgEntitlements) { e.Features.ManagedProxies.Enabled = true }}, {"Custom proxies", func(e *kernel.OrgEntitlements) { e.Features.CustomProxies.Enabled = true }}, {"Proxy bypass hosts", func(e *kernel.OrgEntitlements) { e.Features.ProxyBypassHosts.Enabled = true }}, @@ -352,57 +347,6 @@ func TestOrgLimitsGet_OmitsManagedAuthRowsWhenAbsent(t *testing.T) { assert.NotContains(t, out, "Min Health Check Interval") } -func TestOrgLimitsGet_RendersVaultLimits(t *testing.T) { - buf := capturePtermOutput(t) - fake := &FakeOrgLimitsService{ - GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { - limits := &kernel.OrgLimits{ - MaxConcurrentSessions: 100, - MaxVaults: 3, - VaultsUsed: 2, - } - limits.JSON.MaxVaults = respjson.NewField("3") - limits.JSON.VaultsUsed = respjson.NewField("2") - return limits, nil - }, - } - c := OrgCmd{limits: fake} - assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) - - out := buf.String() - assert.Contains(t, out, "Max Vaults") - assert.Contains(t, out, "Vaults Used") -} - -func TestOrgLimitsGet_NullMaxVaultsShownAsUnlimited(t *testing.T) { - buf := capturePtermOutput(t) - fake := &FakeOrgLimitsService{ - GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { - limits := &kernel.OrgLimits{MaxConcurrentSessions: 100, DefaultProjectMaxConcurrentSessions: 25} - limits.JSON.DefaultProjectMaxConcurrentSessions = respjson.NewField("25") - // Null (not omitted) means a paid plan or active trial: no vault cap. - limits.JSON.MaxVaults = respjson.NewField(respjson.Null) - return limits, nil - }, - } - c := OrgCmd{limits: fake} - assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) - - out := buf.String() - assert.Contains(t, out, "Max Vaults") - assert.Contains(t, out, "unlimited") -} - -func TestOrgLimitsGet_OmitsVaultRowsWhenAbsent(t *testing.T) { - buf := capturePtermOutput(t) - c := OrgCmd{limits: &FakeOrgLimitsService{}} - assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) - - out := buf.String() - assert.NotContains(t, out, "Max Vaults") - assert.NotContains(t, out, "Vaults Used") -} - func TestOrgLimitsGet_SurfacesAPIError(t *testing.T) { capturePtermOutput(t) fake := &FakeOrgLimitsService{ diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index d98088f5..ebfacec0 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -75,7 +75,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d } create := &cobra.Command{Use: "create --name ", Short: "Create or retrieve a vault by immutable name", Args: cobra.NoArgs, PreRunE: vaultPreRun, - Long: "Create or retrieve a vault by immutable name.\nFree organizations can store up to 3 non-deleted vaults across all projects; paid plans and active trials have no vault cap.\nRetrieving an existing vault by name succeeds even at the limit.\nSee kernel org limits get for the current cap and usage.", + Long: "Create or retrieve a vault by immutable name.\nRetrieving an existing vault by name is idempotent and returns the existing vault.", RunE: func(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") return getVaultsHandler(cmd).Create(cmd.Context(), name, vaultOutput(cmd)) diff --git a/go.mod b/go.mod index 338fd0a1..899bbdda 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.100.1-0.20260905202137-8abbe5e87d79 + github.com/kernel/kernel-go-sdk v0.100.1-0.20260908191213-78d784504ddb 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 465b0f90..4d27d68b 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.100.1-0.20260905202137-8abbe5e87d79 h1:WDl+RM1wgDLolusZjnVA2S+MxV8XC9IuruMANiHVX0M= -github.com/kernel/kernel-go-sdk v0.100.1-0.20260905202137-8abbe5e87d79/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260908191213-78d784504ddb h1:252Ep0Ui8rCqRnZKL+0raRbcIYIy4oyzzE+nUxzk8gY= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260908191213-78d784504ddb/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= From 9d84917c9d5db300548f0a203bdee75c393996a4 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:09:59 +0000 Subject: [PATCH 30/51] chore: update Go SDK to 19b510c, restore vault limits, fix webmcp merge Bumps kernel-go-sdk to v0.100.1-0.20260909170029-19b510c645d0 (19b510c). Fix a broken merge on this branch. Merging main brought in cmd/browsers_webmcp.go (PR #247, `browsers webmcp list` / `invoke`), which collided with the duplicate webmcp surface this branch had generated earlier (`webmcp list-tools` / `invoke-tool`). The BrowsersCmd struct ended up with two `webmcp` fields, so the branch did not compile. Keep main's reviewed implementation and drop this branch's duplicate: BrowserWebmcpService, WebmcpListTools/WebmcpInvokeTool, their input structs, the second `webmcp` command group, and its run funcs. README already documents only the `list`/`invoke` spelling. Restore the vault entitlement and limit output that a024421 removed. The SDK re-added those response fields in this range, so the CLI surfaces them again: - `org limits get`: "Max Vaults" and "Vaults Used" rows (OrgLimits.MaxVaults / .VaultsUsed). Null max_vaults renders as "unlimited". - `org entitlements`: the "Vaults" feature row and the "Max vaults" limit row (OrgEntitlementsFeatures.Vaults / OrgEntitlementsLimits.MaxVaults). - `vaults create` help: the free-plan 3-vault cap wording, matching the SDK's restored Upsert doc comment, pointing at `kernel org limits get`. The SDK also narrowed direct-to-VM browser routing back to curl/telemetry-stream/computer/playwright/process, dropping `fs` and `logs/stream`. The CLI does not override the routing subresource list, so it picks this up with no code change. Coverage analysis: full enumeration of all api.md methods against the CLI command tree found no missing commands. The 5 config-registry methods are x-cli-skip in openapi.yaml, which covers this range's only schema change (Analysis.expires_at and the new "expired" status). A field-by-field sweep of every *Params struct against every registered CLI flag found no missing flags; BrowserCurlParams.ResponseEncoding is intentionally unused because `browsers curl` streams raw bytes through the SDK's raw curl client. Tested against the live API: org limits get (shows Max Vaults / Vaults Used), org entitlements (shows Vaults / Max vaults), vaults create/list/delete (usage count returns to its prior value), browsers create/delete, browsers webmcp list, browsers fs list-files. Full `go build ./...`, `go vet ./...` and `go test ./...` pass. Co-Authored-By: Claude Opus 5 --- cmd/browsers.go | 162 ----------------------------------------- cmd/org.go | 13 +++- cmd/org_test.go | 60 ++++++++++++++- cmd/vaults_commands.go | 2 +- go.mod | 2 +- go.sum | 4 +- 6 files changed, 74 insertions(+), 169 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 607fe8e9..564c0e24 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -94,12 +94,6 @@ type BrowserPlaywrightService interface { Execute(ctx context.Context, idOrName string, body kernel.BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *kernel.BrowserPlaywrightExecuteResponse, err error) } -// BrowserWebmcpService defines the subset we use for WebMCP tool discovery and invocation. -type BrowserWebmcpService interface { - InvokeTool(ctx context.Context, idOrName string, body kernel.BrowserWebmcpInvokeToolParams, opts ...option.RequestOption) (res *kernel.InvocationResult, err error) - ListTools(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.ToolsResponse, err error) -} - // BrowserComputerService defines the subset we use for OS-level mouse & screen. type BrowserComputerService interface { Batch(ctx context.Context, idOrName string, body kernel.BrowserComputerBatchParams, opts ...option.RequestOption) (err error) @@ -461,7 +455,6 @@ type BrowsersCmd struct { logs BrowserLogService computer BrowserComputerService playwright BrowserPlaywrightService - webmcp BrowserWebmcpService telemetry BrowserTelemetryService webmcp BrowserWebMCPService } @@ -1804,113 +1797,6 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh return nil } -// WebMCP -type BrowsersWebmcpListToolsInput struct { - Identifier string - Output string -} - -func (b BrowsersCmd) WebmcpListTools(ctx context.Context, in BrowsersWebmcpListToolsInput) error { - if err := validateJSONOutput(in.Output); err != nil { - return err - } - - if b.webmcp == nil { - pterm.Error.Println("webmcp service not available") - return nil - } - br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - res, err := b.webmcp.ListTools(ctx, br.SessionID) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - if in.Output == "json" { - return util.PrintPrettyJSON(res) - } - - if res == nil || len(res.Tools) == 0 { - pterm.Info.Println("No WebMCP tools found") - return nil - } - rows := pterm.TableData{{"Name", "Tool Ref", "Page", "Frame", "Description"}} - for _, t := range res.Tools { - frame := "-" - if t.Source.Frame.URL != "" { - frame = truncateURL(t.Source.Frame.URL, 40) - } - rows = append(rows, []string{ - t.Name, - t.ToolRef, - truncateURL(t.Source.PageURL, 40), - frame, - truncateURL(t.Description, 60), - }) - } - PrintTableNoPad(rows, true) - return nil -} - -type BrowsersWebmcpInvokeToolInput struct { - Identifier string - ToolRef string - InputJSON string - TimeoutSec int64 - Output string -} - -func (b BrowsersCmd) WebmcpInvokeTool(ctx context.Context, in BrowsersWebmcpInvokeToolInput) error { - if err := validateJSONOutput(in.Output); err != nil { - return err - } - - if b.webmcp == nil { - pterm.Error.Println("webmcp service not available") - return nil - } - toolInput := map[string]any{} - if strings.TrimSpace(in.InputJSON) != "" { - if err := json.Unmarshal([]byte(in.InputJSON), &toolInput); err != nil { - pterm.Error.Printf("Invalid --input JSON: %v\n", err) - return nil - } - } - br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - req := kernel.InvokeRequestParam{ToolRef: in.ToolRef, Input: toolInput} - if in.TimeoutSec > 0 { - req.TimeoutSec = kernel.Opt(in.TimeoutSec) - } - res, err := b.webmcp.InvokeTool(ctx, br.SessionID, kernel.BrowserWebmcpInvokeToolParams{InvokeRequest: req}) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - if in.Output == "json" { - return util.PrintPrettyJSON(res) - } - - rows := pterm.TableData{{"Property", "Value"}, {"Invocation ID", res.InvocationID}, {"Status", string(res.Status)}} - PrintTableNoPad(rows, true) - - if res.Output != nil { - bs, err := json.MarshalIndent(res.Output, "", " ") - if err == nil { - pterm.Info.Println("output:") - fmt.Println(string(bs)) - } - } - if res.ErrorText != "" { - pterm.Error.Printf("error: %s\n", res.ErrorText) - } - return nil -} - func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -3096,19 +2982,6 @@ func init() { browsersCmd.AddCommand(playwrightRoot) browsersCmd.AddCommand(newBrowsersWebMCPCommand()) - // webmcp - webmcpRoot := &cobra.Command{Use: "webmcp", Short: "Discover and invoke native page (WebMCP) tools"} - webmcpListTools := &cobra.Command{Use: "list-tools ", Short: "List WebMCP tools across every open tab and embedded frame", Args: cobra.ExactArgs(1), RunE: runBrowsersWebmcpListTools} - addJSONOutputFlag(webmcpListTools) - webmcpInvoke := &cobra.Command{Use: "invoke-tool ", Short: "Invoke a discovered WebMCP tool and wait for its result", Args: cobra.ExactArgs(1), RunE: runBrowsersWebmcpInvokeTool} - webmcpInvoke.Flags().String("tool-ref", "", "Opaque tool reference from 'browsers webmcp list-tools'") - webmcpInvoke.Flags().String("input", "", "Tool input as a JSON object (defaults to {}); use '-' to read from stdin") - webmcpInvoke.Flags().Int64("timeout-sec", 0, "Maximum time to wait for the tool result in seconds (1-120, default 60)") - _ = webmcpInvoke.MarkFlagRequired("tool-ref") - addJSONOutputFlag(webmcpInvoke) - webmcpRoot.AddCommand(webmcpListTools, webmcpInvoke) - browsersCmd.AddCommand(webmcpRoot) - // Add flags for create command addJSONOutputFlag(browsersCreateCmd) browsersCreateCmd.Flags().BoolP("stealth", "s", false, "Launch browser in stealth mode to avoid detection") @@ -3708,41 +3581,6 @@ func runBrowsersPlaywrightExecute(cmd *cobra.Command, args []string) error { return b.PlaywrightExecute(cmd.Context(), BrowsersPlaywrightExecuteInput{Identifier: args[0], Code: strings.TrimSpace(code), Timeout: timeout, Output: output}) } -func runBrowsersWebmcpListTools(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - output, _ := cmd.Flags().GetString("output") - b := BrowsersCmd{browsers: &svc, webmcp: &svc.Webmcp} - return b.WebmcpListTools(cmd.Context(), BrowsersWebmcpListToolsInput{Identifier: args[0], Output: output}) -} - -func runBrowsersWebmcpInvokeTool(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - toolRef, _ := cmd.Flags().GetString("tool-ref") - inputJSON, _ := cmd.Flags().GetString("input") - timeoutSec, _ := cmd.Flags().GetInt64("timeout-sec") - output, _ := cmd.Flags().GetString("output") - - if inputJSON == "-" { - data, err := io.ReadAll(os.Stdin) - if err != nil { - pterm.Error.Printf("failed to read stdin: %v\n", err) - return nil - } - inputJSON = string(data) - } - - b := BrowsersCmd{browsers: &svc, webmcp: &svc.Webmcp} - return b.WebmcpInvokeTool(cmd.Context(), BrowsersWebmcpInvokeToolInput{ - Identifier: args[0], - ToolRef: toolRef, - InputJSON: inputJSON, - TimeoutSec: timeoutSec, - Output: output, - }) -} - func runBrowsersFSNewDirectory(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers diff --git a/cmd/org.go b/cmd/org.go index e8ba0d52..2bce469d 100644 --- a/cmd/org.go +++ b/cmd/org.go @@ -149,6 +149,15 @@ func renderOrgLimits(limits *kernel.OrgLimits) { rows = append(rows, []string{"Min Health Check Interval", fmt.Sprintf("%ds", limits.MinHealthCheckIntervalSeconds)}) } + // Vault limits are plan-derived and, like the managed auth rows above, only + // returned by newer API versions. A null max_vaults means unlimited. + if orgLimitFieldPresent(limits.JSON.MaxVaults) { + rows = append(rows, []string{"Max Vaults", formatProjectLimitValue(limits.MaxVaults, limits.JSON.MaxVaults)}) + } + if orgLimitFieldPresent(limits.JSON.VaultsUsed) { + rows = append(rows, []string{"Vaults Used", fmt.Sprintf("%d", limits.VaultsUsed)}) + } + PrintTableNoPad(rows, true) } @@ -199,6 +208,7 @@ func orgEntitlementRows(entitlements *kernel.OrgEntitlements) pterm.TableData { {"Feature", "Health check maximum (seconds)", fmt.Sprintf("%d", features.ManagedAuth.HealthCheckIntervalMaxSeconds)}, {"Feature", "Credentials", fmt.Sprintf("%t", features.Credentials.Enabled)}, {"Feature", "Credential providers", fmt.Sprintf("%t", features.CredentialProviders.Enabled)}, + {"Feature", "Vaults", fmt.Sprintf("%t", features.Vaults.Enabled)}, {"Feature", "Managed proxies", fmt.Sprintf("%t", features.ManagedProxies.Enabled)}, {"Feature", "Custom proxies", fmt.Sprintf("%t", features.CustomProxies.Enabled)}, {"Feature", "Proxy bypass hosts", fmt.Sprintf("%t", features.ProxyBypassHosts.Enabled)}, @@ -206,6 +216,7 @@ func orgEntitlementRows(entitlements *kernel.OrgEntitlements) pterm.TableData { {"Limit", "Max concurrent browsers", fmt.Sprintf("%d", limits.MaxConcurrentBrowsers)}, {"Limit", "Max concurrent invocations", fmt.Sprintf("%d", limits.MaxConcurrentInvocations)}, {"Limit", "Default max concurrent invocations per app", fmt.Sprintf("%d", limits.DefaultMaxConcurrentInvocationsPerApp)}, + {"Limit", "Max vaults", formatEntitlementLimitValue(limits.MaxVaults, limits.JSON.MaxVaults)}, } } @@ -255,7 +266,7 @@ var orgLimitsCmd = &cobra.Command{ var orgLimitsGetCmd = &cobra.Command{ Use: "get", Short: "Get organization limits", - Long: "Show the organization's effective limits: the concurrency limit, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth limits along with current auth connection usage.", + Long: "Show the organization's effective limits: the concurrency limit, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth and vault limits along with current auth connection and vault usage.", Args: cobra.NoArgs, RunE: runOrgLimitsGet, } diff --git a/cmd/org_test.go b/cmd/org_test.go index e55713fd..9118f619 100644 --- a/cmd/org_test.go +++ b/cmd/org_test.go @@ -45,12 +45,13 @@ func testOrgEntitlementsWithUnlimitedValues(t *testing.T) *kernel.OrgEntitlement "managed_auth":{"enabled":true,"max_connections":null,"health_check_interval_min_seconds":1200,"health_check_interval_default_seconds":3600,"health_check_interval_max_seconds":86400}, "credentials":{"enabled":true}, "credential_providers":{"enabled":true}, + "vaults":{"enabled":true}, "managed_proxies":{"enabled":true}, "custom_proxies":{"enabled":true}, "proxy_bypass_hosts":{"enabled":true}, "gpu":{"enabled":false} }, - "limits":{"max_concurrent_browsers":150,"max_concurrent_invocations":150,"default_max_concurrent_invocations_per_app":20} + "limits":{"max_concurrent_browsers":150,"max_concurrent_invocations":150,"default_max_concurrent_invocations_per_app":20,"max_vaults":null} }`), &entitlements) assert.NoError(t, err) return &entitlements @@ -69,12 +70,13 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { "managed_auth":{"enabled":false,"max_connections":29,"health_check_interval_min_seconds":31,"health_check_interval_default_seconds":37,"health_check_interval_max_seconds":41}, "credentials":{"enabled":true}, "credential_providers":{"enabled":false}, + "vaults":{"enabled":true}, "managed_proxies":{"enabled":true}, "custom_proxies":{"enabled":false}, "proxy_bypass_hosts":{"enabled":true}, "gpu":{"enabled":false} }, - "limits":{"max_concurrent_browsers":43,"max_concurrent_invocations":47,"default_max_concurrent_invocations_per_app":53} + "limits":{"max_concurrent_browsers":43,"max_concurrent_invocations":47,"default_max_concurrent_invocations_per_app":53,"max_vaults":59} }`), &entitlements) assert.NoError(t, err) @@ -99,6 +101,7 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { {"Feature", "Health check maximum (seconds)", "41"}, {"Feature", "Credentials", "true"}, {"Feature", "Credential providers", "false"}, + {"Feature", "Vaults", "true"}, {"Feature", "Managed proxies", "true"}, {"Feature", "Custom proxies", "false"}, {"Feature", "Proxy bypass hosts", "true"}, @@ -106,6 +109,7 @@ func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { {"Limit", "Max concurrent browsers", "43"}, {"Limit", "Max concurrent invocations", "47"}, {"Limit", "Default max concurrent invocations per app", "53"}, + {"Limit", "Max vaults", "59"}, }, orgEntitlementRows(&entitlements)) } @@ -123,6 +127,7 @@ func TestOrgEntitlementRows_BooleanFieldProvenance(t *testing.T) { {"Managed auth", func(e *kernel.OrgEntitlements) { e.Features.ManagedAuth.Enabled = true }}, {"Credentials", func(e *kernel.OrgEntitlements) { e.Features.Credentials.Enabled = true }}, {"Credential providers", func(e *kernel.OrgEntitlements) { e.Features.CredentialProviders.Enabled = true }}, + {"Vaults", func(e *kernel.OrgEntitlements) { e.Features.Vaults.Enabled = true }}, {"Managed proxies", func(e *kernel.OrgEntitlements) { e.Features.ManagedProxies.Enabled = true }}, {"Custom proxies", func(e *kernel.OrgEntitlements) { e.Features.CustomProxies.Enabled = true }}, {"Proxy bypass hosts", func(e *kernel.OrgEntitlements) { e.Features.ProxyBypassHosts.Enabled = true }}, @@ -347,6 +352,57 @@ func TestOrgLimitsGet_OmitsManagedAuthRowsWhenAbsent(t *testing.T) { assert.NotContains(t, out, "Min Health Check Interval") } +func TestOrgLimitsGet_RendersVaultLimits(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{ + MaxConcurrentSessions: 100, + MaxVaults: 3, + VaultsUsed: 2, + } + limits.JSON.MaxVaults = respjson.NewField("3") + limits.JSON.VaultsUsed = respjson.NewField("2") + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Max Vaults") + assert.Contains(t, out, "Vaults Used") +} + +func TestOrgLimitsGet_NullMaxVaultsShownAsUnlimited(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{MaxConcurrentSessions: 100, DefaultProjectMaxConcurrentSessions: 25} + limits.JSON.DefaultProjectMaxConcurrentSessions = respjson.NewField("25") + // Null (not omitted) means a paid plan or active trial: no vault cap. + limits.JSON.MaxVaults = respjson.NewField(respjson.Null) + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Max Vaults") + assert.Contains(t, out, "unlimited") +} + +func TestOrgLimitsGet_OmitsVaultRowsWhenAbsent(t *testing.T) { + buf := capturePtermOutput(t) + c := OrgCmd{limits: &FakeOrgLimitsService{}} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.NotContains(t, out, "Max Vaults") + assert.NotContains(t, out, "Vaults Used") +} + func TestOrgLimitsGet_SurfacesAPIError(t *testing.T) { capturePtermOutput(t) fake := &FakeOrgLimitsService{ diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index ebfacec0..d98088f5 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -75,7 +75,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d } create := &cobra.Command{Use: "create --name ", Short: "Create or retrieve a vault by immutable name", Args: cobra.NoArgs, PreRunE: vaultPreRun, - Long: "Create or retrieve a vault by immutable name.\nRetrieving an existing vault by name is idempotent and returns the existing vault.", + Long: "Create or retrieve a vault by immutable name.\nFree organizations can store up to 3 non-deleted vaults across all projects; paid plans and active trials have no vault cap.\nRetrieving an existing vault by name succeeds even at the limit.\nSee kernel org limits get for the current cap and usage.", RunE: func(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") return getVaultsHandler(cmd).Create(cmd.Context(), name, vaultOutput(cmd)) diff --git a/go.mod b/go.mod index 899bbdda..e3c2aa66 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.100.1-0.20260908191213-78d784504ddb + github.com/kernel/kernel-go-sdk v0.100.1-0.20260909170029-19b510c645d0 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 4d27d68b..425acf39 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.100.1-0.20260908191213-78d784504ddb h1:252Ep0Ui8rCqRnZKL+0raRbcIYIy4oyzzE+nUxzk8gY= -github.com/kernel/kernel-go-sdk v0.100.1-0.20260908191213-78d784504ddb/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260909170029-19b510c645d0 h1:DLXsawpCNV8n0LBO+YykYzTYuBeUCkbKdUQLAf4JXJA= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260909170029-19b510c645d0/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= From e50d2cdcf24c9ee4de4ad7a22b4ef1cc3a93c576 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:02:14 +0000 Subject: [PATCH 31/51] chore: update Go SDK to f8ec7e1 and document ISP proxy countries Bumps kernel-go-sdk to v0.100.1-0.20260909235342-f8ec7e14e93e (f8ec7e14e93e45c2e0740e89726f094671e19787). The SDK diff is documentation-only: ISP proxy configs now document support for US, GB, FR, DE, and SG (previously only US was implied). No new methods or param fields were added, so no new commands or flags were required. The CLI already forwards --country to the ISP config unmodified, so the new countries worked without code changes; this surfaces them in help text so users can discover them. A full enumeration of all 158 SDK methods in api.md against the CLI command tree found no coverage gaps. The 5 config-registry methods are marked x-cli-skip in openapi.yaml and are intentionally excluded. Tested against the live API: created isp proxies with --country DE, GB, SG, and FR (all returned status available with the expected country in config), verified `proxies get` and `proxies check` render the country, then deleted all four. Co-Authored-By: Claude Opus 5 --- cmd/proxies/proxies.go | 7 +++++-- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index 4cd072ef..c495ef22 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -37,7 +37,7 @@ var proxiesCreateCmd = &cobra.Command{ Proxy types (from best to worst for bot detection): - mobile: Mobile carrier proxies - residential: Residential IP proxies -- isp: ISP proxies +- isp: ISP proxies (supported countries: US, GB, FR, DE, SG) - datacenter: Datacenter proxies - custom: Your own proxy server @@ -51,6 +51,9 @@ Examples: # Create a custom TLS-terminating proxy with a CA bundle kernel proxies create --type custom --host proxy.example.com --port 8080 --ca-bundle ./proxy-ca.pem --name "My TLS Proxy" + # Create an ISP proxy in Germany + kernel proxies create --type isp --country DE --name "DE ISP" + # Create a residential proxy with location kernel proxies create --type residential --country US --city sanfrancisco --state CA --name "SF Residential" @@ -108,7 +111,7 @@ func init() { proxiesCreateCmd.Flags().String("protocol", "https", "Protocol to use for the proxy connection (http|https)") // Location flags (datacenter, isp, residential, mobile) - proxiesCreateCmd.Flags().String("country", "", "ISO 3166 country code or EU") + proxiesCreateCmd.Flags().String("country", "", "ISO 3166 country code or EU (isp proxies support US, GB, FR, DE, SG; defaults to US)") proxiesCreateCmd.Flags().String("city", "", "City name (no spaces, e.g. sanfrancisco)") proxiesCreateCmd.Flags().String("state", "", "Two-letter state code") proxiesCreateCmd.Flags().String("zip", "", "US ZIP code") diff --git a/go.mod b/go.mod index e3c2aa66..7112b7ba 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.100.1-0.20260909170029-19b510c645d0 + github.com/kernel/kernel-go-sdk v0.100.1-0.20260909235342-f8ec7e14e93e 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 425acf39..d1e8e7c6 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.100.1-0.20260909170029-19b510c645d0 h1:DLXsawpCNV8n0LBO+YykYzTYuBeUCkbKdUQLAf4JXJA= -github.com/kernel/kernel-go-sdk v0.100.1-0.20260909170029-19b510c645d0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260909235342-f8ec7e14e93e h1:oV5UGE5TlYSrRJURN/yO+BbzHaz3v9RpPbSt22Jk/gQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260909235342-f8ec7e14e93e/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= From 65b4c304dd396a60fbccd7c34eee13c60d5c5b20 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:17:58 +0000 Subject: [PATCH 32/51] chore: update Go SDK to f3dcf5a and clarify proxy country defaults Updates github.com/kernel/kernel-go-sdk to v0.100.1-0.20260910180815-f3dcf5af3a44 (f3dcf5a). The SDK change is documentation-only: it clarifies that residential and mobile proxies fall back to the global pool without country targeting when `country` is omitted. The CLI's `--country` help text claimed "defaults to US" for all proxy types, which is only true for datacenter and isp, so this corrects it and documents the split in `proxies create` long help. Coverage analysis: full enumeration of all 158 SDK routes in api.md against all 168 CLI leaf commands. The 5 /config-registry routes are marked x-cli-skip in openapi.yaml; the remaining 153 all have CLI commands. Every field of all 114 SDK *Params structs is reachable via a flag, positional arg, or nested flag group. No coverage gaps. Tested against the live API (created, verified, and deleted each proxy): - proxies create --type residential (no --country) -> config {} (global pool) - proxies create --type mobile (no --country) -> config {} (global pool) - proxies create --type datacenter (no --country) -> config {country: us} - proxies create --type isp --country DE -> config {country: de}, German IP - proxies get, proxies check, proxies list --query, proxies delete -y Co-Authored-By: Claude Opus 5 --- cmd/proxies/proxies.go | 6 +++++- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index c495ef22..12827f70 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -41,6 +41,10 @@ Proxy types (from best to worst for bot detection): - datacenter: Datacenter proxies - custom: Your own proxy server +Country targeting: +- datacenter and isp default to US when --country is omitted +- residential and mobile use the global pool without country targeting when --country is omitted + Examples: # Create a datacenter proxy kernel proxies create --type datacenter --country US --name "US Datacenter" @@ -111,7 +115,7 @@ func init() { proxiesCreateCmd.Flags().String("protocol", "https", "Protocol to use for the proxy connection (http|https)") // Location flags (datacenter, isp, residential, mobile) - proxiesCreateCmd.Flags().String("country", "", "ISO 3166 country code or EU (isp proxies support US, GB, FR, DE, SG; defaults to US)") + proxiesCreateCmd.Flags().String("country", "", "ISO 3166 country code or EU (isp proxies support US, GB, FR, DE, SG; datacenter and isp default to US, residential and mobile use the global pool without country targeting)") proxiesCreateCmd.Flags().String("city", "", "City name (no spaces, e.g. sanfrancisco)") proxiesCreateCmd.Flags().String("state", "", "Two-letter state code") proxiesCreateCmd.Flags().String("zip", "", "US ZIP code") diff --git a/go.mod b/go.mod index 7112b7ba..3f5c449b 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.100.1-0.20260909235342-f8ec7e14e93e + github.com/kernel/kernel-go-sdk v0.100.1-0.20260910180815-f3dcf5af3a44 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 d1e8e7c6..36147200 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.100.1-0.20260909235342-f8ec7e14e93e h1:oV5UGE5TlYSrRJURN/yO+BbzHaz3v9RpPbSt22Jk/gQ= -github.com/kernel/kernel-go-sdk v0.100.1-0.20260909235342-f8ec7e14e93e/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260910180815-f3dcf5af3a44 h1:dibDUoyEB/22t5POlxqnhIkT0sjtAwuhm83Hv2PjCj4= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260910180815-f3dcf5af3a44/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= From eed96326aa9a0d10a7c8243cf912b8fd750ff8e6 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:31:54 +0000 Subject: [PATCH 33/51] chore: update Go SDK to a65bb49 and surface invocation status reasons Updates github.com/kernel/kernel-go-sdk to a65bb49b82ad9371bdf7a9af01b7f298c86803e8. The SDK change populates status_reason on every invocation response with a nonempty, customer-safe summary whenever an invocation fails, and redocuments output as "the action result or detailed failure output" which may be plain text rather than JSON. `kernel invoke` previously printed only the raw output on failure, so the new summary was never shown. printResult now takes the status reason and prints it above the raw result for failed invocations, on both the sync response path and the SSE invocation_state path. Success output is unchanged. Full enumeration of api.md (158 methods) against the CLI found no other gaps: every method has a command except the ConfigRegistry endpoints (list, lookup, resolve, analyses list/get), which are all marked x-cli-skip: true in openapi.yaml. InvocationListParams fields are all already exposed as flags on `kernel invoke history`, and this SDK diff added no new methods or params. Tested against the live API: - kernel invoke compliance-scan scan_domain -p '{}' (async/SSE path) -> "ERROR: Reason: Invocation failed. See output for details." - kernel invoke compliance-scan scan_domain -p '{}' --sync -> same - kernel invoke prod-jfk-hypeman-2-smoke-20260429 ping -> success output unchanged - kernel invoke history --status failed -o json -> status_reason populated on historical invocations - kernel invoke get -> Status Reason row present Plus new unit tests for printResult; go vet and go test ./... pass. Co-Authored-By: Claude Opus 5 --- cmd/invoke.go | 16 +++++++++++----- cmd/invoke_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 cmd/invoke_test.go diff --git a/cmd/invoke.go b/cmd/invoke.go index ea89f628..738c9d04 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -185,7 +185,7 @@ func runInvoke(cmd *cobra.Command, args []string) error { return nil } succeeded := resp.Status == kernel.InvocationNewResponseStatusSucceeded - printResult(succeeded, resp.Output) + printResult(succeeded, resp.Output, resp.StatusReason) duration := time.Since(startTime) if succeeded { @@ -268,7 +268,7 @@ func runInvoke(cmd *cobra.Command, args []string) error { if status == string(kernel.InvocationGetResponseStatusSucceeded) || status == string(kernel.InvocationGetResponseStatusFailed) { // Finished – print output and exit accordingly succeeded := status == string(kernel.InvocationGetResponseStatusSucceeded) - printResult(succeeded, stateEv.Invocation.Output) + printResult(succeeded, stateEv.Invocation.Output, stateEv.Invocation.StatusReason) duration := time.Since(startTime) if succeeded { @@ -313,14 +313,20 @@ func handleSdkError(err error) error { return nil } -func printResult(success bool, output string) { +func printResult(success bool, output, statusReason string) { output = formatJSONValue(output) // use pterm.Success if succeeded, pterm.Error if failed if success { pterm.Success.Printf("Result:\n%s\n", output) - } else { - pterm.Error.Printf("Result:\n%s\n", output) + return + } + // The API populates status_reason with a customer-safe summary of the failure + // whenever an invocation fails; show it above the raw output, which may be + // plain text rather than JSON. + if statusReason != "" { + pterm.Error.Printf("Reason: %s\n", statusReason) } + pterm.Error.Printf("Result:\n%s\n", output) } func formatJSONValue(value string) string { diff --git a/cmd/invoke_test.go b/cmd/invoke_test.go new file mode 100644 index 00000000..8eb86ce1 --- /dev/null +++ b/cmd/invoke_test.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPrintResultShowsStatusReasonOnFailure(t *testing.T) { + buf := capturePtermOutput(t) + + printResult(false, `{"error":"domain is required"}`, "Invocation failed. See output for details.") + + out := buf.String() + require.Contains(t, out, "Reason: Invocation failed. See output for details.") + require.Contains(t, out, `"error": "domain is required"`) + require.Less(t, strings.Index(out, "Reason:"), strings.Index(out, "Result:"), + "the customer-safe reason should precede the raw output") +} + +func TestPrintResultOmitsStatusReasonWhenEmpty(t *testing.T) { + buf := capturePtermOutput(t) + + printResult(false, "boom", "") + + out := buf.String() + require.NotContains(t, out, "Reason:") + require.Contains(t, out, "boom") +} + +func TestPrintResultIgnoresStatusReasonOnSuccess(t *testing.T) { + buf := capturePtermOutput(t) + + // status_reason is omitted for non-failed invocations, but a stale value must + // never be presented as a failure summary. + printResult(true, `{"ok":true}`, "should not appear") + + out := buf.String() + require.NotContains(t, out, "should not appear") + require.Contains(t, out, `"ok": true`) +} diff --git a/go.mod b/go.mod index 3f5c449b..e187ca6e 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.100.1-0.20260910180815-f3dcf5af3a44 + github.com/kernel/kernel-go-sdk v0.100.1-0.20260910182333-a65bb49b82ad 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 36147200..69cf0f4f 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.100.1-0.20260910180815-f3dcf5af3a44 h1:dibDUoyEB/22t5POlxqnhIkT0sjtAwuhm83Hv2PjCj4= -github.com/kernel/kernel-go-sdk v0.100.1-0.20260910180815-f3dcf5af3a44/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260910182333-a65bb49b82ad h1:EMs9zV1CAvYIKAu7/N8aYt/0lBmLLIP6AlS1iDiw5BQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260910182333-a65bb49b82ad/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= From a5345c2407771b7c106e84dd462b8ef483344828 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:59:01 +0000 Subject: [PATCH 34/51] chore: update Go SDK to f0c6e4b Bump github.com/kernel/kernel-go-sdk to v0.100.1-0.20260910195133-f0c6e4b86ab5 (f0c6e4b), which brands origin TLS timeouts and classifies provider failures on browser proxy telemetry events. The SDK diff only widens the documented enum for BrowserProxyErrorEventData.Code (adds provider_rejected and origin_tls_timeout) on a response field, so no new commands or flags are needed. A full enumeration of all 158 api.md methods against the CLI command tree found no coverage gaps; the five config-registry methods are marked x-cli-skip: true in openapi.yaml. Tested: go build ./..., go vet ./..., go test ./... (all pass). No new commands or flags, so no API smoke tests were required. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e187ca6e..7cbef815 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.100.1-0.20260910182333-a65bb49b82ad + github.com/kernel/kernel-go-sdk v0.100.1-0.20260910195133-f0c6e4b86ab5 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 69cf0f4f..c7f40b57 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.100.1-0.20260910182333-a65bb49b82ad h1:EMs9zV1CAvYIKAu7/N8aYt/0lBmLLIP6AlS1iDiw5BQ= -github.com/kernel/kernel-go-sdk v0.100.1-0.20260910182333-a65bb49b82ad/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260910195133-f0c6e4b86ab5 h1:HB5w44eD47Jj/QnQKJ8sRKrYTDJS1YyLChwDUEqwQMI= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260910195133-f0c6e4b86ab5/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= From c9d43d694ae5a208d1cbdc9ce0e1d2889ea94807 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:25:03 +0000 Subject: [PATCH 35/51] chore: update Go SDK to e3ea91dfaa854364133b99e1bfe38e788de48dad Bumps github.com/kernel/kernel-go-sdk to v0.100.1-0.20260910201751-e3ea91dfaa85. The only SDK change since the previously pinned version (f0c6e4b86ab5) is a new nullable `guidance` response field on ConfigRegistryResponse and LookupResponse. Both belong to the /config-registry endpoints, which are marked `x-cli-skip: true` in openapi.yaml and have no CLI surface, so no commands or flags need to change. Coverage analysis: full enumeration of all 158 SDK methods in api.md against the CLI command tree found no gaps. The only uncovered methods are the five ConfigRegistry ones (List, Lookup, Resolve, Analyses.Get, Analyses.List), all of which are x-cli-skip. Param-field enumeration across every New/List/Update/Upsert params struct also found no missing flags. Tested: go build ./..., go vet ./..., go test ./... all pass; smoke tested `kernel browsers list` and `kernel profiles list` against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7cbef815..8bc27ba0 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.100.1-0.20260910195133-f0c6e4b86ab5 + github.com/kernel/kernel-go-sdk v0.100.1-0.20260910201751-e3ea91dfaa85 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 c7f40b57..8e7c2da2 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.100.1-0.20260910195133-f0c6e4b86ab5 h1:HB5w44eD47Jj/QnQKJ8sRKrYTDJS1YyLChwDUEqwQMI= -github.com/kernel/kernel-go-sdk v0.100.1-0.20260910195133-f0c6e4b86ab5/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260910201751-e3ea91dfaa85 h1:/PU8zH63AZOUuWRbks+hfGfi1EcHszaQmoFUCPzHeQc= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260910201751-e3ea91dfaa85/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= From e776eb9396ecdfa085a8b46b446e07e5762d5e0d Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:37:39 +0000 Subject: [PATCH 36/51] chore: update Go SDK to 9d9ffcf (revert config registry guidance) Bumps github.com/kernel/kernel-go-sdk to v0.100.1-0.20260910213034-9d9ffcf0053d (9d9ffcf). The only SDK change since the CLI's previous pin (e3ea91d) is the removal of the `guidance` response field from ConfigRegistryResponse and LookupResponse. All /config-registry endpoints are marked x-cli-skip in the OpenAPI spec, and the CLI never referenced that field, so no command or flag changes are required. Coverage analysis: enumerated all 158 SDK methods from api.md against the full CLI command tree. Every non-skipped method has a CLI command, and a field-by-field sweep of all *Params structs found no missing flags (BrowserCurlParams.TimeoutMs/ResponseEncoding are covered by the raw curl client's --max-time and byte-stream output; AuthConnectionLoginParams.BrowserTelemetry is deprecated in favor of browser.telemetry, which --telemetry* already covers; AuditLogListParams.PageToken is handled by ListAutoPaging). Tested: go build ./..., go test ./... (all pass), and smoke-tested `kernel browsers list` and `kernel profiles list` against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8bc27ba0..3a43f544 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.100.1-0.20260910201751-e3ea91dfaa85 + github.com/kernel/kernel-go-sdk v0.100.1-0.20260910213034-9d9ffcf0053d 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 8e7c2da2..4a68fbed 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.100.1-0.20260910201751-e3ea91dfaa85 h1:/PU8zH63AZOUuWRbks+hfGfi1EcHszaQmoFUCPzHeQc= -github.com/kernel/kernel-go-sdk v0.100.1-0.20260910201751-e3ea91dfaa85/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260910213034-9d9ffcf0053d h1:00ier5v1Ia8peo3LfFTPhtMs0+MQQcF6+Qmqt4gZmnY= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260910213034-9d9ffcf0053d/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= From 2668f0f5c0811db283f4040618a302099dd91ed6 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:11:07 +0000 Subject: [PATCH 37/51] chore: update Go SDK to cd47c64 and add vault provider config commands Updates kernel-go-sdk to cd47c643d93814b964e996f74b59cb09544877b1, which adds the VaultProviderConfigs resource and reworks wallet vault item specs around customer-owned provider credentials. New commands for the five new SDK methods: kernel vaults provider-configs create client.VaultProviderConfigs.New kernel vaults provider-configs list client.VaultProviderConfigs.List kernel vaults provider-configs get client.VaultProviderConfigs.Get kernel vaults provider-configs update client.VaultProviderConfigs.Update kernel vaults provider-configs delete client.VaultProviderConfigs.Delete list uses the page-based UX (--page/--per-page with the extra-item probe) rather than exposing the endpoint's limit/offset. The write-only client secret is accepted inline or, preferably, via --client-secret-file with "-" for stdin. The SDK dropped WalletVaultItemSpecUnionParam in favour of VaultItemUpsertParamsBodyWalletSpecUnion, so wallets create now builds that type. Wallet specs stay raw --spec JSON, so the new provider_config selector needed no new flag, but the spec help now documents it and the display-safe JSON projection keeps spec.provider_config and spec.authorization.client.provider_config. Customer-managed Link wallets are deliberately left to backends: they require importing a grant's OAuth tokens, which the vaults surface does not accept. A full enumeration of api.md against the CLI command tree found no other gaps. All 163 SDK methods have commands except the config-registry and auth-connections-exchange endpoints marked x-cli-skip in openapi.yaml. Tested against the live API: provider-configs create (secret via stdin), get, list (including --page/--per-page paging over two configs and the has-more footer), update --name, update --client-secret-file, delete, delete of a missing config, agentcard credential rejection, and wallets create forwarding provider_config. All created resources were cleaned up. Co-Authored-By: Claude Opus 5 --- README.md | 13 + cmd/vault_provider_configs.go | 548 +++++++++++++++++++++++++++++ cmd/vault_provider_configs_test.go | 279 +++++++++++++++ cmd/vaults.go | 2 +- cmd/vaults_commands.go | 4 +- cmd/vaults_help.go | 15 +- cmd/vaults_output.go | 13 +- go.mod | 2 +- go.sum | 4 +- 9 files changed, 871 insertions(+), 9 deletions(-) create mode 100644 cmd/vault_provider_configs.go create mode 100644 cmd/vault_provider_configs_test.go diff --git a/README.md b/README.md index 67d3804e..95bbc4a2 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,19 @@ cannot switch projects. | `kernel vaults items invoke ` | GET the item, then POST an advertised operation; optional `--open` opens a returned HTTPS action | | `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | | `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | +| `kernel vaults provider-configs create --name --provider link\|agentcard --client-id ` | Register customer-owned provider credentials; `--client-secret` or `--client-secret-file` (`-` reads stdin) | +| `kernel vaults provider-configs list` | `--page` (default 1), `--per-page 1..100` (default 20) | +| `kernel vaults provider-configs get ` | Get by ID or name; secrets are never returned | +| `kernel vaults provider-configs update ` | `--name` renames, `--client-secret`/`--client-secret-file` rotates; the client ID is immutable | +| `kernel vaults provider-configs delete ` | Refused with 409 while a vault item still references it; `--yes` skips confirmation | + +`provider-configs` commands are organization-scoped: they need an organization-scoped API key +or dashboard login, a project-scoped key receives 403, and `--project` does not apply. +A configuration is shared across the organization's projects and serves many wallets. +Select one for an AgentCard wallet with `provider_config` in `--spec`, or omit it to use +Kernel-managed credentials; the binding is fixed at wallet creation and renaming a +configuration does not rebind existing wallets. Customer-managed Link wallets are created by +importing a grant's OAuth tokens, which this CLI never accepts — create them from your backend. `` accepts an ID or name. `` is the immutable item key within that vault, not its generated item ID. Names and keys use letters, digits, dots, underscores, and hyphens diff --git a/cmd/vault_provider_configs.go b/cmd/vault_provider_configs.go new file mode 100644 index 00000000..d767ad44 --- /dev/null +++ b/cmd/vault_provider_configs.go @@ -0,0 +1,548 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/kernel/cli/pkg/interactive" + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/pterm/pterm" + "github.com/samber/lo" + "github.com/spf13/cobra" +) + +// VaultProviderConfigsService is the subset of the SDK's provider-configuration +// client that the CLI uses. +type VaultProviderConfigsService interface { + New(ctx context.Context, body kernel.VaultProviderConfigNewParams, opts ...option.RequestOption) (res *kernel.VaultProviderConfigUnion, err error) + Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.VaultProviderConfigUnion, err error) + Update(ctx context.Context, idOrName string, body kernel.VaultProviderConfigUpdateParams, opts ...option.RequestOption) (res *kernel.VaultProviderConfigUnion, err error) + List(ctx context.Context, query kernel.VaultProviderConfigListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.VaultProviderConfigUnion], err error) + Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) error +} + +// VaultProviderConfigsCmd handles provider-configuration operations independent +// of cobra. +type VaultProviderConfigsCmd struct { + configs VaultProviderConfigsService + prompter interactive.Prompter +} + +type VaultProviderConfigsCreateInput struct { + Name string + Provider string + ClientID string + ClientSecret string + Output string +} + +type VaultProviderConfigsListInput struct { + Page int + PerPage int + Output string +} + +type VaultProviderConfigsGetInput struct { + Identifier string + Output string +} + +type VaultProviderConfigsUpdateInput struct { + Identifier string + // Nil means "leave as it is". The API has no way to clear either field, so + // neither accepts an empty value. + Name *string + ClientSecret *string + Output string +} + +type VaultProviderConfigsDeleteInput struct { + Identifier string + SkipConfirm bool +} + +func validateVaultProvider(provider string) error { + if provider != "link" && provider != "agentcard" { + return fmt.Errorf("--provider must be link or agentcard") + } + return nil +} + +// vaultProviderConfigPreRun rejects a malformed output format or identifier +// before any request is sent, matching the rest of the vaults surface. +func vaultProviderConfigPreRun(cmd *cobra.Command, args []string) error { + if err := validateJSONOutput(vaultOutput(cmd)); err != nil { + return err + } + if len(args) > 0 { + return validateVaultName(args[0], "provider configuration ID or name") + } + return nil +} + +func (c VaultProviderConfigsCmd) Create(ctx context.Context, in VaultProviderConfigsCreateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if err := validateVaultName(in.Name, "--name"); err != nil { + return err + } + if err := validateVaultProvider(in.Provider); err != nil { + return err + } + if strings.TrimSpace(in.ClientID) == "" { + return fmt.Errorf("--client-id is required") + } + if in.ClientSecret == "" { + return fmt.Errorf("--client-secret or --client-secret-file is required") + } + + params := kernel.VaultProviderConfigNewParams{} + switch in.Provider { + case "link": + params.OfLink = &kernel.VaultProviderConfigNewParamsBodyLink{ + Name: in.Name, + Credentials: kernel.VaultProviderConfigNewParamsBodyLinkCredentials{ + ClientID: in.ClientID, + ClientSecret: in.ClientSecret, + }, + } + case "agentcard": + params.OfAgentcard = &kernel.VaultProviderConfigNewParamsBodyAgentcard{ + Name: in.Name, + Credentials: kernel.VaultProviderConfigNewParamsBodyAgentcardCredentials{ + ClientID: in.ClientID, + ClientSecret: in.ClientSecret, + }, + } + } + + config, err := c.configs.New(ctx, params, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return printVaultProviderConfigJSON(config) + } + pterm.Success.Printf("Registered provider configuration: %s\n", config.ID) + printVaultProviderConfigDetail(config) + printVaultProviderConfigUsage(config) + return nil +} + +func (c VaultProviderConfigsCmd) List(ctx context.Context, in VaultProviderConfigsListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + page := in.Page + perPage := in.PerPage + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 20 + } + if perPage > 100 { + return fmt.Errorf("--per-page must be between 1 and 100") + } + + params := kernel.VaultProviderConfigListParams{} + // Request one extra item so the response itself reveals whether another page + // exists: the SDK keeps the X-Has-More header private. + params.Limit = kernel.Opt(int64(perPage + 1)) + params.Offset = kernel.Opt(int64((page - 1) * perPage)) + + result, err := c.configs.List(ctx, params, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + var items []kernel.VaultProviderConfigUnion + if result != nil { + items = result.Items + } + hasMore := len(items) > perPage + if hasMore { + items = items[:perPage] + } + itemsThisPage := len(items) + + if in.Output == "json" { + values, err := vaultSafeJSONSlice(items, vaultProviderConfigFields) + if err != nil { + return err + } + return printVaultJSON(struct { + ProviderConfigs []vaultJSON `json:"provider_configs"` + Page int `json:"page"` + PerPage int `json:"per_page"` + HasMore bool `json:"has_more"` + }{values, page, perPage, hasMore}) + } + + if len(items) == 0 { + pterm.Info.Println("No provider configurations found") + } else { + rows := pterm.TableData{{"ID", "Name", "Provider", "Client ID", "Mode", "Created At", "Updated At"}} + for _, config := range items { + rows = append(rows, []string{ + config.ID, + config.Name, + config.Provider, + config.ClientID, + vaultProviderConfigMode(config), + util.FormatLocal(config.CreatedAt), + util.FormatLocal(config.UpdatedAt), + }) + } + PrintTableNoPad(rows, true) + } + + 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 { + pterm.Printf("Next: kernel vaults provider-configs list --page %d --per-page %d\n", page+1, perPage) + } + return nil +} + +func (c VaultProviderConfigsCmd) Get(ctx context.Context, in VaultProviderConfigsGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + config, err := c.configs.Get(ctx, in.Identifier, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return printVaultProviderConfigJSON(config) + } + printVaultProviderConfigDetail(config) + return nil +} + +func (c VaultProviderConfigsCmd) Update(ctx context.Context, in VaultProviderConfigsUpdateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + params := kernel.VaultProviderConfigUpdateParams{} + if in.Name != nil { + if err := validateVaultName(*in.Name, "--name"); err != nil { + return err + } + params.Name = kernel.Opt(*in.Name) + } + if in.ClientSecret != nil { + if *in.ClientSecret == "" { + return fmt.Errorf("--client-secret must not be empty; omit it to leave the stored secret unchanged") + } + params.Credentials.ClientSecret = kernel.Opt(*in.ClientSecret) + } + if in.Name == nil && in.ClientSecret == nil { + return fmt.Errorf("nothing to update: pass --name, --client-secret, or --client-secret-file") + } + + config, err := c.configs.Update(ctx, in.Identifier, params, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return printVaultProviderConfigJSON(config) + } + pterm.Success.Printf("Updated provider configuration: %s\n", config.ID) + printVaultProviderConfigDetail(config) + if in.Name != nil { + pterm.Info.Println("Renaming does not rebind existing wallets; they keep the configuration ID they were created with.") + } + return nil +} + +func (c VaultProviderConfigsCmd) Delete(ctx context.Context, in VaultProviderConfigsDeleteInput) error { + if !in.SkipConfirm { + ok, err := c.prompter.Confirm( + fmt.Sprintf("delete provider configuration '%s'", in.Identifier), + fmt.Sprintf("Delete provider configuration '%s'? Wallets already bound to it cannot be rebound.", in.Identifier), + ) + if err != nil { + return err + } + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + if err := c.configs.Delete(ctx, in.Identifier, option.WithMaxRetries(0)); err != nil { + if util.IsNotFound(err) { + pterm.Info.Printf("Provider configuration '%s' not found\n", in.Identifier) + return nil + } + // A 409 means a non-deleted vault item still references the + // configuration; the API's message names what still holds it. + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Deleted provider configuration: %s\n", in.Identifier) + pterm.Info.Println("The external OAuth client still exists and unrelated grants are not revoked.") + return nil +} + +// vaultProviderConfigMode reports the provider-introspected credential mode. +// Only AgentCard configurations return test_mode, so Link rows stay blank +// rather than claiming a mode the API never reported. +func vaultProviderConfigMode(config kernel.VaultProviderConfigUnion) string { + if !config.JSON.TestMode.Valid() { + return "-" + } + return lo.Ternary(config.TestMode, "sandbox", "live") +} + +// printVaultProviderConfigUsage shows how the configuration that was just +// registered gets used. Only AgentCard wallets can select one through the CLI: +// a customer-managed Link wallet is created by importing an existing grant's +// OAuth tokens, which the vaults surface never accepts. +func printVaultProviderConfigUsage(config *kernel.VaultProviderConfigUnion) { + if config.Provider == "agentcard" { + pterm.Info.Printf( + "Select it when creating a wallet: kernel vaults wallets create --provider agentcard --spec '{\"provider_config\": {\"name\": %q}}'\n", + config.Name, + ) + return + } + pterm.Info.Println("Link wallets on this client are created by importing a grant's OAuth tokens from your backend, not through the CLI.") +} + +func printVaultProviderConfigJSON(config *kernel.VaultProviderConfigUnion) error { + raw, err := filterVaultJSON(json.RawMessage(config.RawJSON()), vaultProviderConfigFields) + if err != nil { + return err + } + return printVaultJSON(raw) +} + +func printVaultProviderConfigDetail(config *kernel.VaultProviderConfigUnion) { + rows := pterm.TableData{ + {"Property", "Value"}, + {"ID", config.ID}, + {"Name", config.Name}, + {"Provider", config.Provider}, + {"Client ID", config.ClientID}, + } + if config.JSON.TestMode.Valid() { + rows = append(rows, []string{"Mode (provider-introspected)", vaultProviderConfigMode(*config)}) + } + rows = append(rows, + []string{"Created At", util.FormatLocal(config.CreatedAt)}, + []string{"Updated At", util.FormatLocal(config.UpdatedAt)}, + ) + PrintTableNoPad(rows, true) +} + +// --- Cobra wiring --- + +// readSecretFlag resolves a write-only credential from either its inline flag +// or a file, where "-" reads stdin. The file form keeps the secret out of shell +// history and process listings. +func readSecretFlag(cmd *cobra.Command, flagName string) (*string, error) { + inlineChanged := cmd.Flags().Changed(flagName) + file, _ := cmd.Flags().GetString(flagName + "-file") + if inlineChanged && file != "" { + return nil, fmt.Errorf("pass either --%s or --%s-file, not both", flagName, flagName) + } + if file != "" { + var data []byte + var err error + if file == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(file) + } + if err != nil { + return nil, fmt.Errorf("failed to read --%s-file: %w", flagName, err) + } + // Trailing newlines come from editors and `echo`, never from the secret. + value := strings.TrimRight(string(data), "\r\n") + return &value, nil + } + if inlineChanged { + value, _ := cmd.Flags().GetString(flagName) + return &value, nil + } + return nil, nil +} + +func getVaultProviderConfigsHandler(cmd *cobra.Command) VaultProviderConfigsCmd { + client := getKernelClient(cmd) + svc := client.VaultProviderConfigs + return VaultProviderConfigsCmd{configs: &svc, prompter: interactive.NewPrompter()} +} + +func addVaultProviderConfigSecretFlags(cmd *cobra.Command, usage string) { + cmd.Flags().String("client-secret", "", usage+". Prefer --client-secret-file to keep it out of shell history") + cmd.Flags().String("client-secret-file", "", "Read the client secret from this file, or from stdin when set to -") +} + +func newVaultProviderConfigsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "provider-configs", + Aliases: []string{"provider-config"}, + Short: "Register customer-owned provider credentials for wallets", + Long: `Register and maintain the provider credentials that wallets can be created against. + +A configuration is shared across the organization's projects and serves many +wallets. Names are unique within the organization. Secret credentials are +write-only: they are never returned by any command here. + +These commands are organization-scoped. An organization-scoped API key or +dashboard login is required; a project-scoped key receives 403, and --project +does not apply. + +Omitting a configuration when creating a wallet uses Kernel-managed credentials +instead. A wallet cannot be moved to a different configuration after creation, +and renaming a configuration does not rebind existing wallets.`, + Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, + } + + create := &cobra.Command{ + Use: "create --name --provider --client-id --client-secret ", + Short: "Register a provider configuration", + Long: `Register a provider configuration under a name unique to the organization. +A duplicate name returns 409 and leaves the stored credentials alone. + +For link, Kernel uses the client only to refresh and revoke wallet grants your +backend obtained; Kernel does not run the OAuth flow for an imported wallet. +For agentcard, Kernel obtains application access tokens with client_credentials +and introspects sandbox vs live from the credentials, so invalid credentials are +rejected at registration.`, + Example: ` kernel vaults provider-configs create --name my-link-client --provider link \ + --client-id example-client-id --client-secret-file ./link-secret.txt + + printf '%s' "$AGENTCARD_SECRET" | kernel vaults provider-configs create \ + --name my-agentcard --provider agentcard \ + --client-id example-client-id --client-secret-file -`, + Args: cobra.NoArgs, + PreRunE: vaultProviderConfigPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + secret, err := readSecretFlag(cmd, "client-secret") + if err != nil { + return err + } + name, _ := cmd.Flags().GetString("name") + provider, _ := cmd.Flags().GetString("provider") + clientID, _ := cmd.Flags().GetString("client-id") + in := VaultProviderConfigsCreateInput{ + Name: name, + Provider: provider, + ClientID: clientID, + Output: vaultOutput(cmd), + } + if secret != nil { + in.ClientSecret = *secret + } + return getVaultProviderConfigsHandler(cmd).Create(cmd.Context(), in) + }, + } + create.Flags().String("name", "", "Configuration name, unique within the organization (required)") + _ = create.MarkFlagRequired("name") + create.Flags().String("provider", "", "Provider: link or agentcard (required)") + _ = create.MarkFlagRequired("provider") + create.Flags().String("client-id", "", "OAuth client identity; immutable after registration (required)") + _ = create.MarkFlagRequired("client-id") + addVaultProviderConfigSecretFlags(create, "OAuth client secret; stored write-only and never returned") + addVaultJSONOutputFlag(create) + + list := &cobra.Command{ + Use: "list", + Short: "List provider configurations in the organization", + Long: "List provider configurations. Secret credentials are never returned.", + Args: cobra.NoArgs, + PreRunE: vaultProviderConfigPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + page, _ := cmd.Flags().GetInt("page") + perPage, _ := cmd.Flags().GetInt("per-page") + return getVaultProviderConfigsHandler(cmd).List(cmd.Context(), VaultProviderConfigsListInput{ + Page: page, + PerPage: perPage, + Output: vaultOutput(cmd), + }) + }, + } + list.Flags().Int("page", 1, "Page number (1-based)") + list.Flags().Int("per-page", 20, "Items per page (1-100)") + addVaultJSONOutputFlag(list) + + get := &cobra.Command{ + Use: "get ", + Short: "Get a provider configuration by ID or name", + Long: "Get a provider configuration. Secret credentials are never returned.", + Args: cobra.ExactArgs(1), + PreRunE: vaultProviderConfigPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + return getVaultProviderConfigsHandler(cmd).Get(cmd.Context(), VaultProviderConfigsGetInput{ + Identifier: args[0], + Output: vaultOutput(cmd), + }) + }, + } + addVaultJSONOutputFlag(get) + + update := &cobra.Command{ + Use: "update ", + Short: "Rename a provider configuration or rotate its secret", + Long: `Update only the fields you pass; the rest are left unchanged. + +The client ID is immutable, so this is the way to rotate a client secret. A +rejected rotation leaves the existing credentials in place. Names must stay +unique within the organization, and renaming does not rebind existing wallets.`, + Example: ` kernel vaults provider-configs update my-link-client --name renamed-link-client + + kernel vaults provider-configs update my-agentcard --client-secret-file ./rotated-secret.txt`, + Args: cobra.ExactArgs(1), + PreRunE: vaultProviderConfigPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + secret, err := readSecretFlag(cmd, "client-secret") + if err != nil { + return err + } + in := VaultProviderConfigsUpdateInput{ + Identifier: args[0], + ClientSecret: secret, + Output: vaultOutput(cmd), + } + if cmd.Flags().Changed("name") { + name, _ := cmd.Flags().GetString("name") + in.Name = &name + } + return getVaultProviderConfigsHandler(cmd).Update(cmd.Context(), in) + }, + } + update.Flags().String("name", "", "New configuration name, unique within the organization") + addVaultProviderConfigSecretFlags(update, "Replacement OAuth client secret") + addVaultJSONOutputFlag(update) + + del := &cobra.Command{ + Use: "delete ", + Short: "Delete an unused provider configuration", + Long: `Delete a provider configuration. + +The delete is refused with 409 while any non-deleted vault item still +references the configuration, regardless of connection status. Deleting does not +remove the external OAuth client or revoke unrelated grants.`, + Args: cobra.ExactArgs(1), + PreRunE: vaultProviderConfigPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + yes, _ := cmd.Flags().GetBool("yes") + return getVaultProviderConfigsHandler(cmd).Delete(cmd.Context(), VaultProviderConfigsDeleteInput{ + Identifier: args[0], + SkipConfirm: yes, + }) + }, + } + del.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + cmd.AddCommand(create, list, get, update, del) + return cmd +} diff --git a/cmd/vault_provider_configs_test.go b/cmd/vault_provider_configs_test.go new file mode 100644 index 00000000..9f60c529 --- /dev/null +++ b/cmd/vault_provider_configs_test.go @@ -0,0 +1,279 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const linkProviderConfigFixture = `{"id":"vpc-link-1","name":"my-link-client","provider":"link","client_id":"example-client-id","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` +const agentcardProviderConfigFixture = `{"id":"vpc-ac-1","name":"my-agentcard","provider":"agentcard","client_id":"example-client-id","test_mode":true,"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` + +func TestVaultProviderConfigCommandConstruction(t *testing.T) { + for _, path := range []string{"provider-configs create", "provider-configs list", "provider-configs get", "provider-configs update", "provider-configs delete"} { + t.Run(path, func(t *testing.T) { + cmd, remaining, err := newVaultsCommand().Find(strings.Fields(path)) + require.NoError(t, err) + require.Empty(t, remaining) + assert.NotNil(t, cmd.RunE) + assert.NotNil(t, cmd.PreRunE) + assert.NotNil(t, cmd.Args) + if cmd.Name() == "delete" { + assert.NotNil(t, cmd.Flags().Lookup("yes")) + return + } + require.NotNil(t, cmd.Flags().Lookup("output")) + assert.Contains(t, cmd.Flags().Lookup("output").Usage, "display-safe") + }) + } + + create, _, err := newVaultsCommand().Find([]string{"provider-configs", "create"}) + require.NoError(t, err) + for _, flag := range []string{"name", "provider", "client-id", "client-secret", "client-secret-file"} { + assert.NotNil(t, create.Flags().Lookup(flag), flag) + } + list, _, err := newVaultsCommand().Find([]string{"provider-configs", "list"}) + require.NoError(t, err) + assert.NotNil(t, list.Flags().Lookup("page")) + assert.NotNil(t, list.Flags().Lookup("per-page")) + // The endpoint pages with limit/offset, but those stay an implementation detail. + assert.Nil(t, list.Flags().Lookup("limit")) + assert.Nil(t, list.Flags().Lookup("offset")) + // The client ID is immutable, so update must not offer to change it. + update, _, err := newVaultsCommand().Find([]string{"provider-configs", "update"}) + require.NoError(t, err) + assert.Nil(t, update.Flags().Lookup("client-id")) + assert.Nil(t, update.Flags().Lookup("provider")) +} + +func TestVaultProviderConfigCreate(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + provider string + fixture string + usage string + }{ + {"link", linkProviderConfigFixture, "not through the CLI"}, + {"agentcard", agentcardProviderConfigFixture, `--provider agentcard --spec '{"provider_config": {"name": "my-agentcard"}}'`}, + } { + t.Run(tc.provider, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vault-provider-configs", r.URL.Path) + body, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"name":"cfg-1","provider":"`+tc.provider+`","credentials":{"client_id":"example-client-id","client_secret":"s3cret"}}`, string(body)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, tc.fixture) + }) + out, human, err := executeVaultCommand(t, client, + "vaults", "provider-configs", "create", "--name", "cfg-1", "--provider", tc.provider, + "--client-id", "example-client-id", "--client-secret", "s3cret", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, tc.fixture, out) + assert.Empty(t, human) + + _, human, err = executeVaultCommand(t, client, + "vaults", "provider-configs", "create", "--name", "cfg-1", "--provider", tc.provider, + "--client-id", "example-client-id", "--client-secret", "s3cret") + require.NoError(t, err) + assert.Contains(t, human, "example-client-id") + assert.NotContains(t, human, "s3cret") + assert.Contains(t, human, tc.usage) + }) + } +} + +func TestVaultProviderConfigCreateSecretSources(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + var sent string + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Credentials struct { + ClientSecret string `json:"client_secret"` + } `json:"credentials"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + sent = body.Credentials.ClientSecret + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, linkProviderConfigFixture) + }) + + // A file keeps the secret out of shell history, and its trailing newline is + // an artifact of how the file was written rather than part of the secret. + path := filepath.Join(t.TempDir(), "secret.txt") + require.NoError(t, os.WriteFile(path, []byte("file-secret\n"), 0o600)) + _, _, err := executeVaultCommand(t, client, + "vaults", "provider-configs", "create", "--name", "cfg-1", "--provider", "link", + "--client-id", "example-client-id", "--client-secret-file", path, "-o", "json") + require.NoError(t, err) + assert.Equal(t, "file-secret", sent) + + _, _, err = executeVaultCommand(t, client, + "vaults", "provider-configs", "create", "--name", "cfg-1", "--provider", "link", + "--client-id", "example-client-id", "--client-secret", "inline-secret", "--client-secret-file", path) + require.ErrorContains(t, err, "not both") +} + +func TestVaultProviderConfigCreateValidation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request to %s", r.URL.Path) + }) + for _, tc := range []struct { + args []string + message string + }{ + {[]string{"--name", "cfg-1", "--provider", "stripe", "--client-id", "id", "--client-secret", "s"}, "--provider must be link or agentcard"}, + {[]string{"--name", "bad name", "--provider", "link", "--client-id", "id", "--client-secret", "s"}, "--name must contain"}, + {[]string{"--name", "cfg-1", "--provider", "link", "--client-id", "id"}, "--client-secret"}, + } { + _, _, err := executeVaultCommand(t, client, append([]string{"vaults", "provider-configs", "create"}, tc.args...)...) + require.ErrorContains(t, err, tc.message) + } +} + +func TestVaultProviderConfigListPagination(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + body := "[" + linkProviderConfigFixture + "," + agentcardProviderConfigFixture + "]" + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/vault-provider-configs", r.URL.Path) + // One extra item over --per-page is requested so the response itself + // reveals whether another page exists. + assert.Equal(t, "2", r.URL.Query().Get("limit")) + assert.Equal(t, "1", r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + + out, _, err := executeVaultCommand(t, client, "vaults", "provider-configs", "list", "--page", "2", "--per-page", "1", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"provider_configs":[`+linkProviderConfigFixture+`],"page":2,"per_page":1,"has_more":true}`, out) + + _, human, err := executeVaultCommand(t, client, "vaults", "provider-configs", "list", "--page", "2", "--per-page", "1") + require.NoError(t, err) + assert.Contains(t, human, "Page: 2 Per-page: 1 Items this page: 1 Has more: yes") + assert.Contains(t, human, "Next: kernel vaults provider-configs list --page 3 --per-page 1") +} + +func TestVaultProviderConfigListFooterWithoutMorePages(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, "["+agentcardProviderConfigFixture+"]") + }) + _, human, err := executeVaultCommand(t, client, "vaults", "provider-configs", "list") + require.NoError(t, err) + assert.Contains(t, human, "Page: 1 Per-page: 20 Items this page: 1 Has more: no") + assert.NotContains(t, human, "Next:") + // Only AgentCard configurations report a provider-introspected mode. + assert.Contains(t, human, "sandbox") +} + +func TestVaultProviderConfigGetAndUpdate(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/vault-provider-configs/my-link-client", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPatch { + body, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"name":"renamed","credentials":{"client_secret":"rotated"}}`, string(body)) + } else { + assert.Equal(t, http.MethodGet, r.Method) + } + _, _ = io.WriteString(w, linkProviderConfigFixture) + }) + + out, _, err := executeVaultCommand(t, client, "vaults", "provider-configs", "get", "my-link-client", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, linkProviderConfigFixture, out) + + _, human, err := executeVaultCommand(t, client, "vaults", "provider-configs", "update", "my-link-client", + "--name", "renamed", "--client-secret", "rotated") + require.NoError(t, err) + assert.Contains(t, human, "Updated provider configuration") + assert.NotContains(t, human, "rotated") + assert.Contains(t, human, "does not rebind existing wallets") +} + +func TestVaultProviderConfigUpdateRequiresAField(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request to %s", r.URL.Path) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "provider-configs", "update", "my-link-client") + require.ErrorContains(t, err, "nothing to update") + // An empty secret would otherwise read as "clear it", which the API cannot do. + _, _, err = executeVaultCommand(t, client, "vaults", "provider-configs", "update", "my-link-client", "--client-secret", "") + require.ErrorContains(t, err, "must not be empty") +} + +func TestVaultProviderConfigDelete(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/vault-provider-configs/my-link-client", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "provider-configs", "delete", "my-link-client", "-y") + require.NoError(t, err) + assert.Contains(t, human, "Deleted provider configuration: my-link-client") + assert.Contains(t, human, "still exists") +} + +func TestVaultProviderConfigDeleteConflictIsSurfaced(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"code":"conflict","message":"vault items still reference this configuration"}`) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "provider-configs", "delete", "my-link-client", "-y") + require.ErrorContains(t, err, "vault items still reference this configuration") +} + +func TestVaultProviderConfigNotFoundDeleteIsQuiet(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"code":"not_found","message":"no such configuration"}`) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "provider-configs", "delete", "gone", "-y") + require.NoError(t, err) + assert.Contains(t, human, "not found") +} + +// Wallet specs select a configuration by id or name only; the JSON projection +// must keep that reference and still drop unknown provider data around it. +func TestVaultWalletProviderConfigIsDisplayed(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + const fixture = `{"id":"wallet-id","key":"wallet-1","type":"wallet","spec":{"provider":"agentcard","user_id":"usr_1","provider_config":{"id":"vpc-ac-1","name":"my-agentcard","opaque":"drop-me"}},"state":{"provider":"agentcard","status":"connected"},"available_operations":[],"available_expansions":[]}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "wallet-1", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"vpc-ac-1"`) + assert.Contains(t, out, `"my-agentcard"`) + assert.NotContains(t, out, "drop-me") +} + +func TestVaultWalletSpecHelpDocumentsProviderConfig(t *testing.T) { + cmd, _, err := newVaultsCommand().Find([]string{"wallets", "create"}) + require.NoError(t, err) + assert.Contains(t, cmd.Long, "provider_config?:") + assert.Contains(t, cmd.Long, "vaults provider-configs") + // Importing a Link grant means handling OAuth tokens, which this surface + // never accepts. + assert.Contains(t, cmd.Long, "must never be passed to the CLI") + assert.NotContains(t, cmd.Long, "access_token") +} diff --git a/cmd/vaults.go b/cmd/vaults.go index ebbe121d..02c723e9 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -175,7 +175,7 @@ func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, e return nil } -func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key string, spec kernel.WalletVaultItemSpecUnionParam, output string, open bool) error { +func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key string, spec kernel.VaultItemUpsertParamsBodyWalletSpecUnion, output string, open bool) error { item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfWallet: &kernel.VaultItemUpsertParamsBodyWallet{Spec: spec}}, option.WithMaxRetries(0)) if err != nil { return util.CleanedUpSdkError{Err: err} diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index d98088f5..ac732b46 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -160,7 +160,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d return err } open, _ := cmd.Flags().GetBool("open") - return getVaultsHandler(cmd).CreateWallet(cmd.Context(), args[0], args[1], param.Override[kernel.WalletVaultItemSpecUnionParam](spec), vaultOutput(cmd), open) + return getVaultsHandler(cmd).CreateWallet(cmd.Context(), args[0], args[1], param.Override[kernel.VaultItemUpsertParamsBodyWalletSpecUnion](spec), vaultOutput(cmd), open) }} addVaultSpecFlags(walletCreate) walletCreate.Flags().Bool("open", false, "Open the returned HTTPS connection/enrollment URL") @@ -176,7 +176,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d cards := &cobra.Command{Use: "cards", Short: "Configure card requests"} cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) - cmd.AddCommand(items, wallets, cards) + cmd.AddCommand(items, wallets, cards, newVaultProviderConfigsCommand()) return cmd } diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 989c97e0..d7f0dde5 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -23,8 +23,21 @@ type LinkWalletSpec = { type AgentCardWalletSpec = { provider: "agentcard"; - user_id?: string; // usr_...; already enrolled in this organization + user_id?: string; // usr_...; already enrolled under the same configuration + provider_config?: { // register with vaults provider-configs; select by + id?: string; // either id or name. Omit provider_config to use + name?: string; // Kernel-managed credentials + }; }; + +A wallet's configuration and provider binding are fixed at creation: it cannot be +moved to a different configuration later, and renaming one does not rebind it. +Omitting user_id returns a hosted enrollment action for the user to complete. + +Link wallets on your own OAuth client are created by importing an existing grant's +access and refresh tokens. Those tokens must never be passed to the CLI; create +such wallets from your backend instead. Only the kernel_managed client shown above +is supported here. ` const vaultCardSpecHelp = ` diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index fba1d132..8253c5f5 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -24,6 +24,14 @@ func vaultFieldsOf(names string) vaultOutputFields { } var vaultFields = vaultFieldsOf("id name created_at updated_at") + +// Provider configurations never return their secret credentials, so every +// documented field is display-safe. +var vaultProviderConfigFields = vaultFieldsOf("id name provider client_id test_mode created_at updated_at") + +// A wallet spec names its provider configuration by ID and name only; secrets +// stay on the configuration. +var vaultProviderConfigRefFields = vaultFieldsOf("id name") var vaultOperationFields = vaultFieldsOf("type description") var vaultTotalFields = vaultFieldsOf("type display_text amount") var vaultMethodFields = vaultOutputFields{ @@ -41,8 +49,9 @@ var vaultItemFields = vaultOutputFields{ "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "context": nil, "expires_at": nil, - "authorization": {"method": nil, "client": vaultFieldsOf("type")}, - "totals": vaultTotalFields, + "provider_config": vaultProviderConfigRefFields, + "authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultProviderConfigRefFields}}, + "totals": vaultTotalFields, "line_items": { "name": nil, "quantity": nil, "unit_amount": nil, "description": nil, "sku": nil, "url": nil, "image_url": nil, "product_url": nil, "totals": vaultTotalFields, diff --git a/go.mod b/go.mod index 3a43f544..f491d480 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.100.1-0.20260910213034-9d9ffcf0053d + github.com/kernel/kernel-go-sdk v0.100.1-0.20260911125613-cd47c643d938 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 4a68fbed..5507ef15 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.100.1-0.20260910213034-9d9ffcf0053d h1:00ier5v1Ia8peo3LfFTPhtMs0+MQQcF6+Qmqt4gZmnY= -github.com/kernel/kernel-go-sdk v0.100.1-0.20260910213034-9d9ffcf0053d/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260911125613-cd47c643d938 h1:MMNkdPnDjIBm202MgFvB0OG38772CQcBfNxpH3PJmAc= +github.com/kernel/kernel-go-sdk v0.100.1-0.20260911125613-cd47c643d938/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= From e34ca1911b046db8a55d737ba6c51808bcaa6758 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:57:49 +0000 Subject: [PATCH 38/51] chore: update Go SDK to v0.101.0 (68050bf) Bumps github.com/kernel/kernel-go-sdk to v0.101.0. This release contains no API surface changes -- api.md is byte-identical to the previous pin. The only changes are internal to browser routing: - Direct-to-VM routing now covers the "fs" and "logs/stream" path prefixes in addition to curl/telemetry/computer/playwright/process. - Stale direct-VM auth responses now evict the cached route before the control-plane fallback decision, so a non-replayable body surfaces the auth failure instead of sending a truncated request. Coverage analysis: a full enumeration of all 163 SDK methods in api.md against the 173 CLI commands found no gaps. Every method has a CLI command except the 5 config-registry endpoints marked x-cli-skip, and every param field maps to an existing flag. Tested against the production API (go vet + go test ./... all pass): - browsers fs list-files / write-file / read-file / file-info / move / delete-file / new-directory / upload / download-dir-zip / set-permissions / delete-directory / watch start / watch stop -- all exercise the newly direct-to-VM "fs" prefix and succeed. - browsers logs stream --source supervisor returns "logs source not available" on this browser image; verified identical on a CLI built against the previous SDK pin, so it is pre-existing server behavior and not a routing regression. - browsers create / delete for setup and cleanup. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f491d480..16efd6b8 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.100.1-0.20260911125613-cd47c643d938 + github.com/kernel/kernel-go-sdk v0.101.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 5507ef15..3661f19f 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.100.1-0.20260911125613-cd47c643d938 h1:MMNkdPnDjIBm202MgFvB0OG38772CQcBfNxpH3PJmAc= -github.com/kernel/kernel-go-sdk v0.100.1-0.20260911125613-cd47c643d938/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.101.0 h1:1Fj1kWosiWOH2C+P1/DspUl+2i0nR4tlvr37HRnndFY= +github.com/kernel/kernel-go-sdk v0.101.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= From 4eb79c91dfac7130c4d3f22e10e4be7b5b7c94c7 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:58:37 +0000 Subject: [PATCH 39/51] chore: update Go SDK to 86e3d38 (document punctuation key sequences) Bumps github.com/kernel/kernel-go-sdk from v0.101.0 to v0.101.1-0.20260911144911-86e3d38ceac4. The SDK change is documentation-only: BrowserComputerPressKeyParams.Keys and BrowserComputerBatchParamsActionPressKey.Keys now document that punctuation in chords should use X11 names (Ctrl+minus, Ctrl+plus), and that a literal hyphen is accepted as an alias (Ctrl+- normalizes to Ctrl+minus). Mirrored that guidance into the `browsers computer press-key` --key flag help. Coverage analysis: full enumeration of all 163 SDK methods in api.md against the 211-command CLI tree. The 5 config-registry methods are marked x-cli-skip in openapi.yaml; the remaining 158 all have CLI commands. Param-field sweep over every *Params struct found no missing flags -- remaining differences are positional args, intentionally flattened nested structs (proxy config, browser/telemetry config), or single-value enums hardcoded by the CLI (audit log export type/format). No new commands or flags were needed. Tested against the live API: browsers create; browsers computer press-key with --key Ctrl+minus, Ctrl+-, Ctrl+plus, and Return (all succeeded); browsers delete. go build ./..., go vet ./..., and go test ./... all pass. Co-Authored-By: Claude Opus 5 --- cmd/browsers.go | 2 +- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 6c37c9bd..51b43164 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -2921,7 +2921,7 @@ func init() { // computer press-key computerPressKey := &cobra.Command{Use: "press-key ", Short: "Press one or more keys", Args: cobra.ExactArgs(1), RunE: runBrowsersComputerPressKey} - computerPressKey.Flags().StringSlice("key", []string{}, "One X11 keysym or chord per value, e.g. Return, Ctrl+t, or Ctrl+minus (repeatable)") + computerPressKey.Flags().StringSlice("key", []string{}, "One X11 keysym or chord per value, e.g. Return, Ctrl+t, or Ctrl+minus. Use X11 names for punctuation in chords; a literal hyphen is accepted as an alias, so Ctrl+- is normalized to Ctrl+minus (repeatable)") _ = computerPressKey.MarkFlagRequired("key") computerPressKey.Flags().Int64("duration", 0, "Duration to hold keys down in ms (0=tap)") computerPressKey.Flags().StringSlice("hold-key", []string{}, "Modifier keys to hold (repeatable)") diff --git a/go.mod b/go.mod index 16efd6b8..9e0a33b6 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.101.0 + github.com/kernel/kernel-go-sdk v0.101.1-0.20260911144911-86e3d38ceac4 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 3661f19f..8abfa9e4 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.101.0 h1:1Fj1kWosiWOH2C+P1/DspUl+2i0nR4tlvr37HRnndFY= -github.com/kernel/kernel-go-sdk v0.101.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.101.1-0.20260911144911-86e3d38ceac4 h1:xeqK6doaXXq5KW62KQQJgUOGznaQ3ZKPI6+Mw+T/NJg= +github.com/kernel/kernel-go-sdk v0.101.1-0.20260911144911-86e3d38ceac4/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= From d769a08f9fa1007da20bb38d4f45df8ccb04e9af Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:17:07 +0000 Subject: [PATCH 40/51] chore: update Go SDK to b22a63c and fix vault provider config merge fallout Bumps github.com/kernel/kernel-go-sdk to v0.101.1-0.20260911180643-b22a63c49fc3 (b22a63c). The only SDK change since the CLI's previous pin (v0.101.0) is the re-addition of the `guidance` response field to ConfigRegistryResponse and LookupResponse. All six /config-registry endpoints are marked x-cli-skip in openapi.yaml and the CLI exposes no config-registry commands, so the field needs no CLI surface. No new methods and no new request param fields were introduced. Also repairs merge fallout from 621a5f3. Main added root-level vault-provider-configs commands in #248 while this branch had added its own copy, leaving the package unbuildable: - cmd/vaults_output.go declared a second vaultProviderConfigFields, duplicating main's declaration in cmd/vault_provider_configs.go (redeclaration compile error). Removed the branch copy; main's is canonical and is what the command file uses. - cmd/vaults_output.go declared vaultProviderConfigRefFields, which no code referenced. Removed as dead code. - cmd/vaults_commands.go registered newVaultProviderConfigsCommand() under `vaults`, so the command appeared both at the root and nested. Removed the nesting; root-level `kernel vault-provider-configs` is the path covered by vault_provider_configs_test.go and offset_pagination_test.go. Coverage analysis: enumerated all 163 SDK methods from api.md against the full 213-command cobra tree. The 5 config-registry methods are x-cli-skip and correctly absent; every other method has a CLI command (including ones under non-obvious names: BrowserWebmcp.ListTools -> `browsers webmcp list`, OrganizationEntitlement.Get -> `org entitlements`, AuthContext.Get -> `auth context`). A field sweep of all 117 *Params structs found 111 referenced by the CLI and accounted for the rest: 4 are config-registry, BrowserCurlParams is served by the raw curl client (`browsers curl --max-time`), and AuditLogDownloadParams is served by the chunked `audit-logs download` implementation built on AuditLogExportChunkParams. Tested: go build ./..., go vet ./..., go test ./... (all pass), and against the live API: `vault-provider-configs list`, `vaults list`, `browsers list`, and a `browsers create` -> `get` -> `delete` round trip. Verified `vaults --help` no longer lists the nested duplicate. Co-Authored-By: Claude Opus 5 --- cmd/vaults_commands.go | 2 +- cmd/vaults_output.go | 8 -------- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index f9e0b3fb..0d8be4a3 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -182,7 +182,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d cards := &cobra.Command{Use: "cards", Short: "Configure card requests"} cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) - cmd.AddCommand(items, wallets, cards, newVaultProviderConfigsCommand()) + cmd.AddCommand(items, wallets, cards) return cmd } diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index f2aac579..3262007c 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -24,14 +24,6 @@ func vaultFieldsOf(names string) vaultOutputFields { } var vaultFields = vaultFieldsOf("id name created_at updated_at") - -// Provider configurations never return their secret credentials, so every -// documented field is display-safe. -var vaultProviderConfigFields = vaultFieldsOf("id name provider client_id test_mode created_at updated_at") - -// A wallet spec names its provider configuration by ID and name only; secrets -// stay on the configuration. -var vaultProviderConfigRefFields = vaultFieldsOf("id name") var vaultOperationFields = vaultFieldsOf("type description") var vaultTotalFields = vaultFieldsOf("type display_text amount") var vaultMethodFields = vaultOutputFields{ diff --git a/go.mod b/go.mod index 16efd6b8..bb3bd7da 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.101.0 + github.com/kernel/kernel-go-sdk v0.101.1-0.20260911180643-b22a63c49fc3 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 3661f19f..0a5d4d4c 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.101.0 h1:1Fj1kWosiWOH2C+P1/DspUl+2i0nR4tlvr37HRnndFY= -github.com/kernel/kernel-go-sdk v0.101.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.101.1-0.20260911180643-b22a63c49fc3 h1:HU4bBlS4/ZMXg8ePPiqvvzRbJJqswPJuQSGHPmM2V6c= +github.com/kernel/kernel-go-sdk v0.101.1-0.20260911180643-b22a63c49fc3/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= From efbffa907709dee5163d47910b4ecc9426dd3559 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:58:19 +0000 Subject: [PATCH 41/51] chore: update Go SDK to 410bbabd and align vault recovery guidance Update kernel-go-sdk to 410bbabd91ea07f7df07a72ffacbb872b0d6d2f1. Full enumeration of api.md (163 methods, 5 config-registry methods marked x-cli-skip) against the CLI command tree found no missing commands, and no SDK param field lacks a corresponding flag. The SDK change is a policy change to AgentCard checkout recovery: an unresolved checkout whose create response returned no authorization ID may now be abandoned by deleting that card directly, while deleting its wallet or vault stays blocked. The CLI's own guidance still told users never to delete or replace a recovery_required item, which now contradicts the API. - effectiveVaultItemActions reports Abandonable for an AgentCard card in recovery_required with no authorization ID - items get / list human output, vaults items invoke error, items get help, and vaults items delete help now state the branch that applies - cover both branches in vaults_policy_test.go Tested: go build, go vet, go test ./... all pass; smoke-tested against the real API with vaults list, vaults items list, vaults items get (agentcard card), browsers list, auth context, and vaults items delete --help. Co-Authored-By: Claude Opus 5 --- cmd/vaults.go | 5 ++++- cmd/vaults_commands.go | 10 ++++++++-- cmd/vaults_output.go | 6 +++++- cmd/vaults_policy.go | 18 +++++++++++++----- cmd/vaults_policy_test.go | 27 +++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 7 files changed, 60 insertions(+), 12 deletions(-) diff --git a/cmd/vaults.go b/cmd/vaults.go index 6250948c..9f2fc45a 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -214,7 +214,10 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation, output str return err } if actions.RecoveryRequired { - return fmt.Errorf("recovery_required: reconcile the original operation with the provider or support; do not retry, delete, or replace it") + if actions.Abandonable { + return fmt.Errorf("recovery_required: automatic reuse is blocked; no authorization ID was returned, so delete this card explicitly to abandon the attempt and create a replacement") + } + return fmt.Errorf("recovery_required: reconcile the known authorization ID with the provider or support; do not retry, delete, or replace it") } available := false for _, op := range actions.Operations { diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 0d8be4a3..61002475 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -111,7 +111,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d }} addVaultJSONOutputFlag(itemList) itemGet := &cobra.Command{Use: "get ", Short: "Get item state and any required action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: "Get item state, available operations, provider actions, and returned checkout aliases.\n--wait is a single bounded server-side observation, not a retry or a guarantee of readiness.\nAn item still pending after the wait is returned as-is; ready does not mean paid.\nrecovery_required stops waiting and means unresolved, not declined or expired.\nReconcile with the provider or support; do not retry, delete, or replace the payment.", + Long: "Get item state, available operations, provider actions, and returned checkout aliases.\n--wait is a single bounded server-side observation, not a retry or a guarantee of readiness.\nAn item still pending after the wait is returned as-is; ready does not mean paid.\nrecovery_required stops waiting and means unresolved, not declined or expired.\nA known authorization ID must be reconciled with the provider or support; do not retry, delete, or replace the payment.\nAn AgentCard checkout that returned no authorization ID may be abandoned by deleting that card explicitly, which is not proof that the payment did not occur.", RunE: func(cmd *cobra.Command, args []string) error { wait, _ := cmd.Flags().GetInt64("wait") expand, _ := cmd.Flags().GetStringSlice("expand") @@ -188,10 +188,16 @@ JSON output preserves returned public fields but omits unknown/opaque provider d func newVaultDeleteCommand(item bool) *cobra.Command { use, short, nargs := "delete ", "Delete a vault and invalidate all its items", 1 + long := short + ".\nUnresolved payment operations block deletion, including operations on child cards of a wallet." if item { use, short, nargs = "delete ", "Delete an item and invalidate its credential", 2 + long = short + `. +Unresolved payment operations normally block deletion. An AgentCard checkout whose +create response returned no authorization ID may be abandoned by deleting that card +directly, so a replacement can be created; deleting its wallet or vault stays blocked. +Deleting or recreating an item is not proof that a payment did not occur.` } - cmd := &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(nargs), PreRunE: vaultPreRun, + cmd := &cobra.Command{Use: use, Short: short, Long: long, Args: cobra.ExactArgs(nargs), PreRunE: vaultPreRun, RunE: func(cmd *cobra.Command, args []string) error { key := "" if item { diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 3262007c..28ce79f6 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -280,7 +280,11 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemActions) { if actions.RecoveryRequired { - pterm.Warning.Println("recovery_required: the original operation is unresolved, not declined or expired. Do not retry, delete, or replace it. Reconcile with the provider or support; no reset operation exists.") + if actions.Abandonable { + pterm.Warning.Println("recovery_required: the original operation is unresolved, not declined or expired. Automatic reuse is blocked and no reset operation exists. No authorization ID was returned, so deleting this card explicitly abandons the attempt and lets you create a replacement; deletion is not proof that the payment did not occur. Deleting its wallet or vault stays blocked.") + return + } + pterm.Warning.Println("recovery_required: the original operation is unresolved, not declined or expired. Do not retry, delete, or replace it. Reconcile the known authorization ID with the provider or support; no reset operation exists.") return } if item.Type == "wallet" && item.Spec.Provider == "link" && item.Spec.Authorization.Client.Type == "customer_managed" && item.State.Status == "degraded" { diff --git a/cmd/vaults_policy.go b/cmd/vaults_policy.go index d235c12a..6f21b2ed 100644 --- a/cmd/vaults_policy.go +++ b/cmd/vaults_policy.go @@ -14,17 +14,25 @@ type vaultItemOperation struct { type vaultItemActions struct { RecoveryRequired bool - RequiredAction string - ActionURL string - ApprovalURL string - Operations []vaultItemOperation + // Abandonable is true when an unresolved AgentCard checkout returned no + // authorization ID. The API lets that card be deleted explicitly to abandon + // the attempt so a replacement can be created; its wallet and vault stay + // blocked, and deletion is not proof that the payment did not occur. + Abandonable bool + RequiredAction string + ActionURL string + ApprovalURL string + Operations []vaultItemOperation } // Execution and human output use this policy; JSON preserves the API-advertised // fields through the separate display-safe projection. func effectiveVaultItemActions(item *kernel.VaultItemUnion) (vaultItemActions, error) { if item.State.Status == "recovery_required" { - return vaultItemActions{RecoveryRequired: true}, nil + return vaultItemActions{ + RecoveryRequired: true, + Abandonable: item.Type == "card" && item.State.Provider == "agentcard" && item.State.Authorization.ID == "", + }, nil } var fields struct { Operations []vaultItemOperation `json:"available_operations"` diff --git a/cmd/vaults_policy_test.go b/cmd/vaults_policy_test.go index 2b364fec..d8a1ea54 100644 --- a/cmd/vaults_policy_test.go +++ b/cmd/vaults_policy_test.go @@ -58,3 +58,30 @@ func TestVaultRecoveryActionDisplayPolicy(t *testing.T) { } } } + +// An unresolved AgentCard checkout that returned no authorization ID may be +// abandoned by deleting that card; one with a known authorization ID may not. +func TestVaultRecoveryAbandonmentGuidance(t *testing.T) { + const abandonGuidance = "deleting this card explicitly abandons the attempt" + const reconcileGuidance = "Do not retry, delete, or replace it" + for _, tc := range []struct { + name string + authorization string + wants, avoids string + }{ + {"no-authorization-id", "", abandonGuidance, reconcileGuidance}, + {"known-authorization-id", `,"authorization":{"id":"cauth_test","status":"awaiting_approval","psp":"stripe","merchant":"Example Shop","amount_cents":1234,"currency":"usd"}`, reconcileGuidance, abandonGuidance}, + } { + t.Run(tc.name, func(t *testing.T) { + body := `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop"},"state":{"provider":"agentcard","status":"recovery_required"` + tc.authorization + `},"available_operations":[],"available_expansions":[]}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + _, human, err := executeVaultInputCommand(t, client, "", "vaults", "items", "get", "checkout", "order-1") + require.NoError(t, err) + assert.Contains(t, human, tc.wants) + assert.NotContains(t, human, tc.avoids) + }) + } +} diff --git a/go.mod b/go.mod index bb3bd7da..e4ae54d5 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.101.1-0.20260911180643-b22a63c49fc3 + github.com/kernel/kernel-go-sdk v0.101.1-0.20260911184826-410bbabd91ea 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 0a5d4d4c..eabcdf2a 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.101.1-0.20260911180643-b22a63c49fc3 h1:HU4bBlS4/ZMXg8ePPiqvvzRbJJqswPJuQSGHPmM2V6c= -github.com/kernel/kernel-go-sdk v0.101.1-0.20260911180643-b22a63c49fc3/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.101.1-0.20260911184826-410bbabd91ea h1:rG6wjQGFwFFZj3kXuH07wta128ex7R0h8RQasBlgv4c= +github.com/kernel/kernel-go-sdk v0.101.1-0.20260911184826-410bbabd91ea/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= From 4ade346e1d738f5b2638bfb6086da74b32a1aae8 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:27:01 +0000 Subject: [PATCH 42/51] chore: update Go SDK to dc39717 (config registry recommendation union) Bumps github.com/kernel/kernel-go-sdk to v0.101.1-0.20260911211824-dc397177716f (commit dc397177716f). The only SDK change since 410bbabd is LookupResponse.Recommendation widening from Recommendation to RecommendationResultUnion in configregistry.go. Every /config-registry endpoint is marked x-cli-skip in openapi.yaml and the CLI does not reference the resource, so no CLI changes are required. Full enumeration of api.md (163 methods) against the CLI command tree (211 commands) found no missing commands and no missing flags. All 5 uncovered SDK methods are the x-cli-skip config-registry endpoints. Tested: go build ./..., go vet ./..., go test ./... (all pass); kernel browsers list and kernel profiles list against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e4ae54d5..2bce83d1 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.101.1-0.20260911184826-410bbabd91ea + github.com/kernel/kernel-go-sdk v0.101.1-0.20260911211824-dc397177716f 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 eabcdf2a..52b397a5 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.101.1-0.20260911184826-410bbabd91ea h1:rG6wjQGFwFFZj3kXuH07wta128ex7R0h8RQasBlgv4c= -github.com/kernel/kernel-go-sdk v0.101.1-0.20260911184826-410bbabd91ea/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.101.1-0.20260911211824-dc397177716f h1:OVs9HzxcS0O0z6WHXbayxKfe+pART2QvzbW1S2f0Tqg= +github.com/kernel/kernel-go-sdk v0.101.1-0.20260911211824-dc397177716f/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= From 5a762d8f815a345a96dbf824090d272a72ee08e8 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:59:37 +0000 Subject: [PATCH 43/51] chore: update Go SDK to b4278bb and add vault card fill Updates kernel-go-sdk to b4278bbc29928b8ef3ba3eadd0927438faeba5e9, which turns the vault item operations endpoint into a request/response union: the new fill operation writes selected card fields into a page open in a browser and returns a value-free per-field result instead of the item. - `kernel vaults items invoke fill` gains --browser-id, --page-url, repeatable --field = (expiration takes :MM/YY or :MM/YYYY), and --timeout-ms, validated locally against the API limits before any request. - Fill results print per-field status and error codes labelled by the request bindings, with guidance that fill is not payment and must not be auto-retried. - Operations other than fill keep sending {"type":""} and keep rendering the updated item, which the response union now wraps. - items get hints show the flags fill needs; vaults help prefers fill over aliases. Tested against the production API: vaults create/list/get, items list/get/events, browsers create --vault plus delete, the fill advertisement gate (409-style client rejection), a real 404 from fill on a missing item, and every client-side --field, --page-url, --browser-id, and --timeout-ms validation path. The fill POST itself could not be exercised end to end: no item in the account advertises fill, which requires an authorized, unexpired Link card, and creating one means a real spend authorization awaiting human approval. Request and response handling for fill is covered by unit tests built from the OpenAPI examples. Co-Authored-By: Claude Opus 5 --- cmd/vaults.go | 31 ++++- cmd/vaults_commands.go | 48 +++++++- cmd/vaults_fill.go | 230 ++++++++++++++++++++++++++++++++++++++ cmd/vaults_fill_test.go | 177 +++++++++++++++++++++++++++++ cmd/vaults_invoke_test.go | 2 +- cmd/vaults_output.go | 8 +- go.mod | 2 +- go.sum | 4 +- 8 files changed, 489 insertions(+), 13 deletions(-) create mode 100644 cmd/vaults_fill.go create mode 100644 cmd/vaults_fill_test.go diff --git a/cmd/vaults.go b/cmd/vaults.go index 9f2fc45a..7fec9b8d 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -13,6 +14,7 @@ import ( "github.com/kernel/cli/pkg/util" kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/param" "github.com/pterm/pterm" ) @@ -201,10 +203,15 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. return c.showItem(item, output, false) } -func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation, output string, open bool) error { +// fill carries the parameters of the fill operation; every other advertised +// operation is invoked by type alone. +func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation, output string, open bool, fill *vaultFillRequest) error { if strings.TrimSpace(operation) == "" { return fmt.Errorf("operation must not be empty") } + if operation == vaultFillOperation && fill == nil { + return fmt.Errorf("fill requires --browser-id, --page-url, and at least one --field =") + } item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -232,11 +239,29 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation, output str if !available { return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } - item, err = c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, Type: kernel.VaultItemPerformOperationParamsType(operation)}, option.WithMaxRetries(0)) + params := kernel.VaultItemPerformOperationParams{IDOrName: vault} + if fill != nil { + body := fill.params() + params.OfFill = &body + } else { + // Operations other than fill are advertised by type only; forward the + // advertised type as-is so newly advertised operations keep working. + params = param.Override[kernel.VaultItemPerformOperationParams](map[string]string{"type": operation}) + params.IDOrName = vault + } + response, err := c.vaults.Items.PerformOperation(ctx, key, params, option.WithMaxRetries(0)) if err != nil { return util.CleanedUpSdkError{Err: err} } - return c.showItem(item, output, open) + if response.Type == vaultFillOperation { + return printVaultFillResult(response.AsFillVaultItemOperationResult(), fill, output) + } + // Every other operation returns the updated item. + var updated kernel.VaultItemUnion + if err := json.Unmarshal([]byte(response.RawJSON()), &updated); err != nil { + return fmt.Errorf("invalid vault item response") + } + return c.showItem(&updated, output, open) } func (c VaultsCmd) Events(ctx context.Context, vault, key, after string, wait int64, output string) error { diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 61002475..282b2816 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -63,8 +63,10 @@ Vault names, item keys, and project ownership are immutable. 3. Create a card request with --provider and --spec JSON. 4. Inspect items get, then use items invoke only when advertised. Follow the operation description and any returned provider action. -5. Attach the vault with browsers create --vault . Use only returned - non-secret aliases in that browser. Inspect items get/events for the outcome. +5. Attach the vault with browsers create --vault . Prefer items invoke + fill to write card fields into a page in that browser; returned + non-secret aliases remain an alternative for egress-substitution integrations. + Inspect items get/events for the outcome. Permitted checkout domains are provider-assigned and displayed when returned; there is no domain-setting API. @@ -133,13 +135,49 @@ JSON output preserves returned public fields but omits unknown/opaque provider d itemEvents.Flags().Int64("wait", 0, "Long-poll once for new events (0-60 seconds)") addVaultJSONOutputFlag(itemEvents) invoke := &cobra.Command{Use: "invoke ", Short: "Invoke an operation advertised by an item", Args: cobra.ExactArgs(3), PreRunE: vaultPreRun, - Long: "Retrieve the item and invoke only an operation listed in available_operations.\nRead its description with items get before invoking; follow any approval requirements.\nThe API determines availability regardless of item type, provider, or state.\nRequests are not automatically retried. The updated item may contain a required user action.\nThe current API accepts only {\"type\":\"authorize\"}; there are no operation parameters or --spec flag.", - Example: " kernel vaults items get checkout order-1\n kernel vaults items invoke checkout order-1 authorize", + Long: `Retrieve the item and invoke only an operation listed in available_operations. +Read its description with items get before invoking; follow any approval requirements. +The API determines availability regardless of item type, provider, or state. +Requests are not automatically retried. The updated item may contain a required user action. +There is no --spec flag. Operations other than fill take no parameters and send only +{"type":"authorize"}-style bodies; authorize returns the updated item. + +fill writes selected fields of one ready Link card into a page open in a browser that +has this vault attached, and returns a value-free per-field result instead of an item. +The browser and the vault must belong to the same project, and --page-url must match +exactly one open page; prefixes and globs never match. Fields are written in the order +given and execution stops at the first failure without rolling earlier fields back, so +never automatically retry or fall back to aliases after a failed or unknown outcome. +Fill never submits the form. Secret values are never returned, but an agent with +unrestricted browser access can still read the filled values from the page. + +--field takes = and repeats, in fill order (at most 32): + number, exp_month, exp_year, cvc, billing_name, billing_line1, billing_line2, + billing_city, billing_state, billing_postal_code, billing_country + expiration:MM/YY or expiration:MM/YYYY for the combined expiration +Each selector must resolve to one unique editable input or select across all frames.`, + Example: ` kernel vaults items get checkout order-1 + kernel vaults items invoke checkout order-1 authorize + + kernel vaults items invoke checkout order-1 fill \ + --browser-id \ + --page-url https://shop.example/checkout \ + --field number='#card-number' \ + --field 'expiration:MM/YY=#expiry' \ + --field cvc='#security-code'`, RunE: func(cmd *cobra.Command, args []string) error { + fill, err := vaultFillFromFlags(cmd, args[2]) + if err != nil { + return err + } open, _ := cmd.Flags().GetBool("open") - return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], vaultOutput(cmd), open) + return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], vaultOutput(cmd), open, fill) }} invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") + invoke.Flags().String("browser-id", "", "fill: browser session ID holding the page, not a reusable browser name") + invoke.Flags().String("page-url", "", "fill: exact current HTTPS page URL, including path, query, and fragment") + invoke.Flags().StringArray("field", nil, "fill: repeatable = binding, applied in the given order") + invoke.Flags().Int64("timeout-ms", 0, "fill: total operation deadline in milliseconds (1-30000; API default 10000)") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go new file mode 100644 index 00000000..566cc76e --- /dev/null +++ b/cmd/vaults_fill.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// Keep these limits in sync with https://api.onkernel.com/spec.yaml. +const ( + vaultFillOperation = "fill" + vaultFillExpirationField = "expiration" + vaultFillMaxFields = 32 + vaultFillMaxTimeoutMs = 30000 +) + +var vaultFillStoredFields = []string{ + "number", "exp_month", "exp_year", "cvc", "billing_name", "billing_line1", + "billing_line2", "billing_city", "billing_state", "billing_postal_code", "billing_country", +} + +var vaultFillExpirationFormats = []string{"MM/YY", "MM/YYYY"} + +var vaultFillFlags = []string{"browser-id", "page-url", "field", "timeout-ms"} + +// vaultFillField is one parsed --field binding. Bindings keep their command-line +// order because the API fills in request order and stops at the first failure. +type vaultFillField struct { + Field string + Format string + Selector string +} + +type vaultFillRequest struct { + BrowserID string + PageURL string + TimeoutMs int64 + Fields []vaultFillField +} + +// vaultFillFromFlags returns the fill request for the fill operation and nil for +// every other advertised operation, which takes no parameters. +func vaultFillFromFlags(cmd *cobra.Command, operation string) (*vaultFillRequest, error) { + if operation != vaultFillOperation { + for _, name := range vaultFillFlags { + if cmd.Flags().Changed(name) { + return nil, fmt.Errorf("--%s applies only to the fill operation", name) + } + } + return nil, nil + } + browserID, _ := cmd.Flags().GetString("browser-id") + pageURL, _ := cmd.Flags().GetString("page-url") + raw, _ := cmd.Flags().GetStringArray("field") + timeout, _ := cmd.Flags().GetInt64("timeout-ms") + request := &vaultFillRequest{BrowserID: strings.TrimSpace(browserID), PageURL: strings.TrimSpace(pageURL)} + // A zero value means "unset": the API applies its own default deadline. + if cmd.Flags().Changed("timeout-ms") { + if timeout < 1 { + return nil, vaultFillTimeoutError() + } + request.TimeoutMs = timeout + } + if err := request.validate(raw); err != nil { + return nil, err + } + return request, nil +} + +func vaultFillTimeoutError() error { + return fmt.Errorf("--timeout-ms must be between 1 and %d milliseconds for the whole operation", vaultFillMaxTimeoutMs) +} + +func (r *vaultFillRequest) validate(raw []string) error { + if r.BrowserID == "" { + return fmt.Errorf("fill requires --browser-id with a browser session ID, not a reusable browser name") + } + if err := validateVaultFillPageURL(r.PageURL); err != nil { + return err + } + if len(raw) == 0 { + return fmt.Errorf("fill requires at least one --field = binding") + } + if len(raw) > vaultFillMaxFields { + return fmt.Errorf("fill accepts at most %d --field bindings", vaultFillMaxFields) + } + if r.TimeoutMs < 0 || r.TimeoutMs > vaultFillMaxTimeoutMs { + return vaultFillTimeoutError() + } + seen := make(map[string]bool, len(raw)) + for _, value := range raw { + field, err := parseVaultFillField(value) + if err != nil { + return err + } + if seen[field.Selector] { + return fmt.Errorf("each --field must use a distinct selector; %q is repeated and no two bindings may resolve to the same element", field.Selector) + } + seen[field.Selector] = true + r.Fields = append(r.Fields, field) + } + return nil +} + +// The API requires an exact HTTPS page URL without embedded credentials and +// matches it against exactly one open page; prefixes and globs never match. +func validateVaultFillPageURL(value string) error { + invalid := fmt.Errorf("--page-url must be the exact current HTTPS page URL without embedded credentials") + if value == "" { + return fmt.Errorf("fill requires --page-url with the exact current top-level page URL") + } + if strings.ContainsAny(value, " \t\r\n*") { + return invalid + } + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Host == "" { + return invalid + } + return nil +} + +func parseVaultFillField(value string) (vaultFillField, error) { + // Selectors may contain '=' (for example input[name=card]), so only the first + // separator delimits the card field from its selector. + name, selector, found := strings.Cut(value, "=") + selector = strings.TrimSpace(selector) + if !found || selector == "" { + return vaultFillField{}, fmt.Errorf("--field must be =, for example --field number='#card-number'") + } + name, format, hasFormat := strings.Cut(strings.TrimSpace(name), ":") + field := vaultFillField{Field: name, Format: format, Selector: selector} + if name == vaultFillExpirationField { + if !hasFormat { + return vaultFillField{}, fmt.Errorf("expiration requires a format: use --field expiration:<%s>=", strings.Join(vaultFillExpirationFormats, "|")) + } + if !containsVaultFillValue(vaultFillExpirationFormats, format) { + return vaultFillField{}, fmt.Errorf("expiration format must be one of: %s", strings.Join(vaultFillExpirationFormats, ", ")) + } + return field, nil + } + if hasFormat { + return vaultFillField{}, fmt.Errorf("only expiration takes a format; drop %q from --field %s", format, name) + } + if !containsVaultFillValue(vaultFillStoredFields, name) { + return vaultFillField{}, fmt.Errorf("--field %q is not a card field; use one of: %s, %s:<%s>", name, + strings.Join(vaultFillStoredFields, ", "), vaultFillExpirationField, strings.Join(vaultFillExpirationFormats, "|")) + } + return field, nil +} + +func containsVaultFillValue(values []string, value string) bool { + for _, candidate := range values { + if candidate == value { + return true + } + } + return false +} + +func (r vaultFillRequest) params() kernel.FillVaultItemOperationRequestParam { + body := kernel.FillVaultItemOperationRequestParam{ + BrowserID: r.BrowserID, + PageURL: r.PageURL, + Type: kernel.FillVaultItemOperationRequestTypeFill, + } + if r.TimeoutMs != 0 { + body.TimeoutMs = kernel.Opt(r.TimeoutMs) + } + for _, field := range r.Fields { + if field.Field == vaultFillExpirationField { + body.Fields = append(body.Fields, kernel.VaultCardFillFieldParamOfVaultCardFillFieldVaultCardExpirationFillField(field.Field, field.Format, field.Selector)) + continue + } + body.Fields = append(body.Fields, kernel.VaultCardFillFieldParamOfVaultCardFillFieldVaultCardStoredFillField(field.Field, field.Selector)) + } + return body +} + +var vaultFillResultFields = vaultOutputFields{ + "type": nil, "status": nil, "fields": vaultFieldsOf("index status error_code"), +} + +// Fill returns a value-free execution result instead of the item, so it is +// printed on its own. Request bindings are local and label each result row. +func printVaultFillResult(result kernel.FillVaultItemOperationResult, request *vaultFillRequest, output string) error { + raw, err := filterVaultJSON(json.RawMessage(result.RawJSON()), vaultFillResultFields) + if err != nil { + return err + } + if output == "json" { + return printVaultJSON(raw) + } + var safe kernel.FillVaultItemOperationResult + if err := json.Unmarshal(raw, &safe); err != nil { + return fmt.Errorf("invalid vault fill response") + } + rows := pterm.TableData{{"#", "Field", "Selector", "Status", "Error"}} + for _, field := range safe.Fields { + name, selector := "-", "-" + if request != nil && field.Index >= 0 && int(field.Index) < len(request.Fields) { + binding := request.Fields[int(field.Index)] + name, selector = binding.Field, binding.Selector + if binding.Format != "" { + name += " (" + binding.Format + ")" + } + } + rows = append(rows, []string{fmt.Sprint(field.Index), name, selector, string(field.Status), util.OrDash(string(field.ErrorCode))}) + } + PrintTableNoPad(rows, true) + printVaultFillGuidance(safe.Status) + return nil +} + +func printVaultFillGuidance(status kernel.FillVaultItemOperationResultStatus) { + switch status { + case kernel.FillVaultItemOperationResultStatusCompleted: + pterm.Success.Println("Fill completed: every requested field was written. Filling is not payment and does not confirm merchant acceptance; the form was not submitted.") + case kernel.FillVaultItemOperationResultStatusFailed: + pterm.Warning.Println("Fill failed: execution stopped at the first failed field and earlier fields were not rolled back. Do not automatically retry or fall back to aliases; inspect the page and items events before any explicit new attempt.") + default: + pterm.Warning.Println("Fill outcome unknown: at least one field's result could not be determined, which is not a retry signal. Do not automatically retry or fall back to aliases; reconcile with items events before any explicit new attempt.") + } + pterm.Info.Println("Secret values are never returned. An agent with unrestricted browser access can still read filled values from the page.") +} diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go new file mode 100644 index 00000000..a2f0ee83 --- /dev/null +++ b/cmd/vaults_fill_test.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const fillCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop"},"state":{"provider":"link","status":"ready"},"available_operations":[{"type":"fill","description":"Fill this card into a page open in a browser with this vault attached."}],"available_expansions":[]}` + +const fillCompletedFixture = `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"},{"index":1,"status":"filled"}]}` + +const fillFailedFixture = `{"type":"fill","status":"failed","fields":[{"index":0,"status":"filled"},{"index":1,"status":"failed","error_code":"ambiguous_selector"},{"index":2,"status":"not_attempted"}]}` + +// vaultFillServer answers the advertisement GET with a fillable card and records +// the body of the operations POST. +func vaultFillServer(t *testing.T, result string, body *string) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, fillCardFixture) + return + } + assert.Equal(t, "/vaults/checkout/items/order-1/operations", r.URL.Path) + raw, err := io.ReadAll(r.Body) + require.NoError(t, err) + *body = string(raw) + _, _ = io.WriteString(w, result) + } +} + +func TestVaultFillSendsBindingsInOrder(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + body := "" + client := vaultTestClient(t, vaultFillServer(t, fillCompletedFixture, &body)) + out, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", + "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout?step=pay", + "--field", "number=input[name=cardnumber]", "--field", "expiration:MM/YY=#expiry", + "--timeout-ms", "5000", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"type":"fill","browser_id":"browser-1","page_url":"https://shop.example/checkout?step=pay","timeout_ms":5000,"fields":[{"field":"number","selector":"input[name=cardnumber]"},{"field":"expiration","format":"MM/YY","selector":"#expiry"}]}`, body) + assert.JSONEq(t, fillCompletedFixture, out) + assert.Empty(t, human) +} + +func TestVaultFillOmitsUnsetTimeout(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + body := "" + client := vaultTestClient(t, vaultFillServer(t, fillCompletedFixture, &body)) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", + "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout", "--field", "cvc=#cvc", "-o", "json") + require.NoError(t, err) + assert.NotContains(t, body, "timeout_ms") +} + +func TestVaultFillPrintsPerFieldOutcomesAndGuidance(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + result string + contains []string + }{ + {fillCompletedFixture, []string{"Fill completed", "filled"}}, + {fillFailedFixture, []string{"ambiguous_selector", "not_attempted", "were not rolled back", "Do not automatically retry"}}, + } { + t.Run(tc.result, func(t *testing.T) { + body := "" + client := vaultTestClient(t, vaultFillServer(t, tc.result, &body)) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", + "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout", + "--field", "number=#card-number", "--field", "cvc=#cvc", "--field", "billing_postal_code=#zip") + require.NoError(t, err) + // Request bindings label each result row; the API never returns values. + assert.Contains(t, human, "#card-number") + assert.NotContains(t, human, "browser-1") + for _, want := range tc.contains { + assert.Contains(t, human, want) + } + }) + } +} + +func TestVaultFillRejectsInvalidInputBeforeCallingAPI(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + valid := []string{"--browser-id", "browser-1", "--page-url", "https://shop.example/checkout", "--field", "number=#card-number"} + for _, tc := range []struct { + args []string + message string + }{ + {[]string{"--page-url", "https://shop.example/checkout", "--field", "number=#n"}, "--browser-id"}, + {[]string{"--browser-id", "browser-1", "--field", "number=#n"}, "--page-url"}, + {[]string{"--browser-id", "browser-1", "--page-url", "http://shop.example/checkout", "--field", "number=#n"}, "exact current HTTPS page URL"}, + {[]string{"--browser-id", "browser-1", "--page-url", "https://user:pass@shop.example/checkout", "--field", "number=#n"}, "exact current HTTPS page URL"}, + {[]string{"--browser-id", "browser-1", "--page-url", "https://shop.example/*", "--field", "number=#n"}, "exact current HTTPS page URL"}, + {[]string{"--browser-id", "browser-1", "--page-url", "https://shop.example/checkout"}, "at least one --field"}, + {append(valid, "--field", "cardnumber=#n"), "is not a card field"}, + {append(valid, "--field", "expiration=#expiry"), "expiration requires a format"}, + {append(valid, "--field", "expiration:MM=#expiry"), "expiration format must be one of"}, + {append(valid, "--field", "cvc:MM/YY=#cvc"), "only expiration takes a format"}, + {append(valid, "--field", "#stray-selector"), "="}, + {append(valid, "--field", "cvc="), "="}, + {append(valid, "--field", "cvc=#card-number"), "distinct selector"}, + {append(valid, "--timeout-ms", "60000"), "--timeout-ms must be between"}, + {append(valid, "--timeout-ms", "0"), "--timeout-ms must be between"}, + } { + _, _, err := executeVaultCommand(t, client, append([]string{"vaults", "items", "invoke", "checkout", "order-1", "fill"}, tc.args...)...) + require.ErrorContains(t, err, tc.message) + } +} + +func TestVaultFillRejectsTooManyBindings(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + args := []string{"vaults", "items", "invoke", "checkout", "order-1", "fill", "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout"} + for i := 0; i <= vaultFillMaxFields; i++ { + args = append(args, "--field", "cvc=#field-"+strings.Repeat("x", i+1)) + } + _, _, err := executeVaultCommand(t, client, args...) + require.ErrorContains(t, err, "at most 32") +} + +func TestVaultFillFlagsRejectedForOtherOperations(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + for _, args := range [][]string{ + {"--browser-id", "browser-1"}, + {"--page-url", "https://shop.example/checkout"}, + {"--field", "number=#card-number"}, + {"--timeout-ms", "5000"}, + } { + _, _, err := executeVaultCommand(t, client, append([]string{"vaults", "items", "invoke", "checkout", "order-1", "authorize"}, args...)...) + require.ErrorContains(t, err, "applies only to the fill operation") + } +} + +func TestVaultFillRequiresAdvertisedOperation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", + "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout", "--field", "number=#card-number") + require.ErrorContains(t, err, `operation "fill" is not advertised`) + assert.Equal(t, 1, calls) +} + +func TestVaultFillHintIncludesRequiredFlags(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fillCardFixture) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1") + require.NoError(t, err) + assert.Contains(t, human, "Invoke: kernel vaults items invoke -- checkout order-1 fill --browser-id --page-url --field number=''") +} + +func TestVaultInvokeHelpDocumentsFill(t *testing.T) { + cmd, _, err := newVaultsCommand().Find([]string{"items", "invoke"}) + require.NoError(t, err) + for _, name := range vaultFillFlags { + assert.NotNil(t, cmd.Flags().Lookup(name), name) + } + assert.Contains(t, cmd.Long, "expiration:MM/YY") + assert.Contains(t, cmd.Long, "billing_postal_code") + assert.Contains(t, cmd.Long, "never automatically retry") + assert.Contains(t, cmd.Example, "--browser-id") +} diff --git a/cmd/vaults_invoke_test.go b/cmd/vaults_invoke_test.go index fa185098..e16cddc4 100644 --- a/cmd/vaults_invoke_test.go +++ b/cmd/vaults_invoke_test.go @@ -116,7 +116,7 @@ func TestVaultInvokeOpensOnlyReturnedActionExplicitly(t *testing.T) { c := VaultsCmd{vaults: &client.Vaults, openURL: func(url string) error { opened = url; return nil }} var err error out := captureStdout(t, func() { - err = c.Invoke(context.Background(), "checkout", "order-1", "authorize", "json", open) + err = c.Invoke(context.Background(), "checkout", "order-1", "authorize", "json", open, nil) }) require.NoError(t, err) assert.Equal(t, 2, calls) diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 28ce79f6..3f8ffe2d 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -195,7 +195,13 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s prefix += " --project=" + vaultShellArgument(project) } for _, op := range actions.Operations { - pterm.Printf("Invoke: %s -- %s %s %s\n", prefix, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) + hint := fmt.Sprintf("Invoke: %s -- %s %s %s", prefix, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) + if op.Type == vaultFillOperation { + // fill is the only operation that takes parameters; show them so the + // hint is runnable once the browser, page, and selectors are known. + hint += " --browser-id --page-url --field number=''" + } + pterm.Println(hint) } return nil } diff --git a/go.mod b/go.mod index 2bce83d1..849c7b3a 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.101.1-0.20260911211824-dc397177716f + github.com/kernel/kernel-go-sdk v0.101.1-0.20260913224554-b4278bbc2992 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 52b397a5..e68f4079 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.101.1-0.20260911211824-dc397177716f h1:OVs9HzxcS0O0z6WHXbayxKfe+pART2QvzbW1S2f0Tqg= -github.com/kernel/kernel-go-sdk v0.101.1-0.20260911211824-dc397177716f/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.101.1-0.20260913224554-b4278bbc2992 h1:9AAklY1/rRJI+92zCjHcIc1A8Cd0Mkww8TQQqPnel/8= +github.com/kernel/kernel-go-sdk v0.101.1-0.20260913224554-b4278bbc2992/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= From 671581fe087b2f3c210132f6dec2ab5d592e1496 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" Date: Mon, 14 Sep 2026 12:55:07 +0000 Subject: [PATCH 44/51] chore: update Go SDK to v0.102.0 (a923b20) Updates github.com/kernel/kernel-go-sdk to a923b20e03db557daeb3ed6f62750a0499203273 (released as v0.102.0). The SDK change between the CLI's previous pin (b4278bb) and a923b20 is release metadata only (CHANGELOG, README, .release-please-manifest.json, internal/version.go). No API surface changed, so no new commands or flags were required. Coverage analysis: enumerated all 163 methods in the SDK's api.md against the full CLI command tree. Every method has a corresponding CLI command except the six config-registry endpoints and /auth/connections/{id}/exchange, all of which are marked x-cli-skip: true in the API spec. Audited every *Params struct field against the CLI source; the only unwired fields are the deprecated AuthConnectionLoginParams.BrowserTelemetry (superseded by browser.telemetry, which the CLI already sets) and BrowserCurlParams.ResponseEncoding (not applicable - `kernel browsers curl` streams raw bytes over the browser HTTP client rather than calling Browsers.Curl). Tested: go build ./..., go vet ./..., full `go test ./...` suite passes; smoke-tested `kernel app list --per-page 3` and `kernel browsers list` against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 849c7b3a..fa46bff5 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.101.1-0.20260913224554-b4278bbc2992 + github.com/kernel/kernel-go-sdk v0.102.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 e68f4079..6f4ca2b0 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.101.1-0.20260913224554-b4278bbc2992 h1:9AAklY1/rRJI+92zCjHcIc1A8Cd0Mkww8TQQqPnel/8= -github.com/kernel/kernel-go-sdk v0.101.1-0.20260913224554-b4278bbc2992/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.102.0 h1:ZGumOc/Bub48B8zRye44BSLNCgqM/Z6K7XcKX0DCjH0= +github.com/kernel/kernel-go-sdk v0.102.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/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= From 69517960462791b0712bb224649fef91351ce619 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:18:36 +0000 Subject: [PATCH 45/51] chore: update Go SDK to ec63b01 and add AgentCard prepare_checkout Update kernel-go-sdk to ec63b0146054357d65e420f148a1eb980bd051b0, which adds the single-use AgentCard prepare_checkout vault item operation for Square and renames the authorize request body type. - Repair a merge leftover: items invoke still called the removed vaultFillFromFlags helper, so the package did not build. - Switch authorize to AuthorizeVaultItemOperationRequestParam, replacing the removed VaultItemPerformOperationParamsBodyAuthorize/constant.Authorize. Parameterless operations advertised by name still pass through unchanged. - Add prepare_checkout to items invoke. --params takes browser_id, merchant_origin, and environment; merchant_origin is validated and canonicalized to a scheme/host origin (http only for loopback) and environment must be production or sandbox. A malformed context is rejected before a single-use preparation can be spent. - Extend --open to prepare_checkout so it opens the returned approval URL, which arrives on state.preparation rather than as a required action. - Surface state.preparation in the display-safe projection and the item table (id, status, environment, merchant origin, browser, submit deadline), plus guidance for the new AgentCard statuses preparing, ready_to_submit, consumed, stopped, and outcome_unknown. - Report a failed prepare_checkout as possibly having created a preparation, never as a retry signal. - Document the operation in the invoke help and README. Tested against the live API: vaults items create/get/delete for an AgentCard card, vaults items invoke prepare_checkout (correctly refused because the deployed API does not advertise the operation yet), each client-side --params rejection, and browsers/profiles/vault-provider-configs list as SDK bump regression checks. Full go test ./... passes, including new unit tests that assert the request body, preparation rendering, --open, and status guidance. Co-Authored-By: Claude Opus 5 --- README.md | 59 ++++++++-- cmd/vaults.go | 35 ++++-- cmd/vaults_commands.go | 20 +++- cmd/vaults_operation_params.go | 83 +++++++++++++- cmd/vaults_output.go | 42 ++++++- cmd/vaults_policy.go | 8 +- cmd/vaults_prepare_checkout.go | 22 ++++ cmd/vaults_prepare_checkout_test.go | 167 ++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 10 files changed, 409 insertions(+), 33 deletions(-) create mode 100644 cmd/vaults_prepare_checkout.go create mode 100644 cmd/vaults_prepare_checkout_test.go diff --git a/README.md b/README.md index baa7b33f..b0f478ef 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ cannot switch projects. | `kernel vaults cards update --provider link\|agentcard --spec ''` | Update a card spec; pending issuance preserves omitted optional fields, and the API enforces state/provider constraints | | `kernel vaults items list ` | List item keys, types, providers, status, and required actions | | `kernel vaults items get ` | Inspect state/actions/returned aliases and copyable operation commands; `--wait 0..60`, `--expand payment_methods`, `--open` | -| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; `authorize --open` opens a returned HTTPS action; `fill --params ''` fills checkout fields | +| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; `authorize --open` opens a returned HTTPS action; `prepare_checkout --params ''` prepares an unused AgentCard card for Square Pay; `fill --params ''` fills checkout fields | | `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | | `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | | `kernel vaults provider-configs create --name --provider link\|agentcard --client-id ` | Register customer-owned provider credentials; `--client-secret` or `--client-secret-file` (`-` reads stdin) | @@ -479,10 +479,11 @@ kernel vaults cards create agentcard-checkout order-1 --provider agentcard --spe kernel browsers create --vault agentcard-checkout ``` -AgentCard authorizes at checkout and does not currently advertise `authorize`. To select a -vaulted card in advance, inspect `wallets payment-methods` and include its ID as `card_id` in the -card spec. Otherwise, the cardholder selects a card at approval. A reusable card being -`ready` does not mean the last payment succeeded. +AgentCard authorizes at checkout and does not advertise `authorize`. Eligible unused AgentCard +cards advertise `prepare_checkout` instead; see [Prepare a Square checkout](#prepare-a-square-checkout). +To select a vaulted card in advance, inspect `wallets payment-methods` and include its ID as +`card_id` in the card spec. Otherwise, the cardholder selects a card at approval. A reusable card +being `ready` does not mean the last payment succeeded. #### Invoking item operations @@ -498,13 +499,49 @@ advertised. The API controls availability. The CLI additionally refuses invocati actions in `recovery_required`, even if a stale action or operation was returned. `authorize` sends `{"type":"authorize"}` without `--params` and returns the updated item, -possibly with a required user action. `--open` is supported only for authorize. -The [API spec](https://api.onkernel.com/spec.yaml) also accepts `fill`, with its inputs in -`--params`. The positional operation supplies `type`; including `type` in params is rejected. +possibly with a required user action. `--open` is supported for `authorize` and `prepare_checkout`. +The [API spec](https://api.onkernel.com/spec.yaml) also accepts `prepare_checkout` and `fill`, with +their inputs in `--params`. The positional operation supplies `type`; including `type` in params is +rejected. Parameters must be a JSON object without unknown or duplicate properties. There is no operation `--spec` flag; wallet/card `--spec` flags remain unchanged. New parameterless operations can still be invoked by name when advertised. +##### Prepare a Square checkout + +Eligible unused AgentCard cards advertise `prepare_checkout` before the first native Square Pay +action. Preparation obtains cardholder device approval and binds consent to one browser session and +one declared merchant origin: + +```bash +kernel vaults items get agentcard-checkout order-1 +kernel vaults items invoke agentcard-checkout order-1 prepare_checkout --params '{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}' --open +``` + +- `browser_id` is a browser **session ID** of a browser created with this vault attached, not a + reusable browser name. It is sent unchanged; the CLI does not resolve names. +- `merchant_origin` is the canonical origin of the **top-level merchant document**, not the Square + iframe. Only a scheme and host (with an optional port) are accepted; `http` only for localhost. +- `environment` is `production` or `sandbox`. It describes Square, not the AgentCard credential mode. + +The response is the updated item carrying `state.preparation` with its status, approval URL, and +submission deadline. Deliver the approval URL and keep that page open through token handoff; +`--open` opens it for you. Then poll: + +```bash +kernel vaults items get agentcard-checkout order-1 --wait 60 +``` + +Submit native Square Pay only once the item reaches `ready_to_submit`, and before the printed +deadline. Readiness lasts at most 30 seconds, and polling never extends it. The preparation amount +is display-only and does not constrain the merchant's eventual charge. + +Every preparation is single-use, including after failure or expiry. A failed request is not a retry +signal: one may already have been created. Item `consumed` means the prepared attempt settled, not +that an order or charge succeeded; `stopped` cannot be reused; `outcome_unknown` blocks new requests +and requires merchant reconciliation. Inspect `items events` and reconcile uncertain outcomes with +the merchant rather than preparing again. + ##### Fill checkout fields Fill is supported only when advertised by a ready Link card, not AgentCard. It writes stored @@ -887,8 +924,10 @@ before invoking one. - `kernel vaults items update --spec ` - Update a card item's spec before or between authorizations - `--spec ` / `--spec-file ` - Full replacement card spec (only card items can be updated) - `--output json`, `-o json` - Output raw JSON object -- `kernel vaults items perform-operation ` - Perform an operation the item advertises - - `--type ` - Operation to perform (default `authorize`). Operations may call an external provider and return the item's updated state. +- `kernel vaults items invoke ` - Perform an operation the item advertises + - `` - Operation to perform, e.g. `authorize`, `prepare_checkout`, or `fill`. Operations may call an external provider and return the item's updated state. + - `--params ` - Operation inputs for `prepare_checkout` and `fill`; omit `type` + - `--open` - Open a returned HTTPS action or approval URL for `authorize` and `prepare_checkout` - `--output json`, `-o json` - Output raw JSON object - `kernel vaults items events ` - List an item's immutable audit events, oldest first - `--after ` - Return only events after this event ID diff --git a/cmd/vaults.go b/cmd/vaults.go index fe3ca6f8..8071a12a 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -14,7 +14,6 @@ import ( "github.com/kernel/cli/pkg/util" kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" - "github.com/kernel/kernel-go-sdk/shared/constant" "github.com/pterm/pterm" ) @@ -203,13 +202,16 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. return c.showItem(item, output, false) } -func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultFillParams, output string, open bool) error { +func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultOperationParams, output string, open bool) error { if strings.TrimSpace(operation) == "" { return fmt.Errorf("operation must not be empty") } - if operation == "fill" && (params == nil || open) { + if operation == "fill" && (params == nil || params.Fill == nil || open) { return fmt.Errorf("fill requires --params and does not support --open") } + if operation == "prepare_checkout" && (params == nil || params.Checkout == nil) { + return fmt.Errorf("prepare_checkout requires --params with browser_id, merchant_origin, and environment") + } item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { if operation == "fill" { @@ -244,12 +246,27 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } if operation == "fill" { - return c.fill(ctx, vault, key, params, output) + return c.fill(ctx, vault, key, params.Fill, output) + } + body := kernel.VaultItemPerformOperationParams{IDOrName: vault} + if operation == "prepare_checkout" { + body.OfPrepareCheckout = &kernel.PrepareCheckoutVaultItemOperationRequestParam{ + Type: kernel.PrepareCheckoutVaultItemOperationRequestTypePrepareCheckout, + Checkout: kernel.VaultCheckoutContextParam{ + BrowserID: params.Checkout.BrowserID, + MerchantOrigin: params.Checkout.MerchantOrigin, + Environment: kernel.VaultCheckoutContextEnvironment(params.Checkout.Environment), + }, + } + } else { + // Preserve support for other advertised parameterless operations. + body.OfAuthorize = &kernel.AuthorizeVaultItemOperationRequestParam{Type: kernel.AuthorizeVaultItemOperationRequestType(operation)} } - // Preserve support for other advertised parameterless operations. - authorize := kernel.VaultItemPerformOperationParamsBodyAuthorize{Type: constant.Authorize(operation)} - response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfAuthorize: &authorize}, option.WithMaxRetries(0)) + response, err := c.vaults.Items.PerformOperation(ctx, key, body, option.WithMaxRetries(0)) if err != nil { + if operation == "prepare_checkout" { + return vaultPrepareCheckoutRequestError(err) + } return util.CleanedUpSdkError{Err: err} } if response == nil || (response.Type != "card" && response.Type != "wallet") { @@ -307,6 +324,10 @@ func (c VaultsCmd) showItem(item *kernel.VaultItemUnion, output string, open boo return nil } actionURL := actions.ActionURL + if actionURL == "" { + // prepare_checkout returns an approval URL rather than a required action. + actionURL = actions.ApprovalURL + } if actionURL == "" { if output != "json" { pterm.Info.Println("No action URL returned; no browser opened") diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 094d7ba0..7427a15d 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -139,6 +139,17 @@ JSON output preserves returned public fields but omits unknown/opaque provider d Read its description with items get before invoking; follow any approval requirements. Authorize sends {"type":"authorize"} without --params and returns an updated item; --open opens its returned HTTPS action URL. +Prepare_checkout is advertised by eligible unused AgentCard cards before the first +Square Pay action. It requires --params with browser_id (session ID of a browser +created with this vault attached), merchant_origin (canonical origin of the +top-level merchant document, not the Square iframe; http only for localhost), and +environment (production or sandbox, describing Square and not the AgentCard +credential mode). Deliver the returned approval URL and keep that page open; +--open opens it. Then poll with items get --wait 60 until ready_to_submit and +submit native Pay before the preparation deadline; readiness lasts at most 30 +seconds and polling never extends it. Unused preparations expire automatically. +Every preparation is single-use, including after failure or expiry: do not +automatically retry, and reconcile uncertain outcomes with the merchant. Fill requires --params JSON with browser_id (session ID, not name), exact HTTPS page_url, and 1-32 fields. Each binding has field and selector; expiration also requires format MM/YY or MM/YYYY. Stored fields: number, cvc, exp_month (MM), @@ -154,12 +165,9 @@ prove no writes occurred. No automatic retries, alias fallback, or form submissi Inspect the browser before deciding what to do next; completed does not mean paid.`, Example: ` kernel vaults items get checkout order-1 kernel vaults items invoke checkout order-1 authorize --open + kernel vaults items invoke checkout order-1 prepare_checkout --params '{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}' --open kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"},{"field":"expiration","format":"MM/YY","selector":"#expiry"},{"field":"cvc","selector":"#security-code"}],"timeout_ms":10000}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { - fill, err := vaultFillFromFlags(cmd, args[2]) - if err != nil { - return err - } open, _ := cmd.Flags().GetBool("open") raw, _ := cmd.Flags().GetString("params") params, err := parseVaultOperationParams(args[2], raw, cmd.Flags().Changed("params"), cmd.Flags().Changed("open")) @@ -168,8 +176,8 @@ Inspect the browser before deciding what to do next; completed does not mean pai } return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} - invoke.Flags().String("params", "", "Operation-specific JSON object for fill; omit type (supplied by )") - invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL for authorize") + invoke.Flags().String("params", "", "Operation-specific JSON object for fill and prepare_checkout; omit type (supplied by )") + invoke.Flags().Bool("open", false, "Open a returned HTTPS action or approval URL for authorize and prepare_checkout") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index e519a6c8..3cc7bca6 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -10,6 +10,21 @@ import ( "strings" ) +// vaultOperationParams holds the parsed --params payload for the one operation +// that accepts parameters; exactly one field is set, or none for authorize. +type vaultOperationParams struct { + Fill *vaultFillParams + Checkout *vaultCheckoutContext +} + +// vaultCheckoutContext binds an AgentCard preparation to a browser session and +// the declared top-level merchant origin, not a tab. +type vaultCheckoutContext struct { + BrowserID string `json:"browser_id"` + MerchantOrigin string `json:"merchant_origin"` + Environment string `json:"environment"` +} + type vaultFillParams struct { BrowserID string `json:"browser_id"` PageURL string `json:"page_url"` @@ -65,16 +80,23 @@ func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) return object, nil } -func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) (*vaultFillParams, error) { +func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) (*vaultOperationParams, error) { if strings.TrimSpace(operation) == "" { return nil, fmt.Errorf("operation must not be empty") } - if openSet && operation != "authorize" { - return nil, fmt.Errorf("--open is only supported for authorize") + if openSet && operation != "authorize" && operation != "prepare_checkout" { + return nil, fmt.Errorf("--open is only supported for authorize and prepare_checkout") + } + if operation == "prepare_checkout" { + checkout, err := parseVaultCheckoutContext(raw, paramsSet) + if err != nil { + return nil, err + } + return &vaultOperationParams{Checkout: checkout}, nil } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill; authorize takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill and prepare_checkout; authorize takes no parameters") } return nil, nil } @@ -132,5 +154,56 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } params.Fields = append(params.Fields, binding) } - return ¶ms, nil + return &vaultOperationParams{Fill: ¶ms}, nil +} + +// Preparations are single-use even after failure or expiry, so reject a +// malformed checkout context before spending one. +func parseVaultCheckoutContext(raw string, paramsSet bool) (*vaultCheckoutContext, error) { + if !paramsSet { + return nil, fmt.Errorf("prepare_checkout requires --params with browser_id, merchant_origin, and environment") + } + object, err := vaultParamsObject(raw, "browser_id merchant_origin environment") + if err != nil { + return nil, err + } + var checkout vaultCheckoutContext + if json.Unmarshal(object["browser_id"], &checkout.BrowserID) != nil || strings.TrimSpace(checkout.BrowserID) == "" { + return nil, fmt.Errorf("--params.browser_id must be a non-empty browser session ID, not a name") + } + if json.Unmarshal(object["environment"], &checkout.Environment) != nil || (checkout.Environment != "production" && checkout.Environment != "sandbox") { + return nil, fmt.Errorf("--params.environment must be production or sandbox; it describes Square, not the AgentCard credential mode") + } + if json.Unmarshal(object["merchant_origin"], &checkout.MerchantOrigin) != nil { + return nil, fmt.Errorf("--params.merchant_origin must be the top-level merchant document's origin, not the Square iframe") + } + origin, err := vaultMerchantOrigin(checkout.MerchantOrigin) + if err != nil { + return nil, err + } + checkout.MerchantOrigin = origin + return &checkout, nil +} + +// A canonical origin carries no path, query, fragment, or credentials. HTTP is +// accepted only for loopback test merchants. +func vaultMerchantOrigin(value string) (string, error) { + invalid := fmt.Errorf("--params.merchant_origin must be a canonical HTTPS origin such as https://shop.example.com (http accepted only for localhost), without a path, query, or fragment") + u, err := url.Parse(strings.TrimSpace(value)) + if err != nil || u.Host == "" || u.User != nil || u.Opaque != "" || u.RawQuery != "" || u.Fragment != "" { + return "", invalid + } + if u.Path != "" && u.Path != "/" { + return "", invalid + } + switch u.Scheme { + case "https": + case "http": + if host := u.Hostname(); host != "localhost" && host != "127.0.0.1" && host != "::1" { + return "", invalid + } + default: + return "", invalid + } + return u.Scheme + "://" + u.Host, nil } diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index b8a83774..f447f23e 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -54,6 +54,7 @@ var vaultItemFields = vaultOutputFields{ "masks": vaultFieldsOf("brand last4"), "aliases": vaultFieldsOf("number cvc exp_month exp_year"), "authorization": vaultFieldsOf("id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered"), + "preparation": vaultFieldsOf("id status browser_id merchant_origin environment approval_url created_at expires_at"), }, } var vaultEventFields = vaultOutputFields{ @@ -196,7 +197,7 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s } for _, op := range actions.Operations { command := prefix - if op.Type == "fill" { + if op.Type == "fill" || op.Type == "prepare_checkout" { command += " --params ''" } pterm.Printf("Invoke: %s -- %s %s %s\n", command, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) @@ -277,6 +278,19 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { rows = append(rows, []string{"Processor response delivered", fmt.Sprint(a.ReplayDelivered)}) } } + if item.State.JSON.Preparation.Valid() { + p := item.State.Preparation + rows = append(rows, + []string{"Checkout preparation", util.OrDash(p.ID)}, + []string{"Preparation status", string(p.Status)}, + []string{"Preparation environment (Square)", string(p.Environment)}, + []string{"Merchant origin", p.MerchantOrigin}, + []string{"Preparation browser", p.BrowserID}, + ) + if !p.ExpiresAt.IsZero() { + rows = append(rows, []string{"Submit native Pay before", util.FormatLocal(p.ExpiresAt)}) + } + } PrintTableNoPad(rows, true) printVaultItemGuidance(item, actions) return nil @@ -300,6 +314,7 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction if actions.ApprovalURL != "" { pterm.Printf("Approval URL:\n%s\n", actions.ApprovalURL) } + printVaultPreparationGuidance(item) for _, op := range actions.Operations { pterm.Printf("Available operation: %s — %s\n", op.Type, op.Description) } @@ -326,6 +341,31 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction } } +// Preparation state and item state answer different questions: the preparation +// says whether egress can still claim it, the item says whether the attempt has +// settled. Neither means an order or charge succeeded. +func printVaultPreparationGuidance(item *kernel.VaultItemUnion) { + switch item.State.Status { + case "preparing": + pterm.Info.Println("preparing: the cardholder has not approved this device yet. Keep the approval page open and observe with items get --wait 60; do not prepare again.") + case "ready_to_submit": + pterm.Warning.Println("ready_to_submit: device readiness lasts at most 30 seconds. Submit native Square Pay before the preparation deadline; polling never extends it. An expired readiness window cannot be reused.") + case "consumed": + pterm.Warning.Println("consumed: the prepared attempt has settled. This does not mean an order or charge succeeded. Inspect items events and reconcile with the merchant; preparations are single-use and this one cannot be reused.") + case "stopped": + pterm.Warning.Println("stopped: this preparation cannot be reused. Do not retry it; create a replacement card only after confirming with the merchant that no payment occurred.") + case "outcome_unknown": + pterm.Warning.Println("outcome_unknown: the checkout outcome is unresolved and new requests are blocked. Reconcile with the merchant; do not retry, delete, or replace the card.") + } + if !item.State.JSON.Preparation.Valid() { + return + } + if item.State.Preparation.Status == kernel.AgentcardCheckoutPreparationStatusConsumed { + pterm.Info.Println("Preparation consumed means egress claimed it and it cannot be reused. Use the item status as the lifecycle indicator.") + } + pterm.Info.Println("The preparation amount is display-only and does not constrain the merchant's eventual charge.") +} + func printVaultPaymentMethods(methods []kernel.VaultPaymentMethod) { if len(methods) == 0 { pterm.Info.Println("No payment methods returned") diff --git a/cmd/vaults_policy.go b/cmd/vaults_policy.go index 6f21b2ed..64822c0c 100644 --- a/cmd/vaults_policy.go +++ b/cmd/vaults_policy.go @@ -40,10 +40,16 @@ func effectiveVaultItemActions(item *kernel.VaultItemUnion) (vaultItemActions, e if err := json.Unmarshal([]byte(item.RawJSON()), &fields); err != nil { return vaultItemActions{}, fmt.Errorf("invalid vault item operations: %w", err) } + // An AgentCard preparation carries its own approval URL instead of a + // required action; the cardholder must keep that page open through handoff. + approvalURL := item.State.Authorization.ApprovalURL + if approvalURL == "" { + approvalURL = item.State.Preparation.ApprovalURL + } return vaultItemActions{ RequiredAction: item.Action.Name, ActionURL: item.Action.URL, - ApprovalURL: item.State.Authorization.ApprovalURL, + ApprovalURL: approvalURL, Operations: fields.Operations, }, nil } diff --git a/cmd/vaults_prepare_checkout.go b/cmd/vaults_prepare_checkout.go new file mode 100644 index 00000000..969735c2 --- /dev/null +++ b/cmd/vaults_prepare_checkout.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "errors" + "fmt" + + kernel "github.com/kernel/kernel-go-sdk" +) + +// A preparation is single-use even after failure or expiry, so a failed request +// is not a retry signal: the attempt may already have consumed one. +const vaultPrepareCheckoutUncertain = "a single-use preparation may still have been created; inspect the item and its events, and do not automatically retry" + +func vaultPrepareCheckoutRequestError(err error) error { + var apiErr *kernel.Error + if errors.As(err, &apiErr) { + return fmt.Errorf("prepare_checkout failed (HTTP %d); %s", apiErr.StatusCode, vaultPrepareCheckoutUncertain) + } + // Do not wrap SDK/transport errors: they can contain request or response data, + // and the root error handler extracts raw SDK error messages through Unwrap. + return fmt.Errorf("prepare_checkout result unavailable; %s", vaultPrepareCheckoutUncertain) +} diff --git a/cmd/vaults_prepare_checkout_test.go b/cmd/vaults_prepare_checkout_test.go new file mode 100644 index 00000000..7360ddc4 --- /dev/null +++ b/cmd/vaults_prepare_checkout_test.go @@ -0,0 +1,167 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const prepareCheckoutOperation = `[{"type":"prepare_checkout","description":"Prepare this unused AgentCard card before the first Square Pay action."}]` + +// A ready AgentCard card that advertises prepare_checkout. +var unusedAgentCardFixture = strings.ReplaceAll(strings.ReplaceAll( + `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"agentcard","wallet":"wallet-1","merchant":"Example Shop","amount":2599,"currency":"usd"},"state":{"provider":"agentcard","status":"ready"},"available_operations":OPS,"available_expansions":[]}`, + "OPS", prepareCheckoutOperation), "\n", "") + +// The same card after preparation, awaiting cardholder device approval. +var preparingAgentCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"agentcard","wallet":"wallet-1","merchant":"Example Shop","amount":2599,"currency":"usd"},"state":{"provider":"agentcard","status":"preparing","preparation":{"id":"prep-1","status":"awaiting_approval","browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production","approval_url":"https://provider.example/approve","created_at":"2026-01-01T12:00:00Z","expires_at":"2026-01-01T12:00:30Z"}},"available_operations":[],"available_expansions":[]}` + +func TestVaultPrepareCheckoutSendsCheckoutContext(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + body := unusedAgentCardFixture + if r.Method == http.MethodPost { + assert.Equal(t, "/vaults/checkout/items/order-1/operations", r.URL.Path) + raw, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"prepare_checkout","checkout":{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}}`, string(raw)) + body = preparingAgentCardFixture + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "prepare_checkout", + "--params", `{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com/","environment":"production"}`, "-o", "json") + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.JSONEq(t, preparingAgentCardFixture, out) +} + +func TestVaultPrepareCheckoutRendersPreparationAndDeadline(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + body := unusedAgentCardFixture + if r.Method == http.MethodPost { + body = preparingAgentCardFixture + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "prepare_checkout", + "--params", `{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}`) + require.NoError(t, err) + assert.Contains(t, human, "prep-1") + assert.Contains(t, human, "awaiting_approval") + assert.Contains(t, human, "https://shop.example.com") + assert.Contains(t, human, "Submit native Pay before") + assert.Contains(t, human, "https://provider.example/approve") + assert.Contains(t, human, "Keep the approval page open") + assert.Contains(t, human, "display-only") +} + +func TestVaultPrepareCheckoutOpensApprovalURL(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + body := unusedAgentCardFixture + if r.Method == http.MethodPost { + body = preparingAgentCardFixture + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + opened := "" + handler := VaultsCmd{vaults: &client.Vaults, openURL: func(url string) error { opened = url; return nil }} + params, err := parseVaultOperationParams("prepare_checkout", `{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}`, true, true) + require.NoError(t, err) + captureStdout(t, func() { + err = handler.Invoke(t.Context(), "checkout", "order-1", "prepare_checkout", params, "json", true) + }) + require.NoError(t, err) + assert.Equal(t, "https://provider.example/approve", opened) +} + +func TestVaultPrepareCheckoutRejectsInvalidParams(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + for name, params := range map[string]string{ + "missing": "", + "empty browser": `{"browser_id":" ","merchant_origin":"https://shop.example.com","environment":"production"}`, + "bad environment": `{"browser_id":"b","merchant_origin":"https://shop.example.com","environment":"staging"}`, + "origin with path": `{"browser_id":"b","merchant_origin":"https://shop.example.com/checkout","environment":"production"}`, + "origin query": `{"browser_id":"b","merchant_origin":"https://shop.example.com?a=1","environment":"production"}`, + "insecure origin": `{"browser_id":"b","merchant_origin":"http://shop.example.com","environment":"production"}`, + "credentials": `{"browser_id":"b","merchant_origin":"https://user:pass@shop.example.com","environment":"production"}`, + "unknown key": `{"browser_id":"b","merchant_origin":"https://shop.example.com","environment":"production","tab_id":"1"}`, + "explicit type": `{"type":"prepare_checkout","browser_id":"b","merchant_origin":"https://shop.example.com","environment":"production"}`, + } { + t.Run(name, func(t *testing.T) { + args := []string{"vaults", "items", "invoke", "checkout", "order-1", "prepare_checkout"} + if params != "" { + args = append(args, "--params", params) + } + _, _, err := executeVaultCommand(t, client, args...) + require.Error(t, err) + }) + } +} + +func TestVaultMerchantOriginAcceptsLoopbackAndCanonicalizes(t *testing.T) { + for input, want := range map[string]string{ + "https://shop.example.com": "https://shop.example.com", + "https://shop.example.com/": "https://shop.example.com", + "https://shop.example.com:8443": "https://shop.example.com:8443", + "http://localhost:3000": "http://localhost:3000", + "http://127.0.0.1": "http://127.0.0.1", + } { + got, err := vaultMerchantOrigin(input) + require.NoError(t, err, input) + assert.Equal(t, want, got) + } +} + +func TestVaultPrepareCheckoutFailureDoesNotSuggestRetry(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"code":"item_not_ready","message":"Item not ready"}`) + return + } + _, _ = io.WriteString(w, unusedAgentCardFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "prepare_checkout", + "--params", `{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}`) + require.ErrorContains(t, err, "prepare_checkout failed (HTTP 409)") + require.ErrorContains(t, err, "single-use preparation may still have been created") + // One GET plus one POST: a single-use preparation is never retried. + assert.Equal(t, 2, calls) +} + +func TestVaultItemGuidanceForPreparedStatuses(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for status, want := range map[string]string{ + "ready_to_submit": "at most 30 seconds", + "consumed": "does not mean an order or charge succeeded", + "stopped": "cannot be reused", + "outcome_unknown": "new requests are blocked", + } { + t.Run(status, func(t *testing.T) { + body := strings.ReplaceAll(preparingAgentCardFixture, `"status":"preparing"`, `"status":"`+status+`"`) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1") + require.NoError(t, err) + assert.Contains(t, human, want) + }) + } +} diff --git a/go.mod b/go.mod index fa46bff5..6f9bdb37 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.102.0 + github.com/kernel/kernel-go-sdk v0.102.1-0.20260914170532-ec63b0146054 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 6f4ca2b0..8e2bf0f1 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.102.0 h1:ZGumOc/Bub48B8zRye44BSLNCgqM/Z6K7XcKX0DCjH0= -github.com/kernel/kernel-go-sdk v0.102.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.102.1-0.20260914170532-ec63b0146054 h1:uS4lhskSLRmtOYRrZE1X7OhK2gguRs1T5mwFfstU/tI= +github.com/kernel/kernel-go-sdk v0.102.1-0.20260914170532-ec63b0146054/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= From 18c313aa30bb16c56ddd6d8b7291f84f5032f773 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:35:27 +0000 Subject: [PATCH 46/51] chore: update Go SDK to 7c60d81 Bumps github.com/kernel/kernel-go-sdk to v0.102.1-0.20260914202752-7c60d81c9fa1 (7c60d81). Coverage analysis: full enumeration of api.md methods against the CLI command tree found no gaps. The only SDK change in this bump adds ResolveRequestParam.Intent and ConfigRegistryResponse.WorkloadOutcome, both on /config-registry/resolve, which is marked x-cli-skip in openapi.yaml along with every other /config-registry endpoint. No new commands or flags are needed. Tested: go build ./..., go test ./... (all pass), and smoke-tested `kernel browsers list` and `kernel profiles list` against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 6f9bdb37..8fa818e1 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.102.1-0.20260914170532-ec63b0146054 + github.com/kernel/kernel-go-sdk v0.102.1-0.20260914202752-7c60d81c9fa1 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 8e2bf0f1..6eb5a6c2 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.102.1-0.20260914170532-ec63b0146054 h1:uS4lhskSLRmtOYRrZE1X7OhK2gguRs1T5mwFfstU/tI= -github.com/kernel/kernel-go-sdk v0.102.1-0.20260914170532-ec63b0146054/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.102.1-0.20260914202752-7c60d81c9fa1 h1:0CjSwIeUGkwBBPASsYGJ/tOa6L27EXf491oPopvYzl4= +github.com/kernel/kernel-go-sdk v0.102.1-0.20260914202752-7c60d81c9fa1/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= From b743dc3c6d360c6f1bba877f3322e9c2cfc6b7cc Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:26:13 +0000 Subject: [PATCH 47/51] chore: update Go SDK to 2e5c061 and add managed auth --region Bumps kernel-go-sdk to 2e5c06117d8597f1808b3bd43a4df746a19fe8c7, which adds ManagedAuthBrowserConfig.Region ("Honor managed auth browser regions"). Exposes it as --region on `kernel auth connections create`, `update`, and `login`, reusing the existing parseRegionFlag validation so the accepted values match `kernel browsers create` (us-east, eu-west, ap-southeast). `auth connections get` and `follow` now show a "Browser Region" row when the API reports one. A full enumeration of api.md methods against the CLI command tree found no other gaps; the config-registry endpoints remain x-cli-skip. Tested against the live API: create --region us-east round-trips into the get output, update --region us-east succeeds, update --region eu-west returns the API's region_not_enabled entitlement error (confirming the field is sent), and an invalid value is rejected client-side on create, update, and login. Co-Authored-By: Claude Opus 5 --- README.md | 3 ++ cmd/auth_connections.go | 40 ++++++++++++++++++++++ cmd/auth_connections_test.go | 65 ++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +-- 5 files changed, 111 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b0f478ef..3e50ab5b 100644 --- a/README.md +++ b/README.md @@ -1027,16 +1027,19 @@ Managed auth connections (`kernel auth connections`). The commands below are new - `--output json`, `-o json` - Output raw JSON array - `kernel auth connections create` - New flags: - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy configuration for this connection's login, reauth, and health-check browser sessions (mutually exclusive). Omit to derive the default from stealth. + - `--region us-east|eu-west|ap-southeast` - Region for this connection's browser sessions (default: `us-east`). Non-default regions require an eligible plan and organization access. - `--stealth` - Whether those browser sessions run in stealth mode (default: true); use `--stealth=false` to disable - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Default telemetry for this connection's browser sessions. Same semantics as `kernel browsers create` - `--telemetry-export-otlp ` - Export this connection's captured telemetry over OTLP to one of the org's configured destinations. Implies `--telemetry=all` when `--telemetry` is not set. Use `=off` to disable export. - `kernel auth connections update ` - New flags: - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy configuration for future browser sessions (mutually exclusive). Use `--proxy-mode=default` to drop a selected proxy rather than passing an empty value. + - `--region us-east|eu-west|ap-southeast` - Region for future browser sessions; omit to keep the current region - `--stealth` - Set whether future browser sessions run in stealth mode; use `--stealth=false` to disable - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Update telemetry for future browser sessions - `--telemetry-export-otlp ` - Update where future sessions export captured telemetry. Naming a destination requires passing `--telemetry` in the same command, since the API validates capture and export together and enabling capture here would replace the connection's current category selection. Use `=off` to disable export. - `kernel auth connections login ` - New flags: - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy override for this login's browser session (mutually exclusive); omitted properties inherit the connection defaults + - `--region us-east|eu-west|ap-southeast` - Region override for this login's browser session; omit to inherit the connection's region. Applies only to this login. - `--stealth` - Stealth override for this login's browser session; use `--stealth=false` to disable - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Telemetry override for this login only, merged onto the connection's config - `--telemetry-export-otlp ` - Export override for this login only. Naming a destination requires passing `--telemetry` in the same command. Use `=off` to disable export. diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 860866ae..691e93dd 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -49,6 +49,7 @@ type AuthConnectionCreateInput struct { ProxyID string ProxyName string ProxyMode string + Region string Stealth BoolFlag SaveCredentials bool NoSaveCredentials bool @@ -85,6 +86,7 @@ type AuthConnectionUpdateInput struct { ProxyName string ProxyNameSet bool ProxyMode string + Region string Stealth BoolFlag SaveCredentials BoolFlag HealthCheckInterval int @@ -117,6 +119,7 @@ type AuthConnectionLoginInput struct { ProxyID string ProxyName string ProxyMode string + Region string Stealth BoolFlag RecordSession BoolFlag Telemetry string @@ -220,6 +223,14 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.Browser.Proxy = proxy } + region, err := parseRegionFlag(in.Region) + if err != nil { + return err + } + if region != "" { + params.ManagedAuthCreateRequest.Browser.Region = kernel.ManagedAuthBrowserConfigRegion(region) + } + if in.Stealth.Set { params.ManagedAuthCreateRequest.Browser.Stealth = kernel.Opt(in.Stealth.Value) } @@ -295,6 +306,9 @@ func managedAuthBrowserRows(cfg kernel.ManagedAuthBrowserConfig) pterm.TableData if proxy := formatBrowserProxyConfig(cfg.Proxy); proxy != "" { rows = append(rows, []string{"Browser Proxy", proxy}) } + if cfg.Region != "" { + rows = append(rows, []string{"Browser Region", string(cfg.Region)}) + } // Stealth defaults to true when omitted, so only report what the API sent. if cfg.JSON.Stealth.Valid() { rows = append(rows, []string{"Browser Stealth", fmt.Sprintf("%t", cfg.Stealth)}) @@ -381,6 +395,15 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn hasChanges = true } + region, err := parseRegionFlag(in.Region) + if err != nil { + return err + } + if region != "" { + params.ManagedAuthUpdateRequest.Browser.Region = kernel.ManagedAuthBrowserConfigRegion(region) + hasChanges = true + } + if in.Stealth.Set { params.ManagedAuthUpdateRequest.Browser.Stealth = kernel.Opt(in.Stealth.Value) hasChanges = true @@ -776,6 +799,14 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu params.Browser.Proxy = proxy } + region, err := parseRegionFlag(in.Region) + if err != nil { + return err + } + if region != "" { + params.Browser.Region = kernel.ManagedAuthBrowserConfigRegion(region) + } + if in.Stealth.Set { params.Browser.Stealth = kernel.Opt(in.Stealth.Value) } @@ -1274,6 +1305,7 @@ func init() { authConnectionsCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for this connection's browser sessions (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsCreateCmd.Flags().String("proxy-name", "", "Proxy name to use for this connection's browser sessions (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsCreateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the stealth-derived default") + authConnectionsCreateCmd.Flags().String("region", "", "Region for this connection's browser sessions (us-east, eu-west, ap-southeast); defaults to us-east. Non-default regions require an eligible plan and organization access") authConnectionsCreateCmd.Flags().Bool("stealth", true, "Run this connection's browser sessions in stealth mode; use --stealth=false to disable") authConnectionsCreateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") authConnectionsCreateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks. Defaults to 3600 or your plan minimum, whichever is larger. The maximum is 86400; the minimum depends on your plan (Enterprise 300, Startup 1200, Hobbyist 3600, Free 21600)") @@ -1301,6 +1333,7 @@ func init() { authConnectionsUpdateCmd.Flags().String("proxy-id", "", "Proxy ID to use for future browser sessions (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsUpdateCmd.Flags().String("proxy-name", "", "Proxy name to use for future browser sessions (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsUpdateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' to drop a selected proxy and use the stealth-derived default") + authConnectionsUpdateCmd.Flags().String("region", "", "Region for future browser sessions (us-east, eu-west, ap-southeast); omit to keep the current region. Non-default regions require an eligible plan and organization access") authConnectionsUpdateCmd.Flags().Bool("stealth", true, "Set whether future browser sessions run in stealth mode; use --stealth=false to disable") authConnectionsUpdateCmd.Flags().Bool("save-credentials", false, "Enable saving credentials after successful login") authConnectionsUpdateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") @@ -1334,6 +1367,7 @@ func init() { authConnectionsLoginCmd.Flags().String("proxy-id", "", "Proxy ID to use for this login (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsLoginCmd.Flags().String("proxy-name", "", "Proxy name to use for this login (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsLoginCmd.Flags().String("proxy-mode", "", "Proxy egress mode for this login instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the stealth-derived default") + authConnectionsLoginCmd.Flags().String("region", "", "Region for this login's browser session (us-east, eu-west, ap-southeast); omit to inherit the connection's region. Applies only to this login") authConnectionsLoginCmd.Flags().Bool("stealth", true, "Override stealth mode for this login's browser session; use --stealth=false to disable") authConnectionsLoginCmd.Flags().Bool("record-session", false, "Override whether this login's browser session is recorded; use --record-session=false to disable") authConnectionsLoginCmd.Flags().String("telemetry", "", "Telemetry override for this login only, merged onto the connection's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") @@ -1388,6 +1422,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { proxyID, _ := cmd.Flags().GetString("proxy-id") proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") + region, _ := cmd.Flags().GetString("region") noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") noHealthChecks, _ := cmd.Flags().GetBool("no-health-checks") @@ -1410,6 +1445,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { ProxyID: proxyID, ProxyName: proxyName, ProxyMode: proxyMode, + Region: region, Stealth: readBoolFlag(cmd.Flags(), "stealth"), NoSaveCredentials: noSaveCredentials, HealthCheckInterval: healthCheckInterval, @@ -1447,6 +1483,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { proxyID, _ := cmd.Flags().GetString("proxy-id") proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") + region, _ := cmd.Flags().GetString("region") saveCredentials, _ := cmd.Flags().GetBool("save-credentials") noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") @@ -1497,6 +1534,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { ProxyName: proxyName, ProxyNameSet: cmd.Flags().Changed("proxy-name"), ProxyMode: proxyMode, + Region: region, Stealth: readBoolFlag(cmd.Flags(), "stealth"), SaveCredentials: saveCredentialsFlag, HealthCheckInterval: healthCheckInterval, @@ -1550,6 +1588,7 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { proxyID, _ := cmd.Flags().GetString("proxy-id") proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") + region, _ := cmd.Flags().GetString("region") telemetry, _ := cmd.Flags().GetString("telemetry") telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") @@ -1561,6 +1600,7 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { ProxyID: proxyID, ProxyName: proxyName, ProxyMode: proxyMode, + Region: region, Stealth: readBoolFlag(cmd.Flags(), "stealth"), RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index 4466e483..d33e7293 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -759,6 +759,71 @@ func TestCreate_BrowserConfig(t *testing.T) { assert.False(t, browser.Stealth.Value) } +// Region is part of the connection's browser config: create sets it, update +// moves future sessions, and login overrides it for that login only. +func TestCreate_BrowserRegion(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionNewParams + fake := &FakeAuthConnectionService{ + NewFunc: func(ctx context.Context, body kernel.AuthConnectionNewParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + captured = body + return &kernel.ManagedAuth{ID: "auth_1"}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Create(context.Background(), AuthConnectionCreateInput{ + Domain: "example.com", + ProfileName: "prof", + Region: "eu-west", + })) + + assert.Equal(t, kernel.ManagedAuthBrowserConfigRegionEuWest, captured.ManagedAuthCreateRequest.Browser.Region) +} + +// Region alone is a real change, so it must satisfy update's "at least one +// field" check rather than being dropped. +func TestUpdate_BrowserRegion(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionUpdateParams + fake := &FakeAuthConnectionService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.AuthConnectionUpdateParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + captured = body + return &kernel.ManagedAuth{ID: id}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Update(context.Background(), AuthConnectionUpdateInput{ID: "auth_1", Region: "ap-southeast"})) + + assert.Equal(t, kernel.ManagedAuthBrowserConfigRegionApSoutheast, captured.ManagedAuthUpdateRequest.Browser.Region) +} + +func TestLogin_BrowserRegion(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionLoginParams + fake := &FakeAuthConnectionService{ + LoginFunc: func(ctx context.Context, id string, body kernel.AuthConnectionLoginParams, opts ...option.RequestOption) (*kernel.LoginResponse, error) { + captured = body + return &kernel.LoginResponse{ID: id}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Login(context.Background(), AuthConnectionLoginInput{ID: "auth_1", Region: "us-east"})) + + assert.Equal(t, kernel.ManagedAuthBrowserConfigRegionUsEast, captured.Browser.Region) +} + +func TestCreate_InvalidRegionErrors(t *testing.T) { + capturePtermOutput(t) + c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} + + err := c.Create(context.Background(), AuthConnectionCreateInput{ + Domain: "example.com", ProfileName: "prof", Region: "mars", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --region value") +} + func TestLogin_BrowserProxyMode(t *testing.T) { capturePtermOutput(t) var captured kernel.AuthConnectionLoginParams diff --git a/go.mod b/go.mod index 8fa818e1..0a10ddf9 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.102.1-0.20260914202752-7c60d81c9fa1 + github.com/kernel/kernel-go-sdk v0.102.1-0.20260914221652-2e5c06117d85 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 6eb5a6c2..2d8b36a3 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.102.1-0.20260914202752-7c60d81c9fa1 h1:0CjSwIeUGkwBBPASsYGJ/tOa6L27EXf491oPopvYzl4= -github.com/kernel/kernel-go-sdk v0.102.1-0.20260914202752-7c60d81c9fa1/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.102.1-0.20260914221652-2e5c06117d85 h1:ojOru53IltH4Z+38DuRf+bCz3UQlljiPejAw0a7bN0I= +github.com/kernel/kernel-go-sdk v0.102.1-0.20260914221652-2e5c06117d85/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= From 803688a9d97f6550f6dfd8cfc008ca894529070d Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:44:40 +0000 Subject: [PATCH 48/51] chore: update Go SDK to v0.103.0 (1682e8f) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release-only bump from 2e5c06117d85 to 1682e8f1d567ee65eb51128eae27dd64411a3b3c (v0.103.0). Diffing the two module versions shows changes confined to CHANGELOG.md, README.md, internal/version.go, and the release manifest — the generated API surface is unchanged, so no new commands or flags are required. Full enumeration of api.md (163 methods, 158 after excluding x-cli-skip endpoints) against the CLI found no coverage gaps: - Every non-skipped method is wired through the CLI's service interfaces. - Every top-level Params field is referenced except AuditLogListParams.PageToken (handled internally by ListAutoPaging) and the deprecated AuthConnectionLoginParams.BrowserTelemetry (superseded by browser.telemetry, which the CLI already uses). Tested: go build ./..., go vet ./..., go test ./... all pass; smoke tested against production with auth status, app list, and browsers create/get/delete (session mpf174rvn2bi8j8ijyk2f0ox created and cleaned up). Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0a10ddf9..9586f46d 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.102.1-0.20260914221652-2e5c06117d85 + github.com/kernel/kernel-go-sdk v0.103.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 2d8b36a3..dd7ff1bf 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.102.1-0.20260914221652-2e5c06117d85 h1:ojOru53IltH4Z+38DuRf+bCz3UQlljiPejAw0a7bN0I= -github.com/kernel/kernel-go-sdk v0.102.1-0.20260914221652-2e5c06117d85/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.103.0 h1:gimXCJrsn1CiQ0/Zx3LZPDWIlqIRCDCD25J1HnodZMQ= +github.com/kernel/kernel-go-sdk v0.103.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= From ed9924a37f33f950a25cf927ffdf07cedd361a72 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:04:33 +0000 Subject: [PATCH 49/51] chore: update Go SDK to 5839bab and add credential vault items The SDK now models credential vault items alongside wallets and cards, adds the collect operation, and replaces VaultCardFillFieldUnionParam with the generic VaultFillFieldParam (page_url is now optional). New commands: - kernel vaults credentials create --spec '' (--values-file , --open) for CredentialVaultItemRequest - kernel vaults credentials update --version (--values-file, --description, --expected-item-id, --open) for CredentialVaultItemUpdateRequest - kernel vaults items invoke collect for CollectVaultItemOperationRequest; --open opens the hosted form URL Credential values are write-only, so they are read from a file or stdin and rejected in --spec and in shell arguments. Update takes the expected item version, so a concurrent edit returns 409 instead of being overwritten. Fill now accepts credential items: page_url may be omitted (requiring exactly one open page), field names are declared credential fields, and format is rejected. Card bindings keep their existing constraints, now enforced once the item type is known instead of at parse time. Item output renders credential schema and per-field presence, withholding sensitive values, and the JSON projection gained a wildcard so caller-declared field maps survive filtering. A full enumeration of api.md against the CLI found no other gaps: the only uncovered methods are the five ConfigRegistry endpoints, all x-cli-skip. Tested against the live API: vaults credentials create (with and without --values-file), credentials update (set, clear via null, description-only, stale --version 409), items get/list -o json, items invoke collect, and items invoke fill for both a text/password credential and a totp field (verified a generated 6-digit code reached the page, not the seed). Test resources were deleted afterwards. Co-Authored-By: Claude Opus 5 --- README.md | 68 ++++++-- cmd/vaults.go | 18 ++- cmd/vaults_commands.go | 113 +++++++++++-- cmd/vaults_credentials.go | 180 +++++++++++++++++++++ cmd/vaults_credentials_test.go | 281 +++++++++++++++++++++++++++++++++ cmd/vaults_fill.go | 21 ++- cmd/vaults_fill_test.go | 1 - cmd/vaults_help.go | 31 ++++ cmd/vaults_operation_params.go | 73 ++++++--- cmd/vaults_output.go | 126 ++++++++++++--- cmd/vaults_secrets.go | 46 +++++- cmd/vaults_test.go | 10 +- go.mod | 2 +- go.sum | 4 +- 14 files changed, 895 insertions(+), 79 deletions(-) create mode 100644 cmd/vaults_credentials.go create mode 100644 cmd/vaults_credentials_test.go diff --git a/README.md b/README.md index 3e50ab5b..912dee83 100644 --- a/README.md +++ b/README.md @@ -289,9 +289,11 @@ cannot switch projects. | `kernel vaults wallets payment-methods ` | Fetch advertised live payment methods; JSON is the item with `expanded.payment_methods` | | `kernel vaults cards create --provider link\|agentcard --spec ''` | Create a card request; never implicitly authorize Link | | `kernel vaults cards update --provider link\|agentcard --spec ''` | Update a card spec; pending issuance preserves omitted optional fields, and the API enforces state/provider constraints | +| `kernel vaults credentials create --spec ''` | Declare a credential item's fields; `--values-file ` seeds values, `--open` opens a returned collection URL | +| `kernel vaults credentials update --version ` | Set or clear values and the description; `--values-file `, `--description`, `--expected-item-id` | | `kernel vaults items list ` | List item keys, types, providers, status, and required actions | | `kernel vaults items get ` | Inspect state/actions/returned aliases and copyable operation commands; `--wait 0..60`, `--expand payment_methods`, `--open` | -| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; `authorize --open` opens a returned HTTPS action; `prepare_checkout --params ''` prepares an unused AgentCard card for Square Pay; `fill --params ''` fills checkout fields | +| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; `authorize --open` opens a returned HTTPS action; `prepare_checkout --params ''` prepares an unused AgentCard card for Square Pay; `fill --params ''` fills checkout or login fields; `collect --open` opens a credential item's hosted form | | `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | | `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | | `kernel vaults provider-configs create --name --provider link\|agentcard --client-id ` | Register customer-owned provider credentials; `--client-secret` or `--client-secret-file` (`-` reads stdin) | @@ -542,10 +544,10 @@ that an order or charge succeeded; `stopped` cannot be reused; `outcome_unknown` and requires merchant reconciliation. Inspect `items events` and reconcile uncertain outcomes with the merchant rather than preparing again. -##### Fill checkout fields +##### Fill checkout or login fields -Fill is supported only when advertised by a ready Link card, not AgentCard. It writes stored -card data without returning the values or submitting checkout: +Fill is supported when advertised by a ready credential item or a ready Link card, not +AgentCard. It writes stored values without returning them or submitting the form: ```bash kernel vaults items get checkout order-1 @@ -556,6 +558,7 @@ kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browse the CLI does not resolve names. - `page_url` is the exact current top-level HTTPS URL, including path, query, and fragment, without embedded credentials. It must match exactly one open page; no prefix/glob matching. + Cards require it. Credential items may omit it, which then requires exactly one open page. - `fields` contains 1-32 bindings in write order. Each has `field` and a nonempty CSS `selector` targeting an editable input/select or its container. The API searches the selected page and descendants, including payment iframes. Do not supply frame IDs or literal values. @@ -564,6 +567,9 @@ kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browse `billing_country`. Billing fields use the stored address without reformatting; request only needed fields. Missing requested billing data fails validation before browser writes. - Combined `expiration` requires `format: "MM/YY"` or `"MM/YYYY"`. Other fields reject `format`. +- For a credential item, each `field` is a declared field name that has a stored value, and + `format` is rejected. A `totp` field fills a freshly generated code; its seed never enters + the browser. - Optional `timeout_ms` is an integer from 1 to 30000 (default 10000), for the whole operation. Fill returns an execution result, **not an updated item**. Normal output shows zero-based @@ -891,10 +897,11 @@ vaults to a session with `kernel browsers create --vault `. #### Vault Items -An item is either a wallet (an authorized funding source) or a card (a payment -credential minted from a wallet). Items advertise the operations valid in their -current state, so run `kernel vaults items get` and read `Available Operations` -before invoking one. +An item is a wallet (an authorized funding source), a card (a payment credential +minted from a wallet), or a credential (a login or other non-payment secret with +no wallet or provider). Items advertise the operations valid in their current +state, so run `kernel vaults items get` and read `Available Operations` before +invoking one. - `kernel vaults items list ` - List a vault's items; secret values are never returned - `--output json`, `-o json` - Output raw JSON array @@ -925,9 +932,9 @@ before invoking one. - `--spec ` / `--spec-file ` - Full replacement card spec (only card items can be updated) - `--output json`, `-o json` - Output raw JSON object - `kernel vaults items invoke ` - Perform an operation the item advertises - - `` - Operation to perform, e.g. `authorize`, `prepare_checkout`, or `fill`. Operations may call an external provider and return the item's updated state. - - `--params ` - Operation inputs for `prepare_checkout` and `fill`; omit `type` - - `--open` - Open a returned HTTPS action or approval URL for `authorize` and `prepare_checkout` + - `` - Operation to perform, e.g. `authorize`, `collect`, `prepare_checkout`, or `fill`. Operations may call an external provider and return the item's updated state. + - `--params ` - Operation inputs for `prepare_checkout` and `fill`; omit `type`. `authorize` and `collect` take none. + - `--open` - Open a returned HTTPS action or approval URL for `authorize`, `collect`, and `prepare_checkout` - `--output json`, `-o json` - Output raw JSON object - `kernel vaults items events ` - List an item's immutable audit events, oldest first - `--after ` - Return only events after this event ID @@ -936,6 +943,45 @@ before invoking one. - `kernel vaults items delete ` - Delete an item; its secret value is invalidated - `-y, --yes` - Skip confirmation prompt +#### Credential Items + +A credential item stores a login or other non-payment secret with no wallet and no +external provider. Declare its fields once; field names, types, required flags, and +sensitivity are fixed at creation. Never store card numbers, security codes, or +expiration dates in a credential item - use wallet and card items for payments. + +Values are write-only and never appear in shell arguments: pass them through +`--values-file ` (or `-` for stdin) as a JSON object of field names to values. + +- `kernel vaults credentials create --spec ` - Declare a credential item + - `--spec ` - `{"description"?: string, "fields": {"": {"type": "text"|"email"|"password"|"totp", "required"?: bool, "sensitive"?: bool}}}` (required). Field names match `[a-zA-Z][a-zA-Z0-9_]{0,63}`; 1-32 fields. Values are rejected here. + - `--values-file ` - JSON object of declared field names to non-empty string values (`-` reads stdin) + - `--open` - Open a returned HTTPS collection URL + - `--output json`, `-o json` - Output raw JSON object +- `kernel vaults credentials update --version ` - Set or clear values and the description + - `--version ` - Expected current item version from the latest read (required). A concurrent edit returns 409 instead of being overwritten. + - `--values-file ` - JSON object of field names to values; `null` or `""` clears one immediately (`-` reads stdin) + - `--description ` - Replacement form title; `""` clears it + - `--expected-item-id ` - Immutable item ID precondition; returns 409 if the key now identifies a different item + - `--output json`, `-o json` - Output raw JSON object + + Example: + + ```bash + kernel vaults credentials create logins hacker-news --spec '{"description":"Hacker News","fields":{"username":{"type":"text","sensitive":false},"password":{"type":"password"}}}' --values-file ./values.json --open + + # Open the hosted form again for the person who holds the credential + kernel vaults items invoke logins hacker-news collect --open + + # Fill the login into a browser created with --vault logins + kernel vaults items invoke logins hacker-news fill -o json --params '{"browser_id":"browser-session-id","fields":[{"field":"username","selector":"#login"},{"field":"password","selector":"#password"}]}' + ``` + +If every required field has a value, the item is `ready` and no collection action is +returned; `collect` still opens its form. Otherwise the item is `pending_collection` +with a time-scoped hosted form URL - treat that URL as a secret. `ready` means the +required values are present, not that a login succeeded. + ### Projects - `kernel projects list` - List projects (up to 100 by default) diff --git a/cmd/vaults.go b/cmd/vaults.go index 8071a12a..48fad96a 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -143,7 +143,7 @@ func (c VaultsCmd) ListItems(ctx context.Context, vault, output string) error { if err != nil { return err } - rows = append(rows, []string{item.Key, item.Type, item.Spec.Provider, item.State.Status, util.OrDash(actions.RequiredAction)}) + rows = append(rows, []string{item.Key, item.Type, util.OrDash(item.Spec.Provider), item.State.Status, util.OrDash(actions.RequiredAction)}) } PrintTableNoPad(rows, true) return nil @@ -192,7 +192,7 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. var item *kernel.VaultItemUnion var err error if update { - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, Spec: spec}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCardVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyCardVaultItemUpdateRequest{Spec: spec}}, option.WithMaxRetries(0)) } else { item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) } @@ -246,10 +246,16 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } if operation == "fill" { - return c.fill(ctx, vault, key, params.Fill, output) + if err := validateVaultFillForItem(item, params.Fill); err != nil { + return err + } + return c.fill(ctx, vault, key, item.Type, params.Fill, output) } body := kernel.VaultItemPerformOperationParams{IDOrName: vault} - if operation == "prepare_checkout" { + switch operation { + case "collect": + body.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: kernel.CollectVaultItemOperationRequestTypeCollect} + case "prepare_checkout": body.OfPrepareCheckout = &kernel.PrepareCheckoutVaultItemOperationRequestParam{ Type: kernel.PrepareCheckoutVaultItemOperationRequestTypePrepareCheckout, Checkout: kernel.VaultCheckoutContextParam{ @@ -258,7 +264,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par Environment: kernel.VaultCheckoutContextEnvironment(params.Checkout.Environment), }, } - } else { + default: // Preserve support for other advertised parameterless operations. body.OfAuthorize = &kernel.AuthorizeVaultItemOperationRequestParam{Type: kernel.AuthorizeVaultItemOperationRequestType(operation)} } @@ -269,7 +275,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } return util.CleanedUpSdkError{Err: err} } - if response == nil || (response.Type != "card" && response.Type != "wallet") { + if response == nil || (response.Type != "card" && response.Type != "wallet" && response.Type != "credential") { return fmt.Errorf("unexpected vault operation response; inspect the item and do not retry") } var updated kernel.VaultItemUnion diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 7427a15d..088af7c4 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -52,7 +52,7 @@ func vaultPreRun(cmd *cobra.Command, args []string) error { func newVaultsCommand() *cobra.Command { cmd := &cobra.Command{ Use: "vaults", Aliases: []string{"vault"}, Short: "Prepare and observe project-owned payment credentials", - Long: `Prepare and observe payment credentials; vault commands do not submit merchant payments. + Long: `Prepare and observe payment credentials and stored logins; vault commands do not submit merchant payments. Optionally select a project with --project or KERNEL_PROJECT. Otherwise, the API resolves the project from your credentials and its defaults. @@ -68,6 +68,12 @@ Vault names, item keys, and project ownership are immutable. aliases are an alternative for explicitly chosen egress-substitution integrations, not a fallback after fill. Inspect items get/events for payment outcomes. +For logins and other non-payment credentials, use credentials create instead of a +wallet and card: declare the fields, supply any known values with --values-file, +and hand the returned collection URL to whoever holds the credential. Attach the +vault with browsers create --vault, then use advertised fill to bind its fields. +Credential items must never hold card numbers, security codes, or expiration dates. + Permitted checkout domains are provider-assigned and displayed when returned; there is no domain-setting API. Never supply card data, OAuth codes, ciphertext, or secrets in shell arguments. @@ -139,6 +145,10 @@ JSON output preserves returned public fields but omits unknown/opaque provider d Read its description with items get before invoking; follow any approval requirements. Authorize sends {"type":"authorize"} without --params and returns an updated item; --open opens its returned HTTPS action URL. +Collect is advertised by ready and pending_collection credential items. It takes no +--params and returns the item with a time-scoped hosted form URL, reusing an active +session or renewing an expired one; --open opens it. Opening the form clears no +values and changes neither readiness nor the item version. Treat the URL as a secret. Prepare_checkout is advertised by eligible unused AgentCard cards before the first Square Pay action. It requires --params with browser_id (session ID of a browser created with this vault attached), merchant_origin (canonical origin of the @@ -150,13 +160,17 @@ submit native Pay before the preparation deadline; readiness lasts at most 30 seconds and polling never extends it. Unused preparations expire automatically. Every preparation is single-use, including after failure or expiry: do not automatically retry, and reconcile uncertain outcomes with the merchant. -Fill requires --params JSON with browser_id (session ID, not name), exact HTTPS -page_url, and 1-32 fields. Each binding has field and selector; expiration also -requires format MM/YY or MM/YYYY. Stored fields: number, cvc, exp_month (MM), -exp_year (YYYY), billing_name, billing_line1, billing_line2, billing_city, -billing_state, billing_postal_code, billing_country. Optional timeout_ms is 1-30000 -(default 10000). Do not include type, values, or frame IDs in --params. -Fill is available only when advertised by a ready Link card, not AgentCard. +Fill requires --params JSON with browser_id (session ID, not name) and 1-32 fields. +Each binding has field and selector. Optional timeout_ms is 1-30000 (default 10000). +Do not include type, values, or frame IDs in --params. +For cards, page_url is a required exact HTTPS URL and each field is one of number, +cvc, exp_month (MM), exp_year (YYYY), billing_name, billing_line1, billing_line2, +billing_city, billing_state, billing_postal_code, billing_country, or the combined +expiration, which also requires format MM/YY or MM/YYYY. Fill is available only when +advertised by a ready Link card, not AgentCard. +For credentials, each field is a declared field name with a stored value, format is +not accepted, and page_url may be omitted to require exactly one open page. A totp +field fills a freshly generated code; its seed never enters the browser. The API searches the selected page and descendant frames, including payment iframes. Fill returns value-free per-field outcomes, not an updated item. Completed exits 0; failed/unknown exit nonzero while preserving the result in -o json. @@ -166,7 +180,9 @@ Inspect the browser before deciding what to do next; completed does not mean pai Example: ` kernel vaults items get checkout order-1 kernel vaults items invoke checkout order-1 authorize --open kernel vaults items invoke checkout order-1 prepare_checkout --params '{"browser_id":"browser-session-id","merchant_origin":"https://shop.example.com","environment":"production"}' --open - kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"},{"field":"expiration","format":"MM/YY","selector":"#expiry"},{"field":"cvc","selector":"#security-code"}],"timeout_ms":10000}' -o json`, + kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"},{"field":"expiration","format":"MM/YY","selector":"#expiry"},{"field":"cvc","selector":"#security-code"}],"timeout_ms":10000}' -o json + kernel vaults items invoke logins hacker-news collect --open + kernel vaults items invoke logins hacker-news fill --params '{"browser_id":"browser-session-id","fields":[{"field":"username","selector":"#login"},{"field":"password","selector":"#password"}]}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { open, _ := cmd.Flags().GetBool("open") raw, _ := cmd.Flags().GetString("params") @@ -177,7 +193,7 @@ Inspect the browser before deciding what to do next; completed does not mean pai return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} invoke.Flags().String("params", "", "Operation-specific JSON object for fill and prepare_checkout; omit type (supplied by )") - invoke.Flags().Bool("open", false, "Open a returned HTTPS action or approval URL for authorize and prepare_checkout") + invoke.Flags().Bool("open", false, "Open a returned HTTPS action or approval URL for authorize, collect, and prepare_checkout") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) @@ -220,10 +236,85 @@ Inspect the browser before deciding what to do next; completed does not mean pai cards := &cobra.Command{Use: "cards", Short: "Configure card requests"} cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) - cmd.AddCommand(items, wallets, cards) + + credentials := &cobra.Command{Use: "credentials", Aliases: []string{"credential"}, Short: "Store logins and other non-payment credentials"} + credentials.AddCommand(newVaultCredentialCreateCommand(), newVaultCredentialUpdateCommand()) + cmd.AddCommand(items, wallets, cards, credentials) + return cmd +} + +func newVaultCredentialCreateCommand() *cobra.Command { + cmd := &cobra.Command{Use: "create --spec ''", Short: "Declare a credential item and optionally seed its values", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: `Create a credential item at an immutable key, without a wallet or provider. +Repeating the original creation request returns the current item without +overwriting later edits; a different request at the same key returns 409. +Use vaults credentials update for changes. +` + vaultCredentialSpecHelp, + Example: ` kernel vaults credentials create logins hacker-news --spec '{ + "description": "Hacker News", + "fields": { + "username": {"type": "text", "sensitive": false}, + "password": {"type": "password"} + } + }' --values-file ./values.json --open`, + RunE: func(cmd *cobra.Command, args []string) error { + spec, err := vaultCredentialSpecFromFlags(cmd) + if err != nil { + return err + } + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).CreateCredential(cmd.Context(), args[0], args[1], spec, vaultOutput(cmd), open) + }} + cmd.Flags().String("spec", "", "Credential specification JSON with fields and an optional description (required)") + _ = cmd.MarkFlagRequired("spec") + addVaultCredentialValuesFlag(cmd) + cmd.Flags().Bool("open", false, "Open a returned HTTPS collection URL in your browser") + addVaultJSONOutputFlag(cmd) return cmd } +func newVaultCredentialUpdateCommand() *cobra.Command { + cmd := &cobra.Command{Use: "update --version ", Short: "Set or clear credential values and the description", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: `Atomically update the description and selected values; omitted properties are preserved. +--version is the expected current item version from the latest read, so a +concurrent edit returns 409 instead of being overwritten. Read it with items get. +Field names, types, required flags, and sensitivity cannot change, and unknown +field names return 400. A successful update increments the version and invalidates +outstanding hosted collection sessions. + +--values-file sets values; a JSON null or empty string clears one immediately. +Clearing a required field reopens collection and returns a fresh collection action; +clearing a required totp field returns 400 because no form can collect it. +--description "" clears the description.`, + Example: ` kernel vaults credentials update logins hacker-news --version 3 --values-file ./values.json + kernel vaults credentials update logins hacker-news --version 3 --description "Hacker News"`, + RunE: func(cmd *cobra.Command, args []string) error { + version, _ := cmd.Flags().GetInt64("version") + if version < 1 { + return fmt.Errorf("--version must be the expected current item version (1 or greater)") + } + expectedItemID, _ := cmd.Flags().GetString("expected-item-id") + spec, err := vaultCredentialUpdateSpecFromFlags(cmd) + if err != nil { + return err + } + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).UpdateCredential(cmd.Context(), args[0], args[1], version, expectedItemID, spec, vaultOutput(cmd), open) + }} + cmd.Flags().Int64("version", 0, "Expected current item version from the latest read (required)") + _ = cmd.MarkFlagRequired("version") + cmd.Flags().String("description", "", "Replacement form title; an empty string clears it") + cmd.Flags().String("expected-item-id", "", "Immutable item ID precondition; returns 409 if the key now identifies a different item") + addVaultCredentialValuesFlag(cmd) + cmd.Flags().Bool("open", false, "Open a returned HTTPS collection URL in your browser") + addVaultJSONOutputFlag(cmd) + return cmd +} + +func addVaultCredentialValuesFlag(cmd *cobra.Command) { + cmd.Flags().String("values-file", "", "JSON object of field names to values, read from a file (use '-' for stdin); never pass values as shell arguments") +} + func newVaultDeleteCommand(item bool) *cobra.Command { use, short, nargs := "delete ", "Delete a vault and invalidate all its items", 1 long := short + ".\nUnresolved payment operations block deletion, including operations on child cards of a wallet." diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go new file mode 100644 index 00000000..f1f2b52d --- /dev/null +++ b/cmd/vaults_credentials.go @@ -0,0 +1,180 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "sort" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/param" + "github.com/spf13/cobra" +) + +var vaultCredentialFieldTypes = []string{"text", "email", "password", "totp"} + +// CreateCredential stores a credential item without a wallet or provider. Values +// arrive through a file so secrets never appear in shell arguments; omitted +// required values leave the item pending_collection with a hosted form action. +func (c VaultsCmd) CreateCredential(ctx context.Context, vault, key string, spec map[string]json.RawMessage, output string, open bool) error { + item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{ + IDOrName: vault, + OfCredential: &kernel.CredentialVaultItemRequestParam{ + Type: kernel.CredentialVaultItemRequestTypeCredential, + Spec: param.Override[kernel.CredentialVaultItemSpecInputParam](spec), + }, + }, option.WithMaxRetries(0)) + if err != nil { + return vaultCredentialItemError(err) + } + return c.showItem(item, output, open) +} + +// UpdateCredential atomically replaces selected values and the description. +// --version is the expected current item version, so a concurrent edit returns +// 409 instead of silently overwriting it. +func (c VaultsCmd) UpdateCredential(ctx context.Context, vault, key string, version int64, expectedItemID string, spec map[string]json.RawMessage, output string, open bool) error { + request := kernel.CredentialVaultItemUpdateRequestParam{ + Type: kernel.CredentialVaultItemUpdateRequestTypeCredential, + Version: version, + Spec: param.Override[kernel.CredentialVaultItemSpecUpdateParam](spec), + } + if expectedItemID != "" { + request.ExpectedItemID = kernel.Opt(expectedItemID) + } + item, err := c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &request}, option.WithMaxRetries(0)) + if err != nil { + return vaultCredentialItemError(err) + } + return c.showItem(item, output, open) +} + +// The spec is forwarded without defaults or normalization, like card and wallet +// specs. Only the shape the CLI must reason about is checked here. +func vaultCredentialSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { + raw, _ := cmd.Flags().GetString("spec") + var spec map[string]json.RawMessage + if json.Unmarshal([]byte(raw), &spec) != nil || spec == nil { + return nil, fmt.Errorf("--spec must be a JSON object with fields and an optional description") + } + for key := range spec { + if key != "fields" && key != "description" { + return nil, fmt.Errorf("--spec supports only description and fields") + } + } + fields, err := vaultCredentialFieldsFromSpec(spec["fields"]) + if err != nil { + return nil, err + } + if cmd.Flags().Changed("values-file") { + values, err := readVaultFieldValues(cmd, "values-file") + if err != nil { + return nil, err + } + if err := vaultCredentialApplyValues(fields, values); err != nil { + return nil, err + } + } + encoded, err := json.Marshal(fields) + if err != nil { + return nil, err + } + spec["fields"] = encoded + return spec, nil +} + +func vaultCredentialFieldsFromSpec(raw json.RawMessage) (map[string]json.RawMessage, error) { + var fields map[string]json.RawMessage + if raw == nil || json.Unmarshal(raw, &fields) != nil || len(fields) < 1 || len(fields) > 32 { + return nil, fmt.Errorf("--spec.fields must be a JSON object declaring 1-32 fields") + } + for name, raw := range fields { + if !vaultFieldNamePattern.MatchString(name) { + return nil, fmt.Errorf("--spec.fields names must match [a-zA-Z][a-zA-Z0-9_]{0,63}") + } + var field map[string]json.RawMessage + if json.Unmarshal(raw, &field) != nil || field == nil { + return nil, fmt.Errorf("--spec.fields[%q] must be a JSON object", name) + } + if _, ok := field["value"]; ok { + return nil, fmt.Errorf("--spec must not contain field values; supply them with --values-file") + } + var fieldType string + if json.Unmarshal(field["type"], &fieldType) != nil || !slices.Contains(vaultCredentialFieldTypes, fieldType) { + return nil, fmt.Errorf("--spec.fields[%q].type must be text, email, password, or totp", name) + } + } + return fields, nil +} + +// Creation rejects null and empty values, so refuse them before sending a +// request that cannot succeed. +func vaultCredentialApplyValues(fields map[string]json.RawMessage, values map[string]*string) error { + for _, name := range sortedVaultFieldNames(values) { + raw, declared := fields[name] + if !declared { + return fmt.Errorf("--values-file field %q is not declared in --spec.fields", name) + } + if values[name] == nil || *values[name] == "" { + return fmt.Errorf("--values-file value for %q must be a non-empty string; omit the field to leave it unset", name) + } + var field map[string]json.RawMessage + if err := json.Unmarshal(raw, &field); err != nil { + return err + } + encoded, err := json.Marshal(*values[name]) + if err != nil { + return err + } + field["value"] = encoded + if fields[name], err = json.Marshal(field); err != nil { + return err + } + } + return nil +} + +// An update must change something: the API rejects an empty spec. +func vaultCredentialUpdateSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { + spec := make(map[string]json.RawMessage) + if cmd.Flags().Changed("description") { + description, _ := cmd.Flags().GetString("description") + encoded, err := json.Marshal(description) + if err != nil { + return nil, err + } + spec["description"] = encoded + } + if cmd.Flags().Changed("values-file") { + values, err := readVaultFieldValues(cmd, "values-file") + if err != nil { + return nil, err + } + fields := make(map[string]json.RawMessage, len(values)) + for _, name := range sortedVaultFieldNames(values) { + encoded, err := json.Marshal(map[string]*string{"value": values[name]}) + if err != nil { + return nil, err + } + fields[name] = encoded + } + if spec["fields"], err = json.Marshal(fields); err != nil { + return nil, err + } + } + if len(spec) == 0 { + return nil, fmt.Errorf("update requires --description, --values-file, or both") + } + return spec, nil +} + +func sortedVaultFieldNames[T any](values map[string]T) []string { + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go new file mode 100644 index 00000000..c7e4d6c7 --- /dev/null +++ b/cmd/vaults_credentials_test.go @@ -0,0 +1,281 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const credentialSpecFixture = `{"description":"Hacker News","fields":{"username":{"type":"text","sensitive":false},"password":{"type":"password"}}}` + +const pendingCredentialFixture = `{"id":"item-1","key":"hacker-news","type":"credential","version":1,` + + `"spec":{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}},` + + `"state":{"status":"pending_collection","fields":{"username":{"has_value":true,"value":"ada"},"password":{"has_value":false}}},` + + `"action":{"name":"collect","url":"https://vault.kernel.sh/c/session-token","expires_at":"2026-09-14T00:30:00Z"},` + + `"available_operations":[{"type":"collect","description":"Open the collection form."}],"available_expansions":[],` + + `"created_at":"2026-09-14T00:00:00Z","updated_at":"2026-09-14T00:00:00Z"}` + +const readyCredentialFixture = `{"id":"item-1","key":"hacker-news","type":"credential","version":2,` + + `"spec":{"fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}},` + + `"state":{"status":"ready","fields":{"username":{"has_value":true,"value":"ada"},"password":{"has_value":true}}},` + + `"available_operations":[{"type":"collect","description":"Open the collection form."},{"type":"fill","description":"Fill login fields."}],"available_expansions":[],` + + `"created_at":"2026-09-14T00:00:00Z","updated_at":"2026-09-14T00:00:00Z"}` + +func writeVaultValuesFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "values.json") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func TestVaultCredentialCreateRequestMapping(t *testing.T) { + var body []byte + var method, path string + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, pendingCredentialFixture) + }) + values := writeVaultValuesFile(t, `{"username":"ada"}`) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "logins", "hacker-news", "--spec", credentialSpecFixture, "--values-file", values, "-o", "json") + require.NoError(t, err) + assert.Equal(t, http.MethodPut, method) + assert.Equal(t, "/vaults/logins/items/hacker-news", path) + assert.JSONEq(t, `{"type":"credential","spec":{"description":"Hacker News","fields":{"username":{"type":"text","sensitive":false,"value":"ada"},"password":{"type":"password"}}}}`, string(body)) + assert.JSONEq(t, pendingCredentialFixture, out) +} + +func TestVaultCredentialCreateValidation(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + values := writeVaultValuesFile(t, `{"username":"ada"}`) + for name, args := range map[string][]string{ + "spec not an object": {"--spec", `[]`}, + "spec scalar": {"--spec", `"credential-sentinel"`}, + "unsupported key": {"--spec", `{"fields":{"a":{"type":"text"}},"provider":"link"}`}, + "missing fields": {"--spec", `{"description":"x"}`}, + "empty fields": {"--spec", `{"fields":{}}`}, + "bad field name": {"--spec", `{"fields":{"user-name":{"type":"text"}}}`}, + "bad field type": {"--spec", `{"fields":{"username":{"type":"credential-sentinel"}}}`}, + "missing field type": {"--spec", `{"fields":{"username":{}}}`}, + "inline value": {"--spec", `{"fields":{"username":{"type":"text","value":"credential-sentinel"}}}`}, + "undeclared value": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `{"nickname":"ada"}`)}, + "null value": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `{"username":null}`)}, + "empty value": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `{"username":""}`)}, + "values not object": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `["ada"]`)}, + "missing values file": {"--spec", credentialSpecFixture, "--values-file", filepath.Join(t.TempDir(), "absent.json")}, + "empty values": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `{}`)}, + "bad values name": {"--spec", credentialSpecFixture, "--values-file", writeVaultValuesFile(t, `{"user-name":"ada"}`)}, + } { + t.Run(name, func(t *testing.T) { + out, human, err := executeVaultCommand(t, client, append([]string{"vaults", "credentials", "create", "logins", "hacker-news"}, args...)...) + require.Error(t, err) + assert.NotContains(t, err.Error(), "credential-sentinel") + assert.Empty(t, out) + assert.Empty(t, human) + }) + } + // --spec is required; values alone cannot declare a schema. + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "logins", "hacker-news", "--values-file", values) + require.Error(t, err) +} + +func TestVaultCredentialUpdateRequestMapping(t *testing.T) { + var body []byte + var method string + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + method = r.Method + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, readyCredentialFixture) + }) + values := writeVaultValuesFile(t, `{"password":"hunter2","username":null}`) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "update", "logins", "hacker-news", + "--version", "1", "--expected-item-id", "item-1", "--description", "Hacker News", "--values-file", values, "-o", "json") + require.NoError(t, err) + assert.Equal(t, http.MethodPatch, method) + assert.JSONEq(t, `{"type":"credential","version":1,"expected_item_id":"item-1","spec":{"description":"Hacker News","fields":{"password":{"value":"hunter2"},"username":{"value":null}}}}`, string(body)) + assert.JSONEq(t, readyCredentialFixture, out) + assert.NotContains(t, out, "hunter2") +} + +func TestVaultCredentialUpdateDescriptionOnlyAndValidation(t *testing.T) { + var body []byte + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, readyCredentialFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "update", "logins", "hacker-news", "--version", "2", "--description", "", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"type":"credential","version":2,"spec":{"description":""}}`, string(body)) + + strict := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + for name, args := range map[string][]string{ + "no changes": {"--version", "2"}, + "zero version": {"--version", "0", "--description", "x"}, + "bad version": {"--version", "-1", "--description", "x"}, + } { + t.Run(name, func(t *testing.T) { + _, _, err := executeVaultCommand(t, strict, append([]string{"vaults", "credentials", "update", "logins", "hacker-news"}, args...)...) + require.Error(t, err) + }) + } + _, _, err = executeVaultCommand(t, strict, "vaults", "credentials", "update", "logins", "hacker-news", "--description", "x") + require.ErrorContains(t, err, "version") +} + +func TestVaultCredentialValuesFromStdin(t *testing.T) { + var body []byte + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, readyCredentialFixture) + }) + stdin := strings.NewReader(`{"password":"hunter2"}`) + _, _, err := executeVaultCommandWithStdin(t, client, stdin, "vaults", "credentials", "update", "logins", "hacker-news", "--version", "1", "--values-file", "-", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"type":"credential","version":1,"spec":{"fields":{"password":{"value":"hunter2"}}}}`, string(body)) +} + +func TestVaultCollectOperation(t *testing.T) { + calls := 0 + var body []byte + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls > 1 { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vaults/logins/items/hacker-news/operations", r.URL.Path) + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, pendingCredentialFixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "logins", "hacker-news", "collect", "-o", "json") + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.JSONEq(t, `{"type":"collect"}`, string(body)) + assert.JSONEq(t, pendingCredentialFixture, out) + + // collect takes no parameters. + _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "logins", "hacker-news", "collect", "--params", `{}`) + require.ErrorContains(t, err, "--params") +} + +func TestVaultCredentialItemHumanOutput(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, pendingCredentialFixture) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "logins", "hacker-news") + require.NoError(t, err) + assert.Contains(t, human, "credential") + assert.Contains(t, human, "Version") + assert.Contains(t, human, "Hacker News") + assert.Contains(t, human, "pending_collection") + // The declared schema and per-field presence are both shown. + assert.Contains(t, human, "username") + assert.Contains(t, human, "ada") + assert.Contains(t, human, "password") + assert.Contains(t, human, "(withheld)") + assert.Contains(t, human, "https://vault.kernel.sh/c/session-token") + assert.Contains(t, human, "Collection action") +} + +func TestVaultCredentialFillBindings(t *testing.T) { + calls := 0 + var body []byte + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, readyCredentialFixture) + return + } + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + _, _ = io.WriteString(w, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"},{"index":1,"status":"filled"}]}`) + }) + // Credential items may omit page_url to require exactly one open page. + params := `{"browser_id":"browser-session-id","fields":[{"field":"username","selector":"#login"},{"field":"password","selector":"#password"}]}` + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "logins", "hacker-news", "fill", "--params", params, "-o", "json") + require.NoError(t, err) + assert.Equal(t, 2, calls) + var request map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &request)) + _, hasPageURL := request["page_url"] + assert.False(t, hasPageURL) + assert.JSONEq(t, `[{"field":"username","selector":"#login"},{"field":"password","selector":"#password"}]`, string(request["fields"])) + assert.Contains(t, out, "completed") +} + +func TestVaultFillItemTypeValidation(t *testing.T) { + for name, tc := range map[string]struct{ fixture, params, message string }{ + "card without page_url": { + readyFillCardFixture, + `{"browser_id":"b","fields":[{"field":"number","selector":"#n"}]}`, + "page_url", + }, + "credential field not declared": { + readyCredentialFixture, + `{"browser_id":"b","fields":[{"field":"nickname","selector":"#n"}]}`, + "not declared", + }, + "credential expiration format": { + readyCredentialFixture, + `{"browser_id":"b","fields":[{"field":"expiration","format":"MM/YY","selector":"#e"}]}`, + "expiration", + }, + } { + t.Run(name, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, tc.fixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "logins", "hacker-news", "fill", "--params", tc.params, "-o", "json") + require.ErrorContains(t, err, tc.message) + assert.Equal(t, 1, calls, "no operation may be posted after a rejected binding") + }) + } +} + +func TestVaultCredentialAPIErrorsAreSpecificAndSafe(t *testing.T) { + for status, message := range map[int]string{400: "declared", 409: "items get", 500: "inspect existing state"} { + t.Run(http.StatusText(status), func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"error":{"message":"credential-sentinel"}}`) + }) + values := writeVaultValuesFile(t, `{"username":"credential-sentinel"}`) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "logins", "hacker-news", "--spec", credentialSpecFixture, "--values-file", values) + require.ErrorContains(t, err, message) + assert.NotContains(t, err.Error(), "credential-sentinel") + + _, _, err = executeVaultCommand(t, client, "vaults", "credentials", "update", "logins", "hacker-news", "--version", "1", "--values-file", values) + require.ErrorContains(t, err, message) + assert.NotContains(t, err.Error(), "credential-sentinel") + }) + } +} diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 7f63c6c0..58bb251f 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -41,20 +41,23 @@ func vaultFillRequestError(err error) error { return fmt.Errorf("fill result unavailable; %s", vaultFillUncertain) } -func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFillParams, output string) error { +func (c VaultsCmd) fill(ctx context.Context, vault, key, itemType string, params *vaultFillParams, output string) error { request := kernel.FillVaultItemOperationRequestParam{ BrowserID: params.BrowserID, - PageURL: params.PageURL, Type: kernel.FillVaultItemOperationRequestTypeFill, - Fields: make([]kernel.VaultCardFillFieldUnionParam, 0, len(params.Fields)), + Fields: make([]kernel.VaultFillFieldParam, 0, len(params.Fields)), + } + // Credential items may omit page_url to require exactly one open page. + if params.PageURL != "" { + request.PageURL = kernel.Opt(params.PageURL) } if params.TimeoutMS != nil { request.TimeoutMs = kernel.Opt(int64(*params.TimeoutMS)) } for _, field := range params.Fields { - binding := kernel.VaultCardFillFieldParamOfVaultCardFillFieldVaultCardStoredFillField(field.Field, field.Selector) - if field.Field == "expiration" { - binding = kernel.VaultCardFillFieldParamOfVaultCardFillFieldVaultCardExpirationFillField(field.Field, field.Format, field.Selector) + binding := kernel.VaultFillFieldParam{Field: field.Field, Selector: field.Selector} + if field.Format != "" { + binding.Format = kernel.VaultFillFieldFormat(field.Format) } request.Fields = append(request.Fields, binding) } @@ -81,7 +84,11 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil } PrintTableNoPad(rows, true) if result.Status == "completed" { - pterm.Println("Fields filled; this does not confirm payment or merchant acceptance.") + if itemType == "credential" { + pterm.Println("Fields filled; this does not confirm that the site accepted the values or that a login succeeded.") + } else { + pterm.Println("Fields filled; this does not confirm payment or merchant acceptance.") + } } else { pterm.Println(vaultFillUncertain) } diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index b0531f63..0d595138 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -47,7 +47,6 @@ func TestVaultFillParamsValidation(t *testing.T) { "bad port": replace(`shop.example`, `shop.example:secret`), "URL whitespace": replace(`checkout?`, `checkout ?`), "URL type": replace(`"https://shop.example/checkout?step=2#payment"`, `123`), - "missing URL": replace(`"page_url":"https://shop.example/checkout?step=2#payment",`, ``), "empty fields": replace(`[{"field":"number","selector":"#card-number"},{"field":"expiration","format":"MM/YY","selector":"#expiry"},{"field":"cvc","selector":"#security-code"}]`, `[]`), "fields object": `{"browser_id":"id","page_url":"https://shop.example/","fields":{}}`, "missing fields": `{"browser_id":"id","page_url":"https://shop.example/"}`, diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index d19e5b05..b209bea8 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -101,3 +101,34 @@ type LinkTotal = { Permitted domains are provider-assigned, not configurable in the spec. ` + +const vaultCredentialSpecHelp = ` +--spec takes the credential specification object: an optional description and a +fields map declaring 1-32 fields. Field names match [a-zA-Z][a-zA-Z0-9_]{0,63}. +Values are never accepted in --spec; supply them with --values-file , +a JSON object mapping declared field names to values. Field names, types, +required flags, and sensitivity are fixed at creation and cannot be changed. + +type CredentialSpec = { + description?: string; // site or service name used verbatim as the form title + fields: Record; +}; + +Set sensitive false for ordinary usernames and email addresses so the form can +display and prefill them; reserve true for passwords, API tokens, and TOTP seeds. +A totp value is an RFC 4648 Base32 generator seed, not an otpauth URI or a current +code; browser fill derives the code and never writes the seed. A required totp +field must be given a seed at creation, because no form can collect it. + +If every required field has a value, the item is ready and no collection action is +returned; invoke collect to open its form anyway. Otherwise the item is +pending_collection with a time-scoped hosted form URL. Treat that URL as a secret. + +Credential items are for logins and other non-payment credentials. Do not store, +collect, or fill credit card numbers, security codes, or expiration dates in them; +use wallet and card items for payments instead. +` diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 3cc7bca6..84c24249 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -8,6 +8,8 @@ import ( "regexp" "slices" "strings" + + kernel "github.com/kernel/kernel-go-sdk" ) // vaultOperationParams holds the parsed --params payload for the one operation @@ -40,6 +42,13 @@ type vaultFillField struct { var vaultFillPageURLPattern = regexp.MustCompile(`^https://[^/?#@*\s]+(?:[/?#][^\s]*)?$`) +// Declared credential field names; card field names also satisfy this pattern. +var vaultFieldNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,63}$`) + +// Card fields the API fills from the decrypted card, excluding the combined +// expiration field, which additionally requires a format. +var vaultCardFillFields = []string{"number", "cvc", "exp_month", "exp_year", "billing_name", "billing_line1", "billing_line2", "billing_city", "billing_state", "billing_postal_code", "billing_country"} + // Reject duplicate and unknown keys without including payloads in diagnostics. func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) { invalid := fmt.Errorf("--params must contain JSON objects with only supported, non-duplicate properties") @@ -84,8 +93,8 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( if strings.TrimSpace(operation) == "" { return nil, fmt.Errorf("operation must not be empty") } - if openSet && operation != "authorize" && operation != "prepare_checkout" { - return nil, fmt.Errorf("--open is only supported for authorize and prepare_checkout") + if openSet && operation != "authorize" && operation != "prepare_checkout" && operation != "collect" { + return nil, fmt.Errorf("--open is only supported for authorize, collect, and prepare_checkout") } if operation == "prepare_checkout" { checkout, err := parseVaultCheckoutContext(raw, paramsSet) @@ -96,12 +105,12 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill and prepare_checkout; authorize takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill and prepare_checkout; authorize and collect take no parameters") } return nil, nil } if !paramsSet { - return nil, fmt.Errorf("fill requires --params with browser_id, page_url, and fields") + return nil, fmt.Errorf("fill requires --params with browser_id, fields, and page_url for cards") } object, err := vaultParamsObject(raw, "browser_id page_url fields timeout_ms") if err != nil { @@ -111,12 +120,16 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( if json.Unmarshal(object["browser_id"], ¶ms.BrowserID) != nil || strings.TrimSpace(params.BrowserID) == "" { return nil, fmt.Errorf("--params.browser_id must be a non-empty browser session ID, not a name") } - if json.Unmarshal(object["page_url"], ¶ms.PageURL) != nil || !vaultFillPageURLPattern.MatchString(params.PageURL) { - return nil, fmt.Errorf("--params.page_url must be an exact HTTPS URL without credentials or a wildcard host") - } - u, err := url.Parse(params.PageURL) - if err != nil || u.Hostname() == "" || u.User != nil || u.Opaque != "" { - return nil, fmt.Errorf("--params.page_url must be an exact HTTPS URL without credentials") + // Cards require page_url; credential items may omit it to require exactly one + // open page. The item type decides, so only validate the value when supplied. + if _, ok := object["page_url"]; ok { + if json.Unmarshal(object["page_url"], ¶ms.PageURL) != nil || !vaultFillPageURLPattern.MatchString(params.PageURL) { + return nil, fmt.Errorf("--params.page_url must be an exact HTTPS URL without credentials or a wildcard host") + } + u, err := url.Parse(params.PageURL) + if err != nil || u.Hostname() == "" || u.User != nil || u.Opaque != "" { + return nil, fmt.Errorf("--params.page_url must be an exact HTTPS URL without credentials") + } } if timeout, ok := object["timeout_ms"]; ok { if json.Unmarshal(timeout, ¶ms.TimeoutMS) != nil || params.TimeoutMS == nil || *params.TimeoutMS < 1 || *params.TimeoutMS > 30000 { @@ -137,20 +150,15 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( if json.Unmarshal(field["selector"], &binding.Selector) != nil || strings.TrimSpace(binding.Selector) == "" { return nil, fmt.Errorf("--params.fields[%d].selector must be a non-empty CSS selector", i) } - if json.Unmarshal(field["field"], &binding.Field) != nil { - return nil, fmt.Errorf("--params.fields[%d].field must be a supported card field", i) + if json.Unmarshal(field["field"], &binding.Field) != nil || !vaultFieldNamePattern.MatchString(binding.Field) { + return nil, fmt.Errorf("--params.fields[%d].field must be a supported card field or a declared credential field name", i) } - switch binding.Field { - case "expiration": + if binding.Field == "expiration" { if json.Unmarshal(field["format"], &binding.Format) != nil || (binding.Format != "MM/YY" && binding.Format != "MM/YYYY") { return nil, fmt.Errorf("--params.fields[%d].format must be MM/YY or MM/YYYY for expiration", i) } - case "number", "cvc", "exp_month", "exp_year", "billing_name", "billing_line1", "billing_line2", "billing_city", "billing_state", "billing_postal_code", "billing_country": - if _, ok := field["format"]; ok { - return nil, fmt.Errorf("--params.fields[%d].format is only supported for expiration", i) - } - default: - return nil, fmt.Errorf("--params.fields[%d].field must be a supported card field", i) + } else if _, ok := field["format"]; ok { + return nil, fmt.Errorf("--params.fields[%d].format is only supported for a card's combined expiration field", i) } params.Fields = append(params.Fields, binding) } @@ -207,3 +215,28 @@ func vaultMerchantOrigin(value string) (string, error) { } return u.Scheme + "://" + u.Host, nil } + +// Card and credential items accept different bindings, and the item type is only +// known after the item is read. Reject mismatches before any browser writes. +func validateVaultFillForItem(item *kernel.VaultItemUnion, params *vaultFillParams) error { + if item.Type == "credential" { + for i, field := range params.Fields { + if field.Format != "" { + return fmt.Errorf("--params.fields[%d].format is only supported for a card's combined expiration field", i) + } + if _, declared := item.Spec.Fields[field.Field]; !declared { + return fmt.Errorf("--params.fields[%d].field %q is not declared on this credential item", i, field.Field) + } + } + return nil + } + if params.PageURL == "" { + return fmt.Errorf("--params.page_url is required for card items; only credential items may omit it") + } + for i, field := range params.Fields { + if field.Field != "expiration" && !slices.Contains(vaultCardFillFields, field.Field) { + return fmt.Errorf("--params.fields[%d].field must be a supported card field", i) + } + } + return nil +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index f447f23e..5e61d884 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/url" + "sort" "strings" "github.com/kernel/cli/pkg/util" @@ -15,6 +16,10 @@ import ( type vaultJSON map[string]json.RawMessage type vaultOutputFields map[string]vaultOutputFields +// vaultOutputWildcard applies one schema to every property of an object whose +// keys are not known in advance, such as credential field maps. +const vaultOutputWildcard = "*" + func vaultFieldsOf(names string) vaultOutputFields { fields := make(vaultOutputFields) for _, name := range strings.Fields(names) { @@ -31,16 +36,23 @@ var vaultMethodFields = vaultOutputFields{ "display": vaultFieldsOf("label brand last4"), "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, } + +// Credential field maps are keyed by caller-declared names, so their schema is +// applied to every property instead of a fixed key list. +var vaultCredentialFieldFields = vaultOutputFields{vaultOutputWildcard: vaultFieldsOf("type required sensitive")} +var vaultCredentialFieldStateFields = vaultOutputFields{vaultOutputWildcard: vaultFieldsOf("has_value value")} + var vaultItemFields = vaultOutputFields{ - "id": nil, "key": nil, "type": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, + "id": nil, "key": nil, "type": nil, "version": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, "available_operations": vaultOperationFields, "available_expansions": vaultOperationFields, - "action": vaultFieldsOf("name url"), + "action": vaultFieldsOf("name url expires_at"), "expanded": {"payment_methods": vaultMethodFields}, "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, - "context": nil, "expires_at": nil, + "context": nil, "expires_at": nil, "description": nil, + "fields": vaultCredentialFieldFields, "provider_config": vaultFieldsOf("id name"), "authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultFieldsOf("id name")}}, "totals": vaultTotalFields, @@ -51,6 +63,7 @@ var vaultItemFields = vaultOutputFields{ }, "state": { "provider": nil, "status": nil, "status_reason": nil, "user_id": nil, "domains": nil, + "fields": vaultCredentialFieldStateFields, "masks": vaultFieldsOf("brand last4"), "aliases": vaultFieldsOf("number cvc exp_month exp_year"), "authorization": vaultFieldsOf("id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered"), @@ -97,24 +110,41 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes return nil, fmt.Errorf("invalid vault response shape") } result := make(vaultJSON) + if wildcard, hasWildcard := fields[vaultOutputWildcard]; hasWildcard { + for key, value := range object { + if err := filterVaultProperty(result, key, value, wildcard); err != nil { + return nil, err + } + } + return json.Marshal(result) + } for key, children := range fields { if value, ok := object[key]; ok { - if key == "url" || key == "approval_url" || key == "merchant_url" || key == "image_url" || key == "product_url" { - var address string - if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { - continue - } - } - filtered, err := filterVaultJSON(value, children) - if err != nil { + if err := filterVaultProperty(result, key, value, children); err != nil { return nil, err } - result[key] = filtered } } return json.Marshal(result) } +// Withhold any URL that is not display-safe rather than reporting an error, so a +// credential-bearing link is dropped from output instead of being echoed back. +func filterVaultProperty(result vaultJSON, key string, value json.RawMessage, fields vaultOutputFields) error { + if key == "url" || key == "approval_url" || key == "merchant_url" || key == "image_url" || key == "product_url" { + var address string + if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { + return nil + } + } + filtered, err := filterVaultJSON(value, fields) + if err != nil { + return err + } + result[key] = filtered + return nil +} + func vaultSafeJSONSlice[T util.RawJSONProvider](items []T, fields vaultOutputFields) ([]vaultJSON, error) { result := make([]vaultJSON, 0, len(items)) for _, item := range items { @@ -223,8 +253,17 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { return err } rows := pterm.TableData{ - {"Property", "Value"}, {"Key (immutable)", item.Key}, {"ID", item.ID}, - {"Type", item.Type}, {"Provider", item.Spec.Provider}, {"Status", item.State.Status}, + {"Property", "Value"}, {"Key (immutable)", item.Key}, {"ID", item.ID}, {"Type", item.Type}, + } + if item.Type != "credential" { + rows = append(rows, []string{"Provider", item.Spec.Provider}) + } + rows = append(rows, []string{"Status", item.State.Status}) + if item.Type == "credential" { + rows = append(rows, []string{"Version", fmt.Sprint(item.Version)}) + if item.Spec.Description != "" { + rows = append(rows, []string{"Description", item.Spec.Description}) + } } if item.Type == "wallet" { configID, configName := item.Spec.ProviderConfig.ID, item.Spec.ProviderConfig.Name @@ -256,7 +295,16 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { rows = append(rows, []string{"Permitted domains (provider-assigned)", strings.Join(item.State.Domains, ", ")}) } if actions.RequiredAction != "" { - rows = append(rows, []string{"Required action", actions.RequiredAction}) + // A credential form is offered whenever a session is active; on a ready + // item it is an invitation to edit, not an outstanding requirement. + label := "Required action" + if item.Type == "credential" { + label = "Collection action" + } + rows = append(rows, []string{label, actions.RequiredAction}) + if item.Type == "credential" && !item.Action.ExpiresAt.IsZero() { + rows = append(rows, []string{"Collection link expires", util.FormatLocal(item.Action.ExpiresAt)}) + } } if !item.ExpiresAt.IsZero() { rows = append(rows, []string{"Expires At", util.FormatLocal(item.ExpiresAt)}) @@ -292,10 +340,40 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { } } PrintTableNoPad(rows, true) + if item.Type == "credential" { + printVaultCredentialFields(item) + } printVaultItemGuidance(item, actions) return nil } +// Declared schema and per-field presence answer different questions: the schema +// says what the form collects, the state says what is stored. Sensitive values +// are never returned, so presence is all the API discloses for them. +func printVaultCredentialFields(item *kernel.VaultItemUnion) { + if len(item.Spec.Fields) == 0 { + return + } + names := make([]string, 0, len(item.Spec.Fields)) + for name := range item.Spec.Fields { + names = append(names, name) + } + sort.Strings(names) + rows := pterm.TableData{{"Field", "Type", "Required", "Sensitive", "Has value", "Value"}} + for _, name := range names { + definition := item.Spec.Fields[name] + state := item.State.Fields[name] + value := "-" + if definition.Sensitive { + value = "(withheld)" + } else if state.Value != "" { + value = state.Value + } + rows = append(rows, []string{name, string(definition.Type), fmt.Sprint(definition.Required), fmt.Sprint(definition.Sensitive), fmt.Sprint(state.HasValue), value}) + } + PrintTableNoPad(rows, true) +} + func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemActions) { if actions.RecoveryRequired { if actions.Abandonable { @@ -318,7 +396,8 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction for _, op := range actions.Operations { pterm.Printf("Available operation: %s — %s\n", op.Type, op.Description) } - if item.Type == "card" { + switch item.Type { + case "card": card := item.AsCard() for _, expansion := range card.AvailableExpansions { pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) @@ -327,7 +406,9 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction pterm.Info.Println("Aliases are non-secret checkout values. Use only in a browser created with this vault attached; ready does not mean paid.") } pterm.Info.Println("Inspect items events for payment outcomes. Do not retry failed, timed-out, rejected, or indeterminate payments.") - } else { + case "credential": + printVaultCredentialGuidance(item) + default: wallet := item.AsWallet() for _, expansion := range wallet.AvailableExpansions { pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) @@ -336,11 +417,20 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction if item.Expanded.JSON.PaymentMethods.Valid() { printVaultPaymentMethods(item.Expanded.PaymentMethods) } - if actions.RequiredAction != "" { + if actions.RequiredAction != "" && item.Type != "credential" { pterm.Info.Println("Complete the returned action with the provider; never pass card data or OAuth codes to the CLI. Observe with items get --wait 60.") } } +func printVaultCredentialGuidance(item *kernel.VaultItemUnion) { + if item.State.Status == "pending_collection" { + pterm.Warning.Println("pending_collection: required values are missing. Open the collection URL yourself or hand it to the person who holds the credential; treat it as a secret and keep it out of logs. Observe with items get --wait 60.") + } else { + pterm.Info.Println("ready: every required field has a value. This does not mean a login succeeded.") + } + pterm.Info.Println("Set or clear values with vaults credentials update --version, which requires the version above. Never pass credential values as shell arguments; use --values-file. Do not store card data in credential items.") +} + // Preparation state and item state answer different questions: the preparation // says whether egress can still claim it, the item says whether the attempt has // settled. Neither means an order or charge succeeded. diff --git a/cmd/vaults_secrets.go b/cmd/vaults_secrets.go index 24803447..4b4c66f1 100644 --- a/cmd/vaults_secrets.go +++ b/cmd/vaults_secrets.go @@ -33,7 +33,23 @@ func vaultCredentialError(err error) error { return fmt.Errorf("vault request failed; details withheld to protect credentials; inspect existing state before taking further action") } -func readVaultSecrets(cmd *cobra.Command, flag string, fields ...string) (map[string]string, error) { +// Credential item writes fail for reasons a wallet or provider configuration +// cannot, so map their conflicts to what the caller must actually reconcile. +// Details stay withheld: bodies and transport errors can echo submitted values. +func vaultCredentialItemError(err error) error { + var apiErr *kernel.Error + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case 400: + return fmt.Errorf("credential request rejected (HTTP 400); field names must be declared, values must satisfy their declared type, and a required totp field needs a valid Base32 seed that no form can collect") + case 409: + return fmt.Errorf("credential conflict (HTTP 409); the item changed since your last read or is not a credential item. Re-read it with items get and retry with the version it returns") + } + } + return vaultCredentialError(err) +} + +func readVaultSecretFile(cmd *cobra.Command, flag string) ([]byte, error) { path, _ := cmd.Flags().GetString(flag) if path == "" { return nil, fmt.Errorf("--%s requires a file path or '-' for stdin", flag) @@ -52,6 +68,14 @@ func readVaultSecrets(cmd *cobra.Command, flag string, fields ...string) (map[st if err != nil || len(data) > maxBytes { return nil, fmt.Errorf("could not read --%s (maximum 1 MiB)", flag) } + return data, nil +} + +func readVaultSecrets(cmd *cobra.Command, flag string, fields ...string) (map[string]string, error) { + data, err := readVaultSecretFile(cmd, flag) + if err != nil { + return nil, err + } var values map[string]string if json.Unmarshal(data, &values) != nil || len(values) != len(fields) { return nil, fmt.Errorf("--%s must contain only the documented non-empty JSON string fields", flag) @@ -81,3 +105,23 @@ func vaultSpecHasSecrets(value json.RawMessage) bool { } return false } + +// Credential values are write-only secrets, so they arrive through a protected +// file or stdin rather than shell arguments. A null value clears a stored value +// on update; creation rejects null and empty values separately. +func readVaultFieldValues(cmd *cobra.Command, flag string) (map[string]*string, error) { + data, err := readVaultSecretFile(cmd, flag) + if err != nil { + return nil, err + } + var values map[string]*string + if json.Unmarshal(data, &values) != nil || len(values) < 1 || len(values) > 32 { + return nil, fmt.Errorf("--%s must be a JSON object mapping 1-32 field names to string or null values", flag) + } + for name := range values { + if !vaultFieldNamePattern.MatchString(name) { + return nil, fmt.Errorf("--%s field names must match [a-zA-Z][a-zA-Z0-9_]{0,63}", flag) + } + } + return values, nil +} diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 8584a189..1f3ee4b5 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -33,8 +33,16 @@ func vaultTestClient(t *testing.T, handler http.HandlerFunc) kernel.Client { } func executeVaultCommand(t *testing.T, client kernel.Client, args ...string) (string, string, error) { + t.Helper() + return executeVaultCommandWithStdin(t, client, nil, args...) +} + +func executeVaultCommandWithStdin(t *testing.T, client kernel.Client, stdin io.Reader, args ...string) (string, string, error) { t.Helper() root := &cobra.Command{Use: "kernel", SilenceErrors: true, SilenceUsage: true} + if stdin != nil { + root.SetIn(stdin) + } root.PersistentFlags().String("project", "", "Project") root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { project, _ := cmd.Flags().GetString("project") @@ -52,7 +60,7 @@ func executeVaultCommand(t *testing.T, client kernel.Client, args ...string) (st } func TestVaultCommandConstruction(t *testing.T) { - for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "cards update", "items invoke"} { + for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "cards update", "items invoke", "credentials create", "credentials update"} { t.Run(path, func(t *testing.T) { cmd, remaining, err := newVaultsCommand().Find(strings.Fields(path)) require.NoError(t, err) diff --git a/go.mod b/go.mod index 9586f46d..5141619a 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.103.0 + github.com/kernel/kernel-go-sdk v0.103.1-0.20260914234545-5839babda6a9 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 dd7ff1bf..b0154005 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.103.0 h1:gimXCJrsn1CiQ0/Zx3LZPDWIlqIRCDCD25J1HnodZMQ= -github.com/kernel/kernel-go-sdk v0.103.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.103.1-0.20260914234545-5839babda6a9 h1:FX6UWPFpsyhTexpFB0clIVidprZAEoEDAVjc+E+bFUw= +github.com/kernel/kernel-go-sdk v0.103.1-0.20260914234545-5839babda6a9/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= From 2d3c5a5d8c253485b996b10e951e7329d146e556 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:15:00 +0000 Subject: [PATCH 50/51] chore: update Go SDK to 55c88c0 and render managed auth input modes Updates kernel-go-sdk to v0.103.1-0.20260914235606-55c88c0144a0. The only API change in this range is ManagedAuthField.input_mode, a virtual keyboard hint that is independent of the field type and of browser validation. auth connections get and follow now show it alongside the type, so a numeric one-time code reads as `code, input_mode=numeric, ref=totp_code, required`. The credential vault item work in the previous commit already covers the rest of this SDK version; a full enumeration of api.md against the CLI found no other gaps. Tested: go build, go vet, and the full test suite pass against the new SDK; auth connections list and get were exercised against the live API. Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 58 ++++++++++++++++++++---------------- cmd/auth_connections_test.go | 19 +++++++----- go.mod | 2 +- go.sum | 4 +-- 4 files changed, 47 insertions(+), 36 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 691e93dd..3c8fc592 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -444,13 +444,14 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn // models the one on `get` and the one on the `follow` event stream as two // identical but distinct types, so both are converted to this before rendering. type managedAuthInputField struct { - ID string - Label string - Type string - Ref string - Hint string - Reason string - Required bool + ID string + Label string + Type string + Ref string + Hint string + InputMode string + Reason string + Required bool } // managedAuthInputChoice is the choice counterpart of managedAuthInputField. @@ -464,14 +465,19 @@ type managedAuthInputChoice struct { } // formatManagedAuthField renders one canonical input field as -// `id (Label) [type, ref=…, required, hint="…"]`. The hint carries the API's -// context for the field, such as the masked destination a one-time code was -// sent to, so it is often what tells the user which value to supply. +// `id (Label) [type, input_mode=…, ref=…, required, hint="…"]`. The hint carries +// the API's context for the field, such as the masked destination a one-time code +// was sent to, so it is often what tells the user which value to supply. The +// input mode is a keyboard hint that is independent of the field type, so a +// numeric one-time code shows as `text, input_mode=numeric`. func formatManagedAuthField(f managedAuthInputField) string { - meta := make([]string, 0, 5) + meta := make([]string, 0, 6) if f.Type != "" { meta = append(meta, f.Type) } + if f.InputMode != "" { + meta = append(meta, "input_mode="+f.InputMode) + } if f.Ref != "" { meta = append(meta, "ref="+f.Ref) } @@ -577,13 +583,14 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e 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, - Reason: string(f.Reason), + ID: f.ID, + Label: f.Label, + Type: f.Type, + Ref: f.Ref, + Hint: f.Hint, + InputMode: f.InputMode, + Required: f.Required, + Reason: string(f.Reason), })) } tableData = append(tableData, []string{"Fields", strings.Join(fields, "; ")}) @@ -1138,13 +1145,14 @@ func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowIn 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, - Reason: string(f.Reason), + ID: f.ID, + Label: f.Label, + Type: f.Type, + Ref: f.Ref, + Hint: f.Hint, + InputMode: f.InputMode, + Required: f.Required, + Reason: string(f.Reason), })) } pterm.Info.Printf(" Fields: %s\n", strings.Join(fields, ", ")) diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index d33e7293..6ac153fd 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -152,13 +152,16 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) { InteractionID: "mai_abc123xyz", Fields: []kernel.ManagedAuthField{ { - ID: "otp", - Label: "One-time code", - Type: "code", - Ref: "totp_code", - Hint: "Enter the code sent to +1 ••• ••• 1234", - Reason: "rejected", - Required: true, + ID: "otp", + Label: "One-time code", + Type: "code", + Ref: "totp_code", + // The keyboard hint is independent of the field type, so + // it is shown even though the type is already "code". + InputMode: "numeric", + Hint: "Enter the code sent to +1 ••• ••• 1234", + Reason: "rejected", + Required: true, }, }, Choices: []kernel.ManagedAuthChoice{ @@ -189,7 +192,7 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) { assert.Contains(t, out, `otp (One-time code)`) // 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, `code, input_mode=numeric, ref=totp_code, required, reason=rejected`) assert.Contains(t, out, `hint="Enter the code sent to +1 ••• ••• 1234"`) assert.Contains(t, out, `mfa_sms (Text message)`) assert.Contains(t, out, `mfa_method, sms, to=+1 ••• ••• 1234`) diff --git a/go.mod b/go.mod index 5141619a..ba85d913 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.103.1-0.20260914234545-5839babda6a9 + github.com/kernel/kernel-go-sdk v0.103.1-0.20260914235606-55c88c0144a0 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 b0154005..09f1eeaa 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.103.1-0.20260914234545-5839babda6a9 h1:FX6UWPFpsyhTexpFB0clIVidprZAEoEDAVjc+E+bFUw= -github.com/kernel/kernel-go-sdk v0.103.1-0.20260914234545-5839babda6a9/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.103.1-0.20260914235606-55c88c0144a0 h1:Vs42f4iPL/9oTl/QWE2P6EZQ+T/RL+KrpqKaBidjCrs= +github.com/kernel/kernel-go-sdk v0.103.1-0.20260914235606-55c88c0144a0/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= From 19507193938ca99418d72479ca985d288bc6bf5f Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:24:36 +0000 Subject: [PATCH 51/51] chore: update Go SDK to bcf94cc Bumps github.com/kernel/kernel-go-sdk to v0.103.1-0.20260915001710-bcf94cc5a1bd (bcf94cc). The only SDK change since the CLI's previous pin (55c88c0) is a new `working_configurations` response field on ConfigRegistryResponse and LookupResponse. All /config-registry endpoints are marked x-cli-skip in the API spec, so no CLI surface changes are required. A full enumeration of api.md methods against the CLI command tree found no other gaps. Tested: go build ./..., go vet ./..., go test ./... (all pass); smoke tested `kernel browsers list` and `kernel profiles list --per-page 3` against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ba85d913..e7169cca 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.103.1-0.20260914235606-55c88c0144a0 + github.com/kernel/kernel-go-sdk v0.103.1-0.20260915001710-bcf94cc5a1bd github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 09f1eeaa..fc56e724 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.103.1-0.20260914235606-55c88c0144a0 h1:Vs42f4iPL/9oTl/QWE2P6EZQ+T/RL+KrpqKaBidjCrs= -github.com/kernel/kernel-go-sdk v0.103.1-0.20260914235606-55c88c0144a0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.103.1-0.20260915001710-bcf94cc5a1bd h1:SaXXMQpHFsZfHtdR0uR0XbcGGV0pngJYB6mFZyzJo9w= +github.com/kernel/kernel-go-sdk v0.103.1-0.20260915001710-bcf94cc5a1bd/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=